From d34087f81f73e8430ba93f950df31699da0a1e3b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Tue, 3 Mar 2026 20:53:06 -0800 Subject: [PATCH 01/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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/84] 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 2f31f42ce887c44aeb457e6b0462ef099d08780d Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 11:28:57 -0800 Subject: [PATCH 49/84] feat: Drop the WIP docs --- docs/tmn-wip-docs/context.md | 588 +++++++++++++++++++ docs/tmn-wip-docs/intents.md.md | 486 ++++++++++++++++ docs/tmn-wip-docs/selective-manifests.md | 708 +++++++++++++++++++++++ docs/tmn-wip-docs/settings.md | 449 ++++++++++++++ docs/tmn-wip-docs/usage.md | 196 +++++++ docs/tmn-wip-docs/working-stores.md | 648 +++++++++++++++++++++ 6 files changed, 3075 insertions(+) create mode 100644 docs/tmn-wip-docs/context.md create mode 100644 docs/tmn-wip-docs/intents.md.md create mode 100644 docs/tmn-wip-docs/selective-manifests.md create mode 100644 docs/tmn-wip-docs/settings.md create mode 100644 docs/tmn-wip-docs/usage.md create mode 100644 docs/tmn-wip-docs/working-stores.md diff --git a/docs/tmn-wip-docs/context.md b/docs/tmn-wip-docs/context.md new file mode 100644 index 00000000..ccc84145 --- /dev/null +++ b/docs/tmn-wip-docs/context.md @@ -0,0 +1,588 @@ +# 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/tmn-wip-docs/intents.md.md b/docs/tmn-wip-docs/intents.md.md new file mode 100644 index 00000000..5b331d9f --- /dev/null +++ b/docs/tmn-wip-docs/intents.md.md @@ -0,0 +1,486 @@ +# Using Builder intents + +Intents enable validation, add required default actions, and help prevent invalid operations when using a `Builder`. Intents are about the operation (create, edit, update) executed on the source asset. + +## Why use intents? + +Without intents, the caller must manually construct the correct manifest structure to be compliant with the specification: adding the required actions (`c2pa.created` or `c2pa.opened` as first action as per specification), setting digital source types, managing ingredients, and linking actions to ingredients. Getting any of this wrong produces a manifest that does not comply with the C2PA specification. + +With intents, the caller declares *what is being done* and the Builder handles the rest: + +```py +# Without intents: a caller must manually wire things up, and make sure ingredients are properly linked to actions. +# This is especially important in the case of parentOf ingredient relationships, with the c2pa.opened action +with Builder({ + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + } + ] + }, + } + ], +}) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) + +# With intents: the Builder generates the actions automatically +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Both ways of writing the code produce the same signed manifest. With intents the Builder validates the setup and fills in the spec-required structure. + +## Setting the intent + +There are three ways to set the intent on a `Builder` object instance. The intent determines which actions the Builder auto-generates at sign time. + +### Using Context + +Pass the intent through a `Context` object when creating the `Builder`. This is the an approach that keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. Note that the context is created from settings, so you can modulate the settings for each context. + +```py +from c2pa import Context, Builder + +ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "My App", "version": "1.0.0"}, + } +}) + +with Builder({}, context=ctx) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +The same `Context` can be reused across multiple `Builder` instances, ensuring consistent configuration: + +```py +ctx = Context.from_dict({ + "builder": { + "intent": "edit", + "claim_generator_info": {"name": "Batch Editor"}, + } +}) + +for path in image_paths: + with Builder({}, context=ctx) as builder: + builder.sign_file(path, output_path(path), signer) +``` + +### Using `set_intent` on the Builder + +Call `set_intent` directly on a `Builder` instance. This is useful for one-off operations or when the intent needs to be determined at runtime: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.TRAINED_ALGORITHMIC_MEDIA, + ) + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +If both a `Context` intent and a `set_intent` call are present, the `set_intent` call takes precedence. + +### Using `load_settings` (deprecated) + +The global `load_settings` function can configure the intent for all subsequent `Builder` instances. This approach is deprecated in favor of context-based APIs: + +```py +from c2pa import load_settings, Builder + +# Deprecated: sets intent globally +load_settings({"builder": {"intent": "edit"}}) + +with Builder({}) as builder: + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Intent setting precedence + +When an intent is configured in multiple places, the most specific setting wins: + +```mermaid +flowchart TD + Check{Was set_intent called + on the Builder?} + Check --> |Yes| UseSetIntent["Use set_intent value"] + Check --> |No| CheckCtx{Was a Context with + builder.intent provided?} + CheckCtx --> |Yes| UseCtx["Use Context intent"] + CheckCtx --> |No| CheckGlobal{Was load_settings called + with builder.intent?} + CheckGlobal --> |Yes| UseGlobal["Use global intent + (deprecated)"] + CheckGlobal --> |No| NoIntent["No intent set. + Caller must define actions + manually in manifest JSON."] +``` + +## How intents relate to the source stream + +The intent **operates on the source stream** passed to `sign()`. It does not target a specific ingredient added via `add_ingredient`: it targets the source asset itself (and ONLY the source). + +The following diagram shows what happens at sign time for each intent: + +```mermaid +flowchart LR + subgraph CREATE + S1[source stream] --> B1[Builder] + B1 --> O1[signed output] + B1 -. adds .-> A1["c2pa.created action + + digital source type"] + end +``` + +```mermaid +flowchart LR + subgraph EDIT + S2[source stream] --> B2[Builder] + B2 --> O2[signed output] + S2 -. auto-created as .-> P2[parentOf ingredient] + P2 --> B2 + B2 -. adds .-> A2["c2pa.opened action + linked to parent"] + end +``` + +```mermaid +flowchart LR + subgraph UPDATE + S3[source stream] --> B3[Builder] + B3 --> O3[signed output] + S3 -. auto-created as .-> P3[parentOf ingredient] + P3 --> B3 + B3 -. adds .-> A3["c2pa.opened action + linked to parent"] + B3 -. restricts .-> R3[content must not change] + end +``` + +For **EDIT** and **UPDATE**, the Builder looks at the source stream, and if no `parentOf` ingredient has been added manually, it automatically creates one from that stream (and adds the needed action). The source stream *becomes* the parent ingredient. If a `parentOf` ingredient has already been added manually (via `add_ingredient`), the Builder uses that one instead and does not auto-create one from the source. + +### How intent relates to `add_ingredient` + +The intent and manually-added ingredients serve different roles. **The intent controls what the Builder does with the source stream** at sign time. The `add_ingredient` method **adds other ingredients explicitly**. + +```mermaid +flowchart TD + Intent["Intent + (via Context, set_intent, + or load_settings)"] --> Q{Intent type?} + Q --> |CREATE| CreateFlow["No parent allowed + Source stream is new content"] + Q --> |EDIT or UPDATE| EditFlow{Was a parentOf ingredient + added via add_ingredient?} + EditFlow --> |No| Auto["Builder auto-creates + parentOf from source stream"] + EditFlow --> |Yes| Manual["Builder uses the + manually-added parent"] + Auto --> Opened["Builder adds c2pa.opened + action linked to parent"] + Manual --> Opened + CreateFlow --> Created["Builder adds c2pa.created + action + digital source type"] + + AddIngredient["add_ingredient()"] --> IngType{relationship?} + IngType --> |parentOf| ParentIng["Overrides auto-parent + for EDIT/UPDATE"] + IngType --> |componentOf| CompIng["Additional ingredient + not affected by intent"] + ParentIng --> EditFlow +``` + +## Import + +```py +from c2pa import ( + Builder, + Reader, + Signer, + Context, + Settings, + C2paSignerInfo, + C2paSigningAlg, + C2paBuilderIntent, + C2paDigitalSourceType, +) +``` + +## Intent types + +| Intent | Operation | Parent ingredient | Auto-generated action | +| --- | --- | --- | --- | +| `CREATE` | Brand-new content | Must NOT have one | `c2pa.created` | +| `EDIT` | Modifying existing content | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | +| `UPDATE` | Metadata-only changes | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | + +## Choosing the right intent + +```mermaid +flowchart TD + Start([Start]) --> HasParent{Does the asset have + prior history?} + HasParent --> |No| IsNew[Brand-new content] + IsNew --> CREATE["Use CREATE + + C2paDigitalSourceType"] + HasParent --> |Yes| ContentChanged{Will the content + itself change?} + ContentChanged --> |Yes| EDIT[Use EDIT] + ContentChanged --> |No, metadata only| UPDATE[Use UPDATE] + ContentChanged --> |Need full manual control| MANUAL["Skip intents. + Define actions and ingredients + directly in manifest JSON."] +``` + +## CREATE intent + +Use `CREATE` when the asset is a brand-new creation with no prior history. In this case, a `C2paDigitalSourceType` is required (by the specification) to describe how the asset was produced. The Builder will: + +- Add a `c2pa.created` action with the specified digital source type. +- Reject the operation if a `parentOf` ingredient exists (new creations cannot have parents). + +### Example: New digital creation + +Using `Context`: + +```py +ctx = Context.from_dict({ + "builder": {"intent": {"Create": "digitalCreation"}} +}) + +with Builder({}, context=ctx) as builder: + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent( + C2paBuilderIntent.CREATE, + C2paDigitalSourceType.DIGITAL_CREATION, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Marking AI-generated content + +```py +ctx = Context.from_dict({ + "builder": {"intent": {"Create": "trainedAlgorithmicMedia"}} +}) + +with Builder({}, context=ctx) as builder: + with open("ai_output.jpg", "rb") as source, open("signed_ai_output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: CREATE with additional manifest metadata + +`Context` and a manifest definition can be combined. The context handles the intent, while the manifest definition provides additional metadata and assertions: + +```py +ctx = Context.from_dict({ + "builder": { + "intent": {"Create": "digitalCapture"}, + "claim_generator_info": {"name": "my_app", "version": "1.0.0"}, + } +}) + +manifest_def = { + "title": "My New Image", + "assertions": [ + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": {"use": "notAllowed"}, + "cawg.ai_generative_training": {"use": "notAllowed"}, + } + }, + } + ], +} + +with Builder(manifest_def, context=ctx) as builder: + with open("photo.jpg", "rb") as source, open("signed_photo.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## EDIT intent + +Use `EDIT` when modifying an existing asset. The Builder will: + +1. Check if a `parentOf` ingredient has already been added. If not, it **automatically creates one from the source stream** passed to `sign()`. +2. Add a `c2pa.opened` action linked to the parent ingredient. + +No `digital_source_type` parameter is needed. + +### Example: Editing an asset + +In this case, the source stream becomes the parent ingredient. + +Using `Context`: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({}, context=ctx) as builder: + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.EDIT) + + # The Builder reads "original.jpg" as the parent ingredient, + # then writes the new manifest into "edited.jpg" + with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +The resulting manifest contains: + +- One ingredient with `relationship: "parentOf"` pointing to `original.jpg`. +- A `c2pa.opened` action referencing that ingredient. + +If the source file already has a C2PA manifest, the ingredient preserves the full provenance chain. + +### Example: Editing with a manually-added parent + +To control some the parent ingredient's metadata (for example, to set a title or use a different source), add it explicitly. The Builder will then use that ingredient: + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({}, context=ctx) as builder: + with open("original.jpg", "rb") as original: + builder.add_ingredient( + {"title": "Original Photo", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + with open("canvas.jpg", "rb") as source, open("edited.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +### Example: Editing with additional component ingredients + +A parent ingredient can be combined with component or input ingredients. The intent creates the `c2pa.opened` action for the parent, and additional actions can be added as components (componentOf)/input (inputTo): + +```py +ctx = Context.from_dict({"builder": {"intent": "edit"}}) + +with Builder({ + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.placed", + "parameters": {"ingredientIds": ["overlay_label"]}, + } + ] + }, + } + ], +}, context=ctx) as builder: + + # The Builder auto-creates a parent from the source stream + # and generates a c2pa.opened action for it + + # Add a component ingredient manually + with open("overlay.png", "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.png", + "relationship": "componentOf", + "label": "overlay_label", + }, + "image/png", + overlay, + ) + + with open("original.jpg", "rb") as source, open("composite.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## UPDATE intent + +Use `UPDATE` for non-editorial changes where the asset content itself is not modified, for example adding or changing metadata. This is a limited form of `EDIT`: + +- Allows exactly one ingredient (only the parent). +- Does not allow changes to the parent's hashed content. +- Produces a more compact manifest than `EDIT`. + +Like for the `EDIT` intent, the Builder auto-creates a parent ingredient from the source stream if one is not provided. + +### Example: Adding metadata to a signed asset + +Using `Context`: + +```py +ctx = Context.from_dict({"builder": {"intent": "update"}}) + +with Builder({}, context=ctx) as builder: + with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +Using `set_intent`: + +```py +with Builder({}) as builder: + builder.set_intent(C2paBuilderIntent.UPDATE) + + with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +## Intent values in settings + +When configuring the intent settings, the intent is specified as a string or object in the `builder.intent` field: + +| Intent | Settings value | With digital source type | +|--------|---------------|--------------------------| +| Create | `{"Create": ""}` | Required. E.g., `{"Create": "digitalCapture"}` | +| Edit | `"edit"` | Not applicable | +| Update | `"update"` | Not applicable | + +Available digital source type values for Create: `"digitalCapture"`, `"digitalCreation"`, `"trainedAlgorithmicMedia"`, `"compositeSynthetic"`, `"screenCapture"`, `"algorithmicMedia"`, and others. + +## API reference + +### `Builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY)` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `intent` | `C2paBuilderIntent` | The intent: `CREATE`, `EDIT`, or `UPDATE`. | +| `digital_source_type` | `C2paDigitalSourceType` | Required for `CREATE`. Describes how the asset was made. Defaults to `EMPTY`. | + +**Raises:** `C2paError` if the intent cannot be set (e.g., a `parentOf` ingredient exists with `CREATE`, or no parent can be found for `EDIT`/`UPDATE`). diff --git a/docs/tmn-wip-docs/selective-manifests.md b/docs/tmn-wip-docs/selective-manifests.md new file mode 100644 index 00000000..b7131a33 --- /dev/null +++ b/docs/tmn-wip-docs/selective-manifests.md @@ -0,0 +1,708 @@ +# 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. + +```py +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source) as reader: + # Get the full manifest store as JSON + manifest_store = json.loads(reader.json()) + + # Identify the active manifest, which is the current/latest manifest + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] + + # Access specific parts + ingredients = manifest["ingredients"] + assertions = manifest["assertions"] + 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 `resource_to_stream()`: + +```py +# Extract a thumbnail to an in-memory stream +thumb_stream = io.BytesIO() +reader.resource_to_stream(thumbnail_id, thumb_stream) + +# Or extract to a file +with open("thumbnail.jpg", "wb") as f: + reader.resource_to_stream(thumbnail_id, f) +``` + +## Filtering into a new Builder + +Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. + +When transferring ingredients from a `Reader` to a new `Builder`, you must transfer both the JSON metadata and the associated binary resources (thumbnails, manifest data). The JSON contains identifiers that reference those resources; the same identifiers must be used when calling `builder.add_resource()`. + +### Transferring binary resources + +Since ingredients reference binary data (thumbnails, manifest stores), you need to copy those resources from the `Reader` to the new `Builder`. This helper function encapsulates the pattern: + +```py +def transfer_ingredient_resources(reader, builder, ingredients): + """Copy binary resources for a list of ingredients from reader to builder.""" + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key in ingredient: + uri = ingredient[key]["identifier"] + buf = io.BytesIO() + reader.resource_to_stream(uri, buf) + buf.seek(0) + builder.add_resource(uri, buf) +``` + +This function is used throughout the examples below. + +### Keep only specific ingredients + +```py +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Filter: keep only ingredients with a specific relationship + kept = [ + ing for ing in active["ingredients"] + if ing["relationship"] == "parentOf" + ] + + # Create a new Builder with only the kept ingredients + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": kept, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, kept) + + # Sign the new Builder into an output asset + source.seek(0) + with open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Keep only specific assertions + +```py +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Keep training-mining assertions, filter out everything else + kept = [ + a for a in active["assertions"] + if a["label"] == "cawg.training-mining" + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": kept, + }) as new_builder: + source.seek(0) + with open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Start fresh and preserve provenance + +Sometimes all existing assertions and ingredients may need to be discarded but the provenance chain should be maintained nevertheless. This is done by creating a new `Builder` with a new manifest definition and adding the original signed asset as an ingredient using `add_ingredient()`. + +The function `add_ingredient()` does not copy the original's assertions into the new manifest. Instead, it stores the original's entire manifest store as opaque binary data inside the ingredient record. This means: + +- The new manifest has its own, independent set of assertions +- The original's full manifest is preserved inside the ingredient, so validators can inspect the full provenance history +- The provenance chain is unbroken: anyone reading the new asset can follow the ingredient link back to the original + +```mermaid +flowchart TD + subgraph Original["Original Signed Asset"] + OA["Assertions: A, B, C"] + OI["Ingredients: X, Y"] + end + subgraph NewBuilder["New Builder"] + NA["Assertions: (empty or new)"] + NI["Ingredient: original.jpg (contains full original manifest as binary data)"] + end + Original -->|"add_ingredient()"| NI + NI -.->|"validators can trace back"| Original + + style NA fill:#efe,stroke:#090 + style NI fill:#efe,stroke:#090 +``` + + + +```py +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "assertions": [], +}) as new_builder: + # Add the original as an ingredient to preserve provenance chain. + # add_ingredient() stores the original's manifest as binary data inside + # the ingredient, but does NOT copy the original's assertions. + with open("original_signed.jpg", "rb") as original: + new_builder.add_ingredient( + {"title": "original.jpg", "relationship": "parentOf"}, + "image/jpeg", + original, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +## Adding actions to a working store + +Actions record what was done to an asset (e.g., color adjustments, cropping, placing content). Use `builder.add_action()` to add them to a working store. + +```py +builder.add_action({ + "action": "c2pa.color_adjustments", + "parameters": {"name": "brightnesscontrast"}, +}) + +builder.add_action({ + "action": "c2pa.filtered", + "parameters": {"name": "A filter"}, + "description": "Filtering applied", +}) +``` + +### Action JSON fields + + +| Field | Required | Description | +| --- | --- | --- | +| `action` | Yes | Action identifier, e.g. `"c2pa.created"`, `"c2pa.opened"`, `"c2pa.placed"`, `"c2pa.color_adjustments"`, `"c2pa.filtered"` | +| `parameters` | No | Free-form object with action-specific data (including `ingredientIds` for linking ingredients, for instance) | +| `description` | No | Human-readable description of what happened | +| `digitalSourceType` | Sometimes, depending on action | URI describing the digital source type (typically for `c2pa.created`) | + + +### Linking actions to ingredients + +When an action involves a specific ingredient, the ingredient is linked to the action using `ingredientIds` (in the action's `parameters`), referencing a matching key in the ingredient. + +#### How `ingredientIds` resolution works + +The SDK matches each value in `ingredientIds` against ingredients using this priority: + +1. `label` on the ingredient (primary): if set and non-empty, this is used as the linking key. +2. `instance_id` on the ingredient (fallback): used when `label` is absent or empty. + +#### Linking with `label` + +The `label` field on an ingredient is the **primary** linking key. Set a `label` on the ingredient and reference it in the action's `ingredientIds`. The label can be any string: it acts as a linking key between the ingredient and the action. + +```py +manifest_json = { + "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"] + }, + }, + ] + }, + } + ], +} + +with Builder(manifest_json) as builder: + # The label on the ingredient matches the value in ingredientIds + with open("photo.jpg", "rb") as photo: + builder.add_ingredient( + { + "title": "photo.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3", + }, + "image/jpeg", + photo, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +##### Linking multiple ingredients + +When linking multiple ingredients, each ingredient needs a unique label. + +> [!NOTE] +> The labels used for linking in the working store may not be the exact labels that appear in the signed manifest. They are indicators for the SDK to know which ingredient to link with which action. The SDK assigns final labels during signing. + +```py +manifest_json = { + "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"] + }, + }, + ] + }, + } + ], +} + +with Builder(manifest_json) as builder: + # parentOf ingredient linked to c2pa.opened + with open("original.jpg", "rb") as original: + builder.add_ingredient( + { + "title": "original.jpg", + "format": "image/jpeg", + "relationship": "parentOf", + "label": "c2pa.ingredient.v3_1", + }, + "image/jpeg", + original, + ) + + # componentOf ingredient linked to c2pa.placed + with open("overlay.jpg", "rb") as overlay: + builder.add_ingredient( + { + "title": "overlay.jpg", + "format": "image/jpeg", + "relationship": "componentOf", + "label": "c2pa.ingredient.v3_2", + }, + "image/jpeg", + overlay, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +#### Linking with `instance_id` + +When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. + +```py +# instance_id is used as the linking identifier and must be unique +instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f" + +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredientIds": [instance_id] + }, + } + ] + }, + } + ], +} + +with Builder(manifest_json) as builder: + # No label set: instance_id is used as the linking key + with open("source_photo.jpg", "rb") as photo: + builder.add_ingredient( + { + "title": "source_photo.jpg", + "relationship": "parentOf", + "instance_id": instance_id, + }, + "image/jpeg", + photo, + ) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` + +> [!NOTE] +> The `instance_id` can be read back from the ingredient JSON after signing. + +#### Reading linked ingredients + +After signing, `ingredientIds` is gone. The action's `parameters.ingredients[]` contains hashed JUMBF URIs pointing to ingredient assertions. To match an action to its ingredient, extract the label from the URL: + +```py +with open("signed_asset.jpg", "rb") as signed: + with Reader("image/jpeg", signed) as reader: + manifest_store = json.loads(reader.json()) + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] + + # Build a map: label -> ingredient + label_to_ingredient = { + ing["label"]: ing for ing in manifest["ingredients"] + } + + # Match each action to its ingredients by extracting labels from URLs + for assertion in manifest["assertions"]: + if assertion["label"] != "c2pa.actions.v2": + continue + for action in assertion["data"]["actions"]: + for ref in action.get("parameters", {}).get("ingredients", []): + label = ref["url"].rsplit("/", 1)[-1] + matched = label_to_ingredient.get(label) + # matched is the ingredient linked to this action +``` + +#### When to use `label` vs `instance_id` + +| Property | `label` | `instance_id` | +| --- | --- | --- | +| **Who controls it** | Caller (any string) | Caller (any string, or from XMP metadata) | +| **Priority for linking** | Primary: checked first | Fallback: used when label is absent/empty | +| **When to use** | JSON-defined manifests where the caller controls the ingredient definition | Programmatic workflows using XMP-based IDs | +| **Survives signing** | SDK may reassign the actual assertion label | Unchanged | +| **Stable across rebuilds** | The caller controls the build-time value; the post-signing label may change | Yes, always the same set value | + + +**Use `label`** when defining manifests in JSON. +**Use `instance_id`** when working programmatically with ingredients whose identity comes from other sources, or when a stable identifier that persists unchanged across rebuilds is needed. + +## Working with archives + +A `Builder` represents a **working store**: a manifest that is being assembled but has not yet been signed. Archives serialize this working store (definition + resources) to a `.c2pa` binary format, allowing to save, transfer, or resume the work later. For more background on working stores and archives, see [Working stores](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()`. + +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 +``` + + + +```py +# Read from a catalog of archived ingredients +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Pick only the needed ingredients + selected = [ + ing for ing in active["ingredients"] + if ing["title"] in {"photo_1.jpg", "logo.png"} + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### Overriding ingredient properties + +When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: + +```py +with open("signed_asset.jpg", "rb") as signed: + builder.add_ingredient( + { + "title": "my-custom-title.jpg", + "relationship": "parentOf", + "instance_id": "my-tracking-id:asset-example-id", + }, + "image/jpeg", + signed, + ) +``` + +The `title`, `relationship`, and `instance_id` fields in the provided JSON take priority. The library fills in the rest (thumbnail, manifest_data, format) from the source. This works with signed assets, `.c2pa` archives, or unsigned files. + +### Using custom vendor parameters in actions + +The C2PA specification allows **vendor-namespaced parameters** on actions using reverse domain notation. These parameters survive signing and can be read back, useful for tagging actions with IDs that support filtering. + +```py +manifest_json = { + "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture", + "parameters": { + "com.mycompany.tool": "my-editor", + "com.mycompany.session_id": "session-abc-123", + }, + }, + { + "action": "c2pa.placed", + "description": "Placed an image", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredientIds": ["c2pa.ingredient.v3"], + }, + }, + ] + }, + } + ], +} +``` + +After signing, these custom parameters appear alongside the standard fields: + +```json +{ + "action": "c2pa.placed", + "parameters": { + "com.mycompany.layer_id": "layer-42", + "ingredients": [{"url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"}] + } +} +``` + +Custom vendor parameters can be used to filter actions. For example, to find all actions related to a specific layer: + +```py +layer_actions = [ + action for action in actions + if action.get("parameters", {}).get("com.mycompany.layer_id") == "layer-42" +] +``` + +> **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: + +```py +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], +}) as builder: + # Add ingredients to the working store + with open("A.jpg", "rb") as ing_a: + builder.add_ingredient( + {"title": "A.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_a, + ) + + with open("B.jpg", "rb") as ing_b: + builder.add_ingredient( + {"title": "B.jpg", "relationship": "componentOf"}, + "image/jpeg", + ing_b, + ) + + # Save the working store as an archive + archive_stream = io.BytesIO() + builder.to_archive(archive_stream) +``` + +**Step 2:** Read the archive and extract ingredients: + +```py +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + ingredients = active["ingredients"] +``` + +**Step 3:** Create a new Builder with the extracted ingredients: + +```py + # Pick the desired ingredients + selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign(signer, "image/jpeg", source, dest) +``` + +### 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 `resource_to_stream()`, renamed ID for `add_resource()` when collisions occurred). + +```py +used_ids: set[str] = set() +suffix_counter = 0 +all_ingredients = [] +archive_ingredient_counts = [] + +# Pass 1: Collect ingredients, renaming IDs on collision +for archive_stream in archives: + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + ingredients = active["ingredients"] + + for ingredient in ingredients: + for key in ("thumbnail", "manifest_data"): + if key not in ingredient: + continue + uri = ingredient[key]["identifier"] + if uri in used_ids: + suffix_counter += 1 + ingredient[key]["identifier"] = f"{uri}__{suffix_counter}" + used_ids.add(ingredient[key]["identifier"]) + all_ingredients.append(ingredient) + + archive_ingredient_counts.append(len(ingredients)) + +with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": all_ingredients, +}) as builder: + # Pass 2: Transfer resources (match by ingredient index) + offset = 0 + for archive_stream, count in zip(archives, archive_ingredient_counts): + archive_stream.seek(0) + with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + originals = active["ingredients"] + + for original, merged in zip(originals, all_ingredients[offset:offset + count]): + for key in ("thumbnail", "manifest_data"): + if key not in original: + continue + buf = io.BytesIO() + reader.resource_to_stream(original[key]["identifier"], buf) + buf.seek(0) + builder.add_resource(merged[key]["identifier"], buf) + + offset += count + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + builder.sign(signer, "image/jpeg", source, dest) +``` diff --git a/docs/tmn-wip-docs/settings.md b/docs/tmn-wip-docs/settings.md new file mode 100644 index 00000000..89f8c859 --- /dev/null +++ b/docs/tmn-wip-docs/settings.md @@ -0,0 +1,449 @@ +# 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/tmn-wip-docs/usage.md b/docs/tmn-wip-docs/usage.md new file mode 100644 index 00000000..aeec23a4 --- /dev/null +++ b/docs/tmn-wip-docs/usage.md @@ -0,0 +1,196 @@ +# Using the Python library + +This package works with media files in the [supported formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md). + +For complete working examples, see the [examples folder](https://github.com/contentauth/c2pa-python/tree/main/examples) in the repository. + +## Import + +Import the objects needed from the API: + +```py +from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo +``` + +You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. +Doing this is recommended to ensure proper resource and memory cleanup. + +## Define manifest JSON + +The Python library works with both file-based and stream-based operations. +In both cases, the manifest JSON string defines the C2PA manifest to add to an asset. For example: + +```py +manifest_json = json.dumps({ + "claim_generator": "python_test/0.1", + "assertions": [ + { + "label": "cawg.training-mining", + "data": { + "entries": { + "cawg.ai_inference": { + "use": "notAllowed" + }, + "cawg.ai_generative_training": { + "use": "notAllowed" + } + } + } + } + ] + }) +``` + +## File-based operation + +### 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. + +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). + +```py +try: + # Create a reader from a file path + with Reader("path/to/media_file.jpg") as reader: + # Print manifest store as JSON + print("Manifest store:", reader.json()) + + # Get the active manifest. + manifest = json.loads(reader.json()) + active_manifest = manifest["manifests"][manifest["active_manifest"]] + if active_manifest: + # Get the uri to the manifest's thumbnail and write it to a file + uri = active_manifest["thumbnail"]["identifier"] + with open("thumbnail_v2.jpg", "wb") as f: + reader.resource_to_stream(uri, f) + +except Exception as err: + print(err) +``` + +### Add a signed manifest + +**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). + +Use a `Builder` to add a manifest to an asset: + +```py +try: + # Create a signer from certificate and key files + with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: + cert_data = cert_file.read() + key_data = key_file.read() + + # Create signer info using cert and key info + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.PS256, + cert=cert_data, + key=key_data, + timestamp_url="http://timestamp.digicert.com" + ) + + # Create signer using the defined SignerInfo + signer = Signer.from_info(signer_info) + + # Create builder with manifest and add ingredients + with Builder(manifest_json) as builder: + # Add any ingredients if needed + with open("path/to/ingredient.jpg", "rb") as ingredient_file: + ingredient_json = json.dumps({"title": "Ingredient Image"}) + builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) + + # 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) + + # Verify the signed file by reading data from the signed output file + with Reader("path/to/output.jpg") as reader: + manifest_store = json.loads(reader.json()) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` + +## 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. + +### Read and validate C2PA data using streams + +```py +try: + # Create a reader from a format and stream + with open("path/to/media_file.jpg", "rb") as stream: + # First parameter should be the type of the file (here, we use the mimetype) + # But in any case we need something to identify the file type + with Reader("image/jpeg", stream) as reader: + # Print manifest store as JSON, as extracted by the Reader + print("manifest store:", reader.json()) + + # Get the active manifest + manifest = json.loads(reader.json()) + active_manifest = manifest["manifests"][manifest["active_manifest"]] + if active_manifest: + # get the uri to the manifest's thumbnail and write it to a file + uri = active_manifest["thumbnail"]["identifier"] + with open("thumbnail_v2.jpg", "wb") as f: + reader.resource_to_stream(uri, f) + +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). + +Use a `Builder` to add a manifest to an asset: + +```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" + ) + + # 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) + + # 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"]] + print("Signed manifest:", active_manifest) + +except Exception as e: + print("Failed to sign manifest store: " + str(e)) +``` diff --git a/docs/tmn-wip-docs/working-stores.md b/docs/tmn-wip-docs/working-stores.md new file mode 100644 index 00000000..801816dd --- /dev/null +++ b/docs/tmn-wip-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/) From 0f186a35b32518e6a1f3aef2fdd9d52e55072be2 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:47:53 -0800 Subject: [PATCH 50/84] fix: Doc in branch is reference, this one is done --- docs/tmn-wip-docs/usage.md | 196 ------------------------------------- 1 file changed, 196 deletions(-) delete mode 100644 docs/tmn-wip-docs/usage.md diff --git a/docs/tmn-wip-docs/usage.md b/docs/tmn-wip-docs/usage.md deleted file mode 100644 index aeec23a4..00000000 --- a/docs/tmn-wip-docs/usage.md +++ /dev/null @@ -1,196 +0,0 @@ -# Using the Python library - -This package works with media files in the [supported formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md). - -For complete working examples, see the [examples folder](https://github.com/contentauth/c2pa-python/tree/main/examples) in the repository. - -## Import - -Import the objects needed from the API: - -```py -from c2pa import Builder, Reader, Signer, C2paSigningAlg, C2paSignerInfo -``` - -You can use both `Builder`, `Reader` and `Signer` classes with context managers by using a `with` statement. -Doing this is recommended to ensure proper resource and memory cleanup. - -## Define manifest JSON - -The Python library works with both file-based and stream-based operations. -In both cases, the manifest JSON string defines the C2PA manifest to add to an asset. For example: - -```py -manifest_json = json.dumps({ - "claim_generator": "python_test/0.1", - "assertions": [ - { - "label": "cawg.training-mining", - "data": { - "entries": { - "cawg.ai_inference": { - "use": "notAllowed" - }, - "cawg.ai_generative_training": { - "use": "notAllowed" - } - } - } - } - ] - }) -``` - -## File-based operation - -### 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. - -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). - -```py -try: - # Create a reader from a file path - with Reader("path/to/media_file.jpg") as reader: - # Print manifest store as JSON - print("Manifest store:", reader.json()) - - # Get the active manifest. - manifest = json.loads(reader.json()) - active_manifest = manifest["manifests"][manifest["active_manifest"]] - if active_manifest: - # Get the uri to the manifest's thumbnail and write it to a file - uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: - reader.resource_to_stream(uri, f) - -except Exception as err: - print(err) -``` - -### Add a signed manifest - -**WARNING**: This example accesses the private key and security certificate directly from the local file system. This is fine during development, but doing so in production may be insecure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; for example as show in the [C2PA Python Example](https://github.com/contentauth/c2pa-python-example). - -Use a `Builder` to add a manifest to an asset: - -```py -try: - # Create a signer from certificate and key files - with open("path/to/cert.pem", "rb") as cert_file, open("path/to/key.pem", "rb") as key_file: - cert_data = cert_file.read() - key_data = key_file.read() - - # Create signer info using cert and key info - signer_info = C2paSignerInfo( - alg=C2paSigningAlg.PS256, - cert=cert_data, - key=key_data, - timestamp_url="http://timestamp.digicert.com" - ) - - # Create signer using the defined SignerInfo - signer = Signer.from_info(signer_info) - - # Create builder with manifest and add ingredients - with Builder(manifest_json) as builder: - # Add any ingredients if needed - with open("path/to/ingredient.jpg", "rb") as ingredient_file: - ingredient_json = json.dumps({"title": "Ingredient Image"}) - builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file) - - # 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) - - # Verify the signed file by reading data from the signed output file - with Reader("path/to/output.jpg") as reader: - manifest_store = json.loads(reader.json()) - active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - print("Signed manifest:", active_manifest) - -except Exception as e: - print("Failed to sign manifest store: " + str(e)) -``` - -## 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. - -### Read and validate C2PA data using streams - -```py -try: - # Create a reader from a format and stream - with open("path/to/media_file.jpg", "rb") as stream: - # First parameter should be the type of the file (here, we use the mimetype) - # But in any case we need something to identify the file type - with Reader("image/jpeg", stream) as reader: - # Print manifest store as JSON, as extracted by the Reader - print("manifest store:", reader.json()) - - # Get the active manifest - manifest = json.loads(reader.json()) - active_manifest = manifest["manifests"][manifest["active_manifest"]] - if active_manifest: - # get the uri to the manifest's thumbnail and write it to a file - uri = active_manifest["thumbnail"]["identifier"] - with open("thumbnail_v2.jpg", "wb") as f: - reader.resource_to_stream(uri, f) - -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). - -Use a `Builder` to add a manifest to an asset: - -```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" - ) - - # 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) - - # 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"]] - print("Signed manifest:", active_manifest) - -except Exception as e: - print("Failed to sign manifest store: " + str(e)) -``` From 75c4ff7b2340d84082828c66ca9a567b161fa263 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 13:52:57 -0800 Subject: [PATCH 51/84] 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 52/84] 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 de6f4c90e9fa395b79d1a938d731b032e2f55ffd Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 18:30:56 -0800 Subject: [PATCH 53/84] fix: WIP docs --- docs/tmn-wip-docs/context.md | 102 +++++++++++++++++++++--- docs/tmn-wip-docs/settings.md | 7 +- docs/tmn-wip-docs/working-stores.md | 119 +++++++++++++++++++++++----- 3 files changed, 193 insertions(+), 35 deletions(-) diff --git a/docs/tmn-wip-docs/context.md b/docs/tmn-wip-docs/context.md index ccc84145..2bc4f331 100644 --- a/docs/tmn-wip-docs/context.md +++ b/docs/tmn-wip-docs/context.md @@ -32,14 +32,15 @@ classDiagram +from_json(json_str) Settings$ +from_dict(config) Settings$ +set(path, value) Settings - +update(data, format) Settings + +update(data) Settings +close() +is_valid bool } class ContextProvider { - <> - +is_valid bool + <> + +is_valid bool* + +execution_context* } class Context { @@ -67,7 +68,7 @@ classDiagram class Builder { +from_json(manifest_json, context) Builder$ - +from_archive(stream, context) Builder$ + +from_archive(stream) Builder$ +get_supported_mime_types() list~str~$ +set_no_embed() +set_remote_url(url) @@ -76,7 +77,9 @@ classDiagram +add_ingredient(json, format, source) +add_action(action_json) +to_archive(stream) + +with_archive(stream) Builder +sign(signer, format, source, dest) bytes + +sign_with_context(format, source, dest) bytes +sign_file(source_path, dest_path, signer) bytes +close() } @@ -138,7 +141,7 @@ classDiagram ... } - ContextProvider <|.. Context : satisfies + ContextProvider <|-- Context : extends Settings --> Context : optional input Signer --> Context : optional, consumed C2paSignerInfo --> Signer : creates via from_info @@ -155,6 +158,49 @@ classDiagram > [!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). +## Workflow overview + +The SDK supports two main workflows. `Settings` and `Context` are optional in both; `Reader` and `Builder` can be used directly with SDK defaults. + +### Reading provenance + +Read and inspect C2PA data already embedded in (or attached to) an asset: + +```text +Asset file ──► Reader ──► Manifest JSON (reader.json()) + └──► Binary resources (reader.resource_to_stream()) +``` + +```py +from c2pa import Reader + +reader = Reader("signed_image.jpg") +print(reader.json()) # Manifest store as JSON +``` + +### Signing content + +Create new C2PA provenance data and sign it into an asset: + +```text +Settings ──► Context ──► Builder ──► sign() ──► Signed asset +(optional) (optional) │ ▲ + │ │ + add assertions Signer + add ingredients +``` + +```py +from c2pa import Builder, Signer, C2paSignerInfo, C2paSigningAlg + +builder = Builder(manifest_json) +# ... add assertions, ingredients, resources ... +with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +`Settings` and `Context` are needed only to customize behavior (trust configuration, thumbnail settings, claim generator info, and so on). Without them, the SDK uses sensible defaults. + ## Creating a Context There are several ways to create a `Context`, depending on your needs: @@ -399,6 +445,35 @@ For more information, see [Settings - Offline or air-gapped environments](settin > [!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`. +### Context and archives + +Archives (`.c2pa` files) store only the manifest definition — they do **not** store settings or context. This means: + +- **`Builder.from_archive(stream)`** creates a context-free builder. All settings revert to SDK defaults regardless of what context the original builder had. +- **`Builder({}, context=ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved. + +Use `with_archive()` when your workflow depends on specific settings (thumbnails, claim generator, intent, and so on). Use `from_archive()` only for quick prototyping where SDK defaults are acceptable. + +```py +# Recommended: with_archive preserves context settings +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "1.0"} + } +}) + +with open("manifest.c2pa", "rb") as archive: + builder = Builder({}, context=ctx) + builder.with_archive(archive) + # builder now has the archived definition + context settings + +# NOT recommended when settings matter: +# builder = Builder.from_archive(archive) # context-free, SDK defaults apply +``` + +For more details on archive workflows, see [Working with archives](working-stores.md#working-with-archives). + ### Basic use ```py @@ -446,6 +521,15 @@ mobile_ctx = Context.from_dict({ ## Configuring a signer +### Signing concepts + +C2PA uses a certificate-based trust model to prove who signed an asset. When creating a `Signer`, the following parameters are required: + +- **Certificate chain** (`sign_cert`): An X.509 certificate chain in PEM format. The first certificate identifies the signer; subsequent certificates form a chain up to a trusted root (trust anchor). Verifiers use this chain to confirm that the signature comes from a trusted source. +- **Timestamp authority URL** (`ta_url`): An optional [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161) timestamp server URL. When provided, the SDK requests a trusted timestamp during signing. This proves _when_ the signature was made. Timestamping matters because signatures remain verifiable even after the signing certificate expires, as long as the certificate was valid at the time of signing. + +### Signer creation patterns + You can configure a signer in two ways: - [From Settings (signer-on-context)](#from-settings) @@ -474,7 +558,7 @@ ctx = Context(settings=settings, signer=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) + builder.sign_with_context("image/jpeg", src, dst) ``` > [!NOTE] @@ -540,14 +624,14 @@ dev_builder = Builder(manifest, context=dev_ctx) prod_builder = Builder(manifest, context=prod_ctx) ``` -### 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 enables custom context provider implementations. Subclass it and implement the `is_valid` and `execution_context` abstract properties to create a provider that can be passed to `Reader` or `Builder` as `context`. ```py from c2pa import ContextProvider, Context -# The built-in Context satisfies ContextProvider +# The built-in Context inherits from ContextProvider ctx = Context() assert isinstance(ctx, ContextProvider) # True ``` diff --git a/docs/tmn-wip-docs/settings.md b/docs/tmn-wip-docs/settings.md index 89f8c859..68d11a2c 100644 --- a/docs/tmn-wip-docs/settings.md +++ b/docs/tmn-wip-docs/settings.md @@ -21,8 +21,7 @@ Create and configure settings: | `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)`. | +| `update(data)` | 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. | | `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. | @@ -31,7 +30,6 @@ Create and configure settings: - 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 @@ -45,9 +43,6 @@ 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}}}') diff --git a/docs/tmn-wip-docs/working-stores.md b/docs/tmn-wip-docs/working-stores.md index 801816dd..3e6d7847 100644 --- a/docs/tmn-wip-docs/working-stores.md +++ b/docs/tmn-wip-docs/working-stores.md @@ -120,6 +120,55 @@ reader = Reader("signed_image.jpg", context=ctx) manifest_json = reader.json() ``` +### Understanding Reader output + +`Reader.json()` returns a JSON string representing the manifest store. The top-level structure looks like this: + +```json +{ + "active_manifest": "urn:uuid:...", + "manifests": { + "urn:uuid:...": { + "claim_generator": "MyApp/1.0", + "claim_generator_info": [{"name": "MyApp", "version": "1.0"}], + "title": "signed_image.jpg", + "assertions": [ + {"label": "c2pa.actions", "data": {"actions": [...]}}, + {"label": "c2pa.hash.data", "data": {...}} + ], + "ingredients": [...], + "signature_info": { + "alg": "Es256", + "issuer": "...", + "time": "2025-01-15T12:00:00Z" + } + } + } +} +``` + +- `active_manifest`: The URI label of the most recent manifest. This is typically the one to inspect first. +- `manifests`: A dictionary of all manifests in the store, keyed by their URI label. Assets that have been re-signed or that contain ingredient history may have multiple manifests. +- Within each manifest: `assertions` contain the provenance statements, `ingredients` list source materials, and `signature_info` provides details about who signed and when. + +The SDK also provides convenience methods to avoid manual JSON parsing: + +```py +reader = Reader("signed_image.jpg") + +# Get the active manifest directly as a dict +active = reader.get_active_manifest() + +# Get a specific manifest by label +manifest = reader.get_manifest("urn:uuid:...") + +# Check validation status +state = reader.get_validation_state() +results = reader.get_validation_results() +``` + +`Reader.detailed_json()` returns a more comprehensive JSON representation with a different structure than `json()`. It is useful when additional details about the manifest internals are needed. + ## Using working stores A **working store** is represented by a `Builder` object. It contains "live" manifest data as you add information to it. @@ -323,6 +372,8 @@ except Exception as e: ## Working with resources +C2PA manifest data is not just JSON. A manifest store also contains binary resources (thumbnails, ingredient data, and other embedded files) that are referenced from the JSON metadata by JUMBF URIs. When `reader.json()` is called, the JSON includes URI references (like `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`) that point to these binary resources. To retrieve the actual binary data, use `reader.resource_to_stream()` with the URI from the JSON. This separation keeps the JSON lightweight while allowing manifests to carry rich binary content alongside the metadata. + _Resources_ are binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. ### Understanding resource identifiers @@ -386,6 +437,14 @@ with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: ## Working with ingredients +### Why ingredients matter + +Ingredients are how C2PA tracks the history of content through edits, compositions, and transformations. Adding an ingredient to a manifest creates a verifiable link from the current asset back to its source material. This builds a **provenance chain**: original photo, then edited version, then composite, then published asset. Each link in the chain carries its own signed manifest, so anyone inspecting the final asset can trace its full history and verify that each step was authentic. + +The `relationship` field describes _how_ the source was used: `"parentOf"` for a direct edit, `"componentOf"` for an element composited into a larger work, or `"inputTo"` for a general input. This lets verifiers understand not just _what_ the sources were, but how they contributed to the final asset. + +### Overview + 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. @@ -407,9 +466,6 @@ ingredient_json = json.dumps({ 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) @@ -478,39 +534,62 @@ A Builder containing **only one ingredient and only the ingredient data** (no ot ### 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) +There are two ways to load a working store from an archive. They differ in whether the builder's context (settings) is preserved. -# 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) -``` +#### `with_archive()` — recommended -### Restoring with context preservation - -Pass a `context` to `from_archive()` to preserve custom settings: +Use `with_archive()` when you need the restored builder to use specific settings (thumbnail configuration, claim generator info, intent, verification options, and so on). Create a `Builder` with a `Context` first, then call `with_archive()` to load the archived manifest definition into it. The archive replaces only the manifest definition; the builder's context and settings are preserved. ```py # Create context with custom settings ctx = Context.from_dict({ "builder": { - "thumbnail": {"enabled": False} + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "1.0"} } }) -# Load archive with context +# Create builder with context, then load archive into it with open("manifest.c2pa", "rb") as archive: - builder = Builder.from_archive(archive, context=ctx) + builder = Builder({}, context=ctx) + builder.with_archive(archive) -# The builder has the archived manifest but keeps the custom context +# The builder has the archived manifest definition +# but keeps the context settings (no thumbnails, custom claim generator) with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: builder.sign(signer, "image/jpeg", src, dst) ``` +> [!IMPORTANT] +> `with_archive()` replaces the builder's manifest definition with the one from the archive. Any definition passed to `Builder()` is discarded. An empty dict `{}` is idiomatic for the initial definition when you plan to load an archive immediately after. + +#### `from_archive()` — context-free + +Use `from_archive()` for quick one-off operations where you don't need custom settings. It creates a **context-free** builder: no `Context` is attached, so all settings revert to SDK defaults (thumbnails enabled at 1024px, verification enabled, and so on). + +```py +# Restore from stream — no context, SDK defaults apply +with open("manifest.c2pa", "rb") as archive: + builder = Builder.from_archive(archive) + +# Sign with SDK default settings +with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +> [!WARNING] +> `from_archive()` does not accept a `context` parameter. Any settings that were active when the archive was created are **not** stored in the archive and are lost. For example, if the original builder had thumbnails disabled via a `Context`, the builder returned by `from_archive()` will generate thumbnails using SDK defaults. Use `with_archive()` instead when you need to preserve settings. + +#### Choosing between `with_archive()` and `from_archive()` + +| | `with_archive()` | `from_archive()` | +|---|---|---| +| **Context preserved** | Yes — settings come from the builder's context | No — SDK defaults apply | +| **Usage pattern** | `Builder({}, context=ctx).with_archive(stream)` | `Builder.from_archive(stream)` | +| **When to use** | Production workflows, custom settings needed | Quick prototyping, SDK defaults are acceptable | +| **What the archive carries** | Only the manifest definition | Only the manifest definition | +| **What it does NOT carry** | Settings, signer, context | Settings, signer, context | + ### Two-phase workflow example #### Phase 1: Prepare manifest From d42c1381c24aa7c7d05d233af7272cdf14e8b9eb Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 19:30:37 -0800 Subject: [PATCH 54/84] fix: Docs --- docs/tmn-wip-docs/context.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/tmn-wip-docs/context.md b/docs/tmn-wip-docs/context.md index 2bc4f331..855075ff 100644 --- a/docs/tmn-wip-docs/context.md +++ b/docs/tmn-wip-docs/context.md @@ -147,8 +147,8 @@ classDiagram C2paSignerInfo --> Signer : creates via from_info C2paSigningAlg --> C2paSignerInfo : alg field C2paSigningAlg --> Signer : from_callback alg - Context --> Reader : optional context= - Context --> Builder : optional context= + Context --> Reader : context= + Context --> Builder : context= Signer --> Builder : sign(signer) C2paBuilderIntent --> Builder : set_intent C2paDigitalSourceType --> Builder : set_intent @@ -166,9 +166,11 @@ The SDK supports two main workflows. `Settings` and `Context` are optional in bo Read and inspect C2PA data already embedded in (or attached to) an asset: -```text -Asset file ──► Reader ──► Manifest JSON (reader.json()) - └──► Binary resources (reader.resource_to_stream()) +```mermaid +flowchart LR + A[Asset file] --> B[Reader] + B --> C["Manifest JSON (reader.json())"] + B --> D["Binary resources (reader.resource_to_stream())"] ``` ```py @@ -182,12 +184,14 @@ print(reader.json()) # Manifest store as JSON Create new C2PA provenance data and sign it into an asset: -```text -Settings ──► Context ──► Builder ──► sign() ──► Signed asset -(optional) (optional) │ ▲ - │ │ - add assertions Signer - add ingredients +```mermaid +flowchart LR + A["Settings (optional)"] --> B["Context (optional)"] + B --> C[Builder] + C --> D["sign()"] + D --> E[Signed asset] + F[Signer] --> D + G[add assertions\nadd ingredients] --> C ``` ```py From fa7d6ef3ff09af6b97adbb023988fcb0abd2a6b9 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 19:39:00 -0800 Subject: [PATCH 55/84] fix: Docs --- docs/tmn-wip-docs/context.md | 50 ++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/tmn-wip-docs/context.md b/docs/tmn-wip-docs/context.md index 855075ff..0b88e601 100644 --- a/docs/tmn-wip-docs/context.md +++ b/docs/tmn-wip-docs/context.md @@ -18,7 +18,7 @@ Context encapsulates SDK configuration: - **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. +- **Improves code clarity**: Reading `Builder(manifest_json, ctx)` immediately shows that configuration is being used. ### Class diagram @@ -160,7 +160,7 @@ classDiagram ## Workflow overview -The SDK supports two main workflows. `Settings` and `Context` are optional in both; `Reader` and `Builder` can be used directly with SDK defaults. +The SDK supports two main workflows. `Settings` and `Context` are currently optional in both (but recommended). `Reader` and `Builder` can still be used directly with SDK defaults. ### Reading provenance @@ -168,7 +168,7 @@ Read and inspect C2PA data already embedded in (or attached to) an asset: ```mermaid flowchart LR - A[Asset file] --> B[Reader] + A[Asset file] --> B["Reader (with Context containing Settings)"] B --> C["Manifest JSON (reader.json())"] B --> D["Binary resources (reader.resource_to_stream())"] ``` @@ -186,7 +186,7 @@ Create new C2PA provenance data and sign it into an asset: ```mermaid flowchart LR - A["Settings (optional)"] --> B["Context (optional)"] + A["Settings"] --> B["Context"] B --> C[Builder] C --> D["sign()"] D --> E[Signed asset] @@ -203,7 +203,7 @@ with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: builder.sign(signer, "image/jpeg", src, dst) ``` -`Settings` and `Context` are needed only to customize behavior (trust configuration, thumbnail settings, claim generator info, and so on). Without them, the SDK uses sensible defaults. +`Settings` and `Context` enable to customize behavior (trust configuration, thumbnail settings, claim generator info, and so on). ## Creating a Context @@ -218,7 +218,7 @@ There are several ways to create a `Context`, depending on your needs: 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). +**When to use:** For quick prototyping, or when you're happy with SDK default behavior (verification enabled, thumbnails enabled at 1024px, and so on). ```py from c2pa import Context @@ -276,7 +276,7 @@ settings.update({ } }) -ctx = Context(settings=settings) +ctx = Context(settings) ``` ## Common configuration patterns @@ -322,7 +322,7 @@ if env == "production": else: settings.update({"verify": {"remote_manifest_fetch": False}}) -ctx = Context(settings=settings) +ctx = Context(settings) ``` ### Layered configuration @@ -341,7 +341,7 @@ settings = Settings.from_dict(base_config) # Apply environment-specific overrides settings.update({"builder": {"claim_generator_info": {"version": app_version}}}) -ctx = Context(settings=settings) +ctx = Context(settings) ``` For the full list of settings and defaults, see [Using settings](settings.md). @@ -355,7 +355,7 @@ Use `Context` to control how `Reader` validates manifests and handles remote res - [**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`. +> `Context` is used only at construction. `Reader` copies the configuration it needs internally, so the `Context` object does not need to outlive the `Reader`. A `Context` object can also be reused for multiple `Reader` object instances. ```py ctx = Context.from_dict({"verify": {"remote_manifest_fetch": False}}) @@ -447,14 +447,14 @@ For more information, see [Settings - Offline or air-gapped environments](settin - **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`. +> 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`. A `Context` object can also be reused for multiple `Builder` object instances. ### Context and archives Archives (`.c2pa` files) store only the manifest definition — they do **not** store settings or context. This means: - **`Builder.from_archive(stream)`** creates a context-free builder. All settings revert to SDK defaults regardless of what context the original builder had. -- **`Builder({}, context=ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved. +- **`Builder({}, ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved and propagated to this Builder instance. Use `with_archive()` when your workflow depends on specific settings (thumbnails, claim generator, intent, and so on). Use `from_archive()` only for quick prototyping where SDK defaults are acceptable. @@ -468,7 +468,7 @@ ctx = Context.from_dict({ }) with open("manifest.c2pa", "rb") as archive: - builder = Builder({}, context=ctx) + builder = Builder({}, ctx) builder.with_archive(archive) # builder now has the archived definition + context settings @@ -491,7 +491,7 @@ ctx = Context.from_dict({ } }) -builder = Builder(manifest_json, context=ctx) +builder = Builder(manifest_json, ctx) # Pass signer explicitly at signing time with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: @@ -556,11 +556,11 @@ signer_info = C2paSignerInfo( signer = Signer.from_info(signer_info) # Create context with signer (signer is consumed) -ctx = Context(settings=settings, signer=signer) +ctx = Context(settings, signer) # signer is now invalid and must not be used again # Build and sign — no signer argument needed -builder = Builder(manifest_json, context=ctx) +builder = Builder(manifest_json, ctx) with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: builder.sign_with_context("image/jpeg", src, dst) ``` @@ -574,7 +574,7 @@ For full programmatic control, create a `Signer` and pass it directly to `Builde ```py signer = Signer.from_info(signer_info) -builder = Builder(manifest_json, context=ctx) +builder = Builder(manifest_json, ctx) with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: builder.sign(signer, "image/jpeg", src, dst) @@ -605,11 +605,11 @@ with Context() as ctx: You can reuse the same `Context` to create multiple readers and builders: ```py -ctx = Context(settings=settings) +ctx = Context(settings) # All three use the same configuration -builder1 = Builder(manifest1, context=ctx) -builder2 = Builder(manifest2, context=ctx) +builder1 = Builder(manifest1, ctx) +builder2 = Builder(manifest2, ctx) reader = Reader("image.jpg", context=ctx) # Context can be closed after construction; readers/builders still work @@ -620,12 +620,12 @@ reader = Reader("image.jpg", context=ctx) 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) +dev_ctx = Context(dev_settings) +prod_ctx = Context(prod_settings) # Different builders with different configurations -dev_builder = Builder(manifest, context=dev_ctx) -prod_builder = Builder(manifest, context=prod_ctx) +dev_builder = Builder(manifest, dev_ctx) +prod_builder = Builder(manifest, prod_ctx) ``` ### ContextProvider abstract base class @@ -665,7 +665,7 @@ reader = Reader("image.jpg") # uses global settings from c2pa import Settings, Context, Reader settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -ctx = Context(settings=settings) +ctx = Context(settings) reader = Reader("image.jpg", context=ctx) ``` From f3ed23777ae9217c60666bb65675fbd6047952b2 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 19:46:03 -0800 Subject: [PATCH 56/84] fix: Docs --- docs/tmn-wip-docs/context.md | 44 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/tmn-wip-docs/context.md b/docs/tmn-wip-docs/context.md index 0b88e601..f05ee70d 100644 --- a/docs/tmn-wip-docs/context.md +++ b/docs/tmn-wip-docs/context.md @@ -459,7 +459,7 @@ Archives (`.c2pa` files) store only the manifest definition — they do **not** Use `with_archive()` when your workflow depends on specific settings (thumbnails, claim generator, intent, and so on). Use `from_archive()` only for quick prototyping where SDK defaults are acceptable. ```py -# Recommended: with_archive preserves context settings +# Recommended: with_archive propagates context settings ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, @@ -471,9 +471,6 @@ with open("manifest.c2pa", "rb") as archive: builder = Builder({}, ctx) builder.with_archive(archive) # builder now has the archived definition + context settings - -# NOT recommended when settings matter: -# builder = Builder.from_archive(archive) # context-free, SDK defaults apply ``` For more details on archive workflows, see [Working with archives](working-stores.md#working-with-archives). @@ -509,7 +506,7 @@ no_thumbnails_ctx = Context.from_dict({ } }) -# Or customize thumbnail size and quality for mobile +# Or customize thumbnail size and quality e.g. for mobile mobile_ctx = Context.from_dict({ "builder": { "claim_generator_info": {"name": "Mobile App"}, @@ -523,7 +520,7 @@ mobile_ctx = Context.from_dict({ }) ``` -## Configuring a signer +## Configuring a Signer ### Signing concepts @@ -534,14 +531,14 @@ C2PA uses a certificate-based trust model to prove who signed an asset. When cre ### Signer creation patterns -You can configure a signer in two ways: +A Signer can be configured two ways: -- [From Settings (signer-on-context)](#from-settings) -- [Explicit signer passed to sign()](#explicit-signer) +- [From Settings (signer-on-context)](#from-settings) — pass the signer when creating the `Context`. +- [Explicit signer passed to sign()](#explicit-programmatic-signer) — pass the signer directly at signing time. ### 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. +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 directly after that point. The `Context` takes ownership of the underlying native signer. ```py from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg @@ -557,18 +554,15 @@ signer = Signer.from_info(signer_info) # Create context with signer (signer is consumed) ctx = Context(settings, signer) -# signer is now invalid and must not be used again +# signer 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 a Signer is in the Context builder = Builder(manifest_json, ctx) with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: builder.sign_with_context("image/jpeg", src, 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 +### Explicit (programmatic) signer For full programmatic control, create a `Signer` and pass it directly to `Builder.sign()`: @@ -580,6 +574,14 @@ with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: builder.sign(signer, "image/jpeg", src, dst) ``` +You can also use the fluent `ContextBuilder` API to attach a signer programmatically via `with_signer`: + +```py +ctx = Context.builder().with_settings(settings).with_signer(signer).build() +``` + +### Precedence rules for Signer configuration + If both an explicit signer and a context signer are available, the explicit signer always takes precedence: ```py @@ -589,7 +591,7 @@ builder.sign(explicit_signer, "image/jpeg", source, dest) ## Context lifetime and usage -### Context as a context manager +### `with` statement `Context` supports the `with` statement for automatic resource cleanup: @@ -607,7 +609,7 @@ You can reuse the same `Context` to create multiple readers and builders: ```py ctx = Context(settings) -# All three use the same configuration +# All three use the same configuration through usage of the same context builder1 = Builder(manifest1, ctx) builder2 = Builder(manifest2, ctx) reader = Reader("image.jpg", context=ctx) @@ -617,7 +619,7 @@ reader = Reader("image.jpg", context=ctx) ### Multiple contexts for different purposes -Use different `Context` objects when you need different settings; for example, for development vs. production, or different trust configurations: +Use different `Context` objects when you need different settings. Ror example, for development vs. production, or different trust configurations: ```py dev_ctx = Context(dev_settings) @@ -630,7 +632,7 @@ prod_builder = Builder(manifest, prod_ctx) ### ContextProvider abstract base class -`ContextProvider` is an abstract base class (ABC) that enables custom context provider implementations. Subclass it and implement the `is_valid` and `execution_context` abstract properties to create a provider that can be passed to `Reader` or `Builder` as `context`. +`ContextProvider` is an abstract base class (ABC) that enables context provider implementations. Subclass it and implement the `is_valid` and `execution_context` abstract properties to create a provider that can be passed to `Reader` or `Builder` as `Context`. ```py from c2pa import ContextProvider, Context @@ -642,7 +644,7 @@ assert isinstance(ctx, ContextProvider) # True ## Migrating from load_settings -The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`: +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context` APIs instead: | Aspect | load_settings (legacy) | Context | |--------|------------------------|---------| From 862015a29fc44fca197456f9a2d3d576c7b29f87 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 19:46:14 -0800 Subject: [PATCH 57/84] fix: Docs --- docs/{tmn-wip-docs => }/context.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{tmn-wip-docs => }/context.md (100%) diff --git a/docs/tmn-wip-docs/context.md b/docs/context.md similarity index 100% rename from docs/tmn-wip-docs/context.md rename to docs/context.md From b3accbd29471d83e1cdd27edd2b9d27eff85e3c1 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 19:53:11 -0800 Subject: [PATCH 58/84] fix: Docs --- docs/tmn-wip-docs/settings.md | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/tmn-wip-docs/settings.md b/docs/tmn-wip-docs/settings.md index 68d11a2c..bb248c69 100644 --- a/docs/tmn-wip-docs/settings.md +++ b/docs/tmn-wip-docs/settings.md @@ -22,14 +22,11 @@ Create and configure settings: | `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)` | 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. | -| `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. +- The `set()` and `update()` methods can be chained for incremental configuration. +- When using multiple configuration methods, later calls override earlier ones (last call wins when same setting is set multiple times). ```py from c2pa import Settings @@ -72,7 +69,7 @@ The Settings JSON has this top-level structure: ### Settings format -Settings are provided in **JSON** only. Pass JSON strings to `Settings.from_json()` or dictionaries to `Settings.from_dict()`. +Settings are provided in **JSON** format only. Pass JSON strings (serialized JSON stings) to `Settings.from_json()` or dictionaries to `Settings.from_dict()`. `from_dict` will convert the dictionary in a format compatible with what the udnerlying native libraries expect. ```py # From JSON string @@ -99,7 +96,7 @@ with open("config/settings.json", "r") as f: ## 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 by all languages in the SDK: ```json { @@ -397,7 +394,7 @@ camera_ctx = Context.from_dict({ }) ``` -Or for editing existing content: +Or another example for editing existing content: ```py editor_ctx = Context.from_dict({ @@ -412,8 +409,7 @@ editor_ctx = Context.from_dict({ 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. +See [Configuring a signer](context.md#configuring-a-signer) for details on how to configure a Signer. #### Local signer From b3eb697e2399d5e10f27e2c64900201fd91166d7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:02:11 -0800 Subject: [PATCH 59/84] fix: Docs --- docs/{tmn-wip-docs => }/settings.md | 0 docs/tmn-wip-docs/working-stores.md | 146 ++++++++++++++++++++++------ 2 files changed, 115 insertions(+), 31 deletions(-) rename docs/{tmn-wip-docs => }/settings.md (100%) diff --git a/docs/tmn-wip-docs/settings.md b/docs/settings.md similarity index 100% rename from docs/tmn-wip-docs/settings.md rename to docs/settings.md diff --git a/docs/tmn-wip-docs/working-stores.md b/docs/tmn-wip-docs/working-stores.md index 3e6d7847..498be397 100644 --- a/docs/tmn-wip-docs/working-stores.md +++ b/docs/tmn-wip-docs/working-stores.md @@ -83,43 +83,44 @@ Use the `Reader` class to read manifest stores from signed assets. from c2pa import Reader try: - # Create a Reader from a signed asset file + # Without Context 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 +# With Context (custom validation and trust 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() +manifest_store_json = reader.json() +``` + +### Reading from a stream + +```py +# Without Context +with open("signed_image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream) + manifest_json = reader.json() +``` + +```py +# With Context +with open("signed_image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + manifest_json = reader.json() ``` +For full details on `Context` and `Settings`, see [Using Context to configure the SDK](../context.md). + ### Understanding Reader output `Reader.json()` returns a JSON string representing the manifest store. The top-level structure looks like this: @@ -177,9 +178,9 @@ A **working store** is represented by a `Builder` object. It contains "live" man ```py import json -from c2pa import Builder, Context +from c2pa import Builder -# Create a working store with a manifest definition +# Without Context manifest_json = json.dumps({ "claim_generator_info": [{ "name": "example-app", @@ -190,8 +191,12 @@ manifest_json = json.dumps({ }) builder = Builder(manifest_json) +``` -# Or with custom context +```py +from c2pa import Builder, Context + +# With Context (custom settings applied) ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": True} @@ -262,10 +267,12 @@ manifest_store_json = reader.json() ### Creating a Builder (working store) ```py -# Create with manifest definition +# Without Context builder = Builder(manifest_json) +``` -# Or with custom context +```py +# With Context ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": True} @@ -302,13 +309,22 @@ signer = Signer.from_info(signer_info) ### Signing an asset ```py +# Without Context (explicit signer) 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}") +``` +```py +# With Context (signer configured in context) +# The Builder must have been created with a Context that has a signer. +try: + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + manifest_bytes = builder.sign_with_context("image/jpeg", src, dst) + print("Signed successfully!") except Exception as e: print(f"Signing failed: {e}") ``` @@ -318,10 +334,13 @@ except Exception as e: 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 -) +# Without Context (explicit signer) +manifest_bytes = builder.sign_file("source.jpg", "signed.jpg", signer) +``` + +```py +# With Context (uses the context's signer when no signer argument is passed) +manifest_bytes = builder.sign_file("source.jpg", "signed.jpg") ``` ### Complete example @@ -370,6 +389,54 @@ except Exception as e: print(f"Error: {e}") ``` +### Complete example with Context + +```py +import json +from c2pa import Builder, Reader, Context, Signer, C2paSignerInfo, C2paSigningAlg + +try: + # 1. Define manifest + manifest_json = json.dumps({ + "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], + "title": "Signed image", + "assertions": [] + }) + + # 2. Load credentials and create signer + with open("certs.pem", "rb") as f: + certs = f.read() + with open("private_key.pem", "rb") as f: + private_key = f.read() + + signer_info = C2paSignerInfo( + alg=C2paSigningAlg.ES256, + sign_cert=certs, + private_key=private_key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = Signer.from_info(signer_info) + + # 3. Create context with settings and signer + ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": True}} + }, signer=signer) + + # 4. Create Builder with context and sign + builder = Builder(manifest_json, context=ctx) + with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: + builder.sign_with_context("image/jpeg", src, dst) + + print("Asset signed with context settings") + + # 5. Read back the manifest store + reader = Reader("signed.jpg", context=ctx) + print(reader.json()) + +except Exception as e: + print(f"Error: {e}") +``` + ## Working with resources C2PA manifest data is not just JSON. A manifest store also contains binary resources (thumbnails, ingredient data, and other embedded files) that are referenced from the JSON metadata by JUMBF URIs. When `reader.json()` is called, the JSON includes URI references (like `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`) that point to these binary resources. To retrieve the actual binary data, use `reader.resource_to_stream()` with the URI from the JSON. This separation keeps the JSON lightweight while allowing manifests to carry rich binary content alongside the metadata. @@ -632,6 +699,23 @@ with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: print("Asset signed with manifest store") ``` +#### Phase 2 alternative: Sign with context + +```py +# Restore the working store with context settings preserved +ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": False}} +}, signer=signer) + +with open("artwork_manifest.c2pa", "rb") as archive: + builder = Builder({}, context=ctx) + builder.with_archive(archive) + +# Sign using the context's signer +with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: + builder.sign_with_context("image/jpeg", src, dst) +``` + ## Embedded vs external manifests By default, manifest stores are **embedded** directly into the asset file. You can also use **external** or **remote** manifest stores. From 6cf020772254b516a5ce8ac9c52d05ddef7a4364 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:03:05 -0800 Subject: [PATCH 60/84] fix: Docs --- docs/tmn-wip-docs/working-stores.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tmn-wip-docs/working-stores.md b/docs/tmn-wip-docs/working-stores.md index 498be397..3b325e8e 100644 --- a/docs/tmn-wip-docs/working-stores.md +++ b/docs/tmn-wip-docs/working-stores.md @@ -23,7 +23,7 @@ graph TD A[Working Store
Builder object] -->|sign| MS A -->|to_archive| C[C2PA Archive
.c2pa file] - C -->|from_archive| A + C -->|from_archive or with_archive| A ``` ## Key entities From 10cdfe9801a7eb0e629ce35454b63fc6d99e0d65 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:19:23 -0800 Subject: [PATCH 61/84] fix: Docs --- docs/{tmn-wip-docs => }/working-stores.md | 93 +++++++++++++---------- 1 file changed, 52 insertions(+), 41 deletions(-) rename docs/{tmn-wip-docs => }/working-stores.md (89%) diff --git a/docs/tmn-wip-docs/working-stores.md b/docs/working-stores.md similarity index 89% rename from docs/tmn-wip-docs/working-stores.md rename to docs/working-stores.md index 3b325e8e..1552345d 100644 --- a/docs/tmn-wip-docs/working-stores.md +++ b/docs/working-stores.md @@ -343,29 +343,28 @@ manifest_bytes = builder.sign_file("source.jpg", "signed.jpg", signer) manifest_bytes = builder.sign_file("source.jpg", "signed.jpg") ``` -### Complete example +### Complete example with Context This code combines the above examples to create, sign, and read a manifest. ```py import json -from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg +from c2pa import Builder, Reader, Context, Signer, C2paSignerInfo, C2paSigningAlg try: - # 1. Define manifest for working store + # 1. Define manifest manifest_json = json.dumps({ "claim_generator_info": [{"name": "demo-app", "version": "0.1.0"}], "title": "Signed image", "assertions": [] }) - # 2. Load credentials + # 2. Load credentials and create signer with open("certs.pem", "rb") as f: certs = f.read() with open("private_key.pem", "rb") as f: private_key = f.read() - # 3. Create signer signer_info = C2paSignerInfo( alg=C2paSigningAlg.ES256, sign_cert=certs, @@ -374,41 +373,49 @@ try: ) signer = Signer.from_info(signer_info) - # 4. Create working store (Builder) and sign - builder = Builder(manifest_json) + # 3. Create context with settings and signer + ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": True}} + }, signer=signer) + + # 4. Create Builder with context and sign + builder = Builder(manifest_json, context=ctx) with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) + builder.sign_with_context("image/jpeg", src, dst) - print("Asset signed - working store is now a manifest store") + print("Asset signed with context settings") # 5. Read back the manifest store - reader = Reader("signed.jpg") + reader = Reader("signed.jpg", context=ctx) print(reader.json()) except Exception as e: print(f"Error: {e}") ``` -### Complete example with Context +### Complete example (legacy, without Context) + +This code combines the examples to create, sign, and read a manifest. ```py import json -from c2pa import Builder, Reader, Context, Signer, C2paSignerInfo, C2paSigningAlg +from c2pa import Builder, Reader, Signer, C2paSignerInfo, C2paSigningAlg try: - # 1. Define manifest + # 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 and create signer + # 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, @@ -417,20 +424,15 @@ try: ) signer = Signer.from_info(signer_info) - # 3. Create context with settings and signer - ctx = Context.from_dict({ - "builder": {"thumbnail": {"enabled": True}} - }, signer=signer) - - # 4. Create Builder with context and sign - builder = Builder(manifest_json, context=ctx) + # 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_with_context("image/jpeg", src, dst) + builder.sign(signer, "image/jpeg", src, dst) - print("Asset signed with context settings") + print("Asset signed - working store is now a manifest store") # 5. Read back the manifest store - reader = Reader("signed.jpg", context=ctx) + reader = Reader("signed.jpg") print(reader.json()) except Exception as e: @@ -439,10 +441,10 @@ except Exception as e: ## Working with resources -C2PA manifest data is not just JSON. A manifest store also contains binary resources (thumbnails, ingredient data, and other embedded files) that are referenced from the JSON metadata by JUMBF URIs. When `reader.json()` is called, the JSON includes URI references (like `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`) that point to these binary resources. To retrieve the actual binary data, use `reader.resource_to_stream()` with the URI from the JSON. This separation keeps the JSON lightweight while allowing manifests to carry rich binary content alongside the metadata. - _Resources_ are binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. +C2PA manifest data is not just JSON. A manifest store also contains binary resources (thumbnails, ingredient data, and other embedded files) that are referenced from the JSON metadata by JUMBF URIs. When `reader.json()` is called, the JSON includes URI references (like `"self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg"`) that point to these binary resources. To retrieve the actual binary data, use `reader.resource_to_stream()` with the URI from the JSON. This separation keeps the JSON lightweight while allowing manifests to carry rich binary content alongside the metadata. + ### Understanding resource identifiers When you add a resource to a working store (Builder), you assign it an identifier string. When the manifest store is created during signing, the SDK automatically converts this to a proper JUMBF URI. @@ -506,15 +508,13 @@ with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: ### Why ingredients matter -Ingredients are how C2PA tracks the history of content through edits, compositions, and transformations. Adding an ingredient to a manifest creates a verifiable link from the current asset back to its source material. This builds a **provenance chain**: original photo, then edited version, then composite, then published asset. Each link in the chain carries its own signed manifest, so anyone inspecting the final asset can trace its full history and verify that each step was authentic. +Ingredients are how C2PA tracks the history of content through edits, compositions, and transformations to build a content provenance chain represented by the manifest store. Adding an ingredient to a manifest creates a verifiable link from the current asset back to its source material. This builds a **provenance chain**: original photo, then edited version, then composite, then published asset, etc. -The `relationship` field describes _how_ the source was used: `"parentOf"` for a direct edit, `"componentOf"` for an element composited into a larger work, or `"inputTo"` for a general input. This lets verifiers understand not just _what_ the sources were, but how they contributed to the final asset. +The `relationship` field describes how the source (ingredient) was used: `"parentOf"` for a direct edit, `"componentOf"` for an element composited into a larger work, or `"inputTo"` for a general input. This lets verifiers understand not just what the sources were, but how they contributed to the final asset. ### Overview -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. +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 and only 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, as an ingredient archive can be added back directly to a working store (no un-archiving of the ingredient needed, use `application/c2pa` format when adding an ingredient archive to a Builder instance). ### Adding ingredients to a working store @@ -601,11 +601,11 @@ A Builder containing **only one ingredient and only the ingredient data** (no ot ### Restoring a working store from archive -There are two ways to load a working store from an archive. They differ in whether the builder's context (settings) is preserved. +There are two ways to load a working store from an archive. They differ in whether the builder's current context (settings) is preserved or not. -#### `with_archive()` — recommended +#### `with_archive()` -Use `with_archive()` when you need the restored builder to use specific settings (thumbnail configuration, claim generator info, intent, verification options, and so on). Create a `Builder` with a `Context` first, then call `with_archive()` to load the archived manifest definition into it. The archive replaces only the manifest definition; the builder's context and settings are preserved. +Use `with_archive()` when you need the restored builder to use specific settings that you put on the Builder on instanciation by using a context as parameter of the Builder constructor. Create a `Builder` with a `Context` first, then call `with_archive()` to load the archived manifest definition into it. The archive replaces only the manifest definition; the builder's context and settings are preserved. ```py # Create context with custom settings @@ -628,11 +628,11 @@ with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: ``` > [!IMPORTANT] -> `with_archive()` replaces the builder's manifest definition with the one from the archive. Any definition passed to `Builder()` is discarded. An empty dict `{}` is idiomatic for the initial definition when you plan to load an archive immediately after. +> `with_archive()` replaces the builder's manifest definition with the one from the archive. Any definition passed to `Builder()` on instanciation is discarded. An empty dict `{}` is idiomatic for the initial definition when you plan to load an archive immediately after. -#### `from_archive()` — context-free +#### `from_archive()` (legacy) -Use `from_archive()` for quick one-off operations where you don't need custom settings. It creates a **context-free** builder: no `Context` is attached, so all settings revert to SDK defaults (thumbnails enabled at 1024px, verification enabled, and so on). +Use `from_archive()` for quick one-off operations where you don't need custom settings. It creates a **context-free** builder: no `Context` is attached, so all settings revert to SDK defaults. ```py # Restore from stream — no context, SDK defaults apply @@ -645,7 +645,7 @@ with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: ``` > [!WARNING] -> `from_archive()` does not accept a `context` parameter. Any settings that were active when the archive was created are **not** stored in the archive and are lost. For example, if the original builder had thumbnails disabled via a `Context`, the builder returned by `from_archive()` will generate thumbnails using SDK defaults. Use `with_archive()` instead when you need to preserve settings. +> `from_archive()` does not accept a `context` parameter. Any settings that were active when the archive was created are not stored in the archive and are therefore lost. For example, if the original builder had thumbnails disabled via a `Context`, the builder returned by `from_archive()` will generate thumbnails using SDK defaults. Use `with_archive()` instead when you need to preserve settings on the Builder instance you are loading an archive into. #### Choosing between `with_archive()` and `from_archive()` @@ -661,6 +661,8 @@ with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: #### Phase 1: Prepare manifest +This step prepares the manifest on a Builder, and archives it into a Builder archive for later reuse. + ```py import io import json @@ -670,7 +672,6 @@ manifest_json = json.dumps({ "assertions": [] }) -builder = Builder(manifest_json) with open("thumb.jpg", "rb") as thumb: builder.add_resource("thumbnail", thumb) with open("sketch.png", "rb") as sketch: @@ -701,6 +702,8 @@ print("Asset signed with manifest store") #### Phase 2 alternative: Sign with context +In this step, after reloading the working store into a Builder instance configured with a context, settings on the Builder context can configure signing settings (e.g. thumbnails on/off). + ```py # Restore the working store with context settings preserved ctx = Context.from_dict({ @@ -724,6 +727,8 @@ By default, manifest stores are **embedded** directly into the asset file. You c ```py builder = Builder(manifest_json) +# A builder object in this case can also be created +# using an additional Context parameter for settings propagation # Default behavior: manifest store is embedded in the output with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: @@ -739,6 +744,9 @@ Prevent embedding the manifest store in the asset: ```py builder = Builder(manifest_json) +# A builder object in this case can also be created +# using an additional Context parameter for settings propagation + builder.set_no_embed() # Don't embed the manifest store # Sign: manifest store is NOT embedded, manifest bytes are returned @@ -759,6 +767,9 @@ Reference a manifest store stored at a remote URL: ```py builder = Builder(manifest_json) +# A builder object in this case can also be created +# using an additional Context parameter for settings propagation + builder.set_remote_url("https://example.com/manifests/") # The asset will contain a reference to the remote manifest store @@ -770,7 +781,7 @@ with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: ### Use Context for configuration -Always use `Context` objects for SDK configuration: +Use `Context` objects for SDK configuration: ```py ctx = Context.from_dict({ @@ -788,7 +799,7 @@ reader = Reader("asset.jpg", context=ctx) ### Use ingredients to build provenance chains -Add ingredients to your manifests to maintain a clear provenance chain: +Add ingredients to your manifests to maintain a provenance chain: ```py ingredient_json = json.dumps({ From 75aa399be6e3b59320f1ffd5033e4f07b308d4ae Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:28:54 -0800 Subject: [PATCH 62/84] fix: Docs --- docs/{tmn-wip-docs => }/intents.md.md | 31 +++++++++++---------------- 1 file changed, 12 insertions(+), 19 deletions(-) rename docs/{tmn-wip-docs => }/intents.md.md (92%) diff --git a/docs/tmn-wip-docs/intents.md.md b/docs/intents.md.md similarity index 92% rename from docs/tmn-wip-docs/intents.md.md rename to docs/intents.md.md index 5b331d9f..6a150d59 100644 --- a/docs/tmn-wip-docs/intents.md.md +++ b/docs/intents.md.md @@ -39,7 +39,7 @@ with Builder({}) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -Both ways of writing the code produce the same signed manifest. With intents the Builder validates the setup and fills in the spec-required structure. +Both ways of writing the code produce the same signed manifest. With intents, the Builder validates the setup and fills in the spec-required structure. ## Setting the intent @@ -47,7 +47,7 @@ There are three ways to set the intent on a `Builder` object instance. The inten ### Using Context -Pass the intent through a `Context` object when creating the `Builder`. This is the an approach that keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. Note that the context is created from settings, so you can modulate the settings for each context. +Pass the intent through a `Context` object when creating the `Builder`. This is an approach that keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. Note that the context is created from settings, so you can modulate the settings for each context. ```py from c2pa import Context, Builder @@ -93,16 +93,14 @@ with Builder({}) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -If both a `Context` intent and a `set_intent` call are present, the `set_intent` call takes precedence. - ### Using `load_settings` (deprecated) -The global `load_settings` function can configure the intent for all subsequent `Builder` instances. This approach is deprecated in favor of context-based APIs: +The legacy `load_settings` function can configure the intent for all subsequent `Builder` instances (thread-local configuration). This approach is deprecated in favor of context-based APIs: ```py from c2pa import load_settings, Builder -# Deprecated: sets intent globally +# Deprecated: sets intent settings per thread load_settings({"builder": {"intent": "edit"}}) with Builder({}) as builder: @@ -112,7 +110,7 @@ with Builder({}) as builder: ### Intent setting precedence -When an intent is configured in multiple places, the most specific setting wins: +When an intent is configured in multiple places , the most specific setting wins: ```mermaid flowchart TD @@ -131,9 +129,11 @@ flowchart TD manually in manifest JSON."] ``` +Notably, if a `set_intent` call is present on the Builder, the `set_intent` call takes precedence. + ## How intents relate to the source stream -The intent **operates on the source stream** passed to `sign()`. It does not target a specific ingredient added via `add_ingredient`: it targets the source asset itself (and ONLY the source). +The intent **operates on the source** passed to `sign()`. It does not target a specific ingredient added via `add_ingredient`: it targets the source asset itself (and ONLY the source). The following diagram shows what happens at sign time for each intent: @@ -207,15 +207,10 @@ flowchart TD ## Import +Intents and digital source types are provided as enums by two imports. + ```py from c2pa import ( - Builder, - Reader, - Signer, - Context, - Settings, - C2paSignerInfo, - C2paSigningAlg, C2paBuilderIntent, C2paDigitalSourceType, ) @@ -301,7 +296,7 @@ with Builder({}, context=ctx) as builder: ctx = Context.from_dict({ "builder": { "intent": {"Create": "digitalCapture"}, - "claim_generator_info": {"name": "my_app", "version": "1.0.0"}, + "claim_generator_info": {"name": "an_app", "version": "0.1.0"}, } }) @@ -462,7 +457,7 @@ with Builder({}) as builder: ## Intent values in settings -When configuring the intent settings, the intent is specified as a string or object in the `builder.intent` field: +When configuring the intent settings, the intent is specified as a string or object in the `builder.intent` field, matching the C2PA SDK settings JSON schema: | Intent | Settings value | With digital source type | |--------|---------------|--------------------------| @@ -470,8 +465,6 @@ When configuring the intent settings, the intent is specified as a string or obj | Edit | `"edit"` | Not applicable | | Update | `"update"` | Not applicable | -Available digital source type values for Create: `"digitalCapture"`, `"digitalCreation"`, `"trainedAlgorithmicMedia"`, `"compositeSynthetic"`, `"screenCapture"`, `"algorithmicMedia"`, and others. - ## API reference ### `Builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY)` From c8040e986d6fae61f7a979abe052a3d9213e886b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:37:04 -0800 Subject: [PATCH 63/84] fix: Docs --- docs/tmn-wip-docs/selective-manifests.md | 74 +++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/tmn-wip-docs/selective-manifests.md b/docs/tmn-wip-docs/selective-manifests.md index b7131a33..8e988c92 100644 --- a/docs/tmn-wip-docs/selective-manifests.md +++ b/docs/tmn-wip-docs/selective-manifests.md @@ -70,6 +70,9 @@ with open("thumbnail.jpg", "wb") as f: ## Filtering into a new Builder +> [!NOTE] +> All `Builder` examples on this page also work with a `Context` for custom settings (thumbnails, claim generator, intent). A context can be passed to the constructor with `Builder(manifest_json, context=ctx)`. For signing with a context-provided signer, `builder.sign_with_context()` can be used instead of `builder.sign()`. See [Context](../context.md) for details. + Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. When transferring ingredients from a `Reader` to a new `Builder`, you must transfer both the JSON metadata and the associated binary resources (thumbnails, manifest data). The JSON contains identifiers that reference those resources; the same identifiers must be used when calling `builder.add_resource()`. @@ -454,6 +457,7 @@ flowchart TD A2["Archive: graphics.c2pa (ingredients from design assets)"] A3["Archive: audio.c2pa (ingredients from audio tracks)"] end + CTX["Context (optional)"] subgraph Build["Final Builder"] direction TB SEL["Pick and choose ingredients from any archive in the catalog"] @@ -462,11 +466,13 @@ flowchart TD A1 -->|"select photo_1, photo_3"| SEL A2 -->|"select logo"| SEL A3 -. "skip (not needed)" .-> X((not used)) + CTX -.->|"settings"| FB SEL --> FB FB -->|sign| OUT[Signed Output Asset] style A3 fill:#eee,stroke:#999 style X fill:#f99,stroke:#c00 + style CTX fill:#e8f4fd,stroke:#4a90d9 ``` @@ -494,6 +500,38 @@ with Reader("application/c2pa", archive_stream) as reader: new_builder.sign(signer, "image/jpeg", source, dest) ``` +#### With context + +The same workflow can use a `Context` for custom settings. The context controls thumbnail generation, claim generator info, and other Builder settings. When a signer is configured in the context, `sign_with_context()` can be used instead of passing a signer explicitly. + +```py +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "1.0"} + } +}) + +archive_stream.seek(0) +with Reader("application/c2pa", archive_stream) as reader: + manifest_store = json.loads(reader.json()) + active = manifest_store["manifests"][manifest_store["active_manifest"]] + + selected = [ + ing for ing in active["ingredients"] + if ing["title"] in {"photo_1.jpg", "logo.png"} + ] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }, context=ctx) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign_with_context("image/jpeg", source, dest) +``` + ### Overriding ingredient properties When adding an ingredient from an archive or from a file, the JSON passed to `add_ingredient()` can override properties like `title` and `relationship`. This is useful when reusing archived ingredients in a different context: @@ -586,10 +624,14 @@ flowchart TD AR -->|"Reader(application/c2pa)"| RD[JSON + resources] RD -->|"pick ingredients"| SEL[Selected ingredients] end + CTX["Context (optional)"] subgraph Step3["Step 3: Reuse in a new Builder"] SEL -->|"new Builder + add_resource()"| B2[New Builder] + CTX -.->|"settings"| B2 B2 -->|sign| OUT[Signed Output] end + + style CTX fill:#e8f4fd,stroke:#4a90d9 ``` @@ -620,6 +662,9 @@ with Builder({ builder.to_archive(archive_stream) ``` +> [!NOTE] +> When restoring from an archive, `with_archive()` preserves context settings while `from_archive()` does not. See [Working with archives](../working-stores.md#working-with-archives) for the full comparison. + **Step 2:** Read the archive and extract ingredients: ```py @@ -646,9 +691,36 @@ with Reader("application/c2pa", archive_stream) as reader: new_builder.sign(signer, "image/jpeg", source, dest) ``` +#### Step 3 with context + +When context settings need to be applied (such as thumbnail configuration or claim generator info), the builder can be created with a `Context`. If the archive was previously saved with `to_archive()`, it can be loaded with `with_archive()` to preserve those settings. + +```py + ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "1.0"} + } + }) + + selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] + + with Builder({ + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], + "ingredients": selected, + }, context=ctx) as new_builder: + transfer_ingredient_resources(reader, new_builder, selected) + + with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + new_builder.sign_with_context("image/jpeg", source, dest) +``` + ### 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. +> [!NOTE] +> The `Builder` construction and signing in the merge workflow also support `Context`. The caller can pass `context=ctx` to `Builder()` and use `sign_with_context()` instead of `sign()`. See [Context](../context.md) for details. + +In some cases it is necessary to merge ingredients from multiple working stores (builder archives) into a single `Builder`. This should be a **fallback strategy**. The recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. When merging from multiple sources, resource identifier URIs can collide. Rename identifiers with a unique suffix when needed. Use two passes: (1) collect ingredients with collision handling, build the manifest, create the builder; (2) re-read each archive and transfer resources (use original ID for `resource_to_stream()`, renamed ID for `add_resource()` when collisions occurred). From a10bb6555d0cc6eb07af210f6252239febf137a9 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:41:37 -0800 Subject: [PATCH 64/84] fix: Docs --- docs/tmn-wip-docs/selective-manifests.md | 30 +++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/tmn-wip-docs/selective-manifests.md b/docs/tmn-wip-docs/selective-manifests.md index 8e988c92..5e422f54 100644 --- a/docs/tmn-wip-docs/selective-manifests.md +++ b/docs/tmn-wip-docs/selective-manifests.md @@ -38,7 +38,10 @@ The fundamental workflow is: Use `Reader` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. +`Reader` also accepts an optional `context` parameter. This is especially important for trust configuration, which controls which certificates are trusted when validating signatures. Without a context, `Reader` uses SDK defaults. See [Configuring Reader](../context.md#configuring-reader) and [Trust configuration](../context.md#trust-configuration) for details. + ```py +# Without context (SDK default trust settings) with open("signed_asset.jpg", "rb") as source: with Reader("image/jpeg", source) as reader: # Get the full manifest store as JSON @@ -54,6 +57,27 @@ with open("signed_asset.jpg", "rb") as source: thumbnail_id = manifest["thumbnail"]["identifier"] ``` +### With context (trust configuration) + +To control which certificates are trusted during validation, pass a `Context` with trust settings to `Reader`: + +```py +ctx = Context.from_dict({ + "trust": { + "user_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCA...\n-----END CERTIFICATE-----", + }, + "verify": { + "verify_trust": True + } +}) + +with open("signed_asset.jpg", "rb") as source: + with Reader("image/jpeg", source, context=ctx) as reader: + manifest_store = json.loads(reader.json()) + active_label = manifest_store["active_manifest"] + manifest = manifest_store["manifests"][active_label] +``` + ### Extracting binary resources The JSON returned by `reader.json()` only contains string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `resource_to_stream()`: @@ -71,7 +95,7 @@ with open("thumbnail.jpg", "wb") as f: ## Filtering into a new Builder > [!NOTE] -> All `Builder` examples on this page also work with a `Context` for custom settings (thumbnails, claim generator, intent). A context can be passed to the constructor with `Builder(manifest_json, context=ctx)`. For signing with a context-provided signer, `builder.sign_with_context()` can be used instead of `builder.sign()`. See [Context](../context.md) for details. +> All `Builder` and `Reader` examples on this page also work with a `Context`. For `Reader`, a context provides trust configuration and verification settings: `Reader(format, source, context=ctx)`. For `Builder`, a context provides custom settings (thumbnails, claim generator, intent): `Builder(manifest_json, context=ctx)`. For signing with a context-provided signer, `builder.sign_with_context()` can be used instead of `builder.sign()`. See [Context](../context.md) for details. Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. @@ -440,7 +464,7 @@ There are two distinct types of archives, sharing the same binary format but bei ### 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()`. +A **builder archive** (also called a working store archive) is a serialized snapshot of a `Builder`. It contains the manifest definition, all resources, and any ingredients that were added. It is created by `builder.to_archive()` and restored with `Builder.from_archive()` to create a new builder instance from an archive, or `builder.with_archive()` to load a working store from a builder archive into an existing builder instance. An **ingredient archive** contains the manifest store from an asset that was added as an ingredient. @@ -457,7 +481,7 @@ flowchart TD A2["Archive: graphics.c2pa (ingredients from design assets)"] A3["Archive: audio.c2pa (ingredients from audio tracks)"] end - CTX["Context (optional)"] + CTX["Context (to propagate settings and configuration)"] subgraph Build["Final Builder"] direction TB SEL["Pick and choose ingredients from any archive in the catalog"] From df7d75c38326040ef6fcede8d7d4bb11caf67334 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Fri, 6 Mar 2026 20:44:55 -0800 Subject: [PATCH 65/84] fix: Docs --- docs/{tmn-wip-docs => }/selective-manifests.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{tmn-wip-docs => }/selective-manifests.md (100%) diff --git a/docs/tmn-wip-docs/selective-manifests.md b/docs/selective-manifests.md similarity index 100% rename from docs/tmn-wip-docs/selective-manifests.md rename to docs/selective-manifests.md From 5c0063949491f3616dc737c0edea4ea79d59ab54 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sat, 7 Mar 2026 20:19:40 -0800 Subject: [PATCH 66/84] fix: Docs --- docs/context.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/context.md b/docs/context.md index f05ee70d..7e9d9b88 100644 --- a/docs/context.md +++ b/docs/context.md @@ -187,11 +187,14 @@ Create new C2PA provenance data and sign it into an asset: ```mermaid flowchart LR A["Settings"] --> B["Context"] + F[Signer] --> B B --> C[Builder] - C --> D["sign()"] + G["add assertions
add ingredients"] --> C + C --> D["sign_with_context()"] D --> E[Signed asset] - F[Signer] --> D - G[add assertions\nadd ingredients] --> C + F2[Signer] -.-> D2["sign()"] + C --> D2 + D2 --> E ``` ```py @@ -216,7 +219,7 @@ There are several ways to create a `Context`, depending on your needs: ### Using SDK default settings -The simplest approach is using [SDK default settings](settings.md#default-configuration). +Without additional parameters, a default context is using [SDK default settings](settings.md#default-configuration). **When to use:** For quick prototyping, or when you're happy with SDK default behavior (verification enabled, thumbnails enabled at 1024px, and so on). @@ -451,7 +454,7 @@ For more information, see [Settings - Offline or air-gapped environments](settin ### Context and archives -Archives (`.c2pa` files) store only the manifest definition — they do **not** store settings or context. This means: +Archives (`.c2pa` files) store only the manifest definition. They do **not** store settings or context. This means: - **`Builder.from_archive(stream)`** creates a context-free builder. All settings revert to SDK defaults regardless of what context the original builder had. - **`Builder({}, ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved and propagated to this Builder instance. @@ -533,8 +536,8 @@ C2PA uses a certificate-based trust model to prove who signed an asset. When cre A Signer can be configured two ways: -- [From Settings (signer-on-context)](#from-settings) — pass the signer when creating the `Context`. -- [Explicit signer passed to sign()](#explicit-programmatic-signer) — pass the signer directly at signing time. +- [From Settings (signer-on-context)](#from-settings): pass the signer when creating the `Context`. +- [Explicit signer passed to sign()](#explicit-programmatic-signer): pass the signer directly at signing time. ### From Settings @@ -545,10 +548,7 @@ from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigning # Create a signer signer_info = C2paSignerInfo( - alg=C2paSigningAlg.ES256, - sign_cert=cert_data, - private_key=key_data, - ta_url=b"http://timestamp.digicert.com" + C2paSigningAlg.ES256, cert_data, key_data, b"http://timestamp.digicert.com" ) signer = Signer.from_info(signer_info) @@ -617,6 +617,16 @@ reader = Reader("image.jpg", context=ctx) # Context can be closed after construction; readers/builders still work ``` +Using the `with` statement for automatic cleanup: + +```py +with Context(settings) as ctx: + builder1 = Builder(manifest1, ctx) + builder2 = Builder(manifest2, ctx) + reader = Reader("image.jpg", context=ctx) +# Resources are automatically released +``` + ### Multiple contexts for different purposes Use different `Context` objects when you need different settings. Ror example, for development vs. production, or different trust configurations: @@ -673,6 +683,6 @@ 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. +- [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. From d35489eec6a02b041127d075bdda943bd1e1aa1e Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sat, 7 Mar 2026 21:03:43 -0800 Subject: [PATCH 67/84] 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 36a6441c6847c70cec216f0f1667c3f9247a52d7 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sun, 8 Mar 2026 17:08:05 -0700 Subject: [PATCH 68/84] fix: Wording and examples --- docs/context.md | 12 +- docs/{intents.md.md => intents.md} | 45 ++--- docs/selective-manifests.md | 22 +- docs/settings.md | 4 +- docs/working-stores.md | 10 +- tests/test_unit_tests_threaded.py | 309 ----------------------------- 6 files changed, 41 insertions(+), 361 deletions(-) rename docs/{intents.md.md => intents.md} (82%) diff --git a/docs/context.md b/docs/context.md index 7e9d9b88..fa36c615 100644 --- a/docs/context.md +++ b/docs/context.md @@ -79,7 +79,7 @@ classDiagram +to_archive(stream) +with_archive(stream) Builder +sign(signer, format, source, dest) bytes - +sign_with_context(format, source, dest) bytes + +sign(format, source, dest) bytes +sign_file(source_path, dest_path, signer) bytes +close() } @@ -190,11 +190,9 @@ flowchart LR F[Signer] --> B B --> C[Builder] G["add assertions
add ingredients"] --> C - C --> D["sign_with_context()"] + C --> D["sign()"] D --> E[Signed asset] - F2[Signer] -.-> D2["sign()"] - C --> D2 - D2 --> E + F2[Signer] -.-> D ``` ```py @@ -466,7 +464,7 @@ Use `with_archive()` when your workflow depends on specific settings (thumbnails ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, - "claim_generator_info": {"name": "My App", "version": "1.0"} + "claim_generator_info": {"name": "My App", "version": "0.1.0"} } }) @@ -559,7 +557,7 @@ ctx = Context(settings, signer) # Build and sign, no signer argument needed since a Signer is in the Context builder = Builder(manifest_json, ctx) with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign_with_context("image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) ``` ### Explicit (programmatic) signer diff --git a/docs/intents.md.md b/docs/intents.md similarity index 82% rename from docs/intents.md.md rename to docs/intents.md index 6a150d59..9fb90be3 100644 --- a/docs/intents.md.md +++ b/docs/intents.md @@ -4,7 +4,7 @@ Intents enable validation, add required default actions, and help prevent invali ## Why use intents? -Without intents, the caller must manually construct the correct manifest structure to be compliant with the specification: adding the required actions (`c2pa.created` or `c2pa.opened` as first action as per specification), setting digital source types, managing ingredients, and linking actions to ingredients. Getting any of this wrong produces a manifest that does not comply with the C2PA specification. +Without intents, the caller must manually construct the correct manifest structure: adding the required actions (`c2pa.created` or `c2pa.opened` as the first action per the specification), setting digital source types, managing ingredients, and linking actions to ingredients. Getting any of this wrong produces a non-compliant manifest. With intents, the caller declares *what is being done* and the Builder handles the rest: @@ -43,11 +43,11 @@ Both ways of writing the code produce the same signed manifest. With intents, th ## Setting the intent -There are three ways to set the intent on a `Builder` object instance. The intent determines which actions the Builder auto-generates at sign time. +There are three ways to set the intent on a `Builder` object instance. ### Using Context -Pass the intent through a `Context` object when creating the `Builder`. This is an approach that keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. Note that the context is created from settings, so you can modulate the settings for each context. +Pass the intent through a `Context` object when creating the `Builder`. This keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. ```py from c2pa import Context, Builder @@ -55,7 +55,7 @@ from c2pa import Context, Builder ctx = Context.from_dict({ "builder": { "intent": {"Create": "digitalCapture"}, - "claim_generator_info": {"name": "My App", "version": "1.0.0"}, + "claim_generator_info": {"name": "My App", "version": "0.1.0"}, } }) @@ -129,11 +129,11 @@ flowchart TD manually in manifest JSON."] ``` -Notably, if a `set_intent` call is present on the Builder, the `set_intent` call takes precedence. +If a `set_intent` call is present on the Builder, it takes precedence over all other sources. ## How intents relate to the source stream -The intent **operates on the source** passed to `sign()`. It does not target a specific ingredient added via `add_ingredient`: it targets the source asset itself (and ONLY the source). +The intent operates on the source passed to `sign()`, not on any ingredient added via `add_ingredient`. The following diagram shows what happens at sign time for each intent: @@ -176,7 +176,7 @@ For **EDIT** and **UPDATE**, the Builder looks at the source stream, and if no ` ### How intent relates to `add_ingredient` -The intent and manually-added ingredients serve different roles. **The intent controls what the Builder does with the source stream** at sign time. The `add_ingredient` method **adds other ingredients explicitly**. +The intent controls what the Builder does with the source stream at sign time. The `add_ingredient` method adds other ingredients explicitly. These are separate concerns. ```mermaid flowchart TD @@ -244,10 +244,10 @@ flowchart TD ## CREATE intent -Use `CREATE` when the asset is a brand-new creation with no prior history. In this case, a `C2paDigitalSourceType` is required (by the specification) to describe how the asset was produced. The Builder will: +Use `CREATE` when the asset has no prior history. A `C2paDigitalSourceType` is required to describe how the asset was produced. The Builder will: - Add a `c2pa.created` action with the specified digital source type. -- Reject the operation if a `parentOf` ingredient exists (new creations cannot have parents). +- Reject the operation if a `parentOf` ingredient exists. ### Example: New digital creation @@ -290,7 +290,7 @@ with Builder({}, context=ctx) as builder: ### Example: CREATE with additional manifest metadata -`Context` and a manifest definition can be combined. The context handles the intent, while the manifest definition provides additional metadata and assertions: +A `Context` and a manifest definition can be combined. The context handles the intent; the manifest definition provides additional metadata and assertions: ```py ctx = Context.from_dict({ @@ -324,15 +324,13 @@ with Builder(manifest_def, context=ctx) as builder: Use `EDIT` when modifying an existing asset. The Builder will: -1. Check if a `parentOf` ingredient has already been added. If not, it **automatically creates one from the source stream** passed to `sign()`. +1. Check if a `parentOf` ingredient has already been added. If not, it automatically creates one from the source stream passed to `sign()`. 2. Add a `c2pa.opened` action linked to the parent ingredient. No `digital_source_type` parameter is needed. ### Example: Editing an asset -In this case, the source stream becomes the parent ingredient. - Using `Context`: ```py @@ -355,16 +353,11 @@ with Builder({}) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -The resulting manifest contains: - -- One ingredient with `relationship: "parentOf"` pointing to `original.jpg`. -- A `c2pa.opened` action referencing that ingredient. - -If the source file already has a C2PA manifest, the ingredient preserves the full provenance chain. +The resulting manifest contains one ingredient with `relationship: "parentOf"` pointing to `original.jpg` and a `c2pa.opened` action referencing that ingredient. If the source file already has a C2PA manifest, the ingredient preserves the full provenance chain. ### Example: Editing with a manually-added parent -To control some the parent ingredient's metadata (for example, to set a title or use a different source), add it explicitly. The Builder will then use that ingredient: +To control the parent ingredient's metadata (for example, to set a title or use a different source), add it explicitly: ```py ctx = Context.from_dict({"builder": {"intent": "edit"}}) @@ -383,7 +376,7 @@ with Builder({}, context=ctx) as builder: ### Example: Editing with additional component ingredients -A parent ingredient can be combined with component or input ingredients. The intent creates the `c2pa.opened` action for the parent, and additional actions can be added as components (componentOf)/input (inputTo): +A parent ingredient can be combined with component or input ingredients. The intent creates the `c2pa.opened` action for the parent; additional actions can reference components (`componentOf`) or inputs (`inputTo`): ```py ctx = Context.from_dict({"builder": {"intent": "edit"}}) @@ -425,13 +418,13 @@ with Builder({ ## UPDATE intent -Use `UPDATE` for non-editorial changes where the asset content itself is not modified, for example adding or changing metadata. This is a limited form of `EDIT`: +Use `UPDATE` for metadata-only changes where the asset content itself is not modified. This is a restricted form of `EDIT`: - Allows exactly one ingredient (only the parent). - Does not allow changes to the parent's hashed content. - Produces a more compact manifest than `EDIT`. -Like for the `EDIT` intent, the Builder auto-creates a parent ingredient from the source stream if one is not provided. +As with `EDIT`, the Builder auto-creates a parent ingredient from the source stream if one is not provided. ### Example: Adding metadata to a signed asset @@ -457,7 +450,7 @@ with Builder({}) as builder: ## Intent values in settings -When configuring the intent settings, the intent is specified as a string or object in the `builder.intent` field, matching the C2PA SDK settings JSON schema: +When configuring settings, the intent is specified as a string or object in the `builder.intent` field: | Intent | Settings value | With digital source type | |--------|---------------|--------------------------| @@ -469,11 +462,9 @@ When configuring the intent settings, the intent is specified as a string or obj ### `Builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY)` -**Parameters:** - | Parameter | Type | Description | |-----------|------|-------------| | `intent` | `C2paBuilderIntent` | The intent: `CREATE`, `EDIT`, or `UPDATE`. | | `digital_source_type` | `C2paDigitalSourceType` | Required for `CREATE`. Describes how the asset was made. Defaults to `EMPTY`. | -**Raises:** `C2paError` if the intent cannot be set (e.g., a `parentOf` ingredient exists with `CREATE`, or no parent can be found for `EDIT`/`UPDATE`). +Raises `C2paError` if the intent cannot be set (for example, a `parentOf` ingredient exists with `CREATE`). diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 5e422f54..7971e27a 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -95,7 +95,7 @@ with open("thumbnail.jpg", "wb") as f: ## Filtering into a new Builder > [!NOTE] -> All `Builder` and `Reader` examples on this page also work with a `Context`. For `Reader`, a context provides trust configuration and verification settings: `Reader(format, source, context=ctx)`. For `Builder`, a context provides custom settings (thumbnails, claim generator, intent): `Builder(manifest_json, context=ctx)`. For signing with a context-provided signer, `builder.sign_with_context()` can be used instead of `builder.sign()`. See [Context](../context.md) for details. +> All `Builder` and `Reader` examples on this page also work with a `Context`. For `Reader`, a context provides trust configuration and verification settings: `Reader(format, source, context=ctx)`. For `Builder`, a context provides custom settings (thumbnails, claim generator, intent): `Builder(manifest_json, context=ctx)`. When a signer is configured in the context, `builder.sign()` can be called without an signer instance as argument. See [Context](../context.md) for details. Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. @@ -263,7 +263,7 @@ The `label` field on an ingredient is the **primary** linking key. Set a `label` ```py manifest_json = { - "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ { "label": "c2pa.actions.v2", @@ -312,7 +312,7 @@ When linking multiple ingredients, each ingredient needs a unique label. ```py manifest_json = { - "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ { "label": "c2pa.actions.v2", @@ -377,7 +377,7 @@ When no `label` is set on an ingredient, the SDK matches `ingredientIds` against instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f" manifest_json = { - "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ { "label": "c2pa.actions", @@ -526,13 +526,13 @@ with Reader("application/c2pa", archive_stream) as reader: #### With context -The same workflow can use a `Context` for custom settings. The context controls thumbnail generation, claim generator info, and other Builder settings. When a signer is configured in the context, `sign_with_context()` can be used instead of passing a signer explicitly. +The same workflow can use a `Context` for custom settings. The context controls thumbnail generation, claim generator info, and other Builder settings. When a signer is configured in the context, `sign()` can be called without an explicit signer instance passed in as argument. ```py ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, - "claim_generator_info": {"name": "My App", "version": "1.0"} + "claim_generator_info": {"name": "an-application", "version": "0.1.0"} } }) @@ -553,7 +553,7 @@ with Reader("application/c2pa", archive_stream) as reader: transfer_ingredient_resources(reader, new_builder, selected) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - new_builder.sign_with_context("image/jpeg", source, dest) + new_builder.sign("image/jpeg", source, dest) ``` ### Overriding ingredient properties @@ -581,7 +581,7 @@ The C2PA specification allows **vendor-namespaced parameters** on actions using ```py manifest_json = { - "claim_generator_info": [{"name": "an-application", "version": "1.0"}], + "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ { "label": "c2pa.actions.v2", @@ -723,7 +723,7 @@ When context settings need to be applied (such as thumbnail configuration or cla ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, - "claim_generator_info": {"name": "My App", "version": "1.0"} + "claim_generator_info": {"name": "an-application", "version": "0.1.0"} } }) @@ -736,13 +736,13 @@ When context settings need to be applied (such as thumbnail configuration or cla transfer_ingredient_resources(reader, new_builder, selected) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - new_builder.sign_with_context("image/jpeg", source, dest) + new_builder.sign("image/jpeg", source, dest) ``` ### Merging multiple working stores > [!NOTE] -> The `Builder` construction and signing in the merge workflow also support `Context`. The caller can pass `context=ctx` to `Builder()` and use `sign_with_context()` instead of `sign()`. See [Context](../context.md) for details. +> The `Builder` construction and signing in the merge workflow also support `Context`. The caller can pass `context=ctx` to `Builder()` and call `sign()` without a signer argument when the context has one. See [Context](../context.md) for details. In some cases it is necessary to merge ingredients from multiple working stores (builder archives) into a single `Builder`. This should be a **fallback strategy**. The recommended practice is to maintain a single active working store and add ingredients incrementally (archived ingredient catalogs help with this). Merging is available when multiple working stores must be consolidated. diff --git a/docs/settings.md b/docs/settings.md index bb248c69..28bad61b 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -389,7 +389,7 @@ For example, for original digital capture (photos from camera): camera_ctx = Context.from_dict({ "builder": { "intent": {"Create": "digitalCapture"}, - "claim_generator_info": {"name": "Camera App", "version": "1.0"} + "claim_generator_info": {"name": "Camera App", "version": "0.1.0"} } }) ``` @@ -400,7 +400,7 @@ Or another example for editing existing content: editor_ctx = Context.from_dict({ "builder": { "intent": {"Edit": None}, - "claim_generator_info": {"name": "Photo Editor", "version": "2.0"} + "claim_generator_info": {"name": "Photo Editor", "version": "0.2.0"} } }) ``` diff --git a/docs/working-stores.md b/docs/working-stores.md index 1552345d..e8c0666a 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -131,7 +131,7 @@ For full details on `Context` and `Settings`, see [Using Context to configure th "manifests": { "urn:uuid:...": { "claim_generator": "MyApp/1.0", - "claim_generator_info": [{"name": "MyApp", "version": "1.0"}], + "claim_generator_info": [{"name": "MyApp", "version": "0.1.0"}], "title": "signed_image.jpg", "assertions": [ {"label": "c2pa.actions", "data": {"actions": [...]}}, @@ -323,7 +323,7 @@ except Exception as e: # The Builder must have been created with a Context that has a signer. try: with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - manifest_bytes = builder.sign_with_context("image/jpeg", src, dst) + manifest_bytes = builder.sign("image/jpeg", src, dst) print("Signed successfully!") except Exception as e: print(f"Signing failed: {e}") @@ -381,7 +381,7 @@ try: # 4. Create Builder with context and sign builder = Builder(manifest_json, context=ctx) with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign_with_context("image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) print("Asset signed with context settings") @@ -612,7 +612,7 @@ Use `with_archive()` when you need the restored builder to use specific settings ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, - "claim_generator_info": {"name": "My App", "version": "1.0"} + "claim_generator_info": {"name": "My App", "version": "0.1.0"} } }) @@ -716,7 +716,7 @@ with open("artwork_manifest.c2pa", "rb") as archive: # Sign using the context's signer with open("artwork.jpg", "rb") as src, open("signed_artwork.jpg", "w+b") as dst: - builder.sign_with_context("image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) ``` ## Embedded vs external manifests 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 4920322351a976bd0c5ef08a4fc089d7b2648677 Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Sun, 8 Mar 2026 17:22:10 -0700 Subject: [PATCH 69/84] 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 70/84] 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 71/84] 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 72/84] 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 73/84] 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 74/84] 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 75/84] 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 76/84] 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 77/84] 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 78/84] 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 e12750c5f0daf802c27ab4581ab0bde231642f7b Mon Sep 17 00:00:00 2001 From: Tania Mathern Date: Mon, 9 Mar 2026 15:46:16 -0700 Subject: [PATCH 79/84] fix: Only show APIs with cotnext --- docs/intents.md | 15 --- docs/selective-manifests.md | 195 +++++++++++++++--------------- docs/working-stores.md | 230 +++++++++--------------------------- 3 files changed, 155 insertions(+), 285 deletions(-) diff --git a/docs/intents.md b/docs/intents.md index 9fb90be3..e227a792 100644 --- a/docs/intents.md +++ b/docs/intents.md @@ -93,21 +93,6 @@ with Builder({}) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -### Using `load_settings` (deprecated) - -The legacy `load_settings` function can configure the intent for all subsequent `Builder` instances (thread-local configuration). This approach is deprecated in favor of context-based APIs: - -```py -from c2pa import load_settings, Builder - -# Deprecated: sets intent settings per thread -load_settings({"builder": {"intent": "edit"}}) - -with Builder({}) as builder: - with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: - builder.sign(signer, "image/jpeg", source, dest) -``` - ### Intent setting precedence When an intent is configured in multiple places , the most specific setting wins: diff --git a/docs/selective-manifests.md b/docs/selective-manifests.md index 7971e27a..5ffa140f 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -36,30 +36,7 @@ The fundamental workflow is: ## 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. - -`Reader` also accepts an optional `context` parameter. This is especially important for trust configuration, which controls which certificates are trusted when validating signatures. Without a context, `Reader` uses SDK defaults. See [Configuring Reader](../context.md#configuring-reader) and [Trust configuration](../context.md#trust-configuration) for details. - -```py -# Without context (SDK default trust settings) -with open("signed_asset.jpg", "rb") as source: - with Reader("image/jpeg", source) as reader: - # Get the full manifest store as JSON - manifest_store = json.loads(reader.json()) - - # Identify the active manifest, which is the current/latest manifest - active_label = manifest_store["active_manifest"] - manifest = manifest_store["manifests"][active_label] - - # Access specific parts - ingredients = manifest["ingredients"] - assertions = manifest["assertions"] - thumbnail_id = manifest["thumbnail"]["identifier"] -``` - -### With context (trust configuration) - -To control which certificates are trusted during validation, pass a `Context` with trust settings to `Reader`: +Use `Reader` with a `Context` to extract the manifest store JSON and any binary resources (thumbnails, manifest data). The source asset is never modified. The context is used for trust configuration (which certificates are trusted when validating signatures) and verification settings. See [Configuring Reader](../context.md#configuring-reader) and [Trust configuration](../context.md#trust-configuration) for details. ```py ctx = Context.from_dict({ @@ -95,7 +72,7 @@ with open("thumbnail.jpg", "wb") as f: ## Filtering into a new Builder > [!NOTE] -> All `Builder` and `Reader` examples on this page also work with a `Context`. For `Reader`, a context provides trust configuration and verification settings: `Reader(format, source, context=ctx)`. For `Builder`, a context provides custom settings (thumbnails, claim generator, intent): `Builder(manifest_json, context=ctx)`. When a signer is configured in the context, `builder.sign()` can be called without an signer instance as argument. See [Context](../context.md) for details. +> All examples on this page use `Context` with `Reader` and `Builder`. For `Reader`, the context provides trust configuration and verification settings: `Reader(format, source, context=ctx)`. For `Builder`, the context provides custom settings (thumbnails, claim generator, intent): `Builder(manifest_json, context=ctx)`. When a signer is configured in the context, `builder.sign()` is called without a signer instance. See [Context](../context.md) for details. Each example below creates a **new `Builder`** from filtered data. The original asset and its manifest store are never modified. @@ -123,8 +100,13 @@ This function is used throughout the examples below. ### Keep only specific ingredients ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + with open("signed_asset.jpg", "rb") as source: - with Reader("image/jpeg", source) as reader: + with Reader("image/jpeg", source, context=ctx) as reader: manifest_store = json.loads(reader.json()) active = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -138,20 +120,27 @@ with open("signed_asset.jpg", "rb") as source: with Builder({ "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "ingredients": kept, - }) as new_builder: + }, context=ctx) as new_builder: transfer_ingredient_resources(reader, new_builder, kept) - # Sign the new Builder into an output asset source.seek(0) with open("output.jpg", "wb") as dest: - new_builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + new_builder.sign("image/jpeg", source, dest) ``` ### Keep only specific assertions ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + with open("signed_asset.jpg", "rb") as source: - with Reader("image/jpeg", source) as reader: + with Reader("image/jpeg", source, context=ctx) as reader: manifest_store = json.loads(reader.json()) active = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -164,10 +153,13 @@ with open("signed_asset.jpg", "rb") as source: with Builder({ "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": kept, - }) as new_builder: + }, context=ctx) as new_builder: source.seek(0) with open("output.jpg", "wb") as dest: - new_builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + new_builder.sign("image/jpeg", source, dest) ``` ### Start fresh and preserve provenance @@ -200,10 +192,15 @@ flowchart TD ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + with Builder({ "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [], -}) as new_builder: +}, context=ctx) as new_builder: # Add the original as an ingredient to preserve provenance chain. # add_ingredient() stores the original's manifest as binary data inside # the ingredient, but does NOT copy the original's assertions. @@ -215,7 +212,10 @@ with Builder({ ) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - new_builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a Signer explicitly. + new_builder.sign("image/jpeg", source, dest) ``` ## Adding actions to a working store @@ -262,6 +262,11 @@ The SDK matches each value in `ingredientIds` against ingredients using this pri The `label` field on an ingredient is the **primary** linking key. Set a `label` on the ingredient and reference it in the action's `ingredientIds`. The label can be any string: it acts as a linking key between the ingredient and the action. ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + manifest_json = { "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ @@ -285,7 +290,7 @@ manifest_json = { ], } -with Builder(manifest_json) as builder: +with Builder(manifest_json, context=ctx) as builder: # The label on the ingredient matches the value in ingredientIds with open("photo.jpg", "rb") as photo: builder.add_ingredient( @@ -300,7 +305,10 @@ with Builder(manifest_json) as builder: ) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) ``` ##### Linking multiple ingredients @@ -311,6 +319,11 @@ When linking multiple ingredients, each ingredient needs a unique label. > The labels used for linking in the working store may not be the exact labels that appear in the signed manifest. They are indicators for the SDK to know which ingredient to link with which action. The SDK assigns final labels during signing. ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + manifest_json = { "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "assertions": [ @@ -337,7 +350,7 @@ manifest_json = { ], } -with Builder(manifest_json) as builder: +with Builder(manifest_json, context=ctx) as builder: # parentOf ingredient linked to c2pa.opened with open("original.jpg", "rb") as original: builder.add_ingredient( @@ -365,7 +378,10 @@ with Builder(manifest_json) as builder: ) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) ``` #### Linking with `instance_id` @@ -373,6 +389,11 @@ with Builder(manifest_json) as builder: When no `label` is set on an ingredient, the SDK matches `ingredientIds` against `instance_id`. ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + # instance_id is used as the linking identifier and must be unique instance_id = "xmp:iid:939a4c48-0dff-44ec-8f95-61f52b11618f" @@ -395,7 +416,7 @@ manifest_json = { ], } -with Builder(manifest_json) as builder: +with Builder(manifest_json, context=ctx) as builder: # No label set: instance_id is used as the linking key with open("source_photo.jpg", "rb") as photo: builder.add_ingredient( @@ -409,7 +430,10 @@ with Builder(manifest_json) as builder: ) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) ``` > [!NOTE] @@ -420,8 +444,10 @@ with Builder(manifest_json) as builder: After signing, `ingredientIds` is gone. The action's `parameters.ingredients[]` contains hashed JUMBF URIs pointing to ingredient assertions. To match an action to its ingredient, extract the label from the URL: ```py +ctx = Context.from_dict({"verify": {"verify_trust": True}}) + with open("signed_asset.jpg", "rb") as signed: - with Reader("image/jpeg", signed) as reader: + with Reader("image/jpeg", signed, context=ctx) as reader: manifest_store = json.loads(reader.json()) active_label = manifest_store["active_manifest"] manifest = manifest_store["manifests"][active_label] @@ -501,43 +527,17 @@ flowchart TD -```py -# Read from a catalog of archived ingredients -archive_stream.seek(0) -with Reader("application/c2pa", archive_stream) as reader: - manifest_store = json.loads(reader.json()) - active = manifest_store["manifests"][manifest_store["active_manifest"]] - - # Pick only the needed ingredients - selected = [ - ing for ing in active["ingredients"] - if ing["title"] in {"photo_1.jpg", "logo.png"} - ] - - with Builder({ - "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], - "ingredients": selected, - }) as new_builder: - transfer_ingredient_resources(reader, new_builder, selected) - - with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - new_builder.sign(signer, "image/jpeg", source, dest) -``` - -#### With context - -The same workflow can use a `Context` for custom settings. The context controls thumbnail generation, claim generator info, and other Builder settings. When a signer is configured in the context, `sign()` can be called without an explicit signer instance passed in as argument. - ```py ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, "claim_generator_info": {"name": "an-application", "version": "0.1.0"} - } + }, + "signer": signer, }) archive_stream.seek(0) -with Reader("application/c2pa", archive_stream) as reader: +with Reader("application/c2pa", archive_stream, context=ctx) as reader: manifest_store = json.loads(reader.json()) active = manifest_store["manifests"][manifest_store["active_manifest"]] @@ -553,6 +553,9 @@ with Reader("application/c2pa", archive_stream) as reader: transfer_ingredient_resources(reader, new_builder, selected) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. new_builder.sign("image/jpeg", source, dest) ``` @@ -663,9 +666,13 @@ flowchart TD **Step 1:** Build a working store and archive it: ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, +}) + with Builder({ "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], -}) as builder: +}, context=ctx) as builder: # Add ingredients to the working store with open("A.jpg", "rb") as ing_a: builder.add_ingredient( @@ -693,7 +700,7 @@ with Builder({ ```py archive_stream.seek(0) -with Reader("application/c2pa", archive_stream) as reader: +with Reader("application/c2pa", archive_stream, context=ctx) as reader: manifest_store = json.loads(reader.json()) active = manifest_store["manifests"][manifest_store["active_manifest"]] ingredients = active["ingredients"] @@ -702,29 +709,12 @@ with Reader("application/c2pa", archive_stream) as reader: **Step 3:** Create a new Builder with the extracted ingredients: ```py - # Pick the desired ingredients - selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] - - with Builder({ - "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], - "ingredients": selected, - }) as new_builder: - transfer_ingredient_resources(reader, new_builder, selected) - - with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - new_builder.sign(signer, "image/jpeg", source, dest) -``` - -#### Step 3 with context - -When context settings need to be applied (such as thumbnail configuration or claim generator info), the builder can be created with a `Context`. If the archive was previously saved with `to_archive()`, it can be loaded with `with_archive()` to preserve those settings. - -```py - ctx = Context.from_dict({ + sign_ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, "claim_generator_info": {"name": "an-application", "version": "0.1.0"} - } + }, + "signer": signer, }) selected = [ing for ing in ingredients if ing["title"] == "A.jpg"] @@ -732,10 +722,13 @@ When context settings need to be applied (such as thumbnail configuration or cla with Builder({ "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "ingredients": selected, - }, context=ctx) as new_builder: + }, context=sign_ctx) as new_builder: transfer_ingredient_resources(reader, new_builder, selected) with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. new_builder.sign("image/jpeg", source, dest) ``` @@ -749,6 +742,11 @@ In some cases it is necessary to merge ingredients from multiple working stores When merging from multiple sources, resource identifier URIs can collide. Rename identifiers with a unique suffix when needed. Use two passes: (1) collect ingredients with collision handling, build the manifest, create the builder; (2) re-read each archive and transfer resources (use original ID for `resource_to_stream()`, renamed ID for `add_resource()` when collisions occurred). ```py +ctx = Context.from_dict({ + "builder": {"claim_generator_info": {"name": "an-application", "version": "0.1.0"}}, + "signer": signer, +}) + used_ids: set[str] = set() suffix_counter = 0 all_ingredients = [] @@ -757,7 +755,7 @@ archive_ingredient_counts = [] # Pass 1: Collect ingredients, renaming IDs on collision for archive_stream in archives: archive_stream.seek(0) - with Reader("application/c2pa", archive_stream) as reader: + with Reader("application/c2pa", archive_stream, context=ctx) as reader: manifest_store = json.loads(reader.json()) active = manifest_store["manifests"][manifest_store["active_manifest"]] ingredients = active["ingredients"] @@ -778,12 +776,12 @@ for archive_stream in archives: with Builder({ "claim_generator_info": [{"name": "an-application", "version": "0.1.0"}], "ingredients": all_ingredients, -}) as builder: +}, context=ctx) as builder: # Pass 2: Transfer resources (match by ingredient index) offset = 0 for archive_stream, count in zip(archives, archive_ingredient_counts): archive_stream.seek(0) - with Reader("application/c2pa", archive_stream) as reader: + with Reader("application/c2pa", archive_stream, context=ctx) as reader: manifest_store = json.loads(reader.json()) active = manifest_store["manifests"][manifest_store["active_manifest"]] originals = active["ingredients"] @@ -800,5 +798,8 @@ with Builder({ offset += count with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: - builder.sign(signer, "image/jpeg", source, dest) + # In this example, the Signer is on the context. + # A Signer can also be passed as first argument to + # configure a dedicated Signer explicitly. + builder.sign("image/jpeg", source, dest) ``` diff --git a/docs/working-stores.md b/docs/working-stores.md index e8c0666a..1921b809 100644 --- a/docs/working-stores.md +++ b/docs/working-stores.md @@ -6,7 +6,7 @@ This table summarizes the fundamental entities that you work with when using the |--------|-------------|-------------|-------------| | [**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()` | +| [**Archive**](#archive) | Serialized working store | `.c2pa` file/stream | `Builder.to_archive()` / `Builder.with_archive()` | | [**Resources**](#working-with-resources) | Binary assets referenced by manifest assertions, such as thumbnails or ingredient thumbnails. | In manifest. | `Builder.add_resource()` / `Reader.resource_to_stream()` | | [**Ingredients**](#working-with-ingredients) | Source materials used to create an asset. | In manifest. | `Builder.add_ingredient()` | @@ -23,7 +23,7 @@ graph TD A[Working Store
Builder object] -->|sign| MS A -->|to_archive| C[C2PA Archive
.c2pa file] - C -->|from_archive or with_archive| A + C -->|with_archive| A ``` ## Key entities @@ -68,7 +68,7 @@ A _C2PA archive_ (or just _archive_) contains the serialized bytes of a working **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()`. +- Save an archive by using `Builder.to_archive()` and restore a full working store from an archive by using `Builder.with_archive()` (with a Builder created from a Context). - Useful for separating manifest preparation ("work in progress") from final signing. For more information, see [Working with archives](#working-with-archives). @@ -79,21 +79,9 @@ Use the `Reader` class to read manifest stores from signed assets. ### Reading from a file -```py -from c2pa import Reader - -try: - # Without Context - reader = Reader("signed_image.jpg") - manifest_store_json = reader.json() -except Exception as e: - print(f"C2PA Error: {e}") -``` - ```py from c2pa import Context, Reader -# With Context (custom validation and trust settings) ctx = Context.from_dict({ "verify": { "verify_after_sign": True @@ -106,14 +94,6 @@ manifest_store_json = reader.json() ### Reading from a stream ```py -# Without Context -with open("signed_image.jpg", "rb") as stream: - reader = Reader("image/jpeg", stream) - manifest_json = reader.json() -``` - -```py -# With Context with open("signed_image.jpg", "rb") as stream: reader = Reader("image/jpeg", stream, context=ctx) manifest_json = reader.json() @@ -155,7 +135,7 @@ For full details on `Context` and `Settings`, see [Using Context to configure th The SDK also provides convenience methods to avoid manual JSON parsing: ```py -reader = Reader("signed_image.jpg") +reader = Reader("signed_image.jpg", context=ctx) # Get the active manifest directly as a dict active = reader.get_active_manifest() @@ -178,9 +158,8 @@ A **working store** is represented by a `Builder` object. It contains "live" man ```py import json -from c2pa import Builder +from c2pa import Builder, Context -# Without Context manifest_json = json.dumps({ "claim_generator_info": [{ "name": "example-app", @@ -190,13 +169,6 @@ manifest_json = json.dumps({ "assertions": [] }) -builder = Builder(manifest_json) -``` - -```py -from c2pa import Builder, Context - -# With Context (custom settings applied) ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": True} @@ -241,7 +213,7 @@ builder.set_remote_url("https://example.com/manifests/") When you sign an asset, the working store (Builder) becomes a manifest store embedded in the output: ```py -from c2pa import Signer, C2paSignerInfo, C2paSigningAlg +from c2pa import Signer, C2paSignerInfo, C2paSigningAlg, Context # Create a signer signer_info = C2paSignerInfo( @@ -252,13 +224,19 @@ signer_info = C2paSignerInfo( ) signer = Signer.from_info(signer_info) +ctx = Context.from_dict({ + "builder": {"thumbnail": {"enabled": True}}, + "signer": signer, +}) +builder = Builder(manifest_json, context=ctx) + # Sign the asset - working store becomes a manifest store with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) # Now "signed.jpg" contains a manifest store # You can read it back with Reader -reader = Reader("signed.jpg") +reader = Reader("signed.jpg", context=ctx) manifest_store_json = reader.json() ``` @@ -267,12 +245,6 @@ manifest_store_json = reader.json() ### Creating a Builder (working store) ```py -# Without Context -builder = Builder(manifest_json) -``` - -```py -# With Context ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": True} @@ -308,19 +280,9 @@ signer = Signer.from_info(signer_info) ### Signing an asset -```py -# Without Context (explicit signer) -try: - 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}") -``` +The Builder must be created with a Context that includes a signer. Then call `sign()` without passing a signer argument: ```py -# With Context (signer configured in context) -# The Builder must have been created with a Context that has a signer. try: with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: manifest_bytes = builder.sign("image/jpeg", src, dst) @@ -334,16 +296,10 @@ except Exception as e: You can also sign using file paths directly: ```py -# Without Context (explicit signer) -manifest_bytes = builder.sign_file("source.jpg", "signed.jpg", signer) -``` - -```py -# With Context (uses the context's signer when no signer argument is passed) manifest_bytes = builder.sign_file("source.jpg", "signed.jpg") ``` -### Complete example with Context +### Complete example This code combines the above examples to create, sign, and read a manifest. @@ -393,52 +349,6 @@ except Exception as e: print(f"Error: {e}") ``` -### Complete example (legacy, without Context) - -This code combines the 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. @@ -469,7 +379,7 @@ To extract a resource, you need its JUMBF URI from the manifest store: ```py import json -reader = Reader("signed_image.jpg") +reader = Reader("signed_image.jpg", context=ctx) manifest_store = json.loads(reader.json()) # Get active manifest @@ -493,7 +403,8 @@ if "thumbnail" in manifest: 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) +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": True}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) # Add resource from a stream with open("thumbnail.jpg", "rb") as thumb: @@ -501,7 +412,7 @@ with open("thumbnail.jpg", "rb") as 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) + builder.sign("image/jpeg", src, dst) ``` ## Working with ingredients @@ -521,7 +432,8 @@ Ingredients represent source materials used to create an asset, preserving the p When creating a manifest, add ingredients to preserve the provenance chain: ```py -builder = Builder(manifest_json) +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) # Define ingredient metadata ingredient_json = json.dumps({ @@ -535,7 +447,7 @@ with open("source.jpg", "rb") as ingredient: # Sign: ingredients become part of the manifest store with open("new_asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) ``` ### Ingredient relationships @@ -548,7 +460,7 @@ Specify the relationship between the ingredient and the current 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: +Example with explicit relationship (builder is created with a Context as in the examples above): ```py ingredient_json = json.dumps({ @@ -578,8 +490,8 @@ The default binary format of an archive is the **C2PA JUMBF binary format** (`ap ```py import io -# Create and configure a working store -builder = Builder(manifest_json) +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}}) +builder = Builder(manifest_json, context=ctx) with open("thumbnail.jpg", "rb") as thumb: builder.add_resource("thumbnail", thumb) with open("source.jpg", "rb") as ingredient: @@ -608,12 +520,13 @@ There are two ways to load a working store from an archive. They differ in wheth Use `with_archive()` when you need the restored builder to use specific settings that you put on the Builder on instanciation by using a context as parameter of the Builder constructor. Create a `Builder` with a `Context` first, then call `with_archive()` to load the archived manifest definition into it. The archive replaces only the manifest definition; the builder's context and settings are preserved. ```py -# Create context with custom settings +# Create context with custom settings and signer ctx = Context.from_dict({ "builder": { "thumbnail": {"enabled": False}, "claim_generator_info": {"name": "My App", "version": "0.1.0"} - } + }, + "signer": signer, }) # Create builder with context, then load archive into it @@ -624,38 +537,21 @@ with open("manifest.c2pa", "rb") as archive: # The builder has the archived manifest definition # but keeps the context settings (no thumbnails, custom claim generator) with open("asset.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) ``` > [!IMPORTANT] > `with_archive()` replaces the builder's manifest definition with the one from the archive. Any definition passed to `Builder()` on instanciation is discarded. An empty dict `{}` is idiomatic for the initial definition when you plan to load an archive immediately after. -#### `from_archive()` (legacy) - -Use `from_archive()` for quick one-off operations where you don't need custom settings. It creates a **context-free** builder: no `Context` is attached, so all settings revert to SDK defaults. - -```py -# Restore from stream — no context, SDK defaults apply -with open("manifest.c2pa", "rb") as archive: - builder = Builder.from_archive(archive) - -# Sign with SDK default settings -with open("asset.jpg", "rb") as src, open("signed_asset.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` +#### Choosing how to restore from an archive -> [!WARNING] -> `from_archive()` does not accept a `context` parameter. Any settings that were active when the archive was created are not stored in the archive and are therefore lost. For example, if the original builder had thumbnails disabled via a `Context`, the builder returned by `from_archive()` will generate thumbnails using SDK defaults. Use `with_archive()` instead when you need to preserve settings on the Builder instance you are loading an archive into. +Use `with_archive()` so that the restored builder uses your `Context` (custom settings and signer). The archive carries only the manifest definition; it does not store context or settings. By creating a `Builder` with a `Context` and then calling `with_archive()`, you ensure the restored builder keeps your settings. -#### Choosing between `with_archive()` and `from_archive()` - -| | `with_archive()` | `from_archive()` | -|---|---|---| -| **Context preserved** | Yes — settings come from the builder's context | No — SDK defaults apply | -| **Usage pattern** | `Builder({}, context=ctx).with_archive(stream)` | `Builder.from_archive(stream)` | -| **When to use** | Production workflows, custom settings needed | Quick prototyping, SDK defaults are acceptable | -| **What the archive carries** | Only the manifest definition | Only the manifest definition | -| **What it does NOT carry** | Settings, signer, context | Settings, signer, context | +| | `with_archive()` | +|---|---| +| **Context preserved** | Yes — settings come from the builder's context | +| **Usage pattern** | `Builder({}, context=ctx).with_archive(stream)` | +| **What the archive carries** | Only the manifest definition (not settings, signer, or context) | ### Two-phase workflow example @@ -667,10 +563,12 @@ This step prepares the manifest on a Builder, and archives it into a Builder arc import io import json +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}}) manifest_json = json.dumps({ "title": "Artwork draft", "assertions": [] }) +builder = Builder(manifest_json, context=ctx) with open("thumb.jpg", "rb") as thumb: builder.add_resource("thumbnail", thumb) @@ -688,27 +586,13 @@ 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") -``` - -#### Phase 2 alternative: Sign with context - -In this step, after reloading the working store into a Builder instance configured with a context, settings on the Builder context can configure signing settings (e.g. thumbnails on/off). +Restore the working store with a Context so that settings (e.g. thumbnails on/off) and the signer are applied: ```py -# Restore the working store with context settings preserved ctx = Context.from_dict({ - "builder": {"thumbnail": {"enabled": False}} -}, signer=signer) + "builder": {"thumbnail": {"enabled": False}}, + "signer": signer, +}) with open("artwork_manifest.c2pa", "rb") as archive: builder = Builder({}, context=ctx) @@ -726,16 +610,15 @@ By default, manifest stores are **embedded** directly into the asset file. You c ### Default: embedded manifest stores ```py -builder = Builder(manifest_json) -# A builder object in this case can also be created -# using an additional Context parameter for settings propagation +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": True}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) # Default behavior: manifest store is embedded in the output with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) # Read it back — manifest store is embedded -reader = Reader("signed.jpg") +reader = Reader("signed.jpg", context=ctx) ``` ### External manifest stores (no embed) @@ -743,15 +626,14 @@ reader = Reader("signed.jpg") Prevent embedding the manifest store in the asset: ```py -builder = Builder(manifest_json) -# A builder object in this case can also be created -# using an additional Context parameter for settings propagation +ctx = Context.from_dict({"signer": signer}) +builder = Builder(manifest_json, context=ctx) builder.set_no_embed() # Don't embed the manifest store # Sign: manifest store is NOT embedded, manifest bytes are returned with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - manifest_bytes = builder.sign(signer, "image/jpeg", src, dst) + manifest_bytes = builder.sign("image/jpeg", src, dst) # manifest_bytes contains the manifest store # Save it separately (as a sidecar file or upload to server) @@ -766,15 +648,14 @@ print("Manifest store saved externally to output.c2pa") Reference a manifest store stored at a remote URL: ```py -builder = Builder(manifest_json) -# A builder object in this case can also be created -# using an additional Context parameter for settings propagation +ctx = Context.from_dict({"signer": signer}) +builder = Builder(manifest_json, context=ctx) builder.set_remote_url("https://example.com/manifests/") # The asset will contain a reference to the remote manifest store with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) + builder.sign("image/jpeg", src, dst) ``` ## Best practices @@ -802,6 +683,9 @@ reader = Reader("asset.jpg", context=ctx) Add ingredients to your manifests to maintain a provenance chain: ```py +ctx = Context.from_dict({"builder": {"claim_generator_info": {"name": "my-app", "version": "0.1.0"}}, "signer": signer}) +builder = Builder(manifest_json, context=ctx) + ingredient_json = json.dumps({ "title": "Original source", "relationship": "parentOf" @@ -811,7 +695,7 @@ 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) + builder.sign("image/jpeg", src, dst) ``` ## Additional resources From 557b266bf776ba5e1bcefd5556b6640d0c8e5025 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:17:34 -0700 Subject: [PATCH 80/84] fix: Merge commit Removed the 'with_archive' method from the Builder class, which was responsible for loading an archive into the builder and replacing its manifest definition. --- src/c2pa/c2pa.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1709f961..304347a9 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3485,44 +3485,6 @@ def with_archive(self, stream: Any) -> 'Builder': return self - def with_archive(self, stream: Any) -> 'Builder': - """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 - - 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, From f8b166a867489829061efe404b623f40fa342ae1 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:21:34 -0700 Subject: [PATCH 81/84] fix: Merge commit 2 --- src/c2pa/c2pa.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 304347a9..0ba07318 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -2116,6 +2116,13 @@ def _validate_and_encode_format( class Reader(ManagedResource): """High-level wrapper for C2PA Reader operations. + Example: + ``` + with Reader("image/jpeg", output) as reader: + manifest_json = reader.json() + ``` + Where `output` is either an in-memory stream or an opened file. + """ # Supported mimetypes cache From b91a72a3dddbd033def5023bc12009b315a1cbc0 Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Fri, 13 Mar 2026 15:59:18 -0700 Subject: [PATCH 82/84] Add combined context and settings doc --- docs/context-settings.md | 863 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 863 insertions(+) create mode 100644 docs/context-settings.md diff --git a/docs/context-settings.md b/docs/context-settings.md new file mode 100644 index 00000000..7782ae56 --- /dev/null +++ b/docs/context-settings.md @@ -0,0 +1,863 @@ +# Context and settings + +This guide shows you how to configure the C2PA Python SDK using the `Context` API with declarative settings in JSON format. + +## Overview + +The `Context` class encapsulates configuration for: + +- **Settings**: Verification options, builder behavior, trust configuration, thumbnail settings, and more. +- **Signer configuration**: Optional signer credentials stored in the `Context` for reuse. +- **State isolation**: Each `Context` is independent, allowing different configurations to coexist in the same application. + +`Context` replaces the deprecated global `load_settings()` function with explicit, isolated configuration: + +- **Makes dependencies explicit**: Configuration is passed directly to `Reader` and `Builder`, not hidden in global state. +- **Enables multiple configurations**: Run different configurations simultaneously (for example, one for development with test certificates, another for production with strict validation). +- **Eliminates global state**: Each `Reader` and `Builder` gets its configuration from the `Context` you pass, avoiding subtle bugs from shared state. +- **Simplifies testing**: Create isolated configurations for tests without worrying about cleanup or interference. + +> [!NOTE] +> The deprecated `load_settings()` function still works for backward compatibility, but you are encouraged to migrate your code to use `Context`. See [Migrating from load_settings](#migrating-from-load_settings). + +### 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) Settings + +close() + +is_valid bool + } + + class ContextProvider { + <> + +is_valid bool* + +execution_context* + } + + class Context { + +from_json(json_str, signer) Context$ + +from_dict(config, signer) Context$ + +has_signer bool + +is_valid bool + +close() + } + + class Reader { + +get_supported_mime_types() list~str~$ + +try_create(format_or_path, stream, manifest_data, context) Reader | None$ + +json() str + +detailed_json() str + +get_active_manifest() dict | None + +get_manifest(label) dict + +get_validation_state() str | None + +get_validation_results() dict | None + +resource_to_stream(uri, stream) int + +is_embedded() bool + +get_remote_url() str | None + +close() + } + + class Builder { + +from_json(manifest_json, context) Builder$ + +from_archive(stream) Builder$ + +get_supported_mime_types() list~str~$ + +set_no_embed() + +set_remote_url(url) + +set_intent(intent, digital_source_type) + +add_resource(uri, stream) + +add_ingredient(json, format, source) + +add_action(action_json) + +to_archive(stream) + +with_archive(stream) Builder + +sign(signer, format, source, dest) bytes + +sign(format, source, dest) bytes + +sign_file(source_path, dest_path, signer) bytes + +close() + } + + class Signer { + +from_info(signer_info) Signer$ + +from_callback(callback, alg, certs, tsa_url) Signer$ + +reserve_size() int + +close() + } + + class C2paSignerInfo { + <> + +alg + +sign_cert + +private_key + +ta_url + } + + class C2paSigningAlg { + <> + ES256 + ES384 + ES512 + PS256 + PS384 + PS512 + ED25519 + } + + class C2paBuilderIntent { + <> + CREATE + EDIT + UPDATE + } + + class C2paDigitalSourceType { + <> + DIGITAL_CAPTURE + DIGITAL_CREATION + TRAINED_ALGORITHMIC_MEDIA + ... + } + + class C2paError { + <> + +message str + } + + class C2paError_Subtypes { + <> + ManifestNotFound + NotSupported + Json + Io + Verify + Signature + ... + } + + ContextProvider <|-- Context : extends + Settings --> Context : optional input + Signer --> Context : optional, consumed + C2paSignerInfo --> Signer : creates via from_info + C2paSigningAlg --> C2paSignerInfo : alg field + C2paSigningAlg --> Signer : from_callback alg + Context --> Reader : context= + Context --> Builder : context= + Signer --> Builder : sign(signer) + C2paBuilderIntent --> Builder : set_intent + C2paDigitalSourceType --> Builder : set_intent + C2paError --> C2paError_Subtypes : subclasses +``` + +## Quick start + +### Using SDK default settings + +Without additional parameters, `Context` uses [SDK default settings](#default-configuration). + +**When to use:** For quick prototyping, or when SDK defaults are acceptable (verification enabled, thumbnails enabled at 1024px, and so on). + +```py +from c2pa import Context + +ctx = Context() # Uses SDK defaults +``` + +### From a JSON string + +**When to use:** For simple configuration that doesn't need to be shared across the codebase. + +```py +ctx = Context.from_json('''{ + "verify": {"verify_after_sign": true}, + "builder": { + "thumbnail": {"enabled": false}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}''') +``` + +### From a dictionary + +**When to use:** When you want to build configuration programmatically using native Python data structures. + +```py +ctx = Context.from_dict({ + "verify": {"verify_after_sign": True}, + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) +``` + +### From a Settings object + +**When to use:** For configuration that needs runtime logic (conditional settings based on environment, or incremental/layered configuration). + +```py +from c2pa import Settings, Context + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false") +settings.set("verify.verify_after_sign", "true") +settings.update({ + "builder": { + "claim_generator_info": {"name": "An app", "version": "0.1.0"} + } +}) + +ctx = Context(settings) +``` + +To load settings from a file: + +```py +import json + +with open("config/settings.json", "r") as f: + settings = Settings.from_json(f.read()) + +ctx = Context(settings) +``` + +## Settings API + +Create and configure settings independently of a `Context`: + +| Method | Description | +|--------|-------------| +| `Settings()` | Create default settings with SDK defaults. | +| `Settings.from_json(json_str)` | Create settings from a JSON string. Raises `C2paError` on parse error. | +| `Settings.from_dict(config)` | Create settings from a Python dictionary. | +| `set(path, value)` | Set a single value by dot-separated path (for example, `"verify.verify_after_sign"`). Value must be a string. Returns `self` for chaining. | +| `update(data)` | Merge configuration into existing settings. `data` can be a JSON string or a dict. Later keys override earlier ones. | + +The `set()` and `update()` methods can be chained for incremental configuration. When using multiple configuration methods, later calls override earlier ones (last call wins when the same setting is set multiple times). + +```py +from c2pa import Settings + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false").set("verify.verify_after_sign", "true") + +settings.update({"verify": {"remote_manifest_fetch": True}}) +``` + +## Using Context + +### With Reader + +`Reader` uses `Context` to control how it validates manifests and handles remote resources: + +- **Verification behavior**: Whether to verify after reading, check trust, and so on. +- **Trust configuration**: Which certificates to trust when validating signatures. +- **Network access**: Whether to fetch remote manifests or OCSP responses. + +> [!IMPORTANT] +> `Context` is used only at construction time. `Reader` copies the configuration it needs internally, so the `Context` does not need to outlive the `Reader`. A single `Context` can be reused for multiple `Reader` instances. + +```py +ctx = Context.from_dict({"verify": {"remote_manifest_fetch": False}}) +reader = Reader("image.jpg", context=ctx) +print(reader.json()) +``` + +Reading from a stream: + +```py +with open("image.jpg", "rb") as stream: + reader = Reader("image/jpeg", stream, context=ctx) + print(reader.json()) +``` + +### With Builder + +`Builder` uses `Context` to control how it creates and signs C2PA manifests. The `Context` affects: + +- **Claim generator information**: Application name, version, and metadata embedded in the manifest. +- **Thumbnail generation**: Whether to create thumbnails, and their size, quality, and format. +- **Action tracking**: Auto-generation of actions like `c2pa.created`, `c2pa.opened`, `c2pa.placed`. +- **Intent**: The purpose of the claim (create, edit, or update). +- **Verification after signing**: Whether to validate the manifest immediately after signing. +- **Signer configuration** (optional): Credentials stored in the context for reuse. + +> [!IMPORTANT] +> `Context` is used only when constructing the `Builder`. The `Builder` copies the configuration it needs internally, so the `Context` does not need to outlive the `Builder`. A single `Context` can be reused for multiple `Builder` instances. + +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "An app", "version": "0.1.0"}, + "intent": {"Create": "digitalCapture"} + } +}) + +builder = Builder(manifest_json, ctx) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +#### Context and archives + +Archives (`.c2pa` files) store only the manifest definition. They do **not** store settings or context: + +- **`Builder.from_archive(stream)`** creates a context-free builder. All settings revert to SDK defaults regardless of what context the original builder had. +- **`Builder({}, ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved. + +Use `with_archive()` when your workflow depends on specific settings (thumbnails, claim generator, intent, and so on). Use `from_archive()` only for quick prototyping where SDK defaults are acceptable. + +```py +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False}, + "claim_generator_info": {"name": "My App", "version": "0.1.0"} + } +}) + +with open("manifest.c2pa", "rb") as archive: + builder = Builder({}, ctx) + builder.with_archive(archive) + # builder now has the archived definition + context settings +``` + +For more details, see [Working with archives](working-stores.md#working-with-archives). + +## Settings reference + +### Structure + +The Settings JSON has this top-level structure: + +```json +{ + "version": 1, + "trust": { ... }, + "cawg_trust": { ... }, + "core": { ... }, + "verify": { ... }, + "builder": { ... }, + "signer": { ... }, + "cawg_x509_signer": { ... } +} +``` + +The settings format is **JSON** only. Pass JSON strings to `Settings.from_json()` or `Context.from_json()`, and dictionaries to `Settings.from_dict()` or `Context.from_dict()`. The `from_dict()` methods convert Python dictionaries to a format compatible with the underlying native libraries. + +Notes: +- All properties are optional. If you don't specify a value, the SDK uses the default value. +- If you specify a value of `null` (or `None` in a dict), the property is explicitly set to `null`, not the default. This distinction is important when you want to override a default behavior. +- For Boolean values, use JSON Booleans `true`/`false` in JSON strings, or Python `True`/`False` in dicts. + +The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, and so on). For a complete reference to all properties, see the [SDK object reference - Settings](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema). + +| Property | Description | +|----------|-------------| +| `version` | Settings format version (integer). The default and only supported value is 1. | +| [`builder`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#buildersettings) | Configuration for Builder. | +| [`cawg_trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for CAWG trust lists. | +| [`cawg_x509_signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the CAWG x.509 signer. | +| [`core`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#core) | Configuration for core features. | +| [`signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the base C2PA signer. | +| [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for C2PA trust lists. | +| [`verify`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#verify) | Configuration for verification (validation). | + +### Default configuration + +```json +{ + "version": 1, + "builder": { + "claim_generator_info": null, + "created_assertion_labels": null, + "certificate_status_fetch": null, + "certificate_status_should_override": null, + "generate_c2pa_archive": true, + "intent": null, + "actions": { + "all_actions_included": null, + "templates": null, + "actions": null, + "auto_created_action": { + "enabled": true, + "source_type": "empty" + }, + "auto_opened_action": { + "enabled": true, + "source_type": null + }, + "auto_placed_action": { + "enabled": true, + "source_type": null + } + }, + "thumbnail": { + "enabled": true, + "ignore_errors": true, + "long_edge": 1024, + "format": null, + "prefer_smallest_format": true, + "quality": "medium" + } + }, + "cawg_trust": { + "verify_trust_list": true, + "user_anchors": null, + "trust_anchors": null, + "trust_config": null, + "allowed_list": null + }, + "cawg_x509_signer": null, + "core": { + "merkle_tree_chunk_size_in_kb": null, + "merkle_tree_max_proofs": 5, + "backing_store_memory_threshold_in_mb": 512, + "decode_identity_assertions": true, + "allowed_network_hosts": null + }, + "signer": null, + "trust": { + "user_anchors": null, + "trust_anchors": null, + "trust_config": null, + "allowed_list": null + }, + "verify": { + "verify_after_reading": true, + "verify_after_sign": true, + "verify_trust": true, + "verify_timestamp_trust": true, + "ocsp_fetch": false, + "remote_manifest_fetch": true, + "skip_ingredient_conflict_resolution": false, + "strict_v1_validation": false + } +} +``` + +### Trust + +The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. + +| Property | Type | Description | +|----------|------|-------------| +| `trust.user_anchors` | string | Additional user-provided root certificates (PEM format). Adds custom certificate authorities without replacing the SDK's built-in trust anchors. Recommended for development. | +| `trust.trust_anchors` | string | Default trust anchor root certificates (PEM format). **Replaces** the SDK's built-in trust anchors entirely. | +| `trust.trust_config` | string | Allowed Extended Key Usage (EKU) OIDs. Controls which certificate purposes are accepted (for example, `1.3.6.1.4.1.311.76.59.1.9` for document signing). | +| `trust.allowed_list` | string | Explicitly allowed certificates (PEM format). Trusted regardless of chain validation. Use for development/testing to bypass chain validation. | + +Use `user_anchors` to add your test root CA without replacing the SDK's default trust store: + +```py +with open("test-ca.pem", "r") as f: + test_root_ca = f.read() + +ctx = Context.from_dict({"trust": {"user_anchors": test_root_ca}}) +reader = Reader("signed_asset.jpg", context=ctx) +``` + +Use `allowed_list` to bypass chain validation entirely for quick testing: + +```py +with open("test_cert.pem", "r") as f: + test_cert = f.read() + +ctx = Context.from_dict({"trust": {"allowed_list": test_cert}}) +reader = Reader("signed_asset.jpg", context=ctx) +``` + +### CAWG trust + +The `cawg_trust` properties configure CAWG (Creator Assertions Working Group) validation of identity assertions in C2PA manifests. It has the same properties as [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust). + +> [!NOTE] +> CAWG trust settings are only used when processing identity assertions with X.509 certificates. If your workflow doesn't use CAWG identity assertions, these settings have no effect. + +### Core + +The [`core` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#core) specify core SDK behavior and performance tuning options. + +Common use cases: + +- **Performance tuning for large files**: Set `core.backing_store_memory_threshold_in_mb` to `2048` or higher when processing large video files with sufficient RAM. +- **Restricted network environments**: Set `core.allowed_network_hosts` to limit which domains the SDK can contact. + +### Verify + +The [`verify` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#verify) control how the SDK validates C2PA manifests, affecting both reading existing manifests and verifying newly signed content. + +The following properties default to `true` (verification enabled): + +- `verify_after_reading` - Automatically verify manifests when reading assets. Disable only if you want to manually control verification timing. +- `verify_after_sign` - Automatically verify manifests after signing. Recommended to keep enabled to catch signing errors immediately. +- `verify_trust` - Verify signing certificates against configured trust anchors. WARNING: Disabling makes verification non-compliant. +- `verify_timestamp_trust` - Verify timestamp authority (TSA) certificates. WARNING: Disabling makes verification non-compliant. +- `remote_manifest_fetch` - Fetch remote manifests referenced in the asset. Disable in offline or air-gapped environments. + +> [!WARNING] +> Disabling verification options can make verification non-compliant with the C2PA specification. Only modify these settings in controlled environments or when you have specific requirements. + +### Builder + +The [`builder` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#buildersettings) control how the SDK creates and embeds C2PA manifests in assets. + +#### Claim generator information + +The `claim_generator_info` object identifies your application in the C2PA manifest: + +- `name` (string, required): Your application name (for example, `"My Photo Editor"`) +- `version` (string, recommended): Application version (for example, `"2.1.0"`) +- `icon` (string, optional): Icon in C2PA format +- `operating_system` (string, optional): OS identifier, or `"auto"` to auto-detect + +#### Thumbnail settings + +The [`builder.thumbnail`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#thumbnailsettings) properties control automatic thumbnail generation: + +```py +# Disable thumbnails for batch processing +ctx = Context.from_dict({ + "builder": { + "thumbnail": {"enabled": False} + } +}) + +# Customize for mobile bandwidth +ctx = Context.from_dict({ + "builder": { + "thumbnail": { + "enabled": True, + "long_edge": 512, + "quality": "low", + "prefer_smallest_format": True + } + } +}) +``` + +#### Action tracking + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.actions.auto_created_action.enabled` | Boolean | Automatically add a `c2pa.created` action when creating new content. | `true` | +| `builder.actions.auto_created_action.source_type` | string | Source type for the created action. Usually `"empty"` for new content. | `"empty"` | +| `builder.actions.auto_opened_action.enabled` | Boolean | Automatically add a `c2pa.opened` action when opening/reading content. | `true` | +| `builder.actions.auto_placed_action.enabled` | Boolean | Automatically add a `c2pa.placed` action when placing content as an ingredient. | `true` | + +#### Intent + +The `builder.intent` property describes the purpose of the claim: `{"Create": "digitalCapture"}`, `{"Edit": null}`, or `{"Update": null}`. Defaults to `null`. + +#### Other builder settings + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `builder.generate_c2pa_archive` | Boolean | Generate content in C2PA archive format. Keep enabled for standard C2PA compliance. | `true` | + +### Signer + +The [`signer` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings) configure the primary C2PA signer. Set to `null` if you provide the signer at runtime. Configure as either a **local** or **remote** signer: + +- **Local signer**: For local certificate and private key access. See [signer.local](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerlocal) in the SDK object reference. +- **Remote signer**: For private keys on a secure signing service (HSM, cloud KMS, and so on). See [signer.remote](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signerremote) in the SDK object reference. + +For details on configuring and using signers, see [Configuring signers](#configuring-signers). + +### CAWG X.509 signer + +The `cawg_x509_signer` property configures signing of identity assertions. It has the same structure as `signer` (local or remote). When both `signer` and `cawg_x509_signer` are configured, the SDK uses a dual signer: + +- Main claim signature comes from `signer` +- Identity assertions are signed with `cawg_x509_signer` + +## Configuration examples + +### Minimal configuration + +```py +ctx = Context.from_dict({ + "builder": { + "claim_generator_info": {"name": "My app", "version": "0.1"}, + "intent": {"Create": "digitalCapture"} + } +}) +``` + +### Development environment with test certificates + +During development, you often need to trust self-signed or custom CA certificates with looser verification: + +```py +with open("test-ca.pem", "r") as f: + test_ca = f.read() + +ctx = Context.from_dict({ + "trust": {"user_anchors": test_ca}, + "verify": { + "verify_after_reading": True, + "verify_after_sign": True, + "remote_manifest_fetch": False, + "ocsp_fetch": False + }, + "builder": { + "claim_generator_info": {"name": "Dev Build", "version": "dev"}, + "thumbnail": {"enabled": False} + } +}) +``` + +### Offline operation + +Disable all network-dependent features for air-gapped environments: + +```py +ctx = Context.from_dict({ + "verify": { + "remote_manifest_fetch": False, + "ocsp_fetch": False + } +}) + +reader = Reader("local_asset.jpg", context=ctx) +``` + +### Strict validation + +For certification or compliance testing, enable strict validation: + +```py +ctx = Context.from_dict({ + "verify": { + "strict_v1_validation": True, + "ocsp_fetch": True, + "verify_trust": True, + "verify_timestamp_trust": True + } +}) + +reader = Reader("asset_to_validate.jpg", context=ctx) +``` + +### Production configuration + +```py +with open("trust-anchors.pem", "r") as f: + trust_anchors = f.read() + +ctx = Context.from_dict({ + "trust": { + "trust_anchors": trust_anchors, + "trust_config": "1.3.6.1.5.5.7.3.4\n1.3.6.1.5.5.7.3.36" + }, + "core": {"backing_store_memory_threshold_in_mb": 1024}, + "builder": { + "intent": {"Create": "digitalCapture"}, + "thumbnail": {"long_edge": 512, "quality": "high"} + } +}) +``` + +### Layered configuration + +Load base configuration and apply runtime overrides: + +```py +import json + +with open("config/base.json", "r") as f: + base_config = json.load(f) + +settings = Settings.from_dict(base_config) +settings.update({"builder": {"claim_generator_info": {"version": app_version}}}) + +ctx = Context(settings) +``` + +### Configuration from environment variables + +```py +import os + +env = os.environ.get("ENVIRONMENT", "dev") + +settings = Settings() +if env == "production": + settings.update({"verify": {"strict_v1_validation": True}}) +else: + settings.update({"verify": {"remote_manifest_fetch": False}}) + +ctx = Context(settings) +``` + +## Configuring signers + +### Signing concepts + +C2PA uses a certificate-based trust model to prove who signed an asset. When creating a `Signer`, the following are key parameters: + +- **Certificate chain** (`sign_cert`): An X.509 certificate chain in PEM format. The first certificate identifies the signer; subsequent certificates form a chain up to a trusted root. Verifiers use this chain to confirm the signature comes from a trusted source. +- **Timestamp authority URL** (`ta_url`): An optional [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161) timestamp server URL. When provided, the SDK requests a trusted timestamp during signing, proving _when_ the signature was made. This keeps signatures verifiable even after the signing certificate expires. + +### Signer from settings (recommended) + +Configure signer credentials directly in settings. This is the most common approach: + +```py +ctx = Context.from_dict({ + "signer": { + "local": { + "alg": "ps256", + "sign_cert": "-----BEGIN CERTIFICATE-----\nMIIExample...\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIExample...\n-----END PRIVATE KEY-----", + "tsa_url": "http://timestamp.digicert.com" + } + } +}) + +builder = Builder(manifest_json, ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +### Signer on Context (signer object) + +Create a `Signer` object and pass it to the `Context`. The signer is **consumed**: the `Signer` object becomes invalid after this call and the `Context` takes ownership. + +```py +from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg + +signer_info = C2paSignerInfo( + C2paSigningAlg.ES256, cert_data, key_data, b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) + +ctx = Context(settings, signer) +# signer is now invalid and must not be used directly again + +builder = Builder(manifest_json, ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign("image/jpeg", src, dst) +``` + +### Explicit signer at signing time + +For full programmatic control, pass a `Signer` directly to `Builder.sign()`: + +```py +signer = Signer.from_info(signer_info) +builder = Builder(manifest_json, ctx) + +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + builder.sign(signer, "image/jpeg", src, dst) +``` + +### Precedence rules + +If both an explicit signer (passed to `sign()`) and a context signer are available, the explicit signer always takes precedence. + +### Remote signer + +Use a remote signer when the private key is stored on a secure signing service (HSM, cloud KMS, and so on): + +```py +ctx = Context.from_dict({ + "signer": { + "remote": { + "alg": "ps256", + "url": "https://my-signing-service.com/sign", + "sign_cert": "-----BEGIN CERTIFICATE-----\nMIIExample...\n-----END CERTIFICATE-----", + "tsa_url": "http://timestamp.digicert.com" + } + } +}) +``` + +For all `signer.local` and `signer.remote` properties, see the [SDK object reference - Settings](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#signersettings). + +## Advanced topics + +### Context lifetime + +`Context` supports the `with` statement for automatic resource cleanup: + +```py +with Context() as ctx: + reader = Reader("image.jpg", context=ctx) + print(reader.json()) +# Resources are automatically released +``` + +### Reusable contexts + +You can reuse the same `Context` to create multiple readers and builders. The `Context` can be closed after construction; readers and builders it was used to create still work correctly. + +```py +ctx = Context(settings) + +builder1 = Builder(manifest1, ctx) +builder2 = Builder(manifest2, ctx) +reader = Reader("image.jpg", context=ctx) +``` + +### Multiple contexts for different purposes + +Use different `Context` objects when you need different configurations at the same time: + +```py +dev_ctx = Context(dev_settings) +prod_ctx = Context(prod_settings) + +dev_builder = Builder(manifest, dev_ctx) +prod_builder = Builder(manifest, prod_ctx) +``` + +### ContextProvider abstract base class + +`ContextProvider` is an abstract base class (ABC) that enables custom context provider implementations. Subclass it and implement the `is_valid` and `execution_context` abstract properties to create a provider that can be passed to `Reader` or `Builder` as a context. The built-in `Context` class inherits from `ContextProvider`. + +```py +from c2pa import ContextProvider, Context + +ctx = Context() +assert isinstance(ctx, ContextProvider) # True +``` + +## Migrating from load_settings + +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context` APIs: + +| Aspect | `load_settings` (legacy) | `Context` | +|--------|--------------------------|-----------| +| Scope | Global state | Per Reader/Builder, passed explicitly | +| Multiple configs | Not supported | One context per configuration | +| Testing | Shared global state | Isolated contexts per test | + +**Deprecated:** + +```py +from c2pa import load_settings, Reader + +load_settings({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("image.jpg") # uses global settings +``` + +**Current approach:** + +```py +from c2pa import Settings, Context, Reader + +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +reader = Reader("image.jpg", context=ctx) +``` + +## See also + +- [Usage](usage.md): reading and signing with `Reader` and `Builder`. +- [CAI settings schema reference](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/): full schema reference. From b0f4be7cfd337c28d04ab9b768043062b9395182 Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Fri, 13 Mar 2026 16:21:52 -0700 Subject: [PATCH 83/84] Remove context.md and settings.md which were combined into context-settings.md, Put class diagram into sep file, various other edits --- docs/class-diagram.md | 135 +++++++ docs/context-settings.md | 193 +++------- docs/context.md | 686 ------------------------------------ docs/selective-manifests.md | 10 +- docs/settings.md | 440 ----------------------- docs/usage.md | 351 ++++++------------ 6 files changed, 295 insertions(+), 1520 deletions(-) create mode 100644 docs/class-diagram.md delete mode 100644 docs/context.md delete mode 100644 docs/settings.md diff --git a/docs/class-diagram.md b/docs/class-diagram.md new file mode 100644 index 00000000..5d9ad541 --- /dev/null +++ b/docs/class-diagram.md @@ -0,0 +1,135 @@ + +# Class diagram + +This diagram shows the public classes in the Python library and their relationships. + +```mermaid +classDiagram + direction LR + + class Settings { + +from_json(json_str) Settings$ + +from_dict(config) Settings$ + +set(path, value) Settings + +update(data) Settings + +close() + +is_valid bool + } + + class ContextProvider { + <> + +is_valid bool* + +execution_context* + } + + class Context { + +from_json(json_str, signer) Context$ + +from_dict(config, signer) Context$ + +has_signer bool + +is_valid bool + +close() + } + + class Reader { + +get_supported_mime_types() list~str~$ + +try_create(format_or_path, stream, manifest_data, context) Reader | None$ + +json() str + +detailed_json() str + +get_active_manifest() dict | None + +get_manifest(label) dict + +get_validation_state() str | None + +get_validation_results() dict | None + +resource_to_stream(uri, stream) int + +is_embedded() bool + +get_remote_url() str | None + +close() + } + + class Builder { + +from_json(manifest_json, context) Builder$ + +from_archive(stream) Builder$ + +get_supported_mime_types() list~str~$ + +set_no_embed() + +set_remote_url(url) + +set_intent(intent, digital_source_type) + +add_resource(uri, stream) + +add_ingredient(json, format, source) + +add_action(action_json) + +to_archive(stream) + +with_archive(stream) Builder + +sign(signer, format, source, dest) bytes + +sign(format, source, dest) bytes + +sign_file(source_path, dest_path, signer) bytes + +close() + } + + class Signer { + +from_info(signer_info) Signer$ + +from_callback(callback, alg, certs, tsa_url) Signer$ + +reserve_size() int + +close() + } + + class C2paSignerInfo { + <> + +alg + +sign_cert + +private_key + +ta_url + } + + class C2paSigningAlg { + <> + ES256 + ES384 + ES512 + PS256 + PS384 + PS512 + ED25519 + } + + class C2paBuilderIntent { + <> + CREATE + EDIT + UPDATE + } + + class C2paDigitalSourceType { + <> + DIGITAL_CAPTURE + DIGITAL_CREATION + TRAINED_ALGORITHMIC_MEDIA + ... + } + + class C2paError { + <> + +message str + } + + class C2paError_Subtypes { + <> + ManifestNotFound + NotSupported + Json + Io + Verify + Signature + ... + } + + ContextProvider <|-- Context : extends + Settings --> Context : optional input + Signer --> Context : optional, consumed + C2paSignerInfo --> Signer : creates via from_info + C2paSigningAlg --> C2paSignerInfo : alg field + C2paSigningAlg --> Signer : from_callback alg + Context --> Reader : context= + Context --> Builder : context= + Signer --> Builder : sign(signer) + C2paBuilderIntent --> Builder : set_intent + C2paDigitalSourceType --> Builder : set_intent + C2paError --> C2paError_Subtypes : subclasses +``` \ No newline at end of file diff --git a/docs/context-settings.md b/docs/context-settings.md index 7782ae56..8eb710ec 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -20,141 +20,6 @@ The `Context` class encapsulates configuration for: > [!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). -### 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) Settings - +close() - +is_valid bool - } - - class ContextProvider { - <> - +is_valid bool* - +execution_context* - } - - class Context { - +from_json(json_str, signer) Context$ - +from_dict(config, signer) Context$ - +has_signer bool - +is_valid bool - +close() - } - - class Reader { - +get_supported_mime_types() list~str~$ - +try_create(format_or_path, stream, manifest_data, context) Reader | None$ - +json() str - +detailed_json() str - +get_active_manifest() dict | None - +get_manifest(label) dict - +get_validation_state() str | None - +get_validation_results() dict | None - +resource_to_stream(uri, stream) int - +is_embedded() bool - +get_remote_url() str | None - +close() - } - - class Builder { - +from_json(manifest_json, context) Builder$ - +from_archive(stream) Builder$ - +get_supported_mime_types() list~str~$ - +set_no_embed() - +set_remote_url(url) - +set_intent(intent, digital_source_type) - +add_resource(uri, stream) - +add_ingredient(json, format, source) - +add_action(action_json) - +to_archive(stream) - +with_archive(stream) Builder - +sign(signer, format, source, dest) bytes - +sign(format, source, dest) bytes - +sign_file(source_path, dest_path, signer) bytes - +close() - } - - class Signer { - +from_info(signer_info) Signer$ - +from_callback(callback, alg, certs, tsa_url) Signer$ - +reserve_size() int - +close() - } - - class C2paSignerInfo { - <> - +alg - +sign_cert - +private_key - +ta_url - } - - class C2paSigningAlg { - <> - ES256 - ES384 - ES512 - PS256 - PS384 - PS512 - ED25519 - } - - class C2paBuilderIntent { - <> - CREATE - EDIT - UPDATE - } - - class C2paDigitalSourceType { - <> - DIGITAL_CAPTURE - DIGITAL_CREATION - TRAINED_ALGORITHMIC_MEDIA - ... - } - - class C2paError { - <> - +message str - } - - class C2paError_Subtypes { - <> - ManifestNotFound - NotSupported - Json - Io - Verify - Signature - ... - } - - ContextProvider <|-- Context : extends - Settings --> Context : optional input - Signer --> Context : optional, consumed - C2paSignerInfo --> Signer : creates via from_info - C2paSigningAlg --> C2paSignerInfo : alg field - C2paSigningAlg --> Signer : from_callback alg - Context --> Reader : context= - Context --> Builder : context= - Signer --> Builder : sign(signer) - C2paBuilderIntent --> Builder : set_intent - C2paDigitalSourceType --> Builder : set_intent - C2paError --> C2paError_Subtypes : subclasses -``` - ## Quick start ### Using SDK default settings @@ -227,6 +92,64 @@ with open("config/settings.json", "r") as f: ctx = Context(settings) ``` +## Class diagram + +```mermaid +classDiagram + class ContextProvider { + <> + +is_valid bool + +execution_context + } + + class Settings { + +set(path, value) Settings + +update(data) Settings + +from_json(json_str)$ Settings + +from_dict(config)$ Settings + +close() + } + + class Context { + +has_signer bool + +builder()$ ContextBuilder + +from_json(json_str, signer)$ Context + +from_dict(config, signer)$ Context + +close() + } + + class ContextBuilder { + +with_settings(settings) ContextBuilder + +with_signer(signer) ContextBuilder + +build() Context + } + + class Signer { + +from_info(signer_info)$ Signer + +from_callback(callback, alg, certs, tsa_url)$ Signer + +close() + } + + class Reader { + +json() str + +resource_to_stream(uri, stream) + +close() + } + + class Builder { + +add_ingredient(json, format, stream) + +sign(signer, format, source, dest) bytes + +close() + } + + ContextProvider <|-- Context + ContextBuilder --> Context : builds + Context o-- Settings : optional + Context o-- Signer : optional, consumed + Reader ..> ContextProvider : uses + Builder ..> ContextProvider : uses +``` + ## Settings API Create and configure settings independently of a `Context`: diff --git a/docs/context.md b/docs/context.md deleted file mode 100644 index fa36c615..00000000 --- a/docs/context.md +++ /dev/null @@ -1,686 +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, 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) Settings - +close() - +is_valid bool - } - - class ContextProvider { - <> - +is_valid bool* - +execution_context* - } - - class Context { - +from_json(json_str, signer) Context$ - +from_dict(config, signer) Context$ - +has_signer bool - +is_valid bool - +close() - } - - class Reader { - +get_supported_mime_types() list~str~$ - +try_create(format_or_path, stream, manifest_data, context) Reader | None$ - +json() str - +detailed_json() str - +get_active_manifest() dict | None - +get_manifest(label) dict - +get_validation_state() str | None - +get_validation_results() dict | None - +resource_to_stream(uri, stream) int - +is_embedded() bool - +get_remote_url() str | None - +close() - } - - class Builder { - +from_json(manifest_json, context) Builder$ - +from_archive(stream) Builder$ - +get_supported_mime_types() list~str~$ - +set_no_embed() - +set_remote_url(url) - +set_intent(intent, digital_source_type) - +add_resource(uri, stream) - +add_ingredient(json, format, source) - +add_action(action_json) - +to_archive(stream) - +with_archive(stream) Builder - +sign(signer, format, source, dest) bytes - +sign(format, source, dest) bytes - +sign_file(source_path, dest_path, signer) bytes - +close() - } - - class Signer { - +from_info(signer_info) Signer$ - +from_callback(callback, alg, certs, tsa_url) Signer$ - +reserve_size() int - +close() - } - - class C2paSignerInfo { - <> - +alg - +sign_cert - +private_key - +ta_url - } - - class C2paSigningAlg { - <> - ES256 - ES384 - ES512 - PS256 - PS384 - PS512 - ED25519 - } - - class C2paBuilderIntent { - <> - CREATE - EDIT - UPDATE - } - - class C2paDigitalSourceType { - <> - DIGITAL_CAPTURE - DIGITAL_CREATION - TRAINED_ALGORITHMIC_MEDIA - ... - } - - class C2paError { - <> - +message str - } - - class C2paError_Subtypes { - <> - ManifestNotFound - NotSupported - Json - Io - Verify - Signature - ... - } - - ContextProvider <|-- Context : extends - Settings --> Context : optional input - Signer --> Context : optional, consumed - C2paSignerInfo --> Signer : creates via from_info - C2paSigningAlg --> C2paSignerInfo : alg field - C2paSigningAlg --> Signer : from_callback alg - Context --> Reader : context= - Context --> Builder : context= - Signer --> Builder : sign(signer) - C2paBuilderIntent --> Builder : set_intent - C2paDigitalSourceType --> Builder : set_intent - C2paError --> C2paError_Subtypes : subclasses -``` - -> [!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). - -## Workflow overview - -The SDK supports two main workflows. `Settings` and `Context` are currently optional in both (but recommended). `Reader` and `Builder` can still be used directly with SDK defaults. - -### Reading provenance - -Read and inspect C2PA data already embedded in (or attached to) an asset: - -```mermaid -flowchart LR - A[Asset file] --> B["Reader (with Context containing Settings)"] - B --> C["Manifest JSON (reader.json())"] - B --> D["Binary resources (reader.resource_to_stream())"] -``` - -```py -from c2pa import Reader - -reader = Reader("signed_image.jpg") -print(reader.json()) # Manifest store as JSON -``` - -### Signing content - -Create new C2PA provenance data and sign it into an asset: - -```mermaid -flowchart LR - A["Settings"] --> B["Context"] - F[Signer] --> B - B --> C[Builder] - G["add assertions
add ingredients"] --> C - C --> D["sign()"] - D --> E[Signed asset] - F2[Signer] -.-> D -``` - -```py -from c2pa import Builder, Signer, C2paSignerInfo, C2paSigningAlg - -builder = Builder(manifest_json) -# ... add assertions, ingredients, resources ... -with open("source.jpg", "rb") as src, open("signed.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -`Settings` and `Context` enable to customize behavior (trust configuration, thumbnail settings, claim generator info, and so on). - -## 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 - -Without additional parameters, a default context is using [SDK default settings](settings.md#default-configuration). - -**When to use:** For quick prototyping, or when you're happy with SDK 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) -``` - -## 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) -``` - -### 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) -``` - -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`. A `Context` object can also be reused for multiple `Reader` object instances. - -```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`. A `Context` object can also be reused for multiple `Builder` object instances. - -### Context and archives - -Archives (`.c2pa` files) store only the manifest definition. They do **not** store settings or context. This means: - -- **`Builder.from_archive(stream)`** creates a context-free builder. All settings revert to SDK defaults regardless of what context the original builder had. -- **`Builder({}, ctx).with_archive(stream)`** creates a builder with a context first, then loads the archived manifest definition into it. The context settings are preserved and propagated to this Builder instance. - -Use `with_archive()` when your workflow depends on specific settings (thumbnails, claim generator, intent, and so on). Use `from_archive()` only for quick prototyping where SDK defaults are acceptable. - -```py -# Recommended: with_archive propagates context settings -ctx = Context.from_dict({ - "builder": { - "thumbnail": {"enabled": False}, - "claim_generator_info": {"name": "My App", "version": "0.1.0"} - } -}) - -with open("manifest.c2pa", "rb") as archive: - builder = Builder({}, ctx) - builder.with_archive(archive) - # builder now has the archived definition + context settings -``` - -For more details on archive workflows, see [Working with archives](working-stores.md#working-with-archives). - -### 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, 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 e.g. 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 - -### Signing concepts - -C2PA uses a certificate-based trust model to prove who signed an asset. When creating a `Signer`, the following parameters are required: - -- **Certificate chain** (`sign_cert`): An X.509 certificate chain in PEM format. The first certificate identifies the signer; subsequent certificates form a chain up to a trusted root (trust anchor). Verifiers use this chain to confirm that the signature comes from a trusted source. -- **Timestamp authority URL** (`ta_url`): An optional [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161) timestamp server URL. When provided, the SDK requests a trusted timestamp during signing. This proves _when_ the signature was made. Timestamping matters because signatures remain verifiable even after the signing certificate expires, as long as the certificate was valid at the time of signing. - -### Signer creation patterns - -A Signer can be configured two ways: - -- [From Settings (signer-on-context)](#from-settings): pass the signer when creating the `Context`. -- [Explicit signer passed to sign()](#explicit-programmatic-signer): pass the signer directly at signing time. - -### 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 directly after that point. 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( - C2paSigningAlg.ES256, cert_data, key_data, b"http://timestamp.digicert.com" -) -signer = Signer.from_info(signer_info) - -# Create context with signer (signer is consumed) -ctx = Context(settings, signer) -# signer is now invalid and must not be used directly again - -# Build and sign, no signer argument needed since a Signer is in the Context -builder = Builder(manifest_json, ctx) -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign("image/jpeg", src, dst) -``` - -### Explicit (programmatic) 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, ctx) - -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - builder.sign(signer, "image/jpeg", src, dst) -``` - -You can also use the fluent `ContextBuilder` API to attach a signer programmatically via `with_signer`: - -```py -ctx = Context.builder().with_settings(settings).with_signer(signer).build() -``` - -### Precedence rules for Signer configuration - -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 - -### `with` statement - -`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) - -# All three use the same configuration through usage of the same context -builder1 = Builder(manifest1, ctx) -builder2 = Builder(manifest2, ctx) -reader = Reader("image.jpg", context=ctx) - -# Context can be closed after construction; readers/builders still work -``` - -Using the `with` statement for automatic cleanup: - -```py -with Context(settings) as ctx: - builder1 = Builder(manifest1, ctx) - builder2 = Builder(manifest2, ctx) - reader = Reader("image.jpg", context=ctx) -# Resources are automatically released -``` - -### Multiple contexts for different purposes - -Use different `Context` objects when you need different settings. Ror example, for development vs. production, or different trust configurations: - -```py -dev_ctx = Context(dev_settings) -prod_ctx = Context(prod_settings) - -# Different builders with different configurations -dev_builder = Builder(manifest, dev_ctx) -prod_builder = Builder(manifest, prod_ctx) -``` - -### ContextProvider abstract base class - -`ContextProvider` is an abstract base class (ABC) that enables context provider implementations. Subclass it and implement the `is_valid` and `execution_context` abstract properties to create a provider that can be passed to `Reader` or `Builder` as `Context`. - -```py -from c2pa import ContextProvider, Context - -# The built-in Context inherits from ContextProvider -ctx = Context() -assert isinstance(ctx, ContextProvider) # True -``` - -## Migrating from load_settings - -The `load_settings()` function is deprecated. Replace it with `Settings` and `Context` APIs instead: - -| 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) -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/selective-manifests.md b/docs/selective-manifests.md index 5ffa140f..7fb4444b 100644 --- a/docs/selective-manifests.md +++ b/docs/selective-manifests.md @@ -25,8 +25,6 @@ flowchart LR D -->|sign| E[New Asset] ``` - - The fundamental workflow is: 1. **Read** the existing manifest with `Reader` to get JSON and binary resources @@ -57,7 +55,7 @@ with open("signed_asset.jpg", "rb") as source: ### 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 `resource_to_stream()`: +The JSON returned by `reader.json()` contains only string identifiers (JUMBF URIs) for binary data like thumbnails and ingredient manifest stores. Extract the actual binary content by using `resource_to_stream()`: ```py # Extract a thumbnail to an in-memory stream @@ -168,9 +166,9 @@ Sometimes all existing assertions and ingredients may need to be discarded but t 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 +- 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 diff --git a/docs/settings.md b/docs/settings.md deleted file mode 100644 index 28bad61b..00000000 --- a/docs/settings.md +++ /dev/null @@ -1,440 +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)` | 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. | - -**Important notes:** - -- The `set()` and `update()` methods can be chained for incremental configuration. -- When using multiple configuration methods, later calls override earlier ones (last call wins when same setting is set multiple times). - -```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") - -# 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** format only. Pass JSON strings (serialized JSON stings) to `Settings.from_json()` or dictionaries to `Settings.from_dict()`. `from_dict` will convert the dictionary in a format compatible with what the udnerlying native libraries expect. - -```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 by 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": "0.1.0"} - } -}) -``` - -Or another example for editing existing content: - -```py -editor_ctx = Context.from_dict({ - "builder": { - "intent": {"Edit": None}, - "claim_generator_info": {"name": "Photo Editor", "version": "0.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. - -See [Configuring a signer](context.md#configuring-a-signer) for details on how to configure a Signer. - -#### 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/usage.md b/docs/usage.md index 7964bde3..dbdcbe2c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,6 +4,10 @@ This package works with media files in the [supported formats](https://github.co For complete working examples, see the [examples folder](https://github.com/contentauth/c2pa-python/tree/main/examples) in the repository. +Reference material: +- [Class diagram](class-diagram.md) +- [API reference documentation](https://contentauth.github.io/c2pa-python/api/c2pa/index.html) + ## Import Import the objects needed from the API: @@ -46,6 +50,100 @@ manifest_json = json.dumps({ }) ``` +## Settings, Context, and ContextProvider + +The `Settings` and `Context` classes provide per-instance configuration for `Reader` and `Builder` operations, replacing the global `load_settings()` function, which is now deprecated. See [Context and settings](context-settings.md) for details. + +### Settings + +`Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. + +```py +from c2pa import Settings + +settings = Settings() +settings.set("builder.thumbnail.enabled", "false") # dot-notation path; value is a string +settings.update({"verify": {"remote_manifest_fetch": True}}) # merge additional config + +settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +``` + +For the full Settings API reference, see [Settings API](context-settings.md#settings-api). + +### Context + +A `Context` carries `Settings` and optionally a `Signer`, and is passed to `Reader` or `Builder` to control their behavior. + +```py +from c2pa import Context, Settings + +ctx = Context() # SDK defaults +ctx = Context(settings) +ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') +ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) + +reader = Reader("path/to/media_file.jpg", context=ctx) +builder = Builder(manifest_json, ctx) +``` + +For full details on configuring `Context` and using it with `Reader` and `Builder`, see [Using Context](context-settings.md#using-context) and the [Settings reference](context-settings.md#settings-reference). + +### ContextBuilder (fluent API) + +`ContextBuilder` provides a fluent interface for constructing a `Context`. Use `Context.builder()` to get started. + +```py +from c2pa import Context, ContextBuilder, Settings, Signer + +ctx = ( + Context.builder() + .with_settings(settings) + .with_signer(signer) + .build() +) + +ctx = Context.builder().with_settings(settings).build() +ctx = Context.builder().build() # equivalent to Context() +``` + +You can call `with_settings()` multiple times; each call replaces the previous `Settings` object entirely (last one wins). To merge multiple configurations, use `Settings.update()` on a single `Settings` object before passing it to the context; for example: + +```py +settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) +settings.update({"verify": {"remote_manifest_fetch": True}}) + +ctx = Context.builder().with_settings(settings).build() +``` + +### Context with a Signer + +When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership, enabling signing without passing an explicit signer to `Builder.sign()`: + +```py +ctx = Context(settings=settings, signer=signer) +# signer is now invalid and must not be used directly again + +builder = Builder(manifest_json, ctx) +with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: + manifest_bytes = builder.sign(format="image/jpeg", source=src, dest=dst) +``` + +If both an explicit signer and a context signer are available, the explicit signer takes precedence. For more details, including remote signers, see [Configuring signers](context-settings.md#configuring-signers). + +### ContextProvider (abstract base class) + +`ContextProvider` is an abstract base class (ABC) that defines the interface `Reader` and `Builder` use to access a context. It requires two properties: + +- `is_valid` (bool): Whether the provider is in a usable state. +- `execution_context`: The raw native context pointer (`C2paContext` handle). + +The built-in `Context` class is the standard `ContextProvider` implementation. Custom providers must wrap a compatible native resource rather than constructing native pointers independently. `Settings` is not a `ContextProvider` and cannot be passed directly to `Reader` or `Builder`. For more details and a custom implementation example, see [ContextProvider](context-settings.md#contextprovider-abstract-base-class). + +### Migrating from load_settings + +The `load_settings()` function is deprecated. Replace it with `Settings` and `Context`. See [Migrating from load_settings](context-settings.md#migrating-from-load_settings) for details. + ## File-based operation ### Read and validate C2PA data @@ -182,259 +280,6 @@ except Exception as e: print("Failed to sign manifest store: " + str(e)) ``` -## Settings, Context, and ContextProvider - -The `Settings` and `Context` classes provide per-instance configuration for Reader and Builder operations. This replaces the global `load_settings()` function, which is now deprecated. - -```mermaid -classDiagram - class ContextProvider { - <> - +is_valid bool - +execution_context - } - - class Settings { - +set(path, value) Settings - +update(data) Settings - +from_json(json_str)$ Settings - +from_dict(config)$ Settings - +close() - } - - class Context { - +has_signer bool - +builder()$ ContextBuilder - +from_json(json_str, signer)$ Context - +from_dict(config, signer)$ Context - +close() - } - - class ContextBuilder { - +with_settings(settings) ContextBuilder - +with_signer(signer) ContextBuilder - +build() Context - } - - class Signer { - +from_info(signer_info)$ Signer - +from_callback(callback, alg, certs, tsa_url)$ Signer - +close() - } - - class Reader { - +json() str - +resource_to_stream(uri, stream) - +close() - } - - class Builder { - +add_ingredient(json, format, stream) - +sign(signer, format, source, dest) bytes - +close() - } - - ContextProvider <|-- Context - ContextBuilder --> Context : builds - Context o-- Settings : optional - Context o-- Signer : optional, consumed - Reader ..> ContextProvider : uses - Builder ..> ContextProvider : uses -``` - -### Settings - -`Settings` controls behavior such as thumbnail generation, trust lists, and verification flags. - -```py -from c2pa import Settings - -# Create with defaults -settings = Settings() - -# Set individual values by dot-notation path -settings.set("builder.thumbnail.enabled", "false") - -# Method chaining -settings.set("builder.thumbnail.enabled", "false").set("verify.remote_manifest_fetch", "true") - -# Dict-like access -settings["builder.thumbnail.enabled"] = "false" - -# Create from JSON string -settings = Settings.from_json('{"builder": {"thumbnail": {"enabled": false}}}') - -# Create from a dictionary -settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) - -# Merge additional configuration -settings.update({"verify": {"remote_manifest_fetch": True}}) -``` - -### Context - -A `Context` can carry `Settings` and a `Signer`, and is passed to `Reader` or `Builder` to control their behavior through settings propagation. - -```py -from c2pa import Context, Settings, Reader, Builder, Signer - -# Default context (no custom settings) -ctx = Context() - -# Context with settings -settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -ctx = Context(settings=settings) - -# Create from JSON or dict directly -ctx = Context.from_json('{"builder": {"thumbnail": {"enabled": false}}}') -ctx = Context.from_dict({"builder": {"thumbnail": {"enabled": False}}}) - -# Use with Reader (keyword argument) -reader = Reader("path/to/media_file.jpg", context=ctx) - -# Use with Builder (positional or keyword argument) -builder = Builder(manifest_json, ctx) -``` - -### ContextBuilder (fluent API) - -`ContextBuilder` provides a fluent interface for constructing a `Context`, matching the c2pa-rs `ContextBuilder` pattern. Use `Context.builder()` to get started. - -```py -from c2pa import Context, ContextBuilder, Settings, Signer - -# Fluent construction with settings and signer -ctx = ( - Context.builder() - .with_settings(settings) - .with_signer(signer) - .build() -) - -# Settings only -ctx = Context.builder().with_settings(settings).build() - -# Default context (equivalent to Context()) -ctx = Context.builder().build() -``` - -You can call `with_settings()` multiple times. This is useful when different code paths each need to configure settings before the context is built. Each call replaces the previous `Settings` object entirely (the last one wins): - -```py -# Only settings_b is used, settings_a is replaced -ctx = ( - Context.builder() - .with_settings(settings_a) - .with_settings(settings_b) - .build() -) -``` - -To merge multiple configurations into one, use `Settings.update()` on a single `Settings` object, and then pass the built Settings object to the context: - -```py -settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -settings.update({"verify": {"remote_manifest_fetch": True}}) - -ctx = Context.builder().with_settings(settings).build() -``` - -### Context with a Signer - -When a `Signer` is passed to `Context`, the `Signer` object is consumed and must not be reused directly. The `Context` takes ownership of the underlying native signer. This allows signing without passing an explicit signer to `Builder.sign()`. - -```py -from c2pa import Context, Settings, Builder, Signer, C2paSignerInfo, C2paSigningAlg - -# Create a signer -signer_info = C2paSignerInfo( - alg=C2paSigningAlg.ES256, - sign_cert=cert_data, - private_key=key_data, - ta_url=b"http://timestamp.digicert.com" -) -signer = Signer.from_info(signer_info) - -# Create context with signer (signer is consumed) -ctx = Context(settings=settings, signer=signer) -# The signer object is now invalid and must not be used directly again - -# Build and sign without passing a signer, since the signer is in the context -builder = Builder(manifest_json, ctx) -with open("source.jpg", "rb") as src, open("output.jpg", "w+b") as dst: - manifest_bytes = builder.sign(format="image/jpeg", source=src, dest=dst) -``` - -If both an explicit signer and a context signer are available, the explicit signer takes precedence: - -```py -# Explicit signer wins over context signer -manifest_bytes = builder.sign(explicit_signer, "image/jpeg", source, dest) -``` - -### ContextProvider (abstract base class) - -`ContextProvider` is an abstract base class (ABC) that defines the interface `Reader` and `Builder` use to access a context. It requires two properties: - -- `is_valid` (bool): Whether the provider is in a usable state. `Reader` and `Builder` check this before every operation. -- `execution_context`: The raw native context pointer (`C2paContext` handle). `Reader` and `Builder` pass this to the native library for FFI calls. - -The built-in `Context` class is the standard implementation to provide context: - -```py -from c2pa import ContextProvider, Context - -ctx = Context() -assert isinstance(ctx, ContextProvider) -``` - -Any class can become a `ContextProvider` by inheriting from `ContextProvider` and implementing both properties. The two properties can live on any object through multiple inheritance, but a dedicated context class (as done in the SDK with `Context`) is preferred because it handles native memory management, lifecycle states, and signer ownership. - -In practice, `execution_context` must return a pointer that the native C2PA library understands, so custom providers will likely wrap a compatible native resource, rather than constructing native pointers independently: - -```py -from c2pa import ContextProvider, Context, Settings - -class MyContextProvider(ContextProvider): - """Custom provider that wraps a Context with application-specific logic.""" - - def __init__(self, config: dict): - self._ctx = Context(settings=Settings.from_dict(config)) - - @property - def is_valid(self) -> bool: - return self._ctx.is_valid - - @property - def execution_context(self): - return self._ctx.execution_context - - def close(self): - self._ctx.close() -``` - -`Settings` is not a `ContextProvider`. It inherits from `ManagedResource` only and cannot be passed directly as the `context` parameter to `Reader` or `Builder`. - -### Migrating from load_settings - -The `load_settings()` function that set settings in a thread-local fashion is deprecated. -Replace it with `Settings` and `Context` usage to propagate configurations (do not mix legacy and new APIs): - -```py -# Before: -from c2pa import load_settings -load_settings({"builder": {"thumbnail": {"enabled": False}}}) -reader = Reader("file.jpg") - -# After: -from c2pa import Settings, Context, Reader - -# Settings are on the context, and move with the context -settings = Settings.from_dict({"builder": {"thumbnail": {"enabled": False}}}) -ctx = Context(settings=settings) -reader = Reader("file.jpg", context=ctx) -``` - ## Stream-based operations Instead of working with files, you can read, validate, and add a signed manifest to streamed data. This example is similar to what the file-based example does. From 369702689e67c6e884b8744f02316cece82ba337 Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Fri, 13 Mar 2026 16:35:12 -0700 Subject: [PATCH 84/84] Edit intents and make it follow style, org of c2pa-c version --- docs/intents.md | 162 ++++++++++++++++++++++++++++-------------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/docs/intents.md b/docs/intents.md index e227a792..7d296734 100644 --- a/docs/intents.md +++ b/docs/intents.md @@ -1,16 +1,16 @@ # Using Builder intents -Intents enable validation, add required default actions, and help prevent invalid operations when using a `Builder`. Intents are about the operation (create, edit, update) executed on the source asset. +_Intents_ enable validation, add required actions that are required by the C2PA specification, and help prevent invalid operations when using a `Builder`. Intents are about the operation (create, edit, update) executed on the source asset. ## Why use intents? -Without intents, the caller must manually construct the correct manifest structure: adding the required actions (`c2pa.created` or `c2pa.opened` as the first action per the specification), setting digital source types, managing ingredients, and linking actions to ingredients. Getting any of this wrong produces a non-compliant manifest. +Without intents, you have to manually construct the correct manifest structure: adding the required actions (`c2pa.created` or `c2pa.opened` as the first action per the specification), setting digital source types, managing ingredients, and linking actions to ingredients. Getting any of this wrong produces a non-compliant manifest. -With intents, the caller declares *what is being done* and the Builder handles the rest: +With intents, the caller declares *what is being done* and `Builder` handles the rest. + +For example, without intents you have to manually wire up actions and make sure ingredients are properly linked to actions. This is especially important for `parentOf` ingredient relationships with the `c2pa.opened` action. ```py -# Without intents: a caller must manually wire things up, and make sure ingredients are properly linked to actions. -# This is especially important in the case of parentOf ingredient relationships, with the c2pa.opened action with Builder({ "assertions": [ { @@ -28,8 +28,11 @@ with Builder({ }) as builder: with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: builder.sign(signer, "image/jpeg", source, dest) +``` + +But with intents, `Builder` generates the actions automatically; for example: -# With intents: the Builder generates the actions automatically +```py with Builder({}) as builder: builder.set_intent( C2paBuilderIntent.CREATE, @@ -39,15 +42,20 @@ with Builder({}) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -Both ways of writing the code produce the same signed manifest. With intents, the Builder validates the setup and fills in the spec-required structure. +Both of these code snippets produce the same signed manifest. But with intents, `Builder` validates the setup and fills in the required structure. ## Setting the intent -There are three ways to set the intent on a `Builder` object instance. +You can set the intent on a `Builder` instance by: + +- [Using Context](#using-context) +- [Using `set_intent` on the `Builder`](#using-set_intent-on-the-builder) + +Don't set the intent using the deprecated `load_settings()` function. For existing code, see [Context and settings - Migrating from load_settings](context-settings.md#migrating-from-load_settings). ### Using Context -Pass the intent through a `Context` object when creating the `Builder`. This keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. +Pass the intent through a `Context` object when creating a `Builder`. This keeps intent configuration alongside other builder settings such as `claim_generator_info` and `thumbnail`. ```py from c2pa import Context, Builder @@ -64,7 +72,7 @@ with Builder({}, context=ctx) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -The same `Context` can be reused across multiple `Builder` instances, ensuring consistent configuration: +You can reuse the same `Context` across multiple `Builder` instances, ensuring consistent configuration: ```py ctx = Context.from_dict({ @@ -81,7 +89,7 @@ for path in image_paths: ### Using `set_intent` on the Builder -Call `set_intent` directly on a `Builder` instance. This is useful for one-off operations or when the intent needs to be determined at runtime: +Call `set_intent` directly on a `Builder` instance for one-off operations or when the intent is determined at runtime. For example: ```py with Builder({}) as builder: @@ -93,9 +101,10 @@ with Builder({}) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -### Intent setting precedence +### Intent precedence -When an intent is configured in multiple places , the most specific setting wins: +When an intent is configured in multiple places, the most specific setting takes precedence. +If `set_intent` is called on a `Builder` instance, it takes precedence over all other sources. ```mermaid flowchart TD @@ -114,8 +123,6 @@ flowchart TD manually in manifest JSON."] ``` -If a `set_intent` call is present on the Builder, it takes precedence over all other sources. - ## How intents relate to the source stream The intent operates on the source passed to `sign()`, not on any ingredient added via `add_ingredient`. @@ -157,11 +164,11 @@ flowchart LR end ``` -For **EDIT** and **UPDATE**, the Builder looks at the source stream, and if no `parentOf` ingredient has been added manually, it automatically creates one from that stream (and adds the needed action). The source stream *becomes* the parent ingredient. If a `parentOf` ingredient has already been added manually (via `add_ingredient`), the Builder uses that one instead and does not auto-create one from the source. +For `Edit` and `Update` intents, `Builder` looks at the source stream, and if no `parentOf` ingredient has been added manually, it automatically creates one from that stream (and adds the needed action). The source stream *becomes* the parent ingredient. If a `parentOf` ingredient has already been added manually (via `add_ingredient`), `Builder` uses that one instead and does not automatically create one from the source. ### How intent relates to `add_ingredient` -The intent controls what the Builder does with the source stream at sign time. The `add_ingredient` method adds other ingredients explicitly. These are separate concerns. +The `Builder` intent controls what the Builder does with the source stream (source asset) at sign time. The `add_ingredient` method adds other ingredients explicitly. These are separate concerns. ```mermaid flowchart TD @@ -190,9 +197,9 @@ flowchart TD ParentIng --> EditFlow ``` -## Import +## Importing the enums -Intents and digital source types are provided as enums by two imports. +The `C2paBuilderIntent` and `C2paDigitalSourceType` enums are available from the `c2pa` package: ```py from c2pa import ( @@ -201,13 +208,55 @@ from c2pa import ( ) ``` -## Intent types +### Using `set_intent` + +Use the `Builder.set_intent` method to specify the intent: + +```py +builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY) +``` -| Intent | Operation | Parent ingredient | Auto-generated action | -| --- | --- | --- | --- | -| `CREATE` | Brand-new content | Must NOT have one | `c2pa.created` | -| `EDIT` | Modifying existing content | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | -| `UPDATE` | Metadata-only changes | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | +Where: +- `intent` is one of the [intent types](#intent-types). +- `digital_source_type` is one of the [`C2paDigitalSourceType` values](#c2padigitalsourcetype) that describes how the asset was made. Required for the `Create` intent. Defaults to `EMPTY`. + +Raises `C2paError` if the intent cannot be set (for example, if a `parentOf` ingredient exists with `Create`). + +### Intent types + +Intent types can be any `C2paBuilderIntent` value: + +| Intent | Operation | Parent ingredient | Auto-generated action | +|--------|-----------|-------------------|-----------------------| +| `CREATE` | Brand-new content | Must NOT have one | `c2pa.created` | +| `EDIT` | Modifying existing content | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | +| `UPDATE` | Metadata-only changes | Auto-created from the source stream if not provided | `c2pa.opened` (linked to parent) | + +When configuring intent through `Context` or settings JSON, `Edit` and `Update` are specified as lowercase strings (`"edit"`, `"update"`), and `Create` as an object with the source type: `{"Create": "digitalCapture"}`. + +### C2paDigitalSourceType + +| Enum value | Description | +|------------|-------------| +| `EMPTY` | No source type specified. The default value. | +| `DIGITAL_CAPTURE` | Captured from a real-world source using a digital device | +| `TRAINED_ALGORITHMIC_MEDIA` | Created by a trained algorithm (for example, generative AI) | +| `DIGITAL_CREATION` | Created digitally (for example, drawing software) | +| `COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA` | Composite that includes trained algorithmic media | +| `ALGORITHMICALLY_ENHANCED` | Enhanced by an algorithm | +| `SCREEN_CAPTURE` | Captured from a screen | +| `VIRTUAL_RECORDING` | Recorded from a virtual environment | +| `COMPOSITE` | Composed from multiple sources | +| `COMPOSITE_CAPTURE` | Composite of captured sources | +| `COMPOSITE_SYNTHETIC` | Composite of synthetic sources | +| `DATA_DRIVEN_MEDIA` | Generated from data | +| `ALGORITHMIC_MEDIA` | Created by an algorithm | +| `HUMAN_EDITS` | Human-edited content | +| `COMPUTATIONAL_CAPTURE` | Captured with computational processing | +| `NEGATIVE_FILM` | Scanned from negative film | +| `POSITIVE_FILM` | Scanned from positive film | +| `PRINT` | Scanned from a print | +| `TRAINED_ALGORITHMIC_DATA` | Data created by a trained algorithm | ## Choosing the right intent @@ -216,20 +265,20 @@ flowchart TD Start([Start]) --> HasParent{Does the asset have prior history?} HasParent --> |No| IsNew[Brand-new content] - IsNew --> CREATE["Use CREATE + IsNew --> CREATE["Use Create + C2paDigitalSourceType"] HasParent --> |Yes| ContentChanged{Will the content itself change?} - ContentChanged --> |Yes| EDIT[Use EDIT] - ContentChanged --> |No, metadata only| UPDATE[Use UPDATE] + ContentChanged --> |Yes| EDIT[Use Edit] + ContentChanged --> |No, metadata only| UPDATE[Use Update] ContentChanged --> |Need full manual control| MANUAL["Skip intents. Define actions and ingredients directly in manifest JSON."] ``` -## CREATE intent +## Create intent -Use `CREATE` when the asset has no prior history. A `C2paDigitalSourceType` is required to describe how the asset was produced. The Builder will: +Use the `Create` intent when the asset has no prior history. A `C2paDigitalSourceType` is required to describe how the asset was produced. `Builder` will: - Add a `c2pa.created` action with the specified digital source type. - Reject the operation if a `parentOf` ingredient exists. @@ -256,7 +305,6 @@ with Builder({}) as builder: C2paBuilderIntent.CREATE, C2paDigitalSourceType.DIGITAL_CREATION, ) - with open("source.jpg", "rb") as source, open("output.jpg", "wb") as dest: builder.sign(signer, "image/jpeg", source, dest) ``` @@ -273,9 +321,9 @@ with Builder({}, context=ctx) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -### Example: CREATE with additional manifest metadata +### Example: Create with additional manifest metadata -A `Context` and a manifest definition can be combined. The context handles the intent; the manifest definition provides additional metadata and assertions: +A `Context` and a manifest definition can be combined. The `Context` handles the intent; the manifest definition provides additional metadata and assertions: ```py ctx = Context.from_dict({ @@ -305,12 +353,12 @@ with Builder(manifest_def, context=ctx) as builder: builder.sign(signer, "image/jpeg", source, dest) ``` -## EDIT intent +## Edit intent -Use `EDIT` when modifying an existing asset. The Builder will: +Use the `Edit` intent when an existing asset is modified. With this intent, `Builder`: -1. Check if a `parentOf` ingredient has already been added. If not, it automatically creates one from the source stream passed to `sign()`. -2. Add a `c2pa.opened` action linked to the parent ingredient. +1. Checks if a `parentOf` ingredient has already been added. If not, it automatically creates one from the source stream passed to `sign()`. +2. Adds a `c2pa.opened` action linked to the parent ingredient. No `digital_source_type` parameter is needed. @@ -322,6 +370,8 @@ Using `Context`: ctx = Context.from_dict({"builder": {"intent": "edit"}}) with Builder({}, context=ctx) as builder: + # The Builder reads "original.jpg" as the parent ingredient, + # then writes the new manifest into "edited.jpg" with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: builder.sign(signer, "image/jpeg", source, dest) ``` @@ -331,9 +381,6 @@ Using `set_intent`: ```py with Builder({}) as builder: builder.set_intent(C2paBuilderIntent.EDIT) - - # The Builder reads "original.jpg" as the parent ingredient, - # then writes the new manifest into "edited.jpg" with open("original.jpg", "rb") as source, open("edited.jpg", "wb") as dest: builder.sign(signer, "image/jpeg", source, dest) ``` @@ -383,9 +430,9 @@ with Builder({ }, context=ctx) as builder: # The Builder auto-creates a parent from the source stream - # and generates a c2pa.opened action for it + # and generates a c2pa.opened action for it. - # Add a component ingredient manually + # Add a component ingredient manually. with open("overlay.png", "rb") as overlay: builder.add_ingredient( { @@ -401,15 +448,15 @@ with Builder({ builder.sign(signer, "image/jpeg", source, dest) ``` -## UPDATE intent +## Update intent -Use `UPDATE` for metadata-only changes where the asset content itself is not modified. This is a restricted form of `EDIT`: +Use the `Update` intent for metadata-only changes where the asset content itself is not modified. This is a restricted form of the `Edit` intent that: -- Allows exactly one ingredient (only the parent). +- Allows exactly one ingredient (the parent). - Does not allow changes to the parent's hashed content. -- Produces a more compact manifest than `EDIT`. +- Produces a more compact manifest than `Edit`. -As with `EDIT`, the Builder auto-creates a parent ingredient from the source stream if one is not provided. +As with `Edit` intent, `Builder` automatically creates a parent ingredient from the source stream if one is not provided. ### Example: Adding metadata to a signed asset @@ -432,24 +479,3 @@ with Builder({}) as builder: with open("signed_asset.jpg", "rb") as source, open("updated_asset.jpg", "wb") as dest: builder.sign(signer, "image/jpeg", source, dest) ``` - -## Intent values in settings - -When configuring settings, the intent is specified as a string or object in the `builder.intent` field: - -| Intent | Settings value | With digital source type | -|--------|---------------|--------------------------| -| Create | `{"Create": ""}` | Required. E.g., `{"Create": "digitalCapture"}` | -| Edit | `"edit"` | Not applicable | -| Update | `"update"` | Not applicable | - -## API reference - -### `Builder.set_intent(intent, digital_source_type=C2paDigitalSourceType.EMPTY)` - -| Parameter | Type | Description | -|-----------|------|-------------| -| `intent` | `C2paBuilderIntent` | The intent: `CREATE`, `EDIT`, or `UPDATE`. | -| `digital_source_type` | `C2paDigitalSourceType` | Required for `CREATE`. Describes how the asset was made. Defaults to `EMPTY`. | - -Raises `C2paError` if the intent cannot be set (for example, a `parentOf` ingredient exists with `CREATE`).