diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c471083..0d20b489 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,17 @@ permissions: actions: read jobs: + trusted-vsi-paired: + name: Trusted VSI paired-source API (not release artifacts) + if: | + !startsWith(github.ref, 'refs/tags/castlabs-v') && + (github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test')) + uses: ./.github/workflows/trusted-vsi-paired.yml + read-version: name: Read C2PA version if: ${{ !startsWith(github.ref, 'refs/tags/castlabs-v') }} @@ -144,7 +155,10 @@ jobs: python3 -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python3 ./tests/test_unit_tests.py + run: python3 -m pytest tests/test_unit_tests.py tests/test_sign_ladder.py + + - name: Test disabled trusted API against upstream native (not ABI qualification) + run: python3 -m pytest -q tests/test_trusted_vsi_api.py -k "not paired" tests-windows: name: Unit tests for developer setup (Windows) @@ -230,7 +244,10 @@ jobs: python -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python .\tests\test_unit_tests.py + run: python -m pytest .\tests\test_unit_tests.py .\tests\test_sign_ladder.py + + - name: Test disabled trusted API against upstream native (not ABI qualification) + run: python -m pytest -q tests/test_trusted_vsi_api.py -k "not paired" build-linux-wheel: name: Build Linux wheel @@ -315,7 +332,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/test_unit_tests.py tests/test_sign_ladder.py -v build-windows-wheel: name: Build Windows wheel @@ -407,7 +424,7 @@ jobs: - name: Run tests with pytest (venv) run: | .\venv\Scripts\activate - .\venv\Scripts\pytest .\tests\test_unit_tests.py -v + .\venv\Scripts\pytest .\tests\test_unit_tests.py .\tests\test_sign_ladder.py -v build-macos-wheel: name: Build macOS wheels @@ -496,7 +513,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/test_unit_tests.py tests/test_sign_ladder.py -v sdist: runs-on: ubuntu-latest diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 5dd4c696..128ca2b2 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -7,9 +7,14 @@ on: workflow_dispatch: inputs: source_sha: - description: Full feat/live-video-vsi branch-tip SHA to release + description: Full Python SHA (feat/live-video-vsi tip for dev5; paired source for trusted_vsi_only) required: true type: string + trusted_vsi_only: + description: Only non-publishing paired-source trusted API tests; bypass all dev5 jobs + required: false + type: boolean + default: false concurrency: group: castlabs-vsi-release-0.37.8.dev5 @@ -24,7 +29,14 @@ env: PYTHONHASHSEED: "0" jobs: + trusted-vsi-paired: + if: github.event_name == 'workflow_dispatch' && inputs.trusted_vsi_only + uses: ./.github/workflows/trusted-vsi-paired.yml + with: + python-ref: ${{ inputs.source_sha }} + prepare: + if: ${{ !inputs.trusted_vsi_only }} runs-on: ubuntu-24.04 timeout-minutes: 20 outputs: diff --git a/.github/workflows/test-c2pa-rs-source-build.yml b/.github/workflows/test-c2pa-rs-source-build.yml new file mode 100644 index 00000000..455c7b3c --- /dev/null +++ b/.github/workflows/test-c2pa-rs-source-build.yml @@ -0,0 +1,208 @@ +name: Test against c2pa-rs built from source + +# Validates c2pa-python against a c2pa-rs git ref that has no published +# release artifacts (e.g. a release candidate tag), by building the native +# library from source instead of downloading a prebuilt one. +# +# This is the reusable tool for RC preflights: commit the target ref to +# c2pa-rs-preflight-ref.txt on a PR branch. Its mere presence is what opts +# the PR in, so this reruns automatically on every push to that PR -- same +# as any other check -- instead of you having to remember to re-dispatch by +# hand while iterating on fixes. Delete the file again once the PR is done +# with the RC (or once c2pa-rs ships a real release and you bump +# c2pa-native-version.txt through the normal process instead). +# +# Deliberately no workflow_dispatch trigger here: dispatching this workflow +# against the default branch would run in a context with write access to +# the default branch's Actions cache scope, while checking out and +# executing an arbitrary, unvalidated c2pa-rs ref -- exactly the cache +# poisoning pattern CodeQL's actions/cache-poisoning/poisonable-step query +# looks for. A same-repo pull_request only ever gets write access to its +# own branch's cache scope, so that path doesn't have the same exposure. + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + +permissions: + contents: read + +jobs: + resolve-ref: + name: Resolve c2pa-rs ref to test + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.resolve.outputs.ref }} + steps: + - uses: actions/checkout@v4 + - name: Resolve ref + id: resolve + run: | + if [ -f c2pa-rs-preflight-ref.txt ]; then + ref="$(tr -d '\r\n' < c2pa-rs-preflight-ref.txt)" + else + ref="" + fi + echo "ref=$ref" >> "$GITHUB_OUTPUT" + if [ -z "$ref" ]; then + echo "No c2pa-rs-preflight-ref.txt in this tree -- nothing to test, downstream jobs will skip." + else + echo "Testing against c2pa-rs ref: $ref" + fi + + tests-unix: + name: Unit tests (Unix, ${{ matrix.os }}) + needs: resolve-ref + if: | + needs.resolve-ref.outputs.ref != '' && ( + github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test') + ) + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ macos-latest, ubuntu-latest, ubuntu-24.04-arm ] + + steps: + - name: Checkout c2pa-python + uses: actions/checkout@v4 + with: + path: c2pa-python + + - name: Checkout c2pa-rs (${{ needs.resolve-ref.outputs.ref }}) + uses: actions/checkout@v4 + with: + repository: contentauth/c2pa-rs + ref: ${{ needs.resolve-ref.outputs.ref }} + path: c2pa-rs + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + # No pip cache here: this job checks out and builds an arbitrary, + # not-necessarily-reviewed c2pa-rs ref, and CodeQL flags caching in + # that context as a cache-poisoning vector into the default branch. + + - name: Install project dependencies + working-directory: c2pa-python + run: | + python -m pip install -r requirements.txt + python -m pip install -r requirements-dev.txt + + - name: Build native library from c2pa-rs source + working-directory: c2pa-python + env: + C2PA_RS_PATH: ${{ github.workspace }}/c2pa-rs + # Build for the runner's own arch rather than the universal2 macOS + # default: it's what a local `pip install -e .` picks up anyway, + # and skips the slow cross-compiled second-arch OpenSSL build. + C2PA_LIBS_PLATFORM: ${{ matrix.os == 'macos-latest' && 'aarch64-apple-darwin' || (matrix.os == 'ubuntu-24.04-arm' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }} + run: python scripts/build_local_artifacts.py --clean + + - name: Install package in development mode + working-directory: c2pa-python + run: | + pip uninstall -y c2pa + pip install -e . + + - name: Verify installation + working-directory: c2pa-python + run: python -c "from c2pa import C2paError; print('C2paError imported successfully')" + + - name: Run tests + working-directory: c2pa-python + env: + C2PA_PREFLIGHT_RUN: "1" + run: python -m pytest tests/test_unit_tests.py tests/test_sign_ladder.py + + tests-windows: + name: Unit tests (Windows, ${{ matrix.runs-on }}) + needs: resolve-ref + if: | + needs.resolve-ref.outputs.ref != '' && ( + github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test') + ) + + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + - runs-on: windows-latest + python-version: "3.10" + - runs-on: windows-11-arm + python-version: "3.11" # win-arm runner needs 3.11 at least + + steps: + - name: Checkout c2pa-python + uses: actions/checkout@v4 + with: + path: c2pa-python + + - name: Checkout c2pa-rs (${{ needs.resolve-ref.outputs.ref }}) + uses: actions/checkout@v4 + with: + repository: contentauth/c2pa-rs + ref: ${{ needs.resolve-ref.outputs.ref }} + path: c2pa-rs + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + # No pip cache here: this job checks out and builds an arbitrary, + # not-necessarily-reviewed c2pa-rs ref, and CodeQL flags caching in + # that context as a cache-poisoning vector into the default branch. + + - name: Install ARM64 OpenSSL via vcpkg (Windows ARM64) + if: matrix.runs-on == 'windows-11-arm' + shell: pwsh + run: | + # Pre-installed OpenSSL on runner fails build. + # Static OpenSSL to avoid runtime DLL load complexities. + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install openssl:arm64-windows-static-md + $vcpkgRoot = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static-md" + echo "OPENSSL_DIR=$vcpkgRoot" >> $env:GITHUB_ENV + echo "OPENSSL_STATIC=1" >> $env:GITHUB_ENV + + - name: Install project dependencies + working-directory: c2pa-python + run: | + python -m pip install -r requirements.txt + python -m pip install -r requirements-dev.txt + + - name: Build native library from c2pa-rs source + working-directory: c2pa-python + env: + C2PA_RS_PATH: ${{ github.workspace }}\c2pa-rs + run: python scripts\build_local_artifacts.py --clean + + - name: Install package in development mode + working-directory: c2pa-python + run: | + pip uninstall -y c2pa + pip install -e . + + - name: Verify installation + working-directory: c2pa-python + run: python -c "from c2pa import C2paError; print('C2paError imported successfully')" + + - name: Run tests + working-directory: c2pa-python + env: + C2PA_PREFLIGHT_RUN: "1" + run: python -m pytest .\tests\test_unit_tests.py .\tests\test_sign_ladder.py diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml new file mode 100644 index 00000000..cfdd83d7 --- /dev/null +++ b/.github/workflows/trusted-vsi-paired.yml @@ -0,0 +1,117 @@ +name: Trusted VSI functional source and wheel qualification (non-publishing) + +on: + workflow_call: + inputs: + python-ref: + description: Python source under test (defaults to the caller commit) + type: string + default: "" + +permissions: + contents: read + +jobs: + paired-api: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + library: libc2pa_c.so + - os: windows-2022 + library: c2pa_c.dll + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + CARGO_BUILD_JOBS: "1" + CARGO_INCREMENTAL: "0" + CARGO_TARGET_DIR: ${{ github.workspace }}/paired-rust/target + PYTHONPATH: ${{ github.workspace }}/python-source/src + C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/${{ matrix.library }} + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev + C2PA_TRUSTED_VSI_ABI_REQUIRED: "1" + C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1" + C2PA_REQUIRE_SIGN_LADDER: "1" + C2PA_REQUIRE_FRAGMENTED_FILES: "1" + FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0 + # The release smoke asserts the installed distribution version. + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev0 + steps: + - name: Require a full Python source SHA + shell: bash + env: + PYTHON_SOURCE_SHA: ${{ inputs.python-ref || github.sha }} + run: '[[ "$PYTHON_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.python-ref || github.sha }} + path: python-source + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: castlabs/c2pa-rs + # Integrated native review fixes plus the explicit revision-3 gate. + # Qualification-only source pairing; never an immutable dev5 input. + ref: a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c + path: paired-rust + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Record paired source identities + shell: bash + run: | + git -C python-source rev-parse HEAD + git -C paired-rust rev-parse HEAD + - name: Build isolated functional native library + shell: bash + working-directory: paired-rust + run: | + rustup toolchain install 1.96.0 --profile minimal + cargo +1.96.0 build --locked -p c2pa-c-ffi --no-default-features \ + --features rust_native_crypto,http,add_thumbnails,file_io,unstable_live_video + - name: Prepare source dependencies + shell: bash + working-directory: python-source + run: | + python -m pip install -r requirements.txt pytest==8.4.1 setuptools==68.0.0 toml==0.10.2 wheel==0.46.2 packaging==26.0 cbor2==5.9.0 + python setup.py egg_info + - name: Require functional native ABI and run focused tests without skips + shell: bash + working-directory: python-source + run: >- + python -m pytest -q tests/test_trusted_vsi_api.py + tests/test_fragmented_files.py tests/test_sign_ladder.py + tests/test_native_ownership.py tests/test_native_ownership_opaque.py -ra + - name: Real-native ladder harness (candidate lane) + shell: bash + working-directory: python-source + run: >- + python tests/ladder_native.py --lane candidate + --library "$C2PA_LIBRARY_NAME" + --native-fixtures "${{ github.workspace }}/paired-rust/sdk/tests/fixtures" + - name: Non-threaded regressions + timeout-minutes: 20 + shell: bash + working-directory: python-source + # The release smoke is required here (no module-level skip): it exercises + # dev5 capabilities against the paired functional native library. + env: + CASTLABS_RELEASE_SMOKE_REQUIRED: "1" + run: python -m pytest -q -ra --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 + - name: Threaded regressions + timeout-minutes: 15 + shell: bash + working-directory: python-source + run: python -m pytest -q tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 + - name: Build separate development wheel and sdist (no publication) + shell: bash + working-directory: python-source + run: python scripts/build_trusted_vsi_functional.py --library "$C2PA_LIBRARY_NAME" --out build/functional-qualification + - name: Qualify installed functional wheel without source or library overrides + shell: bash + working-directory: python-source + run: | + python scripts/qualify_trusted_vsi_functional.py \ + --wheel build/functional-qualification/*.whl \ + --venv build/functional-installed --version "$FUNCTIONAL_BUILD_VERSION" + # No upload, immutable release evidence changes, credentials, or publication. diff --git a/MANIFEST.in b/MANIFEST.in index c09288e4..b47df2b2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,5 +4,8 @@ include README.md include requirements.txt include scripts/download_artifacts.py include scripts/castlabs_release.py +include scripts/build_trusted_vsi_functional.py +include scripts/qualify_trusted_vsi_functional.py +include docs/trusted-vsi-python-contract.md recursive-include release *.json recursive-include src/c2pa *.py diff --git a/README.md b/README.md index 88c5a3d2..da1d8517 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ If you want to view the documentation in GitHub, see: - [Supported formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md) - [Configuring the SDK using `Context` and `Settings`](docs/context-settings.md) - [Using Builder intents](docs/intents.md) to ensure spec-compliant manifests +- [Signing single-file fragmented MP4 ladders](docs/ladder-signing.md) - Using [working stores and archives](docs/working-stores.md) - Selectively constructing manifests by [filtering actions and ingredients](docs/selective-manifests.md) - [Diagram of public classes in the Python library and their relationships](docs/class-diagram.md) @@ -84,7 +85,7 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH EXTRA_BUILD_ARGS="--debug" When running the tests against an unreleased source build whose SDK version differs from `c2pa-native-version.txt`, explicitly provide the expected source version: ```sh -C2PA_SOURCE_BUILD_VERSION=0.91.0-dev python3 tests/test_unit_tests.py +C2PA_SOURCE_BUILD_VERSION=0.92.0-dev python3 tests/test_unit_tests.py ``` The version test validates the loaded library against this value. When the variable is unset, it continues to validate downloaded release artifacts against `c2pa-native-version.txt`. @@ -100,6 +101,50 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH ## Castlabs VSI prerelease process +### Unreleased trusted-processor API qualification + +The [trusted-processor target API](docs/usage.md#trusted-processor-vsi-unreleased-disabled) +is disabled and separate from complete-buffer VSI and immutable dev5. Expert +signing accepts only exact `Sig_structure` bytes and targets a frozen +`TrustedVsiSignResult` (64-byte signature, uint32 sequence, optional inclusive +maximum). Every trusted probe remains false, import makes no native trusted +capability call, and session construction/operations fail before side effects. + +`build.yml` runs baseline-gated tests against its upstream downloaded native +library (`-k "not paired"`). Separately, both it and the dedicated workflow use +[`trusted-vsi-paired.yml`](.github/workflows/trusted-vsi-paired.yml) for isolated +Linux/Windows source builds and the full focused `tests/test_trusted_vsi_api.py` +with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`. Missing reduced ABI symbols fail rather +than skip. The paired Rust source is pinned to +`castlabs/c2pa-rs@cee86aae03887b5a0dddcd765a39e96360963bb0`; the job logs resolved +Python/Rust SHAs. This disabled-scaffold qualification pin is separate from +immutable dev5 release inputs and is not release-artifact evidence. + +For the dedicated workflow, manually select the trusted-API workflow revision, +set `trusted_vsi_only=true`, and supply its full Python `source_sha`. This path +skips **all** dev5 prepare/build/test/publish jobs. The reusable job builds only +an isolated native library and source-import metadata, never stages a release +native, builds/uploads a wheel, or publishes anything. Ordinary dev5 dispatch +and tag behavior, native pins, evidence, and release identity remain unchanged. +Do not package this new ABI under a dev5 artifact name. + +To test a locally built paired native without reinstalling the Python package: + +```sh +PYTHONPATH="$PWD/src" \ +C2PA_LIBRARY_NAME=/absolute/path/to/paired-c2pa-rs/target/debug/libc2pa_c.so \ +C2PA_SOURCE_BUILD_VERSION=0.92.0-dev \ +C2PA_TRUSTED_VSI_ABI_REQUIRED=1 \ +python -m pytest -q tests/test_trusted_vsi_api.py -ra +``` + +For an old native library, omit the required-ABI variable for honest local +skips, or use `-k "not paired"` for baseline-only coverage. Neither is paired +qualification. A fresh source checkout also needs distribution metadata for +imports (`python setup.py egg_info`, without a native download or wheel build). + +### Immutable dev5 process + The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.8.dev5` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.88.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. The immutable `castlabs-v0.37.8.dev1` tag records failed prerelease workflow run `34030865864`. Linux compiled successfully but its combined DynamicAssertion-plus-VSI smoke used a 5-byte callback result for a 64-byte reservation and later failed with `assertion.bmffHash.mismatch`; Windows compiled successfully and failed only in platform-sensitive wheel metadata parsing. The Linux failure was an undersized callback contract violation, not an inherent incompatibility between DynamicAssertions and VSI. No dev1 draft or GitHub release was created, and the tag remains unchanged. diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 8ea82960..d642f3f6 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.90.15 +c2pa-v0.91.0 diff --git a/docs/archive/trusted-vsi-review-verification.md b/docs/archive/trusted-vsi-review-verification.md new file mode 100644 index 00000000..10b63325 --- /dev/null +++ b/docs/archive/trusted-vsi-review-verification.md @@ -0,0 +1,603 @@ +# Trusted VSI Review Verification + +Archived verification/provenance, not the product contract or authorization to +merge, repin, label or dispatch CI. See the current +[Python contract](../trusted-vsi-python-contract.md) for the deliberate integration +hold. Historical counts below precede the subsequent review follow-ups. + +The revision-3 gate was added after the Python review merge `71d7cf9` on +`qualification/trusted-vsi-integrated-review`. Earlier results below predate that +gate: neither SDK `0.92.0-dev` nor mask 63 identifies a trusted-VSI contract. +The preserved step2 cdylib `149f4b25...` and older workflow pairing lack the new +stateless revision probe and now fail closed before operational ABI binding. +References below to a deferred step3 probe describe the historical review, not +the current Python contract. Final committed-native/hosted qualification remains +pending; the integrated local revision-3 qualification is recorded below. + +## Earlier Pairings + +The former product-contract status described qualification against the +consolidated functional native from `castlabs/c2pa-rs` +`feat/trusted-vsi-functional` (`0.92.0-dev`, Rust 1.96.0, mask 63). The current +workflow pin `6b506352800c8225cf5564ce99c726aaa71039f4` is `203dc08d` +(ContentAuth main `69907b5a` merged) plus CI-only fixes: rustls `0.23.45` / +rustls-webpki `0.103.15` for RUSTSEC-2026-0285, test-only lint scopes, a feature +gate on a crate-private helper and rustdoc, with no C ABI/capability change. +The Python source integrates `Builder.sign_ladder` and carries unreleased +identity `0.37.13.dev0`. + +The `203dc08d` pairing was qualified at Python +`5c64f2cc090eeb29506bc766faa69b959e4ed982` by hosted Linux/Windows paired run +`castlabs/c2pa-python` Actions `36793704783`: focused 186, ladder harness, +non-threaded 730, threaded 54, installed-wheel 186. Qualification of the +`6b506352` pairing was recorded on castlabs/c2pa-python#4 by run ID. These +pairings do not qualify the new state-v3/input contract. + +Earlier native `5c186c07` (debug library SHA-256 +`dc79e81a084fc7b25e12423539b137f24d69693da46cb0166cb04538bd5589f9`) at Python +`941c2ad5b57d23f31dbabf9fbef4776878cf630c`: local Linux focused 179, +ladder harness, non-threaded 714, threaded 54, installed-wheel 179 passed; +hosted Linux/Windows paired run `36671268428` passed on both. Local +qualification-only artifacts (never published): +`c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` SHA-256 +`8731d135ce2c1db61b061e1f2c76272a55b9f7e7e2e2ea8769b10b5fd4a8707f`, sdist +`e846e5688d07bcf5959885c0c1728afde4ec89bbb7cf2b80a665956056b8b5ab`. +The earlier `0.37.9.dev0` artifacts paired with native `3569fb86` are historical +only. Immutable dev5 release inputs/artifacts are unchanged. + +## Initial Local Review + +Python worktree: `fix/trusted-vsi-review-python`, base +`12d265db92e8dcbf80b8255278e9a7fc5945f750`, uncommitted review changes. +Native input: `/root/opencode-worktrees/c2pa-rs-trusted-vsi-review-native`, +uncommitted step2 changes atop `d6e7b529581a4dfc0fd5585773d246ee2c191378`. +Only the cdylib was built, reusing the existing +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target` with Rust 1.96.0, +one Cargo job, incremental disabled, dev debug info disabled, and features +`rust_native_crypto,http,add_thumbnails,file_io,unstable_live_video`. +The loaded `debug/libc2pa_c.so` reports `0.92.0-dev`, mask 63, SHA-256 +`149f4b250a5d697e38355a3f3f08ce1abf61c7e2b0418175153f690a456eb357`. +This records a local binary, not a new native pin or published revision. + +The initial frozen-source tests used these selections without an editable install: + +```sh +export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src +export C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so +export C2PA_SOURCE_BUILD_VERSION=0.92.0-dev C2PA_TRUSTED_VSI_ABI_REQUIRED=1 +export C2PA_REQUIRE_SIGN_LADDER=1 C2PA_REQUIRE_FRAGMENTED_FILES=1 +python3 -m pytest -q tests/test_trusted_vsi_api.py tests/test_fragmented_files.py tests/test_sign_ladder.py tests/test_native_ownership.py tests/test_native_ownership_opaque.py tests/test_trusted_vsi_build.py -ra +python3 -m pytest -q -ra --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 +python3 -m pytest -q tests/test_unit_tests_threaded.py -ra -o faulthandler_timeout=120 +``` + +Initial results: focused 228 passed / 11 subtests; non-threaded 750 passed / +128 subtests / one module skip; threaded 54 passed. The skip is the existing +release-smoke module requiring an explicitly qualified installed wheel; no wheel +qualification or immutable dev5 evidence is claimed. An earlier run during edits +had four source-introspection subtest failures from changed source line offsets; +the frozen-source rerun passed them without changing those assertions. +Both `tests/ladder_native.py` candidate and stock harnesses passed. + +Stock compatibility used the official ContentAuth `c2pa-v0.91.0` Linux x86-64 +release library (SDK `0.91.0`, SHA-256 +`dfa68d2a8ee739bb75919dcb5300f2cb289e1b51ecce94b0f339ff287ebe0703`), which lacks +dynamic-assertion registration, ladder signing and live-video callback exports. +With `C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/python-review-stock/lib/libc2pa_c.so`, +`C2PA_SOURCE_BUILD_VERSION=0.91.0`, and required-capability flags unset: +`pytest -q tests/test_trusted_vsi_api.py tests/test_sign_ladder.py tests/test_fragmented_files.py tests/test_native_ownership.py -k 'not paired' -ra` +passed 117 / skipped 19 / deselected 70. Exactly four skips are the scripted +dynamic wrapper cases; required qualification makes that missing export fail. +The same stock library passed 101 lifecycle/object/fork tests plus 49 subtests +(the two ManagedResource unit classes and ManagedResourceForkGuard). +Actual-native Builder callback-close subprocesses run in the stock lane too. +The initially suggested prior packaged library reports `0.80.0` and lacks the +required `c2pa_reader_crjson` export, so it was not stock-0.91 evidence. + +## Native Status Compatibility + +Read-only inspection of +`6b506352800c8225cf5564ce99c726aaa71039f4:c2pa_c_ffi/src/live_video.rs` confirmed +`pub blocked: bool` at line 214, native conversion `blocked: rust.blocked()` at +line 616, and the offset-18 assertion at line 2035. The Python status fix fills +the missing bridge, not a missing native field in the actual workflow pin. +Arbitrary same-version/mask library identity remains a step3 concern, not a +demonstrated missing-`blocked` ABI mismatch against `6b506352`. + +## Review Follow-Up Checks + +The follow-up changed Python preflight/admission cleanup, documentation and +tests only. No native build, installation, full-suite rerun, target modification +or artifact cleanup was performed. The preserved step2 cdylib still has SHA-256 +`149f4b250a5d697e38355a3f3f08ce1abf61c7e2b0418175153f690a456eb357`. +Focused pytest used `PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=src` and +`-p no:cacheprovider`; small generated fixtures used fresh, explicitly owned +`python-review-followups-20261007-v*` scratch directories under the approved temp +root. The native target was never used as scratch or changed. + +With the preserved step2 library and the required-capability flags from the +initial local section: + +```sh +python3 -m pytest -q -p no:cacheprovider --basetemp=/tmp/opencode-v1.18.29/opencode/python-review-followups-20261007-v3 tests/test_trusted_vsi_api.py tests/test_fragmented_files.py tests/test_sign_ladder.py tests/test_native_ownership.py tests/test_native_ownership_opaque.py tests/test_trusted_vsi_build.py -ra +``` + +Result: **241 passed / 11 subtests, no skips**. New coverage includes Context +close inside a context-signing Builder callback, two admitted bookkeeping guards +with staggered drain, CLOSED-pending `_release_handle()` preservation, logical +Signer close before preflight vs during admission, interrupted admission with +exception identity, and non-consuming bad-format preflight. The subprocess +lifetime proof has a bounded 90-second timeout. These checks do not authorize +concurrent native operations or establish generic thread safety. + +With the official stock `0.91.0` library/path from the initial local section, +`C2PA_SOURCE_BUILD_VERSION=0.91.0` and required-capability flags unset: + +```sh +python3 -m pytest -q -p no:cacheprovider --basetemp=/tmp/opencode-v1.18.29/opencode/python-review-followups-20261007-v4 tests/test_trusted_vsi_api.py tests/test_sign_ladder.py tests/test_fragmented_files.py tests/test_native_ownership.py tests/test_trusted_vsi_build.py -k 'not paired' -ra +python3 -m pytest -q -p no:cacheprovider tests/test_unit_tests.py::TestManagedResourceObjects tests/test_unit_tests.py::TestManagedResourceLifecycle tests/test_unit_tests_threaded.py::TestManagedResourceForkGuard -ra +``` + +Results: **148 passed / 19 capability skips / 70 deselected**, including the +renamed non-native CI-gate test; **101 passed / 49 subtests** for stock +lifecycle/object/fork checks. The stock subprocess proof now also covers closing +Context inside the signing callback. + +Selective older-library compatibility used +`C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/libc2pa_c-203dc08d.so`, +`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`: + +```sh +python3 -m pytest -q -p no:cacheprovider tests/test_trusted_vsi_api.py -k 'callback_exception_identity_blocked_state_and_durable_retry or reentrant_close_native_subprocess or value_wrappers_are_frozen_and_v1_layouts_exact' -ra +``` + +Result: **7 passed / 140 deselected**. This older artifact reports `0.92.0-dev` +and mask 63, SHA-256 +`0401bf3da2060fbdae3223ec0feb926f836b59604d9d4fe9c993122e29d5b6fe`. +The checks exercise healthy/failed `blocked` status, exact layout and actual +native callback-close paths without requiring state version 3. The artifact's +filename is not an embedded revision attestation. The read-only `6b506352` +source inspection above independently confirms that the actual CI pin writes +`blocked`. Neither narrow compatibility evidence nor the older artifact's +version/mask qualifies the new state-v3 contract. Repin/integration, the deferred +step3 revision probe, hosted paired qualification and any maintainer CI label +remain on hold; no merge-readiness claim is made. + +## Final Admission Review + +The final follow-up documents the deliberate foreign-PID restriction on guarded +admission, not a universal SDK fork ban. It adds rejection-before-lock/FFI and +pending consumed-teardown regressions, clarifies `_release_handle()`'s deferred +semantics, and explains why Builder keeps both close points. No runtime guard +policy was broadened, native files changed, or native/full-suite builds run. + +The admission test follows the existing PID-simulation convention: an inherited +lock fails if touched, an FFI spy must not run, and rejection must leave the +handle, callback pins and lifecycle unchanged without release/free. No actual +fork fixture exists in the current suites, so no actual-fork subprocess proof is +claimed. The consumed teardown test requests `free_handle=False` inside an +admitted bookkeeping guard, preserves the False pending decision and pins until +exit, then requires one release and zero native frees. + +With the preserved step2 library, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, +`C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, `PYTHONDONTWRITEBYTECODE=1` and `PYTHONPATH=src`: + +```sh +python3 -m pytest -q -p no:cacheprovider tests/test_trusted_vsi_api.py -k 'call_guard or admitted_call_guards or release_handle or consumed_teardown_inside or teardown_copies or scripted_reentrant_close or reentrant_close_native_subprocess' -ra +``` + +Result: **17 passed / 132 deselected**, with no new disk-backed fixture outputs. + +## Final Source Regression + +After the review follow-ups, an independent frozen-source run used the same +patched native library above, Python 3.12, `PYTHONDONTWRITEBYTECODE=1`, +`PYTHONPATH=src`, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and all four required +functional capability flags. The final run on 2026-10-07 passed: + +- non-threaded source suite: **765 passed, 128 subtests passed, one module skip**; +- threaded source suite: **54 passed**; +- `git diff --check`: passed. + +The module skip is the installed-release smoke described above, not a skipped +trusted-VSI capability. No installed-wheel or hosted qualification is claimed. +The first independent runner omitted `C2PA_SOURCE_BUILD_VERSION`, making the +version test compare this `0.92.0-dev` source-build library with the stock +`0.91.0` artifact pin. The runner was corrected to use the same explicit source +version as paired CI; no product code or version assertion was weakened. + +The preserved same-key init-epoch probe also passed all six cases against this +patched library and binding. It is compatibility evidence, not a lifecycle or +counter-carry implementation. +On official stock `0.91.0` with its library selection and required flags unset, +the same selection prefixed by `not paired and (...)` passed **15 / deselected +134**. The unchanged stock lifecycle/object/fork command above passed **101 / +49 subtests** again. These focused selections overlap prior evidence; their +counts must not be summed as unique qualification. Native integration/repin, +the step3 revision probe and hosted qualification remain pending. + +## Revision Gate Stock Checks + +After merge `71d7cf9`, the integrated Python worktree adds the exact revision-3 +gate. No native build was performed; the preserved step2 cdylib lacks the probe, +so real paired tests await the native agent's final library handoff. The workflow +pin, build helper and build metadata are unchanged. No commit, push, repin, CI +label or dispatch was performed for this gate. + +Using the official stock `0.91.0` library at +`/tmp/opencode-v1.18.29/opencode/python-review-stock/lib/libc2pa_c.so`, +`C2PA_SOURCE_BUILD_VERSION=0.91.0`, `PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=src`, +and all four required-capability flags unset: + +```sh +python3 -m pytest -q -p no:cacheprovider --basetemp=/tmp/opencode-v1.18.29/opencode/python-contract-revision-stock-20261007 tests/test_trusted_vsi_api.py tests/test_sign_ladder.py tests/test_fragmented_files.py tests/test_native_ownership.py tests/test_trusted_vsi_build.py -k 'not paired' -ra +python3 -m pytest -q -p no:cacheprovider tests/test_unit_tests.py::TestManagedResourceObjects tests/test_unit_tests.py::TestManagedResourceLifecycle tests/test_unit_tests_threaded.py::TestManagedResourceForkGuard -ra +``` + +Results: **208 passed / 19 expected stock capability skips / 70 deselected**; +**101 passed / 49 subtests passed**. The trusted-VSI-only non-paired selection +passed **133 / four expected dynamic-registration skips / 70 deselected** and +overlaps the broader command. Mocked raw-loader subprocesses verify that missing +or non-3 revisions cannot bind or call operational trusted ABI despite matching +SDK/symbol/mask claims. They also cover wrong SDK versions, missing symbols, +partial/unknown masks, exact revision-3 probe ordering, early constructor/helper +rejection and independent ordinary/legacy callback binding. Both required flags +are tested to make paired-fixture revision diagnostics fail, never skip. These +are scripted/stock compatibility checks, not real revision-3 native qualification. + +## Integrated Revision-3 Qualification + +Local Linux/Python 3.12 qualification on 2026-10-07 used +`qualification/trusted-vsi-integrated-review`, HEAD +`71d7cf958a41be0f40c2a350b7bcdd6027b55170`, plus the uncommitted revision-gate +changes. The supplied native library was built separately from native integrated +HEAD `859a8584e13c7767e16fe12257e3ad69523ed7cf` plus the uncommitted revision +probe. No native build was performed by this Python qualification runner. +Preflight required SDK `0.92.0-dev`, exact contract revision 3, mask 63 and all +six Python availability probes true against +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so`. +Its SHA-256 was +`585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950`, checked again +at completion. This is local binary provenance, not an embedded commit identity +or a final native pin. + +The first installed run found a test-only assumption in the raw-loader matrix: +its subprocess forced the checkout's `src/` even in an installed-wheel lane, +where no source-local library is staged. `test_trusted_vsi_api.py` now selects +the parent's actual package directory and asserts the child imports that same +binding file. Production code and qualification gates were not changed. A +separate first ladder child had transient Python-startup `ENOMEM` while scanning +an unrelated editable-package path on `/mnt/c`; the identical harness passed +on retry. The original logs/artifacts were retained in +`/tmp/opencode-v1.18.29/opencode/python-revision3-qualification-20261007/`. + +Final frozen-source results, rerun in a fresh directory after the test fix: + +| Lane | Result | +|---|---| +| Actual-native focused source (trusted VSI, fragmented, ladder, ownership, opaque ownership, build tooling) | 301 passed, 11 subtests passed | +| Candidate ladder harness with native integrated `sdk/tests/fixtures` | Passed signing, shared-manifest/tamper, path-refusal and callback-lifecycle checks | +| Installed-wheel functional qualifier | 283 passed, 11 subtests passed | +| Installed-wheel release smoke | 5 passed | +| Full non-threaded source, including release smoke | 828 passed, 128 subtests passed; 39 existing deprecation warnings | +| Full threaded source | 54 passed | +| `git diff --check` | Passed | + +There were **no skips or deselections** in these final lanes. Counts overlap and +must not be summed as unique qualification. Source runs used +`PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=/src`, the explicit library +above, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and all four flags set to `1`: +`C2PA_TRUSTED_VSI_ABI_REQUIRED`, `C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED`, +`C2PA_REQUIRE_SIGN_LADDER`, `C2PA_REQUIRE_FRAGMENTED_FILES`. + +The existing build helper created qualification-only `0.37.13.dev0` artifacts +using the explicit library. The existing qualifier created a fresh isolated venv +with system dependencies and installed the wheel with `--no-deps`; the runner +set `PIP_NO_INDEX=1`. Installed identity checks proved both `c2pa` and its bundled +library reside under that venv, with no `PYTHONPATH`, `C2PA_LIBRARY_NAME`, +`LD_LIBRARY_PATH` or `DYLD_LIBRARY_PATH` overrides. The bundled library hash, +revision, mask and all six probes were checked. Installed binding bytes matched +the source binding SHA-256 below. Only after installed qualification passed did +the full source lanes enable `CASTLABS_RELEASE_SMOKE_REQUIRED=1` with +`CASTLABS_RELEASE_EXPECTED_VERSION=0.37.13.dev0`, using the venv interpreter for +matching distribution metadata while explicitly selecting the frozen source and +reviewed native library. This is not immutable dev5 release evidence. + +Final evidence root: +`/tmp/opencode-v1.18.29/opencode/python-revision3-qualification-20261007-v2/`. +It contains `status.json` (all final steps successful), `runner.log`, +`preflight.log`, `source-focused.log`, `ladder-candidate.log`, `build.log`, +`installed-focused.log`, `installed-identity.log`, `installed-release-smoke.log`, +`source-metadata.log`, `source-nonthreaded.log`, `source-threaded.log`, +`source-before.json`, `source-after.json`, `source-before.patch`, and +`dist/functional-build.json`. The before/after source snapshots were identical +through qualification; this archive section was appended afterward. + +| Qualified input/artifact | SHA-256 | +|---|---| +| Uncommitted source patch snapshot (`source-before.patch`) | `166cb29c578081a9046ac5413569a8de5ff5f3ec9646a2bbfd063a90350dcf48` | +| Source/installed `c2pa.py` | `928ffc0871bf49fd8bf805c7a672499b6ea7de4cba3844968dd5832f684925bb` | +| `tests/test_trusted_vsi_api.py` | `eadf6f0088b71285e7cd8c36caff47af8fdaac4cf0a710a68058a6ccf63eca89` | +| `dist/c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` | `1b04b29aaf921f17bf3d81899e81101f10c4958c8224e3d541e86373158eb20e` | +| `dist/c2pa_python-0.37.13.dev0.tar.gz` | `dd290b7b4aec9e0af61b1fe12980770e1d0c5f9720f832df88bcecca0a2b1b8f` | + +No build-helper metadata fact or public revision getter was added. No native +build, other-worktree edit, pin/workflow change, commit, push, CI label or +dispatch occurred. Nothing was staged. Final native commit integration/repin and +hosted qualification remain separate authorized work. + +## Segmented ABR Init-Glob Integration Fix + +On 2026-10-07 the signer integrated caller at +`src/stardustproof_c2pa_signer/manifest.py:2431` was inspected read-only. It calls +`Builder.sign_fragmented` with keywords `signer`, `asset_path` (a staged +`*/init` glob), `fragments_glob` (init-relative), and `output_dir`. Python's +literal-file/glob ban prevented this native-supported operation before FFI. +Only that filesystem preflight was removed; text/path-like, UTF-8, empty/NUL, +capability, ownership, callback and admission checks are unchanged. There is no +compatibility shim, alternate native path, per-rendition signing loop, new API, +descriptor JSON, filepath alias or source/output policy relaxation. + +### Native Contract Proof + +Read-only inspection used clean native integrated HEAD +`a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`, tree +`f8335291eb744790c10fc71000e5b6116f95e213`: + +- `c2pa_c_ffi/src/c_api.rs:2436-2551`: `reserve_fragmented_output` expands the + native init glob, joins fragment globs to each init parent, checks empty matches + and flattened-name collisions, rejects existing rendition output entries, then + exclusively reserves output directories and init files. Reservation/signing + failures can leave partial outputs; there is no automatic cleanup. +- `c2pa_c_ffi/src/c_api.rs:2554-2627`: the public six-argument C ABI accepts an + init path/glob, fragment glob and output root, passes the DA-aware borrowed + signer to `sign_fragmented_files`, then returns manifest bytes read from the + first output through the Builder Context. No multi-asset descriptor mode is + necessary. Python continues to use this exact ABI. +- `sdk/src/builder.rs:3483-3551`: `Builder::sign_fragmented_files` documents + multi-init globs and passes the expanded paths to a single Store operation. +- `sdk/src/store.rs:2982-3271`: `save_to_bmff_fragmented` validates fragment + matches, flattened layouts and source/output separation, assigns a distinct + `uniqueId`/`localId` pair to each rendition, and signs one shared manifest. + The Rust SDK allows existing non-source output inits; the C/Python ABI does + **not** inherit that overwrite allowance, because its reservation preflight + rejects existing rendition directories. +- `docs/archive/vsi-consolidation-merges.md:74-82` records integration of #17's + glob-aware, exclusively reserved FFI contract, superseding the older literal + compatibility shape. Native tests `fragmented::sign_bunny_segmented_renditions` + and `fragmented::rejects_collisions_before_writes` at + `c2pa_c_ffi/src/c_api.rs:3994` and `:4091` corroborate the documented contract; + those native tests were inspected, not rerun here. + +| Inspected native/SDK file | SHA-256 | +|---|---| +| `c2pa_c_ffi/src/c_api.rs` | `024e069363f1ff16cb3392afaf66bdeaeb5c415498d903a3ed3d440dfa3e546a` | +| `sdk/src/builder.rs` | `b4de95c42eeac9a80d251e16c30a9535bacd2abafcef9bfd68a89ecba7f66ff3` | +| `sdk/src/store.rs` | `d30fa3b8b8c02728fbd084feff28af3b809f1299f143b5a44a5ce33283c8beb2` | + +These three files are unchanged between `859a8584` and `a6d4cdcc`. The supplied +binary's earlier build provenance is `859a8584` plus the then-uncommitted +revision probe, as recorded above. The probe was subsequently committed in +`a6d4cdcc`; inspecting the current clean tree is not a claim that an embedded +version/probe attests that commit, nor an independent reconstruction of the +original build. No native source, target, library or ABI was changed in place, +and no native build or new target prefix was used. + +### Python Source Verification + +Python integrated HEAD remains `71d7cf958a41be0f40c2a350b7bcdd6027b55170`, tree +`80edbf39dcfcd504e9d1e66af7e8869d695c06f9`, plus the preexisting revision-gate +changes and this uncommitted wrapper/test delta. Existing dirty changes in +`tests/test_trusted_vsi_api.py` and `docs/trusted-vsi-python-contract.md` were +preserved; neither file was edited for this fix. Source patch fingerprint +(`git diff --binary -- src tests | sha256sum`) for this initial verification was +`30545de8d5371532fea4730143d2fbd63ac00b1bf1944251dfeb767dbd6cd52e`. + +| Tested Python source | SHA-256 | +|---|---| +| `src/c2pa/c2pa.py` | `dd46aac5c2d22927c442affcda873edfe87112f91d5cf3c9822d68b5c9dcebc6` | +| `tests/test_fragmented_files.py` | `b05942c3283f05e146c0e1d536e1af862458924903def1207299409db4fba5b9` | +| Unchanged preexisting `tests/test_trusted_vsi_api.py` | `eadf6f0088b71285e7cd8c36caff47af8fdaac4cf0a710a68058a6ccf63eca89` | + +The explicit loaded native library remains +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so`, +SHA-256 `585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950`, +checked before and after testing. Preflight confirmed the source binding path, +SDK `0.92.0-dev`, revision 3, mask 63, all six trusted-VSI probes, fragmented +capability and ladder capability. Tests used Python 3.12 and: + +```sh +export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src +export C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so +export C2PA_SOURCE_BUILD_VERSION=0.92.0-dev +export C2PA_TRUSTED_VSI_ABI_REQUIRED=1 C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED=1 +export C2PA_REQUIRE_SIGN_LADDER=1 C2PA_REQUIRE_FRAGMENTED_FILES=1 +python3 -m pytest -q -p no:cacheprovider tests/test_fragmented_files.py -ra +python3 -m pytest -q -p no:cacheprovider tests/test_trusted_vsi_api.py tests/test_fragmented_files.py tests/test_sign_ladder.py tests/test_native_ownership.py tests/test_native_ownership_opaque.py tests/test_trusted_vsi_build.py -ra +python3 -m pytest -q -p no:cacheprovider --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 -ra +python3 -m pytest -q -p no:cacheprovider tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 -ra +``` + +| Source lane | Result | +|---|---| +| Fragmented file APIs | 14 passed, 18 subtests passed | +| Focused native/ownership/build suite | 304 passed, 18 subtests passed, no skips | +| Full non-threaded source | 826 passed, 135 subtests passed, one existing installed-release-smoke module skip, 39 deprecation warnings | +| Full threaded source | 54 passed | + +The final focused run (26.83s) includes an added explicit nonempty-manifest +assertion, preventing a vacuous empty-byte containment check. The full runs and +standalone fragmented run preceded that one-line test strengthening: their +fragmented test file SHA-256 was +`850aacae08cac6ebdc021e440a12c4382a749d8a02ab04e472b50bee6e2ba533`, with source +patch fingerprint `f647e32a5cc56742ad7dce598ee972fc79a8ae5644e6ee68895fd6601a68cc8c`. +The production binding and native binary were identical in all runs. + +The first full non-threaded/threaded attempts exceeded the shell's 120-second +limit and were terminated, not counted as passes. Identical commands rerun with +a 600-second shell limit completed in 377.01s and 179.62s. No test expectations +or unrelated gate-test source were changed to obtain these results. + +The new native-backed fragmented tests exercise the unchanged keyword interface: +two synthetic renditions derived from the committed DASH fixtures, identical +returned JUMBF bytes in both signed inits, distinct fragment selectors `(1,1)` / +`(2,2)` matching the two maps in the shared manifest, successful validation of +both renditions, rejection of changed media retaining the wrong selector, +unmodified sources, preservation of an existing destination and rejection of +an existing source-directory output at the C ABI's existing-directory preflight +(not an exercise of the SDK's filesystem identity guard), malformed init/fragment +globs, and empty matches. +They establish wrapper/native multi-init behavior, not encoding/playback of a +real ABR ladder or the signer's full regression gate. Documentation now describes +the segmented shape and distinguishes it from single-file `sign_ladder`. + +This delta is source-qualified only. No wheel/sdist rebuild, installed-wheel +qualification, release-version/pin change, signer/native/B edit, commit, push, +repin, CI label or dispatch occurred; nothing was staged. Earlier wheel results +above do not qualify this new source delta. Fresh main-agent code review and +signer segmented-ABR regression remain the handoff; compatibility with the +released `0.31+stardustproof6` library was not tested here. + +### Minor Review Follow-Up + +The 2026-10-07 follow-up changed only four files in this Python worktree: +`tests/test_fragmented_files.py`, the `Builder.sign_fragmented` docstring in +`src/c2pa/c2pa.py`, `docs/usage.md`, and this archive. Production runtime behavior +and all preexisting revision-gate hunks remain unchanged; nothing was staged. + +`cbor2` is no longer a module-level dependency of the fragmented tests. Only the +Merkle-selector test calls `pytest.importorskip("cbor2")`, lazily; missing CBOR +skips that test in optional mode but produces a clear `pytest.fail` with the +`requirements-dev.txt` install instruction when +`C2PA_REQUIRE_FRAGMENTED_FILES=1`. The fixture-only inline walker now explicitly +documents its nonzero 32-bit box-size/8-byte-header constraint; it is not a +general BMFF parser. The existing source-directory test is named/commented to +identify its earlier C ABI existing-directory rejection, not imply SDK identity +guard coverage. + +The docstring and usage documentation scope init-glob and exclusive-output +guarantees to the paired glob-aware Castlabs `0.92.0-dev` native including #17. +Export presence alone does not prove those semantics in older lineages. Older +releases can contain backports (including stable Castlabs `0.80`); no blanket +pre-`0.92` rejection, fallback or new capability/version gate was added. Named +init parent directories and the `C2paError.Encoding` exception are documented. + +Using the same source environment/required flags and unchanged SHA-256 +`585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950` native library +above, `pytest -q -p no:cacheprovider tests/test_fragmented_files.py -ra` passed +**14 tests / 18 subtests** in 0.73s. + +A separate source-only missing-dependency simulation used an import finder to +raise `ModuleNotFoundError` for `cbor2` and `pytest`, then imported the module +with `runpy.run_path` successfully without either dependency. After unblocking +pytest (but retaining the CBOR block), with `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`: + +- `--collect-only tests/test_fragmented_files.py`: **14 tests collected**. +- The module excluding the Merkle-selector test, with fragmented required: + **13 passed / 18 subtests / one deselected**. +- The Merkle-selector test with fragmented required unset: **one expected skip**. +- The same test with `C2PA_REQUIRE_FRAGMENTED_FILES=1`: **one expected failure**, + with the missing-dev-dependency install diagnostic. The outer simulation + asserted pytest's exit code was 1 and completed successfully; this is negative + coverage, not a failed qualification lane. + +| Final follow-up source | SHA-256 | +|---|---| +| `src/c2pa/c2pa.py` (docstring-only follow-up) | `1f9096ff4dcc0d66835d177c1b6fffc4c9f54df6b03e46b2b165d9cabea0673f` | +| `tests/test_fragmented_files.py` | `2f9615d71777e8586d2a58e04745f0029a66d8efcd3fb9aad247e39c28f61a1a` | +| `git diff --binary -- src tests` | `5dbfc02dbf128803727848a9d26b8c304d3854fb03d5e49554d3a60fdb8bd743` | + +Earlier full-suite counts apply to the source snapshots identified above, not a +rerun of this follow-up. No installed-wheel qualification or rebuild is claimed; +main will rebuild/qualify the final source separately. No native/signer/B edits, +builds, commits, pushes, repins, CI changes or staging occurred. + +## Final Glob-Aware Wheel Qualification + +The final local qualification on 2026-10-07 supersedes the pre-glob installed +coverage above. In particular, the older `1b04b29a...` wheel does **not** qualify +the current wrapper or lazy-CBOR test follow-up. This run rebuilt both +qualification-only `0.37.13.dev0` artifacts from the latest frozen source, +including the glob-aware wrapper, final docstring, lazy `cbor2` fixture and +main's final full native workflow pin +`a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`. + +Python HEAD remained `71d7cf958a41be0f40c2a350b7bcdd6027b55170` plus uncommitted +integration changes. The supplied same-production native library remained +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so`, +SHA-256 `585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950`. +Preflight and installed checks required SDK `0.92.0-dev`, exact contract revision +3, mask 63 and all six trusted-VSI probes true. The native production-tree/pin +handoff is provenance supplied by main; the runtime probe is not an embedded +commit attestation. No native build or target modification was performed here. + +The existing build/qualifier scripts were used unchanged, offline with +`PIP_NO_INDEX=1`, a fresh output directory and a fresh system-dependency venv. +Both the source and installed dependency metadata reported `cbor2==5.9.0`. +The wheel's `c2pa/c2pa.py` bytes were hashed and compared with the frozen source +before qualification. Installed identity subsequently proved the imported +package, binding and bundled library reside under the fresh venv, with no +`PYTHONPATH`, `C2PA_LIBRARY_NAME`, `LD_LIBRARY_PATH` or `DYLD_LIBRARY_PATH` +overrides. Installed binding bytes matched that same frozen source hash. + +| Final lane | Result | +|---|---| +| Installed functional qualifier (including current fragmented ABI tests) | 286 passed, 18 subtests passed | +| Explicit installed two-rendition glob/selector and destination-refusal cases | 2 passed, 2 subtests passed | +| Installed release smoke | 5 passed | +| Workflow paired/dev5 isolation test, run separately with required flags | 1 passed | +| Full non-threaded source with release smoke enabled | 831 passed, 135 subtests passed; 39 existing deprecation warnings | +| Full threaded source | 54 passed | +| `git diff --check` | Passed | + +These functional lanes had **zero skips and zero deselections**. Counts overlap +and must not be summed as unique qualification. Source runs used +`PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=/src`, the explicit library +above, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and all four required flags set to +`1`: `C2PA_TRUSTED_VSI_ABI_REQUIRED`, `C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED`, +`C2PA_REQUIRE_SIGN_LADDER`, `C2PA_REQUIRE_FRAGMENTED_FILES`. Only after fresh +installed identity/qualification passed did full source runs enable +`CASTLABS_RELEASE_SMOKE_REQUIRED=1` and +`CASTLABS_RELEASE_EXPECTED_VERSION=0.37.13.dev0`, using the qualified venv's +matching metadata while explicitly loading the frozen source/native input. +The full jobs ran in separate processes under a detached, logged runner. + +A separate installed-wheel missing-CBOR simulation blocked `cbor2` imports only +inside fresh child processes; it did not uninstall shared dependencies or alter +the wheel. Package-location checks proved these children used the installed +wheel with no source/library overrides. With plugin autoload disabled: + +- Fragmented-module collection succeeded: **14 tests collected**, pytest exit 0. +- Optional Merkle-selector execution produced **one intentional skip**, exit 0. +- Required Merkle-selector execution produced **one expected failure**, exit 1, + with the `C2PA_REQUIRE_FRAGMENTED_FILES=1 requires cbor2` diagnostic. The outer + harness asserted that exit code and passed. This is negative dependency + coverage, not a failed or skipped functional qualification lane. + +Evidence/artifact root: +`/tmp/opencode-v1.18.29/opencode/python-final-glob-qualification-20261007/`. +Logs include `preflight.log`, `build.log`, `installed-focused.log`, +`installed-identity.log`, `installed-glob-abi.log`, `installed-release-smoke.log`, +`cbor-absent-collection.log`, `cbor-absent-normal.log`, +`cbor-absent-required.log`, `source-identity.log`, `workflow-isolation.log`, +`source-nonthreaded.log`, `source-threaded.log` and `runner.log`. +`status.json` reports all steps passed. `wheel-source-identity.json` and +`dist/functional-build.json` record byte/artifact identities. Before/after +snapshots in `source-before.json` and `source-after.json` are identical, excluding +only this informational archive file; staged diffs were empty. This final section +was appended afterward. No production, test, helper, workflow or pin edits were +made during this qualification. + +| Final input/artifact | SHA-256 | +|---|---| +| Frozen source patch excluding this archive (`source-before.patch`) | `8d6124397272a18ad23df89444a7e4a5c0a4705a952796dd1f63b91fc0207691` | +| Source, wheel and installed `c2pa.py` | `1f9096ff4dcc0d66835d177c1b6fffc4c9f54df6b03e46b2b165d9cabea0673f` | +| `tests/test_fragmented_files.py` | `2f9615d71777e8586d2a58e04745f0029a66d8efcd3fb9aad247e39c28f61a1a` | +| `dist/c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` | `847666afc16fd448e0e2bbf8fc408134f3ad87dea3486c63bc45680edb80f08b` | +| `dist/c2pa_python-0.37.13.dev0.tar.gz` | `0f58c7bbdc4313c69ab7abb91ead30893308126bd52cb0d714d718f3b1066e36` | + +Only this archive verification record was updated. Old artifacts/logs and +immutable dev5 inputs/evidence were preserved. Nothing was staged or committed; +no push, CI label/dispatch, publication, other-worktree edit, native build or +native-target/pin modification occurred. This is local Linux qualification, not +hosted Linux/Windows CI evidence. diff --git a/docs/class-diagram.md b/docs/class-diagram.md index ab17e744..c77bee9a 100644 --- a/docs/class-diagram.md +++ b/docs/class-diagram.md @@ -32,6 +32,7 @@ classDiagram class Reader { +get_supported_mime_types() list~str~$ +try_create(format_or_path, stream, manifest_data, context) Reader | None$ + +from_fragmented_files(asset_path, fragments, context) Reader$ +json() str +detailed_json() str +get_active_manifest() dict | None @@ -59,6 +60,8 @@ classDiagram +sign(signer, format, source, dest) bytes +sign(format, source, dest) bytes +sign_file(source_path, dest_path, signer) bytes + +sign_fragmented(signer, asset_path, fragments_glob, output_dir) bytes + +sign_ladder(signer, sources, dests) bytes +close() } @@ -83,6 +86,23 @@ classDiagram +close() } + class TrustedVsiSession { + +TrustedVsiSession(context, manifest_json, algorithm, public_cose_key, kid, ..., callback, mode, ...) + +from_callback(context, manifest_json, algorithm, public_cose_key, kid, ...) TrustedVsiSession$ + +reserve_init_uuid(format) bytes + +reserved_manifest_id() str + +finalize_init_uuid(canonical_hash) bytes + +commit_init_uuid() + +sign_sig_structure(sig_structure, sequence_number) bytes + +reserve_media_emsg_at(sequence_number, signing_time_unix_seconds, ...) bytes + +finalize_media_emsg(canonical_hash) bytes + +export_state() bytes + +import_state(state) + +preflight(operation, data) + +status() TrustedVsiStatus + +close() + } + class C2paSignerInfo { <> +alg @@ -142,6 +162,8 @@ classDiagram Signer --> Context : optional, consumed Context --> LiveVideoVsiSession : borrowed with signer LiveVideoVsiSession --> Callable : pins optional clock + Context --> TrustedVsiSession : retained with signer + TrustedVsiSession --> Callable : pins signing callback C2paSignerInfo --> Signer : creates via from_info C2paSigningAlg --> C2paSignerInfo : alg field C2paSigningAlg --> Signer : from_callback alg @@ -152,3 +174,7 @@ classDiagram C2paDigitalSourceType --> Builder : set_intent C2paError --> C2paError_Subtypes : subclasses ``` + +[`Builder.sign_ladder`](ladder-signing.md) requires an explicit signer and a native +library with ladder support. An attempted native call closes the builder, not the signer; +preflight errors leave the builder usable. diff --git a/docs/context-settings.md b/docs/context-settings.md index 35692a82..8b095386 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -160,9 +160,16 @@ Create and configure settings independently of a `Context`: | `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. | +| `update(data)` | Merge configuration into existing settings. `data` can be a JSON string or a dict. Later scalar values override earlier ones; `trust.anchors` entries accumulate and deduplicate. | -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). +The `set()` and `update()` methods can be chained for incremental configuration. +Scalar properties use the last value set. With native SDK 0.91, `update()` merges +`trust.anchors` by complete entry equality, not by `trust_uri`: updating an entry +with different certificates or policy does not remove the old entry. An empty +anchor list does not clear existing trust. For trust removal or replacement, +construct fresh `Settings` from the complete intended configuration and create +a new `Context`. Existing contexts, readers, and builders retain their copied +configuration; do not reuse them when applying a reduced trust policy. ```py from c2pa import Settings @@ -263,7 +270,6 @@ The Settings JSON has this top-level structure: { "version": 1, "trust": { ... }, - "cawg_trust": { ... }, "core": { ... }, "verify": { ... }, "builder": { ... }, @@ -276,7 +282,7 @@ The settings format is **JSON** only. Pass JSON strings to `Settings.from_json() > [!NOTE] > - 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. +> - `null` (or `None` in a dict) is accepted only for nullable properties. Do not use it as a general reset operation; use fresh settings when removing trust. > - 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). @@ -285,11 +291,10 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an |----------|-------------| | `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. | +| [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Purpose-tagged manifest, CAWG, and TSA trust lists. | | [`verify`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#verify) | Configuration for verification (validation). | ### Default configuration @@ -330,13 +335,6 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an "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, @@ -347,10 +345,8 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an }, "signer": null, "trust": { - "user_anchors": null, - "trust_anchors": null, - "trust_config": null, - "allowed_list": null + "anchors": null, + "trust_config": null }, "verify": { "verify_after_reading": true, @@ -367,41 +363,77 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an ### Trust -The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. +The following schema applies to the upstream-integrated native SDK 0.91 source +baseline. It does not describe or change the immutable dev5 native artifacts. +Use `trust.anchors` for all certificate trust purposes; the old `cawg_trust` +section and top-level `trust.allowed_list` are not the typed configuration. | 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. | +| `trust.anchors` | array | Purpose-tagged trust entries. No certificate anchors are configured by default in the production library. | +| `trust.trust_config` | string or null | Global allowed Extended Key Usage (EKU) OIDs, newline-separated. Preserve your existing policy during migration. | +| `trust.anchors[].trust_kind` | string | Required purpose: `"manifest"`, `"cawg"`, or `"tsa"` (lowercase). | +| `trust.anchors[].trust_anchors` | string | Required PEM certificate bundle; may be empty for an entry that only configures other trust policy. | +| `trust.anchors[].trust_uri` | string or null | Optional trust-list identifier. Not a replacement key for updates. | +| `trust.anchors[].trust_config` | string or null | Per-entry EKU policy; overlays the global policy for manifest trust and overrides it for CAWG trust. | +| `trust.anchors[].allowed_list` | string or null | Explicitly allowed certificates. Preserve only existing, intentional allow-list membership; do not use this to repair trust failures. | +| `trust.anchors[].trusted_ica_issuers` | array of strings or null | Explicit trusted ICA issuer DIDs for CAWG entries. Empty by default; a valid self-signature alone does not establish issuer trust. | -Use `user_anchors` to add your test root CA without replacing the SDK's default trust store: +Trust your test root CA for manifest signatures: ```py with open("test-ca.pem", "r") as f: test_root_ca = f.read() -ctx = Context.from_dict({"trust": {"user_anchors": test_root_ca}}) +ctx = Context.from_dict({"trust": {"anchors": [{ + "trust_kind": "manifest", + "trust_anchors": test_root_ca, + "trust_uri": "urn:example:test-manifest-roots" +}]}}) reader = Reader("signed_asset.jpg", context=ctx) ``` -Use `allowed_list` to bypass chain validation entirely for quick testing: +Configure each intended purpose explicitly. A publisher identity root should not +implicitly become a claim-signing root or a TSA root: ```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) -``` +with open("publisher-roots.pem", "r") as f: + publisher_roots = f.read() +with open("tsa-roots.pem", "r") as f: + tsa_roots = f.read() + +ctx = Context.from_dict({"trust": {"anchors": [ + {"trust_kind": "manifest", "trust_anchors": test_root_ca}, + {"trust_kind": "cawg", "trust_anchors": publisher_roots}, + {"trust_kind": "tsa", "trust_anchors": tsa_roots} +]}}) +``` + +When migrating legacy `trust.trust_anchors` / `trust.user_anchors`, preserve +the original manifest and timestamp trust memberships and EKUs explicitly. +The native legacy conversion creates manifest entries only. If the old bundle +also authorized TSA chains, retain that membership in a `tsa` entry. Migrate +CAWG roots and allowed certificates to `cawg` entries without widening their +purposes. Do not disable `verify_trust` or `verify_timestamp_trust`, add an +allow-list, or enable revocation fetching merely to make migrated tests pass. + +To remove all explicitly configured trust, use `Settings.from_dict({"trust": +{"anchors": []}})` and construct a new `Context` from it. To remove only some +entries, supply the complete retained list to fresh settings instead. Reapply +the other intended settings too, rather than inheriting old trust accidentally. ### 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). +Entries with `trust_kind: "cawg"` configure CAWG (Creator Assertions Working +Group) identity validation. Certificate trust and explicitly trusted ICA issuer +DIDs are separate policy inputs; include only identities your application +already authorizes. > [!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. +> Native baseline `e0f980ec` fixes cross-purpose trust leakage found during +> integration. The unchanged Python regressions now reject manifest trust from +> `cawg`-only or `tsa`-only configurations. Earlier 0.91 source snapshots are not +> qualified for this isolation; see [baseline qualification](upstream-integration-baseline.md). ### Core @@ -515,14 +547,14 @@ ctx = Context.from_dict({ ### Development environment with test certificates -During development, you often need to trust self-signed or custom CA certificates with looser verification: +During development, explicitly trust the test CA without disabling verification: ```py with open("test-ca.pem", "r") as f: test_ca = f.read() ctx = Context.from_dict({ - "trust": {"user_anchors": test_ca}, + "trust": {"anchors": [{"trust_kind": "manifest", "trust_anchors": test_ca}]}, "verify": { "verify_after_reading": True, "verify_after_sign": True, @@ -576,7 +608,7 @@ with open("trust-anchors.pem", "r") as f: ctx = Context.from_dict({ "trust": { - "trust_anchors": trust_anchors, + "anchors": [{"trust_kind": "manifest", "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}, diff --git a/docs/ladder-signing.md b/docs/ladder-signing.md new file mode 100644 index 00000000..2703a76e --- /dev/null +++ b/docs/ladder-signing.md @@ -0,0 +1,100 @@ +# Single-File Ladder Signing + +`Builder.sign_ladder(signer, sources, dests)` signs an ordered set of single-file +fragmented MP4 renditions with one shared manifest. Each input must contain its +own initialization and media fragments and one track, without an existing C2PA +manifest. A ladder contains 1 to 256 renditions. Outputs correspond to inputs by +position. Destination paths must be distinct and must not exist, and their parent +directories must exist; the native implementation validates file layouts and +overlap, and refuses to overwrite existing destinations. Errors may leave partial +outputs. Never delete sources or preexisting destinations while cleaning up a +failed operation. Delete only newly created files you positively own. Use a fresh, +exclusively owned staging directory per operation so output ownership is clear; +do not infer ownership just because a path was supplied in `dests`. + +```python +with Builder(manifest_definition) as builder: + manifest_bytes = builder.sign_ladder( + signer, + [Path("low.mp4"), Path("high.mp4")], + [Path("signed-low.mp4"), Path("signed-high.mp4")], + ) +``` + +Pass an explicit active `Signer`, created from signing information or a callback. +This method does not fall back to a context signer. Like ordinary signing, an +attempted native call closes the builder on success or failure; the signer remains +usable. Preflight errors leave the builder usable. Paths must be UTF-8 strings +or `Path` objects and cannot contain NUL characters. + +After validation, failure to admit the signing borrow (for example, an explicit +Signer closing between preflight and admission) closes the Builder too, matching +ordinary and fragmented signing. This does not change non-consuming preflight +validation failures. The call guard is a lifetime mechanism, not permission to +run concurrent native operations on either borrowed resource. + +Dynamic assertions registered on the signer run once for the shared manifest. +Callback errors follow the other Builder signing paths: an exception raised by a +dynamic-assertion callback is re-raised unchanged; a claim-signer callback +propagates only interrupts such as `KeyboardInterrupt`, `SystemExit` and +`asyncio.CancelledError`, while its ordinary exceptions are reported as +`C2paError`. Error state left by a previous operation is cleared before signing. + +The native export `c2pa_builder_sign_ladder` is optional. A library without it +still imports and supports ordinary signing. Calling `sign_ladder` on that library +raises `C2paError.NotSupported`. No native release pin changes are needed for this +binding addition. + +## Verification + +Use a local virtual environment for dependencies. The existing CI commands run +`tests/test_unit_tests.py tests/test_sign_ladder.py`. The focused ladder file +includes mock tests, typed `CFUNCTYPE` marshalling/cleanup tests, and a real-native +smoke using the committed tiny fixture and existing offline test key/certificates +(no TSA). Fixture provenance is in +`tests/fixtures/single-file-fragmented/README.md`. + +```sh +PYTHONPATH=src C2PA_LIBRARY_NAME=/absolute/path/to/stock/libc2pa_c.so \ + .venv/bin/python -m pytest -q tests/test_sign_ladder.py +PYTHONPATH=src C2PA_LIBRARY_NAME=/absolute/path/to/candidate/libc2pa_c.so \ + C2PA_REQUIRE_SIGN_LADDER=1 .venv/bin/python -m pytest -q tests/test_sign_ladder.py +``` + +Only the native smoke skips when the loaded library lacks the optional symbol. +`C2PA_REQUIRE_SIGN_LADDER=1` makes that absence fail instead; it is a test-only +capability requirement, not a loader override or a change to the binding API. +When the symbol is present, the smoke always executes, regardless of the flag. +It checks the production export's six argument types and return type before signing. +Missing committed fixture data fails even on stock. The smoke signs two copies +of the same fixture (not different resolution encodes), checks that the returned +manifest is embedded byte-for-byte in both outputs, compares their active +manifests, requires Reader state `Valid`, and checks inputs remain unchanged. +It also checks empty-list Python preflight preserves the builder and native +signing closes it. It does not replace the broader isolated harness below. + +Run both real-native lanes explicitly. The harness copies this package and the +specified library into a temporary directory and launches a fresh Python process. +It checks the exact loaded path and SHA-256 and reports the native SDK version. +It uses the existing `C2PA_LIBRARY_NAME` loader seam, not a new loader override. +It does not replace an installed package's library. + +Run the harness without `-O`, `-OO`, or `PYTHONOPTIMIZE`: optimized Python is +rejected because it would disable the verification assertions. + +```sh +.venv/bin/python tests/ladder_native.py --lane stock \ + --library /absolute/path/to/stock/libc2pa_c.so +.venv/bin/python tests/ladder_native.py --lane candidate \ + --library /absolute/path/to/candidate/libc2pa_c.so \ + --native-fixtures /absolute/path/to/c2pa-rs/sdk/tests/fixtures +``` + +The stock lane requires the export to be absent, checks the call-time typed error, +then signs and validates an ordinary JPEG with the same builder. The candidate +lane requires the export to be present: missing capability is a failure, never a +skip. It also checks ordinary signing, info and callback signers, both synthetic +`single_file_fragments*.mp4` fixtures, identical embedded manifests, validation, +media tampering, callback failure, and native rejection of source aliases, +duplicate destinations, and existing destinations without overwriting them. +Fixture hashes are included in the output; the source fixtures are never modified. diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 60daf32a..cab18ef8 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -7,9 +7,9 @@ `ManagedResource` is the internal base class responsible for managing native pointers owned by the C2PA Python SDK. It guarantees: - Native memory is freed exactly once (no double-free). -- Resources are cleaned up deterministically via context managers or explicit `close()`. +- Context managers or explicit `close()` close resources logically; physical cleanup waits for any admitted guarded signing calls to drain. - Ownership transfers (e.g. signer to context) are handled so the same pointer is not freed twice (and the objects/classes know which one owns what). -- Cleanup never raises (trade-off to avoid raising errors on clean-up only, but errors are logged). +- Ordinary cleanup exceptions are logged/suppressed; `BaseException` interrupts are not suppressed. A new wrapper around a native resource inherits from `ManagedResource` and follows the documented lifecycle rules. @@ -19,7 +19,7 @@ A **native pointer** is an address that says where a piece of memory lives. The The **native side** of the Rust library is reached through its C FFI. -A **handle** is a single native pointer a `ManagedResource` object holds and manages, stored in its `_handle` attribute. Each object owns one handle at a time. The lifecycle machinery is mostly about tracking that one handle: creating it, swapping it, and freeing it. +A **handle** is the native pointer-shaped value a `ManagedResource` object holds in its `_handle` attribute. Stock 0.91.0 uses real allocation addresses, which can be reused; newer native registries use opaque, non-reused IDs. Python treats both as handles to pass back to native, not memory to dereference. Each object owns one handle at a time. **Ownership** answers one question: who is responsible for freeing a piece of native memory. Native memory has to be freed (exactly once). The owner is whoever must free it. If nobody frees it, the memory leaks. If two owners each free it, the same memory is freed twice, which corrupts the allocator and can crash the process. So exactly one side owns each pointer at any moment, and that side frees it. @@ -94,7 +94,7 @@ Notes: ## Python frees only what Python owns -The C FFI is consistent about one thing that shapes this whole layer: some calls consume the pointer passed to them and hand back a replacement, because the native side may free and reallocate the underlying value. A pointer that went into a consuming call must never be freed by Python afterwards. Its address may already have been reallocated to a different object (address space is not infinite, so addresses get reused). +Some C FFI calls take ownership of a handle and return a replacement. Once ownership actually moves, Python must not free the old handle. A rejected call may leave ownership unchanged, but rejection of a later argument may happen after the managed handle was consumed. On stock 0.91.0, the old allocation address may already belong to a different object; newer opaque IDs are not reused. Python owns and frees two kinds of things: the **single current native handle** for each object, and **Python-side resources it created itself** (stream wrappers, callbacks pinned so the native side can call back into them, caches). It swaps that one tracked handle to whatever a consuming call returns and, on the success path, never frees the value the call took. Beyond those owned resources it also carries bookkeeping it never frees (lifecycle state, the owning process ID, a borrowed reference to a caller-supplied `Context`), and it does not manage native reallocation itself: it swaps handles and, on the ambiguous failure paths, reads the native error tags to decide who still owns the pointer rather than assuming. @@ -102,20 +102,21 @@ Therefore, the managed resources have the following principles: - Each `ManagedResource` holds exactly one `_handle`. `_swap_handle()` replaces it with the pointer a consuming call returned and does not free the old value, since the native side took it (see [Consume-and-swap](#consume-and-swap)). - `_teardown(free_handle=False)`, `_consume_no_replacement()`, and `_consume_into()` all close or advance the object without calling `c2pa_free`, because ownership moved to the native side. -- Only a few sites free a live handle. Two of them free a pointer this layer still provably owns: normal teardown (`_teardown(free_handle=True)`), and the create-then-validate path, which frees a freshly created pointer if activation fails. The third, `_release_handle()`, is a *guarded* free used only when ownership is genuinely unknown (a consuming call failed without setting an error, or a Python exception was raised before the native side reported anything): if the native side already took the pointer, its address is no longer in the registry and `c2pa_free` is a `-1` no-op, so the free touches no memory. No path frees a pointer known to have been consumed and reallocated (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). -- `_release()` drops stream wrappers, callbacks, and caches before the native pointer is freed (see [Subclass-specific cleanup with `_release()`](#subclass-specific-cleanup)). +- Normal teardown (`_teardown(free_handle=True)`) and create-then-validate failure free handles Python still owns. `_release_handle()` issues a *guarded* free when ownership cannot be established: a missing native error, an exception other than `ctypes.ArgumentError`, or a consume-first registry rejection without a comparable handle value. The registry rejects an untracked value, but a stale stock address may have been reused; this is not a universal stale-free guarantee. Known-consumed handles are closed without freeing (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). +- Physical teardown runs `_release()` before native free. A separate snapshot keeps callback objects alive through that free even when `_release()` clears the instance's pins (see [Subclass-specific cleanup with `_release()`](#subclass-specific-cleanup)). ### Double-free risk mitigations -Three distinct risks. Two have a mechanism in this layer; the third is the caller's to synchronize: +Ownership, fork, guarded signing-borrow and unguarded-operation risks have different protections: | Hazard | Covered by | How | | --- | --- | --- | -| Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | The consumed pointer is abandoned, never freed. The retained-vs-consumed decision reads the native error tag (`UntrackedPointer:` / `WrongPointerType:` mean not taken). | +| Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | Known-consumed handles are never freed. Failure triage uses call order and, for consume-first registry rejections, compares the rejected value with the managed handle; the tag alone does not establish retention. | | A forked child freeing a pointer its parent owns | PID stamp (`record_owner_pid` / `is_foreign_process`) | Cleanup in a process that did not allocate the pointer nulls the handle and marks `CLOSED` without freeing (see [Fork safety](#fork-safety)). | -| Two **threads** in one process racing frees on distinct objects, where the allocator recycles a just-freed address | Not covered here | `ManagedResource` has no lock and no thread stamping. The PID stamp cannot see it: sibling threads share a PID. Safety for genuinely shared handles must come from the caller's own synchronization or from the native registry, not this layer. | +| Close during an admitted borrowed signing call, including from a callback or another thread | `_native_call()` / deferred teardown | A short per-resource RLock protects admission/counting and close bookkeeping, never native code or callbacks. Physical cleanup waits for the last admitted call. | +| Concurrent unguarded operations, shared/aliased handles, or allocator address reuse across distinct objects | Caller/native synchronization required | The bookkeeping lock does not serialize native operations or protect distinct wrappers sharing a handle. The PID stamp cannot distinguish sibling threads. | -The PID stamp is fork-only: it compares process IDs, and two threads in the same process always match. Sharing one `ManagedResource` instance across threads without external synchronization is outside what this layer protects against. +The PID stamp is fork-only: sibling threads share a PID. Selected signing paths support close during an admitted call, not concurrent native operations in general. Unguarded methods, including Reader calls, `Builder.with_archive()` and complete-buffer VSI `recover()`, must be externally serialized with all other operations and close on the same resource. A lifecycle check alone is not a borrow guard. ## Guarantees provided by ManagedResource @@ -123,21 +124,21 @@ The PID stamp is fork-only: it compares process IDs, and two threads in the same | Guarantee | Description | | --- | --- | -| **Pointer freed exactly once** | Each native pointer is passed to `c2pa_free` at most once. No leak (zero frees) and no double-free. | -| **Cleanup is idempotent** | Calling `close()` (or exiting a `with` block) multiple times is safe; after the first successful cleanup, further calls do nothing. | -| **Cleanup never raises (ordinary errors)** | The cleanup path catches and logs `Exception`, never re-raising it. `_release()` runs inside `_safe_release()`, which logs and swallows; the `c2pa_free` call has its own handler; and `_cleanup_resources()` wraps both. The original exception from the `with` block (if any) is never masked. **Asynchronous interrupts are the deliberate exception.** The cleanup handlers catch `Exception`, which excludes the `BaseException` signals the interpreter raises to unwind a process (a cancellation request or an exit in progress). Those propagate through cleanup untouched, and the remaining free may not run. Such a signal means the process is being torn down and its address space, native allocations included, is about to be reclaimed as a whole. Catching it would suppress a shutdown the caller asked for in order to complete a free that is about to become irrelevant, so the handlers stay scoped to `Exception`. | +| **Ownership-aware release** | Python frees handles it still owns and does not free handles known to have been consumed. Unknown-ownership failures use the guarded-free fallback described below. | +| **Cleanup is idempotent** | The first `close()` marks the resource logically closed; repeats do not free it twice or discard a pending teardown. Physical cleanup may finish later when admitted calls drain. | +| **Cleanup never raises (ordinary errors)** | Cleanup catches/logs `Exception` at the release/free boundaries and does not mask the original operation exception. Cleanup handlers do not suppress `BaseException`; an interrupt during physical cleanup may prevent the remaining free. This is distinct from an operation/callback raising `BaseException`: the call guard still drains in `finally`. | | **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED to ACTIVE to CLOSED. A closed resource cannot be reactivated. | | **Transitions go through helper methods** | Subclasses call `_activate()`, `_swap_handle()` or `_teardown()` and never assign `_handle` or `_lifecycle_state` directly. `_activate()` and `_swap_handle()` validate before mutating, so an object cannot end up active with a null handle. | | **Ownership transfer is safe** | When a pointer is transferred elsewhere (e.g. via `_teardown(free_handle=False)`), the object stops managing it and does not call `c2pa_free` on it. | -| **Public methods validate lifecycle state** | Every public API calls `_ensure_valid_state()` before use; closed or invalid state yields `C2paError` instead of undefined behavior or crashes. | +| **Native operations validate lifecycle state** | Methods requiring a live handle check state before use; logical close rejects new operations even if the handle is retained for an admitted call. This check alone does not prevent an unguarded operation racing with close. | ## Preventing garbage collection of live references When a Python object passes a callback or pointer to the native library, that reference must stay alive for as long as the native side might use it. Python's garbage collector has no way to know that native code is still holding a reference to a Python callback. -The SDK solves this by storing these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`) as instance attributes. As long as the `Stream` object is alive, its callbacks have a nonzero reference count and will not be collected. Similarly, when a `Signer` is consumed by a `Context`, the Context copies the signer's claim callback and DynamicAssertion registrations before consumption, so the ctypes callbacks, original Python callbacks, and per-thread exception state survive even though the Signer object is now closed. A `Builder`, `LiveVideoVsiSession`, or fragmented-file `Reader` created from that Context pins its own copies for its native lifetime, including when the caller explicitly closes the Context. A callback-backed `LiveVideoVsiSession` additionally pins its VSI callback, the original Python callable, and thread-local exception state until the session closes. Any explicit-time clock is likewise pinned until the session closes. +The SDK stores these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`). When a `Signer` is consumed by a `Context`, the Context copies the claim callback and DynamicAssertion registrations before consumption, preserving actual ctypes thunks, Python callbacks and exception state after the Signer closes. A `Builder`, `LiveVideoVsiSession`, `TrustedVsiSession`, or fragmented-file `Reader` created from that Context pins its own copies for its native lifetime, including after caller `Context.close()`. Callback-backed VSI sessions also pin their VSI callable/thunk and exception state; complete-buffer sessions pin their explicit-time clock. For guarded signing, these references remain until the admitted call drains and physical teardown finishes, not merely until logical close. -During cleanup, `_release()` sets these attributes to `None`, which drops the reference count on the callback objects and allows them to be collected. In the cleanup sequence, `_release()` runs first, then `c2pa_free` frees the native pointer. `_release()` goes first so that subclass-specific resources (open file handles, stream wrappers) are torn down before the native pointer they depend on is freed. +Physical cleanup copies callback pins (including the DA list) before `_release()` clears the instance attributes. The snapshot retains the actual callback objects through `c2pa_free`; clearing the attributes does not yet make those objects collectible. `_release()` still precedes native free, preserving subclass stream/file ordering. Pins become collectible after physical teardown returns, unless another owner or exception traceback legitimately retains them. ## How native memory is freed @@ -151,9 +152,9 @@ def _free_native_ptr(ptr): All native pointers are freed through this single path, regardless of which constructor created them (`c2pa_reader_from_stream`, `c2pa_builder_from_json`, `c2pa_signer_from_info`, etc.). No explicit `ctypes.cast` is needed: `c2pa_free`'s declared argtype is `c_void_p`, so ctypes converts any pointer instance on the way in. Casting explicitly with `ctypes.cast(ptr, c_void_p)` performs the same conversion but leaves a reference cycle behind on every call, which creates additional load on the (Python) garbage collector. -It returns `c2pa_free`'s status code: `0` when the pointer was really freed, `-1` when the native registry rejected an already-consumed or untracked address. That `-1` is expected on the guarded-free paths and is handled gracefully by the native lib too. +It returns `c2pa_free`'s status code: `0` when a tracked value was freed, `-1` when the registry rejected it. An untracked handle is rejected, but a stale stock address that has been reused can identify a different live allocation. -`ManagedResource` guarantees that `c2pa_free` is called exactly once per pointer: not zero times (leak), not twice (double-free). +Normal owned cleanup calls `c2pa_free` once; consumed handles are not freed by Python. Unknown-ownership failures use the guarded-free fallback. ## Lifecycle states @@ -171,7 +172,7 @@ stateDiagram-v2 - `UNINITIALIZED`: The Python object exists but the native pointer has not been set yet. This is a transient state during construction. - `ACTIVE`: The native pointer is valid. The object can be used. -- `CLOSED`: The native pointer has been freed (or ownership was transferred). Any further use raises `C2paError`. +- `CLOSED`: Logically closed: new native operations are rejected. With an admitted call, the handle and pins remain until the last call drains; otherwise physical cleanup has run or ownership was transferred. `CLOSED` does not imply `_handle is None` during deferred teardown. `CLOSED` is a one-way state: once closed, an object cannot be reactivated. It is normally reached from `ACTIVE`, but a construction that fails before `_activate()` closes straight from `UNINITIALIZED` when `close()` or `__del__` runs (nothing to free, just marked closed). @@ -181,21 +182,21 @@ Each transition has one method that performs it, and subclasses must go through | --- | --- | --- | | `_activate(handle)` | UNINITIALIZED to ACTIVE | Rejects a null handle, and refuses to run on an already-activated resource. A rejected activation leaves the object exactly as it was. | | `_swap_handle(new_handle)` | ACTIVE to ACTIVE | Requires the resource to already be active and the replacement to be non-null. Used when an FFI call consumed the old handle and returned a new one. | -| `_teardown(free_handle=False)` | ACTIVE to CLOSED | Drops the handle without freeing it, for when ownership passed to the native side (e.g. `Signer` into `Context`). Runs `_release()` first, so subclass cleanup still happens. Unlike the other two, it validates nothing. | -| `_release_handle()` | ACTIVE to CLOSED | Frees the handle (guarded, via `_teardown(free_handle=True)`) and closes the object. Same post-state as the consumed teardown. | +| `_teardown(free_handle=False)` | ACTIVE to CLOSED | Closes logically and schedules release/nulling without native free when ownership passed to native. Physical cleanup is deferred if calls are admitted; consuming operations themselves require external serialization. | +| `_release_handle()` | ACTIVE to CLOSED | Requests a guarded free via `_teardown(free_handle=True)`. If already CLOSED with pending teardown, it leaves the retained handle intact for the last admitted call to drain. | Because activation is the only way in, no code path can leave an object ACTIVE while holding a null handle. -Two terms recur throughout this document. An **owned free** calls `c2pa_free` on a pointer this layer still provably holds: the normal `close()` / `__del__` path and the create-then-validate failure path both do this. A **guarded free** is the same call made when ownership is uncertain, which is what `_release_handle()` does: the native pointer registry tolerates being asked to free an address it no longer tracks, returning `-1` instead of crashing, so the free does not double-free a pointer the native side already took. That tolerance makes a guarded free safe to *issue*, but it is not free of consequence under concurrency — on a branch where the value is already known to be consumed, the layer skips the free rather than relying on the `-1`, because a stale free can race a recycled address (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). +An **owned free** calls `c2pa_free` on a handle Python still owns (normal cleanup or create-then-validate failure). A **guarded free** makes that call when ownership is uncertain, via `_release_handle()`. The registry rejects untracked values, but stock allocation addresses can be recycled, so generic guarded frees are not guaranteed harmless. Known-consumed branches skip the free (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). -`_teardown(free_handle)` is the one method that performs the ACTIVE to CLOSED transition, and the boolean decides the only thing that varies between the two exit paths: whether the native pointer is freed. Both paths run `_release()`, set `CLOSED`, and null the handle. +`_teardown(free_handle)` marks CLOSED first. If calls are admitted, it records pending teardown and retains the handle/pins. Otherwise, or when the last admitted call drains, `_finish_teardown()` snapshots callback pins, runs `_release()`, nulls the handle, and optionally frees it. The boolean chooses whether native free is requested, not whether logical close is immediate. Foreign-process cleanup only marks CLOSED/nulls the copied handle, without release/free or acquiring a possibly inherited lock. | `free_handle` | When | What it does with the pointer | | --- | --- | --- | -| `True` | Either the pointer is still provably ours (normal `close()`, `__del__`) — an owned free — or ownership is unknown after a failure (`_release_handle()`) — a guarded free. | Calls `c2pa_free`. On the owned paths the pointer is really freed; on the unknown-ownership path the registry returns `-1` without touching memory if the native side already took it. | -| `False` | The native side already took ownership: a consuming FFI call swallowed the pointer, or it passed to another object. | Frees nothing; the new owner does. A `c2pa_free` here would double-free (or hit the guarded `-1` no-op that dirties the error slot and risks racing a recycled address). | +| `True` | Python still owns the handle, or ownership is unknown after a failure (`_release_handle()`). | Calls `c2pa_free`; an untracked value is rejected, but a reused stock address need not be untracked. | +| `False` | The native side took ownership. | Frees nothing; native now owns or has dropped the value. Avoids an unnecessary free, error-slot overwrite, and stock address-reuse risk. | -Every public method calls `_ensure_valid_state()` before doing any work, which raises `C2paError` unless the resource is ACTIVE with a non-null handle. +Methods requiring a live native handle check for ACTIVE with a non-null handle. This is not synchronization for unguarded calls; callers must also serialize them with close. ## Ways to clean up @@ -211,7 +212,7 @@ with Reader("image.jpg") as reader: # reader is automatically closed here, even if an exception occurs ``` -When the `with` block exits, `__exit__` calls `close()`, which frees the native pointer. This is the safest approach because cleanup happens even if the code inside the block raises an exception. +When the `with` block exits, `__exit__` calls `close()`. Logical close is immediate; physical cleanup is immediate only if no guarded calls are admitted. A context exit during a signing callback is not cancellation: the admitted call may complete before cleanup finishes. Unguarded calls must not overlap context exit. ### Explicit close @@ -223,7 +224,7 @@ finally: reader.close() ``` -Calling `close()` directly is equivalent to exiting a `with` block. It is idempotent: calling it multiple times is safe and does nothing after the first call. +Calling `close()` directly has the same logical/deferred behavior as context exit. Repeated close is idempotent and does not discard pending teardown. Close may run during an admitted guarded signing call, but must be externally serialized with unguarded operations. ### Destructor fallback @@ -233,15 +234,15 @@ If neither the context manager nor an explicit `.close()` is used, `__del__` att Cleanup must not raise an *ordinary* exception. A failure during cleanup (for example, the native library crashing on free) should not mask the original exception that caused the `with` block to exit. `ManagedResource` enforces this: -- `close()` delegates to `_cleanup_resources()`, which wraps the entire cleanup sequence in a try/except that catches and silences `Exception`. +- `close()` delegates to `_cleanup_resources()`, which catches/silences ordinary `Exception`. Admitted calls can defer physical cleanup until their guard drains; `_safe_release()` and native-free handlers also suppress ordinary exceptions on that deferred path. - `_release()` is never called directly during cleanup. It runs inside `_safe_release()`, which logs any `Exception` with a traceback and returns normally, so a subclass whose `_release()` raises an ordinary error cannot stop the native pointer from being freed afterwards. - If freeing the native pointer fails, the error is logged via Python's `logging` module but not re-raised. - The state is set to `CLOSED` as the very first step, before attempting to free anything. If cleanup fails halfway, the object is still marked closed, preventing a second attempt from doing further damage. - Cleanup is idempotent. Calling `close()` on an already-closed object returns immediately. -These handlers catch `Exception`, not `BaseException`. The signals the interpreter raises to unwind a process (a cancellation request, or an exit already in progress) are `BaseException`, so they pass through cleanup untouched and the remaining free may not run. That is intentional: the signal means the whole process is going away, and its address space, native allocations included, is reclaimed on exit. Holding the interpreter in cleanup to finish a free that is about to become irrelevant would only delay the shutdown the caller asked for. +These handlers catch `Exception`, not `BaseException`; an interrupt during physical cleanup can prevent the remaining free, and cancellation does not necessarily terminate the process. Separately, the call guard's `finally` always drains admitted-call bookkeeping when an operation unwinds, including a callback's re-raised `BaseException`. -All three cleanup entry points converge on the same method, and the exception handling sits at three different levels inside it: +All three close entry points converge on `_cleanup_resources()`. Physical release/free may instead run later from the last admitted call's guard: ```mermaid flowchart TD @@ -251,10 +252,14 @@ flowchart TD FP -->|no| ST{"already CLOSED?"} ST -->|yes| DONE ST -->|no| SET["set CLOSED first"] - SET --> REL["_safe_release()
logs and swallows"] - REL --> H{"handle set?"} + SET --> AC{"admitted calls?"} + AC -->|yes| WAIT["record pending teardown,
retain handle and pins"] --> DONE + LAST["last admitted call drains"] --> FIN["_finish_teardown()
snapshot callback pins"] + AC -->|no| FIN + FIN --> REL["_safe_release()
logs ordinary errors"] + REL --> NULL["_handle = None"] --> H{"free_handle and saved handle?"} H -->|no| DONE - H -->|yes| FREE["_free_native_ptr()
logs on failure"] --> NULL["_handle = None"] --> DONE + H -->|yes| FREE["_free_native_ptr()
pins retained through free"] --> DONE ``` The `foreign process` branch is explained under [Fork safety](#fork-safety). @@ -298,7 +303,7 @@ When the Reader is closed, it first releases its own resources (open file handle ## Builder lifecycle -A `Builder` follows the same pattern as Reader, with one difference: **signing closes the builder**. A Builder is single-use, so after `sign()` or `sign_fragmented()` attempts native signing it cannot be reused. Preflight validation failures leave it active because no native sign was attempted. +A `Builder` follows the same pattern as Reader, with one difference: **signing closes the builder**. A Builder is single-use, so after validated `sign()`, `sign_fragmented()` or `sign_ladder()` enters call admission it closes on admission failure or an attempted native sign. Earlier preflight validation failures (including a logically closed Signer or invalid format) leave it active. Signing guards do not protect other Builder operations from racing with close. ```mermaid stateDiagram-v2 @@ -314,15 +319,21 @@ stateDiagram-v2 end note ``` -While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly. `.sign()` closes the Builder when it returns, on both the success and the failure path. Closing without signing frees the pointer the same way. +While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly with external serialization. After successful preflight, signing closes the Builder on admission/native failure or success; preflight validation failures leave it active. Closing without signing frees immediately unless an admitted call must first drain. -The native sign calls borrow the builder's pointer rather than taking ownership of it, so `Builder` never marks it consumed and the pointer is freed normally through `c2pa_free`. The close enforces single use; it is not a memory-management requirement. `sign_fragmented()` also borrows its explicit Signer, which remains active, and copies its tracked output buffer before freeing that buffer once through `c2pa_free`. +Native sign calls borrow the Builder rather than consuming it. Single-use close is library policy; the call guard makes callback-initiated close safe while native still borrows the handle. `sign_fragmented()` also guards its explicit borrowed Signer, which remains active unless the caller closes it, and copies/frees its tracked output buffer once through `c2pa_free`. + +[`sign_ladder()`](ladder-signing.md) follows the same single-use rule after an +attempted native call, while preflight errors (including unavailable capability) +leave the builder usable. The explicit signer is borrowed and remains usable. +Any returned manifest buffer is freed through `ManagedResource._free_native_ptr` +(`c2pa_free`) even when signing or copying fails, without masking the original error. ## Ownership transfer Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). -`_teardown(free_handle=False)` handles this. It runs `_release()`, then sets `_handle = None` and `_lifecycle_state = CLOSED` without freeing the pointer. +`_teardown(free_handle=False)` marks CLOSED and schedules `_release()` then handle nulling without native free. Ownership-transfer calls are unguarded and must be externally serialized, including with close; they do not acquire an operation-long Python lock. In the SDK this happens in one place: passing a `Signer` to a `Context`. The Context runs a short-lived native context builder, feeds the signer into it, builds the context, and activates the result. The builder itself is wrapped in `_NativeBuilder` (a small `ManagedResource`), so every failure inside the `with` block frees it through `close()` unless a consuming call already took it. There is no raw pointer held across the calls and no bespoke error handler. @@ -347,14 +358,14 @@ sequenceDiagram alt status 0 (success) S->>S: _teardown(free_handle=False) Note right of S: Consumed: native took the signer - else pre-consume rejection (UntrackedPointer / WrongPointerType) + else registry rejection (UntrackedPointer / WrongPointerType / PointerInUse / WrongWrapperKind) Note right of S: Rejected before ownership moved:
Signer retained, typed error raised else other error S->>S: _teardown(free_handle=False) Note right of S: Native took it then failed and dropped it end - X->>B: _consume_into(build) + X->>B: _consume_into(build, consumes_first=True) B->>N: c2pa_context_builder_build(builder_ptr) N-->>X: context_ptr (builder consumed) X->>X: _activate(context_ptr) (outside the with) @@ -363,7 +374,7 @@ sequenceDiagram Details in that sequence that are easy to get wrong: - Callback references are copied to the Context *before* the transfer. A successful consume runs `_release()`, which drops the Signer's claim callback and DynamicAssertion registration list; copying either afterwards would leave native code with a callable that Python may already have collected. The DynamicAssertion registration tuples also carry the original Python callback and its exception state so callback failures can be re-raised after the FFI signing call returns. -- `set_signer` does not always take the pointer. A pre-consume rejection (`UntrackedPointer:` / `WrongPointerType:`) leaves the Signer `ACTIVE` and retained, so the triage must read the native error before deciding to close it. Treating every failure as "consumed" would close a signer the native side never took. +- `set_signer` validates before consuming the signer. Any registry rejection retains the Signer, whether it identifies the signer, the builder, or no address. This uses the helpers' default `consumes_first=False`. `PointerInUse:` and `WrongWrapperKind:` occur only with newer opaque registries. - A `ctypes.ArgumentError` from `set_signer` is re-raised untouched by `_invoke_consume`: marshalling failed, the native function never ran, and the Signer still owns its handle. Only calls that reached native go through the consumed/retained triage. - The builder is never held as a raw local across the signer and build calls. `_NativeBuilder`'s `with` block owns it: a settings error, a retained-signer error, a build rejection, or an async interrupt all free it through `close()`, and a successful build consumes it so `close()` is then a no-op. The old raw-pointer recovery block that used to free `builder_ptr` on the un-reached-build path is gone. @@ -404,36 +415,29 @@ Every call of this shape goes through one helper, which takes the FFI call as a ```python # Reader.with_fragment() internally does: self._consume_and_swap( - lambda handle: _lib.c2pa_reader_with_fragment(handle, format_bytes, stream), - Reader._ERROR_MESSAGES['reader_error']) + lambda handle: _lib.c2pa_reader_with_fragment( + handle, format_arg, main_obj._stream, frag_obj._stream), + Reader._ERROR_MESSAGES['fragment_error'], consumes_first=True) ``` The call is passed as a lambda because the helper supplies the handle and, on success, replaces it via `_swap_handle()`. -The helper exists because a failed return can be ambiguous. The native functions run in phases: it validates the **borrowed pointer** (passed in without transferring ownership; the caller still owns it unless the callee explicitly takes it over), then takes ownership, then does the work. A failure in the first phase and a failure after the second come back to Python as the same value (a null pointer, or a non-zero status), but they leave ownership in opposite places. +The return value alone cannot establish ownership. All three consume helpers accept the keyword-only `consumes_first=False`. Validate-first `c2pa_context_builder_set_signer` retains the signer on any registry rejection. These six calls instead take ownership of the managed handle before validating later arguments and use `consumes_first=True`: `c2pa_context_builder_build`, `c2pa_reader_with_stream`, `c2pa_reader_with_manifest_data_and_stream`, `c2pa_reader_with_fragment`, `c2pa_builder_with_definition`, and `c2pa_builder_with_archive`. -```mermaid -flowchart TD - CALL["FFI call(handle)"] --> V{"validate borrowed handle"} - V -->|invalid| R["reject: handle NOT taken
sets UntrackedPointer / WrongPointerType"] --> F1["returns a failure value
(null, or non-zero status)"] - V -->|valid| TAKE["take ownership of handle"] - TAKE --> WORK{"execute function logic"} - WORK -->|fails| DROP["native drops the value itself
sets some other error"] --> F2["returns a failure value
(null, or non-zero status)"] - WORK -->|succeeds| OK["returns replacement / 0 / new pointer"] - - F1 -.failure value returned to Python.- AMB(["needs to consult error to know failure mode from Python"]) - F2 -.failure value returned to Python.- AMB -``` - -The two failure paths are indistinguishable from the return value alone. Only the native error message set alongside them tells the phases apart: +Registry rejections start with `UntrackedPointer:`, `WrongPointerType:`, `PointerInUse:`, or `WrongWrapperKind:`, optionally wrapped in stock native's `Other: ` prefix. Tags quoted inside another error's payload do not establish ownership. The last two tags are addressless and exist only in newer opaque registries. Failure triage is: -| Native error | Who owns the handle | What the helper does | +| Native error / call order | Ownership decision | What the helper does | | --- | --- | --- | -| `UntrackedPointer:` or `WrongPointerType:` | Still ours: rejected before ownership moved | Handle kept, resource stays `ACTIVE`, typed error raised. Normal cleanup frees it later. | -| Any other error | Taken, then the operation failed | `_teardown(free_handle=False)`: the native side already dropped the value, so nothing is freed here. Resource goes `CLOSED`, error typed from the native message. | -| No error at all | Unknown | `_release_handle()` guarded free, the caller's message is raised with `"Unknown error"` filled in. | +| Any registry rejection, validate-first | Managed handle not taken | Retains the handle and `ACTIVE` state; raises the typed native error. | +| Addressed rejection, consume-first, rejected value equals managed value | Managed handle not taken | Retains the handle and `ACTIVE` state; raises the typed native error. | +| Addressed rejection, consume-first, known rejected value differs from managed value | Managed handle consumed before another argument was rejected | `_teardown(free_handle=False)`; closes without freeing, raises the typed native error. | +| Registry rejection, consume-first, missing rejected address or unreadable managed value (`None`) | Cannot establish ownership | Requests native release through `_release_handle()` and closes the Python resource; raises the saved typed native error. | +| Any non-registry native error | Native took and dropped the value | Closes without freeing; raises the typed native error. | +| No native error | Unknown | Guarded free and close; raises the caller's message with `"Unknown error"`. | + +`_handle_value()` reads the Python handle representation without dereferencing native memory: integers are used directly, otherwise `ctypes.c_void_p.from_buffer(handle).value` reads the stored pointer value. It avoids `ctypes.cast` and its reference cycle; an unreadable representation returns `None`. -This error and ownership triage relies on the native error still being readable (and correctly being the last error encountered) after the call returns. Reading an error copies the message out and frees the copy, but leaves the native slot set until the next error overwrites it. +Triage saves the native error before cleanup can overwrite it, preserving the exception raised by the wrapper, not restoring the slot itself. A defensive free can leave its own error in the sticky thread-local slot. The wrapper does not clear the slot before the call and trusts each failing native path to set its own error. Reading an error copies the message out and frees the copy, but leaves the slot set until the next error overwrites it. Three consume helpers share this triage; they differ only in what the FFI call returns on success: @@ -443,23 +447,24 @@ Three consume helpers share this triage; they differ only in what the FFI call r | `_consume_no_replacement()` | a status code (`0` = ok) | `_teardown(free_handle=False)`, resource `CLOSED` | | `_consume_into()` | a *different* object's pointer | `_teardown(free_handle=False)`, the pointer returned for the caller to own | -`_consume_no_replacement()` is how a `Signer` is fed to a `Context` (`set_signer` returns a status code); `_consume_into()` is how that same `Context` build returns the new context pointer. A failure in any of the three is handled by the same native-error triage, so a pre-consume rejection retains the handle rather than assuming it was taken. +`_consume_no_replacement()` feeds a `Signer` to a `Context` with the validate-first default; `_consume_into(..., consumes_first=True)` builds the context. All three helpers share the failure triage above. #### Why an ownership-taken failure does not free -A consuming FFI call can fail. It may reject the borrowed pointer before taking it, or it may take ownership first and then, on a later failure, drop the value itself. +A consume-first call can reject its own handle before taking it, or consume it and then reject another argument. Only an addressed rejection matching the managed value proves retention on this path. A known different rejected value or a non-registry error closes without freeing: native already owns or has dropped the managed value. -The native error message indicates which of the errors happened. A rejection carries one of the `_PRE_CONSUME_ERROR_TAGS` (`UntrackedPointer:` or `WrongPointerType:`), which means the handle was never taken and is retained. Any other error message means the native side may have taken ownership and already dropped the value. On top of those, preparing the call's own arguments can fail in Python before the native function ever runs (for example, encoding a bad value or a ctypes marshalling error other than `ArgumentError`), and that outcome is handled separately. +An unnecessary free can overwrite the native error slot. On stock 0.91.0 it can also free an unrelated allocation if the old address has been recycled. Newer opaque IDs avoid address reuse, but that does not justify issuing generic guarded frees for known-consumed handles. -The two settled branches each take the exact action their ownership implies. A pre-consume rejection (an error prefixed `UntrackedPointer:` or `WrongPointerType:`) means the handle is still the caller's, so it is retained and freed later by normal cleanup. Any other native error means the value is already gone, so `_teardown(free_handle=False)` runs the Python-side cleanup without freeing anything. +`_release_handle()` is the fallback for a missing native error, an exception other than `ctypes.ArgumentError` from `_invoke_consume`, or a consume-first registry rejection without a comparable address. `ctypes.ArgumentError` retains the handle and propagates unchanged because native was not called. For native failures the original error is saved before any guarded free, so cleanup cannot replace the reported failure. -Always calling the guarded free instead, even where the value is known to be gone, is tempting because a stale free looks like a harmless `-1` no-op. It is only harmless while the freed address stays unclaimed. The native registry rejects an address it no longer tracks, but once another thread allocates a fresh tracked object at that recycled address, the registry does track it again — and a stale free aimed at the old value would now find a live entry and destroy a different thread's object. The scenario is unlikely, but not unreachable: it needs a second thread inside its own FFI call, an allocator that hands back the exact address just freed, and that reuse to happen during the (narrow) window between the native drop and this free. But the window is real under concurrent use. The failure is a silent cross-thread corruption rather than a clean error, and the free is not needed in the first place on this branch. So where the value is known to be consumed, the free is skipped rather than issued and left to the registry to reject. The native error slot stays sticky: it holds whatever it last held until the next error overwrites it, and nothing clears it in between. Issuing an unneeded free would set an untracked-pointer error there that a later caller could mistake for the failure it actually asked about, so skipping the free keeps the slot free for the next real error. +The addressless `PointerInUse:` / `WrongWrapperKind:` fallback is safe with the newer opaque registry: release removes a tracked entry or rejects an already untracked handle without targeting a different allocation. Removal is not necessarily immediate destruction. Outstanding checkout guards retain the entry, so actual cleanup waits until the last guard is dropped. These errors do not exist on stock 0.91.0. Stock emitted addressed-rejection paths provide a comparable managed value, so the guarded-free rejection fallback is unreachable there; this is not a claim that arbitrary stale raw-address frees are safe. -`_release_handle()` (a guarded free) is reserved for the two branches where ownership is not known for certain: a Python exception raised before native reports anything, and a failure that leaves the error slot empty (which no defined native failure is expected to produce). In both, a guarded free is a good default, since it is a real free when the handle is still ours and a `-1` no-op when the native side already took it. +The per-resource RLock protects close/admission bookkeeping, not these consuming operations or their retained-vs-consumed triage. Their ownership guarantees depend on the native call order, pointer registry and thread-local error slot. Caller synchronization must cover unguarded operations and close; the selected signing-call guards do not make Reader, `with_archive()` or `recover()` thread-safe. This is distinct from [Fork safety](#fork-safety), which concerns a copied resource in a child process. -None of this is protected by a lock on the Python side: `ManagedResource` has no thread-safety mechanism of its own, and the retained-vs-consumed guarantee comes entirely from the native pointer registry and its thread-local error slot. As noted under [Which double-free risks this layer guards](#double-free-risk-mitigations), sharing one instance across threads without external synchronization is the caller's responsibility. This is a different hazard from [Fork safety](#fork-safety), which concerns a forked child process, not a thread within the same process. +Registry rejection describes the rejected argument, not necessarily the managed handle. Correct helper configuration therefore depends on the native call's ownership order, not just an error prefix. A native implementation with a different ownership contract could leak or free the wrong allocation; the fallback is not a general compatibility guarantee. -A consuming C FFI function first removes the pointer from its registry, then reconstructs the owned value from it. `untrack_or_return!` runs ahead of `Box::from_raw` in `c2pa_c_ffi`. If the address is unknown or the wrong type, the untrack step fails before ownership is taken and sets an error whose prefix (`UntrackedPointer:` or `WrongPointerType:`) identifies it as a pre-consume rejection. Once the value has been reconstructed, a later failure simply drops it, the same as any owned value going out of scope. The Python side stays defensive (and as generic as possible) rather than assuming any exact behavior: it retains the handle when it recognizes one of those rejection prefixes, and where the outcome is unclear it falls back to the guarded free. A native side that behaved differently would degrade in one of two bounded ways: If it kept a pointer the Python side treated as consumed, nothing would free that pointer and it would leak. If it had already released a pointer the Python side then tried to free, the registry would not find the address and the free would return `-1` without touching memory. +The remaining real-native concurrency and sticky-error checks are tracked in +[the roadmap](roadmap.md#native-resource-ownership-follow-ups). ### Adopting the handle before giving it away @@ -472,9 +477,9 @@ self._create_and_activate( self._consume_and_swap( lambda handle: _lib.c2pa_reader_with_stream( - handle, format_bytes, self._own_stream._stream, + handle, format_arg, self._own_stream._stream, ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], consumes_first=True) ``` Activating a handle that is about to be handed to the native library looks backwards, and there are two reasons for it. `_consume_and_swap` needs an active resource to read the handle from and swap the result into. It also puts the intermediate pointer under normal cleanup before anything can go wrong with it: whichever way the consuming call goes, `close()` and `__del__` will free the pointer if the native side did not take it. The alternative, holding the pointer in a local variable across the call, means every failure path has to decide for itself whether to free it. @@ -493,7 +498,7 @@ Examples from the codebase: | Signer | Drops the reference to the signing callback | | Settings | (no override, nothing extra to clean up) | -The cleanup order matters: `_release()` runs first (closing streams, dropping callbacks), then `c2pa_free` frees the native pointer. This order prevents the native library from accessing Python objects that no longer exist. +At physical teardown, `_release()` runs before `c2pa_free`, preserving stream/file ordering. A copied callback snapshot, not that ordering alone, prevents callbacks from becoming collectible before native destruction returns. Logical close during an admitted call does neither release nor free yet. ### Dropping a Context reference @@ -532,6 +537,20 @@ sequenceDiagram Both `_cleanup_resources()` and the consumed teardown take this branch. Neither simply skips the work: they null the handle and mark the object `CLOSED` so the child cannot go on to use it or try to free it later. Mutating the child's copy has no effect on the parent's, which is untouched and still valid. +The signing-call guard deliberately adds a limited admission restriction to the +previous cleanup-only PID behavior: `_native_call()` rejects a resource created +in another PID before acquiring its inherited lock or reaching the guarded FFI +call. Rejected admission does not close the object, clear its callback pins, or +release/free the parent's resource. Create each worker's signers, Contexts and +sessions within that worker process rather than inheriting them for signing. + +This is not a universal SDK-wide fork ban. Unguarded constructors using an +inherited Context are not uniformly blocked, and other unguarded methods do not +all perform this admission check. Lack of a check is not a guarantee that an +inherited native object is safe to use. External serialization of unguarded +operations and close remains the caller's responsibility; serialization does +not repair native mutex state inherited from vanished threads. + The memory the child skips is not lost for good. A child that calls `exec()` replaces its address space; a child that exits has its memory reclaimed by the OS. Even a long-lived child (a `multiprocessing` worker using the fork start method) retains at most the objects it inherited at fork time, which is a bounded, one-off amount rather than a growing leak. Anything the child allocates itself carries the child's own PID and is freed normally. > [!NOTE] @@ -545,6 +564,8 @@ The reason is that ownership runs in the opposite direction. A `Reader` or `Buil `Stream` tracks its own state with `_closed` and `_initialized` flags rather than `LifecycleState`, but it supports the same three cleanup paths: context manager, explicit `.close()`, and `__del__` fallback. +Python's `C2paStream` declaration is opaque (`_fields_ = []`): it only passes the pointer back to native and never reads its fields. This is safe with both stock 0.91.0's C-layout stream and newer opaque stream handles. `Stream` keeps the Python callback references separately. + ## Which method to use when? `_create_and_activate`, `_consume_and_swap`, `_consume_no_replacement`, @@ -557,13 +578,17 @@ different situation when writing a new subclass: | An FFI call consumes the current handle and returns a replacement for the same object | `_consume_and_swap(ffi_call, error_message)` | | An FFI call consumes the current handle to configure or feed another object, returning only a status code | `_consume_no_replacement(ffi_call, error_message)` | | An FFI call consumes the current handle and returns a *different* object's pointer, for that object to own | `_consume_into(ffi_call, error_message)` | -| A call fails and it is unclear whether native took the handle first (a Python exception before native reported anything, or an empty error slot) | `_release_handle()` | +| Ownership cannot be established after a failure (an exception other than `ctypes.ArgumentError`, an empty error slot, or a consume-first rejection without a comparable address) | `_release_handle()` | | A Python instance needs to wrap a handle a native call already returned, without creating a new one | `_wrap_native_handle(handle)` (classmethod) | | Ordinary teardown (`close()`, `__del__`) | Neither: these already route through `_cleanup_resources()` and `_teardown()`. Nothing outside `ManagedResource` itself calls `_teardown()` directly. | `_activate()` and `_swap_handle()` are two low-level primitives this situation table builds on. +The three consume helpers default to keyword-only `consumes_first=False`. +Pass `consumes_first=True` when native takes the managed handle before +validating later arguments, as in the fragment and stream examples above. + ## Implementing a subclass of `ManagedResource` To wrap a new native resource, inherit from `ManagedResource` and follow these rules: @@ -619,7 +644,10 @@ class NativeResource(ManagedResource): # 5. Check state at the start of every public method. # This raises C2paError if the resource is closed. self._ensure_valid_state() - return _lib.c2pa_my_resource_do_something(self._handle) + # 6. For a borrowed call that must survive callback close, admit it. + # This does not serialize native operations with each other. + with self._native_call(): + return _lib.c2pa_my_resource_do_something(self._handle) ``` ### Troubleshooting @@ -634,8 +662,25 @@ class NativeResource(ManagedResource): - `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls), so it must handle being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern. -- Calling `c2pa_free` directly is not recommended. `ManagedResource` handles this. A redundant free of an already-released pointer is not a crash: the native pointer registry rejects an untracked address without touching memory and returns `-1`. `ManagedResource` relies on this guard so the unknown-ownership failure paths can issue a guarded free without risking a double-free. A manual free is still wrong — the lifecycle owns the pointer and bypassing it defeats the state checks. +- Calling `c2pa_free` directly bypasses `ManagedResource` ownership and state checks. The registry rejects untracked values, but a stale stock address may have been reused for another allocation; a redundant raw-address free is not guaranteed harmless. Use the lifecycle helpers instead. - When a subclass inherits from both `ManagedResource` and an ABC like `ContextProvider`, and both define a property with the same name (e.g. `is_valid`), Python resolves it using the MRO. The parent listed first in the class definition wins. With the ABC listed first, Python finds the abstract property before the concrete one and raises `TypeError: Can't instantiate abstract class`. The class with the concrete implementation therefore comes first (e.g. `class Context(ManagedResource, ContextProvider)`, not `class Context(ContextProvider, ManagedResource)`). - When two parent classes define the same method or property with different concrete implementations, the MRO silently picks the first one, which can cause subtle bugs where the wrong implementation is used. With shared property names across multiple inheritance, `ClassName.__mro__` or `ClassName.mro()` confirms the expected resolution order. + +## Signing Calls And Reentrant Close + +An admitted borrowed signing call defers physical teardown of its resource until +the last admitted call returns. `close()` is immediately logical and rejects new +operations; it is not cancellation. Trusted VSI `_call()` and its manifest-ID +getter, complete-buffer LiveVideo VSI `_copy_signed_output()`, and Builder native +signing calls use the guard; Builder also guards its explicit borrowed Signer. +Other methods are not implicitly guarded: Reader, `with_archive()`, `recover()`, +constructors and other unguarded operations require caller serialization with +close. Concurrent native operations remain externally serialized even on the +guarded paths. The per-resource RLock protects admission/teardown bookkeeping only, +never a native call or user callback, so callback-initiated close cannot deadlock. +The guard drains in `finally`, including BaseException paths. Actual teardown +retains copied callback objects across `_release()` and native free, even though +`_release()` clears the resource's pin attributes and DA list. Consuming FFI +ownership rules and Reader stream release ordering remain unchanged. diff --git a/docs/release-notes.md b/docs/release-notes.md index 06f799e7..bb3a9d31 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,36 @@ # Release notes +## Unreleased: functional trusted-processor VSI API + +- Replaces the unshipped counter/result scaffold with + `TrustedVsiSession.sign_sig_structure(sig_structure, sequence_number) + -> bytes`. Expert mode signs supplied sequence metadata without allocating a + sequence or keeping a media journal. `TrustedVsiSignResult` is removed. +- Renames the unshipped `TrustedVsiPrehashedSession` to `TrustedVsiSession` + with the native contract's argument order (`context` first, `callback` after + `validity_period_secs`); `reserve_init_uuid()` returns the UUID box bytes and + `TrustedVsiInitUuidReservation` is removed. No aliases. +- Adds mode-pinned init/composed reserve/finalize, side-effect-free preflight, + canonical input validation/hash templates, and explicit state export/import. + The old unshipped `recover(init_uuid, previous_emsg)` is removed without alias. +- Pins claim/VSI/DA callbacks across context consumption, explicit close and + state import; preserves original callback exceptions and native errors. +- Capability probes require the exact functional symbol set, native version, + and complete capability mask. Older native libraries fail closed. +- Adds non-publishing Linux/Windows source and installed-wheel qualification. + Functional artifacts use the unreleased source identity `0.37.13.dev0` + (historically staged as `0.37.9.dev0` against native `3569fb86`). Immutable + dev5 release facts, pins and artifact names remain unchanged. Functional + native qualification is required, never an optional skip. +- Pairs with the consolidated native `0.92.0-dev` + (`castlabs/c2pa-rs@6b506352`, Rust 1.96.0) and integrates single-file ladder + signing (`Builder.sign_ladder`). Ladder signing now propagates + DynamicAssertion and claim-signer interrupt exceptions like the other Builder + paths. Consume-first FFI calls (Reader/Builder `with_*`) no longer treat a + registry rejection of another argument as proof the managed handle was + retained, and registry tags are recognized only as the error prefix (a tag + quoted inside another error's payload no longer establishes ownership). + ## Version 0.37.8.dev5 ### Breaking changes diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..c1a0981f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,28 @@ +# Roadmap + +## Native Resource Ownership Follow-Ups + +The generic consume-first ownership fix is in `7ba8615` and is integrated into +the functional Python branch. Follow-up `a303db8` documents that guarded +cleanup can change the sticky native error slot even though Python raises the +original snapshotted error; it adds a direct non-NULL typed-pointer test and +clarifies that a `PointerInUse` release can defer the native object's drop. +These are documentation and test improvements, not a new ABI or signing policy. + +Remaining work is separate from this integration: + +- Exercise a real opaque-native `PointerInUse` with an outstanding checkout + guard. Confirm that guarded release removes only that handle's registry entry, + defers the object drop until the last guard exits, and does not change the + Python exception. The existing sticky-slot assertions are mocked; add a + real-native check that distinguishes the raised exception from the native + error slot after cleanup. Do not generalize the opaque-ID guarantee to stock + native, whose raw allocation addresses can be reused. +- Stock c2pa-rs 0.91.0 Windows x64 ownership, unit/ladder and threaded suites + passed in [mstattma/c2pa-python Actions run 36775608167](https://github.com/mstattma/c2pa-python/actions/runs/36775608167). + Stock Windows ARM64 was outside this ownership patch's qualification scope; + evaluate that ownership lane separately from legacy Windows ARM64 wheel jobs. + +Qualify each new merged Python head against the pinned consolidated native +`6b506352` before updating the functional branch. These follow-ups do not +authorize a dev5 release or change immutable historical release evidence. diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md new file mode 100644 index 00000000..7d308ced --- /dev/null +++ b/docs/trusted-vsi-python-contract.md @@ -0,0 +1,291 @@ +# Trusted VSI Python Contract + +Status: integrated, unreleased PR4 review changes requiring native trusted-VSI +contract revision 3 (state version 3 and typed expert VSI payloads), following +`c2pa-rs` +`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev0`; +immutable dev5 release inputs and artifacts are unchanged. The paired source +workflow pins native `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c` for the +integrated revision-3 qualification batch. Select a reviewed local library with +`C2PA_LIBRARY_NAME` for integration tests. Version `0.92.0-dev` and mask 63 do not establish contract +identity; Python now requires the explicit native contract-revision probe. +Local source and installed-wheel checks passed; hosted results must be recorded +against the final source SHAs before claiming Linux/Windows qualification. +Prior results and machine-local provenance are archived in +[review verification](archive/trusted-vsi-review-verification.md). + +## Availability + +All `has_live_video_trusted_vsi_*()` probes return `True` only when the loaded +library exports every symbol in the contract's C ABI, reports the exact SDK +release token `c2pa-rs/0.92.0-dev`, returns +`c2pa_live_video_trusted_vsi_contract_revision() == 3`, and returns +`c2pa_live_video_trusted_vsi_capabilities() == 63`. The revision probe has the +safe stateless signature `uint32_t c2pa_live_video_trusted_vsi_contract_revision(void)`. +Only stateless probes are bound before these checks; operational trusted-VSI +ctypes signatures are bound only after every check passes. A missing revision +probe, revisions 0/1/2/4 (or any revision other than exactly 3), missing symbols, +wrong SDK version or any other mask disables all six availability probes. +There is no trial construction, status/state inspection or fallback to infer +compatibility. The SDK version is a release-line check, not a native commit or +generic SDK identity attestation. Old scaffold symbol layouts are never bound. +When unavailable, the constructor, `from_callback`, `validate_trusted_vsi_input` and +`trusted_vsi_hash_template` raise `C2paError.NotSupported` before inspecting +arguments, allocating buffers, registering/invoking callbacks, touching +operational native code or managed-resource state. Ordinary SDK signing and +legacy complete-buffer `LiveVideoVsiSession` callback bindings remain independent; +they are not feature fallbacks for trusted VSI. The observed revision is internal, +not a new public Python availability API. + +## Session + +```python +TrustedVsiSession.from_callback( + context, manifest_json, algorithm, public_cose_key, kid, + min_sequence_number, created_at, validity_period_secs, callback, *, + mode, reservation_nonce, signing_time_unix_seconds, sequence_max=None) +``` + +`TrustedVsiSession(...)` takes identical arguments. Argument checks (Python +`TypeError`/`ValueError`, before any native call): + +| Argument | Python type / range | +|---|---| +| `context` | active `Context` created with an explicit claim `Signer` (else `C2paError`) | +| `manifest_json` | `str` or `dict`, nonempty, no NUL | +| `algorithm` | `C2paSigningAlg.ES256`/`ED25519` or `"es256"`/`"ed25519"`/`"eddsa"` | +| `public_cose_key`, `kid` | nonempty `bytes` (public COSE_Key CBOR; private keys rejected natively) | +| `min_sequence_number` | uint32 | +| `created_at` | nonempty RFC 3339 `str` | +| `validity_period_secs` | 1 .. 2**64-1 | +| `callback` | callable `(VsiSigningContextV1, bytes) -> bytes` | +| `mode` | exactly `"expert_sig_structure"` or `"signer_composed_emsg"` | +| `reservation_nonce` | exactly 32 lowercase hex chars (public, coordinator-retained; not a key seed) | +| `signing_time_unix_seconds` | signed int64, pinned init iat | +| `sequence_max` | `None` (= UINT32_MAX) or uint32 >= `min_sequence_number` | + +Keyword options serialize to the native `options_json` +(`mode`, `reservation_nonce`, `signing_time_unix_seconds`, `sequence_max`). +Construction validates configuration natively but never signs. + +Methods (externally serialize calls on one session): + +| Method | Result | +|---|---| +| `reserve_init_uuid(format="video/mp4")` | `bytes`: complete placeholder UUID box; repeat returns the same frozen reservation | +| `reserved_manifest_id()` | `str` | +| `finalize_init_uuid(canonical_bmff_hash: bytes)` | `bytes`: complete signed UUID, same length as reservation; identical-input replay only | +| `commit_init_uuid()` | `None`; durable coordinator activation, NOT a publication ACK | +| `sign_sig_structure(sig_structure: bytes, sequence_number: int)` | `bytes`: exactly 64 raw signature bytes (ES256 P1363 or Ed25519) | +| `reserve_media_emsg_at(sequence_number, signing_time_unix_seconds, timescale, event_duration)` | `TrustedVsiMediaEmsgReservation` | +| `finalize_media_emsg(canonical_bmff_hash: bytes)` | `bytes`: complete signed EMSG, same length as reservation | +| `export_state()` | `bytes`: versioned public JSON record (currently version 3), including pending reservations | +| `import_state(state: bytes)` | `None`; only into a NEW session with identical identity (see State records) | +| `status()` | `TrustedVsiStatus` | +| `preflight(operation, data=b"", *, sequence_number=0, iat=0, timescale=0, event_duration=0, format="video/mp4")` | `None`; no callbacks, key use, reservation or mutation | +| `close()` | idempotent; releases only this session | + +Module functions: `validate_trusted_vsi_input(kind, algorithm, data: bytes) -> None` +and `trusted_vsi_hash_template(kind) -> bytes` (init/media kinds; canonical +zero-digest bmff-hash v3 template). Enums (int values accepted, bools rejected): +`TrustedVsiOperation` RESERVE_INIT=0, FINALIZE_INIT=1, COMMIT_INIT=2, +EXPERT_SIGN=3, RESERVE_MEDIA=4, FINALIZE_MEDIA=5; `TrustedVsiInputKind` +INIT_HASH=0, SIG_STRUCTURE=1, MEDIA_HASH=2. + +### Value types (frozen dataclasses) + +- `VsiSigningContextV1(purpose, sequence_number=None, event_id=None, exhaust_after_sign=False)`. + `purpose` is `"signer_binding"` (no sequence/event, never exhausting) or `"vsi"`. + Expert callbacks: `("vsi", supplied_sequence, None, False)` always, even at UINT32_MAX. +- `TrustedVsiMediaEmsgReservation(placeholder_emsg_box, signing_context, + signing_time_unix_seconds, timescale, event_duration)` with read-only + `sequence_number` / `event_id` properties. `signing_context` is exactly what the + finalize callback will receive (terminal `exhaust_after_sign=True` at the limit). +- `TrustedVsiStatus(init_uuid_committed, init_uuid_pending, media_emsg_pending, + next_sequence_number, next_event_id, exhausted, exhaustion_reason=None, blocked=False)`; optional + fields are `None` when absent. Expert: counters `None`, `exhausted=False`. + `exhaustion_reason` is `"sequence_max"`, `"event_id_max"` or `"legacy_sentinel"`. + `blocked` reports an external-signing failure, independently of exhaustion. + The C V1 layout has `blocked: bool` at offset 18; `exhaustion_reason: uint32` + remains at offset 20 (24-byte size, 4-byte alignment). + This native field already exists in the older workflow pin `6b506352`, including + its `blocked: rust.blocked()` conversion; the Python bridge now exposes it. + The integration hold is not a known missing-`blocked` ABI problem in that pin; + the new revision gate nevertheless rejects that older library. + +Removed without aliases: `TrustedVsiPrehashedSession`, `TrustedVsiSignResult`, +`TrustedVsiInitUuidReservation`, `recover(...)`, and the private Python gate. + +## Semantics For The Signer Adapter + +- Expert: the processor supplies the sequence (must equal `moof/mfhd`) and owns + ordering, replay IDs and rollover. Any sequence in `[min, max]` is accepted, + including repeats of older sequences. Native validates framing and a typed, + untagged VSI payload map: `sequenceNumber` (uint32 matching the supplied + sequence), `manifestId` (the pinned init ID), and `bmffHash` (the exact native + SHA-256 media-template map with a 32-byte hash), plus optional valid + `manifestUri` hashed-URI map. Protected headers require canonical matching + integer `alg` and integer `iat` within the configured key window. Payload + field order need not be canonical. Native signs the original bytes unchanged + and keeps no expert media counter. A detached signerBinding certificate bstr + is not an expert VSI payload. Static validation checks shape/types but cannot + check a particular session's manifest ID or key validity window. +- Composed: reserve requires `sequence_number == next_sequence_number` + (initially `min`); events start at 1; timing values must be positive. Reserve + signs nothing. Finalize advances counters or exhausts without wrapping. +- The trusted processor hashes final-placement bytes with the reserved box + installed (`trusted_vsi_hash_template` + 32-byte SHA-256 `hash`). Only + `video/mp4` is supported. There is no C/Python hash helper; this is the + BMFF v2+/v3 top-level rule that the native Rust reference `trusted_vsi_compute_hash` + (`BmffHash::gen_hash_from_stream`) implements: + SHA-256 over, for each top-level box in file order except the single excluded + C2PA box (init: `uuid` with the C2PA UUID at offset 8; media: `emsg` with scheme + `urn:c2pa:verifiable-segment-info` at offset 12), the box's big-endian uint64 + file offset followed by its complete bytes. Offsets are those of the FINAL + placement. Native tests place the init UUID directly after `ftyp`, and the media + EMSG at the front of the segment; after a leading `styp` is also valid. The + replacement box has exactly the reserved length, so the hash is unchanged by + finalization. See `_hash_input`/`_place` in `tests/test_trusted_vsi_api.py`. +- Verification: the signed init validates via `Reader("video/mp4", init_stream, + context=...)`. Do NOT use `Reader.from_fragmented_files` / `with_fragment` for + live-video VSI: that Merkle fragmented-BMFF path rejects every section 19.3 init + manifest (`assertion.bmffHash.mismatch`, "Hash value should not be present for a + fragmented BMFF asset"), including output from the shipped complete-buffer + `LiveVideoVsiSession`. Media VSI EMSGs are validated by the Rust-only + `LiveVideoValidator`; no C/Python segment validator exists. Python tests verify + the EMSG independently: version-0 `emsg`, `urn:c2pa:verifiable-segment-info`, + pinned timescale/duration/event ID, tagged COSE_Sign1 with protected + `{1: alg, "iat": iat}`, unprotected `{4: kid}`, payload + `{sequenceNumber, manifestId, bmffHash}`, where `bmffHash` equals the canonical + hash input exactly, plus the signature over `["Signature1", protected, b"", payload]`. +- After a failure once an external signing call began, the session is blocked. + Discard it, construct a NEW session and `import_state()` the durable + PRE-operation record. Operation-ID/same-input retry enforcement belongs to the + coordinator and key provider, not native V1 metadata. + +## State Records + +`export_state()` returns native-owned bytes; Python neither parses nor rewrites +them. Treat them as opaque, persist them atomically and authenticated, and pass +them back byte-for-byte. The native format is +`{"format": "c2pa.trusted-vsi.state", "version": 3, "identity", "state"}`. +Versions 1 and 2 (unreleased) are rejected; there is no migration. +Trusted reservation salts are deterministically derived from the public nonce +and versioned artifact/label domains, not private key material. Import and init +finalize preflight reconstruct the entire expected reservation (including static +assertions, resources and DA slots) and require byte-for-byte equality; matching +the editable identity alone is not sufficient. Signed-init imports additionally +validate full finalized-store consistency. Python treats these bytes as opaque. + +`import_state()` succeeds only on a NEW session whose identity matches exactly: +mode, VSI session config/public key/kid, constructor options (including the +reservation nonce and init iat), manifest, claim-signer certificate, claim-signer +**reserve size**, and the ordered DynamicAssertion declarations (label and +reserve size of each, in registration order). Mismatches raise `C2paError` +("state record identity does not match ...") from `import_state` itself, before +any mutation and without invoking the VSI, claim-signer or DynamicAssertion +callbacks; the session remains New and usable. + +Adapter consequence: the claim-signer reserve size depends on how the `Signer` +is built, not only on its certificate. For example, with the ES256 fixture +certificate `Signer.from_info` reserves 2361 bytes and `Signer.from_callback` +11836. The process that imports a record must build its Context signer the same +way (same factory, certificate, TSA setting) and register the same +DynamicAssertions in the same order as the process that exported it. + +## Ownership And Errors + +The caller owns its `Context`; native retains it (Arc). The session separately +pins the Python claim-signer callback, DynamicAssertion callbacks and the VSI +callback, so they survive signer consumption into the Context and caller +`Context.close()`. Returned native byte buffers are initialized to NULL, copied, +and freed exactly once with `c2pa_free`; the manifest-ID string uses +`c2pa_string_free`. Input buffers are borrowed. + +Signing calls on TrustedVsiSession, complete-buffer LiveVideoVsiSession and +Builder admit a native borrow under a short per-resource lock. `close()` makes +the resource logically closed immediately and rejects new operations, but does +not cancel an admitted operation. Physical `_release` and handle free wait for +the last admitted call to return, including when close is called reentrantly +from a callback or from another thread. Builder also guards its explicit borrowed +Signer. Locks are never held across native calls or user callbacks. Callback +objects (including a copied DA list) remain pinned through `_release` and native +destruction, then become collectible. Consuming-handle ownership and Reader +stream cleanup ordering are unchanged. Builder still closes automatically after +an attempted sign, on success or failure including BaseException. If a borrowed +Signer closes after preflight and admission fails, Builder closes too; earlier +validation failures remain non-consuming. These guards do not make all resource +methods thread-safe: unguarded Reader, `with_archive()` and complete-buffer +`recover()` calls must be externally serialized with close and other operations. +Concurrent native operations also require serialization on guarded paths. + +Guarded admission deliberately rejects foreign-PID inherited resources before +touching an inherited lock or making the guarded FFI call. This is a limited +behavior change from cleanup-only suppression of inherited native frees, not an +SDK-wide fork ban. Create worker-owned signers, Contexts and sessions in the +worker. Unguarded constructors accepting inherited Contexts are not uniformly +blocked; their existence does not establish safe inherited-object use. Caller +serialization obligations for unguarded operations and close remain unchanged. +See [fork safety](native-resources-management.md#fork-safety). + +Errors: Python argument problems raise `TypeError`/`ValueError`. Callback results +that are not exactly 64 `bytes` raise `TypeError`/`ValueError`. Any exception +raised by the VSI callback, claim-signer callback or a DynamicAssertion callback +is re-raised with its identity intact. Native validation/state failures raise +typed `C2paError` subclasses from the native error text. Python never rewrites +CBOR, BMFF or validation results. + +## Qualification Identity + +`scripts/build_trusted_vsi_functional.py --library --out +` first requires all probes true against that library, then builds a +NON-PUBLISHING wheel and sdist in a temporary staging copy with +`FUNCTIONAL_BUILD_VERSION` (default: the checkout's source identity +`0.37.13.dev0`; rejects `0.37.8.*`, dev5, release versions and anything older +than the source identity, including the historical `0.37.9.dev0`). +`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native library only. The +immutable dev5 release lock and tooling are unchanged and refuse this source, +whose `pyproject.toml` version differs from the lock. + +`scripts/qualify_trusted_vsi_functional.py --wheel --venv +--version 0.37.13.dev0` installs the wheel into an isolated venv, strips +`PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, +`test_fragmented_files.py`, `test_sign_ladder.py`, +`test_native_ownership.py` and `test_native_ownership_opaque.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, +`C2PA_REQUIRE_SIGN_LADDER=1` and `C2PA_REQUIRE_FRAGMENTED_FILES=1`; the paired +fixture asserts the imported package and native library come from that venv +with the expected version, and missing ladder or fragmented capabilities fail +rather than skip. + +Paired tests require the full native library and FAIL under +`C2PA_TRUSTED_VSI_ABI_REQUIRED=1` (all Linux/Windows qualification jobs); they +skip only in ad-hoc local runs. `.github/workflows/trusted-vsi-paired.yml` +builds the native with Rust 1.96.0 from the reviewed consolidated commit +`6b506352800c8225cf5564ce99c726aaa71039f4` (ContentAuth main `69907b5a` merged +plus CI-only fixes; previously `203dc08d`, before that `5c186c07`). That older +pin emits state version 2 and lacks the required revision probe, so Python +disables trusted VSI before binding its operational ABI. Required qualification +fails with missing-symbol/revision diagnostics rather than skipping. The preserved +step2 review cdylib (`149f4b25...`) also lacks the new probe and cannot qualify +this gate. No tests or capability gates are weakened to accommodate this hold. +Final native integration/qualification and a full-SHA repin belong to a separate +authorized step, not these Python-only review changes. + +## PR4 CI Gate + +At head `12d265db92e8dcbf80b8255278e9a7fc5945f750`, Build run +`36810322522` completed its version/format/tooling jobs but skipped the paired +and platform test jobs. The current PR API reports author association `MEMBER` +and no labels; the source gate already accepts `COLLABORATOR` and `MEMBER`. +The retained run metadata does not establish the event-time association, so this +is not evidence that the current MEMBER condition is wrong. No author gate is +broadened here (in particular no speculative OWNER exception or fork secrets). +Do NOT apply the maintainer `safe to test` label or run paired CI expecting it to +pass until the final native revision is integrated and pinned in the separate +authorized integration step. The unchanged older native pin and the new +revision-3 gate are intentionally incompatible. Only after integration +should a maintainer review the head, apply the existing label to trigger the +`labeled` event, and confirm required jobs actually ran. This task does not post +labels, dispatch CI, or repin. A green workflow with skipped test jobs is not +qualification, and local targeted older-library compatibility checks do not lift +the integration hold. diff --git a/docs/upstream-integration-baseline.md b/docs/upstream-integration-baseline.md new file mode 100644 index 00000000..896413c2 --- /dev/null +++ b/docs/upstream-integration-baseline.md @@ -0,0 +1,131 @@ +# Upstream Integration Baseline + +Baseline-only Python integration, tested 2026-09-10. Functional trusted-VSI +bindings are deliberately not implemented or enabled. This is not release +qualification and does not change immutable dev5 source/version/artifact facts. + +Subsequent checkpoint: native `e0f980ec` fixes the purpose-isolation blocker +recorded below. The unchanged `TestSettings` plus scaffold ABI checks passed +56 tests and 6 subtests in 1.15s on that baseline. The historical results below +remain unchanged; ongoing functional Python work is documented separately in +`trusted-vsi-python-contract.md` and does not use the disabled scaffold contract. + +## Scope And Pairing + +- Python worktree: `/root/opencode-worktrees/c2pa-python-trusted-vsi-functional`. +- Python starting commit: `0d48e6a09b9d627dcf6385e3e591be8086e9af1f`. +- Native library: `/root/opencode-worktrees/c2pa-rs-trusted-vsi-functional/target/debug/libc2pa_c.so`. +- Native reported version: `0.91.0-dev`, integrating ContentAuth `312491af0e3e9fb5b3ba604d86ef44194ab580d9`. +- Native worktree HEAD at inspection: `cee86aae03887b5a0dddcd765a39e96360963bb0`; + integrated native changes were not yet committed. A later native review/rebuild + must be qualified again; these results do not certify that future binary. +- Tested library SHA-256: `10e6bf5d17d707f7e25d11eba69bce23318d12679a2c7b39046b8d5cdd43eaea`. +- Tested library size: 280930096 bytes; mtime `2026-09-10 13:32:02.274382637 +0200`. + +Every native-backed command used: + +```sh +env PYTHONPATH=/root/opencode-worktrees/c2pa-python-trusted-vsi-functional/src \ + C2PA_LIBRARY_NAME=/root/opencode-worktrees/c2pa-rs-trusted-vsi-functional/target/debug/libc2pa_c.so \ + C2PA_SOURCE_BUILD_VERSION=0.91.0-dev \ + C2PA_TRUSTED_VSI_ABI_REQUIRED=1 \ + python3 -m pytest ... +``` + +No native builds, global pip changes, wheel/sdist builds, publication, commits, +or pushes were performed. `python3 setup.py egg_info` generated ignored metadata +under `src/c2pa_python.egg-info` with the existing `0.37.8.dev5` Python version +solely to run the opt-in smoke tests from source. Otherwise installed metadata +reported unrelated version `0.31.0`. No new native binary was copied or packaged +under a dev5 artifact name. + +## Changes + +Exact edited files: + +- `src/c2pa/c2pa.py`: `Settings.update` documents native additive trust merging + and fresh-settings/context removal semantics; no new FFI bindings or runtime + trust rewriting. +- `docs/context-settings.md`: typed manifest/CAWG/TSA configuration, explicit + memberships, additive updates, removal semantics, and qualification warning. +- `tests/trust_config_test_settings.json`: original bundle retained byte-for-byte + in manifest and TSA entries; original global EKU policy retained. No new roots, + CAWG membership, allowed-list, revocation fetch, or verification bypass. +- `tests/test_unit_tests.py`: fixture membership digests, additive/removal and + purpose-isolation regressions; correct four ingredient MIME arguments and add + an explicit mismatch-rejection regression. +- `docs/upstream-integration-baseline.md`: this qualification record. + +The nine-certificate legacy bundle hashes to +`f3de5e4ea3213319eedc5e3890f0ff615bf0e754323ffd20dcca8a3f1c5ab921`. +The unchanged EKU text hashes to +`174983a609d76784c4ef5e2621740bf32fb615f88412d94f4fc26670365a9b81`. + +## Compatibility Findings + +1. Legacy thread-local `load_settings` with `trust.trust_anchors` returned `Valid` + instead of the original expected `Trusted` for `C.jpg`. Explicit `Context` + still converted the legacy field successfully. Typed entries fix the legacy + test path while keeping all original trust assertions intact. TSA membership + is explicit so new timestamps retain the original bundle authorization. +2. Native ingredient parsing now rejects JPEG bytes declared as `image/png`, + raising `Other: asset could not be parsed: invalid header` with PNG/JPEG magic + bytes in the message. Four multiple-ingredient/resource tests used `A.jpg` + with a PNG MIME. They now declare `image/jpeg`, retaining the same assets and + operations. A separate negative test retains coverage of the rejected input. + Reader MIME autodetection tests remain unchanged and passed. +3. Native settings merges are additive, including changed entries sharing a + `trust_uri`; `anchors: []` does not remove existing entries. Fresh settings + plus a new context remove trust without mutating existing contexts. Tested + with actual native readers, not mocked settings. +4. **Open native security blocker:** a fresh context containing only a `cawg` + anchor or only a `tsa` anchor for the fixture root still reports `C.jpg` as + `Trusted`, including `signingCredential.trusted`, no failure codes, and the + supplied trust-list URI. No manifest-purpose anchor was supplied. The new + `TestSettings.test_settings_typed_trust_purposes_do_not_authorize_other_roles` + preserves the expected `Valid` result for those two cases and currently fails + both subtests. It is not skipped or xfailed. This blocks qualification of + purpose isolation; Python must not hide or relabel native validation results. + +Native evidence for item 4 (read-only inspection): `sdk/src/store.rs:166-198` +loads every purpose into the same certificate trust policy; +`sdk/src/crypto/cose/certificate_trust/openssl.rs:34` iterates all anchor sets; +`sdk/src/crypto/cose/verifier.rs:311-325` logs `signingCredential.trusted` for a +successful result without rejecting the returned non-manifest anchor type. +The Rust-native backend also iterates all sets. Native remediation belongs to +the native owner, not this Python baseline change. + +## Test Evidence + +Commands below were run from the Python worktree with the pairing environment +above. Durations are pytest wall times. No old-library capability skips were +introduced; the paired trusted-VSI scaffold checks ran against the real library. + +| Command / selection | Result | Seconds | +|---|---|---:| +| `--collect-only -q` before changes | 613 tests collected | 0.94 | +| `tests/test_unit_tests.py::TestSettings tests/test_unit_tests.py::TestReader::test_stream_read_get_validation_state_with_trust_config tests/test_trusted_vsi_api.py` before fixture migration | 53 passed, 1 legacy trust failure | 0.68 | +| `--ignore=tests/test_unit_tests_threaded.py --durations=20 -o faulthandler_timeout=120` after fixture migration | 555 passed, 4 ingredient MIME failures, 73 subtests passed; release-smoke module opt-in skip | 504.99 | +| `tests/test_unit_tests_threaded.py -k 'not TestContextualBuilderWithThreads' --durations=15 -o faulthandler_timeout=120` | 41 passed, 13 assigned to next partition | 190.74 | +| `tests/test_unit_tests_threaded.py -k TestContextualBuilderWithThreads --durations=15 -o faulthandler_timeout=120` | 13 passed, 41 already covered | 159.19 | +| `tests/test_unit_tests.py -k 'TestSettings or add_multiple_ingredients or rejects_mismatched_format' --durations=10` after MIME fixes and new regressions | 16 test methods passed; 2 purpose-isolation subtests failed, 4 subtests passed | 11.00 | +| `tests/test_castlabs_release_smoke.py --durations=10` with additional `CASTLABS_RELEASE_SMOKE_REQUIRED=1` | 5 passed, no skips | 1.29 | +| `tests/test_trusted_vsi_api.py tests/test_castlabs_release_smoke.py tests/test_castlabs_release_tooling.py tests/test_unit_tests.py::TestC2paSdk --durations=5` with additional `CASTLABS_RELEASE_SMOKE_REQUIRED=1` | 70 passed, no skips | 2.76 | + +The original suite's failures were rerun after correction; all original tests +have passing coverage across these runs. The newly added purpose-isolation +regression remains failing. The release-smoke module's initial opt-in skip was +subsequently exercised explicitly, not accepted as qualification evidence. + +Timeout allowances: non-threaded 600 seconds; threaded partitions 600 and 480 +seconds. Neither partition timed out. Process inspection found no concurrent +native build/test jobs before starting the long runs. Slowest tests were +non-threaded sign-all-files (83.02s / 71.08s) and threaded async sign-all-files +(79.97s / 58.87s), consistent with these tests' live TSA requests. Tests retained +their original network behavior and contention workloads. + +The trusted-VSI surface remains disabled: Python capability helpers return +false and the paired native scaffold ABI returns disabled outputs. Functional +bindings must wait for the separately assigned functional ABI and qualification. +The native library SHA-256 was unchanged at the final check. `git diff --check` +passed; only the five files listed above are modified/untracked. diff --git a/docs/usage.md b/docs/usage.md index 69e730a3..ddadae57 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -24,6 +24,31 @@ from c2pa import Settings, Context, ContextBuilder, ContextProvider All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. +## Trusted-processor VSI (unreleased functional API) + +`TrustedVsiSession` is separate from complete-buffer +`LiveVideoVsiSession`. It requires the complete functional 0.92.0-dev native ABI +and capability mask 63. Older libraries import normally but do not advertise +trusted functionality; construction fails before inspecting arguments or +invoking callbacks. These changes are not in immutable dev5 artifacts. + +Expert mode uses `sign_sig_structure(sig_structure: bytes, sequence_number: int) +-> bytes`. The trusted processor supplies the uint32 sequence as metadata and +owns media ordering, MFHD/VSI equality, and EMSG construction. Native validates +canonical framing and signs the original bytes without decoding the opaque +payload. The result is exactly 64 raw signature bytes, not a sequence result. +Expert callback metadata has no event ID or exhaustion, even at UINT32_MAX. + +Composed mode reserves a full EMSG at the supplied sequence/time and finalizes +it against the canonical BMFF hash. Init uses the same reserve/finalize split +for full UUID boxes. Explicit public-state export/import preserves pending +reservations; preflight validates without key use or state mutation. Native +owns all state validation and cryptography, while the coordinator owns durable +operation identities and provider retry enforcement. + +See the [Python contract](trusted-vsi-python-contract.md) for exact constructor +options, mode names, callback ownership, error behavior, and qualification commands. + ## Define manifest JSON The Python library works with both file-based and stream-based operations. @@ -319,6 +344,15 @@ Recovery remains artifact-driven and does not invoke the configured clock. The Castlabs native fork can sign and validate DASH/HLS-style fragmented BMFF file sets. Check the native capability before using these file-based APIs: +The glob-aware signing and exclusive-output guarantees below are verified with +the paired Castlabs `0.92.0-dev` native including #17. `has_fragmented_files()` +checks export presence, not that semantic contract. Older native lineages may +accept only literal init paths or have different overwrite semantics; older +Castlabs builds (including stable `0.80` releases) may contain backports. No +blanket pre-`0.92` rejection or fallback is imposed by this wrapper. Check the +exact native implementation/provenance before relying on these guarantees with +a different library. + ```py from pathlib import Path @@ -346,7 +380,37 @@ with Reader.from_fragmented_files( print(reader.json()) ``` -`asset_path` must be one literal existing initialization-segment file; only `fragments_glob` is a glob. A native sign attempt closes the single-use `Builder` but borrows and leaves the explicit `Signer` active. The returned manifest buffer is copied into Python-owned `bytes` and released natively. +`asset_path` is an init path or native glob matching one or more initialization +segments. For segmented ABR, use e.g. `asset_path="renditions/*/init.mp4"` with +`fragments_glob="segment-*.m4s"`. The native operation signs all matched +renditions with **one shared manifest**, with a separate Merkle map and media +selectors for each rendition. `fragments_glob` is evaluated relative to each +init's parent, including subdirectories; output flattens fragment subdirectories +under `//`. Init parent directory names must be +distinct and each matched init must have a named parent directory (e.g. +`video/init.mp4`, not bare `init.mp4`). This input shape differs from +`Builder.sign_ladder`, which accepts single-file fragmented MP4 renditions, not +separate inits and media segments. + +Paths use native glob syntax even for a single init; escape literal +metacharacters with bracket expressions (e.g. `[[]` for `[`). Matched init parent +paths must not contain literal glob metacharacters, because native reuses them +in fragment globs. Native rejects malformed or empty-match globs, output name +collisions, and existing rendition output directories before writing. Use fresh, +exclusively owned destinations outside input globs, and do not modify inputs or +outputs concurrently. Reservation or signing errors can leave empty or partial +outputs; remove only newly created files you positively own, never sources or +preexisting destinations. The C ABI reserves directories/inits exclusively; the +lower-level Rust SDK's allowance for existing non-source output inits is **not** +the Python/C ABI contract. + +Python validates text/path-like input types, UTF-8, nonempty paths and NULs; +invalid UTF-8 raises `C2paError.Encoding`, while empty/NUL paths raise `ValueError`. +Native validates glob syntax, matches and layouts. A native sign attempt +(including native validation failure) closes the single-use `Builder` but borrows +and leaves the explicit `Signer` active. Python preflight failures remain +non-consuming. The returned shared manifest buffer is copied into Python-owned +`bytes` and released natively. Pass an active `Context` to `Reader.from_fragmented_files()` to use explicit verification and trust settings. Omitting `context` preserves the legacy thread-local settings behavior. The reader requires at least one explicit fragment path and retains the supplied Context and callback references until the reader closes. diff --git a/pyproject.toml b/pyproject.toml index cb84a7d6..9eb5b58f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.8.dev5" +version = "0.37.13.dev0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } @@ -22,10 +22,7 @@ maintainers = [ ] urls = {homepage = "https://contentauthenticity.org", repository = "https://github.com/castlabs/c2pa-python"} dependencies = [ - "wheel>=0.41.2", - "setuptools>=68.0.0", "toml>=0.10.2", - "pytest>=7.4.0", "cryptography>=41.0.0", "requests>=2.0.0" ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 083439e7..1e09955b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,6 +6,8 @@ toml==0.10.2 # For reading pyproject.toml files # Testing dependencies pytest>=8.1.0 +cbor2>=5.6.0 # Canonical CBOR fixtures for functional trusted-VSI qualification +packaging>=23.0 # Separate development artifact version validation # for downloading the library artifacts requests>=2.0.0 diff --git a/scripts/build_trusted_vsi_functional.py b/scripts/build_trusted_vsi_functional.py new file mode 100644 index 00000000..7d59c798 --- /dev/null +++ b/scripts/build_trusted_vsi_functional.py @@ -0,0 +1,130 @@ +"""Build source-paired functional test artifacts, never release/publish dev5. + +The development version is applied only in a temporary source staging tree. +The repository's version and immutable release inputs are left untouched. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import sysconfig + +import toml +from packaging.version import Version + + +ROOT = Path(__file__).resolve().parents[1] + + +def source_version(root: Path = ROOT) -> str: + """Return the checkout's unreleased functional source identity.""" + return toml.load(root / "pyproject.toml")["project"]["version"] + + +def functional_version(value: str, root: Path = ROOT) -> str: + version = Version(value) + if (not version.is_devrelease or version <= Version("0.37.8.dev5") + or version.release == (0, 37, 8)): + raise ValueError("functional version must be a newer development series than 0.37.8") + minimum = Version(source_version(root)) + if version < minimum: + raise ValueError( + f"functional version {version} must not be older than the source " + f"identity {minimum}") + return str(version) + + +PROBES = ( + "split_init", "expert_sig_structure", "composed_emsg", "recovery", + "signing_context_v1", "full_uint32_exhaustion", +) +PROBE_SCRIPT = "import c2pa\n" + "".join( + f"assert c2pa.has_live_video_trusted_vsi_{name}(), {name!r}\n" for name in PROBES) +STAGED_DIRECTORIES = ("src", "scripts", "release", "docs", "tests") +STAGED_FILES = ("pyproject.toml", "setup.py", "MANIFEST.in", "README.md", "LICENSE-MIT", + "LICENSE-APACHE", "requirements.txt", "c2pa-native-version.txt") + + +def stage_source(stage: Path, version: str, root: Path = ROOT) -> None: + """Copy the checkout into ``stage`` and apply ``version`` there only.""" + version = functional_version(version, root) + for directory in STAGED_DIRECTORIES: + if (root / directory).is_dir(): + shutil.copytree(root / directory, stage / directory, ignore=shutil.ignore_patterns( + "__pycache__", "*.egg-info", "libs", "temp_data", "*.log")) + for name in STAGED_FILES: + shutil.copy2(root / name, stage / name) + project = toml.load(stage / "pyproject.toml") + source_version = project["project"]["version"] + project["project"]["version"] = version + (stage / "pyproject.toml").write_text(toml.dumps(project), encoding="utf-8") + binding = stage / "src/c2pa/c2pa.py" + text = binding.read_text(encoding="utf-8") + marker = f"# Version: {source_version}" + if marker not in text: + raise ValueError("binding version header does not match pyproject.toml") + binding.write_text(text.replace(marker, f"# Version: {version}", 1), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--library", type=Path, required=True) + parser.add_argument("--version", default=os.environ.get("FUNCTIONAL_BUILD_VERSION") or source_version()) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + version = functional_version(args.version) + library = args.library.resolve(strict=True) + expected_name = {"linux": "libc2pa_c.so", "win32": "c2pa_c.dll", "darwin": "libc2pa_c.dylib"}[sys.platform] + if library.name != expected_name: + parser.error(f"expected the native library {expected_name}") + output = args.out.resolve() + output.mkdir(parents=True, exist_ok=True) + if any(output.iterdir()): + parser.error("output directory must be empty") + + # The actual paired library must qualify before any new artifact is built. + env = dict(os.environ, PYTHONPATH=str(ROOT / "src"), C2PA_LIBRARY_NAME=str(library)) + subprocess.run([sys.executable, "-c", PROBE_SCRIPT], env=env, cwd=ROOT, check=True) + + with tempfile.TemporaryDirectory(prefix="functional-build-", dir=output) as temporary: + stage = Path(temporary) + stage_source(stage, version) + + # Existing setup.py stages artifacts into the wheel and removes that + # staging directory afterward. Do not touch the checkout's libs/ at all. + platform_id = subprocess.check_output([ + sys.executable, "-c", "from c2pa.lib import get_platform_identifier; print(get_platform_identifier())", + ], env=env, cwd=ROOT, text=True).strip() + native_dir = stage / "artifacts" / platform_id + native_dir.mkdir(parents=True) + shutil.copy2(library, native_dir / library.name) + build_env = dict(os.environ) + build_env.pop("PYTHONPATH", None) + subprocess.run([sys.executable, "setup.py", "sdist", "--dist-dir", str(output)], + cwd=stage, env=build_env, check=True) + wheel_platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + subprocess.run([sys.executable, "setup.py", "bdist_wheel", "--plat-name", wheel_platform, + "--dist-dir", str(output)], + cwd=stage, env=build_env, check=True) + + artifacts = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in output.iterdir() if p.is_file()} + (output / "functional-build.json").write_text(json.dumps({ + "qualification_only": True, + "python_version": version, + "native_library": str(library), + "native_sha256": hashlib.sha256(library.read_bytes()).hexdigest(), + "artifacts": artifacts, + }, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/qualify_trusted_vsi_functional.py b/scripts/qualify_trusted_vsi_functional.py new file mode 100644 index 00000000..b52c9d87 --- /dev/null +++ b/scripts/qualify_trusted_vsi_functional.py @@ -0,0 +1,51 @@ +"""Run functional tests against an installed qualification wheel, not src/.""" + +import argparse +import os +from pathlib import Path +import subprocess +import sys +import venv + + +# test_trusted_vsi_api.py asserts the import resolves inside the venv, so the +# whole session is proven to exercise the installed wheel rather than src/. +INSTALLED_TESTS = ( + "test_trusted_vsi_api.py", + "test_fragmented_files.py", + "test_sign_ladder.py", + "test_native_ownership.py", + "test_native_ownership_opaque.py", +) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--venv", type=Path, required=True) + parser.add_argument("--version", required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + environment = args.venv.resolve() + if environment.exists(): + parser.error("qualification venv must not already exist") + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment) + python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = dict(os.environ) + for name in ("PYTHONPATH", "C2PA_LIBRARY_NAME", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"): + env.pop(name, None) + env.update(C2PA_TRUSTED_VSI_ABI_REQUIRED="1", + C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED="1", + C2PA_REQUIRE_SIGN_LADDER="1", + C2PA_REQUIRE_FRAGMENTED_FILES="1", + C2PA_FUNCTIONAL_EXPECTED_VERSION=args.version, + C2PA_FUNCTIONAL_INSTALLED_ROOT=str(environment)) + subprocess.run([str(python), "-m", "pip", "install", "--no-deps", "--ignore-installed", + str(args.wheel.resolve(strict=True))], cwd=environment, env=env, check=True) + subprocess.run([str(python), "-m", "pytest", "-q", + *(str(root / "tests" / name) for name in INSTALLED_TESTS), "-ra"], + cwd=environment, env=env, check=True, timeout=480) + + +if __name__ == "__main__": + main() diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 20761c19..9e571e9b 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -32,6 +32,14 @@ ContextBuilder, ContextProvider, LiveVideoVsiSession, + TrustedVsiSession, + VsiSigningContextV1, + TrustedVsiOperation, + TrustedVsiInputKind, + validate_trusted_vsi_input, + trusted_vsi_hash_template, + TrustedVsiMediaEmsgReservation, + TrustedVsiStatus, has_dynamic_assertions, has_fragmented_files, has_live_video_vsi, @@ -39,6 +47,12 @@ has_live_video_vsi_explicit_time, has_live_video_vsi_mfhd_probe, has_live_video_vsi_recovery, + has_live_video_trusted_vsi_split_init, + has_live_video_trusted_vsi_expert_sig_structure, + has_live_video_trusted_vsi_composed_emsg, + has_live_video_trusted_vsi_recovery, + has_live_video_trusted_vsi_signing_context_v1, + has_live_video_trusted_vsi_full_uint32_exhaustion, moof_sequence_number, sdk_version, load_settings @@ -60,6 +74,14 @@ 'ContextBuilder', 'ContextProvider', 'LiveVideoVsiSession', + 'TrustedVsiSession', + 'VsiSigningContextV1', + 'TrustedVsiOperation', + 'TrustedVsiInputKind', + 'validate_trusted_vsi_input', + 'trusted_vsi_hash_template', + 'TrustedVsiMediaEmsgReservation', + 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', 'has_live_video_vsi', @@ -67,6 +89,12 @@ 'has_live_video_vsi_explicit_time', 'has_live_video_vsi_mfhd_probe', 'has_live_video_vsi_recovery', + 'has_live_video_trusted_vsi_split_init', + 'has_live_video_trusted_vsi_expert_sig_structure', + 'has_live_video_trusted_vsi_composed_emsg', + 'has_live_video_trusted_vsi_recovery', + 'has_live_video_trusted_vsi_signing_context_v1', + 'has_live_video_trusted_vsi_full_uint32_exhaustion', 'moof_sequence_number', 'sdk_version', 'load_settings' diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index aab837e1..986f6199 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -11,12 +11,13 @@ # specific language governing permissions and limitations under # each license. -# Version: 0.37.8.dev5 +# Version: 0.37.13.dev0 import ctypes import enum import json import logging +import re import sys import os import threading @@ -24,6 +25,8 @@ import weakref from abc import ABC, abstractmethod from collections.abc import Sequence +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass from pathlib import Path from typing import Optional, Union, Callable, Any, overload import io @@ -125,6 +128,61 @@ 'c2pa_live_video_moof_sequence_number', ) +# Optional trusted-processor prehashed VSI API. The native ABI is being +# developed independently, so every symbol remains optional and the Python +# surface fails closed unless the exact contract revision, version, mask and +# complete symbol set match. Only stateless probes are safe before that gate. +_TRUSTED_VSI_CONTRACT_REVISION_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_contract_revision', +) +_TRUSTED_VSI_CAPABILITIES_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_capabilities', +) +_TRUSTED_VSI_CREATE_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_create_callback_v1', +) +_TRUSTED_VSI_SPLIT_INIT_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_reserve_init_uuid', + 'c2pa_live_video_trusted_vsi_session_reserved_manifest_id', + 'c2pa_live_video_trusted_vsi_session_finalize_init_uuid', + 'c2pa_live_video_trusted_vsi_session_commit_init_uuid', +) +_TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_sign_sig_structure', +) +_TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_reserve_media_emsg', + 'c2pa_live_video_trusted_vsi_session_finalize_media_emsg', +) +_TRUSTED_VSI_RECOVERY_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_export_state', + 'c2pa_live_video_trusted_vsi_session_import_state', +) +_TRUSTED_VSI_STATUS_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_status_v1', +) +_TRUSTED_VSI_PREFLIGHT_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_preflight', + 'c2pa_live_video_trusted_vsi_validate_input', + 'c2pa_live_video_trusted_vsi_hash_template', +) +_TRUSTED_VSI_FUNCTIONS = ( + _TRUSTED_VSI_CONTRACT_REVISION_FUNCTIONS + + _TRUSTED_VSI_CAPABILITIES_FUNCTIONS + _TRUSTED_VSI_CREATE_FUNCTIONS + + _TRUSTED_VSI_SPLIT_INIT_FUNCTIONS + _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS + + _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS + _TRUSTED_VSI_RECOVERY_FUNCTIONS + + _TRUSTED_VSI_STATUS_FUNCTIONS + _TRUSTED_VSI_PREFLIGHT_FUNCTIONS +) + +_TRUSTED_VSI_CAP_SPLIT_INIT = 1 << 0 +_TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE = 1 << 1 +_TRUSTED_VSI_CAP_COMPOSED_MEDIA = 1 << 2 +_TRUSTED_VSI_CAP_RECOVERY = 1 << 3 +_TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1 = 1 << 4 +_TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE = 1 << 5 +_TRUSTED_VSI_REQUIRED_CAPABILITIES = 63 +_TRUSTED_VSI_REQUIRED_CONTRACT_REVISION = 3 + # Castlabs dynamic-assertion extension. Keep this optional so the package can # still be imported with standard upstream native libraries. _DYNAMIC_ASSERTION_FUNCTIONS = ( @@ -222,6 +280,42 @@ def _validate_library_exports(lib): _LIVE_VIDEO_VSI_MFHD_PROBE_AVAILABLE = all( hasattr(_lib, name) for name in _LIVE_VIDEO_VSI_MFHD_PROBE_FUNCTIONS ) +_TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_CAPABILITIES_FUNCTIONS +) +_TRUSTED_VSI_CREATE_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_CREATE_FUNCTIONS +) +_TRUSTED_VSI_SPLIT_INIT_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_SPLIT_INIT_FUNCTIONS +) +_TRUSTED_VSI_EXPERT_SIG_STRUCTURE_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS +) +_TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS +) +_TRUSTED_VSI_RECOVERY_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_RECOVERY_FUNCTIONS +) +_TRUSTED_VSI_STATUS_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_STATUS_FUNCTIONS +) +_TRUSTED_VSI_CAPABILITIES = 0 +_TRUSTED_VSI_CONTRACT_REVISION = 0 +_TRUSTED_VSI_ABI_AVAILABLE = all(hasattr(_lib, name) for name in _TRUSTED_VSI_FUNCTIONS) +_TRUSTED_VSI_VERSION_MATCHES = False +# Required SDK release line, not contract identity. The revision probe gates the +# trusted-VSI contract; qualification pins the native commit separately. +_TRUSTED_VSI_NATIVE_VERSION = "0.92.0-dev" + + +def _trusted_vsi_version_matches(native_version: bytes) -> bool: + """Return whether c2pa_version() names the required SDK release line.""" + expected = b"c2pa-rs/" + _TRUSTED_VSI_NATIVE_VERSION.encode("ascii") + return expected in native_version.split() + + _DYNAMIC_ASSERTIONS_AVAILABLE = all( hasattr(_lib, name) for name in _DYNAMIC_ASSERTION_FUNCTIONS ) @@ -310,8 +404,12 @@ class ManagedResource: the handle without returning a replacement: the new owner frees it, so this does not. - Call `_release_handle()` when a consuming FFI call fails with ownership - unknown: it frees eagerly (guarded), then closes. `_consume_and_swap` - uses it on that failure path. + unknown: it closes and requests a guarded free. `_consume_and_swap` + uses it on that failure path; admitted calls defer physical cleanup. + - Use `_native_call()` around borrowed signing FFI calls that must survive + callback-initiated close, also guarding any explicitly borrowed Signer. + This is not generic thread safety: unguarded operations (Reader calls, + with_archive, recover, etc.) and close must be externally serialized. - Override `_release()` to free class-specific resources (streams, caches, callbacks, etc.), called before the native pointer is freed. @@ -334,6 +432,9 @@ def _init_attrs(self): def __init__(self): self._lifecycle_state = LifecycleState.UNINITIALIZED self._handle = None + self._call_lock = threading.RLock() + self._active_calls = 0 + self._pending_teardown = None record_owner_pid(self) @staticmethod @@ -389,7 +490,7 @@ def _safe_release(self): ) def _teardown(self, free_handle: bool): - """Close the object: run _release, optionally free the handle, null it. + """Close logically; release/null/free after admitted calls drain, if any. free_handle=False (consumed) frees nothing, the new owner needs to free. """ if is_foreign_process(self): @@ -397,10 +498,28 @@ def _teardown(self, free_handle: bool): self._lifecycle_state = LifecycleState.CLOSED return - self._lifecycle_state = LifecycleState.CLOSED - self._safe_release() + with getattr(self, '_call_lock', nullcontext()): + if self._lifecycle_state == LifecycleState.CLOSED: + return + self._lifecycle_state = LifecycleState.CLOSED + if getattr(self, '_active_calls', 0): + self._pending_teardown = free_handle + return + handle = self._handle + self._finish_teardown(handle, free_handle) - handle, self._handle = self._handle, None + def _finish_teardown(self, handle, free_handle): + if is_foreign_process(self): + self._handle = None + return + # _release clears these attributes/lists. Retain the actual ctypes + # thunks, not just their error states, until native destruction returns. + callback_pins = [getattr(self, name, None) for name in ( + '_callback_cb', '_signer_callback_cb', '_vsi_callback', + '_trusted_vsi_callback')] + callback_pins.extend(list(getattr(self, '_dynamic_assertion_cbs', ()))) + self._safe_release() + self._handle = None if free_handle and handle: try: ManagedResource._free_native_ptr(handle) @@ -408,13 +527,47 @@ def _teardown(self, free_handle: bool): logger.error("Failed to free native %s resources", type(self).__name__, exc_info=True) + @contextmanager + def _native_call(self): + """Admit a borrowed native call; close is logical, not cancellation. + + No lock is held across native code or user callbacks. This guards the + selected signing paths, not all resource methods or concurrent native + operations. Unguarded calls and close require external serialization. + Foreign-PID resources are rejected before touching an inherited lock; + create worker-owned signers/sessions in the worker. This admission rule + is deliberately limited to guarded calls, not an SDK-wide fork ban. + """ + if is_foreign_process(self): + raise C2paError("Native resources cannot be used after fork") + with self._call_lock: + self._ensure_valid_state() + self._active_calls += 1 + try: + yield + finally: + teardown = None + if is_foreign_process(self): + self._cleanup_resources() + else: + with self._call_lock: + self._active_calls -= 1 + if not self._active_calls and self._pending_teardown is not None: + teardown = (self._handle, self._pending_teardown) + self._pending_teardown = None + if teardown is not None: + self._finish_teardown(*teardown) + def _release_handle(self): - """Free this handle, then close the object. Used only where ownership is - unknown (a guarded free is a real free if ours, a no-op if not). + """Request guarded free/close when ownership is unknown. + + Admitted calls defer physical cleanup. Already-CLOSED resources retain + their pending cleanup decision, handle and pins until the guard drains. """ if self._lifecycle_state != LifecycleState.ACTIVE: - self._handle = None - self._lifecycle_state = LifecycleState.CLOSED + if self._lifecycle_state != LifecycleState.CLOSED: + self._handle = None + self._lifecycle_state = LifecycleState.CLOSED return self._teardown(free_handle=True) @@ -491,9 +644,11 @@ def _swap_handle(self, new_handle): self._handle = new_handle # Errors set by native lib, hinting at the cause of the error - # These errors here means the pointer got somehow rejected by the lib, - # so it is still ours to deal with. + # The rejected handle may be this resource or another argument. _PRE_CONSUME_ERROR_TAGS = ("UntrackedPointer:", "WrongPointerType:") + _ADDRESSLESS_REJECTION_TAGS = ("PointerInUse:", "WrongWrapperKind:") + _REJECTED_HANDLE_RE = re.compile( + r"(?:UntrackedPointer|WrongPointerType):\s*(0x[0-9a-fA-F]+)\b") def _invoke_consume(self, ffi_call, error_message): """Run an FFI call that consumes this handle, returning its raw result. @@ -524,11 +679,24 @@ def _invoke_consume(self, ffi_call, error_message): self._release_handle() raise C2paError(error_message.format(e)) from e - def _raise_consume_failure(self, error_message): + def _handle_value(self): + """Read the handle value without dereferencing native memory.""" + try: + handle = self._handle + if not handle: + return None + if isinstance(handle, int): + return handle + return ctypes.c_void_p.from_buffer(handle).value + except Exception: + # An unfamiliar representation cannot establish ownership. + return None + + def _raise_consume_failure(self, error_message, *, consumes_first=False): """Raise the error from an FFI handler consuming call. - The native error is read before any free so a free's own - pointer-tracking error cannot overwrite it: the native error slot is + The native error is copied before any free so the raised exception + preserves it even if cleanup changes the native error slot. The slot is sticky and thread-local and the SDK does not clear it before the call, so this trusts that the failing native path set its own error. @@ -539,28 +707,56 @@ def _raise_consume_failure(self, error_message): with another one and, because that substitute carries a pre-consume tag, invert the retain/consume decision made below. + Validate-first calls retain the resource on any registry rejection. + Consume-first calls retain it only when the rejected address matches + its handle. A different known address means it was consumed; an absent + address or unreadable handle value needs a guarded free and close. + Address-less registry rejections are safe to clean up with the newer + opaque registry and are not emitted by stock 0.91.0. Release removes + the registry entry; outstanding guards can defer the actual drop. + Args: error_message: Format string with one placeholder, used when the native layer offers no error of its own. + consumes_first: Whether native takes this handle before validating + its other arguments. Raises: C2paError: Always; typed by the native error when there is one. """ error = _read_native_error() if error: - if any(tag in error - for tag in ManagedResource._PRE_CONSUME_ERROR_TAGS): + # Stock wraps registry errors in Other; tags quoted inside another + # error's payload are not evidence of handle rejection. + rejection = error.removeprefix("Other: ") + rejected = rejection.startswith( + self._PRE_CONSUME_ERROR_TAGS + self._ADDRESSLESS_REJECTION_TAGS) + if rejected and not consumes_first: logger.warning( - "%s: native call rejected the handle before taking " + "%s: native call rejected an argument before taking " "ownership (%s); handle retained", type(self).__name__, error) _raise_typed_c2pa_error(error) + if rejected: + match = self._REJECTED_HANDLE_RE.match(rejection) + managed = self._handle_value() + if match and managed is not None: + if int(match.group(1), 16) == managed: + logger.warning( + "%s: native call rejected the managed handle " + "(%s); handle retained", type(self).__name__, error) + _raise_typed_c2pa_error(error) + self._teardown(free_handle=False) + else: + self._release_handle() + _raise_typed_c2pa_error(error) + # A non-tag error means the native side took ownership then failed, - # dropping the value itself: mark consumed, do not free (a free here - # would be a guarded no-op that dirties the error slot and races a - # recycled address in other threads). + # dropping the value itself: mark consumed, do not free. An extra + # free can dirty the error slot and, on stock native, race a reused + # address in another thread. self._teardown(free_handle=False) _raise_typed_c2pa_error(error) @@ -568,7 +764,7 @@ def _raise_consume_failure(self, error_message): self._release_handle() raise C2paError(error_message.format("Unknown error")) - def _consume_and_swap(self, ffi_call, error_message): + def _consume_and_swap(self, ffi_call, error_message, *, consumes_first=False): """Run an FFI call that consumes this handle and returns a replacement. On success the native lib consumed the handle and returned a new one, which we swap in. A null return is a failure. @@ -577,9 +773,10 @@ def _consume_and_swap(self, ffi_call, error_message): if new_ptr: self._swap_handle(new_ptr) return - self._raise_consume_failure(error_message) + self._raise_consume_failure(error_message, consumes_first=consumes_first) - def _consume_no_replacement(self, ffi_call, error_message): + def _consume_no_replacement(self, ffi_call, error_message, *, + consumes_first=False): """Run an FFI call that consumes this handle on success, when the native call returns a status code (0 = success) rather than a replacement handle. A non-zero status is a failure routed to @@ -589,9 +786,9 @@ def _consume_no_replacement(self, ffi_call, error_message): if result == 0: self._teardown(free_handle=False) return - self._raise_consume_failure(error_message) + self._raise_consume_failure(error_message, consumes_first=consumes_first) - def _consume_into(self, ffi_call, error_message): + def _consume_into(self, ffi_call, error_message, *, consumes_first=False): """Run an FFI call that consumes this handle and returns a *different* object's pointer. On success this handle is consumed (mark, don't free) and the new pointer is returned for the caller to own. A null return is @@ -601,7 +798,7 @@ def _consume_into(self, ffi_call, error_message): if result: self._teardown(free_handle=False) return result - self._raise_consume_failure(error_message) + self._raise_consume_failure(error_message, consumes_first=consumes_first) @classmethod def _wrap_native_handle(cls, handle): @@ -659,7 +856,7 @@ def is_valid(self) -> bool: ) def close(self) -> None: - """Release the resource (idempotent, never raises).""" + """Close logically; admitted calls defer physical release (idempotent).""" self._cleanup_resources() def __enter__(self): @@ -733,6 +930,46 @@ def __del__(self): ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, ) +class C2paLiveVideoTrustedVsiSigningContextV1(ctypes.Structure): + """Version-one native trusted-VSI callback context.""" + + _fields_ = [ + ("purpose", ctypes.c_uint32), + ("sequence_number", ctypes.c_uint32), + ("has_sequence_number", ctypes.c_bool), + ("event_id", ctypes.c_uint32), + ("has_event_id", ctypes.c_bool), + ("exhaust_after_sign", ctypes.c_bool), + ] + + +class C2paLiveVideoTrustedVsiStatusV1(ctypes.Structure): + """Native public status for a trusted prehashed VSI session.""" + + _fields_ = [ + ("init_uuid_committed", ctypes.c_bool), + ("init_uuid_pending", ctypes.c_bool), + ("media_emsg_pending", ctypes.c_bool), + ("has_next_sequence_number", ctypes.c_bool), + ("next_sequence_number", ctypes.c_uint32), + ("has_next_event_id", ctypes.c_bool), + ("next_event_id", ctypes.c_uint32), + ("exhausted", ctypes.c_bool), + ("has_exhaustion_reason", ctypes.c_bool), + ("blocked", ctypes.c_bool), + ("exhaustion_reason", ctypes.c_uint32), + ] + + +TrustedVsiSignCallbackV1 = ctypes.CFUNCTYPE( + ctypes.c_ssize_t, + ctypes.c_void_p, + ctypes.POINTER(C2paLiveVideoTrustedVsiSigningContextV1), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, +) class StreamContext(ctypes.Structure): @@ -746,40 +983,13 @@ class C2paSigner(ctypes.Structure): class C2paStream(ctypes.Structure): - """A C2paStream is a Rust Read/Write/Seek stream that can be created in C. - - This class represents a low-level stream interface that bridges Python - and Rust/C code. It implements the Rust Read/Write/Seek traits in C, - allowing for efficient data transfer between Python and the C2PA library - without unnecessary copying. - - The stream is used for various operations including: - - Reading manifest data from files - - Writing signed content to files - - Handling binary resources - - Managing ingredient data - - The structure contains function pointers that implement stream operations: - - reader: Function to read data from the stream - - seeker: Function to change the stream position - - writer: Function to write data to the stream - - flusher: Function to flush any buffered data - - This is a critical component for performance as it allows direct memory - access between Python and the C2PA library without intermediate copies. + """Opaque native stream handle. + + Python only passes this pointer back to native and never reads its fields. + This works with both stock 0.91.0's C-layout stream and newer opaque handles. + Stream retains the callbacks separately for their required lifetime. """ - _fields_ = [ - # Opaque context pointer for the stream - ("context", ctypes.POINTER(StreamContext)), - # Function to read data from the stream - ("reader", ReadCallback), - # Function to change stream position - ("seeker", SeekCallback), - # Function to write data to the stream - ("writer", WriteCallback), - # Function to flush buffered data - ("flusher", FlushCallback), - ] + _fields_ = [] def _read_native_error() -> Optional[str]: @@ -899,6 +1109,11 @@ class C2paLiveVideoVsiSigner(ctypes.Structure): """Opaque structure for a live-video VSI signing session.""" _fields_ = [] # Empty as it's opaque in the C API + +class C2paLiveVideoTrustedVsiSession(ctypes.Structure): + """Opaque structure for a trusted prehashed live-video VSI session.""" + _fields_ = [] # Empty as it's opaque in the C API + # Helper function to set function prototypes @@ -1271,6 +1486,167 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_int ) +# Bind/call only safe, stateless probes before admitting the operational ABI. +# Older contracts reused symbols and SDK versions with incompatible semantics. +if hasattr(_lib, 'c2pa_live_video_trusted_vsi_contract_revision'): + _setup_function( + _lib.c2pa_live_video_trusted_vsi_contract_revision, + [], + ctypes.c_uint32, + ) + _TRUSTED_VSI_CONTRACT_REVISION = int( + _lib.c2pa_live_video_trusted_vsi_contract_revision()) +if _TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_capabilities, + [], + ctypes.c_uint64, + ) +if (_TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_CONTRACT_REVISION == _TRUSTED_VSI_REQUIRED_CONTRACT_REVISION): + native_version_ptr = _lib.c2pa_version() + if native_version_ptr: + try: + _TRUSTED_VSI_VERSION_MATCHES = _trusted_vsi_version_matches( + ctypes.string_at(native_version_ptr)) + finally: + _lib.c2pa_string_free(native_version_ptr) + if _TRUSTED_VSI_VERSION_MATCHES: + _TRUSTED_VSI_CAPABILITIES = int(_lib.c2pa_live_video_trusted_vsi_capabilities()) +_TRUSTED_VSI_ABI_AVAILABLE = ( + _TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_CONTRACT_REVISION == _TRUSTED_VSI_REQUIRED_CONTRACT_REVISION + and _TRUSTED_VSI_VERSION_MATCHES + and _TRUSTED_VSI_CAPABILITIES == _TRUSTED_VSI_REQUIRED_CAPABILITIES +) +if _TRUSTED_VSI_ABI_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1, + [ctypes.POINTER(C2paContext), + ctypes.c_char_p, + ctypes.c_int, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.c_uint64, + ctypes.c_char_p, + ctypes.c_uint64, + ctypes.c_char_p, + ctypes.c_void_p, + TrustedVsiSignCallbackV1], + ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ) +if _TRUSTED_VSI_ABI_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_reserve_init_uuid, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.c_char_p, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_reserved_manifest_id, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession)], + ctypes.c_void_p, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_finalize_init_uuid, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_commit_init_uuid, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession)], + ctypes.c_int, + ) +if _TRUSTED_VSI_ABI_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.c_uint32, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) +if _TRUSTED_VSI_ABI_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.c_uint32, + ctypes.c_int64, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ctypes.POINTER(C2paLiveVideoTrustedVsiSigningContextV1)], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_finalize_media_emsg, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) +if _TRUSTED_VSI_ABI_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_export_state, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_import_state, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t], + ctypes.c_int, + ) +if _TRUSTED_VSI_ABI_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_status_v1, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(C2paLiveVideoTrustedVsiStatusV1)], + ctypes.c_int, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_preflight, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), ctypes.c_uint32, + ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, ctypes.c_uint32, + ctypes.c_int64, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_char_p], + ctypes.c_int, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_validate_input, + [ctypes.c_uint32, ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t], + ctypes.c_int, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_hash_template, + [ctypes.c_uint32, ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + + +# Optional capability: older native libraries still support ordinary signing. +_HAS_SIGN_LADDER = hasattr(_lib, "c2pa_builder_sign_ladder") +if _HAS_SIGN_LADDER: + _setup_function( + _lib.c2pa_builder_sign_ladder, + [ctypes.POINTER(C2paBuilder), + ctypes.POINTER(C2paSigner), + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_char_p), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 + ) + class C2paError(Exception): """Exception raised for C2PA errors. @@ -1535,6 +1911,35 @@ def _raise_typed_c2pa_error(error_str: str) -> None: raise C2paError(error_str) +def _claim_signer_error_state(callback_cb): + """Return the thread-local error slot pinned on a claim-signer callback.""" + return getattr(callback_cb, '_error_state', None) + + +def _reraise_callback_errors(states, interrupt_states=()): + """Re-raise the first stored callback exception with its identity intact. + + ``states`` re-raise any stored exception. ``interrupt_states`` (claim signers + on pre-existing Builder/complete-buffer paths, whose ordinary exceptions stay + reported as C2paError) only re-raise non-``Exception`` BaseExceptions such as + KeyboardInterrupt, SystemExit and asyncio.CancelledError. + """ + for state in states: + error = getattr(state, 'exception', None) + if error is not None: + raise error + for state in interrupt_states: + error = getattr(state, 'exception', None) + if error is not None and not isinstance(error, Exception): + raise error + + +def _clear_callback_errors(states): + for state in states: + if state is not None: + state.exception = None + + def _check_ffi_operation_result( result, fallback_msg, @@ -1806,8 +2211,13 @@ def update( self, data: Union[str, dict], ) -> 'Settings': """Update current configuration from a JSON string or dict. - If the updated string overwrite an existing settings value, - the last setting value set for that property wins. + + Scalar properties use the last value set. With native SDK 0.91, + trust.anchors entries are merged and deduplicated, not replaced: + an empty list does not remove existing anchors. Use purpose-tagged + entries (trust_kind: manifest, cawg, or tsa). To remove or replace + trust, create fresh Settings and a new Context with the complete + intended configuration; existing contexts keep their configuration. Args: data: A JSON string or dict with configuration to merge. @@ -1955,7 +2365,8 @@ def __init__( context_ptr = nb._consume_into( lambda h: _lib.c2pa_context_builder_build(h), - "Failed to build Context: {}") + "Failed to build Context: {}", + consumes_first=True) self._activate(context_ptr) @@ -2025,6 +2436,109 @@ def execution_context(self): return self._handle +@dataclass(frozen=True) +class VsiSigningContextV1: + """Purpose-bound context supplied to a trusted VSI signing callback.""" + + purpose: str + sequence_number: Optional[int] = None + event_id: Optional[int] = None + exhaust_after_sign: bool = False + + +@dataclass(frozen=True) +class TrustedVsiMediaEmsgReservation: + """Complete placeholder EMSG box and its pinned media facts. + + ``signing_context`` is the V1 context the finalize callback will receive + (supplied sequence, allocated event ID, terminal ``exhaust_after_sign``). + """ + + placeholder_emsg_box: bytes + signing_context: VsiSigningContextV1 + signing_time_unix_seconds: int + timescale: int + event_duration: int + + @property + def sequence_number(self) -> int: + return self.signing_context.sequence_number + + @property + def event_id(self) -> int: + return self.signing_context.event_id + + +@dataclass(frozen=True) +class TrustedVsiStatus: + """Public state snapshot for a trusted prehashed VSI session.""" + + init_uuid_committed: bool + init_uuid_pending: bool + media_emsg_pending: bool + next_sequence_number: Optional[int] + next_event_id: Optional[int] + exhausted: bool + exhaustion_reason: Optional[str] = None + blocked: bool = False + + +def _has_trusted_vsi_capability(bit: int, symbols_available: bool = True) -> bool: + return ( + _TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_CONTRACT_REVISION == _TRUSTED_VSI_REQUIRED_CONTRACT_REVISION + and _TRUSTED_VSI_VERSION_MATCHES + and _TRUSTED_VSI_CAPABILITIES == _TRUSTED_VSI_REQUIRED_CAPABILITIES + and symbols_available + and bool(_TRUSTED_VSI_CAPABILITIES & bit) + ) + + +def has_live_video_trusted_vsi_split_init() -> bool: + """Return whether split-init complete-UUID operations are available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_SPLIT_INIT, + _TRUSTED_VSI_SPLIT_INIT_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_expert_sig_structure() -> bool: + """Return whether exact expert COSE Sig_structure signing is available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE, + _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_composed_emsg() -> bool: + """Return whether signer-composed complete-EMSG operations are available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_COMPOSED_MEDIA, + _TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_recovery() -> bool: + """Return whether trusted prehashed VSI recovery is available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_RECOVERY, + _TRUSTED_VSI_RECOVERY_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_signing_context_v1() -> bool: + """Return whether version-1 purpose-bound callback contexts are available.""" + return _has_trusted_vsi_capability(_TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1) + + +def has_live_video_trusted_vsi_full_uint32_exhaustion() -> bool: + """Return whether composed VSI exhausts safely at uint32 max. + + Expert mode has no sequence counter or exhaustion state. + """ + return _has_trusted_vsi_capability(_TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE) + + def has_live_video_vsi() -> bool: """Return whether the loaded native library provides live-video VSI.""" return _LIVE_VIDEO_VSI_AVAILABLE @@ -2094,6 +2608,327 @@ def has_fragmented_files() -> bool: ) +class TrustedVsiOperation(enum.IntEnum): + RESERVE_INIT = 0 + FINALIZE_INIT = 1 + COMMIT_INIT = 2 + EXPERT_SIGN = 3 + RESERVE_MEDIA = 4 + FINALIZE_MEDIA = 5 + + +class TrustedVsiInputKind(enum.IntEnum): + INIT_HASH = 0 + SIG_STRUCTURE = 1 + MEDIA_HASH = 2 + + +def _require_trusted_vsi(): + if not has_live_video_trusted_vsi_signing_context_v1(): + raise C2paError.NotSupported( + f"Functional trusted VSI requires the complete {_TRUSTED_VSI_NATIVE_VERSION} native ABI " + "with contract revision 3 and capability mask 63; the loaded library is unavailable") + + +def _trusted_vsi_integer(value, name, maximum=2**32 - 1, minimum=0): + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}") + return value + + +def _trusted_vsi_algorithm(algorithm): + if isinstance(algorithm, str): + try: + algorithm = {"es256": C2paSigningAlg.ES256, + "ed25519": C2paSigningAlg.ED25519, + "eddsa": C2paSigningAlg.ED25519}[algorithm.lower().replace("-", "")] + except KeyError as error: + raise ValueError("algorithm must be ES256 or Ed25519") from error + if not isinstance(algorithm, C2paSigningAlg): + raise TypeError("algorithm must be a C2paSigningAlg or str") + if algorithm not in (C2paSigningAlg.ES256, C2paSigningAlg.ED25519): + raise ValueError("algorithm must be ES256 or Ed25519") + return algorithm + + +def _trusted_vsi_output(call): + output = ctypes.POINTER(ctypes.c_ubyte)() + try: + length = call(ctypes.byref(output)) + _check_ffi_operation_result(length, "Trusted VSI operation failed", check=lambda r: r < 0) + if not output: + if length == 0: + return b"" + raise C2paError("Trusted VSI native output pointer is null") + return ctypes.string_at(output, length) + finally: + if output: + ManagedResource._free_native_ptr(output) + + +def validate_trusted_vsi_input(kind: TrustedVsiInputKind, + algorithm: Union[C2paSigningAlg, str], data: bytes) -> None: + """Native canonical-input validation without a session or signing callback.""" + _require_trusted_vsi() + kind = TrustedVsiInputKind(_trusted_vsi_integer(kind, "kind", 2)) + algorithm = _trusted_vsi_algorithm(algorithm) + array = LiveVideoVsiSession._segment_array(data, "data") + _check_ffi_operation_result( + _lib.c2pa_live_video_trusted_vsi_validate_input(kind, algorithm, array, len(data)), + "Invalid trusted VSI input", check=lambda r: r != 0) + + +def trusted_vsi_hash_template(kind: TrustedVsiInputKind) -> bytes: + """Return the native canonical zero-digest init/media BMFF hash template.""" + _require_trusted_vsi() + kind = TrustedVsiInputKind(_trusted_vsi_integer(kind, "kind", 2)) + return _trusted_vsi_output(lambda output: _lib.c2pa_live_video_trusted_vsi_hash_template(kind, output)) + + +class TrustedVsiSession(ManagedResource): + """Mode-pinned trusted-processor session; externally serialize its calls. + + The caller owns Context. Native retains it and Python separately pins all + callbacks so closing the caller's Context cannot invalidate the session. + Import requires a new session with the same public configuration/options. + """ + + def __init__(self, context: 'Context', manifest_json: Union[str, dict], + algorithm: Union[C2paSigningAlg, str], public_cose_key: bytes, + kid: bytes, min_sequence_number: int, created_at: str, + validity_period_secs: int, + callback: Callable[[VsiSigningContextV1, bytes], bytes], *, + mode: str, reservation_nonce: str, + signing_time_unix_seconds: int, sequence_max: Optional[int] = None): + _require_trusted_vsi() + super().__init__() + self._init_attrs() + if not isinstance(manifest_json, (str, dict)): + raise TypeError("manifest_json must be a str or dict") + manifest_bytes = _to_utf8_bytes(manifest_json, "manifest_json") + if not manifest_bytes or b'\0' in manifest_bytes: + raise ValueError("manifest_json must be nonempty and contain no NUL") + if not isinstance(context, Context): + raise TypeError("context must be a Context") + context._ensure_valid_state() + if not context.has_signer: + raise C2paError("TrustedVsiSession requires a Context with an explicit signer") + if not callable(callback): + raise TypeError("callback must be callable") + algorithm = _trusted_vsi_algorithm(algorithm) + public_key_array = LiveVideoVsiSession._segment_array(public_cose_key, "public_cose_key") + kid_array = LiveVideoVsiSession._segment_array(kid, "kid") + _trusted_vsi_integer(min_sequence_number, "min_sequence_number") + _trusted_vsi_integer(validity_period_secs, "validity_period_secs", 2**64 - 1, 1) + created_at_bytes = self._text(created_at, "created_at") + if not isinstance(mode, str): + raise TypeError("mode must be a str") + if mode not in ("expert_sig_structure", "signer_composed_emsg"): + raise ValueError("mode must be expert_sig_structure or signer_composed_emsg") + if not isinstance(reservation_nonce, str): + raise TypeError("reservation_nonce must be a str") + if len(reservation_nonce) != 32 or any(c not in "0123456789abcdef" for c in reservation_nonce): + raise ValueError("reservation_nonce must contain 32 lowercase hex characters") + _trusted_vsi_integer(signing_time_unix_seconds, "signing_time_unix_seconds", 2**63 - 1, -(2**63)) + if sequence_max is not None: + _trusted_vsi_integer(sequence_max, "sequence_max", minimum=min_sequence_number) + options = json.dumps(dict(mode=mode, reservation_nonce=reservation_nonce, + signing_time_unix_seconds=signing_time_unix_seconds, + sequence_max=sequence_max)).encode() + error_state = threading.local() + error_state.exception = None + + def wrapped_callback(user_data, native_context, tbs, tbs_len, signature, capacity): + error_state.exception = None + try: + if not native_context or not tbs or not tbs_len or not signature or capacity < 64: + raise C2paError("Invalid trusted VSI callback buffers") + signing_context = TrustedVsiSession._signing_context(native_context.contents) + result = callback(signing_context, ctypes.string_at(tbs, tbs_len)) + if not isinstance(result, bytes): + raise TypeError("Trusted VSI callback must return bytes") + if len(result) != 64: + raise ValueError("Trusted VSI callback must return exactly 64 signature bytes") + ctypes.memmove(signature, result, 64) + return 64 + except BaseException as error: + error_state.exception = error + return -1 + + callback_cb = TrustedVsiSignCallbackV1(wrapped_callback) + self._context = context + self._signer_callback_cb = context._signer_callback_cb + self._dynamic_assertion_cbs = list(context._dynamic_assertion_cbs) + self._trusted_vsi_callback = (callback_cb, error_state, callback) + self._create_and_activate( + lambda: _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1( + context.execution_context, manifest_bytes, algorithm, public_key_array, + len(public_cose_key), kid_array, len(kid), min_sequence_number, + created_at_bytes, validity_period_secs, options, None, callback_cb), + "Failed to create trusted VSI session") + + @classmethod + def from_callback(cls, context, manifest_json, algorithm, public_cose_key, kid, + min_sequence_number, created_at, validity_period_secs, callback, *, + mode, reservation_nonce, signing_time_unix_seconds, sequence_max=None): + """Create a session with the same signature and ownership as the constructor.""" + _require_trusted_vsi() + return cls(context, manifest_json, algorithm, public_cose_key, kid, + min_sequence_number, created_at, validity_period_secs, callback, mode=mode, + reservation_nonce=reservation_nonce, signing_time_unix_seconds=signing_time_unix_seconds, + sequence_max=sequence_max) + + def _init_attrs(self): + super()._init_attrs() + self._context = None + self._signer_callback_cb = None + self._dynamic_assertion_cbs = [] + self._trusted_vsi_callback = None + + def _release(self): + self._context = None + self._signer_callback_cb = None + self._dynamic_assertion_cbs.clear() + self._trusted_vsi_callback = None + + @staticmethod + def _text(value, name): + if not isinstance(value, str): + raise TypeError(f"{name} must be a str") + if not value or '\0' in value: + raise ValueError(f"{name} must be nonempty and contain no NUL") + return _to_utf8_bytes(value, name) + + @staticmethod + def _signing_context(native): + if native.purpose == 0: + if native.has_sequence_number or native.has_event_id or native.exhaust_after_sign: + raise C2paError("Invalid signer_binding context") + return VsiSigningContextV1("signer_binding") + if native.purpose != 1 or not native.has_sequence_number: + raise C2paError("Invalid trusted VSI signing context") + return VsiSigningContextV1("vsi", native.sequence_number, + native.event_id if native.has_event_id else None, + native.exhaust_after_sign) + + def _call(self, function, *args): + states = [state for _, state, _ in self._dynamic_assertion_cbs] + if self._trusted_vsi_callback is not None: + states.append(self._trusted_vsi_callback[1]) + claim_state = _claim_signer_error_state(self._signer_callback_cb) + if claim_state is not None: + states.append(claim_state) + _clear_callback_errors(states) + with self._native_call(): + result = function(self._handle, *args) + if result < 0: + _reraise_callback_errors(states) + _check_ffi_operation_result(result, "Trusted VSI operation failed", check=lambda r: r < 0) + return result + + def reserve_init_uuid(self, format: str = "video/mp4") -> bytes: + """Return the complete placeholder UUID box; repeat calls return the same + frozen reservation. No signing or DynamicAssertion content callbacks run.""" + self._ensure_valid_state() + format_bytes = self._text(format, "format") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_reserve_init_uuid, format_bytes, output)) + + def reserved_manifest_id(self) -> str: + self._ensure_valid_state() + with self._native_call(): + pointer = _lib.c2pa_live_video_trusted_vsi_session_reserved_manifest_id(self._handle) + _check_ffi_operation_result(pointer, "No reserved trusted VSI manifest ID") + try: + return ctypes.string_at(pointer).decode('utf-8') + finally: + _lib.c2pa_string_free(pointer) + + def finalize_init_uuid(self, canonical_hash: bytes) -> bytes: + self._ensure_valid_state() + array = LiveVideoVsiSession._segment_array(canonical_hash, "canonical_hash") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_finalize_init_uuid, array, len(canonical_hash), output)) + + def commit_init_uuid(self) -> None: + """Activate durable coordinator init state, not a public publication ACK.""" + self._ensure_valid_state() + self._call(_lib.c2pa_live_video_trusted_vsi_session_commit_init_uuid) + + def sign_sig_structure(self, sig_structure: bytes, sequence_number: int) -> bytes: + """Sign original expert bytes with supplied sequence metadata, never a counter.""" + self._ensure_valid_state() + _trusted_vsi_integer(sequence_number, "sequence_number") + array = LiveVideoVsiSession._segment_array(sig_structure, "sig_structure") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure, + array, len(sig_structure), sequence_number, output)) + + def reserve_media_emsg_at(self, sequence_number: int, signing_time_unix_seconds: int, + timescale: int, event_duration: int) -> TrustedVsiMediaEmsgReservation: + self._ensure_valid_state() + _trusted_vsi_integer(sequence_number, "sequence_number") + _trusted_vsi_integer(signing_time_unix_seconds, "signing_time_unix_seconds", 2**63 - 1, -(2**63)) + _trusted_vsi_integer(timescale, "timescale", minimum=1) + _trusted_vsi_integer(event_duration, "event_duration", minimum=1) + context = C2paLiveVideoTrustedVsiSigningContextV1() + data = _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg, sequence_number, + signing_time_unix_seconds, timescale, event_duration, output, ctypes.byref(context))) + return TrustedVsiMediaEmsgReservation(data, self._signing_context(context), + signing_time_unix_seconds, timescale, event_duration) + + def finalize_media_emsg(self, canonical_hash: bytes) -> bytes: + self._ensure_valid_state() + array = LiveVideoVsiSession._segment_array(canonical_hash, "canonical_hash") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_finalize_media_emsg, array, len(canonical_hash), output)) + + def export_state(self) -> bytes: + """Export exact versioned public state, including pending reservations.""" + self._ensure_valid_state() + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_export_state, output)) + + def import_state(self, state: bytes) -> None: + """Import into a new session with matching config, options, and claim signer.""" + self._ensure_valid_state() + array = LiveVideoVsiSession._segment_array(state, "state") + self._call(_lib.c2pa_live_video_trusted_vsi_session_import_state, array, len(state)) + + def preflight(self, operation: TrustedVsiOperation, data: bytes = b"", *, + sequence_number: int = 0, iat: int = 0, timescale: int = 0, + event_duration: int = 0, format: str = "video/mp4") -> None: + """Validate state and input without mutation, reservation, or callbacks.""" + self._ensure_valid_state() + operation = TrustedVsiOperation(_trusted_vsi_integer(operation, "operation", 5)) + if not isinstance(data, bytes): + raise TypeError("data must be bytes") + array = LiveVideoVsiSession._segment_array(data, "data") if data else None + _trusted_vsi_integer(sequence_number, "sequence_number") + _trusted_vsi_integer(iat, "iat", 2**63 - 1, -(2**63)) + _trusted_vsi_integer(timescale, "timescale") + _trusted_vsi_integer(event_duration, "event_duration") + self._call(_lib.c2pa_live_video_trusted_vsi_session_preflight, operation, + array, len(data), sequence_number, iat, timescale, event_duration, + self._text(format, "format")) + + def status(self) -> TrustedVsiStatus: + self._ensure_valid_state() + native = C2paLiveVideoTrustedVsiStatusV1() + self._call(_lib.c2pa_live_video_trusted_vsi_session_status_v1, ctypes.byref(native)) + reasons = {1: "sequence_max", 2: "event_id_max", 3: "legacy_sentinel"} + if native.has_exhaustion_reason and native.exhaustion_reason not in reasons: + raise C2paError("Unknown trusted VSI exhaustion reason") + return TrustedVsiStatus(native.init_uuid_committed, native.init_uuid_pending, + native.media_emsg_pending, native.next_sequence_number if native.has_next_sequence_number else None, + native.next_event_id if native.has_next_event_id else None, native.exhausted, + reasons[native.exhaustion_reason] if native.has_exhaustion_reason else None, + blocked=native.blocked) + + class LiveVideoVsiSession(ManagedResource): """Stateful C2PA 2.4 Verifiable Segment Info signing session. @@ -2353,7 +3188,7 @@ def wrapped_callback( "VSI callback must return exactly 64 signature bytes") ctypes.memmove(signature, result, len(result)) return len(result) - except Exception as error: + except BaseException as error: error_state.exception = error logger.error( "Error in live-video VSI %s callback: %s", @@ -2432,22 +3267,17 @@ def _segment_array(segment: bytes, name: str): return (ctypes.c_ubyte * len(segment)).from_buffer_copy(segment) def _copy_signed_output(self, ffi_call, error_message: str) -> bytes: + states = [state for _, state, _ in self._dynamic_assertion_cbs] if self._vsi_callback is not None: - self._vsi_callback[1].exception = None - for _, error_state, _ in self._dynamic_assertion_cbs: - error_state.exception = None + states.insert(0, self._vsi_callback[1]) + claim_state = _claim_signer_error_state(self._signer_callback_cb) + _clear_callback_errors(states + [claim_state]) output = ctypes.POINTER(ctypes.c_ubyte)() - length = ffi_call(ctypes.byref(output)) + with self._native_call(): + length = ffi_call(ctypes.byref(output)) if length < 0: - if self._vsi_callback is not None: - callback_error = getattr( - self._vsi_callback[1], 'exception', None) - if callback_error is not None: - raise callback_error - for _, error_state, _ in self._dynamic_assertion_cbs: - callback_error = getattr(error_state, 'exception', None) - if callback_error is not None: - raise callback_error + _reraise_callback_errors( + states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( length, error_message, check=lambda result: result < 0) @@ -3593,7 +4423,8 @@ def _init_from_context(self, context, format_or_path, len(manifest_data), ) ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], + consumes_first=True) else: # Consume reader with stream self._consume_and_swap( @@ -3601,7 +4432,8 @@ def _init_from_context(self, context, format_or_path, handle, format_arg, self._own_stream._stream, ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], + consumes_first=True) except Exception: self._close_streams() raise @@ -3714,7 +4546,8 @@ def with_fragment(self, format: Optional[str], stream, main_obj._stream, frag_obj._stream, ), - Reader._ERROR_MESSAGES['fragment_error']) + Reader._ERROR_MESSAGES['fragment_error'], + consumes_first=True) # Invalidate caches: processing a new BMFF fragment updates the native # reader's state, which can change the manifest data it returns. @@ -4038,6 +4871,11 @@ def from_callback( ) ) + # Retained on the ctypes callback itself so Context consumption and + # session borrowing preserve the original Python exception state. + callback_error_state = threading.local() + callback_error_state.exception = None + # Create a wrapper callback that handles errors and memory management def wrapped_callback( context, @@ -4045,6 +4883,7 @@ def wrapped_callback( data_len, signed_bytes_ptr, signed_len): + callback_error_state.exception = None # Returns -1 on error as it is what the native code expects. # The reason is that otherwise we ping-pong errors # between native code and Python code, @@ -4089,7 +4928,11 @@ def wrapped_callback( # Native code expects the signed len to be returned, we oblige return actual_len - except Exception as e: + except BaseException as e: + # Store every original exception (including KeyboardInterrupt, + # SystemExit and CancelledError) so callers can re-raise it; + # an exception escaping into ctypes would be discarded. + callback_error_state.exception = e logger.error( cls._ERROR_MESSAGES['callback_error'].format( str(e))) @@ -4111,6 +4954,7 @@ def wrapped_callback( # Create the callback object using the callback function callback_cb = SignerCallback(wrapped_callback) + callback_cb._error_state = callback_error_state # Create the signer with the wrapped callback signer_ptr = _lib.c2pa_signer_create( @@ -4292,7 +5136,9 @@ def wrapped_callback( if result_size: ctypes.memmove(out_data, result, result_size) return result_size - except Exception as error: + except BaseException as error: + # See Signer.from_callback: never let an exception escape into + # ctypes, where it is ignored and the original is lost. error_state.exception = error logger.error( "Error in dynamic assertion callback for '%s': %s", @@ -4503,7 +5349,8 @@ def _init_from_context(self, context, json_str): self._consume_and_swap( lambda handle: _lib.c2pa_builder_with_definition( handle, json_str), - Builder._ERROR_MESSAGES['builder_error']) + Builder._ERROR_MESSAGES['builder_error'], + consumes_first=True) def _init_attrs(self): super()._init_attrs() @@ -4794,7 +5641,8 @@ def with_archive(self, stream: Any) -> 'Builder': self._consume_and_swap( lambda handle: _lib.c2pa_builder_with_archive( handle, stream_obj._stream), - Builder._ERROR_MESSAGES['archive_load_error']) + Builder._ERROR_MESSAGES['archive_load_error'], + consumes_first=True) return self @@ -4829,7 +5677,7 @@ def _sign_internal( self._ensure_valid_state() if signer is not None: - if not hasattr(signer, '_handle') or not signer._handle: + if not hasattr(signer, '_handle') or not signer.is_valid: raise C2paError("Invalid or closed signer") dynamic_assertion_cbs = list( @@ -4837,8 +5685,11 @@ def _sign_internal( if signer is not None else self._dynamic_assertion_cbs ) - for _, error_state, _ in dynamic_assertion_cbs: - error_state.exception = None + claim_state = _claim_signer_error_state( + signer._callback_cb if signer is not None + else self._signer_callback_cb) + da_states = [state for _, state, _ in dynamic_assertion_cbs] + _clear_callback_errors(da_states + [claim_state]) # allow_autodetect=False, so this never returns None (raises instead). format_arg = _format_ffi_arg( @@ -4846,36 +5697,39 @@ def _sign_internal( manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() try: - if signer is not None: - result = _lib.c2pa_builder_sign( - self._handle, - format_arg, - source_stream._stream, - dest_stream._stream, - signer._handle, - ctypes.byref(manifest_bytes_ptr) - ) - else: - result = _lib.c2pa_builder_sign_context( - self._handle, - format_arg, - source_stream._stream, - dest_stream._stream, - ctypes.byref(manifest_bytes_ptr), - ) - # Sign borrows the Builder without taking ownership. - # Closing here ensures resources clean up, - # and single use/single sign done by a Builder. - self.close() + with self._native_call(), signer._native_call() if signer is not None else nullcontext(): + try: + if signer is not None: + result = _lib.c2pa_builder_sign( + self._handle, + format_arg, + source_stream._stream, + dest_stream._stream, + signer._handle, + ctypes.byref(manifest_bytes_ptr) + ) + else: + result = _lib.c2pa_builder_sign_context( + self._handle, + format_arg, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) + finally: + # Close logically inside the borrows; physical teardown runs + # as the guards drain after the native call has returned. + self.close() except Exception as e: - self.close() raise C2paError(f"Error during signing: {e}") from e + finally: + # Admission can fail after preflight if the borrowed Signer closes. + # Match the other signing paths without consuming preflight errors. + self.close() if result < 0: - for _, error_state, _ in dynamic_assertion_cbs: - callback_error = getattr(error_state, 'exception', None) - if callback_error is not None: - raise callback_error + _reraise_callback_errors( + da_states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( result, @@ -5036,17 +5890,29 @@ def sign_fragmented( ) -> bytes: """Sign a fragmented BMFF file set and return its manifest bytes. - The native library writes signed files below - ``//``. This compatibility API accepts - one literal existing initialization segment and an explicit Signer. + With the paired glob-aware Castlabs 0.92.0-dev native including #17, + the native library writes signed files below + ``//``. An init path or glob selects one + or more renditions, all signed with one shared manifest and an explicit + Signer. Native code validates globs and output collisions and refuses + existing rendition output directories. Use fresh, exclusively owned + outputs outside the input globs; failures may leave partial outputs. + These glob/output guarantees are verified for that pairing. Export + presence alone does not establish them for older native lineages, which + may be literal-only or have different overwrite semantics. Older builds + may contain backports; no version cutoff is imposed here. Like :meth:`sign`, an attempted native signing operation closes this single-use Builder while leaving the borrowed Signer active. Args: signer: Active Signer borrowed for this operation. - asset_path: Literal path to an existing initialization segment. - fragments_glob: Fragment filename glob relative to the asset's - parent directory. + asset_path: Init path or glob matching one or more initialization + segments, each with a named parent directory (e.g. + ``video/init.mp4``, not bare ``init.mp4``). Parent directory names + must be distinct. Paths use native glob syntax even for a single + init in the paired native. + fragments_glob: Fragment glob relative to each init's parent + directory. Fragment subdirectories are flattened in output. output_dir: Root directory for the nested signed output. Returns: @@ -5055,9 +5921,9 @@ def sign_fragmented( Raises: C2paError.NotSupported: If the native API is unavailable. TypeError: If signer or path inputs have invalid types. - ValueError: If a path is empty, contains a NUL character, or - ``asset_path`` is not one literal existing file. - C2paError: If signing fails. + ValueError: If a path is empty or contains a NUL character. + C2paError.Encoding: If a path cannot be encoded as UTF-8. + C2paError: If native glob/layout validation or signing fails. """ self._ensure_valid_state() if not isinstance(signer, Signer): @@ -5070,42 +5936,35 @@ def sign_fragmented( "file_io feature" ) - asset_path_str, asset_path_bytes = _encode_path( + _, asset_path_bytes = _encode_path( asset_path, "asset_path") _, fragments_glob_bytes = _encode_path( fragments_glob, "fragments_glob") _, output_dir_bytes = _encode_path(output_dir, "output_dir") - if any(character in asset_path_str for character in "*?[]"): - raise ValueError( - "asset_path must be one literal initialization-segment path") - if not Path(asset_path_str).is_file(): - raise ValueError( - "asset_path must identify an existing regular file") - dynamic_assertion_cbs = list(signer._dynamic_assertion_cbs) - for _, error_state, _ in dynamic_assertion_cbs: - error_state.exception = None + claim_state = _claim_signer_error_state(signer._callback_cb) + da_states = [state for _, state, _ in dynamic_assertion_cbs] + _clear_callback_errors(da_states + [claim_state]) manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() try: try: - result = _lib.c2pa_builder_sign_fragmented( - self._handle, - signer._handle, - asset_path_bytes, - fragments_glob_bytes, - output_dir_bytes, - ctypes.byref(manifest_bytes_ptr), - ) + with self._native_call(), signer._native_call(): + result = _lib.c2pa_builder_sign_fragmented( + self._handle, + signer._handle, + asset_path_bytes, + fragments_glob_bytes, + output_dir_bytes, + ctypes.byref(manifest_bytes_ptr), + ) except Exception as error: raise C2paError( f"Error during fragmented signing: {error}") from error if result < 0: - for _, error_state, _ in dynamic_assertion_cbs: - callback_error = getattr(error_state, 'exception', None) - if callback_error is not None: - raise callback_error + _reraise_callback_errors( + da_states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( result, @@ -5124,9 +5983,124 @@ def sign_fragmented( # closes after an attempted sign; Signer remains caller-owned. self.close() if manifest_bytes_ptr: - ManagedResource._free_native_ptr(manifest_bytes_ptr) + # A cleanup failure must not replace the original callback or + # native exception (or a successful result). + try: + ManagedResource._free_native_ptr(manifest_bytes_ptr) + except Exception: + logger.error( + "Failed to release native manifest bytes memory") manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + def sign_ladder( + self, + signer: Signer, + sources: list[Union[str, Path]], + dests: list[Union[str, Path]], + ) -> bytes: + """Sign single-file fragmented MP4 renditions with one shared manifest. + + Each source must contain its own initialization and media fragments, + with one track per file. This is not an init-segment-plus-fragments + API. Sources must not already contain a C2PA manifest. Destinations + correspond to sources in order; they must be distinct, must not exist, + and their parent directories must exist. Native code validates the file + layout and path overlap and refuses to overwrite existing destinations. + Errors may leave partial outputs. Delete only newly created files you + positively own, never sources or preexisting destinations. Prefer a + fresh, exclusively owned staging directory for every operation. + + Like :py:meth:`sign`, an attempted native signing call closes this + Builder on success or failure. Preflight errors (including unavailable + native capability) leave it usable. The signer is borrowed and remains + usable. A context signer is not used by this method. + + Args: + signer: An explicit, active Signer (from info or a callback). + sources: List of 1 to 256 UTF-8 paths, one per rendition. + dests: Equally sized list of corresponding output paths. + + Returns: + The manifest bytes embedded in every output rendition. + + Raises: + C2paError.NotSupported: If the native library lacks ladder signing. + C2paError.Encoding: If a path is invalid UTF-8 or contains NUL. + C2paError: If inputs are invalid, signing fails, or copying the + returned manifest fails. + """ + self._ensure_valid_state() + if not _HAS_SIGN_LADDER: + raise C2paError.NotSupported( + "This native library does not export c2pa_builder_sign_ladder; " + "use a native library with single-file ladder signing support.") + if not isinstance(signer, Signer): + raise C2paError("An explicit Signer is required for ladder signing") + signer._ensure_valid_state() + if not isinstance(sources, list) or not isinstance(dests, list): + raise C2paError("sources and dests must be lists of paths") + if len(sources) != len(dests): + raise C2paError("sources and dests must have the same length") + if not 1 <= len(sources) <= 256: + raise C2paError("A ladder requires 1 to 256 renditions") + + # Retain both the encoded strings and ordered pointer arrays until + # the borrowed native call returns. + encoded_paths = [] + for paths in (sources, dests): + encoded = [] + for path in paths: + try: + text = os.fspath(path) + if not isinstance(text, str) or "\0" in text: + raise ValueError("paths must be strings without NUL") + encoded.append(text.encode("utf-8")) + except (TypeError, ValueError) as e: + raise C2paError.Encoding( + f"Invalid ladder path: {e}") from e + encoded_paths.append(encoded) + count = len(sources) + source_array = (ctypes.c_char_p * count)(*encoded_paths[0]) + dest_array = (ctypes.c_char_p * count)(*encoded_paths[1]) + manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + # Pin the explicit signer's callbacks for the borrowed native call and + # drop error state left by an earlier operation, as sign_fragmented does. + dynamic_assertion_cbs = list(signer._dynamic_assertion_cbs) + claim_state = _claim_signer_error_state(signer._callback_cb) + da_states = [state for _, state, _ in dynamic_assertion_cbs] + _clear_callback_errors(da_states + [claim_state]) + + try: + try: + with self._native_call(), signer._native_call(): + result = _lib.c2pa_builder_sign_ladder( + self._handle, signer._handle, source_array, dest_array, + count, ctypes.byref(manifest_bytes_ptr)) + except Exception as e: + raise C2paError(f"Error during ladder signing: {e}") from e + if result < 0: + # Dynamic-assertion exceptions keep their identity; claim-signer + # callbacks only propagate interrupts (KeyboardInterrupt, ...). + _reraise_callback_errors( + da_states, [claim_state] if claim_state is not None else []) + _check_ffi_operation_result( + result, "Error during ladder signing", check=lambda r: r < 0) + if result <= 0 or not manifest_bytes_ptr: + raise C2paError("Ladder signing returned no manifest bytes") + try: + return ctypes.string_at(manifest_bytes_ptr, result) + except Exception as e: + raise C2paError(f"Error during ladder signing: {e}") from e + finally: + if manifest_bytes_ptr: + try: + ManagedResource._free_native_ptr(manifest_bytes_ptr) + except Exception: + logger.error("Failed to release native manifest bytes memory") + # Native code borrows both handles. Free our builder, not the signer. + self.close() + @overload def sign_file( self, @@ -5383,6 +6357,14 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'Builder', 'Signer', 'LiveVideoVsiSession', + 'TrustedVsiSession', + 'VsiSigningContextV1', + 'TrustedVsiOperation', + 'TrustedVsiInputKind', + 'validate_trusted_vsi_input', + 'trusted_vsi_hash_template', + 'TrustedVsiMediaEmsgReservation', + 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', 'has_live_video_vsi', @@ -5390,6 +6372,12 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'has_live_video_vsi_explicit_time', 'has_live_video_vsi_mfhd_probe', 'has_live_video_vsi_recovery', + 'has_live_video_trusted_vsi_split_init', + 'has_live_video_trusted_vsi_expert_sig_structure', + 'has_live_video_trusted_vsi_composed_emsg', + 'has_live_video_trusted_vsi_recovery', + 'has_live_video_trusted_vsi_signing_context_v1', + 'has_live_video_trusted_vsi_full_uint32_exhaustion', 'moof_sequence_number', 'load_settings', 'format_embeddable', diff --git a/tests/fixtures/single-file-fragmented/README.md b/tests/fixtures/single-file-fragmented/README.md new file mode 100644 index 00000000..058f0abf --- /dev/null +++ b/tests/fixtures/single-file-fragmented/README.md @@ -0,0 +1,25 @@ +# Single-file fragmented fixture + +`single_file_fragments.mp4` contains an initialization prefix (`ftyp`/`moov`) +and three H.264 `moof`/`mdat` fragments in one file. It is synthetic test media, +not separate resolution encodes. The Python smoke signs two copies of these +same bytes; it does not claim multi-resolution coverage. + +The bytes match c2pa-rs `sdk/tests/fixtures/single_file_fragments.mp4` exactly. + +- Size: 3,812 bytes +- Git blob: `1a4813c4c60473c619665e130c75f4f63b071b01` +- SHA-256: `0dcc2720b3c217e192b2bf7205f8f3675eac417f02f306db3dfe73713660f968` + +The source README records generation with FFmpeg 6.1.1 and its synthetic +`testsrc2` source: + +```sh +ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc2=size=32x32:rate=2 -t 3 \ + -c:v libx264 -threads 1 -g 2 -bf 0 \ + -movflags +empty_moov+frag_keyframe+default_base_moof+global_sidx \ + -y single_file_fragments.mp4 +``` + +Tests use the committed bytes and existing test-only ES256 key/certificates in +the parent directory. They need neither FFmpeg nor an external timestamp server. diff --git a/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 b/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 new file mode 100644 index 00000000..1a4813c4 Binary files /dev/null and b/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 differ diff --git a/tests/ladder_native.py b/tests/ladder_native.py new file mode 100644 index 00000000..9210dea5 --- /dev/null +++ b/tests/ladder_native.py @@ -0,0 +1,216 @@ +"""Explicit real-native lanes, run in a fresh process with an isolated package. + +See docs/ladder-signing.md for commands. Neither lane skips missing capability. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run_lane(args): + import c2pa.c2pa as binding + from c2pa import Builder, C2paError, C2paSignerInfo, Reader, Signer + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + + loaded = Path(binding._lib._name).resolve(strict=True) + expected = Path(os.environ["C2PA_LIBRARY_NAME"]).resolve(strict=True) + assert loaded == expected, (loaded, expected) + assert Path(binding.__file__).resolve().parent == expected.parent + assert digest(loaded) == digest(args.library), "Library changed during staging" + print(json.dumps({ + "lane": args.lane, "source_library": str(args.library), + "loaded_library": str(loaded), "sha256": digest(loaded), + "sdk_version": binding.sdk_version(), + "has_sign_ladder": binding._HAS_SIGN_LADDER, + }), flush=True) + assert binding._HAS_SIGN_LADDER == (args.lane == "candidate"), ( + "Candidate requires c2pa_builder_sign_ladder; stock must lack it") + if args.lane == "candidate": + export = binding._lib.c2pa_builder_sign_ladder + assert export.restype is binding.ctypes.c_int64 + assert export.argtypes == [ + binding.ctypes.POINTER(binding.C2paBuilder), + binding.ctypes.POINTER(binding.C2paSigner), + binding.ctypes.POINTER(binding.ctypes.c_char_p), + binding.ctypes.POINTER(binding.ctypes.c_char_p), + binding.ctypes.c_size_t, + binding.ctypes.POINTER( + binding.ctypes.POINTER(binding.ctypes.c_ubyte)), + ] + + definition = { + "claim_generator_info": [{"name": "ladder-binding-test"}], + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], + } + certs = (FIXTURES / "es256_certs.pem").read_bytes() + key = (FIXTURES / "es256_private.key").read_bytes() + info = C2paSignerInfo(alg=b"es256", sign_cert=certs, + private_key=key, ta_url=None) + with Signer.from_info(info) as signer: + with Builder(definition) as builder: + if args.lane == "stock": + try: + builder.sign_ladder(signer, ["in.mp4"], ["out.mp4"]) + except C2paError.NotSupported as error: + assert "c2pa_builder_sign_ladder" in str(error) + else: + raise AssertionError("Stock capability error was not raised") + builder._ensure_valid_state() + ordinary = Path("ordinary.jpg") + assert builder.sign_file(FIXTURES / "A.jpg", ordinary, signer) + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + signer._ensure_valid_state() + with Reader(ordinary) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + print("ordinary signing and validation passed", flush=True) + if args.lane == "stock": + return + + assert args.native_fixtures is not None, "--native-fixtures is required" + fixtures = [args.native_fixtures / name for name in ( + "single_file_fragments.mp4", "single_file_fragments_absolute.mp4")] + before = [digest(path) for path in fixtures] + print(json.dumps({"fixtures": dict(zip(map(str, fixtures), before))}), + flush=True) + sources = [Path(path.name) for path in fixtures] + for source, fixture in zip(sources, fixtures): + shutil.copy2(fixture, source) + private_key = serialization.load_pem_private_key(key, password=None) + + def callback(data): + return private_key.sign(data, ec.ECDSA(hashes.SHA256())) + + with Signer.from_callback(callback, binding.C2paSigningAlg.ES256, + certs.decode()) as callback_signer: + for label, active_signer in (("info", signer), + ("callback", callback_signer)): + dests = [Path(f"{label}-{i}.mp4") for i in range(len(sources))] + with Builder(definition) as builder: + manifest = builder.sign_ladder(active_signer, sources, dests) + assert manifest + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + active_signer._ensure_valid_state() + manifests = [] + for dest in dests: + assert manifest in dest.read_bytes() + with Reader(dest) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + data = json.loads(reader.json()) + manifests.append(data["manifests"][data["active_manifest"]]) + assert manifests[0] == manifests[1] + + # Change media payload, not box structure or the signed manifest. + tampered = bytearray(dests[0].read_bytes()) + offset = 0 + while tampered[offset + 4:offset + 8] != b"mdat": + size = int.from_bytes(tampered[offset:offset + 4], "big") + assert size >= 8 + offset += size + assert offset + 8 < len(tampered) + tampered[offset + 8] ^= 1 + dests[0].write_bytes(tampered) + with Reader(dests[0]) as reader: + assert reader.get_validation_state() == "Invalid", reader.json() + print(f"{label} ladder signing, shared manifest, and tamper checks passed", + flush=True) + + # Native path validation failure still ends this builder's single use. + existing = Path("existing.mp4") + sentinel = b"existing destination must not be overwritten" + existing.write_bytes(sentinel) + for label, dests in ( + ("source alias", sources), + ("duplicate destination", [Path("duplicate.mp4")] * 2), + ("existing destination", [existing, Path("new.mp4")]), + ): + with Builder(definition) as builder: + try: + builder.sign_ladder(signer, sources, dests) + except C2paError: + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + else: + raise AssertionError(f"Native layer accepted {label}") + signer._ensure_valid_state() + assert existing.read_bytes() == sentinel + assert [digest(path) for path in sources] == before + print(f"{label} refusal and lifecycle checks passed", flush=True) + + calls = [] + + def fail_callback(data): + calls.append(len(data)) + return b"" + + with Signer.from_callback(fail_callback, binding.C2paSigningAlg.ES256, + certs.decode()) as failing_signer: + with Builder(definition) as builder: + try: + builder.sign_ladder(failing_signer, sources, + [Path("failed-0.mp4"), Path("failed-1.mp4")]) + except C2paError: + assert calls, "Signing failed before invoking the callback" + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + else: + raise AssertionError("Native layer accepted a failed callback") + failing_signer._ensure_valid_state() + print("callback failure and lifecycle checks passed", flush=True) + assert [digest(path) for path in sources] == before + + +def main(): + if sys.flags.optimize: + raise SystemExit( + "Native ladder verification requires assertions; " + "rerun without -O/-OO or PYTHONOPTIMIZE.") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--library", type=Path, required=True) + parser.add_argument("--lane", choices=("stock", "candidate"), required=True) + parser.add_argument("--native-fixtures", type=Path) + parser.add_argument("--child", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + args.library = args.library.resolve(strict=True) + if args.native_fixtures is not None: + args.native_fixtures = args.native_fixtures.resolve(strict=True) + if args.child: + run_lane(args) + return + with tempfile.TemporaryDirectory(prefix="c2pa-ladder-") as temp: + root = Path(temp) + package = root / "c2pa" + shutil.copytree(ROOT / "src" / "c2pa", package, + ignore=shutil.ignore_patterns("libs", "__pycache__")) + staged = package / args.library.name + shutil.copy2(args.library, staged) + env = os.environ.copy() + env.pop("PYTHONOPTIMIZE", None) + env["PYTHONPATH"] = str(root) + # Existing loader seam, with an absolute filename and a checked result. + env["C2PA_LIBRARY_NAME"] = str(staged) + command = [sys.executable, str(Path(__file__).resolve()), + "--child", "--lane", args.lane, "--library", str(args.library)] + if args.native_fixtures is not None: + command += ["--native-fixtures", str(args.native_fixtures)] + subprocess.run(command, cwd=root, env=env, check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/perf/scenarios.py b/tests/perf/scenarios.py index 23300aed..bdfd2795 100644 --- a/tests/perf/scenarios.py +++ b/tests/perf/scenarios.py @@ -43,6 +43,13 @@ _DST_COMPOSITE = "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" +# c2pa-rs >= 0.91 rejects manifests whose first action is not created or opened. +_CREATED_ACTION = { + "action": "c2pa.created", + "softwareAgent": {"name": "perf_test"}, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", +} + _PARENT_ID = "xmp:iid:aaaaaaaa-0001-0001-0001-aaaaaaaaaaaa" _PLACED_ID = "xmp:iid:bbbbbbbb-0002-0002-0002-bbbbbbbbbbbb" _PARENT_ID2 = "xmp:iid:cccccccc-0003-0003-0003-cccccccccccc" @@ -307,7 +314,7 @@ def scenario_builder_sign_jpeg_component_of(iterations: int = 100) -> None: "ingredients": [{"format": "image/jpeg", "relationship": "componentOf", "instance_id": _PLACED_ID}], "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_PLACED_ID]}, @@ -411,7 +418,7 @@ def scenario_builder_sign_jpeg_two_components_same_mime(iterations: int = 100) - **MANIFEST_BASE, "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_PLACED_ID4, _PLACED_ID5]}, @@ -441,7 +448,7 @@ def scenario_builder_sign_jpeg_two_components_mixed_mime(iterations: int = 100) **MANIFEST_BASE, "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_PLACED_ID4, _PLACED_ID5]}, @@ -836,7 +843,7 @@ def scenario_builder_sign_jpeg_two_ingredient_archives(iterations: int = 100) -> **MANIFEST_BASE, "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_ARCH_COMP_ID, _ARCH_COMP_ID2]}, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 43f11fc6..d383cf25 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -7,6 +7,7 @@ import json import os import re +import shutil import subprocess import tarfile import tempfile @@ -28,12 +29,39 @@ SPEC.loader.exec_module(release) +def _posix_bash() -> str: + """Return a POSIX bash for workflow shell snippets. + + On Windows a bare ``bash`` resolves to ``System32\\bash.exe`` (the WSL + launcher, unusable without a distribution). GitHub's ``shell: bash`` uses + Git for Windows' bash, so use the same one; fail rather than skip if absent. + """ + if os.name != "nt": + return "bash" + git = shutil.which("git") + assert git, "Git for Windows is required to run workflow shell helpers" + # git.exe may live in \\cmd, \\bin or \\mingw64\\bin. + for root in Path(git).resolve().parents: + for candidate in (root / "bin" / "bash.exe", root / "usr" / "bin" / "bash.exe"): + if candidate.is_file() and "system32" not in str(candidate).lower(): + return str(candidate) + raise AssertionError(f"Git for Windows bash not found near {git}") + + def test_prerelease_version_is_consistent(): - assert release.project_version(ROOT) == "0.37.8.dev5" + # The immutable dev5 release identity stays frozen in the tooling and lock. + assert release.RELEASE_VERSION == "0.37.8.dev5" + assert release.load_lock()["package"]["version"] == "0.37.8.dev5" + # This checkout is the unreleased consolidated functional source. Its + # identity is consistent and deliberately differs from dev5, so + # validate-sources refuses to release it under the dev5 lock. + source = release.project_version(ROOT) + assert source == "0.37.13.dev0" + assert source != release.RELEASE_VERSION first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) - assert first_line == "# Version: 0.37.8.dev5" + assert first_line == f"# Version: {source}" def test_release_lock_and_schemas_are_valid_json(): @@ -60,6 +88,45 @@ def test_release_lock_and_schemas_are_valid_json(): jsonschema.validate(lock, schema) +def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): + workflow_dir = ROOT / ".github" / "workflows" + paired = (workflow_dir / "trusted-vsi-paired.yml").read_text(encoding="utf-8") + legacy = (workflow_dir / "build.yml").read_text(encoding="utf-8") + dedicated = (workflow_dir / "castlabs-vsi-release.yml").read_text(encoding="utf-8") + for caller in (legacy, dedicated): + assert "uses: ./.github/workflows/trusted-vsi-paired.yml" in caller + assert legacy.count('tests/test_trusted_vsi_api.py -k "not paired"') == 2 + assert "if: github.event_name == 'workflow_dispatch' && inputs.trusted_vsi_only" in dedicated + assert " prepare:\n if: ${{ !inputs.trusted_vsi_only }}" in dedicated + assert dedicated.count(f"ref: {release.RUST_COMMIT}") == 3 + assert 'C2PA_TRUSTED_VSI_ABI_REQUIRED: "1"' in paired + assert 'C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1"' in paired + focused = ("python -m pytest -q tests/test_trusted_vsi_api.py\n" + " tests/test_fragmented_files.py tests/test_sign_ladder.py\n" + " tests/test_native_ownership.py tests/test_native_ownership_opaque.py -ra") + assert focused in paired + assert "-k " not in paired + assert "ubuntu-24.04" in paired and "windows-2022" in paired + assert "C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/" in paired + assert "PYTHONPATH: ${{ github.workspace }}/python-source/src" in paired + assert "python setup.py egg_info" in paired + assert "cargo +1.96.0 build --locked" in paired + # Paired native is pinned to a reviewed full SHA, never a moving branch. + assert re.search(r"^\s+ref: [0-9a-f]{40}$", paired, re.MULTILINE) + assert "ref: feat/" not in paired + assert "FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0" in paired + assert "C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev" in paired + assert 'C2PA_REQUIRE_SIGN_LADDER: "1"' in paired + assert 'C2PA_REQUIRE_FRAGMENTED_FILES: "1"' in paired + assert "tests/ladder_native.py --lane candidate" in paired + assert "scripts/build_trusted_vsi_functional.py" in paired + assert "scripts/qualify_trusted_vsi_functional.py" in paired + for forbidden in ("download_artifacts.py", "castlabs_release.py", + "upload-artifact@", "bdist_wheel", "contents: write", + "id-token: write", "continue-on-error", "gh release"): + assert forbidden not in paired + + def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( tmp_path, ): @@ -72,13 +139,20 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( legacy_workflow = (ROOT / ".github" / "workflows" / "build.yml").read_text( encoding="utf-8" ) - for workflow in (release_workflow, pypi_workflow): + paired_workflow = ( + ROOT / ".github" / "workflows" / "trusted-vsi-paired.yml" + ).read_text(encoding="utf-8") + for workflow in (release_workflow, pypi_workflow, paired_workflow): action_shas = re.findall( r"^\s*(?:-\s+)?uses:\s+[^@\s]+@([0-9a-f]{40})(?:\s+#.*)?$", workflow, re.MULTILINE, ) - assert len(action_shas) == workflow.count("uses:") + local_calls = workflow.count( + "uses: ./.github/workflows/trusted-vsi-paired.yml" + ) + assert local_calls == (1 if workflow == release_workflow else 0) + assert len(action_shas) + local_calls == workflow.count("uses:") assert "mstattma/" not in workflow assert release_workflow.count("timeout-minutes:") == 6 assert pypi_workflow.count("timeout-minutes:") == 1 @@ -280,7 +354,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_workflow[shell_helpers_start:shell_helpers_end] ) subprocess.run( - ["bash"], + [_posix_bash()], cwd=tmp_path, input=( "set -euo pipefail\n" diff --git a/tests/test_fragmented_files.py b/tests/test_fragmented_files.py index 86e76162..00dcbee8 100644 --- a/tests/test_fragmented_files.py +++ b/tests/test_fragmented_files.py @@ -5,6 +5,7 @@ import ctypes import gc import json +import os import tempfile import unittest import weakref @@ -137,8 +138,8 @@ def callback(data: bytes) -> bytes: None, ) - def _prepare_input(self, root: Path) -> tuple[Path, Path]: - input_dir = root / "input" + def _prepare_input(self, root: Path, name: str = "input") -> tuple[Path, Path]: + input_dir = root / name input_dir.mkdir() init_path = input_dir / "init.mp4" with open(FIXTURES_DIR / "dashinit.mp4", "rb") as file: @@ -225,11 +226,174 @@ def test_missing_capability_has_clear_errors(self): context.close() +class TestFragmentedCapabilityRequired(unittest.TestCase): + def test_required_fragmented_capability_is_present(self): + if os.environ.get("C2PA_REQUIRE_FRAGMENTED_FILES") != "1": + self.skipTest("C2PA_REQUIRE_FRAGMENTED_FILES is not set") + self.assertTrue( + has_fragmented_files(), + "C2PA_REQUIRE_FRAGMENTED_FILES=1 but the loaded native library " + "lacks the fragmented BMFF file APIs") + + @unittest.skipUnless( has_fragmented_files(), "native library does not provide fragmented BMFF file APIs", ) class TestFragmentedFiles(FragmentedTestCase): + def test_init_glob_signs_two_renditions_with_one_manifest_and_selectors(self): + import pytest + + try: + cbor2 = pytest.importorskip( + "cbor2", reason="Merkle selector checks require the cbor2 dev dependency") + except pytest.skip.Exception: + if os.environ.get("C2PA_REQUIRE_FRAGMENTED_FILES") == "1": + pytest.fail( + "C2PA_REQUIRE_FRAGMENTED_FILES=1 requires cbor2 for Merkle " + "selector checks; install requirements-dev.txt") + raise + + signer = self._make_signer() + self.addCleanup(signer.close) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + inits = [] + fragments = [] + for index in range(2): + # Distinct parent names match the segmented ABR caller's staging. + init, fragment = self._prepare_input(root, f"rendition-{index}") + # Different media bytes make cross-rendition selection observable. + media = bytearray(fragment.read_bytes()) + media[-1] ^= index + fragment.write_bytes(media) + inits.append(init) + fragments.append(fragment) + sources = {path: path.read_bytes() for path in inits + fragments} + output = root / "signed" + builder = Builder(_manifest_definition()) + manifest = builder.sign_fragmented( + signer=signer, + asset_path=root / "rendition-*" / "init.mp4", + fragments_glob=Path("segment-*.m4s"), + output_dir=output, + ) + self.assertGreater(len(manifest), 0) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + self.assertEqual( + sorted(path.name for path in output.iterdir()), + ["rendition-0", "rendition-1"], + ) + selectors = [] + reports = [] + signed_fragments = [] + for init, fragment in zip(inits, fragments): + signed_dir = output / init.parent.name + signed_init = signed_dir / init.name + signed_fragment = signed_dir / fragment.name + signed_fragments.append(signed_fragment) + self.assertEqual( + sorted(path.name for path in signed_dir.iterdir()), + [init.name, fragment.name], + ) + # Exact returned JUMBF bytes must occur in BOTH init segments. + self.assertIn(manifest, signed_init.read_bytes()) + media = signed_fragment.read_bytes() + offset = 0 + fragment_selectors = [] + # These tiny fixture outputs use nonzero 32-bit box sizes and + # 8-byte headers; the UUID offsets below assume that layout. + while offset < len(media): + size = int.from_bytes(media[offset:offset + 4], "big") + self.assertGreaterEqual(size, 8) + self.assertLessEqual(offset + size, len(media)) + box = media[offset:offset + size] + if box[4:8] == b"uuid" and box[28:35] == b"merkle\0": + merkle = cbor2.loads(box[35:]) + fragment_selectors.append( + (merkle["uniqueId"], merkle["localId"])) + offset += size + self.assertEqual(len(fragment_selectors), 1) + selectors.extend(fragment_selectors) + with Reader.from_fragmented_files( + signed_init, [signed_fragment], + ) as reader: + self.assertEqual(reader.get_validation_state(), "Valid") + report = json.loads(reader.json()) + reports.append(report["manifests"][report["active_manifest"]]) + self.assertEqual(selectors, [(1, 1), (2, 2)]) + self.assertEqual(reports[0], reports[1]) + bmff = next(assertion["data"] for assertion in reports[0]["assertions"] + if assertion["label"].startswith("c2pa.hash.bmff")) + self.assertEqual( + [(entry["uniqueId"], entry["localId"]) for entry in bmff["merkle"]], + selectors, + ) + # Keep rendition 1's selector but replace its media with rendition 2's. + wrong_media = bytearray(signed_fragments[0].read_bytes()) + wrong_media[-1] ^= 1 + signed_fragments[0].write_bytes(wrong_media) + with Reader.from_fragmented_files( + output / inits[0].parent.name / inits[0].name, + [signed_fragments[0]], + ) as reader: + self.assertEqual(reader.get_validation_state(), "Invalid") + self.assertEqual({path: path.read_bytes() for path in sources}, sources) + + def test_init_glob_preserves_existing_destinations_including_source_directory(self): + signer = self._make_signer() + self.addCleanup(signer.close) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + init, fragment = self._prepare_input(root) + sources = {path: path.read_bytes() for path in (init, fragment)} + output = root / "output" + destination = output / init.parent.name + destination.mkdir(parents=True) + sentinel = destination / init.name + sentinel.write_bytes(b"do not overwrite") + # The source-directory case fails at the C ABI's existing-directory + # preflight, before the SDK's filesystem identity checks. + for target in (output, root): + with self.subTest(output=target): + builder = Builder(_manifest_definition()) + with self.assertRaisesRegex(C2paError, "already exists"): + builder.sign_fragmented( + signer=signer, + asset_path=root / "*" / "init.mp4", + fragments_glob="segment-*.m4s", + output_dir=target, + ) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + self.assertEqual(sentinel.read_bytes(), b"do not overwrite") + self.assertEqual(list(destination.iterdir()), [sentinel]) + self.assertEqual( + {path: path.read_bytes() for path in sources}, sources) + + def test_native_rejects_invalid_and_unmatched_globs_without_outputs(self): + signer = self._make_signer() + self.addCleanup(signer.close) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + init, _ = self._prepare_input(root) + for asset, fragments, message in ( + (root / "[", "segment-*.m4s", "Invalid glob pattern"), + (root / "*" / "missing*.mp4", "segment-*.m4s", "No init segments"), + (root / "missing-init.mp4", "segment-*.m4s", "No init segments"), + (init, "[", "Invalid glob pattern"), + (init, "missing*.m4s", "No fragments"), + ): + with self.subTest(asset=asset, fragments=fragments): + output = root / "output" + builder = Builder(_manifest_definition()) + with self.assertRaisesRegex(C2paError, message): + builder.sign_fragmented(signer, asset, fragments, output) + self.assertFalse(output.exists()) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + def test_successful_sign_and_legacy_read_round_trip(self): signer = self._make_signer() self.addCleanup(signer.close) @@ -342,11 +506,6 @@ def test_invalid_inputs_are_rejected_before_native_calls(self): with self.subTest(asset_path=value): with self.assertRaises((TypeError, ValueError)): builder.sign_fragmented(signer, value, "*.m4s", "out") - with self.assertRaises(ValueError): - builder.sign_fragmented(signer, "*.mp4", "*.m4s", "out") - with self.assertRaises(ValueError): - builder.sign_fragmented( - signer, "missing-init.mp4", "*.m4s", "out") self.assertTrue(builder.is_valid) self.assertTrue(signer.is_valid) @@ -535,6 +694,61 @@ def record_free(pointer): self.assertFalse(builder.is_valid) self.assertTrue(signer.is_valid) + def test_output_free_failure_does_not_replace_callback_exception(self): + class CallbackFailure(RuntimeError): + pass + + failure = CallbackFailure("fragmented dynamic assertion failed") + signer = self._make_signer() + self.addCleanup(signer.close) + state = type("State", (), {"exception": None})() + signer._dynamic_assertion_cbs.append((object(), state, object())) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + init_path, _ = self._prepare_input(root) + builder = Builder(_manifest_definition()) + builder_handle = builder._handle + output_buffer = (ctypes.c_ubyte * 4)(1, 2, 3, 4) + output_address = ctypes.addressof(output_buffer) + real_call = c2pa_module._lib.c2pa_builder_sign_fragmented + real_free = ManagedResource._free_native_ptr + + def failed_call(*args): + output = ctypes.cast( + args[-1], + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ) + output[0] = ctypes.cast( + output_buffer, ctypes.POINTER(ctypes.c_ubyte)) + state.exception = failure + return -1 + + def failing_free(pointer): + if ctypes.cast(pointer, ctypes.c_void_p).value == output_address: + raise RuntimeError("free failed") + return real_free(pointer) + + c2pa_module._lib.c2pa_builder_sign_fragmented = failed_call + ManagedResource._free_native_ptr = staticmethod(failing_free) + try: + with self.assertLogs("c2pa", level="ERROR") as logs: + with self.assertRaises(CallbackFailure) as raised: + builder.sign_fragmented( + signer, init_path, "segment-*.m4s", + root / "output") + finally: + c2pa_module._lib.c2pa_builder_sign_fragmented = real_call + ManagedResource._free_native_ptr = real_free + + self.assertIs(raised.exception, failure) + self.assertTrue(any( + "Failed to release native manifest bytes memory" in line + for line in logs.output)) + self.assertIsNone(builder._handle) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + del builder_handle + if __name__ == "__main__": unittest.main() diff --git a/tests/test_native_ownership.py b/tests/test_native_ownership.py new file mode 100644 index 00000000..f1dc59fd --- /dev/null +++ b/tests/test_native_ownership.py @@ -0,0 +1,232 @@ +"""Native ownership regressions for raw-address and opaque-handle registries. + +Consume-first calls can reject another argument after dropping their managed +handle. Never probe a consumed address with c2pa_free: on the raw-address +registry it may already belong to a new allocation. +""" + +import ctypes +import io +from pathlib import Path + +import pytest + +import c2pa.c2pa as binding +from c2pa import Builder, C2paError, C2paSignerInfo, Context, Reader, Signer +from c2pa.c2pa import LifecycleState, ManagedResource + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _addr(pointer): + return ctypes.cast(pointer, ctypes.c_void_p).value + + +def _untracked_pointer(pointer_type): + # Keep the buffer alive so its address cannot become a native allocation. + buffer = ctypes.create_string_buffer(64) + return ctypes.cast(buffer, ctypes.POINTER(pointer_type)), buffer + + +@pytest.fixture(autouse=True) +def _restore_native_error_slot(): + # The slot is sticky and thread-local. Neutral text works on both libraries. + binding._lib.c2pa_error_set_last(b"Other: native ownership test setup") + yield + binding._lib.c2pa_error_set_last(b"Other: native ownership test teardown") + + +@pytest.fixture +def reader(): + with open(FIXTURES / "dashinit.mp4", "rb") as init: + value = Reader("video/mp4", init) + try: + yield value + finally: + value.close() + + +@pytest.fixture +def frees(monkeypatch): + calls = [] + real_free = ManagedResource._free_native_ptr + + def record(pointer): + calls.append(_addr(pointer)) + return real_free(pointer) + + monkeypatch.setattr(ManagedResource, "_free_native_ptr", staticmethod(record)) + return calls + + +def _assert_closed(resource): + assert resource._handle is None + assert resource._lifecycle_state == LifecycleState.CLOSED + resource.close() + resource.close() + + +def test_rejected_fragment_stream_after_reader_consumed_closes_reader( + reader, monkeypatch, frees): + consumed = _addr(reader._handle) + bogus, _keep = _untracked_pointer(binding.C2paStream) + real_call = binding._lib.c2pa_reader_with_fragment + monkeypatch.setattr( + binding._lib, "c2pa_reader_with_fragment", + lambda handle, fmt, stream, fragment: real_call(handle, fmt, stream, bogus)) + before = len(frees) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + reader.with_fragment("video/mp4", init, fragment) + assert f"0x{_addr(bogus):x}" in str(caught.value) + _assert_closed(reader) + assert consumed not in frees[before:] + + +def test_rejected_stream_after_context_reader_consumed_is_not_freed( + monkeypatch, frees): + bogus, _keep = _untracked_pointer(binding.C2paStream) + real_call = binding._lib.c2pa_reader_with_stream + seen = [] + + def call(handle, fmt, stream): + seen.append((_addr(handle), len(frees))) + return real_call(handle, fmt, bogus) + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_stream", call) + with Context() as context, open(FIXTURES / "dashinit.mp4", "rb") as init: + partial_reader = Reader.__new__(Reader) + with pytest.raises(C2paError, match="UntrackedPointer"): + partial_reader.__init__("video/mp4", init, context=context) + assert len(seen) == 1, "the consume-first native call was not reached" + consumed, before = seen[0] + _assert_closed(partial_reader) + # Check before context cleanup or any later allocation can reuse it. + assert consumed not in frees[before:] + + +def test_rejection_naming_the_managed_handle_retains_it(reader, frees): + bogus, _keep = _untracked_pointer(binding.C2paReader) + real_handle = reader._handle + reader._handle = bogus + before = len(frees) + try: + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + reader.with_fragment("video/mp4", init, fragment) + assert f"0x{_addr(bogus):x}" in str(caught.value) + assert reader._handle is bogus + assert reader._lifecycle_state == LifecycleState.ACTIVE + assert _addr(bogus) not in frees[before:] + finally: + reader._handle = real_handle + assert reader.json() + + +def test_successful_fragment_swap_does_not_free_old_and_closes_replacement_once( + reader, frees): + consumed = _addr(reader._handle) + before = len(frees) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + assert reader.with_fragment("video/mp4", init, fragment) is reader + assert reader._lifecycle_state == LifecycleState.ACTIVE + assert reader._handle + assert consumed not in frees[before:], "swap must not free the consumed handle" + replacement = _addr(reader._handle) + # The replacement may have the same address as the consumed reader. + before_close = len(frees) + reader.close() + _assert_closed(reader) + assert frees[before_close:] == [replacement] + + +@pytest.mark.parametrize("tag", [ + "Other: PointerInUse: handle already in (exclusive) use", + "Other: WrongWrapperKind: Arc-backed handle can't have single ownership", +]) +def test_addressless_rejection_on_consume_first_call_releases_defensively( + reader, monkeypatch, frees, tag): + managed = _addr(reader._handle) + + def rejected(*_args): + # Stock does not emit these tags, but can hold them in its error slot. + binding._lib.c2pa_error_set_last(tag.encode()) + return None + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_fragment", rejected) + before = len(frees) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError) as caught: + reader.with_fragment("video/mp4", init, fragment) + assert tag.split(": ", 1)[1].split(":")[0] in str(caught.value) + _assert_closed(reader) + assert frees[before:] == [managed] + + +def test_rejected_archive_stream_after_builder_consumed_closes_without_free( + monkeypatch, frees): + builder = Builder({"claim_generator_info": [{"name": "ownership-test"}], + "assertions": []}) + consumed = _addr(builder._handle) + bogus, _keep = _untracked_pointer(binding.C2paStream) + real_call = binding._lib.c2pa_builder_with_archive + monkeypatch.setattr(binding._lib, "c2pa_builder_with_archive", + lambda handle, stream: real_call(handle, bogus)) + before = len(frees) + try: + with pytest.raises(C2paError, match="UntrackedPointer"): + builder.with_archive(io.BytesIO(b"unused")) + _assert_closed(builder) + assert consumed not in frees[before:] + finally: + builder.close() + + +def test_set_signer_validates_builder_first_and_retains_signer(monkeypatch, frees): + signer = Signer.from_info(C2paSignerInfo( + alg=b"es256", sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + bogus, _keep = _untracked_pointer(binding.C2paContextBuilder) + # Do not allocate a real native builder that a failing constructor could leak. + monkeypatch.setattr(binding._lib, "c2pa_context_builder_new", lambda: bogus) + managed = _addr(signer._handle) + before = len(frees) + try: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + Context(signer=signer) + assert f"0x{_addr(bogus):x}" in str(caught.value) + assert signer._lifecycle_state == LifecycleState.ACTIVE + assert _addr(signer._handle) == managed + assert managed not in frees[before:] + assert signer.reserve_size() > 0 + finally: + signer.close() + + +@pytest.mark.parametrize("tag", [ + "UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind", +]) +def test_invalid_definition_quoting_registry_tag_does_not_free_consumed_builder( + monkeypatch, frees, tag): + real_call = binding._lib.c2pa_builder_with_definition + seen = [] + + def call(handle, definition): + seen.append((_addr(handle), len(frees))) + return real_call(handle, definition) + + monkeypatch.setattr(binding._lib, "c2pa_builder_with_definition", call) + with Context() as context: + builder = Builder.__new__(Builder) + with pytest.raises(C2paError, match="Json") as caught: + builder.__init__({"claim_version": f"{tag}: 0xcafe"}, context=context) + assert tag in str(caught.value) + assert len(seen) == 1 + consumed, before = seen[0] + _assert_closed(builder) + assert consumed not in frees[before:] diff --git a/tests/test_native_ownership_opaque.py b/tests/test_native_ownership_opaque.py new file mode 100644 index 00000000..fbfa292d --- /dev/null +++ b/tests/test_native_ownership_opaque.py @@ -0,0 +1,112 @@ +"""Opaque-registry-only ownership checks for the consolidated native. + +On the paired opaque native (odd, never-reused object ids) a consumed handle +is provably gone from the registry, so these tests additionally assert that +no consumed id is still tracked. Stock natives reuse raw addresses, so this +cannot be asserted there; generic ownership tests live in +test_native_ownership.py. +""" + +import ctypes +import io +import os +from pathlib import Path + +import pytest + +import c2pa.c2pa as binding +from c2pa import Builder, C2paError, Context, Reader + +FIXTURES = Path(__file__).parent / "fixtures" + +def _qualification_required(): + return "1" in (os.environ.get("C2PA_TRUSTED_VSI_ABI_REQUIRED"), + os.environ.get("C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED")) + + +@pytest.fixture(autouse=True) +def _require_opaque_registry(): + """Fail (never skip) under paired qualification if not the opaque native.""" + problem = None + if not binding.has_live_video_trusted_vsi_signing_context_v1(): + problem = "paired trusted-VSI native unavailable" + else: + with open(FIXTURES / "dashinit.mp4", "rb") as init: + probe = Reader("video/mp4", init) + try: + if probe._handle_value() & 1 != 1: + problem = "object handles are not odd opaque registry ids" + finally: + probe.close() + if problem: + if _qualification_required(): + pytest.fail("opaque-registry ownership checks: " + problem) + pytest.skip(problem) + yield + binding._lib.c2pa_error_set_last(b"Other: cleared by test teardown") + + +def _untracked_stream(): + buffer = ctypes.create_string_buffer(64) + return ctypes.cast(buffer, ctypes.POINTER(binding.C2paStream)), buffer + + +def _still_tracked(handle_value): + """Return whether the registry still tracks an id (frees it if so).""" + return binding._lib.c2pa_free(ctypes.c_void_p(handle_value)) == 0 + + +def test_consumed_reader_id_is_gone_after_rejected_fragment(monkeypatch): + with open(FIXTURES / "dashinit.mp4", "rb") as init: + reader = Reader("video/mp4", init) + consumed = reader._handle_value() + bogus, _keep = _untracked_stream() + real_call = binding._lib.c2pa_reader_with_fragment + monkeypatch.setattr( + binding._lib, "c2pa_reader_with_fragment", + lambda handle, fmt, stream, fragment: real_call(handle, fmt, stream, bogus)) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer"): + reader.with_fragment("video/mp4", init, fragment) + assert not _still_tracked(consumed) + + +def test_consumed_context_reader_id_is_gone_after_rejected_stream(monkeypatch): + bogus, _keep = _untracked_stream() + real_call = binding._lib.c2pa_reader_with_stream + seen = [] + + def call(handle, fmt, stream): + seen.append(ctypes.cast(handle, ctypes.c_void_p).value) + return real_call(handle, fmt, bogus) + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_stream", call) + with Context() as context, open(FIXTURES / "dashinit.mp4", "rb") as init: + with pytest.raises(C2paError, match="UntrackedPointer"): + Reader("video/mp4", init, context=context) + assert len(seen) == 1 and not _still_tracked(seen[0]) + + +def test_consumed_builder_id_is_gone_after_rejected_archive(monkeypatch): + builder = Builder({"claim_generator_info": [{"name": "ownership-test"}], + "assertions": []}) + consumed = builder._handle_value() + bogus, _keep = _untracked_stream() + real_call = binding._lib.c2pa_builder_with_archive + monkeypatch.setattr(binding._lib, "c2pa_builder_with_archive", + lambda handle, stream: real_call(handle, bogus)) + with pytest.raises(C2paError, match="UntrackedPointer"): + builder.with_archive(io.BytesIO(b"unused")) + assert not _still_tracked(consumed) + + +def test_successful_swap_leaves_consumed_id_untracked(): + with open(FIXTURES / "dashinit.mp4", "rb") as init: + reader = Reader("video/mp4", init) + consumed = reader._handle_value() + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + reader.with_fragment("video/mp4", init, fragment) + assert not _still_tracked(consumed) + reader.close() diff --git a/tests/test_sign_ladder.py b/tests/test_sign_ladder.py new file mode 100644 index 00000000..24342b9f --- /dev/null +++ b/tests/test_sign_ladder.py @@ -0,0 +1,574 @@ +"""Focused binding tests and an optional-capability, offline native smoke.""" + +import asyncio +import ctypes +import gc +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import c2pa.c2pa as binding + + +_NATIVE_SIGNATURE = ctypes.CFUNCTYPE( + ctypes.c_int64, + ctypes.POINTER(binding.C2paBuilder), + ctypes.POINTER(binding.C2paSigner), + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_char_p), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), +) + + +@pytest.fixture +def ladder(monkeypatch): + # Mock retains call arguments; gc.collect() below is not a lifetime proof. + native = SimpleNamespace( + c2pa_builder_sign_ladder=Mock(), + c2pa_free=Mock(return_value=0), + ) + monkeypatch.setattr(binding, "_lib", native) + monkeypatch.setattr(binding, "_HAS_SIGN_LADDER", True) + builder = binding.Builder._wrap_native_handle( + ctypes.pointer(binding.C2paBuilder())) + signer = binding.Signer._wrap_native_handle( + ctypes.pointer(binding.C2paSigner())) + yield builder, signer, native + builder.close() + signer.close() + + +def manifest_result(native, result=4, error=None): + buffer = (ctypes.c_ubyte * 4)(65, 0, 66, 255) + + def sign(*args): + output = ctypes.cast( + args[-1], ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))) + output[0] = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + if error: + raise error + return result + + native.c2pa_builder_sign_ladder.side_effect = sign + return buffer + + +def assert_closed(builder, signer, native, manifest=None): + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + assert builder._handle is None + signer._ensure_valid_state() + builder_handle = native.c2pa_builder_sign_ladder.call_args.args[0] + expected = [ctypes.addressof(manifest)] if manifest is not None else [] + expected.append(ctypes.addressof(builder_handle.contents)) + calls = native.c2pa_free.call_args_list[:] + assert [ctypes.addressof(call.args[0].contents) for call in calls] == expected + builder.close() + assert native.c2pa_free.call_args_list == calls + with pytest.raises(binding.C2paError, match="closed"): + builder.sign_ladder(signer, ["in.mp4"], ["out.mp4"]) + native.c2pa_builder_sign_ladder.assert_called_once() + assert native.c2pa_free.call_args_list == calls + + +def test_order_utf8_marshalling_and_binary_copy(ladder): + builder, signer, native = ladder + manifest = manifest_result(native) + sign = native.c2pa_builder_sign_ladder.side_effect + builder_handle = builder._handle + signer_handle = signer._handle + + def inspect(*args): + gc.collect() + assert args[0] is builder_handle + assert args[1] is signer_handle + assert list(args[2]) == [b"z.mp4", "\u00e9.mp4".encode()] + assert list(args[3]) == [b"out-z.mp4", b"out-e.mp4"] + assert args[4] == 2 + return sign(*args) + + native.c2pa_builder_sign_ladder.side_effect = inspect + assert builder.sign_ladder( + signer, [Path("z.mp4"), "\u00e9.mp4"], + ["out-z.mp4", Path("out-e.mp4")]) == b"A\0B\xff" + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("result", [4, -1]) +def test_typed_callback_marshalling_and_cleanup(ladder, monkeypatch, result): + builder, signer, native = ladder + manifest = (ctypes.c_ubyte * 4)(65, 0, 66, 255) + calls = [] + + @_NATIVE_SIGNATURE + def sign(builder_ptr, signer_ptr, sources, dests, count, output): + gc.collect() + calls.append((ctypes.addressof(builder_ptr.contents), + ctypes.addressof(signer_ptr.contents), + [sources[i] for i in range(count)], + [dests[i] for i in range(count)], count)) + output[0] = ctypes.cast(manifest, ctypes.POINTER(ctypes.c_ubyte)) + return result + + native.c2pa_builder_sign_ladder.side_effect = sign + expected = (ctypes.addressof(builder._handle.contents), + ctypes.addressof(signer._handle.contents), + [b"z.mp4", "\u00e9.mp4".encode()], + [b"out-z.mp4", "out-\u00e9.mp4".encode()], 2) + sources = [Path("z.mp4"), "\u00e9.mp4"] + dests = ["out-z.mp4", Path("out-\u00e9.mp4")] + if result < 0: + monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: sign failed") + with pytest.raises(binding.C2paError.Io, match="sign failed"): + builder.sign_ladder(signer, sources, dests) + else: + assert builder.sign_ladder(signer, sources, dests) == b"A\0B\xff" + # Assert outside the ctypes callback, which would swallow assertion errors. + assert calls == [expected] + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("sources,dests,error", [ + ([], [], binding.C2paError), + (["a"] * 257, ["b"] * 257, binding.C2paError), + (["a"], [], binding.C2paError), + ("a", ["b"], binding.C2paError), + (["a"], "b", binding.C2paError), + (["a\0hidden"], ["b"], binding.C2paError.Encoding), + (["a"], [Path("b\0hidden")], binding.C2paError.Encoding), + (["\ud800"], ["b"], binding.C2paError.Encoding), + (["a"], ["\udfff"], binding.C2paError.Encoding), + ([b"a"], ["b"], binding.C2paError.Encoding), + ([object()], ["b"], binding.C2paError.Encoding), +]) +def test_path_preflight_preserves_builder(ladder, sources, dests, error): + builder, signer, native = ladder + with pytest.raises(error): + builder.sign_ladder(signer, sources, dests) + native.c2pa_builder_sign_ladder.assert_not_called() + native.c2pa_free.assert_not_called() + builder._ensure_valid_state() + manifest_result(native) + assert builder.sign_ladder(signer, ["a"], ["b"]) == b"A\0B\xff" + + +@pytest.mark.parametrize("kind", ["none", "object", "duck", "closed", "uninitialized"]) +def test_requires_active_explicit_signer(ladder, kind): + builder, signer, native = ladder + builder._has_context_signer = True + invalid = {"none": None, "object": object(), + "duck": SimpleNamespace(_handle=signer._handle)} + if kind == "closed": + signer.close() + invalid[kind] = signer + if kind == "uninitialized": + invalid[kind] = binding.Signer.__new__(binding.Signer) + binding.ManagedResource.__init__(invalid[kind]) + invalid[kind]._init_attrs() + with pytest.raises(binding.C2paError): + builder.sign_ladder(invalid[kind], ["a"], ["b"]) + native.c2pa_builder_sign_ladder.assert_not_called() + builder._ensure_valid_state() + + +def test_capability_preflight_preserves_builder(ladder, monkeypatch): + builder, signer, native = ladder + monkeypatch.setattr(binding, "_HAS_SIGN_LADDER", False) + with pytest.raises(binding.C2paError.NotSupported, + match="c2pa_builder_sign_ladder"): + builder.sign_ladder(signer, ["a"], ["b"]) + native.c2pa_builder_sign_ladder.assert_not_called() + native.c2pa_free.assert_not_called() + builder._ensure_valid_state() + + +@pytest.mark.parametrize("allocated", [False, True]) +def test_native_typed_error_and_cleanup(ladder, monkeypatch, allocated): + builder, signer, native = ladder + monkeypatch.setattr(binding, "_read_native_error", + lambda: "Io: cannot write destination") + manifest = None + if allocated: + manifest = manifest_result(native, result=-1) + else: + native.c2pa_builder_sign_ladder.return_value = -1 + with pytest.raises(binding.C2paError.Io, match="cannot write"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("allocated", [False, True]) +def test_call_exception_and_cleanup(ladder, allocated): + builder, signer, native = ladder + error = ctypes.ArgumentError("call failed") + manifest = None + if allocated: + manifest = manifest_result(native, error=error) + else: + native.c2pa_builder_sign_ladder.side_effect = error + with pytest.raises(binding.C2paError, match="call failed") as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value.__cause__ is error + assert_closed(builder, signer, native, manifest) + + +def test_copy_error_is_not_success(ladder, monkeypatch): + builder, signer, native = ladder + manifest = manifest_result(native) + error = MemoryError("copy failed") + # ctypes is shared process-wide; limit the patch to this mocked call. + with monkeypatch.context() as patch: + patch.setattr(binding.ctypes, "string_at", Mock(side_effect=error)) + with pytest.raises(binding.C2paError, match="copy failed") as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value.__cause__ is error + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("result,allocated", [(0, False), (0, True), (4, False)]) +def test_missing_manifest_is_not_success(ladder, result, allocated): + builder, signer, native = ladder + manifest = None + if allocated: + manifest = manifest_result(native, result=result) + else: + native.c2pa_builder_sign_ladder.return_value = result + with pytest.raises(binding.C2paError, match="no manifest bytes"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("result", [4, -1]) +def test_free_error_still_closes_builder(ladder, caplog, monkeypatch, result): + builder, signer, native = ladder + manifest = manifest_result(native, result=result) + + def free(pointer): + if ctypes.addressof(pointer.contents) == ctypes.addressof(manifest): + raise RuntimeError("free failed") + return 0 + + native.c2pa_free.side_effect = free + if result < 0: + monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: sign failed") + with pytest.raises(binding.C2paError.Io, match="sign failed"): + builder.sign_ladder(signer, ["a"], ["b"]) + else: + assert builder.sign_ladder(signer, ["a"], ["b"]) == b"A\0B\xff" + assert "Failed to release native manifest bytes memory" in caplog.text + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("mode", ["-O", "-OO", "PYTHONOPTIMIZE"]) +def test_native_harness_refuses_optimized_python(mode): + env = os.environ.copy() + env.pop("PYTHONOPTIMIZE", None) + command = [sys.executable] + if mode == "PYTHONOPTIMIZE": + env[mode] = "1" + else: + command.append(mode) + command.append(str(Path(__file__).with_name("ladder_native.py"))) + result = subprocess.run(command, env=env, capture_output=True, text=True) + assert result.returncode != 0 + assert "requires assertions" in result.stderr + assert "rerun without -O/-OO or PYTHONOPTIMIZE" in result.stderr + + +def test_real_native_ladder_signs_and_validates(tmp_path): + fixtures = Path(__file__).parent / "fixtures" + fixture = fixtures / "single-file-fragmented" / "single_file_fragments.mp4" + original = fixture.read_bytes() # Missing committed test data is never a skip. + if not binding._HAS_SIGN_LADDER: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 but the loaded native library " + "lacks c2pa_builder_sign_ladder") + pytest.skip("native library lacks c2pa_builder_sign_ladder") + + export = binding._lib.c2pa_builder_sign_ladder + assert export.restype is _NATIVE_SIGNATURE._restype_ + assert tuple(export.argtypes) == _NATIVE_SIGNATURE._argtypes_ + + # Two copies exercise the ordered multi-file API, not different encodes. + sources = [tmp_path / f"copy-{i}.mp4" for i in range(2)] + dests = [tmp_path / f"signed-{i}.mp4" for i in range(2)] + for source in sources: + shutil.copy2(fixture, source) + definition = { + "claim_generator_info": [{"name": "ladder-binding-test"}], + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], + } + info = binding.C2paSignerInfo( + alg=b"es256", sign_cert=(fixtures / "es256_certs.pem").read_bytes(), + private_key=(fixtures / "es256_private.key").read_bytes(), ta_url=None) + with binding.Signer.from_info(info) as signer: + with binding.Builder(definition) as builder: + with pytest.raises(binding.C2paError, match="1 to 256"): + builder.sign_ladder(signer, [], []) + builder._ensure_valid_state() + manifest = builder.sign_ladder(signer, sources, dests) + assert manifest + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + assert builder._handle is None + signer._ensure_valid_state() + + manifests = [] + for dest in dests: + assert manifest in dest.read_bytes(), "returned manifest not embedded byte-for-byte" + with binding.Reader(dest) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + report = json.loads(reader.json()) + manifests.append(report["manifests"][report["active_manifest"]]) + assert manifests[0] == manifests[1] + assert [source.read_bytes() for source in sources] == [original, original] + assert fixture.read_bytes() == original + + +class _DynamicAssertionFailure(RuntimeError): + pass + + +def _callback_state(): + state = threading.local() + state.exception = None + return state + + +_BASE_EXCEPTIONS = [ + pytest.param(lambda: _DynamicAssertionFailure("assertion failed"), id="RuntimeError"), + pytest.param(lambda: KeyboardInterrupt("operator interrupt"), id="KeyboardInterrupt"), + pytest.param(lambda: SystemExit(3), id="SystemExit"), + pytest.param(lambda: asyncio.CancelledError("worker cancelled"), id="CancelledError"), +] + + +@pytest.mark.parametrize("make_error", _BASE_EXCEPTIONS) +def test_dynamic_assertion_error_keeps_identity_and_cleans_up(ladder, monkeypatch, make_error): + builder, signer, native = ladder + error = make_error() + state = _callback_state() + pinned = object() + signer._dynamic_assertion_cbs.append((pinned, state, pinned)) + manifest = manifest_result(native, result=-1) + sign = native.c2pa_builder_sign_ladder.side_effect + + def failing(*args): + state.exception = error + return sign(*args) + + native.c2pa_builder_sign_ladder.side_effect = failing + monkeypatch.setattr(binding, "_read_native_error", lambda: "Other: callback failed") + with pytest.raises(BaseException) as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value is error + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("make_error,propagates", [ + (lambda: _DynamicAssertionFailure("claim callback failed"), False), + (lambda: KeyboardInterrupt("operator interrupt"), True), + (lambda: SystemExit(3), True), + (lambda: asyncio.CancelledError("worker cancelled"), True), +]) +def test_claim_signer_interrupts_propagate_ordinary_errors_stay_typed( + ladder, monkeypatch, make_error, propagates): + builder, signer, native = ladder + error = make_error() + state = _callback_state() + signer._callback_cb = SimpleNamespace(_error_state=state) + + def failing(*_args): + state.exception = error + return -1 + + native.c2pa_builder_sign_ladder.side_effect = failing + monkeypatch.setattr(binding, "_read_native_error", lambda: "Signature: claim signer failed") + if propagates: + with pytest.raises(BaseException) as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value is error + else: + with pytest.raises(binding.C2paError.Signature, match="claim signer failed"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert_closed(builder, signer, native) + + +def test_stale_callback_errors_are_cleared_before_native_call(ladder, monkeypatch): + builder, signer, native = ladder + da_state = _callback_state() + claim_state = _callback_state() + da_state.exception = _DynamicAssertionFailure("stale assertion failure") + claim_state.exception = KeyboardInterrupt("stale interrupt") + signer._dynamic_assertion_cbs.append((object(), da_state, object())) + signer._callback_cb = SimpleNamespace(_error_state=claim_state) + seen = [] + + def failing(*_args): + seen.append((da_state.exception, claim_state.exception)) + return -1 + + native.c2pa_builder_sign_ladder.side_effect = failing + monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: never entered callbacks") + with pytest.raises(binding.C2paError.Io, match="never entered callbacks"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert seen == [(None, None)] + assert_closed(builder, signer, native) + + +def test_callback_error_survives_manifest_free_failure(ladder, caplog, monkeypatch): + builder, signer, native = ladder + error = _DynamicAssertionFailure("assertion failed") + state = _callback_state() + signer._dynamic_assertion_cbs.append((object(), state, object())) + manifest = manifest_result(native, result=-1) + sign = native.c2pa_builder_sign_ladder.side_effect + + def failing(*args): + state.exception = error + return sign(*args) + + def free(pointer): + if ctypes.addressof(pointer.contents) == ctypes.addressof(manifest): + raise RuntimeError("free failed") + return 0 + + native.c2pa_builder_sign_ladder.side_effect = failing + native.c2pa_free.side_effect = free + with pytest.raises(_DynamicAssertionFailure) as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value is error + assert "Failed to release native manifest bytes memory" in caplog.text + assert_closed(builder, signer, native, manifest) + + +def _cbor2(): + # Only the real-native DynamicAssertion test needs cbor2; build.yml's + # installed-wheel jobs run this file without test-only dependencies. + try: + import cbor2 + except ImportError: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 requires cbor2") + pytest.skip("cbor2 is not installed") + return cbor2 + + +def _exact_size_cbor(size, label): + cbor2 = _cbor2() + for pad in range(size): + encoded = cbor2.dumps({"note": label, "pad": "x" * pad}) + if len(encoded) == size: + return encoded + if len(encoded) > size: + break + raise AssertionError(f"cannot encode exactly {size} bytes") + + +def _require_real_ladder_with_dynamic_assertions(): + if not binding._HAS_SIGN_LADDER: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 but the loaded native library " + "lacks c2pa_builder_sign_ladder") + pytest.skip("native library lacks c2pa_builder_sign_ladder") + if not binding.has_dynamic_assertions(): + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 requires dynamic assertions") + pytest.skip("native library lacks dynamic assertions") + + +def _ladder_inputs(tmp_path, count=2): + fixture = (Path(__file__).parent / "fixtures" / "single-file-fragmented" + / "single_file_fragments.mp4") + sources = [tmp_path / f"copy-{i}.mp4" for i in range(count)] + dests = [tmp_path / f"signed-{i}.mp4" for i in range(count)] + for source in sources: + shutil.copy2(fixture, source) + return sources, dests + + +def _real_signer(): + fixtures = Path(__file__).parent / "fixtures" + return binding.Signer.from_info(binding.C2paSignerInfo( + alg=b"es256", sign_cert=(fixtures / "es256_certs.pem").read_bytes(), + private_key=(fixtures / "es256_private.key").read_bytes(), ta_url=None)) + + +_LADDER_DEFINITION = { + "claim_generator_info": [{"name": "ladder-binding-test"}], + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], +} + + +def test_real_native_ladder_includes_exact_size_dynamic_assertion(tmp_path): + _require_real_ladder_with_dynamic_assertions() + sources, dests = _ladder_inputs(tmp_path) + label = "com.example.ladder" + reserve = 96 + content = _exact_size_cbor(reserve, "ladder dynamic assertion") + calls = [] + + def callback(callback_label, reserve_size, partial_claim): + calls.append((callback_label, reserve_size, partial_claim)) + return content + + with _real_signer() as signer: + signer.add_dynamic_assertion(callback, label=label, reserve_size=reserve) + del callback + gc.collect() # Registration, not the local name, must pin the callback. + with binding.Builder(_LADDER_DEFINITION) as builder: + manifest = builder.sign_ladder(signer, sources, dests) + signer._ensure_valid_state() + + assert len(calls) == 1, "one shared manifest invokes the assertion once" + callback_label, reserve_size, partial_claim = calls[0] + assert (callback_label, reserve_size) == (label, reserve) + urls = [entry["url"] for entry in partial_claim] + # The dynamic assertion endorses the rendition hard binding it is signed with. + assert any("c2pa.hash.bmff" in url for url in urls), urls + assert all({"url", "alg", "hash"} <= set(entry) for entry in partial_claim) + + manifests = [] + for dest in dests: + assert manifest in dest.read_bytes() + with binding.Reader(dest) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + report = json.loads(reader.json()) + active = report["manifests"][report["active_manifest"]] + manifests.append(active) + dynamic = [a for a in active["assertions"] if a["label"] == label] + assert [a["data"] for a in dynamic] == [_cbor2().loads(content)] + assert manifests[0] == manifests[1] + + +@pytest.mark.parametrize("make_error", _BASE_EXCEPTIONS) +def test_real_native_ladder_reraises_dynamic_assertion_exception(tmp_path, make_error): + _require_real_ladder_with_dynamic_assertions() + sources, dests = _ladder_inputs(tmp_path, count=1) + error = make_error() + + def callback(*_args): + raise error + + with _real_signer() as signer: + signer.add_dynamic_assertion(callback, label="com.example.ladder", reserve_size=64) + builder = binding.Builder(_LADDER_DEFINITION) + with pytest.raises(BaseException) as caught: + builder.sign_ladder(signer, sources, dests) + assert caught.value is error + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + signer._ensure_valid_state() + assert signer._dynamic_assertion_cbs[0][1].exception is error diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py new file mode 100644 index 00000000..b14c1563 --- /dev/null +++ b/tests/test_trusted_vsi_api.py @@ -0,0 +1,1847 @@ +"""Trusted VSI Python bindings. + +Tests named ``paired`` require the complete functional native library and FAIL +(never skip or pass) under C2PA_TRUSTED_VSI_ABI_REQUIRED=1. Legacy dev5 jobs +select only ``not paired``. Non-paired tests exercise Python gating and +marshalling against scripted native entry points; they are not functional +native qualification. +""" + +import asyncio +import ctypes +from dataclasses import FrozenInstanceError +import gc +import hashlib +import inspect +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import weakref +from unittest.mock import Mock + +import cbor2 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature +import pytest + +import c2pa +import c2pa.c2pa as bindings + + +FIXTURES = Path(__file__).parent / "fixtures" +IAT = 1789041600 +OP = c2pa.TrustedVsiOperation +KIND = c2pa.TrustedVsiInputKind +PROBES = [getattr(c2pa, name) for name in ( + "has_live_video_trusted_vsi_split_init", "has_live_video_trusted_vsi_expert_sig_structure", + "has_live_video_trusted_vsi_composed_emsg", "has_live_video_trusted_vsi_recovery", + "has_live_video_trusted_vsi_signing_context_v1", "has_live_video_trusted_vsi_full_uint32_exhaustion", +)] + + +def _fixture_claim_signature(data): + """Healthy ES256 claim-signer callback for the es256 fixture certificate.""" + key = serialization.load_pem_private_key( + (FIXTURES / "es256_private.key").read_bytes(), password=None) + return key.sign(data, ec.ECDSA(hashes.SHA256())) + + +def _exact_dynamic_assertion(label, reserve_size, partial_claim): + """Canonical CBOR {"pad": h'58..'} of exactly the 64 reserved bytes.""" + assert reserve_size == 64 + return b"\xa1\x63pad\x58\x39" + b"X" * 57 + + +# BaseException subclasses that are not Exception must also be stored by the +# ctypes wrappers and re-raised with identity; escaping into ctypes loses them. +CALLBACK_FAILURES = [ + pytest.param(lambda: RuntimeError("provider failure"), id="RuntimeError"), + pytest.param(lambda: KeyboardInterrupt("operator interrupt"), id="KeyboardInterrupt"), + pytest.param(lambda: SystemExit(3), id="SystemExit"), + pytest.param(lambda: asyncio.CancelledError("worker cancelled"), id="CancelledError"), +] + + +def test_unshipped_counter_result_and_recovery_are_removed(): + assert not hasattr(c2pa, "TrustedVsiSignResult") + assert not hasattr(bindings, "TrustedVsiSignResult") + assert not hasattr(c2pa.TrustedVsiSession, "recover") + assert not hasattr(bindings, "_TRUSTED_VSI_PYTHON_API_ENABLED") + signature = inspect.signature(c2pa.TrustedVsiSession.sign_sig_structure) + assert list(signature.parameters) == ["self", "sig_structure", "sequence_number"] + assert signature.return_annotation is bytes + for module in (c2pa, bindings): + assert all(hasattr(module, name) for name in module.__all__) + + +@pytest.mark.parametrize("mask", [0, 1, 31, 62, 64, 127]) +def test_partial_or_unknown_capability_mask_never_advertises_functionality(monkeypatch, mask): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", mask) + assert not any(probe() for probe in PROBES) + + +@pytest.mark.parametrize("missing", ["_TRUSTED_VSI_ABI_AVAILABLE", "_TRUSTED_VSI_VERSION_MATCHES"]) +def test_missing_symbols_or_wrong_native_version_fail_closed(monkeypatch, missing): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) + monkeypatch.setattr(bindings, missing, False) + assert not any(probe() for probe in PROBES) + + +def test_paired_native_version_is_exact_consolidated_release_line(): + # This release line is necessary but does not identify the trusted contract. + assert bindings._TRUSTED_VSI_NATIVE_VERSION == "0.92.0-dev" + assert bindings._TRUSTED_VSI_REQUIRED_CONTRACT_REVISION == 3 + + +@pytest.mark.parametrize("native_version, expected", [ + (b"c2pa-c-ffi/0.92.0-dev c2pa-rs/0.92.0-dev", True), + (b"c2pa-rs/0.92.0-dev", True), + (b"c2pa-c-ffi/0.91.0-dev c2pa-rs/0.91.0-dev", False), + (b"c2pa-c-ffi/0.92.0 c2pa-rs/0.92.0", False), + (b"c2pa-c-ffi/0.92.0-dev c2pa-rs/0.92.0-dev.1", False), + (b"c2pa-c-ffi/0.92.0-dev c2pa-rs/0.93.0-dev", False), + (b"c2pa-c-ffi/0.92.0-dev", False), + (b"xc2pa-rs/0.92.0-dev", False), + (b"", False), +]) +def test_native_version_gate_accepts_only_exact_paired_token(native_version, expected): + assert bindings._trusted_vsi_version_matches(native_version) is expected + + +@pytest.fixture(params=[ + ("_TRUSTED_VSI_CONTRACT_REVISION", revision) for revision in (0, 1, 2, 4) +] + [ + ("_TRUSTED_VSI_ABI_AVAILABLE", False), + ("_TRUSTED_VSI_VERSION_MATCHES", False), + ("_TRUSTED_VSI_CAPABILITIES", 0), + ("_TRUSTED_VSI_CAPABILITIES", 62), + ("_TRUSTED_VSI_CAPABILITIES", 127), +]) +def disabled_gate(monkeypatch, request): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) + monkeypatch.setattr(bindings, *request.param) + assert not any(probe() for probe in PROBES) + + +@pytest.mark.parametrize("factory", [c2pa.TrustedVsiSession, c2pa.TrustedVsiSession.from_callback]) +def test_disabled_constructor_gates_before_arguments_callbacks_or_bookkeeping(monkeypatch, disabled_gate, factory): + forbidden = Mock(side_effect=AssertionError("side effect before capability gate")) + monkeypatch.setattr(bindings.ManagedResource, "__init__", forbidden) + monkeypatch.setattr(bindings, "_lib", forbidden) + args = [object() for _ in range(9)] + with pytest.raises(c2pa.C2paError.NotSupported, match="Functional trusted VSI"): + factory(*args, mode=object(), reservation_nonce=object(), signing_time_unix_seconds=object()) + forbidden.assert_not_called() + assert forbidden.mock_calls == [] + + +def test_value_wrappers_are_frozen_and_v1_layouts_exact(): + context = c2pa.VsiSigningContextV1("vsi", 2**32 - 1) + with pytest.raises(FrozenInstanceError): + context.sequence_number = 0 + native = bindings.C2paLiveVideoTrustedVsiSigningContextV1 + assert ctypes.sizeof(native) == 20 and ctypes.alignment(native) == 4 + assert [getattr(native, name).offset for name, _ in native._fields_] == [0, 4, 8, 12, 16, 17] + status = bindings.C2paLiveVideoTrustedVsiStatusV1 + assert ctypes.sizeof(status) == 24 and ctypes.alignment(status) == 4 + assert status._fields_ == [ + ("init_uuid_committed", ctypes.c_bool), ("init_uuid_pending", ctypes.c_bool), + ("media_emsg_pending", ctypes.c_bool), ("has_next_sequence_number", ctypes.c_bool), + ("next_sequence_number", ctypes.c_uint32), ("has_next_event_id", ctypes.c_bool), + ("next_event_id", ctypes.c_uint32), ("exhausted", ctypes.c_bool), + ("has_exhaustion_reason", ctypes.c_bool), ("blocked", ctypes.c_bool), + ("exhaustion_reason", ctypes.c_uint32)] + assert [getattr(status, name).offset for name, _ in status._fields_] == [0, 1, 2, 3, 4, 8, 12, 16, 17, 18, 20] + + +def test_python_mapping_matches_native_contract_signatures(): + expected = ["context", "manifest_json", "algorithm", "public_cose_key", "kid", + "min_sequence_number", "created_at", "validity_period_secs", "callback", + "mode", "reservation_nonce", "signing_time_unix_seconds", "sequence_max"] + for factory in (c2pa.TrustedVsiSession.from_callback, c2pa.TrustedVsiSession.__init__): + names = [n for n in inspect.signature(factory).parameters if n not in ("self", "cls")] + assert names == expected + params = inspect.signature(factory).parameters + assert all(params[n].kind is inspect.Parameter.KEYWORD_ONLY for n in expected[9:]) + session = c2pa.TrustedVsiSession + assert inspect.signature(session.reserve_init_uuid).return_annotation is bytes + assert list(inspect.signature(session.reserve_media_emsg_at).parameters) == [ + "self", "sequence_number", "signing_time_unix_seconds", "timescale", "event_duration"] + assert list(inspect.signature(session.preflight).parameters) == [ + "self", "operation", "data", "sequence_number", "iat", "timescale", "event_duration", "format"] + for name in ("export_state", "import_state", "status", "reserved_manifest_id", + "finalize_init_uuid", "commit_init_uuid", "finalize_media_emsg"): + assert callable(getattr(session, name)) + assert [m.value for m in c2pa.TrustedVsiOperation] == list(range(6)) + assert [m.value for m in c2pa.TrustedVsiInputKind] == [0, 1, 2] + assert not hasattr(c2pa, "TrustedVsiInitUuidReservation") + assert not hasattr(c2pa, "TrustedVsiPrehashedSession") + + +@pytest.mark.parametrize("function,args", [ + ("validate_trusted_vsi_input", (object(), object(), object())), + ("trusted_vsi_hash_template", (object(),)), +]) +def test_static_helpers_gate_before_arguments_or_native(monkeypatch, disabled_gate, function, args): + forbidden = Mock(side_effect=AssertionError("native touched")) + monkeypatch.setattr(bindings, "_lib", forbidden) + with pytest.raises(c2pa.C2paError.NotSupported): + getattr(c2pa, function)(*args) + forbidden.assert_not_called() + + +@pytest.mark.parametrize("revision,version,mask,missing", [ + (revision, "0.92.0-dev", 63, None) for revision in (None, 0, 1, 2, 3, 4) +] + [ + (3, "0.91.0", 63, None), + (3, "0.92.0-dev", 63, "c2pa_live_video_trusted_vsi_session_status_v1"), + (3, "0.92.0-dev", 63, "c2pa_live_video_trusted_vsi_capabilities"), +] + [ + (3, "0.92.0-dev", mask, None) for mask in (0, 1, 31, 62, 64, 127) +]) +def test_raw_loader_contract_gate_precedes_operational_ffi(revision, version, mask, missing): + """A fresh import must not bind/call old ABI even with matching names/mask.""" + program = r''' +import ctypes +import json +from pathlib import Path +import sys + +revision, version, mask, missing = json.loads(sys.argv[1]) +prefix = "c2pa_live_video_trusted_vsi_" +probe_names = {prefix + "contract_revision", prefix + "capabilities"} +events = [] +version_buffer = ctypes.create_string_buffer(("c2pa-rs/" + version).encode()) + +class Function: + def __init__(self, name): + self.name = name + + def __setattr__(self, name, value): + if name in ("argtypes", "restype"): + events.append(("bind", self.name, name)) + object.__setattr__(self, name, value) + + def __call__(self, *args): + events.append(("call", self.name)) + if self.name in probe_names: + assert args == () and self.argtypes == [] + assert self.restype is (ctypes.c_uint32 if self.name.endswith("revision") else ctypes.c_uint64) + return revision if self.name.endswith("revision") else mask + if self.name == "c2pa_version": + return ctypes.addressof(version_buffer) + if self.name == "c2pa_string_free": + return None + raise AssertionError("unsafe native call: " + self.name) + +class Library: + def __getattr__(self, name): + if name == missing or (name == prefix + "contract_revision" and revision is None): + raise AttributeError(name) + function = Function(name) + setattr(self, name, function) + return function + +library = Library() +ctypes.CDLL = lambda *args, **kwargs: library +import c2pa +import c2pa.c2pa as bindings +assert Path(bindings.__file__).resolve() == Path(sys.argv[2]).resolve() + +accepted = revision == 3 and version == "0.92.0-dev" and mask == 63 and missing is None +probes = [getattr(c2pa, name) for name in c2pa.__all__ if name.startswith("has_live_video_trusted_vsi_")] +assert len(probes) == 6 and [probe() for probe in probes] == [accepted] * 6 +assert bindings._TRUSTED_VSI_ABI_AVAILABLE is accepted +assert bindings._TRUSTED_VSI_CONTRACT_REVISION == (revision or 0) +operational = set(bindings._TRUSTED_VSI_FUNCTIONS) - probe_names +for name in operational - {missing}: + assert ("argtypes" in vars(getattr(library, name))) is accepted, name + assert ("restype" in vars(getattr(library, name))) is accepted, name +if accepted: + revision_call = events.index(("call", prefix + "contract_revision")) + version_call = events.index(("call", "c2pa_version")) + mask_call = events.index(("call", prefix + "capabilities")) + assert revision_call < version_call < mask_call + assert all(mask_call < i for i, event in enumerate(events) + if event[0] == "bind" and event[1] in operational) +else: + def forbidden(*args, **kwargs): + raise AssertionError("argument processing/buffer/callback/bookkeeping before gate") + bindings.ManagedResource.__init__ = forbidden + bindings.ctypes.create_string_buffer = forbidden + bindings.TrustedVsiSignCallbackV1 = forbidden + bindings._trusted_vsi_algorithm = forbidden + for factory in (c2pa.TrustedVsiSession, c2pa.TrustedVsiSession.from_callback): + try: + factory(*[object() for _ in range(9)], mode=object(), + reservation_nonce=object(), signing_time_unix_seconds=object()) + except c2pa.C2paError.NotSupported: + pass + else: + raise AssertionError("constructor accepted rejected contract") + for function, args in ((c2pa.validate_trusted_vsi_input, (object(), object(), object())), + (c2pa.trusted_vsi_hash_template, (object(),))): + try: + function(*args) + except c2pa.C2paError.NotSupported: + pass + else: + raise AssertionError("helper accepted rejected contract") +assert not any(event[0] == "call" and event[1] in operational for event in events) +assert c2pa.has_live_video_vsi() and c2pa.has_live_video_vsi_callbacks() +assert library.c2pa_live_video_vsi_signer_create_callback.argtypes +assert library.c2pa_builder_sign.argtypes +print("raw loader contract gate verified") +''' + env = os.environ.copy() + # Keep raw-loader checks on the parent's selected source or installed package. + env["PYTHONPATH"] = str(Path(bindings.__file__).resolve().parent.parent) + env["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run( + [sys.executable, "-c", program, json.dumps([revision, version, mask, missing]), bindings.__file__], + cwd=Path(__file__).resolve().parents[1], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert "raw loader contract gate verified" in result.stdout + + +class _ScriptedNative: + """Records calls to scripted trusted-VSI entry points installed on _lib.""" + + def __init__(self, monkeypatch): + self.calls = [] + self.freed = [] + self.buffers = [] + self.callback = None + self.handle = ctypes.cast(ctypes.pointer(ctypes.c_int(7)), + ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession)) + self._keep = self.handle._objects + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) + real_free = bindings.ManagedResource._free_native_ptr + + def free(ptr): + # Record scripted pointers only; real native handles (e.g. the + # caller's Context) are released by the real c2pa_free. + address = ctypes.addressof(ptr.contents) + scripted = {ctypes.addressof(b) for b in self.buffers} + scripted.add(ctypes.addressof(self.handle.contents)) + if address in scripted: + self.freed.append(address) + return 0 + return real_free(ptr) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + for name in bindings._TRUSTED_VSI_FUNCTIONS: + monkeypatch.setattr(bindings._lib, name, self._unexpected(name), raising=False) + + def _unexpected(self, name): + def call(*args): + raise AssertionError(f"unexpected native call {name}") + return call + + def install(self, monkeypatch, name, function): + def recorded(*args): + self.calls.append((name, args)) + return function(*args) + monkeypatch.setattr(bindings._lib, "c2pa_live_video_trusted_vsi_" + name, recorded) + + def output(self, output_arg, data): + buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) + self.buffers.append(buffer) + target = ctypes.cast(output_arg, ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))) + assert not target[0], "output must be initialized to NULL" + target[0] = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + return len(data) + + def invoke(self, purpose=1, sequence=5, has_sequence=True, event=0, has_event=False, + exhaust=False, data=b"tbs"): + context = bindings.C2paLiveVideoTrustedVsiSigningContextV1( + purpose, sequence, has_sequence, event, has_event, exhaust) + tbs = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) + signature = (ctypes.c_ubyte * 64)() + result = self.callback(None, ctypes.pointer(context), tbs, len(data), signature, 64) + return result, bytes(signature) + + +def test_scripted_static_helpers_use_current_contract(monkeypatch): + native = _ScriptedNative(monkeypatch) + + def validate(kind, algorithm, data, length): + assert (kind, algorithm, ctypes.string_at(data, length)) == ( + KIND.SIG_STRUCTURE, c2pa.C2paSigningAlg.ED25519, b"exact-input") + return 0 + + native.install(monkeypatch, "validate_input", validate) + native.install(monkeypatch, "hash_template", lambda kind, output: native.output(output, b"template")) + assert c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", b"exact-input") is None + assert c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) == b"template" + assert [name for name, args in native.calls] == ["validate_input", "hash_template"] + assert len(native.freed) == 1 + + +@pytest.fixture +def scripted(monkeypatch): + native = _ScriptedNative(monkeypatch) + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + context = c2pa.Context(signer=signer) + calls = [] + behaviour = {"result": lambda ctx, data: b"S" * 64} + + def callback(ctx, data): + calls.append((ctx, data)) + return behaviour["result"](ctx, data) + + def create(*args): + native.callback = args[-1] + return native.handle + native.install(monkeypatch, "session_create_callback_v1", create) + session = c2pa.TrustedVsiSession( + context, {"format": "video/mp4"}, "es256", b"\xa1\x01\x02", b"kid", 3, + "2026-09-10T00:00:00Z", 86400, callback, mode="expert_sig_structure", + reservation_nonce="0" * 32, signing_time_unix_seconds=IAT, sequence_max=9) + yield native, session, context, calls, behaviour + session.close() + context.close() + + +def test_scripted_constructor_marshals_exact_order_and_options(scripted): + native, session, context, calls, _ = scripted + name, args = native.calls[0] + assert name == "session_create_callback_v1" and len(args) == 13 + assert args[0] is not None and args[1] == b'{"format": "video/mp4"}' + assert args[2] == c2pa.C2paSigningAlg.ES256 + assert bytes(args[3]) == b"\xa1\x01\x02" and args[4] == 3 + assert bytes(args[5]) == b"kid" and args[6] == 3 + assert args[7:10] == (3, b"2026-09-10T00:00:00Z", 86400) + assert json.loads(args[10]) == {"mode": "expert_sig_structure", "reservation_nonce": "0" * 32, + "signing_time_unix_seconds": IAT, "sequence_max": 9} + assert args[11] is None and isinstance(args[12], bindings.TrustedVsiSignCallbackV1) + assert calls == [], "construction must not sign" + assert session.is_valid + + +def test_scripted_expert_sign_supplied_sequence_bytes_and_single_free(monkeypatch, scripted): + native, session, _, calls, _ = scripted + def sign(handle, data, length, sequence, output): + assert ctypes.addressof(handle.contents) == ctypes.addressof(native.handle.contents) + assert ctypes.string_at(data, length) == b"exact-sig-structure" + result, signature = native.invoke(sequence=sequence, data=ctypes.string_at(data, length)) + assert result == 64 and signature == b"S" * 64 + return native.output(output, signature) + native.install(monkeypatch, "session_sign_sig_structure", sign) + for sequence in (9, 3, 2**32 - 1, 3): + assert session.sign_sig_structure(b"exact-sig-structure", sequence) == b"S" * 64 + assert calls[-1] == (c2pa.VsiSigningContextV1("vsi", sequence), b"exact-sig-structure") + assert [call[1][3] for call in native.calls[1:]] == [9, 3, 2**32 - 1, 3] + assert len(native.freed) == 4 + for bad, error in ((-1, ValueError), (2**32, ValueError), (True, TypeError), ("1", TypeError)): + with pytest.raises(error): + session.sign_sig_structure(b"x", bad) + with pytest.raises(TypeError): + session.sign_sig_structure(bytearray(b"x"), 1) + assert len(native.calls) == 5 + + +@pytest.mark.parametrize("result,error", [ + (lambda ctx, data: b"short", ValueError), + (lambda ctx, data: bytearray(64), TypeError), + (None, RuntimeError), +]) +def test_scripted_callback_errors_keep_identity_and_free_nothing(monkeypatch, scripted, result, error): + native, session, _, _, behaviour = scripted + failure = RuntimeError("provider unavailable") + def raising(ctx, data): + raise failure + behaviour["result"] = result or raising + def sign(handle, data, length, sequence, output): + assert native.invoke(sequence=sequence)[0] == -1 + return -1 + native.install(monkeypatch, "session_sign_sig_structure", sign) + with pytest.raises(error) as caught: + session.sign_sig_structure(b"x", 5) + if result is None: + assert caught.value is failure + assert native.freed == [] + + +@pytest.mark.parametrize("native_context,valid", [ + ((0, 0, False, 0, False, False), True), + ((0, 5, True, 0, False, False), False), + ((1, 0, False, 0, False, False), False), + ((2, 1, True, 0, False, False), False), +]) +def test_scripted_callback_context_validation(scripted, native_context, valid): + native, _, _, calls, _ = scripted + purpose, sequence, has_sequence, event, has_event, exhaust = native_context + result, _ = native.invoke(purpose, sequence, has_sequence, event, has_event, exhaust) + assert (result == 64) is valid + assert bool(calls) is valid + if valid: + assert calls[0][0] == c2pa.VsiSigningContextV1("signer_binding") + + +def test_scripted_native_error_without_callback_is_typed_and_output_absent(monkeypatch, scripted): + native, session, _, calls, _ = scripted + native.install(monkeypatch, "session_sign_sig_structure", lambda *args: -1) + monkeypatch.setattr(bindings, "_read_native_error", lambda: "NotSupported: mode-pinned") + with pytest.raises(c2pa.C2paError.NotSupported): + session.sign_sig_structure(b"x", 5) + assert calls == [] and native.freed == [] + + +def test_scripted_media_reservation_status_preflight_and_state(monkeypatch, scripted): + native, session, _, _, _ = scripted + def reserve(handle, sequence, iat, timescale, duration, output, context): + assert (sequence, iat, timescale, duration) == (4, IAT, 1000, 2000) + target = ctypes.cast(context, ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSigningContextV1)) + target[0] = bindings.C2paLiveVideoTrustedVsiSigningContextV1(1, 4, True, 1, True, True) + return native.output(output, b"emsg-box") + native.install(monkeypatch, "session_reserve_media_emsg", reserve) + reservation = session.reserve_media_emsg_at(4, IAT, 1000, 2000) + assert reservation.placeholder_emsg_box == b"emsg-box" + assert (reservation.sequence_number, reservation.event_id) == (4, 1) + assert reservation.signing_context.exhaust_after_sign is True + for args in ((4, IAT, 0, 1), (4, IAT, 1, 0), (2**32, IAT, 1, 1), (4, 2**63, 1, 1)): + with pytest.raises(ValueError): + session.reserve_media_emsg_at(*args) + + def status(handle, output): + target = ctypes.cast(output, ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiStatusV1)) + target[0] = bindings.C2paLiveVideoTrustedVsiStatusV1( + True, False, False, True, 9, True, 2, True, True, False, 2) + return 0 + native.install(monkeypatch, "session_status_v1", status) + assert session.status() == c2pa.TrustedVsiStatus(True, False, False, 9, 2, True, "event_id_max") + + def preflight(handle, operation, data, length, sequence, iat, timescale, duration, format): + assert (operation, data, length, format) == (OP.RESERVE_INIT, None, 0, b"video/mp4") + return 0 + native.install(monkeypatch, "session_preflight", preflight) + assert session.preflight(OP.RESERVE_INIT) is None + with pytest.raises(ValueError): + session.preflight(6) + + native.install(monkeypatch, "session_export_state", lambda handle, output: native.output(output, b'{"v":1}')) + native.install(monkeypatch, "session_import_state", + lambda handle, data, length: 0 if ctypes.string_at(data, length) == b'{"v":1}' else -1) + state = session.export_state() + assert state == b'{"v":1}' + session.import_state(state) + freed = len(native.freed) + assert freed == 2 # reservation + exported state; import borrows input + + +@pytest.mark.parametrize("kwargs,error", [ + ({"mode": "expert"}, ValueError), + ({"mode": "composed"}, ValueError), + ({"reservation_nonce": "A" * 32}, ValueError), + ({"reservation_nonce": "0" * 31}, ValueError), + ({"signing_time_unix_seconds": 1.5}, TypeError), + ({"sequence_max": 2}, ValueError), + ({"sequence_max": 2**32}, ValueError), +]) +def test_scripted_constructor_rejects_bad_options_before_native(monkeypatch, scripted, kwargs, error): + native, _, context, _, _ = scripted + count = len(native.calls) + options = dict(mode="expert_sig_structure", reservation_nonce="0" * 32, + signing_time_unix_seconds=IAT, sequence_max=None) + options.update(kwargs) + with pytest.raises(error): + c2pa.TrustedVsiSession(context, {"format": "video/mp4"}, "es256", b"k", b"kid", 3, + "2026-09-10T00:00:00Z", 86400, lambda *a: b"", **options) + assert len(native.calls) == count + + +def test_scripted_session_pins_callbacks_after_caller_context_close(monkeypatch, scripted): + native, session, context, calls, _ = scripted + context.close() + gc.collect() + assert session._context is context and session._trusted_vsi_callback is not None + assert native.invoke()[0] == 64 and len(calls) == 1 + session.close() + session.close() + assert session._trusted_vsi_callback is None and not session.is_valid + with pytest.raises(c2pa.C2paError): + session.export_state() + + +@pytest.mark.parametrize("which", ["claim", "dynamic", "vsi"]) +@pytest.mark.parametrize("make_error", CALLBACK_FAILURES) +def test_scripted_wrappers_store_base_exceptions_return_minus_one_and_reraise( + monkeypatch, which, make_error): + """Real ctypes wrappers + real session plumbing, scripted native calls.""" + error = make_error() + + def fail(*args): + raise error + certs = (FIXTURES / "es256_certs.pem").read_bytes() + if which == "claim": + signer = c2pa.Signer.from_callback(fail, c2pa.C2paSigningAlg.ES256, certs, None) + else: + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + if which == "dynamic": + if not c2pa.has_dynamic_assertions(): + message = "scripted dynamic wrapper needs native registration export" + if _qualification_required(): + pytest.fail(message) + pytest.skip(message) + signer.add_dynamic_assertion(fail, label="com.example.functional", reserve_size=64) + context = c2pa.Context(signer=signer) + native = _ScriptedNative(monkeypatch) + native.install(monkeypatch, "session_create_callback_v1", + lambda *args: setattr(native, "callback", args[-1]) or native.handle) + session = c2pa.TrustedVsiSession( + context, {"format": "video/mp4"}, "es256", b"k", b"kid", 1, + "2026-09-10T00:00:00Z", 86400, fail if which == "vsi" else (lambda *a: b"S" * 64), + mode="expert_sig_structure", reservation_nonce="0" * 32, signing_time_unix_seconds=IAT) + context.close() + gc.collect() + results = [] + + def finalize(handle, data, length, output): + buffer = (ctypes.c_ubyte * 64)() + if which == "claim": + tbs = (ctypes.c_ubyte * 3)(1, 2, 3) + results.append(session._signer_callback_cb(None, tbs, 3, buffer, 64)) + elif which == "dynamic": + callback = session._dynamic_assertion_cbs[0][0] + results.append(callback(None, b"com.example.functional", 64, b"[]", buffer, 64)) + else: + results.append(native.invoke()[0]) + return -1 + native.install(monkeypatch, "session_finalize_init_uuid", finalize) + try: + with pytest.raises(type(error)) as caught: + session.finalize_init_uuid(b"hash") + assert caught.value is error + assert results == [-1] + assert native.freed == [] + finally: + session.close() + + +@pytest.mark.parametrize("fails", [False, True]) +def test_output_is_initialized_and_freed_once_even_on_copy_failure(monkeypatch, fails): + buffer = (ctypes.c_ubyte * 3)(1, 2, 3) + free = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", free) + def call(output): + pointer = ctypes.cast(output, ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))) + assert not pointer[0] + pointer[0] = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + return 3 + if fails: + monkeypatch.setattr(bindings.ctypes, "string_at", Mock(side_effect=MemoryError("copy"))) + with pytest.raises(MemoryError): + bindings._trusted_vsi_output(call) + else: + assert bindings._trusted_vsi_output(call) == b"\x01\x02\x03" + free.assert_called_once() + + +def _qualification_required(): + return "1" in (os.environ.get("C2PA_TRUSTED_VSI_ABI_REQUIRED"), + os.environ.get("C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED")) + + +@pytest.fixture +def paired_native(): + """Require the complete functional native library. + + Under C2PA_TRUSTED_VSI_ABI_REQUIRED=1 (all paired qualification jobs) an + old/scaffold/partial library is a hard FAILURE, never a skip or a pass. + Outside qualification (e.g. an ad-hoc local run) it is reported as a skip; + legacy dev5 jobs deselect these tests with ``-k "not paired"``. + """ + missing = [name for name in bindings._TRUSTED_VSI_FUNCTIONS if not hasattr(bindings._lib, name)] + problems = [] + if missing: + problems.append("missing symbols: " + ", ".join(missing)) + revision = bindings._TRUSTED_VSI_CONTRACT_REVISION + if revision != 3: + problems.append(f"contract revision {revision} != 3") + expected = bindings._TRUSTED_VSI_NATIVE_VERSION + if c2pa.sdk_version() != expected: + problems.append(f"native version {c2pa.sdk_version()!r} != {expected!r}") + if not missing: + mask = int(bindings._lib.c2pa_live_video_trusted_vsi_capabilities()) + if mask != 63: + problems.append(f"capability mask {mask} != 63") + if not problems and not all(probe() for probe in PROBES): + problems.append("Python capability probes are not all true") + if problems: + message = "Functional trusted VSI native unavailable: " + "; ".join(problems) + if _qualification_required(): + pytest.fail(message + " (required qualification; never skipped)") + pytest.skip(message + " (not a qualification run)") + installed = os.environ.get("C2PA_FUNCTIONAL_INSTALLED_ROOT") + if installed: + root = Path(installed).resolve() + assert Path(c2pa.__file__).resolve().is_relative_to(root) + assert Path(bindings._lib._name).resolve().is_relative_to(root) + assert c2pa.__version__ == os.environ["C2PA_FUNCTIONAL_EXPECTED_VERSION"] + assert "C2PA_LIBRARY_NAME" not in os.environ and "PYTHONPATH" not in os.environ + + +@pytest.mark.parametrize("flag", ["C2PA_TRUSTED_VSI_ABI_REQUIRED", "C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED"]) +@pytest.mark.parametrize("revision", [None, 0, 1, 2, 4]) +def test_required_qualification_revision_diagnostic_fails_never_skips(monkeypatch, flag, revision): + library = Mock() + if revision is None: + del library.c2pa_live_video_trusted_vsi_contract_revision + library.c2pa_live_video_trusted_vsi_capabilities.return_value = 63 + monkeypatch.setattr(bindings, "_lib", library) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", revision or 0) + monkeypatch.setattr(c2pa, "sdk_version", lambda: "0.92.0-dev") + monkeypatch.delenv("C2PA_TRUSTED_VSI_ABI_REQUIRED", raising=False) + monkeypatch.delenv("C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED", raising=False) + monkeypatch.setenv(flag, "1") + with pytest.raises(pytest.fail.Exception, match=f"contract revision {revision or 0} != 3") as caught: + paired_native.__wrapped__() + assert "required qualification; never skipped" in str(caught.value) + if revision is None: + assert "missing symbols: c2pa_live_video_trusted_vsi_contract_revision" in str(caught.value) + library.c2pa_live_video_trusted_vsi_session_create_callback_v1.assert_not_called() + library.c2pa_live_video_trusted_vsi_session_status_v1.assert_not_called() + + +@pytest.fixture +def sessions(paired_native): + resources = [] + def create(mode="expert_sig_structure", algorithm="ed25519", minimum=1, + maximum=None, callback=None, claim_callback=None, dynamic=None, + public_cose_key=None, **overrides): + kid = b"functional-python-session" + if algorithm == "ed25519": + key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + cose = {1: 1, 2: kid, 3: -8, -1: 6, -2: public} + sign = key.sign + else: + key = ec.derive_private_key(7, ec.SECP256R1()) + public = key.public_key().public_numbers() + cose = {1: 2, 2: kid, 3: -7, -1: 1, -2: public.x.to_bytes(32, "big"), -3: public.y.to_bytes(32, "big")} + def sign(data): + r, s = decode_dss_signature(key.sign(data, ec.ECDSA(hashes.SHA256()))) + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + calls = [] + def signing(context, data): + calls.append((context, data)) + return callback(context, data) if callback else sign(data) + certs = (FIXTURES / "es256_certs.pem").read_bytes() + if claim_callback is None: + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + else: + signer = c2pa.Signer.from_callback(claim_callback, c2pa.C2paSigningAlg.ES256, certs, None) + resources.append(signer) + if dynamic: + signer.add_dynamic_assertion(dynamic, label="com.example.functional", reserve_size=64) + config = json.loads((Path(__file__).parent / "trust_config_test_settings.json").read_text()) + config["builder"] = {"thumbnail": {"enabled": False}} + context = c2pa.Context.from_dict(config, signer=signer) + resources.append(context) + assert not signer.is_valid + options = dict(mode=mode, reservation_nonce="0123456789abcdef0123456789abcdef", + signing_time_unix_seconds=IAT, sequence_max=maximum) + options.update(overrides) + session = c2pa.TrustedVsiSession.from_callback( + context, + {"claim_version": 2, "format": "video/mp4", "assertions": [{"label": "c2pa.actions", "data": { + "actions": [{"action": "c2pa.created", "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty"}] + }}]}, algorithm, + public_cose_key if public_cose_key is not None else cbor2.dumps(cose, canonical=True), kid, + minimum, "2026-09-10T00:00:00Z", 86400, signing, **options) + resources.append(session) + assert calls == [] + return session, calls, key, context + yield create + for resource in reversed(resources): + resource.close() + + +def _sig_structure(algorithm="ed25519", payload=None, *, session=None, sequence=1, iat=IAT): + protected = cbor2.dumps({1: -8 if algorithm == "ed25519" else -7, "iat": iat}, canonical=True) + if payload is None: + media_hash = {"alg": "sha256", "name": "jumbf manifest", "hash": bytes(32), + "exclusions": [{"xpath": "/emsg", "data": [ + {"offset": 12, "value": b"urn:c2pa:verifiable-segment-info"}]}]} + payload = cbor2.dumps({"sequenceNumber": sequence, + "manifestId": session.reserved_manifest_id() if session else "test-manifest", + "bmffHash": media_hash}) + return cbor2.dumps(["Signature1", protected, b"", payload], canonical=True) + + +def _boxes(data): + offset = 0 + while offset < len(data): + size = int.from_bytes(data[offset:offset + 4], "big") + if size == 1: + size = int.from_bytes(data[offset + 8:offset + 16], "big") + if size == 0: + size = len(data) - offset + assert size >= 8 and offset + size <= len(data) + yield offset, data[offset + 4:offset + 8], data[offset:offset + size] + offset += size + + +def _hash_input(kind, asset): + """Trusted-processor side: hash FINAL-placement bytes (reserved box installed). + + C2PA BMFF v2+/v3 hashing inserts each included top-level box's big-endian + uint64 offset before its bytes; fully excluded boxes contribute nothing. + Test assets contain exactly one C2PA UUID/EMSG, which is the exclusion. + """ + template = cbor2.loads(c2pa.trusted_vsi_hash_template(kind)) + hasher = hashlib.sha256() + excluded = b"uuid" if kind == KIND.INIT_HASH else b"emsg" + for offset, box_type, box in _boxes(asset): + if box_type != excluded: + hasher.update(offset.to_bytes(8, "big")) + hasher.update(box) + template["hash"] = hasher.digest() + return cbor2.dumps(template, canonical=True) + + +def _place(segment, box): + """Install a reserved box after a leading ftyp/styp, else at the front.""" + first = next(_boxes(segment)) + if first[1] in (b"ftyp", b"styp"): + split = len(first[2]) + return segment[:split] + box + segment[split:] + return box + segment + + +def _unsigned_init(): + return b"".join(box for _, kind, box in _boxes((FIXTURES / "dashinit.mp4").read_bytes()) + if kind in (b"ftyp", b"moov")) + + +def _unsigned_media(): + return b"".join(box for _, kind, box in _boxes((FIXTURES / "dash1.m4s").read_bytes()) + if kind not in (b"uuid", b"emsg")) + + +def _init(session): + raw = _unsigned_init() + reservation = session.reserve_init_uuid() + canonical = _hash_input(KIND.INIT_HASH, _place(raw, reservation)) + final = session.finalize_init_uuid(canonical) + assert len(final) == len(reservation) + assert final[4:8] == b"uuid" + # Placeholder replacement is an exact in-place substitution. + signed = _place(raw, final) + assert len(signed) == len(_place(raw, reservation)) + session.commit_init_uuid() + return signed, canonical + + +def test_paired_ctypes_exact_functional_contract_and_error_outputs(paired_native): + lib = bindings._lib + assert lib.c2pa_live_video_trusted_vsi_contract_revision.argtypes == [] + assert lib.c2pa_live_video_trusted_vsi_contract_revision.restype is ctypes.c_uint32 + assert lib.c2pa_live_video_trusted_vsi_contract_revision() == 3 + assert lib.c2pa_live_video_trusted_vsi_capabilities.argtypes == [] + assert lib.c2pa_live_video_trusted_vsi_capabilities.restype is ctypes.c_uint64 + session = ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession) + byte = ctypes.POINTER(ctypes.c_ubyte) + output = ctypes.POINTER(byte) + assert lib.c2pa_live_video_trusted_vsi_session_create_callback_v1.argtypes == [ + ctypes.POINTER(bindings.C2paContext), ctypes.c_char_p, ctypes.c_int, byte, ctypes.c_size_t, + byte, ctypes.c_size_t, ctypes.c_uint64, ctypes.c_char_p, ctypes.c_uint64, + ctypes.c_char_p, ctypes.c_void_p, bindings.TrustedVsiSignCallbackV1] + assert lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure.argtypes == [session, byte, ctypes.c_size_t, ctypes.c_uint32, output] + assert lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg.argtypes == [ + session, ctypes.c_uint32, ctypes.c_int64, ctypes.c_uint32, ctypes.c_uint32, + output, ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSigningContextV1)] + assert lib.c2pa_live_video_trusted_vsi_session_preflight.argtypes == [ + session, ctypes.c_uint32, byte, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_int64, + ctypes.c_uint32, ctypes.c_uint32, ctypes.c_char_p] + sentinel = ctypes.c_ubyte(42) + ptr = ctypes.pointer(sentinel) + assert lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure(None, None, 0, 7, ctypes.byref(ptr)) == -1 + assert not ptr + context = bindings.C2paLiveVideoTrustedVsiSigningContextV1(1, 2, True, 3, True, True) + ptr = ctypes.pointer(sentinel) + assert lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg(None, 7, IAT, 1, 1, ctypes.byref(ptr), ctypes.byref(context)) == -1 + assert not ptr and bytes(context) == bytes(ctypes.sizeof(context)) + + +@pytest.mark.parametrize("algorithm", ["ed25519", "es256"]) +def test_paired_expert_exact_bytes_supplied_sequence_retry_and_no_counter(sessions, algorithm): + session, calls, key, context = sessions(algorithm=algorithm) + asset, _ = _init(session) + with c2pa.Reader("video/mp4", io.BytesIO(asset), context=context) as reader: + assert reader.get_validation_state() == "Trusted" + assert reader.get_validation_results()["activeManifest"]["failure"] == [] + before = session.export_state() + for sequence in (1, 37, 2**32 - 1, 1): + original = _sig_structure(algorithm, session=session, sequence=sequence) + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, algorithm, original) + session.preflight(OP.EXPERT_SIGN, original, sequence_number=sequence) + signature = session.sign_sig_structure(original, sequence) + assert isinstance(signature, bytes) and len(signature) == 64 + if algorithm == "ed25519": + key.public_key().verify(signature, original) + else: + der = encode_dss_signature(int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")) + key.public_key().verify(der, original, ec.ECDSA(hashes.SHA256())) + assert calls[-1] == (c2pa.VsiSigningContextV1("vsi", sequence), original) + assert session.export_state() == before + status = session.status() + assert status.init_uuid_committed and status.next_sequence_number is None + assert status.next_event_id is None and not status.exhausted and status.exhaustion_reason is None + context.close() + gc.collect() + assert len(session.sign_sig_structure(_sig_structure(algorithm, session=session, sequence=2), 2)) == 64 + + +def test_paired_init_pending_export_import_preflight_and_identical_replay(sessions): + first, calls, _, _ = sessions() + new = first.export_state() + first.preflight(OP.RESERVE_INIT) + assert new == first.export_state() and calls == [] + reserved = first.reserve_init_uuid() + assert first.reserve_init_uuid() == reserved and calls == [] + independent, independent_calls, _, _ = sessions() + assert independent.reserve_init_uuid() == reserved and independent_calls == [] + pending = first.export_state() + restored, restored_calls, _, context = sessions() + restored.import_state(pending) + assert restored_calls == [] and restored.reserve_init_uuid() == reserved + context.close() + gc.collect() + canonical = c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) + restored.preflight(OP.FINALIZE_INIT, canonical) + assert restored.export_state() == pending and restored_calls == [] + final = restored.finalize_init_uuid(canonical) + count = len(restored_calls) + assert len(final) == len(reserved) + assert restored.finalize_init_uuid(canonical) == final + assert len(restored_calls) == count + modified = cbor2.loads(canonical) + modified["hash"] = b"X" * 32 + with pytest.raises(c2pa.C2paError): + restored.finalize_init_uuid(cbor2.dumps(modified, canonical=True)) + final_state = restored.export_state() + committed, replay_calls, _, _ = sessions() + committed.import_state(final_state) + committed.preflight(OP.COMMIT_INIT) + committed.commit_init_uuid() + assert replay_calls == [] and committed.status().init_uuid_committed + + +def test_paired_composed_pending_import_and_uint32_exhaustion(sessions): + session, calls, _, _ = sessions(mode="signer_composed_emsg", minimum=2**32 - 1) + _init(session) + before = session.export_state() + count = len(calls) + session.preflight(OP.RESERVE_MEDIA, sequence_number=2**32 - 1, iat=IAT, timescale=1000, event_duration=2000) + assert session.export_state() == before and len(calls) == count + reserved = session.reserve_media_emsg_at(2**32 - 1, IAT, 1000, 2000) + assert len(calls) == count + assert reserved.signing_context == c2pa.VsiSigningContextV1("vsi", 2**32 - 1, 1, True) + pending = session.export_state() + restored, restored_calls, _, context = sessions(mode="signer_composed_emsg", minimum=2**32 - 1) + restored.import_state(pending) + context.close() + assert restored_calls == [] + assert restored.reserve_media_emsg_at(2**32 - 1, IAT, 1000, 2000) == reserved + canonical = c2pa.trusted_vsi_hash_template(KIND.MEDIA_HASH) + restored.preflight(OP.FINALIZE_MEDIA, canonical) + assert restored.export_state() == pending and restored_calls == [] + final = restored.finalize_media_emsg(canonical) + assert final[4:8] == b"emsg" and len(final) == len(reserved.placeholder_emsg_box) + assert restored_calls[-1][0] == reserved.signing_context + assert restored.status().exhausted and restored.status().exhaustion_reason == "sequence_max" + assert not restored.status().blocked + with pytest.raises(c2pa.C2paError): + restored.reserve_media_emsg_at(0, IAT, 1000, 2000) + + +def _parse_vsi_emsg(box): + """Parse an ISO/IEC 23009-1 version-0 EMSG carrying a C2PA VSI COSE_Sign1.""" + assert box[4:8] == b"emsg" and box[8] == 0 + position = 12 + fields = [] + for _ in range(2): + end = box.index(b"\0", position) + fields.append(box[position:end]) + position = end + 1 + timescale, delta, duration, event_id = ( + int.from_bytes(box[position + 4 * i:position + 4 * i + 4], "big") for i in range(4)) + cose = cbor2.loads(box[position + 16:]) + return fields, (timescale, delta, duration, event_id), cose + + +def test_paired_composed_real_fragment_native_verification(sessions): + """Native-verified init plus independent verification of the composed EMSG. + + The Python SDK exposes no live-video segment validator (LiveVideoValidator is + Rust-only). ``Reader.from_fragmented_files`` is NOT applicable: it implements + the Merkle fragmented-BMFF model and rejects any init bmff hash that carries a + top-level ``hash`` ("Hash value should not be present for a fragmented BMFF + asset"), which C2PA 2.4 section 19.3 live-video init manifests always carry, + including those from the shipped complete-buffer ``LiveVideoVsiSession``. + """ + raw = _unsigned_media() + sequence = c2pa.moof_sequence_number(raw) + session, calls, key, context = sessions(mode="signer_composed_emsg", minimum=sequence) + init, _ = _init(session) + manifest_id = session.reserved_manifest_id() + with c2pa.Reader("video/mp4", io.BytesIO(init), context=context) as reader: + assert reader.get_validation_results()["activeManifest"]["failure"] == [] + assert reader.get_validation_state() == "Trusted" + assert json.loads(reader.json())["active_manifest"] == manifest_id + + reserved = session.reserve_media_emsg_at(sequence, IAT, 1000, 2000) + assert (reserved.sequence_number, reserved.event_id) == (sequence, 1) + canonical = _hash_input(KIND.MEDIA_HASH, _place(raw, reserved.placeholder_emsg_box)) + final = session.finalize_media_emsg(canonical) + assert len(final) == len(reserved.placeholder_emsg_box) + signed_media = _place(raw, final) + # The EMSG is excluded, so the final-placement hash equals the reserved one. + assert _hash_input(KIND.MEDIA_HASH, signed_media) == canonical + assert calls[-1][0] == reserved.signing_context + + (scheme, value), timing, cose = _parse_vsi_emsg(final) + assert scheme == b"urn:c2pa:verifiable-segment-info" + assert timing == (1000, 0, 2000, 1) + assert cose.tag == 18 and len(cose.value) == 4 + protected, unprotected, payload, signature = cose.value + assert cbor2.loads(protected) == {1: -8, "iat": IAT} + assert unprotected == {4: b"functional-python-session"} + info = cbor2.loads(payload) + assert info["sequenceNumber"] == sequence + assert info["manifestId"] == manifest_id + assert cbor2.dumps(info["bmffHash"], canonical=True) == canonical + sig_structure = cbor2.dumps(["Signature1", protected, b"", payload]) + assert calls[-1][1] == sig_structure + key.public_key().verify(signature, sig_structure) + assert value # non-empty EMSG value per native framing + + +@pytest.mark.parametrize("invalid", [ + "garbage", "indefinite", "trailing", "duplicate_alg", "nonminimal_alg", "aad", "wrong_algorithm", +]) +def test_paired_invalid_expert_framing_before_callback_and_no_mutation(sessions, invalid): + session, calls, _, _ = sessions() + _init(session) + count, state = len(calls), session.export_state() + data = _sig_structure(session=session) + framing = cbor2.loads(data) + if invalid == "garbage": + data = b"garbage" + elif invalid == "indefinite": + data = b"\x9f\xff" + elif invalid == "trailing": + data += b"\x00" + elif invalid == "wrong_algorithm": + data = _sig_structure("es256", session=session) + else: + if invalid == "duplicate_alg": + framing[1] = b"\xa3\x01\x27\x01\x27\x63iat" + cbor2.dumps(IAT) + elif invalid == "nonminimal_alg": + framing[1] = b"\xa2\x01\x38\x07\x63iat" + cbor2.dumps(IAT) + else: + framing[2] = b"aad" + data = cbor2.dumps(framing) + with pytest.raises(c2pa.C2paError): + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", data) + with pytest.raises(c2pa.C2paError): + session.preflight(OP.EXPERT_SIGN, data, sequence_number=1) + with pytest.raises(c2pa.C2paError): + session.sign_sig_structure(data, 1) + assert len(calls) == count and session.export_state() == state + + +def test_paired_mode_and_sequence_limits_precede_callback(sessions): + expert, calls, _, _ = sessions(minimum=7, maximum=9) + _init(expert) + count = len(calls) + for value in (6, 10): + with pytest.raises(c2pa.C2paError): + expert.sign_sig_structure(_sig_structure(session=expert, sequence=value), value) + for value in (-1, 2**32, True, 1.0): + with pytest.raises((TypeError, ValueError)): + expert.sign_sig_structure(_sig_structure(), value) + with pytest.raises(c2pa.C2paError): + expert.reserve_media_emsg_at(7, IAT, 1, 1) + assert len(calls) == count + composed, calls, _, _ = sessions(mode="signer_composed_emsg") + _init(composed) + count = len(calls) + with pytest.raises(c2pa.C2paError): + composed.sign_sig_structure(_sig_structure(), 1) + with pytest.raises(c2pa.C2paError): + composed.reserve_media_emsg_at(2, IAT, 1, 1) + assert len(calls) == count + + +def test_paired_callback_exception_identity_blocked_state_and_durable_retry(sessions): + error = RuntimeError("provider lost response") + def callback(context, data): + if context.purpose == "vsi": + raise error + return ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32).sign(data) + session, calls, _, _ = sessions(callback=callback) + _init(session) + before = session.export_state() + assert not session.status().blocked and not session.status().exhausted + tbs = _sig_structure(session=session) + with pytest.raises(RuntimeError) as caught: + session.sign_sig_structure(tbs, 1) + assert caught.value is error + assert session.status().blocked and not session.status().exhausted + assert session.export_state() != before + count = len(calls) + with pytest.raises(c2pa.C2paError): + session.sign_sig_structure(tbs, 1) + assert len(calls) == count + restored, _, _, _ = sessions() + restored.import_state(before) + assert not restored.status().blocked + assert len(restored.sign_sig_structure(tbs, 1)) == 64 + + + + +@pytest.mark.parametrize("scenario", ["direct", "context_closed", "imported"]) +@pytest.mark.parametrize("which", ["claim", "dynamic"]) +@pytest.mark.parametrize("make_error", CALLBACK_FAILURES) +def test_paired_claim_and_da_errors_keep_identity_and_block(sessions, which, scenario, make_error): + error = make_error() + failures = [] + + def fail(*args): + failures.append(args) + raise error + failing = {"claim_callback": fail} if which == "claim" else {"dynamic": fail} + if scenario == "imported": + # Pending state from a healthy session with the same claim certificate and + # DynamicAssertion declaration (label/reserve size), finalized by a NEW + # session whose Context signer/DA fails and whose Context is closed. + # Declarations must be identical (native v3 state pins the claim + # signer's reserve size and ordered DA label/reserve size), so the + # healthy claim signer is also a from_callback signer with the same + # certificate; only its signing behavior differs. + healthy = ({"claim_callback": _fixture_claim_signature} if which == "claim" + else {"dynamic": _exact_dynamic_assertion}) + original, _, _, _ = sessions(**healthy) + original.reserve_init_uuid() + pending = original.export_state() + session, calls, _, context = sessions(**failing) + session.import_state(pending) + else: + session, calls, _, context = sessions(**failing) + session.reserve_init_uuid() + if scenario != "direct": + context.close() + gc.collect() + canonical = c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) + assert not session.status().blocked + with pytest.raises(type(error)) as caught: + session.finalize_init_uuid(canonical) + assert caught.value is error + assert session.status().blocked and not session.status().exhausted + assert len(failures) == 1 + # The external signing attempt blocks the local session: no retry, no + # further callback, and init is never committed. + vsi_calls = len(calls) + with pytest.raises(c2pa.C2paError): + session.finalize_init_uuid(canonical) + with pytest.raises(c2pa.C2paError): + session.commit_init_uuid() + assert len(failures) == 1 and len(calls) == vsi_calls + assert not session.status().init_uuid_committed + + +@pytest.mark.parametrize("reserving,importing", [ + pytest.param({}, {"claim_callback": "callback"}, id="claim-reserve-size-2361-vs-11836"), + pytest.param({"claim_callback": "callback"}, {}, id="claim-reserve-size-11836-vs-2361"), + pytest.param({}, {"dynamic": [("com.example.a", 64)]}, id="da-added"), + pytest.param({"dynamic": [("com.example.a", 64)]}, {}, id="da-removed"), + pytest.param({"dynamic": [("com.example.a", 64)]}, {"dynamic": [("com.example.b", 64)]}, id="da-label"), + pytest.param({"dynamic": [("com.example.a", 64)]}, {"dynamic": [("com.example.a", 96)]}, id="da-reserve-size"), + pytest.param({"dynamic": [("com.example.a", 64), ("com.example.b", 64)]}, + {"dynamic": [("com.example.b", 64), ("com.example.a", 64)]}, id="da-order"), +]) +def test_paired_import_rejects_mismatched_claim_and_da_declarations(paired_native, reserving, importing): + """v3 identity pins claim reserve size and ordered DA declarations. + + A mismatch must be rejected by import_state itself, before mutation and + without invoking the VSI session key, claim signer, or any DA callback. + """ + certs = (FIXTURES / "es256_certs.pem").read_bytes() + touched = [] + resources = [] + + def build(declarations, record): + def claim(data): + touched.append(("claim", record)) + return _fixture_claim_signature(data) + if declarations.get("claim_callback"): + signer = c2pa.Signer.from_callback(claim, c2pa.C2paSigningAlg.ES256, certs, None) + else: + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + for label, size in declarations.get("dynamic", ()): + def dynamic(*args, label=label): + touched.append(("dynamic", record, label)) + return _exact_dynamic_assertion(*args) + signer.add_dynamic_assertion(dynamic, label=label, reserve_size=size) + context = c2pa.Context(signer=signer) + resources.append(context) + key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + kid = b"functional-python-session" + + def vsi(ctx, data): + touched.append(("vsi", record)) + return key.sign(data) + session = c2pa.TrustedVsiSession( + context, {"claim_version": 2, "format": "video/mp4"}, "ed25519", + cbor2.dumps({1: 1, 2: kid, 3: -8, -1: 6, -2: public}, canonical=True), kid, + 1, "2026-09-10T00:00:00Z", 86400, vsi, mode="expert_sig_structure", + reservation_nonce="0123456789abcdef0123456789abcdef", signing_time_unix_seconds=IAT) + resources.append(session) + return session + + try: + reserving_session = build(reserving, "reserving") + reserving_session.reserve_init_uuid() + record = reserving_session.export_state() + state = json.loads(record) + assert (state["format"], state["version"]) == ("c2pa.trusted-vsi.state", 3) + assert [(d["label"], d["reserve_size"]) for d in state["identity"]["dynamic_assertions"]] == \ + list(reserving.get("dynamic", ())) + importing_session = build(importing, "importing") + fresh = importing_session.export_state() + with pytest.raises(c2pa.C2paError, match="identity does not match"): + importing_session.import_state(record) + assert importing_session.export_state() == fresh + status = importing_session.status() + assert not status.init_uuid_pending and not status.init_uuid_committed + assert touched == [] + # Still a usable New session: its own reservation works afterwards. + importing_session.reserve_init_uuid() + assert touched == [] + finally: + for resource in reversed(resources): + resource.close() + + +def test_paired_import_rejects_mismatched_mode_options_and_corruption(sessions): + session, _, _, _ = sessions() + session.reserve_init_uuid() + state = session.export_state() + for options in ({"mode": "signer_composed_emsg"}, {"reservation_nonce": "a" * 32}): + other, calls, _, _ = sessions(**options) + before = other.export_state() + with pytest.raises(c2pa.C2paError): + other.import_state(state) + assert other.export_state() == before and calls == [] + other, calls, _, _ = sessions() + with pytest.raises(c2pa.C2paError): + other.import_state(state[:-1]) + assert calls == [] + session.close() + session.close() + assert not session.is_valid + with pytest.raises(c2pa.C2paError): + session.export_state() + + +def test_paired_invalid_public_key_and_algorithm_fail_before_callback(sessions): + callback = Mock(side_effect=AssertionError("key used before validation")) + for key in (b"invalid", cbor2.dumps({1: 1, 2: b"functional-python-session", 3: -8, + -1: 6, -2: b"X" * 32, -4: b"secret" * 6}, canonical=True)): + with pytest.raises(c2pa.C2paError): + sessions(public_cose_key=key, callback=callback) + with pytest.raises(ValueError): + sessions(algorithm="ps256", callback=callback) + callback.assert_not_called() + + +@pytest.mark.parametrize("signature,error", [(b"bad", ValueError), (bytearray(64), TypeError), (bytes(64), c2pa.C2paError)]) +def test_paired_invalid_signatures_preserve_errors_and_block_session(sessions, signature, error): + session, calls, _, _ = sessions(callback=lambda *_: signature) + session.reserve_init_uuid() + with pytest.raises(error): + session.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + assert len(calls) == 1 + with pytest.raises(c2pa.C2paError): + session.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + assert len(calls) == 1 + + +def test_paired_claim_and_dynamic_callbacks_survive_pending_import_and_close(sessions): + claim_calls, da_calls = [], [] + key = serialization.load_pem_private_key((FIXTURES / "es256_private.key").read_bytes(), password=None) + def claim(data): + claim_calls.append(data) + return key.sign(data, ec.ECDSA(hashes.SHA256())) + def dynamic(label, size, partial_claim): + da_calls.append((label, size, partial_claim)) + return b"\xa1\x63pad\x58\x39" + b"X" * 57 + original, _, _, context = sessions(claim_callback=claim, dynamic=dynamic) + reservation = original.reserve_init_uuid() + pending = original.export_state() + assert claim_calls == da_calls == [] + restored, _, _, restored_context = sessions(claim_callback=claim, dynamic=dynamic) + restored.import_state(pending) + context.close() + restored_context.close() + gc.collect() + final = restored.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + assert len(final) == len(reservation) + assert len(da_calls) == 1 and len(claim_calls) >= 1 + assert da_calls[0][0:2] == ("com.example.functional", 64) + + +@pytest.mark.parametrize("field", ["sequence", "manifest", "iat", "binding_payload"]) +def test_paired_expert_rejects_signed_identity_and_time_mismatch_without_callbacks(sessions, field): + session, calls, _, _ = sessions() + _init(session) + before, count = session.export_state(), len(calls) + tbs = _sig_structure(session=session, sequence=2 if field == "sequence" else 1, + iat=IAT + 86400 if field == "iat" else IAT) + framing = cbor2.loads(tbs) + if field == "manifest": + payload = cbor2.loads(framing[3]) + payload["manifestId"] = "foreign-manifest" + framing[3] = cbor2.dumps(payload) + elif field == "binding_payload": + framing[3] = cbor2.dumps(b"detached signerBinding certificate") + tbs = cbor2.dumps(framing) + # Static validation cannot know session identity or its validity window. + if field == "binding_payload": + with pytest.raises(c2pa.C2paError): + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", tbs) + else: + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", tbs) + with pytest.raises(c2pa.C2paError): + session.preflight(OP.EXPERT_SIGN, tbs, sequence_number=1) + with pytest.raises(c2pa.C2paError): + session.sign_sig_structure(tbs, 1) + assert session.export_state() == before and len(calls) == count + assert not session.status().blocked + + +@pytest.mark.parametrize("version", [1, 2]) +def test_paired_old_state_versions_rejected_before_mutation(sessions, version): + original, _, _, _ = sessions() + original.reserve_init_uuid() + state = json.loads(original.export_state()) + assert state["version"] == 3 + state["version"] = version + new, calls, _, _ = sessions() + before = new.export_state() + with pytest.raises(c2pa.C2paError): + new.import_state(json.dumps(state).encode()) + assert new.export_state() == before and calls == [] + + +@pytest.mark.parametrize("make_error", [None] + [p.values[0] for p in CALLBACK_FAILURES]) +def test_scripted_reentrant_close_retains_thunks_until_free_and_then_collects(monkeypatch, make_error): + native = _ScriptedNative(monkeypatch) + certs = (FIXTURES / "es256_certs.pem").read_bytes() + signer = c2pa.Signer.from_callback(_fixture_claim_signature, c2pa.C2paSigningAlg.ES256, certs, None) + context = c2pa.Context(signer=signer) + error = make_error() if make_error else None + holder = {} + + def callback(ctx, data): + session = holder["session"] + session.close() + session.close() + assert not session.is_valid and session._handle is not None + with pytest.raises(c2pa.C2paError, match="closed"): + session.export_state() + gc.collect() + assert claim_ref() is not None and vsi_ref() is not None + assert native.freed == [] + if error is not None: + raise error + return b"S" * 64 + + native.install(monkeypatch, "session_create_callback_v1", + lambda *args: setattr(native, "callback", weakref.proxy(args[-1])) or native.handle) + session = c2pa.TrustedVsiSession( + context, {"format": "video/mp4"}, "es256", b"k", b"kid", 1, + "2026-09-10T00:00:00Z", 86400, callback, mode="expert_sig_structure", + reservation_nonce="0" * 32, signing_time_unix_seconds=IAT) + holder["session"] = session + claim_ref = weakref.ref(session._signer_callback_cb) + vsi_ref = weakref.ref(session._trusted_vsi_callback[0]) + # Recorded constructor arguments would otherwise be artificial strong pins. + native.calls.clear() + context.close() + real_free = bindings.ManagedResource._free_native_ptr + + def free(ptr): + if ctypes.addressof(ptr.contents) == ctypes.addressof(native.handle.contents): + assert session._trusted_vsi_callback is None # _release already ran + gc.collect() + assert claim_ref() is not None and vsi_ref() is not None + return real_free(ptr) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + + def sign(handle, data, length, sequence, output): + result, signature = native.invoke() + if error is not None: + assert result == -1 + return -1 + assert result == 64 + return native.output(output, signature) + native.install(monkeypatch, "session_sign_sig_structure", sign) + if error is not None: + with pytest.raises(type(error)) as caught: + session.sign_sig_structure(b"tbs", 1) + assert caught.value is error + # Exception tracebacks legitimately retain callback frames. + error.__traceback__ = None + del caught + else: + assert session.sign_sig_structure(b"tbs", 1) == b"S" * 64 + session.close() + assert native.freed.count(ctypes.addressof(native.handle.contents)) == 1 + gc.collect() + assert claim_ref() is None and vsi_ref() is None + + +def test_call_guard_close_from_other_thread_is_nonblocking_and_drains_once(monkeypatch): + resource = bindings.ManagedResource() + resource._activate(ctypes.c_void_p(123)) + freed = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + with resource._native_call(): + closer = threading.Thread(target=resource.close) + closer.start() + closer.join(timeout=2) + assert not closer.is_alive(), "close must not wait for a callback/native call" + assert not resource.is_valid and resource._handle is not None + freed.assert_not_called() + with pytest.raises(c2pa.C2paError, match="closed"): + with resource._native_call(): + pytest.fail("closed resource admitted another operation") + freed.assert_called_once() + assert resource._handle is None + resource.close() + freed.assert_called_once() + + +def test_two_admitted_call_guards_close_and_staggered_drain(monkeypatch): + # Exercise bookkeeping, not concurrent native operations (which require + # external serialization even when both calls have a lifetime guard). + resource = bindings.ManagedResource() + handle = ctypes.c_void_p(123) + resource._activate(handle) + freed = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + with resource._native_call(): + with resource._native_call(): + assert resource._active_calls == 2 + resource.close() + assert not resource.is_valid and resource._handle is handle + freed.assert_not_called() + assert resource._active_calls == 1 and resource._handle is handle + freed.assert_not_called() + assert resource._active_calls == 0 and resource._handle is None + freed.assert_called_once_with(handle) + resource.close() + freed.assert_called_once() + + +def test_release_handle_preserves_closed_pending_borrow(monkeypatch): + resource = bindings.ManagedResource() + handle = ctypes.c_void_p(123) + resource._activate(handle) + freed = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + with resource._native_call(): + resource.close() + resource._release_handle() + assert resource._handle is handle and resource._pending_teardown is True + assert resource._active_calls == 1 and not resource.is_valid + freed.assert_not_called() + assert resource._handle is None and resource._pending_teardown is None + freed.assert_called_once_with(handle) + + +def test_call_guard_foreign_pid_drain_never_locks_releases_or_frees(monkeypatch): + resource = bindings.ManagedResource() + resource._activate(ctypes.c_void_p(123)) + freed = Mock() + release = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + resource._release = release + with resource._native_call(): + resource.close() + resource._owner_pid = os.getpid() + 1 + # A copied lock might have belonged to a vanished thread at fork. + resource._call_lock = Mock(side_effect=AssertionError("foreign lock touched")) + assert not resource.is_valid and resource._handle is None + release.assert_not_called() + freed.assert_not_called() + + +def test_call_guard_foreign_pid_admission_rejects_before_lock_or_ffi_without_cleanup(monkeypatch): + resource = bindings.Signer._wrap_native_handle(ctypes.pointer(bindings.C2paSigner())) + handle, parent_pid, parent_lock = resource._handle, resource._owner_pid, resource._call_lock + callback_pin = bindings.SignerCallback(lambda *args: -1) + resource._callback_cb = callback_pin + dynamic_pins = resource._dynamic_assertion_cbs + dynamic_pins.append((bindings.DynamicAssertionCallback(lambda *args: -1), + threading.local(), lambda *args: b"")) + dynamic_pin = dynamic_pins[0] + freed, release, ffi = Mock(), Mock(), Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + monkeypatch.setattr(resource, "_release", release) + + class InheritedLock: + def __enter__(self): + raise AssertionError("foreign admission touched an inherited lock") + + def __exit__(self, *args): + raise AssertionError("foreign admission touched an inherited lock") + + resource._owner_pid = parent_pid + 1 + resource._call_lock = InheritedLock() + try: + with pytest.raises(c2pa.C2paError, match="after fork"): + with resource._native_call(): + ffi(handle) + ffi.assert_not_called() + release.assert_not_called() + freed.assert_not_called() + assert resource.is_valid and resource._handle is handle + assert resource._active_calls == 0 and resource._pending_teardown is None + assert resource._callback_cb is callback_pin + assert resource._dynamic_assertion_cbs is dynamic_pins + assert dynamic_pins == [dynamic_pin] + finally: + # Restore the simulated parent's identity/lock for its own cleanup. + resource._owner_pid, resource._call_lock = parent_pid, parent_lock + resource.close() + + +def test_consumed_teardown_inside_call_guard_retains_pins_then_releases_without_free(monkeypatch): + # Bookkeeping-only: actual consuming FFI operations still require external + # serialization and must not consume a handle another native call borrows. + resource = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + handle = resource._handle + callback_pin = bindings.SignerCallback(lambda *args: -1) + resource._signer_callback_cb = callback_pin + dynamic_pin = (bindings.DynamicAssertionCallback(lambda *args: -1), + threading.local(), lambda *args: b"") + resource._dynamic_assertion_cbs.append(dynamic_pin) + freed = Mock() + release = Mock(wraps=resource._release) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + monkeypatch.setattr(resource, "_release", release) + with resource._native_call(): + resource._teardown(free_handle=False) + resource.close() + resource._release_handle() + assert not resource.is_valid and resource._handle is handle + assert resource._pending_teardown is False and resource._active_calls == 1 + assert resource._signer_callback_cb is callback_pin + assert resource._dynamic_assertion_cbs == [dynamic_pin] + release.assert_not_called() + freed.assert_not_called() + assert resource._handle is None and resource._pending_teardown is None + assert resource._active_calls == 0 and not resource.is_valid + assert resource._signer_callback_cb is None and resource._dynamic_assertion_cbs == [] + release.assert_called_once() + resource.close() + release.assert_called_once() + freed.assert_not_called() + + +def test_teardown_copies_dynamic_thunks_through_release_and_free(monkeypatch): + resource = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + resource._dynamic_assertion_cbs.append(( + bindings.DynamicAssertionCallback(lambda *args: -1), threading.local(), lambda *args: b"")) + callback_ref = weakref.ref(resource._dynamic_assertion_cbs[0][0]) + + def free(ptr): + assert resource._dynamic_assertion_cbs == [] + gc.collect() + assert callback_ref() is not None + return 0 + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + resource.close() + gc.collect() + assert callback_ref() is None + + +def _actual_native_reentrant_close(kind): + """Executed only in a subprocess: a stale ctypes thunk can crash Python.""" + certs = (FIXTURES / "es256_certs.pem").read_bytes() + seen, churn, holder = [], [], {} + manifest = {"claim_version": 2, "assertions": [{"label": "c2pa.actions", "data": { + "actions": [{"action": "c2pa.created", "digitalSourceType": + "http://c2pa.org/digitalsourcetype/empty"}]}}]} + + def impostor(*args): + seen.append("IMPOSTOR") + return -1 + + def close_and_churn(): + holder["resource"].close() + if "signer" in holder: + holder["signer"].close() + gc.collect() + churn.extend(bindings.SignerCallback(impostor) for _ in range(512)) + + def claim(data): + seen.append("claim") + if kind.startswith("builder"): + if kind == "builder_context_callback_close": + holder["context"].close() + assert not holder["context"].is_valid + close_and_churn() + return _fixture_claim_signature(data) + + signer = c2pa.Signer.from_callback(claim, c2pa.C2paSigningAlg.ES256, certs, None) + if kind.startswith("builder"): + if kind == "builder_explicit": + builder = c2pa.Builder(manifest) + holder["signer"] = signer + else: + context = c2pa.Context(signer=signer) + builder = c2pa.Builder(manifest, context=context) + if kind == "builder_context_callback_close": + holder["context"] = context + else: + context.close() + holder["resource"] = builder + source = io.BytesIO((FIXTURES / "A.jpg").read_bytes()) + dest = io.BytesIO() + result = (builder.sign(signer, "image/jpeg", source, dest) + if kind == "builder_explicit" else builder.sign("image/jpeg", source, dest)) + assert result and dest.getvalue() and not builder.is_valid + signer.close() + else: + config = json.loads((Path(__file__).parent / "trust_config_test_settings.json").read_text()) + context = c2pa.Context.from_dict(config, signer=signer) + key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + cose = cbor2.dumps({1: 1, 2: b"kid", 3: -8, -1: 6, -2: public}, canonical=True) + + def vsi(*args): + seen.append("binding") + close_and_churn() + return key.sign(args[-1]) + + if kind == "trusted": + resource = c2pa.TrustedVsiSession( + context, manifest, "ed25519", cose, b"kid", 1, + "2026-09-10T00:00:00Z", 86400, vsi, mode="expert_sig_structure", + reservation_nonce="0" * 32, signing_time_unix_seconds=IAT) + resource.reserve_init_uuid() + else: + resource = c2pa.LiveVideoVsiSession.from_callback( + manifest, context, vsi, "ed25519", cose, + b"kid", 1, "2026-09-10T00:00:00Z", 86400, clock=lambda: IAT) + holder["resource"] = resource + context.close() + if kind == "trusted": + result = resource.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + else: + result = resource.sign_init_segment(_unsigned_init()) + assert result and not resource.is_valid + assert seen[0] == "binding" + resource.close() + assert "claim" in seen and "IMPOSTOR" not in seen + print("actual native reentrant close:", kind, seen) + + +def _check_native_reentrant_close_subprocess(kind): + result = subprocess.run( + [sys.executable, "-c", "from tests.test_trusted_vsi_api import _actual_native_reentrant_close; " + f"_actual_native_reentrant_close({kind!r})"], + cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stdout + result.stderr + assert "actual native reentrant close:" in result.stdout + + +@pytest.mark.parametrize("kind", ["trusted", "live"]) +def test_paired_reentrant_close_native_subprocess(paired_native, kind): + _check_native_reentrant_close_subprocess(kind) + + +@pytest.mark.parametrize("kind", ["builder_context", "builder_explicit", "builder_context_callback_close"]) +def test_builder_reentrant_close_native_subprocess(kind): + _check_native_reentrant_close_subprocess(kind) + + +@pytest.mark.parametrize("kind", ["ordinary", "fragmented", "ladder"]) +@pytest.mark.parametrize("interrupt", [False, True]) +def test_builder_borrowed_signer_reentrant_close_and_automatic_close(monkeypatch, tmp_path, kind, interrupt): + """Scripted native call: both borrowed handles survive callback close.""" + error = SystemExit(7) + holder = {} + + def claim(data): + holder["builder"].close() + holder["signer"].close() + if interrupt: + raise error + return _fixture_claim_signature(data) + + signer = c2pa.Signer.from_callback(claim, c2pa.C2paSigningAlg.ES256, + (FIXTURES / "es256_certs.pem").read_bytes(), None) + builder = c2pa.Builder({}) + holder.update(builder=builder, signer=signer) + builder_handle, signer_handle = builder._handle, signer._handle + freed = [] + real_free = bindings.ManagedResource._free_native_ptr + + def free(ptr): + freed.append(ctypes.cast(ptr, ctypes.c_void_p).value) + return real_free(ptr) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + callback_ref = weakref.ref(signer._callback_cb) + + def sign(*args): + tbs = (ctypes.c_ubyte * 3)(1, 2, 3) + signature = (ctypes.c_ubyte * 2048)() + result = signer._callback_cb(None, tbs, 3, signature, len(signature)) + gc.collect() + assert builder._handle is builder_handle and signer._handle is signer_handle + assert not builder.is_valid and not signer.is_valid + assert freed == [] and callback_ref() is not None + if interrupt: + assert result == -1 + return -1 + assert result > 0 + # Failure without a callback error is sufficient to exercise cleanup; + # no fake byte allocation should reach the real native deallocator. + return -1 + + monkeypatch.setattr(bindings, "_read_native_error", lambda: "Other: scripted signing failure") + if kind == "ordinary": + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign", sign) + operation = lambda: builder.sign(signer, "image/jpeg", io.BytesIO(b"input"), io.BytesIO()) + elif kind == "fragmented": + monkeypatch.setattr(bindings, "_FRAGMENTED_SIGN_AVAILABLE", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_fragmented", sign, raising=False) + source = tmp_path / "init.mp4" + source.write_bytes(b"input") + operation = lambda: builder.sign_fragmented(signer, source, "*.m4s", tmp_path) + else: + monkeypatch.setattr(bindings, "_HAS_SIGN_LADDER", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_ladder", sign, raising=False) + operation = lambda: builder.sign_ladder(signer, ["input.mp4"], ["output.mp4"]) + with pytest.raises(SystemExit if interrupt else c2pa.C2paError) as caught: + operation() + if interrupt: + assert caught.value is error + error.__traceback__ = None + del caught + assert builder._handle is None and signer._handle is None + for handle in (builder_handle, signer_handle): + assert freed.count(ctypes.cast(handle, ctypes.c_void_p).value) == 1 + builder.close() + signer.close() + gc.collect() + assert callback_ref() is None + + +@pytest.mark.parametrize("kind", ["ordinary", "fragmented", "ladder"]) +@pytest.mark.parametrize("when", ["preflight", "admission", "interrupted_admission"]) +def test_builder_rejects_logically_closed_signer_preflight_or_admission(monkeypatch, tmp_path, kind, when): + builder = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + signer = bindings.Signer._wrap_native_handle(ctypes.pointer(bindings.C2paSigner())) + handle = signer._handle + freed = Mock(return_value=0) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + native = Mock(side_effect=AssertionError("closed Signer reached native signing")) + interruption = KeyboardInterrupt("admission interrupted") + if kind == "ordinary": + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign", native) + operation = lambda: builder.sign(signer, "image/jpeg", io.BytesIO(b"input"), io.BytesIO()) + elif kind == "fragmented": + monkeypatch.setattr(bindings, "_FRAGMENTED_SIGN_AVAILABLE", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_fragmented", native, raising=False) + source = tmp_path / "init.mp4" + source.write_bytes(b"input") + operation = lambda: builder.sign_fragmented(signer, source, "*.m4s", tmp_path) + else: + monkeypatch.setattr(bindings, "_HAS_SIGN_LADDER", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_ladder", native, raising=False) + operation = lambda: builder.sign_ladder(signer, ["input.mp4"], ["output.mp4"]) + try: + # A simulated close between preflight and call admission, not permission + # to run concurrent native operations on the borrowed Signer. + with signer._native_call(): + if when == "preflight": + signer.close() + else: + admit = signer._native_call + + def close_then_admit(): + signer.close() + if when == "interrupted_admission": + raise interruption + return admit() + monkeypatch.setattr(signer, "_native_call", close_then_admit) + expected = KeyboardInterrupt if when == "interrupted_admission" else c2pa.C2paError + with pytest.raises(expected) as caught: + operation() + if when == "interrupted_admission": + assert caught.value is interruption + else: + assert "closed" in str(caught.value) + native.assert_not_called() + assert not signer.is_valid and signer._handle is handle + assert builder.is_valid is (when == "preflight") + assert freed.call_count == (0 if when == "preflight" else 1) + assert signer._handle is None + finally: + builder.close() + signer.close() + assert freed.call_count == 2 + + +def test_builder_bad_format_preflight_keeps_builder_and_signer_active(monkeypatch): + builder = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + signer = bindings.Signer._wrap_native_handle(ctypes.pointer(bindings.C2paSigner())) + freed = Mock(return_value=0) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + native = Mock(side_effect=AssertionError("bad format reached native signing")) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign", native) + try: + with pytest.raises(c2pa.C2paError.NotSupported): + builder.sign(signer, "", io.BytesIO(b"input"), io.BytesIO()) + assert builder.is_valid and signer.is_valid + assert builder._active_calls == signer._active_calls == 0 + native.assert_not_called() + freed.assert_not_called() + finally: + builder.close() + signer.close() diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py new file mode 100644 index 00000000..f4df4c03 --- /dev/null +++ b/tests/test_trusted_vsi_build.py @@ -0,0 +1,104 @@ +"""Functional build identity is separate from immutable dev5 release inputs.""" + +import importlib.util +from pathlib import Path + +import pytest +from packaging.utils import canonicalize_name, parse_sdist_filename +from packaging.version import Version + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "functional_build", ROOT / "scripts/build_trusted_vsi_functional.py") +functional_build = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(functional_build) + + +@pytest.mark.parametrize("version", ["0.37.13.dev0", "0.37.13.dev1", "0.38.0.dev1", "1.0.dev0+local"]) +def test_new_functional_development_version(version): + assert functional_build.functional_version(version) == version + + +@pytest.mark.parametrize("version", [ + "0.37.8.dev5", "0.37.8.dev6", "0.37.8.dev5+functional", "0.37.7.dev1", + "0.37.9", "0.38.0rc1", "invalid", + # Historical functional qualification identities are older than the source. + "0.37.9.dev0", "0.37.12.dev0", +]) +def test_functional_build_rejects_release_or_dev5_identity(version): + with pytest.raises(ValueError): + functional_build.functional_version(version) + + +def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): + import subprocess + import sys + import tarfile + + before = {name: (ROOT / name).read_bytes() for name in ("pyproject.toml", "src/c2pa/c2pa.py")} + stage = tmp_path / "stage" + stage.mkdir() + functional_build.stage_source(stage, "0.37.13.dev1") + assert 'version = "0.37.13.dev1"' in (stage / "pyproject.toml").read_text() + assert "# Version: 0.37.13.dev1" in (stage / "src/c2pa/c2pa.py").read_text() + assert not (stage / "src/c2pa/libs").exists() + subprocess.run([sys.executable, "setup.py", "-q", "sdist", "--dist-dir", str(tmp_path / "dist")], + cwd=stage, check=True, capture_output=True) + # setuptools < 69 names sdists "c2pa-python-", newer "c2pa_python-"; + # compare canonicalized name/version instead of one spelling. + (sdist,) = (tmp_path / "dist").iterdir() + name, version = parse_sdist_filename(sdist.name) + assert canonicalize_name(name) == "c2pa-python" + assert version == Version("0.37.13.dev1") + root = sdist.name[:-len(".tar.gz")] + with tarfile.open(sdist) as archive: + names = archive.getnames() + info = archive.extractfile(f"{root}/PKG-INFO").read().decode() + assert "Version: 0.37.13.dev1" in info.splitlines() + for member in ("scripts/build_trusted_vsi_functional.py", + "scripts/qualify_trusted_vsi_functional.py", + "docs/trusted-vsi-python-contract.md", "src/c2pa/c2pa.py"): + assert f"{root}/{member}" in names + assert not any(name.endswith((".so", ".dll", ".dylib")) for name in names) + after = {name: (ROOT / name).read_bytes() for name in before} + assert after == before + # The checkout carries the unreleased functional source identity, never dev5. + assert 'version = "0.37.13.dev0"' in before["pyproject.toml"].decode() + + +def test_default_build_version_is_the_source_identity(monkeypatch): + monkeypatch.delenv("FUNCTIONAL_BUILD_VERSION", raising=False) + assert functional_build.source_version() == "0.37.13.dev0" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev0" + + +def test_probe_script_requires_every_functional_capability(): + assert len(functional_build.PROBES) == 6 + for name in functional_build.PROBES: + assert f"has_live_video_trusted_vsi_{name}()" in functional_build.PROBE_SCRIPT + + +def test_functional_installed_qualification_removes_source_overrides(): + source = (ROOT / "scripts/qualify_trusted_vsi_functional.py").read_text() + assert '"PYTHONPATH", "C2PA_LIBRARY_NAME"' in source + assert 'C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED="1"' in source + assert 'C2PA_FUNCTIONAL_INSTALLED_ROOT=str(environment)' in source + assert "timeout=480" in source + for required in ('C2PA_REQUIRE_SIGN_LADDER="1"', 'C2PA_REQUIRE_FRAGMENTED_FILES="1"'): + assert required in source + for name in ("test_trusted_vsi_api.py", "test_fragmented_files.py", + "test_sign_ladder.py", "test_native_ownership.py", + "test_native_ownership_opaque.py"): + assert f'"{name}"' in source + + +def test_ci_keeps_existing_trusted_author_and_label_gate(): + workflow = (ROOT / ".github/workflows/build.yml").read_text() + gate = workflow.split(" trusted-vsi-paired:", 1)[1].split(" read-version:", 1)[0] + assert "author_association == 'COLLABORATOR'" in gate + assert "author_association == 'MEMBER'" in gate + assert "contains(github.event.pull_request.labels.*.name, 'safe to test')" in gate + assert "pull_request_target" not in workflow + assert " - labeled" in workflow + assert "secrets: inherit" not in gate diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index fea79f54..4ab20d64 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -11,7 +11,9 @@ # specific language governing permissions and limitations under # each license. +import asyncio import gc +import hashlib import inspect import os import io @@ -21,6 +23,7 @@ import json import re import unittest +from unittest.mock import patch import ctypes import warnings from cryptography.hazmat.primitives import hashes, serialization @@ -64,7 +67,7 @@ def load_test_settings_json(): """ - Load default (legacy) trust configuration test settings from a + Load purpose-tagged trust configuration test settings from a JSON config file and return its content as JSON-compatible dict. The return value is used to load settings (thread_local) in tests. @@ -88,16 +91,27 @@ def load_test_settings_json(): def parse_native_version(): """ - Parse the expected native SDK version from c2pa-native-version.txt. + Parse the expected native SDK version. + + Reads c2pa-rs-preflight-ref.txt instead of c2pa-native-version.txt when + C2PA_PREFLIGHT_RUN is set: that flag is set only by + test-c2pa-rs-source-build.yml's own "Run tests" step, because the + presence of c2pa-rs-preflight-ref.txt in the checked-out tree isn't by + itself proof of anything -- an ordinary build.yml run on a branch that + happens to carry that file (e.g. this PR) still downloads and installs + the real pinned release, not the preflight ref. Returns: str: The semantic version string (e.g. "0.85.2"). """ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - version_path = os.path.join(repo_root, 'c2pa-native-version.txt') - with open(version_path, 'r') as f: + if os.environ.get('C2PA_PREFLIGHT_RUN'): + path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') + else: + path = os.path.join(repo_root, 'c2pa-native-version.txt') + with open(path, 'r') as f: raw = f.read().strip() - # Strip the "c2pa-v" prefix to get the bare semantic version. + # Strip the "c2pa-v" / "c2pa-rc-v" prefix to get the bare semantic version. return raw.split('v', 1)[1] if 'v' in raw else raw @@ -3508,7 +3522,7 @@ def test_builder_add_multiple_ingredients(self): # Test adding another ingredient ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3528,7 +3542,7 @@ def test_builder_add_multiple_ingredients_2(self): # Test adding another ingredient with a JSON string ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3557,7 +3571,7 @@ def test_builder_add_multiple_ingredients_and_resources(self): ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3615,10 +3629,17 @@ def test_builder_add_multiple_ingredients_and_resources_interleaved(self): ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() + def test_builder_add_ingredient_rejects_mismatched_format(self): + # Unlike Reader autodetection, ingredient parsing requires the real MIME. + with Builder(self.manifestDefinition) as builder: + with open(self.testPath2, "rb") as source: + with self.assertRaisesRegex(Error, "invalid header"): + builder.add_ingredient({}, "image/png", source) + def test_builder_sign_with_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._handle is not None @@ -6634,6 +6655,56 @@ def test_settings_update_dict(self): self.assertIs(result, settings) settings.close() + def test_typed_trust_fixture_preserves_legacy_memberships(self): + trust = load_test_settings_json()["trust"] + self.assertEqual(set(trust), {"anchors", "trust_config"}) + self.assertEqual( + {entry["trust_kind"] for entry in trust["anchors"]}, + {"manifest", "tsa"}, + ) + self.assertEqual(len(trust["anchors"]), 2) + # Digests pin the original nine-certificate bundle and EKU policy. + for entry in trust["anchors"]: + self.assertEqual(set(entry), {"trust_kind", "trust_uri", "trust_anchors"}) + self.assertEqual( + hashlib.sha256(entry["trust_anchors"].encode()).hexdigest(), + "f3de5e4ea3213319eedc5e3890f0ff615bf0e754323ffd20dcca8a3f1c5ab921", + ) + self.assertEqual( + hashlib.sha256(trust["trust_config"].encode()).hexdigest(), + "174983a609d76784c4ef5e2621740bf32fb615f88412d94f4fc26670365a9b81", + ) + + def test_settings_typed_trust_purposes_do_not_authorize_other_roles(self): + trust = load_test_settings_json()["trust"] + for kind in ("manifest", "cawg", "tsa"): + with self.subTest(kind=kind): + entry = dict(trust["anchors"][0], trust_kind=kind) + config = {"trust": {"anchors": [entry], "trust_config": trust["trust_config"]}} + with Settings() as settings: + self.assertIs(settings.update(config), settings) + with Context(settings) as ctx, Reader(DEFAULT_TEST_FILE, context=ctx) as reader: + self.assertEqual( + reader.get_validation_state(), + "Trusted" if kind == "manifest" else "Valid", + ) + + def test_settings_trust_updates_are_additive_and_removal_needs_fresh_context(self): + config = load_test_settings_json() + with Settings.from_dict(config) as settings, Context(settings) as original: + # Neither an empty update nor a changed entry with the same URI + # replaces the previously authorized certificates. + settings.update({"trust": {"anchors": []}}) + entries = [dict(entry, trust_anchors="") for entry in config["trust"]["anchors"]] + settings.update(json.dumps({"trust": {"anchors": entries}})) + with Context(settings) as updated: + with Settings.from_dict({"trust": {"anchors": []}}) as fresh: + with Context(fresh) as removed: + for ctx, expected in ((original, "Trusted"), (updated, "Trusted"), (removed, "Valid")): + with self.subTest(expected=expected, context=ctx): + with Reader(DEFAULT_TEST_FILE, context=ctx) as reader: + self.assertEqual(reader.get_validation_state(), expected) + def test_settings_is_valid_after_close(self): settings = Settings() settings.close() @@ -7088,6 +7159,47 @@ def callback(label, reserve_size, partial_claim): self.assertIs(raised.exception, failure) + def test_base_exceptions_from_callbacks_are_reraised_during_builder_signing(self): + # KeyboardInterrupt/SystemExit/CancelledError must not escape into + # ctypes (where they are ignored); the original object is re-raised. + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + certs = f.read() + for make in (lambda: KeyboardInterrupt("stop"), lambda: SystemExit(3), + lambda: asyncio.CancelledError("cancelled")): + for which in ("dynamic", "claim"): + failure = make() + with self.subTest(error=type(failure).__name__, callback=which): + def fail(*_): + raise failure + if which == "claim": + signer = Signer.from_callback(fail, SigningAlg.ES256, certs, None) + else: + signer = self._make_signer() + signer.add_dynamic_assertion( + fail, label="com.example.interrupt", reserve_size=64) + self.addCleanup(signer.close) + builder = Builder(self.test_manifest) + with open(DEFAULT_TEST_FILE, "rb") as source: + with self.assertRaises(type(failure)) as raised: + builder.sign(signer, "image/jpeg", source, io.BytesIO()) + self.assertIs(raised.exception, failure) + + def test_ordinary_claim_signer_exception_is_still_reported_as_c2pa_error(self): + # Pre-existing Builder behavior for Exception subclasses is unchanged. + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + certs = f.read() + + def fail(_): + raise RuntimeError("remote signer unavailable") + signer = Signer.from_callback(fail, SigningAlg.ES256, certs, None) + self.addCleanup(signer.close) + builder = Builder(self.test_manifest) + with open(DEFAULT_TEST_FILE, "rb") as source: + with self.assertRaises(Error) as raised: + builder.sign(signer, "image/jpeg", source, io.BytesIO()) + self.assertNotIsInstance(raised.exception, RuntimeError) + self.assertIsInstance(signer._callback_cb._error_state.exception, RuntimeError) + def test_closed_and_uninitialized_resources(self): signer = self._make_signer() signer.close() @@ -7819,6 +7931,23 @@ def callback(*_): self.assertIsNone(session.active_manifest_id) self.assertEqual(session.next_sequence_number, 1) + def test_callback_base_exceptions_keep_identity(self): + context = self._make_context() + self.addCleanup(context.close) + private_key, kid, created_at, _ = self._callback_session_material() + for failure in (KeyboardInterrupt("stop"), SystemExit(3), + asyncio.CancelledError("cancelled")): + with self.subTest(error=type(failure).__name__): + def callback(*_): + raise failure + with self._make_callback_session( + context, callback, private_key, kid, created_at, + ) as session: + with self.assertRaises(type(failure)) as raised: + session.sign_init_segment(self.init_segment) + self.assertIs(raised.exception, failure) + self.assertIsNone(session.active_manifest_id) + def test_callback_recovery_does_not_sign_and_resumes(self): context = self._make_context() self.addCleanup(context.close) @@ -8083,6 +8212,36 @@ class CallbackFailure(RuntimeError): session.sign_init_segment(self.init_segment) self.assertIs(raised.exception, failure) + def test_dynamic_assertion_and_claim_base_exceptions_during_init_signing(self): + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + certs = f.read() + with open(os.path.join(FIXTURES_DIR, "es256_private.key"), "rb") as f: + key = f.read() + for which in ("dynamic", "claim"): + for failure in (KeyboardInterrupt("stop"), SystemExit(3), + asyncio.CancelledError("cancelled")): + with self.subTest(callback=which, error=type(failure).__name__): + def fail(*_): + raise failure + if which == "claim": + signer = Signer.from_callback(fail, SigningAlg.ES256, certs, None) + else: + signer = Signer.from_info(C2paSignerInfo(b"es256", certs, key, None)) + signer.add_dynamic_assertion( + fail, label="com.example.live-interrupt", reserve_size=64) + settings = Settings() + settings.set("verify.verify_trust", "false") + try: + context = Context(settings=settings, signer=signer) + finally: + settings.close() + self.addCleanup(context.close) + with self._make_session(context) as session: + context.close() + with self.assertRaises(type(failure)) as raised: + session.sign_init_segment(self.init_segment) + self.assertIs(raised.exception, failure) + class TestReaderWithContext(TestContextAPIs): @@ -9599,7 +9758,8 @@ def test_construction_failure_leaves_nothing_to_free(self): c2pa_module._lib.c2pa_builder_from_json = real_json def test_context_build_null_return_frees_builder(self): - # Set a pre-consume tag in the error slot to mock a pointer rejection. + # Build is consume-first. A registry tag without a parseable address is + # ambiguous there, so the builder is released by the guarded free. settings = Settings() c2pa_module._lib.c2pa_error_set_last( b"UntrackedPointer: mocked pre-consume rejection") @@ -9663,6 +9823,154 @@ def test_consume_no_replacement_marks_consumed_on_other_error(self): self.assertIsNone(res._handle) self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + def test_consume_first_integer_handle_rejection_retains_own_handle(self): + for tag in ("UntrackedPointer", "WrongPointerType"): + with self.subTest(tag=tag): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f"Other: {tag}: 0xcafe" + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error) as caught: + res._consume_and_swap( + lambda h: None, "swap failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertEqual(res._handle_value(), 0xCAFE) + self.assertTrue(res.is_valid) + self.assertEqual(self.freed, []) + res.close() + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_consume_first_integer_handle_rejection_closes_other_handle(self): + for tag in ("UntrackedPointer", "WrongPointerType"): + with self.subTest(tag=tag): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f"Other: {tag}: 0xbeef" + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error) as caught: + res._consume_and_swap( + lambda h: None, "swap failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertIsNone(res._handle) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, []) + + def test_consume_first_unreadable_handle_uses_guarded_free(self): + res = self._FakeHandleResource() + handle = object() + res._activate(handle) + error = "Other: WrongPointerType: 0xbeef" + state = [error] + + def free(pointer): + self.freed.append(pointer) + state[0] = "Other: UntrackedPointer: cleanup error" + + with patch.object(c2pa_module, "_read_native_error", side_effect=lambda: state[0]), \ + patch.object(ManagedResource, "_free_native_ptr", side_effect=free): + self.assertIsNone(res._handle_value()) + with self.assertRaises(Error) as caught: + res._consume_into( + lambda h: None, "build failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertNotIn("cleanup error", str(caught.exception)) + self.assertEqual(state[0], "Other: UntrackedPointer: cleanup error") + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, [handle]) + + def test_handle_value_accepts_pointer_integer_and_unreadable_handles(self): + class Unreadable: + def __bool__(self): + raise ValueError("unreadable handle") + + res = self._FakeHandleResource() + for handle, expected in [(0xCAFE, 0xCAFE), + (ctypes.c_void_p(0xCAFE), 0xCAFE), + (ctypes.POINTER(c2pa_module.C2paReader)(), None), + (None, None), (object(), None), (Unreadable(), None)]: + with self.subTest(handle=type(handle).__name__): + res._handle = handle + self.assertEqual(res._handle_value(), expected) + res._handle = None + + def test_handle_value_reads_non_null_native_pointer(self): + self._use_real_frees() + with open(os.path.join(FIXTURES_DIR, "dashinit.mp4"), "rb") as init: + with Reader("video/mp4", init) as reader: + self.assertTrue(reader._handle) + # Cast reads the pointer value, not the native object's memory. + expected = ctypes.cast(reader._handle, ctypes.c_void_p).value + self.assertEqual(reader._handle_value(), expected) + self.assertTrue(reader.json()) + + def test_consume_first_addressless_rejection_preserves_error(self): + for tag in ("UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind"): + with self.subTest(tag=tag): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f"Other: {tag}: rejection without an address" + state = [error] + + def free(pointer): + self.freed.append(pointer) + state[0] = "Other: UntrackedPointer: cleanup error" + + with patch.object(c2pa_module, "_read_native_error", side_effect=lambda: state[0]), \ + patch.object(ManagedResource, "_free_native_ptr", side_effect=free): + with self.assertRaises(Error) as caught: + res._consume_no_replacement( + lambda h: -1, "set failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertEqual(state[0], "Other: UntrackedPointer: cleanup error") + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_validate_first_registry_rejections_retain_handle(self): + for error in ("Other: UntrackedPointer: 0xcafe", + "Other: WrongPointerType: 0xbeef", + "Other: PointerInUse: exclusive use", + "Other: WrongWrapperKind: shared wrapper"): + with self.subTest(error=error): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error): + res._consume_no_replacement(lambda h: -1, "set failed: {}") + self.assertTrue(res.is_valid) + self.assertEqual(self.freed, []) + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_stream_layout_is_opaque(self): + self.assertEqual(c2pa_module.C2paStream._fields_, []) + + def test_payload_registry_tags_do_not_establish_ownership(self): + for consumes_first in (False, True): + for tag in ("UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind"): + for prefix in ("Json", "Other"): + with self.subTest(consumes_first=consumes_first, tag=tag, prefix=prefix): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f'{prefix}: invalid input "{tag}: 0xcafe"' + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error) as caught: + res._consume_into( + lambda h: None, "build failed: {}", + consumes_first=consumes_first) + self.assertIn(error, str(caught.exception)) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, []) + class TestManagedResourceObjects(TestContextAPIs): """Tests native resource handling management when managed manually. @@ -9809,14 +10117,12 @@ def test_builder_with_archive_swaps_the_handle(self): context = Context() self.addCleanup(context.close) builder = Builder(self.test_manifest, context=context) - original_handle = builder._handle original_stamp = builder._owner_pid result = builder.with_archive(self._make_archive()) self.assertIs(result, builder, "with_archive should return self") - self.assertNotEqual(builder._handle, original_handle, - "the native handle was not replaced") + self.assertTrue(builder._handle) self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) # The replacement came from this process, the stamp still applies. self.assertEqual(builder._owner_pid, original_stamp) @@ -9832,15 +10138,13 @@ def test_reader_with_fragment_swaps_the_handle(self): with open(init_path, "rb") as init: reader = Reader("video/mp4", init, context=context) self.addCleanup(reader.close) - original_handle = reader._handle # The Reader consumed the first handle, so the init stream is reopened. with open(init_path, "rb") as init, open(fragment_path, "rb") as frag: result = reader.with_fragment("video/mp4", init, frag) self.assertIs(result, reader, "with_fragment should return self") - self.assertNotEqual(reader._handle, original_handle, - "the native handle was not replaced") + self.assertTrue(reader._handle) self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) self.assertEqual(reader._owner_pid, os.getpid()) @@ -10406,6 +10710,7 @@ def test_mocked_null_without_error_is_a_known_limitation(self): with open(init_path, "rb") as init: reader = Reader("video/mp4", init) + retained_handle = reader._handle real_call = c2pa_module._lib.c2pa_reader_with_fragment c2pa_module._lib.c2pa_reader_with_fragment = ( @@ -10422,10 +10727,12 @@ def test_mocked_null_without_error_is_a_known_limitation(self): c2pa_module._lib.c2pa_error_set_last( b"Other: cleared by test teardown") - # The stale tag wins, so the handle is kept. Safe here (the mock - # consumed nothing), and the reader is still usable. - self.assertIsNotNone(reader._handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + # The stale tag names another handle, so consume-first triage closes + # the reader without a free. The mock consumed nothing: the test must + # release the still-live handle that this unsupported failure leaked. + self.assertIsNone(reader._handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertEqual(c2pa_module._lib.c2pa_free(retained_handle), 0) reader.close() # Backfilling a pointer minted by a direct FFI call. Builder.from_archive diff --git a/tests/trust_config_test_settings.json b/tests/trust_config_test_settings.json index 00fe1815..b2410257 100644 --- a/tests/trust_config_test_settings.json +++ b/tests/trust_config_test_settings.json @@ -1,7 +1,18 @@ { "version": 1, "trust": { - "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\nd3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg\nRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV\nUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu\nY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG\nSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y\nithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If\nxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV\nySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO\nDCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ\njdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/\nCNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi\nEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM\nfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY\nuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK\nchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t\n9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD\nggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2\nSV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd\n+SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc\nfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa\nsjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N\ncCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N\n0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie\n4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI\nr/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1\n/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm\ngKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+\n-----END CERTIFICATE-----\n", + "anchors": [ + { + "trust_kind": "manifest", + "trust_uri": "urn:c2pa-python:test-manifest-roots", + "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\nd3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg\nRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV\nUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu\nY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG\nSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y\nithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If\nxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV\nySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO\nDCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ\njdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/\nCNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi\nEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM\nfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY\nuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK\nchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t\n9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD\nggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2\nSV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd\n+SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc\nfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa\nsjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N\ncCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N\n0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie\n4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI\nr/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1\n/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm\ngKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+\n-----END CERTIFICATE-----\n" + }, + { + "trust_kind": "tsa", + "trust_uri": "urn:c2pa-python:test-tsa-roots", + "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\nd3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg\nRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV\nUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu\nY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG\nSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y\nithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If\nxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV\nySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO\nDCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ\njdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/\nCNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi\nEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM\nfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY\nuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK\nchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t\n9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD\nggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2\nSV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd\n+SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc\nfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa\nsjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N\ncCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N\n0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie\n4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI\nr/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1\n/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm\ngKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+\n-----END CERTIFICATE-----\n" + } + ], "trust_config": "//id-kp-emailProtection\n1.3.6.1.5.5.7.3.4\n//id-kp-documentSigning\n1.3.6.1.5.5.7.3.36\n//id-kp-timeStamping\n1.3.6.1.5.5.7.3.8\n//id-kp-OCSPSigning\n1.3.6.1.5.5.7.3.9\n// MS C2PA Signing\n1.3.6.1.4.1.311.76.59.1.9\n// c2pa-kp-claimSigning\n1.3.6.1.4.1.62558.2.1\n" } }