diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 99814e93..45c81f59 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.8.dev5 + default: castlabs-v0.37.13.dev1 type: string concurrency: - group: castlabs-vsi-pypi-0.37.8.dev5 + group: castlabs-vsi-pypi-0.37.13.dev1 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.8.dev5 + ref: castlabs-v0.37.13.dev1 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.8.dev5 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.8.dev5 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.8.dev5 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.13.dev1 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.13.dev1 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.13.dev1 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.8.dev5)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.13.dev1)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.8.dev5 \ + gh release download castlabs-v0.37.13.dev1 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.8.dev5 \ + --source-ref refs/tags/castlabs-v0.37.13.dev1 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.8.dev5"); assert v.is_prerelease and str(v)=="0.37.8.dev5"' + python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev1"); assert v.is_prerelease and str(v)=="0.37.13.dev1"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.8.dev5/json) + https://pypi.org/pypi/c2pa-python/0.37.13.dev1/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 128ca2b2..7a2165fe 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,21 +3,21 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.8.dev5 + - castlabs-v0.37.13.dev1 workflow_dispatch: inputs: source_sha: - description: Full Python SHA (feat/live-video-vsi tip for dev5; paired source for trusted_vsi_only) + description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13.dev1; 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 + description: Only non-publishing paired-source trusted API tests; bypass all release jobs required: false type: boolean default: false concurrency: - group: castlabs-vsi-release-0.37.8.dev5 + group: castlabs-vsi-release-0.37.13.dev1 cancel-in-progress: false permissions: @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de fetch-depth: 1 path: c2pa-rs @@ -80,7 +80,7 @@ jobs: shell: bash run: | git -C c2pa-python fetch --no-tags origin \ - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de fetch-depth: 1 path: c2pa-rs @@ -131,7 +131,7 @@ jobs: shell: bash run: | git -C c2pa-python fetch --no-tags origin \ - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ @@ -195,7 +195,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -215,7 +215,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -278,7 +278,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de fetch-depth: 1 path: c2pa-rs @@ -302,7 +302,7 @@ jobs: throw "checked-out Cargo.lock SHA-256 changed" } git -C c2pa-python fetch --no-tags origin ` - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional $event = if ($env:GITHUB_REF.StartsWith("refs/tags/")) { "tag" } else { "manual" } python c2pa-python/scripts/castlabs_release.py validate-sources ` --python-root c2pa-python --rust-root c2pa-rs ` @@ -351,7 +351,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -371,7 +371,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -424,9 +424,9 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | test "$(find release-out -maxdepth 1 -name '*.whl' | wc -l)" -eq 1 python -m venv "${RUNNER_TEMP}/wheel-test" @@ -464,9 +464,9 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true @@ -487,7 +487,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.8.dev5' + if: github.ref == 'refs/tags/castlabs-v0.37.13.dev1' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -522,7 +522,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -539,13 +539,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -559,7 +559,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.8.dev5 + TAG=castlabs-v0.37.13.dev1 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -575,7 +575,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.8.dev5 + TAG=castlabs-v0.37.13.dev1 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -601,7 +601,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.8.dev5")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.13.dev1")' > "$1" return 0 } query_release_id() { diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index cfdd83d7..fad74d99 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -34,9 +34,10 @@ jobs: C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1" C2PA_REQUIRE_SIGN_LADDER: "1" C2PA_REQUIRE_FRAGMENTED_FILES: "1" - FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0 + C2PA_FRAGMENT_OFFSET_REQUIRED: "1" + FUNCTIONAL_BUILD_VERSION: 0.37.13.dev1 # The release smoke asserts the installed distribution version. - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev0 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 steps: - name: Require a full Python source SHA shell: bash @@ -50,9 +51,10 @@ jobs: - 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 + # Revision-3 trusted VSI plus offset-aware fragment verification: + # the same native commit the 0.37.13.dev1 release lock pins. This job + # is qualification-only and never produces release artifacts. + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -94,7 +96,7 @@ jobs: 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. + # release 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 diff --git a/README.md b/README.md index da1d8517..537eaab6 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,14 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH ### 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. +The [trusted-processor API](docs/usage.md#trusted-processor-vsi-unreleased-functional-api) +is separate from complete-buffer VSI and immutable dev5. It is first released in +`0.37.13.dev1`. Its probes are true only on a native library reporting trusted +contract revision 3 and capability mask 63 (the pinned release native does); +otherwise every probe is false and session construction/operations fail before +side effects. Expert signing accepts only exact `Sig_structure` bytes and returns +the 64-byte signature; the earlier `TrustedVsiSignResult` was removed. Import +makes no native trusted capability call. `build.yml` runs baseline-gated tests against its upstream downloaded native library (`-k "not paired"`). Separately, both it and the dedicated workflow use @@ -116,17 +118,17 @@ library (`-k "not paired"`). Separately, both it and the dedicated workflow use 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. +`castlabs/c2pa-rs@3328b2b440fd07d9f857cbefa3c6d565093425de`, the same commit as +the 0.37.13.dev1 release lock; the job logs resolved Python/Rust SHAs. The +paired job is qualification only 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. +skips **all** release prepare/build/test/publish jobs. The reusable job builds +only an isolated native library and source-import metadata, never stages a +release native, uploads a wheel, or publishes anything. Ordinary release +dispatch and tag behavior, native pins, evidence, and release identity remain +unchanged. Do not package this ABI under the immutable dev5 artifact names. To test a locally built paired native without reinstalling the Python package: @@ -143,9 +145,9 @@ 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 +### Immutable Castlabs VSI prerelease 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 dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.13.dev1` 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.96.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. @@ -157,17 +159,17 @@ The published `castlabs-v0.37.8.dev4` prerelease and its tag are immutable histo Release controls: -- Configure `castlabs-v0.37.8.dev5` as a protected tag and require approval for the `castlabs-vsi-release` environment. -- A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/live-video-vsi` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. +- Configure `castlabs-v0.37.13.dev1` as a protected tag and require approval for the `castlabs-vsi-release` environment. +- A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/trusted-vsi-functional` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. - Source checkout URLs, full SHAs, package/native versions, and the pinned `Cargo.lock` SHA-256 are asserted before each build. - The Linux wheel is built and repaired in the digest-pinned `manylinux_2_28_x86_64` image. Every wheel is tested without capability skips on clean Python 3.10 through 3.13; Python 3.10 also runs the broader unit and fragmented-file suites. - Draft release creation happens only after both platform builds and all wheel tests. Every Action is pinned by full commit SHA, every bundle and evidence document has a SHA-256 sidecar, and all release files are individually covered by GitHub build provenance. The full REST create response provides the candidate ID; an immediate exact-ID GET must confirm the expected tag, draft state, empty assets, and normalized ownership marker before cleanup can be armed. Strict validation then checks prerelease state, name, normalized release identity text, and documented `target_commitish` forms. The immutable tag is independently resolved to the source SHA immediately before creation and final verification, so GitHub's ignored/defaulted `target_commitish` is never the source authority. A rerun may discover a prior draft by list only when exactly one match exists; after discovery, all queries, uploads, and downloads use the exact release or asset ID. Missing assets upload through the exact `uploads.github.com` release-ID endpoint without clobber. Immediate upload digests are checked when available; final success always requires exact API digests and downloaded byte equality. Published, mismatched, duplicate, or unexpected state fails. Cleanup uses only exact ID, tag, empty draft state, and ownership marker, and is disabled before the first upload, so response loss and all asset-bearing or reconciled drafts are preserved for manual inspection. The completed output remains a draft intentionally for operator finalization. - [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) is a separate manual workflow protected by the `pypipublish` environment. It requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. - PyPI does not provide a multi-file transaction. Promotion therefore queries the version JSON API first. A fresh release uploads both platform wheels together; a retry accepts an existing wheel only when PyPI's SHA-256 exactly matches the local attested wheel, then uploads all missing wheels together. A mismatched or unexpected PyPI file fails. This policy intentionally publishes no source distribution for the two-platform prerelease and safely recovers from an unavoidable prior partial wheel upload. -Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.8.dev5 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. +Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.13.dev1 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. -`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to dev5. +`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13.dev1`. At the pinned c2pa-rs commit, VSI initialization signs through `Builder::from_shared_context`, so it inherits DynamicAssertions registered on the Context's claim signer. The common Python callback bridge enforces `reserve_size` as the exact serialized CBOR size for every signing path. The earlier `assertion.bmffHash.mismatch` came from returning only 5 bytes for a 64-byte reservation; it was not an inherent DynamicAssertion-plus-VSI incompatibility. The release smoke now returns valid exact-size CBOR, requires clean Reader validation for both standalone image and combined VSI init signing, and exercises combined media signing and recovery without an xfail. @@ -206,6 +208,10 @@ can restore a callback-backed session without invoking the key. See `has_fragmented_files()` reports whether the loaded native library contains the Castlabs fragmented file APIs. When available, `Builder.sign_fragmented()` signs an initialization segment and its media-fragment glob, while `Reader.from_fragmented_files()` validates the signed file set. Standard upstream native binaries remain importable; unavailable operations raise `C2paError.NotSupported`. See [Using the Python library](docs/usage.md#fragmented-bmff-file-sets). +## Byte-range fragments of single-file fragmented MP4 + +`has_fragment_at_offset()` reports whether the loaded native library exports `c2pa_reader_with_fragment_at_offset` and its probe returns true. When available, `Reader.from_fragment_at_offset(format, init_stream, fragment_stream, fragment_offset, context=None)` (or `Reader.with_fragment_at_offset(...)` on an existing reader) verifies one byte-range slice of a signed single-file fragmented MP4 at its absolute offset in the original file. A wrong cut or offset yields an `Invalid` reader with `assertion.bmffHash.mismatch` rather than an exception. Older native libraries remain importable; the methods raise `C2paError.NotSupported` before touching native state. See [Using the Python library](docs/usage.md#byte-range-fragments-of-single-file-fragmented-mp4). + ## Examples See the [`examples` directory](https://github.com/castlabs/c2pa-python/tree/feat/live-video-vsi/examples) for some helpful examples: diff --git a/docs/release-notes.md b/docs/release-notes.md index bb3a9d31..c8302e20 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,45 @@ # Release notes -## Unreleased: functional trusted-processor VSI API +## Version 0.37.13.dev1 + +Castlabs VSI prerelease (tag `castlabs-v0.37.13.dev1`, released from +`feat/trusted-vsi-functional`). It is the first release of the trusted-processor +VSI line. The immutable `0.37.8.dev5` release inputs and artifacts +(`feat/live-video-vsi`) stay unchanged. + +- Native `castlabs/c2pa-rs` `3328b2b440fd07d9f857cbefa3c6d565093425de` + (workspace `0.92.0-dev`, `Cargo.lock` SHA-256 + `f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370`), built + with Rust `1.96.0`. The previous line used `9b80687f` (`0.91.0-dev`, Rust + `1.88.0`). +- Native trusted-processor VSI line at contract revision 3 with the complete + capability mask 63. The Python probes require the explicit revision-3 probe, + the exact functional symbol set and mask 63, and otherwise fail closed. +- Dependency advisories fixed in the native lockfile: `event-listener` 5.4.2, + `rustls` 0.23.46 (RUSTSEC-2026-0346 to -0349) and `quinn-proto` 0.11.18 + (RUSTSEC-2026-0338 to -0342). +- Context trust policies keep the built-in default EKU allow-list. Before the + fix, `Store::from_context` cleared it, so `documentSigning`-only leaf + certificates failed with `signingCredential.invalid`. Default trust anchors + and private credentials are still not restored. +- Offset-aware fragment verification for single-file fragmented MP4: + `has_fragment_at_offset()`, `Reader.from_fragment_at_offset()` and + `Reader.with_fragment_at_offset()` (details below). The release smoke now + requires `has_fragment_at_offset()`. +- `c2pa-native-version.txt` stays `c2pa-v0.91.0`. It pins the upstream + prebuilt library that `build.yml` downloads. Source-built Castlabs libraries + are checked against `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` instead. + +### Offset-aware fragment verification + +- Adds optional bindings for `c2pa_reader_with_fragment_at_offset` and + `c2pa_reader_fragment_at_offset_supported`, `has_fragment_at_offset()`, and + `Reader.from_fragment_at_offset()` / `Reader.with_fragment_at_offset()` to + verify a byte-range fragment of a single-file fragmented MP4 at its absolute + offset. Native libraries without the exports still import; the API raises + `C2paError.NotSupported`. + +### Functional trusted-processor VSI API - Replaces the unshipped counter/result scaffold with `TrustedVsiSession.sign_sig_structure(sig_structure, sequence_number) @@ -18,13 +57,14 @@ - 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 + Functional artifacts use the source identity, `0.37.13.dev1` for this + release (before it `0.37.13.dev0`, and historically `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. +- First paired with the consolidated native `0.92.0-dev` + (`castlabs/c2pa-rs@6b506352`, Rust 1.96.0), now `3328b2b4`. 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 diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 7d308ced..cd3c4f51 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -3,10 +3,12 @@ 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 +`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev1`, +prepared as the Castlabs VSI prerelease on native +`3328b2b440fd07d9f857cbefa3c6d565093425de`; immutable dev5 release inputs and +artifacts are unchanged. The paired source workflow pins the same native commit +(previously `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`) for revision-3 +qualification. 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 @@ -241,14 +243,14 @@ CBOR, BMFF or validation results. ` 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. +`0.37.13.dev1`; rejects `0.37.8.*`, dev5, release versions and anything older +than the source identity, including `0.37.13.dev0` and the historical +`0.37.9.dev0`). `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native +library only. The release lock and tooling now match this source +(`castlabs-v0.37.13.dev1`); the immutable dev5 release remains historical. `scripts/qualify_trusted_vsi_functional.py --wheel --venv ---version 0.37.13.dev0` installs the wheel into an isolated venv, strips +--version 0.37.13.dev1` 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`, @@ -260,16 +262,14 @@ 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. +builds the native with Rust 1.96.0 from castlabs/c2pa-rs +`3328b2b440fd07d9f857cbefa3c6d565093425de`, the same commit as the +`castlabs-v0.37.13.dev1` release lock (trusted contract revision 3, mask 63, +offset-aware fragment exports). It sets `C2PA_FRAGMENT_OFFSET_REQUIRED=1`, so a +native library without the fragment-offset probe fails rather than skips. Older +pins (`6b506352`, `203dc08d`, `5c186c07`) emitted state version 2 and lacked the +revision probe; they cannot qualify this gate. No tests or capability gates are +weakened. ## PR4 CI Gate diff --git a/docs/usage.md b/docs/usage.md index ddadae57..4a1ea458 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -414,6 +414,53 @@ non-consuming. The returned shared manifest buffer is copied into Python-owned 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. +## Byte-range fragments of single-file fragmented MP4 + +A single-file fragmented MP4 signed with fragment Merkle bindings hashes +absolute root-box offsets, so one byte-range slice of it (for example an HLS +`EXT-X-BYTERANGE` segment) verifies only at the offset where it sat in the +original file. Check `has_fragment_at_offset()` first; it requires both native +exports (`c2pa_reader_with_fragment_at_offset`, +`c2pa_reader_fragment_at_offset_supported`) and a true probe, independent of +the trusted VSI revision/capability mask. The probe is called on first use, +never at import. + +```py +import io +from c2pa import Reader, has_fragment_at_offset + +if not has_fragment_at_offset(): + raise RuntimeError("loaded native library cannot verify fragments at an offset") + +# init_bytes: every byte before the first C2PA `merkle` uuid box. +# segment: from a `merkle` uuid box up to the next one (or end of file), +# found at absolute offset `segment_offset` in the signed file. +with Reader.from_fragment_at_offset( + "video/mp4", + io.BytesIO(init_bytes), + io.BytesIO(segment), + segment_offset, + context=verification_context, +) as reader: + print(reader.get_validation_state()) # "Trusted" with matching trust anchors +``` + +A wrong cut, or the right cut at the wrong offset, does not raise: the reader +reports validation state `Invalid` with `assertion.bmffHash.mismatch`. The +offset is caller-supplied; it proves the bytes belong to the Merkle leaf they +name, not where they were fetched from. `fragment_offset=0` matches +`with_fragment()`. + +Python validates the format (a non-empty `str`; no auto-detection), offset (an `int` in `0..2**64-1`, `bool` +rejected), stream-likeness, and that the two streams are distinct objects +before any native call; unavailable native support raises +`C2paError.NotSupported` with no side effects. Settings and trust come from +`context`; without one, a fresh default Context (default SDK settings, no +custom trust anchors) is used. `Reader.with_fragment_at_offset()` applies the +same operation to an existing reader (create it with a Context): like +`with_fragment()`, the native call consumes the underlying reader on success +and failure, so a native failure closes that Reader. + ## File-based operation ### Read and validate C2PA data diff --git a/pyproject.toml b/pyproject.toml index 9eb5b58f..3c49910a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.13.dev0" +version = "0.37.13.dev1" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index b9703c71..6a3d73b8 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/castlabs/c2pa-python/blob/feat/live-video-vsi/release/castlabs-release-evidence.schema.json", + "$id": "https://github.com/castlabs/c2pa-python/blob/feat/trusted-vsi-functional/release/castlabs-release-evidence.schema.json", "title": "Castlabs c2pa-python release evidence", "type": "object", "additionalProperties": false, @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.8.dev5"}, + "version": {"const": "0.37.13.dev1"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,9 +49,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "9b80687f386f2b373fd1ac9c705d8e61a07b608e"}, - "version": {"const": "0.91.0-dev"}, - "cargoLockSha256": {"const": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f"} + "commit": {"const": "3328b2b440fd07d9f857cbefa3c6d565093425de"}, + "version": {"const": "0.92.0-dev"}, + "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} } } } @@ -90,7 +90,7 @@ "type": "object", "additionalProperties": false, "required": ["target", "rustToolchain", "cargoCommand", "cargoTreeCommand", "noDefaultFeatures", "features", "resolvedFeatureReportSha256", "sourceDateEpoch", "cargoBuildJobs", "cargoIncremental", "pythonHashSeed", "container"], "properties": { - "target": {"enum": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]}, "rustToolchain": {"const": "1.88.0"}, + "target": {"enum": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]}, "rustToolchain": {"const": "1.96.0"}, "cargoCommand": {"type": "array", "minItems": 12, "items": {"type": "string"}}, "noDefaultFeatures": {"const": true}, "cargoTreeCommand": {"type": "array", "minItems": 16, "items": {"type": "string"}}, "features": {"const": ["rust_native_crypto", "http", "add_thumbnails", "file_io", "unstable_live_video"]}, "resolvedFeatureReportSha256": {"$ref": "#/$defs/sha256"}, diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index c5e082f2..cfa8d565 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,23 +2,23 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.8.dev5" + "version": "0.37.13.dev1" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", - "releaseBranch": "feat/live-video-vsi", - "releaseTag": "castlabs-v0.37.8.dev5" + "releaseBranch": "feat/trusted-vsi-functional", + "releaseTag": "castlabs-v0.37.13.dev1" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "9b80687f386f2b373fd1ac9c705d8e61a07b608e", - "version": "0.91.0-dev", - "cargoLockSha256": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f" + "commit": "3328b2b440fd07d9f857cbefa3c6d565093425de", + "version": "0.92.0-dev", + "cargoLockSha256": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" }, "rustToolchain": { - "channel": "1.88.0", + "channel": "1.96.0", "rustupVersion": "1.28.2", "installers": { "x86_64-unknown-linux-gnu": { diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 7fdb3df7..e0460536 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/castlabs/c2pa-python/blob/feat/live-video-vsi/release/castlabs-vsi-inputs.schema.json", + "$id": "https://github.com/castlabs/c2pa-python/blob/feat/trusted-vsi-functional/release/castlabs-vsi-inputs.schema.json", "title": "Castlabs VSI c2pa-python release input lock", "type": "object", "additionalProperties": false, @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.8.dev5"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13.dev1"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -38,8 +38,8 @@ "properties": { "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, - "releaseBranch": {"const": "feat/live-video-vsi"}, - "releaseTag": {"const": "castlabs-v0.37.8.dev5"} + "releaseBranch": {"const": "feat/trusted-vsi-functional"}, + "releaseTag": {"const": "castlabs-v0.37.13.dev1"} } }, "rustSource": { @@ -48,9 +48,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "9b80687f386f2b373fd1ac9c705d8e61a07b608e"}, - "version": {"const": "0.91.0-dev"}, - "cargoLockSha256": {"const": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f"} + "commit": {"const": "3328b2b440fd07d9f857cbefa3c6d565093425de"}, + "version": {"const": "0.92.0-dev"}, + "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} } }, "installer": { @@ -62,7 +62,7 @@ "type": "object", "additionalProperties": false, "required": ["channel", "rustupVersion", "installers"], "properties": { - "channel": {"const": "1.88.0"}, + "channel": {"const": "1.96.0"}, "rustupVersion": {"const": "1.28.2"}, "installers": { "type": "object", "additionalProperties": false, diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index a280922f..a093f741 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,23 +26,25 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.8.dev5" -RUST_COMMIT = "9b80687f386f2b373fd1ac9c705d8e61a07b608e" -CARGO_LOCK_SHA256 = "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f" -RUST_TOOLCHAIN = "1.88.0" +RELEASE_VERSION = "0.37.13.dev1" +RUST_COMMIT = "3328b2b440fd07d9f857cbefa3c6d565093425de" +CARGO_LOCK_SHA256 = "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" +RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.8.dev5" +RELEASE_TAG = "castlabs-v0.37.13.dev1" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" - f"{RELEASE_IDENTITY_TEXT}. Successor to immutable dev4; enforces exact " - "DynamicAssertion callback sizing for every signing path. See the attached " - "schema-2 evidence and SHA-256 sidecars." + f"{RELEASE_IDENTITY_TEXT}. Trusted-processor VSI line (native contract " + "revision 3, capability mask 63) on castlabs/c2pa-rs 3328b2b4 (0.92.0-dev, " + "Rust 1.96.0): offset-aware single-file fMP4 fragment verification, default " + "EKU trust-policy fix and patched event-listener/rustls/quinn-proto. See " + "the attached schema-2 evidence and SHA-256 sidecars." ) @@ -218,12 +220,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.8.dev5") + fail("release lock package identity is not 0.37.13.dev1") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", - "releaseBranch": "feat/live-video-vsi", - "releaseTag": "castlabs-v0.37.8.dev5", + "releaseBranch": "feat/trusted-vsi-functional", + "releaseTag": "castlabs-v0.37.13.dev1", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] @@ -231,7 +233,7 @@ def validate_lock(lock: dict[str, Any]) -> None: "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", "commit": RUST_COMMIT, - "version": "0.91.0-dev", + "version": "0.92.0-dev", "cargoLockSha256": CARGO_LOCK_SHA256, }: fail("unexpected c2pa-rs release source policy") diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 9e571e9b..0fa9d8f1 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -42,6 +42,7 @@ TrustedVsiStatus, has_dynamic_assertions, has_fragmented_files, + has_fragment_at_offset, has_live_video_vsi, has_live_video_vsi_callbacks, has_live_video_vsi_explicit_time, @@ -84,6 +85,7 @@ 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', + 'has_fragment_at_offset', 'has_live_video_vsi', 'has_live_video_vsi_callbacks', 'has_live_video_vsi_explicit_time', diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 986f6199..57f61898 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -11,7 +11,7 @@ # specific language governing permissions and limitations under # each license. -# Version: 0.37.13.dev0 +# Version: 0.37.13.dev1 import ctypes import enum @@ -200,6 +200,14 @@ _FRAGMENTED_CONTEXT_READER_FUNCTIONS = ( 'c2pa_reader_from_fragmented_files_context', ) +# Castlabs offset-aware single-file fragment verification. Optional so older +# native libraries still import; admitted only when the stateless probe also +# reports support. +_FRAGMENT_AT_OFFSET_FUNCTIONS = ( + 'c2pa_reader_with_fragment_at_offset', + 'c2pa_reader_fragment_at_offset_supported', +) +_FRAGMENT_OFFSET_MAX = 2**64 - 1 def _validate_library_exports(lib): @@ -328,6 +336,12 @@ def _trusted_vsi_version_matches(native_version: bytes) -> bool: _FRAGMENTED_CONTEXT_READER_AVAILABLE = all( hasattr(_lib, name) for name in _FRAGMENTED_CONTEXT_READER_FUNCTIONS ) +_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE = all( + hasattr(_lib, name) for name in _FRAGMENT_AT_OFFSET_FUNCTIONS +) +# Cached result of c2pa_reader_fragment_at_offset_supported(); None until +# has_fragment_at_offset() first runs. Import never calls the probe. +_FRAGMENT_AT_OFFSET_PROBE = None class C2paSeekMode(enum.IntEnum): @@ -1359,6 +1373,19 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paReader) ) +if _FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE: + _setup_function( + _lib.c2pa_reader_with_fragment_at_offset, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream), + ctypes.c_uint64], + ctypes.POINTER(C2paReader) + ) + _setup_function( + _lib.c2pa_reader_fragment_at_offset_supported, + [], + ctypes.c_bool + ) _setup_function( _lib.c2pa_builder_from_context, [ctypes.POINTER(C2paContext)], @@ -2608,6 +2635,61 @@ def has_fragmented_files() -> bool: ) +def has_fragment_at_offset() -> bool: + """Return whether offset-aware single-file fragment verification works. + + Requires both ``c2pa_reader_with_fragment_at_offset`` and + ``c2pa_reader_fragment_at_offset_supported`` exports, and the probe must + report ``True``. Independent of the trusted VSI revision and capability + mask. The stateless probe is called on first use (never at import) and + its result is cached. + """ + global _FRAGMENT_AT_OFFSET_PROBE + if not _FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE: + return False + if _FRAGMENT_AT_OFFSET_PROBE is None: + _FRAGMENT_AT_OFFSET_PROBE = bool( + _lib.c2pa_reader_fragment_at_offset_supported()) + return _FRAGMENT_AT_OFFSET_PROBE + + +def _require_fragment_at_offset(): + if not has_fragment_at_offset(): + raise C2paError.NotSupported( + "Offset-aware fragment verification is unavailable in the loaded " + "native library; it requires the Castlabs c2pa-rs fork exporting " + "c2pa_reader_with_fragment_at_offset") + + +def _validate_fragment_at_offset_args(format, stream, fragment_stream, + fragment_offset): + """Validate offset-aware fragment arguments without touching native code. + + Returns the native format argument and the offset. + """ + if not isinstance(format, str): + raise TypeError("format must be a str (MIME type)") + if not format.strip(): + # Native fragment loading cannot auto-detect the container. + raise ValueError("format must be a non-empty MIME type") + if "\0" in format: + raise ValueError("format must not contain a NUL character") + if isinstance(fragment_offset, bool) or not isinstance( + fragment_offset, int): + raise TypeError("fragment_offset must be an integer") + if not 0 <= fragment_offset <= _FRAGMENT_OFFSET_MAX: + raise ValueError( + f"fragment_offset must be between 0 and {_FRAGMENT_OFFSET_MAX}") + if not Stream.is_read_stream(stream): + raise TypeError("stream must be a stream-like object") + if not Stream.is_read_stream(fragment_stream): + raise TypeError("fragment_stream must be a stream-like object") + if stream is fragment_stream: + raise ValueError( + "stream and fragment_stream must be distinct stream objects") + return _format_ffi_arg(_encode_format(format, "Reader")), fragment_offset + + class TrustedVsiOperation(enum.IntEnum): RESERVE_INIT = 0 FINALIZE_INIT = 1 @@ -4202,6 +4284,91 @@ def from_fragmented_files( context, '_dynamic_assertion_cbs', ())) return reader + @classmethod + def from_fragment_at_offset( + cls, + format: str, + init_stream: Any, + fragment_stream: Any, + fragment_offset: int, + context: Optional['ContextProvider'] = None, + ) -> 'Reader': + """Create a Reader verifying one byte-range fragment of a single-file + fragmented MP4 at its absolute offset. + + See :meth:`with_fragment_at_offset` for the cut rules and result + semantics: a wrong cut or offset yields a Reader whose validation + state is ``Invalid`` with ``assertion.bmffHash.mismatch``, not an + exception. + + Args: + format: Non-empty MIME type (e.g. "video/mp4"). + init_stream: Stream-like object with every byte before the first + C2PA ``merkle`` uuid box. + fragment_stream: Stream-like object with the fragment bytes; must + be a different object from ``init_stream``. + fragment_offset: Absolute byte offset of the fragment's first byte + in the original asset (0 to 2**64-1). + context: Optional active ContextProvider supplying verification + and trust settings. When omitted, a fresh default Context is + used (default SDK settings, no custom trust anchors). + + Returns: + A Reader owning the configured native handle. + + Raises: + C2paError.NotSupported: If the loaded native library lacks the + API. Raised before arguments are inspected or any native + object is created. + TypeError, ValueError: For invalid arguments, before any native + call. + C2paError: If the native reader cannot be created (for example + when the init stream carries no manifest). + """ + _require_fragment_at_offset() + format_arg, offset = _validate_fragment_at_offset_args( + format, init_stream, fragment_stream, fragment_offset) + if context is not None: + if not isinstance(context, ContextProvider): + raise TypeError("context must implement ContextProvider") + if not context.is_valid: + raise C2paError("Context is not valid") + + owned_context = Context() if context is None else None + active_context = context if context is not None else owned_context + try: + reader_ptr = _lib.c2pa_reader_from_context( + active_context.execution_context) + _check_ffi_operation_result( + reader_ptr, Reader._ERROR_MESSAGES['reader_error']) + try: + reader = cls._wrap_native_handle(reader_ptr) + except Exception: + ManagedResource._free_native_ptr(reader_ptr) + raise + + if context is not None: + # The native Reader shares the Context; pin the Python + # Context and its callback state for the same lifetime. + reader._context = context + reader._signer_callback_cb = getattr( + context, '_signer_callback_cb', None) + reader._dynamic_assertion_cbs = list(getattr( + context, '_dynamic_assertion_cbs', ())) + + try: + reader._apply_fragment_at_offset( + format_arg, init_stream, fragment_stream, offset) + except Exception: + reader.close() + raise + return reader + finally: + # A default Context carries no Python callbacks; the native + # Reader keeps its own reference, so it can be released here. + if owned_context is not None: + owned_context.close() + @overload def __init__( self, @@ -4558,6 +4725,73 @@ def with_fragment(self, format: Optional[str], stream, return self + def with_fragment_at_offset(self, format: str, stream, + fragment_stream, + fragment_offset: int) -> "Reader": + """Verify one byte-range fragment of a single-file fragmented MP4. + + A single-file fragmented MP4 signed with fragment Merkle bindings + hashes absolute root-box offsets, so a byte-range slice of it (for + example an HLS ``EXT-X-BYTERANGE`` segment) only verifies at the + offset where it sat in the original file. ``stream`` holds every byte + before the first C2PA ``merkle`` uuid box; ``fragment_stream`` starts + at a ``merkle`` uuid box and ends right before the next one (or at the + end of the file). ``fragment_offset=0`` behaves like + :meth:`with_fragment`. + + A wrong cut or offset does not raise: the reader reports + ``assertion.bmffHash.mismatch`` and validation state ``Invalid``. The + offset is caller-supplied; it proves the bytes belong to the Merkle + leaf they name, not where they were fetched from. + + Settings and trust come from this reader, so create it with a + Context (or use :meth:`from_fragment_at_offset`). + + Args: + format: Non-empty MIME type (e.g. "video/mp4"). + stream: Stream-like object with the initialization bytes. + fragment_stream: Stream-like object with the fragment bytes; must + be a different object from ``stream``. + fragment_offset: Absolute byte offset of the fragment's first byte + in the original single-file asset (0 to 2**64-1). + + Returns: + This reader instance, for method chaining. + + Raises: + C2paError.NotSupported: If the loaded native library lacks the API + (this reader is left unchanged). + TypeError, ValueError: For invalid arguments (reader unchanged). + C2paError: If the native call fails. The native call consumes the + underlying reader on failure too, so this Reader is then + closed and cannot be reused. + """ + _require_fragment_at_offset() + format_arg, offset = _validate_fragment_at_offset_args( + format, stream, fragment_stream, fragment_offset) + self._ensure_valid_state() + self._apply_fragment_at_offset( + format_arg, stream, fragment_stream, offset) + return self + + def _apply_fragment_at_offset(self, format_arg, stream, fragment_stream, + offset): + with Stream(stream) as main_obj, Stream(fragment_stream) as frag_obj: + self._consume_and_swap( + lambda handle: _lib.c2pa_reader_with_fragment_at_offset( + handle, + format_arg, + main_obj._stream, + frag_obj._stream, + offset, + ), + Reader._ERROR_MESSAGES['fragment_error'], + consumes_first=True) + + # The native reader now holds a different store; drop stale caches. + self._manifest_json_str_cache = None + self._manifest_data_cache = None + def json(self) -> str: """Get the manifest store as a JSON string. @@ -6367,6 +6601,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', + 'has_fragment_at_offset', 'has_live_video_vsi', 'has_live_video_vsi_callbacks', 'has_live_video_vsi_explicit_time', diff --git a/tests/fixtures/single-file-fragmented/README.md b/tests/fixtures/single-file-fragmented/README.md index 058f0abf..e2a84c71 100644 --- a/tests/fixtures/single-file-fragmented/README.md +++ b/tests/fixtures/single-file-fragmented/README.md @@ -23,3 +23,25 @@ ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc2=size=32x32:rate=2 -t 3 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. + +## Per-fragment `sidx` fixture + +`single_file_fragments_sidx.mp4` is the same synthetic source muxed with a +per-fragment `sidx` before every `moof` (version 1 boxes), the layout +byte-range HLS packagers cut at. `tests/test_fragment_at_offset.py` signs it and +verifies each Merkle-uuid-delimited slice at its absolute offset. + +The bytes match c2pa-rs `sdk/tests/fixtures/single_file_fragments_sidx.mp4` +exactly (castlabs/c2pa-rs commit `ec9ac2f6917201a5cc69c0ef12f756228e359da2`, +branch `feat/trusted-vsi-fragment-offset`). + +- Size: 3,892 bytes +- Git blob: `3158e05962c808fd25be713bb8d7f84214275233` +- SHA-256: `8d95ae1959740b8fd02d30ad9e4eb04da7dc26869c14605677c084ce9b03ccff` + +```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+dash \ + -y single_file_fragments_sidx.mp4 +``` diff --git a/tests/fixtures/single-file-fragmented/single_file_fragments_sidx.mp4 b/tests/fixtures/single-file-fragmented/single_file_fragments_sidx.mp4 new file mode 100644 index 00000000..3158e059 Binary files /dev/null and b/tests/fixtures/single-file-fragmented/single_file_fragments_sidx.mp4 differ diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 0058218b..95c1fd33 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -34,6 +34,7 @@ Settings, Signer, has_dynamic_assertions, + has_fragment_at_offset, has_fragmented_files, has_live_video_vsi, has_live_video_vsi_callbacks, @@ -45,10 +46,11 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.8.dev5") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13.dev1") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, + "fragment at offset": has_fragment_at_offset, "live-video VSI": has_live_video_vsi, "VSI callbacks": has_live_video_vsi_callbacks, "VSI recovery": has_live_video_vsi_recovery, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index d383cf25..6f160c4e 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -49,15 +49,13 @@ def _posix_bash() -> str: def test_prerelease_version_is_consistent(): - # 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. + # The release identity is consistent across the tooling, lock and source, + # so validate-sources accepts this checkout under the 0.37.13.dev1 lock. + assert release.RELEASE_VERSION == "0.37.13.dev1" + assert release.RELEASE_TAG == "castlabs-v0.37.13.dev1" + assert release.load_lock()["package"]["version"] == "0.37.13.dev1" source = release.project_version(ROOT) - assert source == "0.37.13.dev0" - assert source != release.RELEASE_VERSION + assert source == release.RELEASE_VERSION first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) @@ -69,7 +67,13 @@ def test_release_lock_and_schemas_are_valid_json(): release.validate_lock(lock) assert lock["rustSource"]["commit"] == release.RUST_COMMIT assert lock["rustSource"]["cargoLockSha256"] == release.CARGO_LOCK_SHA256 - assert lock["rustToolchain"]["channel"] == "1.88.0" + assert lock["rustToolchain"]["channel"] == "1.96.0" + assert release.RUST_COMMIT == "3328b2b440fd07d9f857cbefa3c6d565093425de" + assert release.CARGO_LOCK_SHA256 == ( + "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" + ) + assert lock["rustSource"]["version"] == "0.92.0-dev" + assert lock["pythonSource"]["releaseBranch"] == "feat/trusted-vsi-functional" for name in ( "castlabs-vsi-inputs.schema.json", "castlabs-release-evidence.schema.json", @@ -88,7 +92,7 @@ def test_release_lock_and_schemas_are_valid_json(): jsonschema.validate(lock, schema) -def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): +def test_trusted_vsi_workflows_isolate_paired_abi_from_release(): 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") @@ -114,7 +118,11 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): # 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 f"FUNCTIONAL_BUILD_VERSION: {release.RELEASE_VERSION}" in paired + assert f"CASTLABS_RELEASE_EXPECTED_VERSION: {release.RELEASE_VERSION}" in paired + # The paired qualification uses the release-locked native commit. + assert f"ref: {release.RUST_COMMIT}" in paired + assert 'C2PA_FRAGMENT_OFFSET_REQUIRED: "1"' 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 @@ -187,7 +195,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -238,7 +246,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.8.dev5'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.13.dev1'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -312,9 +320,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.8.dev5" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.13.dev1" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.8.dev5/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.13.dev1/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -343,7 +351,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.8.dev5' not in legacy_workflow + assert 'test "$VERSION" != 0.37.13.dev1' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -373,7 +381,10 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1") == 2 + assert release_workflow.count("C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev") == 2 + # The pinned native exports offset-aware fragment verification. + assert '"fragment at offset": has_fragment_at_offset,' in smoke assert "pytest.skip(" in smoke assert "unittest.skip" not in smoke dynamic_start = smoke.index( @@ -436,7 +447,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.8.dev5" in release_notes + assert "## Version 0.37.13.dev1" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -631,7 +642,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -642,7 +653,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: @@ -1070,7 +1081,7 @@ def test_release_response_validators_cover_owned_empty_and_complete_states(tmp_p ) == release_id ) - for allowed_target in ("main", "feat/live-video-vsi", TEST_SOURCE_SHA): + for allowed_target in ("main", "feat/trusted-vsi-functional", TEST_SOURCE_SHA): target_response = copy.deepcopy(create_response) target_response["target_commitish"] = allowed_target assert ( diff --git a/tests/test_fragment_at_offset.py b/tests/test_fragment_at_offset.py new file mode 100644 index 00000000..445743c4 --- /dev/null +++ b/tests/test_fragment_at_offset.py @@ -0,0 +1,537 @@ +"""Offset-aware single-file fragment verification (Castlabs native API). + +Scripted tests exercise Python gating, argument validation and marshalling +without the native entry point (they delegate to ``c2pa_reader_with_fragment``, +which every supported native library exports). Tests marked ``real native`` +need ``c2pa_reader_with_fragment_at_offset``: they skip when the loaded library +lacks it, and FAIL instead under ``C2PA_FRAGMENT_OFFSET_REQUIRED=1``. +""" + +import ctypes +import gc +import io +import json +import os +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import c2pa +import c2pa.c2pa as bindings +from c2pa import C2paError, Context, Reader, Settings + + +FIXTURES = Path(__file__).parent / "fixtures" +SIDX_FIXTURE = FIXTURES / "single-file-fragmented" / "single_file_fragments_sidx.mp4" +C2PA_BOX_UUID = bytes([ + 0xd8, 0xfe, 0xc3, 0xd6, 0x1b, 0x0e, 0x48, 0x3c, + 0x92, 0x97, 0x58, 0x28, 0x87, 0x7e, 0xc4, 0x81, +]) +MATCH = "assertion.bmffHash.match" +MISMATCH = "assertion.bmffHash.mismatch" +U64_MAX = 2**64 - 1 + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +def _forbid_native(monkeypatch): + """Replace _lib and Context so any native touch fails the test.""" + forbidden = Mock(side_effect=AssertionError("native touched")) + monkeypatch.setattr(bindings, "_lib", forbidden) + monkeypatch.setattr(bindings, "Context", forbidden) + return forbidden + + +def _enable_gate(monkeypatch): + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", True) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", True) + + +def _signed_reader(): + """A real Reader over a signed asset (no offset API involved).""" + return Reader(str(FIXTURES / "C.jpg")) + + +def _trust_context(): + settings = Settings.from_dict(json.loads( + (Path(__file__).parent / "trust_config_test_settings.json").read_text())) + try: + return Context(settings=settings) + finally: + settings.close() + + +def _merkle_uuid_starts(data: bytes): + """Start offsets of root C2PA ``merkle`` uuid boxes, in file order.""" + starts, pos = [], 0 + while pos + 8 <= len(data): + size = int.from_bytes(data[pos:pos + 4], "big") + kind = data[pos + 4:pos + 8] + header = 8 + if size == 0: + size = len(data) - pos + elif size == 1: + size = int.from_bytes(data[pos + 8:pos + 16], "big") + header = 16 + assert header <= size and pos + size <= len(data), f"bad box at {pos}" + payload = pos + header + if (kind == b"uuid" and pos + size >= payload + 16 + 4 + 7 + and data[payload:payload + 16] == C2PA_BOX_UUID + and data[payload + 20:payload + 27] == b"merkle\0"): + starts.append(pos) + pos += size + return starts + + +def _codes(reader, kind): + results = reader.get_validation_results() or {} + active = results.get("activeManifest") or {} + return {entry.get("code") for entry in active.get(kind, [])} + + +# -------------------------------------------------------------------------- +# Gating +# -------------------------------------------------------------------------- + +def test_exported_from_package(): + assert c2pa.has_fragment_at_offset is bindings.has_fragment_at_offset + assert "has_fragment_at_offset" in c2pa.__all__ + assert "has_fragment_at_offset" in bindings.__all__ + assert bindings._FRAGMENT_AT_OFFSET_FUNCTIONS == ( + "c2pa_reader_with_fragment_at_offset", + "c2pa_reader_fragment_at_offset_supported", + ) + # Optional: never part of the import-time required export set. + for name in bindings._FRAGMENT_AT_OFFSET_FUNCTIONS: + assert name not in bindings._REQUIRED_FUNCTIONS + + +@pytest.mark.parametrize("symbols, probe, expected", [ + (False, False, False), + (False, True, False), + (True, False, False), + (True, True, True), +]) +def test_gate_requires_symbols_and_probe(monkeypatch, symbols, probe, expected): + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", symbols) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", probe) + assert c2pa.has_fragment_at_offset() is expected + + +def test_gate_matches_loaded_library(): + present = all(hasattr(bindings._lib, name) + for name in bindings._FRAGMENT_AT_OFFSET_FUNCTIONS) + assert bindings._FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE is present + if not present: + assert c2pa.has_fragment_at_offset() is False + assert bindings._FRAGMENT_AT_OFFSET_PROBE is None, "probe never called" + + +@pytest.mark.parametrize("native_result", [True, False]) +def test_probe_is_lazy_and_cached(monkeypatch, native_result): + calls = [] + + class Library: + def c2pa_reader_fragment_at_offset_supported(self): + calls.append(1) + return native_result + monkeypatch.setattr(bindings, "_lib", Library()) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", True) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", None) + assert c2pa.has_fragment_at_offset() is native_result + assert c2pa.has_fragment_at_offset() is native_result + assert calls == [1] + + +def test_missing_symbols_never_call_probe(monkeypatch): + forbidden = Mock(side_effect=AssertionError("probe called")) + monkeypatch.setattr(bindings, "_lib", forbidden) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", False) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", None) + assert c2pa.has_fragment_at_offset() is False + assert forbidden.mock_calls == [] + + +@pytest.mark.parametrize("symbols, probe", [(False, False), (True, False), (False, True)]) +def test_unavailable_classmethod_raises_before_arguments_or_native( + monkeypatch, symbols, probe): + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", symbols) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", probe) + forbidden = _forbid_native(monkeypatch) + # Arguments are deliberately invalid: gating must come first. + with pytest.raises(C2paError.NotSupported, match="fragment"): + Reader.from_fragment_at_offset(5, object(), object(), -1, context=object()) + assert forbidden.mock_calls == [] + + +def test_unavailable_instance_method_leaves_reader_unchanged(monkeypatch): + reader = _signed_reader() + try: + handle = reader._handle + json_before = reader.json() + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", False) + real_lib = bindings._lib + forbidden = _forbid_native(monkeypatch) + with pytest.raises(C2paError.NotSupported): + reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(b"a"), io.BytesIO(b"b"), 0) + assert forbidden.mock_calls == [] + monkeypatch.setattr(bindings, "_lib", real_lib) + assert reader.is_valid + assert reader._handle is handle + assert reader.json() == json_before + finally: + reader.close() + + +# -------------------------------------------------------------------------- +# Argument validation (gate forced open, native forbidden) +# -------------------------------------------------------------------------- + +def _valid_args(): + return ["video/mp4", io.BytesIO(b"init"), io.BytesIO(b"fragment"), 0] + + +BAD_ARGS = [ + pytest.param(3, -1, ValueError, id="negative-offset"), + pytest.param(3, 2**64, ValueError, id="offset-over-u64"), + pytest.param(3, True, TypeError, id="bool-offset"), + pytest.param(3, 1.0, TypeError, id="float-offset"), + pytest.param(3, "0", TypeError, id="str-offset"), + pytest.param(3, None, TypeError, id="none-offset"), + pytest.param(0, b"video/mp4", TypeError, id="bytes-format"), + pytest.param(0, 7, TypeError, id="int-format"), + pytest.param(0, "video/mp4\0x", ValueError, id="nul-format"), + pytest.param(0, None, TypeError, id="none-format"), + pytest.param(0, "", ValueError, id="empty-format"), + pytest.param(0, " ", ValueError, id="blank-format"), + pytest.param(1, object(), TypeError, id="non-stream-init"), + pytest.param(1, "init.mp4", TypeError, id="path-init"), + pytest.param(2, b"fragment", TypeError, id="bytes-fragment"), + pytest.param(2, None, TypeError, id="none-fragment"), +] + + +@pytest.mark.parametrize("index, value, error", BAD_ARGS) +def test_classmethod_rejects_invalid_arguments_before_native( + monkeypatch, index, value, error): + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + args = _valid_args() + args[index] = value + with pytest.raises(error): + Reader.from_fragment_at_offset(*args) + assert forbidden.mock_calls == [] + + +def test_classmethod_rejects_same_stream_object(monkeypatch): + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + shared = io.BytesIO(b"x") + with pytest.raises(ValueError, match="distinct"): + Reader.from_fragment_at_offset("video/mp4", shared, shared, 0) + assert forbidden.mock_calls == [] + + +@pytest.mark.parametrize("context", [object(), "context"]) +def test_classmethod_rejects_non_context(monkeypatch, context): + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + with pytest.raises(TypeError, match="ContextProvider"): + Reader.from_fragment_at_offset(*_valid_args(), context=context) + assert forbidden.mock_calls == [] + + +def test_classmethod_rejects_closed_context(monkeypatch): + context = Context() + context.close() + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + with pytest.raises(C2paError, match="Context is not valid"): + Reader.from_fragment_at_offset(*_valid_args(), context=context) + assert forbidden.mock_calls == [] + + +@pytest.mark.parametrize("index, value, error", BAD_ARGS) +def test_instance_method_rejects_invalid_arguments_and_keeps_reader( + monkeypatch, index, value, error): + reader = _signed_reader() + try: + handle = reader._handle + _enable_gate(monkeypatch) + real_lib = bindings._lib + forbidden = _forbid_native(monkeypatch) + args = _valid_args() + args[index] = value + with pytest.raises(error): + reader.with_fragment_at_offset(*args) + shared = io.BytesIO(b"x") + with pytest.raises(ValueError, match="distinct"): + reader.with_fragment_at_offset("video/mp4", shared, shared, 0) + assert forbidden.mock_calls == [] + monkeypatch.setattr(bindings, "_lib", real_lib) + assert reader.is_valid and reader._handle is handle + assert reader.json() + finally: + reader.close() + + +def test_instance_method_on_closed_reader_raises(monkeypatch): + _enable_gate(monkeypatch) + reader = _signed_reader() + reader.close() + with pytest.raises(C2paError, match="closed"): + reader.with_fragment_at_offset(*_valid_args()) + + +# -------------------------------------------------------------------------- +# Scripted marshalling: the scripted entry point records its arguments and +# delegates to the real c2pa_reader_with_fragment (same ownership contract). +# -------------------------------------------------------------------------- + +class _ScriptedOffsetCall: + def __init__(self, monkeypatch): + self.calls = [] + _enable_gate(monkeypatch) + real = bindings._lib.c2pa_reader_with_fragment + + def call(handle, fmt, stream, fragment, offset): + self.calls.append((fmt, stream, fragment, offset)) + return real(handle, fmt, stream, fragment) + monkeypatch.setattr( + bindings._lib, "c2pa_reader_with_fragment_at_offset", call, + raising=False) + + +def _dash_streams(): + return (io.BytesIO((FIXTURES / "dashinit.mp4").read_bytes()), + io.BytesIO((FIXTURES / "dash1.m4s").read_bytes())) + + +@pytest.mark.parametrize("offset", [0, 1, 2**32, U64_MAX]) +def test_scripted_classmethod_marshals_offset_and_pins_context(monkeypatch, offset): + scripted = _ScriptedOffsetCall(monkeypatch) + init, fragment = _dash_streams() + context = Context() + try: + with Reader.from_fragment_at_offset( + " Video/MP4 ", init, fragment, offset, context=context) as reader: + assert reader.is_valid + assert reader._context is context + assert reader.json() + finally: + context.close() + assert len(scripted.calls) == 1 + fmt, stream, frag, seen_offset = scripted.calls[0] + assert fmt == b"video/mp4" + assert seen_offset == offset + assert ctypes.cast(stream, ctypes.c_void_p).value != ctypes.cast( + frag, ctypes.c_void_p).value + + +def test_scripted_classmethod_without_context_uses_default_context(monkeypatch): + scripted = _ScriptedOffsetCall(monkeypatch) + created = [] + real_context = bindings.Context + + def tracking_context(*args, **kwargs): + context = real_context(*args, **kwargs) + created.append(context) + return context + monkeypatch.setattr(bindings, "Context", tracking_context) + init, fragment = _dash_streams() + with Reader.from_fragment_at_offset("video/mp4", init, fragment, 7) as reader: + assert reader.json() + assert reader._context is None + assert len(created) == 1 and not created[0].is_valid, \ + "the temporary default Context must be released" + assert scripted.calls[0][3] == 7 + + +def test_scripted_instance_method_swaps_handle_and_clears_caches(monkeypatch): + scripted = _ScriptedOffsetCall(monkeypatch) + with Context() as context: + init, fragment = _dash_streams() + with Reader("video/mp4", io.BytesIO(init.getvalue()), context=context) as reader: + reader.json() + reader._manifest_data_cache = {"stale": True} + result = reader.with_fragment_at_offset( + "video/mp4", init, fragment, 1234) + assert result is reader + assert reader.is_valid + assert reader._manifest_data_cache is None + assert reader._manifest_json_str_cache is None + assert reader.json() + assert scripted.calls[0][3] == 1234 + + +def test_scripted_native_failure_closes_instance_reader(monkeypatch): + scripted = _ScriptedOffsetCall(monkeypatch) + with Context() as context: + init, fragment = _dash_streams() + reader = Reader("video/mp4", io.BytesIO(init.getvalue()), context=context) + # An init stream without a manifest fails after native took the reader. + with pytest.raises(C2paError): + reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), fragment, 0) + assert not reader.is_valid + reader.close() + assert len(scripted.calls) == 1 + + +def test_scripted_native_failure_from_classmethod_raises_without_reader(monkeypatch): + _ScriptedOffsetCall(monkeypatch) + created = [] + real_wrap = bindings.Reader._wrap_native_handle.__func__ + + def tracking_wrap(cls, handle): + reader = real_wrap(cls, handle) + created.append(reader) + return reader + monkeypatch.setattr(bindings.Reader, "_wrap_native_handle", + classmethod(tracking_wrap)) + _, fragment = _dash_streams() + with Context() as context: + with pytest.raises(C2paError): + Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), fragment, 0, context=context) + assert len(created) == 1 + assert not created[0].is_valid + assert created[0]._context is None, "failed reader must release its pins" + + +# -------------------------------------------------------------------------- +# Real native +# -------------------------------------------------------------------------- + +def _require_native(): + if c2pa.has_fragment_at_offset(): + return + if os.environ.get("C2PA_FRAGMENT_OFFSET_REQUIRED") == "1": + pytest.fail("C2PA_FRAGMENT_OFFSET_REQUIRED=1 but the loaded native " + "library lacks c2pa_reader_with_fragment_at_offset or its " + "probe") + pytest.skip("native library lacks offset-aware fragment verification") + + +_DEFINITION = { + "claim_generator_info": [{"name": "fragment-at-offset-test", "version": "0.0.1"}], + "title": "fragment at offset", + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], +} + + +@pytest.fixture(scope="module") +def signed_cut(): + """Sign the per-fragment-sidx fixture; cut it merkle uuid to merkle uuid.""" + source = SIDX_FIXTURE.read_bytes() # Missing committed data is never a skip. + _require_native() + 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) + dest = io.BytesIO() + with c2pa.Signer.from_info(info) as signer, \ + c2pa.Builder(_DEFINITION) as builder: + builder.sign(signer, "video/mp4", io.BytesIO(source), dest) + signed = dest.getvalue() + starts = _merkle_uuid_starts(signed) + assert len(starts) >= 2, f"expected several merkle uuid boxes, got {starts}" + init = signed[:starts[0]] + segments = [(start, signed[start:(starts[i + 1] if i + 1 < len(starts) + else len(signed))]) + for i, start in enumerate(starts)] + return signed, init, segments + + +@pytest.fixture +def trust_context(): + context = _trust_context() + yield context + context.close() + + +def test_real_native_export_signatures(): + _require_native() + call = bindings._lib.c2pa_reader_with_fragment_at_offset + assert call.argtypes == [ + ctypes.POINTER(bindings.C2paReader), ctypes.c_char_p, + ctypes.POINTER(bindings.C2paStream), ctypes.POINTER(bindings.C2paStream), + ctypes.c_uint64] + assert call.restype is ctypes.POINTER(bindings.C2paReader) + probe = bindings._lib.c2pa_reader_fragment_at_offset_supported + assert probe.argtypes == [] and probe.restype is ctypes.c_bool + assert probe() is True + + +def test_real_native_whole_file_is_trusted(signed_cut, trust_context): + signed, _, _ = signed_cut + with Reader("video/mp4", io.BytesIO(signed), context=trust_context) as reader: + assert reader.get_validation_state() == "Trusted", reader.json() + + +def test_real_native_each_segment_verifies_at_its_offset(signed_cut, trust_context): + _, init, segments = signed_cut + for offset, segment in segments: + with Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset, + context=trust_context) as reader: + assert reader.get_validation_state() == "Trusted", (offset, reader.json()) + assert MATCH in _codes(reader, "success"), (offset, reader.json()) + assert MISMATCH not in _codes(reader, "failure") + + +def test_real_native_segments_valid_without_trust_anchors(signed_cut): + _, init, segments = signed_cut + offset, segment = segments[-1] + with Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + assert MATCH in _codes(reader, "success") + + +def test_real_native_wrong_offset_reports_mismatch(signed_cut, trust_context): + _, init, segments = signed_cut + offset, segment = segments[1] + other_offset = segments[0][0] + for wrong in (0, offset + 1, offset - 1, other_offset): + with Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), wrong, + context=trust_context) as reader: + assert reader.get_validation_state() == "Invalid", (wrong, reader.json()) + assert MISMATCH in _codes(reader, "failure"), (wrong, reader.json()) + + +def test_real_native_instance_method_on_context_reader(signed_cut, trust_context): + signed, init, segments = signed_cut + offset, segment = segments[-1] + with Reader("video/mp4", io.BytesIO(signed), context=trust_context) as reader: + assert reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset) is reader + assert reader.get_validation_state() == "Trusted", reader.json() + assert MATCH in _codes(reader, "success") + assert reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset + 1) is reader + assert reader.get_validation_state() == "Invalid" + assert MISMATCH in _codes(reader, "failure") + + +def test_real_native_unsigned_init_raises_and_closes(signed_cut, trust_context): + _, _, segments = signed_cut + offset, segment = segments[0] + with pytest.raises(C2paError): + Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), io.BytesIO(segment), offset, + context=trust_context) + reader = Reader("video/mp4", io.BytesIO(signed_cut[0]), context=trust_context) + with pytest.raises(C2paError): + reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), io.BytesIO(segment), offset) + assert not reader.is_valid + reader.close() + gc.collect() diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index f4df4c03..704a43a0 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -15,7 +15,7 @@ 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"]) +@pytest.mark.parametrize("version", ["0.37.13.dev1", "0.37.13.dev2", "0.38.0.dev1", "1.0.dev0+local"]) def test_new_functional_development_version(version): assert functional_build.functional_version(version) == version @@ -24,7 +24,7 @@ def test_new_functional_development_version(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", + "0.37.9.dev0", "0.37.12.dev0", "0.37.13.dev0", ]) def test_functional_build_rejects_release_or_dev5_identity(version): with pytest.raises(ValueError): @@ -39,9 +39,9 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): 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() + functional_build.stage_source(stage, "0.37.13.dev2") + assert 'version = "0.37.13.dev2"' in (stage / "pyproject.toml").read_text() + assert "# Version: 0.37.13.dev2" 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) @@ -50,12 +50,12 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): (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") + assert version == Version("0.37.13.dev2") 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() + assert "Version: 0.37.13.dev2" 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"): @@ -63,14 +63,14 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): 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() + # The checkout keeps its own source identity; staging never rewrites it. + assert 'version = "0.37.13.dev1"' 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" + assert functional_build.source_version() == "0.37.13.dev1" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev1" def test_probe_script_requires_every_functional_capability():