From cf3f5fb6c78da3e1ea508df288668a5b8328d0fa Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:38:22 -0700 Subject: [PATCH 01/32] chore: Update c2pa version to v0.90.16 (#316) * chore: Update c2pa version to v0.90.16 * Bump version from 0.37.8 to 0.37.9 * Bump version from 0.37.8 to 0.37.9 --- c2pa-native-version.txt | 2 +- pyproject.toml | 2 +- src/c2pa/c2pa.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 8ea82960..e4781d0c 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.90.15 +c2pa-v0.90.16 diff --git a/pyproject.toml b/pyproject.toml index d0a7bc62..e8945b67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.8" +version = "0.37.9" 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/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 5f3dfa61..537135a4 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.8 +# Version: 0.37.9 import ctypes import enum From 4d909cf0b6a0bc4ec5f11f5632ef4f841340073a Mon Sep 17 00:00:00 2001 From: Fabian Witt Date: Fri, 4 Sep 2026 19:06:24 +0200 Subject: [PATCH 02/32] Drop wheel, setuptools and pytest from runtime dependencies (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: drop wheel, setuptools and pytest from runtime dependencies None of the three is imported anywhere under `src/`. `c2pa.py` and `lib.py` import only the standard library; `build.py` — the `download-artifacts` console script — imports `requests` and, lazily, `toml`. Those two stay. They are also already classified correctly elsewhere in the repo: * `[build-system] requires` already lists `setuptools>=68.0.0` and `wheel`, so the build has what it needs and the runtime entries are duplicates. * `requirements-dev.txt` lists `wheel` and `setuptools` under "# Build dependencies" and `pytest` under "# Testing dependencies". * `.github/workflows/build.yml` installs pytest explicitly (`pip install pytest`, lines 285 and 377), so CI does not rely on the runtime declaration either. Removing them is therefore a no-op for this repo's own build and test paths, and it keeps three packages out of every consumer's production environment. * build: declare pytest in a PEP 735 dev dependency group Dropping pytest from `[project.dependencies]` left it undeclared in pyproject.toml entirely, with `requirements-dev.txt` as the only manifest naming it. `[dependency-groups] dev` states it where it belongs: installed for contributors (`uv sync`, `pip install --group dev`) and, unlike `[project.optional-dependencies]`, absent from the published package metadata — which is the separation this branch is about. The bound matches requirements-dev.txt (`pytest>=8.1.0`) rather than the `>=7.4.0` the runtime entry carried; nothing installs the old one. The comment above the remaining dependencies goes with it. The rationale for keeping `toml` and `requests` belongs in the pull request, not in a manifest that has carried no comments so far. * build: keep pytest declared in requirements-dev.txt only Review feedback: the PEP 735 group restated a bound that `requirements-dev.txt` already carries, so the two could drift — which is what this branch set out to stop, not to reproduce one line further down. Nothing in the repo would have read the group. The Makefile's `install-deps` and every workflow that installs dependencies do so with `pip install -r requirements-dev.txt` (`build.yml` lines 52, 90/93, 164/167, 490/492), and the wheel test jobs install pytest by name. The group was a declaration with no consumer. The published metadata — the point of this branch — is unaffected either way, and the diff is now purely subtractive. --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e8945b67..f7fb6395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,10 +22,7 @@ maintainers = [ ] urls = {homepage = "https://contentauthenticity.org", repository = "https://github.com/contentauth/c2pa-python"} dependencies = [ - "wheel>=0.41.2", - "setuptools>=68.0.0", "toml>=0.10.2", - "pytest>=7.4.0", "cryptography>=41.0.0", "requests>=2.0.0" ] From 63d09c622859d421df8c8231abdc305de03e2413 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:07:52 -0700 Subject: [PATCH 03/32] chore: Update c2pa version from v0.90.16 to v0.90.19 (#322) * Update c2pa version from v0.90.16 to v0.90.19 * Bump version from 0.37.9 to 0.37.10 * Bump version from 0.37.9 to 0.37.10 --- c2pa-native-version.txt | 2 +- pyproject.toml | 2 +- src/c2pa/c2pa.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index e4781d0c..95fc7a03 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.90.16 +c2pa-v0.90.19 diff --git a/pyproject.toml b/pyproject.toml index f7fb6395..7620ff9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.9" +version = "0.37.10" 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/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 537135a4..d6f57c07 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.9 +# Version: 0.37.10 import ctypes import enum From 7ca863dff6c8275691469d8d0a4efa262d39804d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 9 Sep 2026 09:01:57 +0200 Subject: [PATCH 04/32] feat: scaffold trusted VSI API --- src/c2pa/__init__.py | 22 ++ src/c2pa/c2pa.py | 461 ++++++++++++++++++++++++++++++++++ tests/test_trusted_vsi_api.py | 104 ++++++++ 3 files changed, 587 insertions(+) create mode 100644 tests/test_trusted_vsi_api.py diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 20761c19..bc3fe6cd 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -32,6 +32,11 @@ ContextBuilder, ContextProvider, LiveVideoVsiSession, + TrustedVsiPrehashedSession, + VsiSigningContextV1, + TrustedVsiInitUuidReservation, + TrustedVsiMediaEmsgReservation, + TrustedVsiStatus, has_dynamic_assertions, has_fragmented_files, has_live_video_vsi, @@ -39,6 +44,12 @@ has_live_video_vsi_explicit_time, has_live_video_vsi_mfhd_probe, has_live_video_vsi_recovery, + has_live_video_trusted_vsi_split_init, + has_live_video_trusted_vsi_expert_emsg, + has_live_video_trusted_vsi_composed_emsg, + has_live_video_trusted_vsi_recovery, + has_live_video_trusted_vsi_signing_context_v1, + has_live_video_trusted_vsi_full_uint32_exhaustion, moof_sequence_number, sdk_version, load_settings @@ -60,6 +71,11 @@ 'ContextBuilder', 'ContextProvider', 'LiveVideoVsiSession', + 'TrustedVsiPrehashedSession', + 'VsiSigningContextV1', + 'TrustedVsiInitUuidReservation', + 'TrustedVsiMediaEmsgReservation', + 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', 'has_live_video_vsi', @@ -67,6 +83,12 @@ 'has_live_video_vsi_explicit_time', 'has_live_video_vsi_mfhd_probe', 'has_live_video_vsi_recovery', + 'has_live_video_trusted_vsi_split_init', + 'has_live_video_trusted_vsi_expert_emsg', + 'has_live_video_trusted_vsi_composed_emsg', + 'has_live_video_trusted_vsi_recovery', + 'has_live_video_trusted_vsi_signing_context_v1', + 'has_live_video_trusted_vsi_full_uint32_exhaustion', 'moof_sequence_number', 'sdk_version', 'load_settings' diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index aab837e1..dafc545d 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -24,6 +24,7 @@ import weakref from abc import ABC, abstractmethod from collections.abc import Sequence +from dataclasses import dataclass from pathlib import Path from typing import Optional, Union, Callable, Any, overload import io @@ -125,6 +126,43 @@ 'c2pa_live_video_moof_sequence_number', ) +# Optional trusted-processor prehashed VSI API. The native ABI is being +# developed independently, so every symbol remains optional and the Python +# surface fails closed unless both the corresponding bit and symbols exist. +_TRUSTED_VSI_CAPABILITIES_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_capabilities', +) +_TRUSTED_VSI_CREATE_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_create_callback_v1', +) +_TRUSTED_VSI_SPLIT_INIT_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_reserve_init_uuid', + 'c2pa_live_video_trusted_vsi_session_reserved_manifest_id', + 'c2pa_live_video_trusted_vsi_session_finalize_init_uuid', + 'c2pa_live_video_trusted_vsi_session_commit_init_uuid', +) +_TRUSTED_VSI_EXPERT_MEDIA_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_sign_emsg_sig_structure', +) +_TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_reserve_media_emsg', + 'c2pa_live_video_trusted_vsi_session_finalize_media_emsg', +) +_TRUSTED_VSI_RECOVERY_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_recover', +) +_TRUSTED_VSI_STATUS_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_status_v1', +) + +_TRUSTED_VSI_CAP_SPLIT_INIT = 1 << 0 +_TRUSTED_VSI_CAP_EXPERT_MEDIA = 1 << 1 +_TRUSTED_VSI_CAP_COMPOSED_MEDIA = 1 << 2 +_TRUSTED_VSI_CAP_RECOVERY = 1 << 3 +_TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1 = 1 << 4 +_TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE = 1 << 5 +_TRUSTED_VSI_PYTHON_API_ENABLED = False + # Castlabs dynamic-assertion extension. Keep this optional so the package can # still be imported with standard upstream native libraries. _DYNAMIC_ASSERTION_FUNCTIONS = ( @@ -222,6 +260,28 @@ def _validate_library_exports(lib): _LIVE_VIDEO_VSI_MFHD_PROBE_AVAILABLE = all( hasattr(_lib, name) for name in _LIVE_VIDEO_VSI_MFHD_PROBE_FUNCTIONS ) +_TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_CAPABILITIES_FUNCTIONS +) +_TRUSTED_VSI_CREATE_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_CREATE_FUNCTIONS +) +_TRUSTED_VSI_SPLIT_INIT_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_SPLIT_INIT_FUNCTIONS +) +_TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_EXPERT_MEDIA_FUNCTIONS +) +_TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS +) +_TRUSTED_VSI_RECOVERY_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_RECOVERY_FUNCTIONS +) +_TRUSTED_VSI_STATUS_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_STATUS_FUNCTIONS +) +_TRUSTED_VSI_CAPABILITIES = 0 _DYNAMIC_ASSERTIONS_AVAILABLE = all( hasattr(_lib, name) for name in _DYNAMIC_ASSERTION_FUNCTIONS ) @@ -733,6 +793,45 @@ def __del__(self): ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, ) +class C2paLiveVideoTrustedVsiSigningContextV1(ctypes.Structure): + """Version-one native trusted-VSI callback context.""" + + _fields_ = [ + ("purpose", ctypes.c_uint32), + ("sequence_number", ctypes.c_uint32), + ("has_sequence_number", ctypes.c_bool), + ("event_id", ctypes.c_uint32), + ("has_event_id", ctypes.c_bool), + ("exhaust_after_sign", ctypes.c_bool), + ] + + +class C2paLiveVideoTrustedVsiStatusV1(ctypes.Structure): + """Native public status for a trusted prehashed VSI session.""" + + _fields_ = [ + ("init_uuid_committed", ctypes.c_bool), + ("init_uuid_pending", ctypes.c_bool), + ("media_emsg_pending", ctypes.c_bool), + ("has_next_sequence_number", ctypes.c_bool), + ("next_sequence_number", ctypes.c_uint32), + ("has_next_event_id", ctypes.c_bool), + ("next_event_id", ctypes.c_uint32), + ("exhausted", ctypes.c_bool), + ("has_exhaustion_reason", ctypes.c_bool), + ("exhaustion_reason", ctypes.c_uint32), + ] + + +TrustedVsiSignCallbackV1 = ctypes.CFUNCTYPE( + ctypes.c_ssize_t, + ctypes.c_void_p, + ctypes.POINTER(C2paLiveVideoTrustedVsiSigningContextV1), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, +) class StreamContext(ctypes.Structure): @@ -899,6 +998,11 @@ class C2paLiveVideoVsiSigner(ctypes.Structure): """Opaque structure for a live-video VSI signing session.""" _fields_ = [] # Empty as it's opaque in the C API + +class C2paLiveVideoTrustedVsiSession(ctypes.Structure): + """Opaque structure for a trusted prehashed live-video VSI session.""" + _fields_ = [] # Empty as it's opaque in the C API + # Helper function to set function prototypes @@ -1271,6 +1375,112 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_int ) +# Provisional declarations for the separately versioned trusted-processor ABI. +# Calls remain unavailable from this Python scaffold; keeping setup conditional +# ensures older native libraries are never asked for these optional symbols. +if _TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_capabilities, + [], + ctypes.c_uint64, + ) + try: + _TRUSTED_VSI_CAPABILITIES = int( + _lib.c2pa_live_video_trusted_vsi_capabilities() + ) + except Exception: # pragma: no cover - defensive import compatibility + _TRUSTED_VSI_CAPABILITIES = 0 +if _TRUSTED_VSI_CREATE_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1, + [ctypes.POINTER(C2paContext), + ctypes.c_char_p, + ctypes.c_int, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.c_uint64, + ctypes.c_char_p, + ctypes.c_uint64, + ctypes.c_void_p, + TrustedVsiSignCallbackV1], + ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ) +if _TRUSTED_VSI_SPLIT_INIT_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_reserve_init_uuid, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.c_char_p, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_reserved_manifest_id, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession)], + ctypes.c_void_p, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_finalize_init_uuid, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_commit_init_uuid, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession)], + ctypes.c_int, + ) +if _TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_sign_emsg_sig_structure, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) +if _TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.c_int64, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ctypes.POINTER(C2paLiveVideoTrustedVsiSigningContextV1)], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_finalize_media_emsg, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) +if _TRUSTED_VSI_RECOVERY_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_recover, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_size_t], + ctypes.c_int, + ) +if _TRUSTED_VSI_STATUS_AVAILABLE: + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_status_v1, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(C2paLiveVideoTrustedVsiStatusV1)], + ctypes.c_int, + ) + class C2paError(Exception): """Exception raised for C2PA errors. @@ -2025,6 +2235,100 @@ def execution_context(self): return self._handle +@dataclass(frozen=True) +class VsiSigningContextV1: + """Purpose-bound context supplied to a trusted VSI signing callback.""" + + purpose: str + sequence_number: Optional[int] = None + event_id: Optional[int] = None + exhaust_after_sign: bool = False + + +@dataclass(frozen=True) +class TrustedVsiInitUuidReservation: + """Opaque complete placeholder UUID box returned by init reservation.""" + + placeholder_uuid_box: bytes + manifest_id: str + + +@dataclass(frozen=True) +class TrustedVsiMediaEmsgReservation: + """Complete placeholder EMSG box and its pinned media facts.""" + + placeholder_emsg_box: bytes + signing_context: VsiSigningContextV1 + signing_time_unix_seconds: int + timescale: int + event_duration: int + + +@dataclass(frozen=True) +class TrustedVsiStatus: + """Public state snapshot for a trusted prehashed VSI session.""" + + init_uuid_committed: bool + init_uuid_pending: bool + media_emsg_pending: bool + next_sequence_number: Optional[int] + next_event_id: Optional[int] + exhausted: bool + exhaustion_reason: Optional[str] = None + + +def _has_trusted_vsi_capability(bit: int, symbols_available: bool = True) -> bool: + return ( + _TRUSTED_VSI_PYTHON_API_ENABLED + and _TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE + and _TRUSTED_VSI_CREATE_AVAILABLE + and symbols_available + and bool(_TRUSTED_VSI_CAPABILITIES & bit) + ) + + +def has_live_video_trusted_vsi_split_init() -> bool: + """Return whether split-init complete-UUID operations are available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_SPLIT_INIT, + _TRUSTED_VSI_SPLIT_INIT_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_expert_emsg() -> bool: + """Return whether expert EMSG/Sig_structure signing is available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_EXPERT_MEDIA, + _TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_composed_emsg() -> bool: + """Return whether signer-composed complete-EMSG operations are available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_COMPOSED_MEDIA, + _TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_recovery() -> bool: + """Return whether trusted prehashed VSI recovery is available.""" + return _has_trusted_vsi_capability( + _TRUSTED_VSI_CAP_RECOVERY, + _TRUSTED_VSI_RECOVERY_AVAILABLE, + ) + + +def has_live_video_trusted_vsi_signing_context_v1() -> bool: + """Return whether version-1 purpose-bound callback contexts are available.""" + return _has_trusted_vsi_capability(_TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1) + + +def has_live_video_trusted_vsi_full_uint32_exhaustion() -> bool: + """Return whether VSI supports signing then exhausting at uint32 max.""" + return _has_trusted_vsi_capability(_TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE) + + def has_live_video_vsi() -> bool: """Return whether the loaded native library provides live-video VSI.""" return _LIVE_VIDEO_VSI_AVAILABLE @@ -2094,6 +2398,152 @@ def has_fragmented_files() -> bool: ) +class TrustedVsiPrehashedSession(ManagedResource): + """Managed scaffold for trusted-processor prehashed VSI sessions. + + This release publishes the stable Python shape but intentionally does not + enable trusted signing. Every entry point fails closed before invoking a + callback or native operation. + """ + + def __init__( + self, + manifest_json: Union[str, dict], + context: 'Context', + callback: Callable[[VsiSigningContextV1, bytes], bytes], + algorithm: Union[C2paSigningAlg, str], + public_cose_key: bytes, + kid: bytes, + min_sequence_number: int, + created_at: str, + validity_period_secs: int, + ): + super().__init__() + self._init_attrs() + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_signing_context_v1(), + "trusted prehashed VSI session creation", + ) + + @classmethod + def from_callback( + cls, + manifest_json: Union[str, dict], + context: 'Context', + callback: Callable[[VsiSigningContextV1, bytes], bytes], + algorithm: Union[C2paSigningAlg, str], + public_cose_key: bytes, + kid: bytes, + min_sequence_number: int, + created_at: str, + validity_period_secs: int, + ) -> 'TrustedVsiPrehashedSession': + """Create a trusted session backed by a purpose-bound callback.""" + return cls( + manifest_json, + context, + callback, + algorithm, + public_cose_key, + kid, + min_sequence_number, + created_at, + validity_period_secs, + ) + + def _init_attrs(self): + super()._init_attrs() + self._trusted_vsi_callback = None + + def _release(self): + self._trusted_vsi_callback = None + + @staticmethod + def _raise_scaffold_unavailable(available: bool, operation: str) -> None: + if not _TRUSTED_VSI_PYTHON_API_ENABLED: + raise C2paError.NotSupported( + f"{operation} is not enabled by this Python API scaffold" + ) + if not available: + raise C2paError.NotSupported( + f"{operation} is unavailable in the loaded native library" + ) + raise C2paError.NotSupported(f"{operation} is unavailable") + + def reserve_init_uuid( + self, + format: str = "video/mp4", + ) -> TrustedVsiInitUuidReservation: + """Reserve a complete fixed-size placeholder C2PA UUID box.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_split_init(), + "trusted VSI init UUID reservation", + ) + + def finalize_init_uuid(self, canonical_hash: bytes) -> bytes: + """Finalize the reserved UUID box with a canonical hard binding.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_split_init(), + "trusted VSI init UUID finalization", + ) + + def commit_init_uuid(self) -> None: + """Commit publication of the finalized init UUID box.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_split_init(), + "trusted VSI init publication commit", + ) + + def sign_emsg_sig_structure( + self, + emsg_skeleton: bytes, + sig_structure: bytes, + ) -> bytes: + """Sign a validated expert-mode EMSG COSE Sig_structure.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_expert_emsg(), + "trusted VSI expert EMSG signing", + ) + + def reserve_media_emsg_at( + self, + signing_time_unix_seconds: int, + timescale: int, + event_duration: int, + ) -> TrustedVsiMediaEmsgReservation: + """Reserve a complete fixed-size placeholder VSI EMSG box.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_composed_emsg(), + "trusted VSI media EMSG reservation", + ) + + def finalize_media_emsg(self, canonical_hash: bytes) -> bytes: + """Finalize the reserved EMSG with a canonical BMFF hard binding.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_composed_emsg(), + "trusted VSI media EMSG finalization", + ) + + def recover( + self, + signed_init_uuid: bytes, + previous_emsg: Optional[bytes] = None, + ) -> None: + """Restore trusted session state from published complete boxes.""" + self._raise_scaffold_unavailable( + has_live_video_trusted_vsi_recovery(), + "trusted prehashed VSI recovery", + ) + + def status(self) -> TrustedVsiStatus: + """Return the trusted session's public transaction state.""" + available = ( + has_live_video_trusted_vsi_signing_context_v1() + and _TRUSTED_VSI_STATUS_AVAILABLE + ) + self._raise_scaffold_unavailable(available, "trusted VSI status") + + class LiveVideoVsiSession(ManagedResource): """Stateful C2PA 2.4 Verifiable Segment Info signing session. @@ -5383,6 +5833,11 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'Builder', 'Signer', 'LiveVideoVsiSession', + 'TrustedVsiPrehashedSession', + 'VsiSigningContextV1', + 'TrustedVsiInitUuidReservation', + 'TrustedVsiMediaEmsgReservation', + 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', 'has_live_video_vsi', @@ -5390,6 +5845,12 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'has_live_video_vsi_explicit_time', 'has_live_video_vsi_mfhd_probe', 'has_live_video_vsi_recovery', + 'has_live_video_trusted_vsi_split_init', + 'has_live_video_trusted_vsi_expert_emsg', + 'has_live_video_trusted_vsi_composed_emsg', + 'has_live_video_trusted_vsi_recovery', + 'has_live_video_trusted_vsi_signing_context_v1', + 'has_live_video_trusted_vsi_full_uint32_exhaustion', 'moof_sequence_number', 'load_settings', 'format_embeddable', diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py new file mode 100644 index 00000000..4dd4866a --- /dev/null +++ b/tests/test_trusted_vsi_api.py @@ -0,0 +1,104 @@ +from dataclasses import FrozenInstanceError +import ctypes + +import pytest + +import c2pa +import c2pa.c2pa as bindings + + +def test_trusted_vsi_scaffold_exports_and_capabilities_are_unavailable(): + assert c2pa.has_live_video_trusted_vsi_split_init() is False + assert c2pa.has_live_video_trusted_vsi_expert_emsg() is False + assert c2pa.has_live_video_trusted_vsi_composed_emsg() is False + assert c2pa.has_live_video_trusted_vsi_recovery() is False + assert c2pa.has_live_video_trusted_vsi_signing_context_v1() is False + assert c2pa.has_live_video_trusted_vsi_full_uint32_exhaustion() is False + + +def test_trusted_vsi_public_values_are_immutable(): + context = c2pa.VsiSigningContextV1( + purpose="vsi", + sequence_number=7, + event_id=1, + exhaust_after_sign=False, + ) + reservation = c2pa.TrustedVsiMediaEmsgReservation( + placeholder_emsg_box=b"emsg", + signing_context=context, + signing_time_unix_seconds=1_700_000_000, + timescale=1_000, + event_duration=2_000, + ) + status = c2pa.TrustedVsiStatus( + init_uuid_committed=False, + init_uuid_pending=False, + media_emsg_pending=False, + next_sequence_number=7, + next_event_id=1, + exhausted=False, + ) + init_reservation = c2pa.TrustedVsiInitUuidReservation( + placeholder_uuid_box=b"uuid", + manifest_id="urn:c2pa:manifest-1", + ) + assert context.sequence_number == reservation.signing_context.sequence_number == 7 + assert status.next_event_id == 1 + assert init_reservation.manifest_id == "urn:c2pa:manifest-1" + with pytest.raises(FrozenInstanceError): + context.event_id = 2 + + +def test_trusted_vsi_construction_fails_before_callback(): + calls = [] + + def callback(context, data): + calls.append((context, data)) + raise AssertionError("callback must not run") + + with pytest.raises(c2pa.C2paError.NotSupported, match="not enabled"): + c2pa.TrustedVsiPrehashedSession.from_callback( + {"assertions": []}, + object(), + callback, + "ES256", + b"public-key", + b"kid", + 1, + "2026-01-01T00:00:00Z", + 60, + ) + assert calls == [] + + +def test_trusted_vsi_ctypes_declarations_match_v1_native_abi(): + context_fields = bindings.C2paLiveVideoTrustedVsiSigningContextV1._fields_ + assert [name for name, _type in context_fields] == [ + "purpose", + "sequence_number", + "has_sequence_number", + "event_id", + "has_event_id", + "exhaust_after_sign", + ] + assert ctypes.sizeof(bindings.C2paLiveVideoTrustedVsiSigningContextV1) == 20 + assert ctypes.sizeof(bindings.C2paLiveVideoTrustedVsiStatusV1) == 24 + + +def test_trusted_vsi_optional_composed_prototype_matches_native_abi(): + if not bindings._TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE: + pytest.skip("trusted VSI composed-media symbols are not present") + reserve = bindings._lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg + assert reserve.argtypes[-1] == ctypes.POINTER( + bindings.C2paLiveVideoTrustedVsiSigningContextV1 + ) + + +def test_trusted_vsi_optional_status_prototype_matches_native_abi(): + if not bindings._TRUSTED_VSI_STATUS_AVAILABLE: + pytest.skip("trusted VSI status symbol is not present") + status = bindings._lib.c2pa_live_video_trusted_vsi_session_status_v1 + assert status.restype is ctypes.c_int + assert status.argtypes[-1] == ctypes.POINTER( + bindings.C2paLiveVideoTrustedVsiStatusV1 + ) From 0d48e6a09b9d627dcf6385e3e591be8086e9af1f Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 10 Sep 2026 05:26:11 +0200 Subject: [PATCH 05/32] refactor: reduce trusted VSI expert bindings --- .github/workflows/build.yml | 17 ++ .github/workflows/castlabs-vsi-release.yml | 14 +- .github/workflows/trusted-vsi-paired.yml | 75 ++++++++ README.md | 44 +++++ docs/release-notes.md | 16 ++ docs/usage.md | 38 ++++ src/c2pa/__init__.py | 6 +- src/c2pa/c2pa.py | 131 +++++++++---- tests/test_castlabs_release_tooling.py | 38 +++- tests/test_trusted_vsi_api.py | 212 ++++++++++++++++++--- 10 files changed, 518 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/trusted-vsi-paired.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c471083..b310951f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,17 @@ permissions: actions: read jobs: + trusted-vsi-paired: + name: Trusted VSI paired-source API (not release artifacts) + if: | + !startsWith(github.ref, 'refs/tags/castlabs-v') && + (github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test')) + uses: ./.github/workflows/trusted-vsi-paired.yml + read-version: name: Read C2PA version if: ${{ !startsWith(github.ref, 'refs/tags/castlabs-v') }} @@ -146,6 +157,9 @@ jobs: - name: Run tests run: python3 ./tests/test_unit_tests.py + - name: Test disabled trusted API against upstream native (not ABI qualification) + run: python3 -m pytest -q tests/test_trusted_vsi_api.py -k "not paired" + tests-windows: name: Unit tests for developer setup (Windows) needs: read-version @@ -232,6 +246,9 @@ jobs: - name: Run tests run: python .\tests\test_unit_tests.py + - name: Test disabled trusted API against upstream native (not ABI qualification) + run: python -m pytest -q tests/test_trusted_vsi_api.py -k "not paired" + build-linux-wheel: name: Build Linux wheel uses: ./.github/workflows/build-wheel.yml diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 5dd4c696..128ca2b2 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -7,9 +7,14 @@ on: workflow_dispatch: inputs: source_sha: - description: Full feat/live-video-vsi branch-tip SHA to release + description: Full Python SHA (feat/live-video-vsi tip for dev5; paired source for trusted_vsi_only) required: true type: string + trusted_vsi_only: + description: Only non-publishing paired-source trusted API tests; bypass all dev5 jobs + required: false + type: boolean + default: false concurrency: group: castlabs-vsi-release-0.37.8.dev5 @@ -24,7 +29,14 @@ env: PYTHONHASHSEED: "0" jobs: + trusted-vsi-paired: + if: github.event_name == 'workflow_dispatch' && inputs.trusted_vsi_only + uses: ./.github/workflows/trusted-vsi-paired.yml + with: + python-ref: ${{ inputs.source_sha }} + prepare: + if: ${{ !inputs.trusted_vsi_only }} runs-on: ubuntu-24.04 timeout-minutes: 20 outputs: diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml new file mode 100644 index 00000000..553d3372 --- /dev/null +++ b/.github/workflows/trusted-vsi-paired.yml @@ -0,0 +1,75 @@ +name: Trusted VSI paired-source API qualification (non-publishing) + +on: + workflow_call: + inputs: + python-ref: + description: Python source under test (defaults to the caller commit) + type: string + default: "" + +permissions: + contents: read + +jobs: + paired-api: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + library: libc2pa_c.so + - os: windows-2022 + library: c2pa_c.dll + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + CARGO_BUILD_JOBS: "1" + CARGO_INCREMENTAL: "0" + CARGO_TARGET_DIR: ${{ github.workspace }}/paired-rust/target + PYTHONPATH: ${{ github.workspace }}/python-source/src + C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/${{ matrix.library }} + C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_TRUSTED_VSI_ABI_REQUIRED: "1" + steps: + - name: Require a full Python source SHA + shell: bash + env: + PYTHON_SOURCE_SHA: ${{ inputs.python-ref || github.sha }} + run: '[[ "$PYTHON_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ inputs.python-ref || github.sha }} + path: python-source + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: castlabs/c2pa-rs + # Reviewed reduced scaffold, NOT the immutable dev5 release input. + ref: cee86aae03887b5a0dddcd765a39e96360963bb0 + path: paired-rust + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.10" + - name: Record paired source identities + shell: bash + run: | + git -C python-source rev-parse HEAD + git -C paired-rust rev-parse HEAD + - name: Build isolated paired native scaffold + shell: bash + working-directory: paired-rust + run: | + rustup toolchain install 1.88.0 --profile minimal + cargo +1.88.0 build --locked -p c2pa-c-ffi --no-default-features \ + --features rust_native_crypto,http,add_thumbnails,file_io,unstable_live_video + - name: Prepare source imports (no wheel or native staging) + shell: bash + working-directory: python-source + run: | + python -m pip install -r requirements.txt pytest==8.4.1 setuptools==68.0.0 toml==0.10.2 + python setup.py egg_info + - name: Require reduced trusted ABI and run focused tests without skips + shell: bash + working-directory: python-source + run: python -m pytest -q tests/test_trusted_vsi_api.py -ra + # No artifacts, release metadata, wheels, or publication from this job. diff --git a/README.md b/README.md index 88c5a3d2..d6173f0e 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,50 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH ## Castlabs VSI prerelease process +### Unreleased trusted-processor API qualification + +The [trusted-processor target API](docs/usage.md#trusted-processor-vsi-unreleased-disabled) +is disabled and separate from complete-buffer VSI and immutable dev5. Expert +signing accepts only exact `Sig_structure` bytes and targets a frozen +`TrustedVsiSignResult` (64-byte signature, uint32 sequence, optional inclusive +maximum). Every trusted probe remains false, import makes no native trusted +capability call, and session construction/operations fail before side effects. + +`build.yml` runs baseline-gated tests against its upstream downloaded native +library (`-k "not paired"`). Separately, both it and the dedicated workflow use +[`trusted-vsi-paired.yml`](.github/workflows/trusted-vsi-paired.yml) for isolated +Linux/Windows source builds and the full focused `tests/test_trusted_vsi_api.py` +with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`. Missing reduced ABI symbols fail rather +than skip. The paired Rust source is pinned to +`castlabs/c2pa-rs@cee86aae03887b5a0dddcd765a39e96360963bb0`; the job logs resolved +Python/Rust SHAs. This disabled-scaffold qualification pin is separate from +immutable dev5 release inputs and is not release-artifact evidence. + +For the dedicated workflow, manually select the trusted-API workflow revision, +set `trusted_vsi_only=true`, and supply its full Python `source_sha`. This path +skips **all** dev5 prepare/build/test/publish jobs. The reusable job builds only +an isolated native library and source-import metadata, never stages a release +native, builds/uploads a wheel, or publishes anything. Ordinary dev5 dispatch +and tag behavior, native pins, evidence, and release identity remain unchanged. +Do not package this new ABI under a dev5 artifact name. + +To test a locally built paired native without reinstalling the Python package: + +```sh +PYTHONPATH="$PWD/src" \ +C2PA_LIBRARY_NAME=/absolute/path/to/paired-c2pa-rs/target/debug/libc2pa_c.so \ +C2PA_SOURCE_BUILD_VERSION=0.91.0-dev \ +C2PA_TRUSTED_VSI_ABI_REQUIRED=1 \ +python -m pytest -q tests/test_trusted_vsi_api.py -ra +``` + +For an old native library, omit the required-ABI variable for honest local +skips, or use `-k "not paired"` for baseline-only coverage. Neither is paired +qualification. A fresh source checkout also needs distribution metadata for +imports (`python setup.py egg_info`, without a native download or wheel build). + +### Immutable dev5 process + The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.8.dev5` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.88.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. The immutable `castlabs-v0.37.8.dev1` tag records failed prerelease workflow run `34030865864`. Linux compiled successfully but its combined DynamicAssertion-plus-VSI smoke used a 5-byte callback result for a 64-byte reservation and later failed with `assertion.bmffHash.mismatch`; Windows compiled successfully and failed only in platform-sensitive wheel metadata parsing. The Linux failure was an undersized callback contract violation, not an inherent incompatibility between DynamicAssertions and VSI. No dev1 draft or GitHub release was created, and the tag remains unchanged. diff --git a/docs/release-notes.md b/docs/release-notes.md index 06f799e7..c173edaf 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,21 @@ # Release notes +## Unreleased: disabled trusted-processor VSI API + +- Replaces the unshipped expert EMSG scaffold with + `TrustedVsiPrehashedSession.sign_sig_structure(sig_structure)` and + `has_live_video_trusted_vsi_expert_sig_structure()`. No old aliases remain. +- Adds frozen `TrustedVsiSignResult`: a 64-byte fixed-format signature, uint32 + sequence number, and optional inclusive uint32 maximum not below that number. +- Mirrors the paired native signature/sequence output ABI. Split-init, + signer-composed EMSG, status, recovery, and V1 callback-context targets remain. +- All trusted capabilities remain false. Import does not invoke the native + trusted capability function; construction and operations reject before + argument inspection, callbacks, native calls, or managed-resource bookkeeping. +- Adds isolated Linux/Windows paired-source API tests. This is not functional + signing, CBOR/COSE validation, or dev5 artifact qualification. The immutable + dev5 release, source pins, version, and publication identity are unchanged. + ## Version 0.37.8.dev5 ### Breaking changes diff --git a/docs/usage.md b/docs/usage.md index 69e730a3..2bb367e9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -24,6 +24,44 @@ from c2pa import Settings, Context, ContextBuilder, ContextProvider All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. +## Trusted-processor VSI (unreleased, disabled) + +This is a separate target API, not the existing complete-buffer +`LiveVideoVsiSession` implementation. Every +`has_live_video_trusted_vsi_*()` probe returns `False`, including +`has_live_video_trusted_vsi_expert_sig_structure()`. Do not enable the private +Python gate: construction and every session operation raise +`C2paError.NotSupported` before inspecting inputs or invoking callbacks/native +code or allocating managed resources. Import does not call the native trusted +capability function while gated. + +The expert target signature is +`TrustedVsiPrehashedSession.sign_sig_structure(sig_structure: bytes) -> TrustedVsiSignResult`. +It accepts only the caller's exact COSE `Sig_structure`, without an EMSG skeleton +or duplicate hash/header envelope. The packager owns EMSG construction and event +IDs. The future signer owns ordered sequence allocation and signs the supplied +bytes without reconstruction. Its frozen result has exactly these fields: + +- `signature: bytes`: exactly 64 fixed-format bytes (not DER ECDSA). +- `sequence_number: int`: uint32, including zero and `2**32 - 1`. +- `sequence_max: Optional[int] = None`: optional inclusive uint32 ceiling, at + least `sequence_number`; absence advertises no ceiling. + +The packager predicts the sequence when composing the payload and treats the +result as confirmation. The future validator is limited to one canonical CBOR +item, an untagged four-element `Signature1` array, protected-header bytes, empty +external-AAD bytes, payload bytes, and a canonical protected-header algorithm +matching the session's fixed signature shape. This scaffold implements **no** +CBOR/COSE validation, signing, persistence, or state transitions. It does not +inspect payload sequence, hash, manifest, timing, or EMSG fields. + +Split-init reservation/finalization/commit, signer-composed EMSG reservation/ +finalization, recovery, status, and `VsiSigningContextV1` remain disabled native +target contracts. Expert callback context uses purpose `vsi`, the assigned +sequence, `event_id=None`, and sequence-derived `exhaust_after_sign`. Existing +complete-buffer VSI APIs are unchanged. Superseded unshipped expert names are +removed, not compatibility aliases. These changes are not in immutable dev5. + ## Define manifest JSON The Python library works with both file-based and stream-based operations. diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index bc3fe6cd..1bfb7eaa 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -34,6 +34,7 @@ LiveVideoVsiSession, TrustedVsiPrehashedSession, VsiSigningContextV1, + TrustedVsiSignResult, TrustedVsiInitUuidReservation, TrustedVsiMediaEmsgReservation, TrustedVsiStatus, @@ -45,7 +46,7 @@ has_live_video_vsi_mfhd_probe, has_live_video_vsi_recovery, has_live_video_trusted_vsi_split_init, - has_live_video_trusted_vsi_expert_emsg, + has_live_video_trusted_vsi_expert_sig_structure, has_live_video_trusted_vsi_composed_emsg, has_live_video_trusted_vsi_recovery, has_live_video_trusted_vsi_signing_context_v1, @@ -73,6 +74,7 @@ 'LiveVideoVsiSession', 'TrustedVsiPrehashedSession', 'VsiSigningContextV1', + 'TrustedVsiSignResult', 'TrustedVsiInitUuidReservation', 'TrustedVsiMediaEmsgReservation', 'TrustedVsiStatus', @@ -84,7 +86,7 @@ 'has_live_video_vsi_mfhd_probe', 'has_live_video_vsi_recovery', 'has_live_video_trusted_vsi_split_init', - 'has_live_video_trusted_vsi_expert_emsg', + 'has_live_video_trusted_vsi_expert_sig_structure', 'has_live_video_trusted_vsi_composed_emsg', 'has_live_video_trusted_vsi_recovery', 'has_live_video_trusted_vsi_signing_context_v1', diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index dafc545d..934ef431 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -141,8 +141,8 @@ 'c2pa_live_video_trusted_vsi_session_finalize_init_uuid', 'c2pa_live_video_trusted_vsi_session_commit_init_uuid', ) -_TRUSTED_VSI_EXPERT_MEDIA_FUNCTIONS = ( - 'c2pa_live_video_trusted_vsi_session_sign_emsg_sig_structure', +_TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_sign_sig_structure', ) _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS = ( 'c2pa_live_video_trusted_vsi_session_reserve_media_emsg', @@ -156,7 +156,7 @@ ) _TRUSTED_VSI_CAP_SPLIT_INIT = 1 << 0 -_TRUSTED_VSI_CAP_EXPERT_MEDIA = 1 << 1 +_TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE = 1 << 1 _TRUSTED_VSI_CAP_COMPOSED_MEDIA = 1 << 2 _TRUSTED_VSI_CAP_RECOVERY = 1 << 3 _TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1 = 1 << 4 @@ -269,8 +269,8 @@ def _validate_library_exports(lib): _TRUSTED_VSI_SPLIT_INIT_AVAILABLE = all( hasattr(_lib, name) for name in _TRUSTED_VSI_SPLIT_INIT_FUNCTIONS ) -_TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE = all( - hasattr(_lib, name) for name in _TRUSTED_VSI_EXPERT_MEDIA_FUNCTIONS +_TRUSTED_VSI_EXPERT_SIG_STRUCTURE_AVAILABLE = all( + hasattr(_lib, name) for name in _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS ) _TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE = all( hasattr(_lib, name) for name in _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS @@ -1384,12 +1384,13 @@ def _setup_function(func, argtypes, restype=None): [], ctypes.c_uint64, ) - try: - _TRUSTED_VSI_CAPABILITIES = int( - _lib.c2pa_live_video_trusted_vsi_capabilities() - ) - except Exception: # pragma: no cover - defensive import compatibility - _TRUSTED_VSI_CAPABILITIES = 0 + if _TRUSTED_VSI_PYTHON_API_ENABLED: + try: + _TRUSTED_VSI_CAPABILITIES = int( + _lib.c2pa_live_video_trusted_vsi_capabilities() + ) + except Exception: # pragma: no cover - defensive import compatibility + _TRUSTED_VSI_CAPABILITIES = 0 if _TRUSTED_VSI_CREATE_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1, @@ -1433,15 +1434,16 @@ def _setup_function(func, argtypes, restype=None): [ctypes.POINTER(C2paLiveVideoTrustedVsiSession)], ctypes.c_int, ) -if _TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE: +if _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_AVAILABLE: _setup_function( - _lib.c2pa_live_video_trusted_vsi_session_sign_emsg_sig_structure, + _lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure, [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, - ctypes.POINTER(ctypes.c_ubyte), - ctypes.c_size_t, - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ctypes.POINTER(ctypes.c_uint32), + ctypes.POINTER(ctypes.c_uint32), + ctypes.POINTER(ctypes.c_bool)], ctypes.c_int64, ) if _TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE: @@ -2245,6 +2247,35 @@ class VsiSigningContextV1: exhaust_after_sign: bool = False +@dataclass(frozen=True) +class TrustedVsiSignResult: + """Fixed-format signature and signer-assigned uint32 sequence metadata. + + ``sequence_max`` is an optional inclusive ceiling, not an event ID. + """ + + signature: bytes + sequence_number: int + sequence_max: Optional[int] = None + + def __post_init__(self): + if not isinstance(self.signature, bytes): + raise TypeError("signature must be bytes") + if len(self.signature) != 64: + raise ValueError("signature must be exactly 64 bytes") + for name, value in (("sequence_number", self.sequence_number), + ("sequence_max", self.sequence_max)): + if name == "sequence_max" and value is None: + continue + if type(value) is not int: + raise TypeError(f"{name} must be an integer") + if not 0 <= value <= 2**32 - 1: + raise ValueError(f"{name} must be a uint32") + if (self.sequence_max is not None + and self.sequence_max < self.sequence_number): + raise ValueError("sequence_max must not be below sequence_number") + + @dataclass(frozen=True) class TrustedVsiInitUuidReservation: """Opaque complete placeholder UUID box returned by init reservation.""" @@ -2295,11 +2326,11 @@ def has_live_video_trusted_vsi_split_init() -> bool: ) -def has_live_video_trusted_vsi_expert_emsg() -> bool: - """Return whether expert EMSG/Sig_structure signing is available.""" +def has_live_video_trusted_vsi_expert_sig_structure() -> bool: + """Return whether exact expert COSE Sig_structure signing is available.""" return _has_trusted_vsi_capability( - _TRUSTED_VSI_CAP_EXPERT_MEDIA, - _TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE, + _TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE, + _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_AVAILABLE, ) @@ -2401,9 +2432,9 @@ def has_fragmented_files() -> bool: class TrustedVsiPrehashedSession(ManagedResource): """Managed scaffold for trusted-processor prehashed VSI sessions. - This release publishes the stable Python shape but intentionally does not - enable trusted signing. Every entry point fails closed before invoking a - callback or native operation. + This unreleased API shape intentionally does not enable trusted signing. + Every entry point fails closed before argument inspection, callbacks, + native operations, or managed-resource bookkeeping. """ def __init__( @@ -2418,8 +2449,6 @@ def __init__( created_at: str, validity_period_secs: int, ): - super().__init__() - self._init_attrs() self._raise_scaffold_unavailable( has_live_video_trusted_vsi_signing_context_v1(), "trusted prehashed VSI session creation", @@ -2439,18 +2468,31 @@ def from_callback( validity_period_secs: int, ) -> 'TrustedVsiPrehashedSession': """Create a trusted session backed by a purpose-bound callback.""" - return cls( - manifest_json, - context, - callback, - algorithm, - public_cose_key, - kid, - min_sequence_number, - created_at, - validity_period_secs, + cls._raise_scaffold_unavailable( + has_live_video_trusted_vsi_signing_context_v1(), + "trusted prehashed VSI session creation", ) + def _cleanup_resources(self): + # Rejected construction owns nothing, including no PID/lifecycle state. + if _TRUSTED_VSI_PYTHON_API_ENABLED: + super()._cleanup_resources() + + def close(self) -> None: + """Unavailable while the trusted session scaffold is disabled.""" + self._raise_scaffold_unavailable(False, "trusted VSI close") + + def __enter__(self): + self._raise_scaffold_unavailable(False, "trusted VSI context entry") + + @property + def is_valid(self) -> bool: + self._raise_scaffold_unavailable(False, "trusted VSI validity") + + @classmethod + def _wrap_native_handle(cls, handle): + cls._raise_scaffold_unavailable(False, "trusted VSI handle wrapping") + def _init_attrs(self): super()._init_attrs() self._trusted_vsi_callback = None @@ -2494,15 +2536,19 @@ def commit_init_uuid(self) -> None: "trusted VSI init publication commit", ) - def sign_emsg_sig_structure( + def sign_sig_structure( self, - emsg_skeleton: bytes, sig_structure: bytes, - ) -> bytes: - """Sign a validated expert-mode EMSG COSE Sig_structure.""" + ) -> TrustedVsiSignResult: + """Sign exact caller-composed COSE Sig_structure bytes (disabled). + + The caller owns EMSG construction, event IDs, and payload semantics. + The future signer owns sequence allocation and signs without rebuilding + these bytes. No CBOR/COSE validator is implemented by this scaffold. + """ self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_expert_emsg(), - "trusted VSI expert EMSG signing", + has_live_video_trusted_vsi_expert_sig_structure(), + "trusted VSI expert Sig_structure signing", ) def reserve_media_emsg_at( @@ -5835,6 +5881,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'LiveVideoVsiSession', 'TrustedVsiPrehashedSession', 'VsiSigningContextV1', + 'TrustedVsiSignResult', 'TrustedVsiInitUuidReservation', 'TrustedVsiMediaEmsgReservation', 'TrustedVsiStatus', @@ -5846,7 +5893,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'has_live_video_vsi_mfhd_probe', 'has_live_video_vsi_recovery', 'has_live_video_trusted_vsi_split_init', - 'has_live_video_trusted_vsi_expert_emsg', + 'has_live_video_trusted_vsi_expert_sig_structure', 'has_live_video_trusted_vsi_composed_emsg', 'has_live_video_trusted_vsi_recovery', 'has_live_video_trusted_vsi_signing_context_v1', diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 43f11fc6..bb0d6628 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -60,6 +60,33 @@ def test_release_lock_and_schemas_are_valid_json(): jsonschema.validate(lock, schema) +def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): + workflow_dir = ROOT / ".github" / "workflows" + paired = (workflow_dir / "trusted-vsi-paired.yml").read_text(encoding="utf-8") + legacy = (workflow_dir / "build.yml").read_text(encoding="utf-8") + dedicated = (workflow_dir / "castlabs-vsi-release.yml").read_text(encoding="utf-8") + for caller in (legacy, dedicated): + assert "uses: ./.github/workflows/trusted-vsi-paired.yml" in caller + assert legacy.count('tests/test_trusted_vsi_api.py -k "not paired"') == 2 + assert "if: github.event_name == 'workflow_dispatch' && inputs.trusted_vsi_only" in dedicated + assert " prepare:\n if: ${{ !inputs.trusted_vsi_only }}" in dedicated + assert dedicated.count(f"ref: {release.RUST_COMMIT}") == 3 + assert 'C2PA_TRUSTED_VSI_ABI_REQUIRED: "1"' in paired + assert "python -m pytest -q tests/test_trusted_vsi_api.py -ra" in paired + assert "-k " not in paired + assert "ubuntu-24.04" in paired and "windows-2022" in paired + assert "C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/" in paired + assert "PYTHONPATH: ${{ github.workspace }}/python-source/src" in paired + assert "python setup.py egg_info" in paired + assert "cargo +1.88.0 build --locked" in paired + assert re.search(r"^\s+ref: [0-9a-f]{40}$", paired, re.MULTILINE) + assert "ref: feat/" not in paired + for forbidden in ("download_artifacts.py", "castlabs_release.py", + "upload-artifact@", "bdist_wheel", "contents: write", + "id-token: write", "continue-on-error", "gh release"): + assert forbidden not in paired + + def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( tmp_path, ): @@ -72,13 +99,20 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( legacy_workflow = (ROOT / ".github" / "workflows" / "build.yml").read_text( encoding="utf-8" ) - for workflow in (release_workflow, pypi_workflow): + paired_workflow = ( + ROOT / ".github" / "workflows" / "trusted-vsi-paired.yml" + ).read_text(encoding="utf-8") + for workflow in (release_workflow, pypi_workflow, paired_workflow): action_shas = re.findall( r"^\s*(?:-\s+)?uses:\s+[^@\s]+@([0-9a-f]{40})(?:\s+#.*)?$", workflow, re.MULTILINE, ) - assert len(action_shas) == workflow.count("uses:") + local_calls = workflow.count( + "uses: ./.github/workflows/trusted-vsi-paired.yml" + ) + assert local_calls == (1 if workflow == release_workflow else 0) + assert len(action_shas) + local_calls == workflow.count("uses:") assert "mstattma/" not in workflow assert release_workflow.count("timeout-minutes:") == 6 assert pypi_workflow.count("timeout-minutes:") == 1 diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py index 4dd4866a..2a8dcb4a 100644 --- a/tests/test_trusted_vsi_api.py +++ b/tests/test_trusted_vsi_api.py @@ -1,26 +1,48 @@ -from dataclasses import FrozenInstanceError +from dataclasses import FrozenInstanceError, fields import ctypes +import importlib.util +import inspect +import os +import sys +from unittest.mock import Mock import pytest import c2pa import c2pa.c2pa as bindings +import c2pa.lib as library_loader def test_trusted_vsi_scaffold_exports_and_capabilities_are_unavailable(): assert c2pa.has_live_video_trusted_vsi_split_init() is False - assert c2pa.has_live_video_trusted_vsi_expert_emsg() is False + assert c2pa.has_live_video_trusted_vsi_expert_sig_structure() is False assert c2pa.has_live_video_trusted_vsi_composed_emsg() is False assert c2pa.has_live_video_trusted_vsi_recovery() is False assert c2pa.has_live_video_trusted_vsi_signing_context_v1() is False assert c2pa.has_live_video_trusted_vsi_full_uint32_exhaustion() is False + assert bindings._TRUSTED_VSI_PYTHON_API_ENABLED is False + assert bindings._TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE == 2 + for module in (c2pa, bindings): + assert "TrustedVsiSignResult" in module.__all__ + assert "has_live_video_trusted_vsi_expert_sig_structure" in module.__all__ + assert "has_live_video_trusted_vsi_expert_emsg" not in module.__all__ + assert not hasattr(module, "has_live_video_trusted_vsi_expert_emsg") + assert all(hasattr(module, name) for name in module.__all__) + for name in ("_TRUSTED_VSI_CAP_EXPERT_MEDIA", + "_TRUSTED_VSI_EXPERT_MEDIA_FUNCTIONS", + "_TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE"): + assert not hasattr(bindings, name) + assert not hasattr(c2pa.TrustedVsiPrehashedSession, "sign_emsg_sig_structure") + method = inspect.signature(c2pa.TrustedVsiPrehashedSession.sign_sig_structure) + assert list(method.parameters) == ["self", "sig_structure"] + assert method.return_annotation is c2pa.TrustedVsiSignResult def test_trusted_vsi_public_values_are_immutable(): context = c2pa.VsiSigningContextV1( purpose="vsi", sequence_number=7, - event_id=1, + event_id=None, exhaust_after_sign=False, ) reservation = c2pa.TrustedVsiMediaEmsgReservation( @@ -49,26 +71,123 @@ def test_trusted_vsi_public_values_are_immutable(): context.event_id = 2 -def test_trusted_vsi_construction_fails_before_callback(): - calls = [] +@pytest.mark.parametrize("sequence,maximum", [ + (0, None), (0, 0), (7, 8), (2**32 - 1, None), (2**32 - 1, 2**32 - 1), +]) +def test_trusted_vsi_sign_result_is_frozen(sequence, maximum): + result = c2pa.TrustedVsiSignResult(b"s" * 64, sequence, maximum) + assert [field.name for field in fields(result)] == [ + "signature", "sequence_number", "sequence_max", + ] + assert result.signature == b"s" * 64 + assert result.sequence_number == sequence + assert result.sequence_max == maximum + for name in ("signature", "sequence_number", "sequence_max"): + with pytest.raises(FrozenInstanceError): + setattr(result, name, None) + assert c2pa.TrustedVsiSignResult(b"s" * 64, 0).sequence_max is None + + +@pytest.mark.parametrize("signature,sequence,maximum,error", [ + (b"", 0, None, ValueError), + (b"s" * 63, 0, None, ValueError), + (b"s" * 65, 0, None, ValueError), + (bytearray(64), 0, None, TypeError), + ("s" * 64, 0, None, TypeError), + (None, 0, None, TypeError), + (b"s" * 64, -1, None, ValueError), + (b"s" * 64, 2**32, None, ValueError), + (b"s" * 64, True, None, TypeError), + (b"s" * 64, None, None, TypeError), + (b"s" * 64, 1.0, None, TypeError), + (b"s" * 64, "1", None, TypeError), + (b"s" * 64, 0, -1, ValueError), + (b"s" * 64, 0, 2**32, ValueError), + (b"s" * 64, 0, False, TypeError), + (b"s" * 64, 0, 1.0, TypeError), + (b"s" * 64, 0, "1", TypeError), + (b"s" * 64, 7, 6, ValueError), +]) +def test_trusted_vsi_sign_result_rejects_invalid_values( + signature, sequence, maximum, error, +): + with pytest.raises(error): + c2pa.TrustedVsiSignResult(signature, sequence, maximum) + + +def test_trusted_vsi_import_never_invokes_native_while_gated(monkeypatch): + functions = {} + + class NativeDeclarationsOnly: + def __getattr__(self, name): + if not (name.startswith("c2pa_live_video_trusted_vsi_") + or hasattr(bindings._lib, name)): + raise AttributeError(name) + return functions.setdefault(name, Mock( + side_effect=AssertionError("native call during gated import"))) + + monkeypatch.setattr(library_loader, "dynamically_load_library", + lambda _name: NativeDeclarationsOnly()) + spec = importlib.util.spec_from_file_location( + "c2pa._trusted_gate_test", bindings.__file__) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + assert module._TRUSTED_VSI_CAPABILITIES == 0 + # Even a future native library advertising every bit cannot enable Python. + module._TRUSTED_VSI_CAPABILITIES = (1 << 6) - 1 + for name in c2pa.__all__: + if name.startswith("has_live_video_trusted_vsi_"): + assert getattr(module, name)() is False + for function in functions.values(): + function.assert_not_called() + + +class Uninspectable: + def __getattribute__(self, name): + raise AssertionError("argument inspected") + + def __len__(self): + raise AssertionError("argument length inspected") - def callback(context, data): - calls.append((context, data)) - raise AssertionError("callback must not run") + def __bool__(self): + raise AssertionError("argument truth inspected") + def __bytes__(self): + raise AssertionError("argument converted") + + def __call__(self, *args, **kwargs): + raise AssertionError("callback invoked") + + +@pytest.mark.parametrize("operation,arity", [ + ("__init__", 9), ("from_callback", 9), + ("reserve_init_uuid", 1), ("finalize_init_uuid", 1), + ("commit_init_uuid", 0), ("sign_sig_structure", 1), + ("reserve_media_emsg_at", 3), ("finalize_media_emsg", 1), + ("recover", 2), ("status", 0), ("close", 0), + ("__enter__", 0), ("__exit__", 3), ("is_valid", 0), + ("_wrap_native_handle", 1), +]) +def test_trusted_vsi_all_operations_gate_before_side_effects( + monkeypatch, operation, arity, +): + forbidden = Mock(side_effect=AssertionError("resource/native side effect")) + monkeypatch.setattr(bindings, "_lib", forbidden) + monkeypatch.setattr(bindings.ManagedResource, "__init__", forbidden) + monkeypatch.setattr(bindings.ManagedResource, "_cleanup_resources", forbidden) + monkeypatch.setattr(bindings, "record_owner_pid", forbidden) + monkeypatch.setattr(bindings.TrustedVsiPrehashedSession, "_init_attrs", forbidden) + session = object.__new__(c2pa.TrustedVsiPrehashedSession) with pytest.raises(c2pa.C2paError.NotSupported, match="not enabled"): - c2pa.TrustedVsiPrehashedSession.from_callback( - {"assertions": []}, - object(), - callback, - "ES256", - b"public-key", - b"kid", - 1, - "2026-01-01T00:00:00Z", - 60, - ) - assert calls == [] + if operation == "is_valid": + session.is_valid + else: + getattr(session, operation)(*[Uninspectable() for _ in range(arity)]) + assert vars(session) == {} + session.__del__() + forbidden.assert_not_called() + assert forbidden.mock_calls == [] def test_trusted_vsi_ctypes_declarations_match_v1_native_abi(): @@ -85,18 +204,59 @@ def test_trusted_vsi_ctypes_declarations_match_v1_native_abi(): assert ctypes.sizeof(bindings.C2paLiveVideoTrustedVsiStatusV1) == 24 -def test_trusted_vsi_optional_composed_prototype_matches_native_abi(): - if not bindings._TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE: - pytest.skip("trusted VSI composed-media symbols are not present") +@pytest.fixture +def paired_native(): + groups = ( + bindings._TRUSTED_VSI_CAPABILITIES_FUNCTIONS, + bindings._TRUSTED_VSI_CREATE_FUNCTIONS, + bindings._TRUSTED_VSI_SPLIT_INIT_FUNCTIONS, + bindings._TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS, + bindings._TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS, + bindings._TRUSTED_VSI_RECOVERY_FUNCTIONS, + bindings._TRUSTED_VSI_STATUS_FUNCTIONS, + ) + missing = [name for group in groups for name in group + if not hasattr(bindings._lib, name)] + if missing: + message = "paired trusted VSI ABI missing: " + ", ".join(missing) + if os.environ.get("C2PA_TRUSTED_VSI_ABI_REQUIRED") == "1": + pytest.fail(message) + pytest.skip(message + " (not paired qualification)") + + +def test_trusted_vsi_paired_expert_prototype_and_disabled_outputs(paired_native): + assert not hasattr(bindings._lib, + "c2pa_live_video_trusted_vsi_session_sign_emsg_sig_structure") + assert bindings._lib.c2pa_live_video_trusted_vsi_capabilities() == 0 + sign = bindings._lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure + assert sign.restype is ctypes.c_int64 + assert sign.argtypes == [ + ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ctypes.POINTER(ctypes.c_bool), + ] + sentinel = ctypes.c_ubyte(7) + output = ctypes.pointer(sentinel) + sequence = ctypes.c_uint32(42) + maximum = ctypes.c_uint32(43) + has_maximum = ctypes.c_bool(True) + assert sign(None, None, 0, ctypes.byref(output), ctypes.byref(sequence), + ctypes.byref(maximum), ctypes.byref(has_maximum)) == -1 + assert not output + assert sequence.value == maximum.value == 0 + assert has_maximum.value is False + + +def test_trusted_vsi_paired_composed_prototype_matches_native_abi(paired_native): reserve = bindings._lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg assert reserve.argtypes[-1] == ctypes.POINTER( bindings.C2paLiveVideoTrustedVsiSigningContextV1 ) -def test_trusted_vsi_optional_status_prototype_matches_native_abi(): - if not bindings._TRUSTED_VSI_STATUS_AVAILABLE: - pytest.skip("trusted VSI status symbol is not present") +def test_trusted_vsi_paired_status_prototype_matches_native_abi(paired_native): status = bindings._lib.c2pa_live_video_trusted_vsi_session_status_v1 assert status.restype is ctypes.c_int assert status.argtypes[-1] == ctypes.POINTER( From c27d51e9e1d508286ad0b4dcf87c92921477e38a Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:57:28 -0700 Subject: [PATCH 06/32] chore: Update C2PA version to 0.90.22 (#324) * chore: Update C2PA version to 0.90.22 * Bump version from 0.37.10 to 0.37.11 * Bump version from 0.37.10 to 0.37.11 --- c2pa-native-version.txt | 2 +- pyproject.toml | 2 +- src/c2pa/c2pa.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 95fc7a03..4ec17cad 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.90.19 +c2pa-v0.90.22 diff --git a/pyproject.toml b/pyproject.toml index 7620ff9e..faf1919f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.10" +version = "0.37.11" 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/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index d6f57c07..76cc9306 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.10 +# Version: 0.37.11 import ctypes import enum From 05dbc97df12459a6066f8d285c9049e3795f58cc Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Thu, 17 Sep 2026 14:56:22 -0700 Subject: [PATCH 07/32] Merge pull request #326 from contentauth/chore/rc-preflight-tooling Add reusable c2pa-rs RC-preflight workflow; fix latent test bugs --- .../workflows/test-c2pa-rs-source-build.yml | 204 ++++++++++++++++++ tests/test_unit_tests.py | 130 +++++++++-- 2 files changed, 315 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/test-c2pa-rs-source-build.yml diff --git a/.github/workflows/test-c2pa-rs-source-build.yml b/.github/workflows/test-c2pa-rs-source-build.yml new file mode 100644 index 00000000..4fde0afe --- /dev/null +++ b/.github/workflows/test-c2pa-rs-source-build.yml @@ -0,0 +1,204 @@ +name: Test against c2pa-rs built from source + +# Validates c2pa-python against a c2pa-rs git ref that has no published +# release artifacts (e.g. a release candidate tag), by building the native +# library from source instead of downloading a prebuilt one. +# +# This is the reusable tool for RC preflights: commit the target ref to +# c2pa-rs-preflight-ref.txt on a PR branch. Its mere presence is what opts +# the PR in, so this reruns automatically on every push to that PR -- same +# as any other check -- instead of you having to remember to re-dispatch by +# hand while iterating on fixes. Delete the file again once the PR is done +# with the RC (or once c2pa-rs ships a real release and you bump +# c2pa-native-version.txt through the normal process instead). +# +# Deliberately no workflow_dispatch trigger here: dispatching this workflow +# against the default branch would run in a context with write access to +# the default branch's Actions cache scope, while checking out and +# executing an arbitrary, unvalidated c2pa-rs ref -- exactly the cache +# poisoning pattern CodeQL's actions/cache-poisoning/poisonable-step query +# looks for. A same-repo pull_request only ever gets write access to its +# own branch's cache scope, so that path doesn't have the same exposure. + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + +permissions: + contents: read + +jobs: + resolve-ref: + name: Resolve c2pa-rs ref to test + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.resolve.outputs.ref }} + steps: + - uses: actions/checkout@v4 + - name: Resolve ref + id: resolve + run: | + if [ -f c2pa-rs-preflight-ref.txt ]; then + ref="$(tr -d '\r\n' < c2pa-rs-preflight-ref.txt)" + else + ref="" + fi + echo "ref=$ref" >> "$GITHUB_OUTPUT" + if [ -z "$ref" ]; then + echo "No c2pa-rs-preflight-ref.txt in this tree -- nothing to test, downstream jobs will skip." + else + echo "Testing against c2pa-rs ref: $ref" + fi + + tests-unix: + name: Unit tests (Unix, ${{ matrix.os }}) + needs: resolve-ref + if: | + needs.resolve-ref.outputs.ref != '' && ( + github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test') + ) + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ macos-latest, ubuntu-latest, ubuntu-24.04-arm ] + + steps: + - name: Checkout c2pa-python + uses: actions/checkout@v4 + with: + path: c2pa-python + + - name: Checkout c2pa-rs (${{ needs.resolve-ref.outputs.ref }}) + uses: actions/checkout@v4 + with: + repository: contentauth/c2pa-rs + ref: ${{ needs.resolve-ref.outputs.ref }} + path: c2pa-rs + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + # No pip cache here: this job checks out and builds an arbitrary, + # not-necessarily-reviewed c2pa-rs ref, and CodeQL flags caching in + # that context as a cache-poisoning vector into the default branch. + + - name: Install project dependencies + working-directory: c2pa-python + run: | + python -m pip install -r requirements.txt + python -m pip install -r requirements-dev.txt + + - name: Build native library from c2pa-rs source + working-directory: c2pa-python + env: + C2PA_RS_PATH: ${{ github.workspace }}/c2pa-rs + # Build for the runner's own arch rather than the universal2 macOS + # default: it's what a local `pip install -e .` picks up anyway, + # and skips the slow cross-compiled second-arch OpenSSL build. + C2PA_LIBS_PLATFORM: ${{ matrix.os == 'macos-latest' && 'aarch64-apple-darwin' || (matrix.os == 'ubuntu-24.04-arm' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }} + run: python scripts/build_local_artifacts.py --clean + + - name: Install package in development mode + working-directory: c2pa-python + run: | + pip uninstall -y c2pa + pip install -e . + + - name: Verify installation + working-directory: c2pa-python + run: python -c "from c2pa import C2paError; print('C2paError imported successfully')" + + - name: Run tests + working-directory: c2pa-python + run: python ./tests/test_unit_tests.py + + tests-windows: + name: Unit tests (Windows, ${{ matrix.runs-on }}) + needs: resolve-ref + if: | + needs.resolve-ref.outputs.ref != '' && ( + github.event_name != 'pull_request' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'safe to test') + ) + + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + - runs-on: windows-latest + python-version: "3.10" + - runs-on: windows-11-arm + python-version: "3.11" # win-arm runner needs 3.11 at least + + steps: + - name: Checkout c2pa-python + uses: actions/checkout@v4 + with: + path: c2pa-python + + - name: Checkout c2pa-rs (${{ needs.resolve-ref.outputs.ref }}) + uses: actions/checkout@v4 + with: + repository: contentauth/c2pa-rs + ref: ${{ needs.resolve-ref.outputs.ref }} + path: c2pa-rs + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + # No pip cache here: this job checks out and builds an arbitrary, + # not-necessarily-reviewed c2pa-rs ref, and CodeQL flags caching in + # that context as a cache-poisoning vector into the default branch. + + - name: Install ARM64 OpenSSL via vcpkg (Windows ARM64) + if: matrix.runs-on == 'windows-11-arm' + shell: pwsh + run: | + # Pre-installed OpenSSL on runner fails build. + # Static OpenSSL to avoid runtime DLL load complexities. + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install openssl:arm64-windows-static-md + $vcpkgRoot = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static-md" + echo "OPENSSL_DIR=$vcpkgRoot" >> $env:GITHUB_ENV + echo "OPENSSL_STATIC=1" >> $env:GITHUB_ENV + + - name: Install project dependencies + working-directory: c2pa-python + run: | + python -m pip install -r requirements.txt + python -m pip install -r requirements-dev.txt + + - name: Build native library from c2pa-rs source + working-directory: c2pa-python + env: + C2PA_RS_PATH: ${{ github.workspace }}\c2pa-rs + run: python scripts\build_local_artifacts.py --clean + + - name: Install package in development mode + working-directory: c2pa-python + run: | + pip uninstall -y c2pa + pip install -e . + + - name: Verify installation + working-directory: c2pa-python + run: python -c "from c2pa import C2paError; print('C2paError imported successfully')" + + - name: Run tests + working-directory: c2pa-python + run: python .\tests\test_unit_tests.py diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 4bff6dbb..d8eca739 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -2629,7 +2629,17 @@ def test_write_ingredient_archive_produces_readable_archive(self): def test_add_ingredient_from_archive_roundtrip(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } builder = Builder.from_json(manifest) ingredient_json = { @@ -2662,7 +2672,17 @@ def test_add_ingredient_from_archive_roundtrip(self): def test_add_ingredient_from_archive_preserves_instance_id(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "0.1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } archive_builder = Builder.from_json(manifest) ingredient_json = { @@ -2695,7 +2715,17 @@ def test_add_ingredient_from_archive_preserves_instance_id(self): def test_add_ingredient_from_archive_preserves_instance_id_component_of(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } archive_builder = Builder.from_json(manifest) ingredient_json = { @@ -2729,7 +2759,17 @@ def test_add_ingredient_from_archive_preserves_instance_id_component_of(self): def test_add_ingredient_from_archive_preserves_instance_id_input_to(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } archive_builder = Builder.from_json(manifest) ingredient_json = { @@ -2763,7 +2803,17 @@ def test_add_ingredient_from_archive_preserves_instance_id_input_to(self): def test_add_ingredient_from_archive_roundtrip_parent_of(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } builder = Builder.from_json(manifest) ingredient_json = { @@ -2797,7 +2847,17 @@ def test_add_ingredient_from_archive_roundtrip_parent_of(self): def test_add_ingredient_from_archive_roundtrip_input_to(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } builder = Builder.from_json(manifest) ingredient_json = { @@ -2963,7 +3023,17 @@ def test_ingredient_from_archive_linked_to_edited_action(self): def test_add_two_ingredient_archives_to_one_builder(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } archives = [] for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]: @@ -3001,7 +3071,17 @@ def test_add_two_ingredient_archives_to_one_builder(self): def test_write_ingredient_archive_only_contains_requested_ingredient(self): manifest = { "claim_generator_info": [{"name": "c2pa-test", "version": "1.0"}], - "assertions": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }] + } + } + ], } archive_builder = Builder.from_json(manifest) for title, instance_id in [("A.jpg", "ingredient-A"), ("B.jpg", "ingredient-B")]: @@ -3444,7 +3524,7 @@ def test_builder_add_multiple_ingredients(self): # Test adding another ingredient ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3464,7 +3544,7 @@ def test_builder_add_multiple_ingredients_2(self): # Test adding another ingredient with a JSON string ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3493,7 +3573,7 @@ def test_builder_add_multiple_ingredients_and_resources(self): ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3551,7 +3631,7 @@ def test_builder_add_multiple_ingredients_and_resources_interleaved(self): ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -5546,6 +5626,10 @@ def test_link_archive_label_on_signing_builder_placed(self): "label": "c2pa.actions.v2", "data": { "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }, { "action": "c2pa.placed", "parameters": { @@ -5689,16 +5773,16 @@ def test_link_archive_two_ingredients_labels(self): "data": { "actions": [ { - "action": "c2pa.placed", + "action": "c2pa.opened", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", "parameters": { - "ingredientIds": ["ingredient-for-placed"] + "ingredientIds": ["ingredient-for-opened"] }, }, { - "action": "c2pa.opened", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + "action": "c2pa.placed", "parameters": { - "ingredientIds": ["ingredient-for-opened"] + "ingredientIds": ["ingredient-for-placed"] }, }, ] @@ -5781,6 +5865,10 @@ def test_link_archive_multiple_ingredients_in_one_placed_action(self): "label": "c2pa.actions.v2", "data": { "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", + }, { "action": "c2pa.placed", "parameters": { @@ -8539,9 +8627,13 @@ def test_swapped_builder_is_freed_exactly_once(self): builder.close() builder.close() - # Only the replacement is must be freed here. + # The replacement must be freed exactly once; a second close() is a + # no-op. We don't separately assert original_handle's count here: the + # native allocator may legally reuse the just-freed original address + # for the replacement Box (same-size free-then-alloc within one FFI + # call), so swapped_handle and original_handle can be the same + # pointer value. The check above already covers that case correctly. self.assertEqual(self._free_count(freed, swapped_handle), 1) - self.assertEqual(self._free_count(freed, original_handle), 0) def test_repeated_swaps_on_one_builder(self): # Each with_archive consumes the handle the previous one returned, so From f9ea2b22f7162cef76d094409949e010428747ca Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Thu, 17 Sep 2026 16:04:42 -0700 Subject: [PATCH 08/32] Make test_sdk_version aware of the RC-preflight ref file (#327) test_sdk_version compares the loaded native library's version against c2pa-native-version.txt, but an RC-preflight run (per test-c2pa-rs-source-build.yml) actually builds from whatever ref is in c2pa-rs-preflight-ref.txt instead, so the test always failed on that one assertion during a preflight even when everything else passed. parse_native_version() now prefers c2pa-rs-preflight-ref.txt when present, falling back to c2pa-native-version.txt otherwise -- the same precedence the workflow itself uses to decide what to build. Verified both paths locally: green against the pinned 0.90.22 with no preflight file, and green against a c2pa-rc-v0.91.0-rc.3 build with the file present. Co-authored-by: Claude Sonnet 5 --- tests/test_unit_tests.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index d8eca739..5ab85c91 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -76,16 +76,25 @@ def load_test_settings_json(): def parse_native_version(): """ - Parse the expected native SDK version from c2pa-native-version.txt. + Parse the expected native SDK version. + + Prefers c2pa-rs-preflight-ref.txt when present: that's the same file + test-c2pa-rs-source-build.yml reads to decide which c2pa-rs ref to build + from for an RC preflight (see that workflow's header comment), so the + native library actually loaded during such a run was built from that + ref, not from c2pa-native-version.txt. Falls back to + c2pa-native-version.txt otherwise. Returns: str: The semantic version string (e.g. "0.85.2"). """ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + preflight_path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') version_path = os.path.join(repo_root, 'c2pa-native-version.txt') - with open(version_path, 'r') as f: + path = preflight_path if os.path.isfile(preflight_path) else version_path + with open(path, 'r') as f: raw = f.read().strip() - # Strip the "c2pa-v" prefix to get the bare semantic version. + # Strip the "c2pa-v" / "c2pa-rc-v" prefix to get the bare semantic version. return raw.split('v', 1)[1] if 'v' in raw else raw From 4821daf18d8580f5cb734dd615c91bba7f5a153b Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Thu, 17 Sep 2026 16:41:11 -0700 Subject: [PATCH 09/32] Fix test_sdk_version regression: gate on an env var, not file presence (#328) #327's parse_native_version() fix preferred c2pa-rs-preflight-ref.txt whenever it existed in the checked-out tree. That broke the *real* build.yml jobs on #325: that PR adds the ref file to the branch, so an ordinary tests-unix/tests-windows run (which downloads and installs the actual pinned release, unrelated to the preflight workflow) picked up the file too and wrongly expected the RC's version string, failing with e.g. "'0.91.0-rc.3' not found in '0.90.22'". The file's mere presence was never a reliable signal -- only test-c2pa-rs-source-build.yml's own "Run tests" step actually builds from that ref. Gate on a new C2PA_PREFLIGHT_RUN env var that only that step sets instead. Verified locally: with the pinned 0.90.22 installed and no env var set, all 444 tests pass regardless of whether c2pa-rs-preflight-ref.txt happens to exist in the tree. Co-authored-by: Claude Sonnet 5 --- .../workflows/test-c2pa-rs-source-build.yml | 4 ++++ tests/test_unit_tests.py | 20 ++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-c2pa-rs-source-build.yml b/.github/workflows/test-c2pa-rs-source-build.yml index 4fde0afe..a6c9ab2d 100644 --- a/.github/workflows/test-c2pa-rs-source-build.yml +++ b/.github/workflows/test-c2pa-rs-source-build.yml @@ -121,6 +121,8 @@ jobs: - name: Run tests working-directory: c2pa-python + env: + C2PA_PREFLIGHT_RUN: "1" run: python ./tests/test_unit_tests.py tests-windows: @@ -201,4 +203,6 @@ jobs: - name: Run tests working-directory: c2pa-python + env: + C2PA_PREFLIGHT_RUN: "1" run: python .\tests\test_unit_tests.py diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 5ab85c91..bc925aad 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -78,20 +78,22 @@ def parse_native_version(): """ Parse the expected native SDK version. - Prefers c2pa-rs-preflight-ref.txt when present: that's the same file - test-c2pa-rs-source-build.yml reads to decide which c2pa-rs ref to build - from for an RC preflight (see that workflow's header comment), so the - native library actually loaded during such a run was built from that - ref, not from c2pa-native-version.txt. Falls back to - c2pa-native-version.txt otherwise. + Reads c2pa-rs-preflight-ref.txt instead of c2pa-native-version.txt when + C2PA_PREFLIGHT_RUN is set: that flag is set only by + test-c2pa-rs-source-build.yml's own "Run tests" step, because the + presence of c2pa-rs-preflight-ref.txt in the checked-out tree isn't by + itself proof of anything -- an ordinary build.yml run on a branch that + happens to carry that file (e.g. this PR) still downloads and installs + the real pinned release, not the preflight ref. Returns: str: The semantic version string (e.g. "0.85.2"). """ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - preflight_path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') - version_path = os.path.join(repo_root, 'c2pa-native-version.txt') - path = preflight_path if os.path.isfile(preflight_path) else version_path + if os.environ.get('C2PA_PREFLIGHT_RUN'): + path = os.path.join(repo_root, 'c2pa-rs-preflight-ref.txt') + else: + path = os.path.join(repo_root, 'c2pa-native-version.txt') with open(path, 'r') as f: raw = f.read().strip() # Strip the "c2pa-v" / "c2pa-rc-v" prefix to get the bare semantic version. From 352e268cb93cb5f8d673a33c45506beeee3f7f9f Mon Sep 17 00:00:00 2001 From: Eric Scouten Date: Wed, 23 Sep 2026 15:02:46 -0700 Subject: [PATCH 10/32] chore: bump c2pa-rs to v0.91.0 (#325) * Fix test_sdk_version regression: gate on an env var, not file presence #327's parse_native_version() fix preferred c2pa-rs-preflight-ref.txt whenever it existed in the checked-out tree. That broke the *real* build.yml jobs on #325: that PR adds the ref file to the branch, so an ordinary tests-unix/tests-windows run (which downloads and installs the actual pinned release, unrelated to the preflight workflow) picked up the file too and wrongly expected the RC's version string, failing with e.g. "'0.91.0-rc.3' not found in '0.90.22'". The file's mere presence was never a reliable signal -- only test-c2pa-rs-source-build.yml's own "Run tests" step actually builds from that ref. Gate on a new C2PA_PREFLIGHT_RUN env var that only that step sets instead. Verified locally: with the pinned 0.90.22 installed and no env var set, all 444 tests pass regardless of whether c2pa-rs-preflight-ref.txt happens to exist in the tree. Co-Authored-By: Claude Sonnet 5 * Point the RC preflight at c2pa-rs 0.91.0-rc.3 This is the entire diff this PR now carries on top of #328: pin the preflight workflow at c2pa-rc-v0.91.0-rc.3 so it builds from that git tag (no crates.io publish, no prebuilt GitHub release binaries for an RC) and runs the full unit test suite against it. Co-Authored-By: Claude Sonnet 5 * Point the RC preflight at c2pa-rs 0.91.0-rc.3 This is the entire diff this PR now carries on top of #328: pin the preflight workflow at c2pa-rc-v0.91.0-rc.3 so it builds from that git tag (no crates.io publish, no prebuilt GitHub release binaries for an RC) and runs the full unit test suite against it. Co-Authored-By: Claude Sonnet 5 * Preflight against c2pa-rs 0.91.0-rc.4 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: bump c2pa-rs to v0.91.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- c2pa-native-version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt index 4ec17cad..d642f3f6 100644 --- a/c2pa-native-version.txt +++ b/c2pa-native-version.txt @@ -1 +1 @@ -c2pa-v0.90.22 +c2pa-v0.91.0 From eafb98d8fbda5ef1d980a1c8df445ef267443565 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:38:13 -0700 Subject: [PATCH 11/32] fix: Make memory benchmark run again (#331) --- tests/perf/scenarios.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/perf/scenarios.py b/tests/perf/scenarios.py index 23300aed..bdfd2795 100644 --- a/tests/perf/scenarios.py +++ b/tests/perf/scenarios.py @@ -43,6 +43,13 @@ _DST_COMPOSITE = "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia" +# c2pa-rs >= 0.91 rejects manifests whose first action is not created or opened. +_CREATED_ACTION = { + "action": "c2pa.created", + "softwareAgent": {"name": "perf_test"}, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", +} + _PARENT_ID = "xmp:iid:aaaaaaaa-0001-0001-0001-aaaaaaaaaaaa" _PLACED_ID = "xmp:iid:bbbbbbbb-0002-0002-0002-bbbbbbbbbbbb" _PARENT_ID2 = "xmp:iid:cccccccc-0003-0003-0003-cccccccccccc" @@ -307,7 +314,7 @@ def scenario_builder_sign_jpeg_component_of(iterations: int = 100) -> None: "ingredients": [{"format": "image/jpeg", "relationship": "componentOf", "instance_id": _PLACED_ID}], "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_PLACED_ID]}, @@ -411,7 +418,7 @@ def scenario_builder_sign_jpeg_two_components_same_mime(iterations: int = 100) - **MANIFEST_BASE, "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_PLACED_ID4, _PLACED_ID5]}, @@ -441,7 +448,7 @@ def scenario_builder_sign_jpeg_two_components_mixed_mime(iterations: int = 100) **MANIFEST_BASE, "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_PLACED_ID4, _PLACED_ID5]}, @@ -836,7 +843,7 @@ def scenario_builder_sign_jpeg_two_ingredient_archives(iterations: int = 100) -> **MANIFEST_BASE, "assertions": [{ "label": "c2pa.actions.v2", - "data": {"actions": [{ + "data": {"actions": [_CREATED_ACTION, { "action": "c2pa.placed", "softwareAgent": {"name": "perf_test"}, "parameters": {"ingredientIds": [_ARCH_COMP_ID, _ARCH_COMP_ID2]}, From 7785f540bd12fa0c063e7f9f524390704ca23179 Mon Sep 17 00:00:00 2001 From: tmathern <60901087+tmathern@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:33:43 -0700 Subject: [PATCH 12/32] chore: Bump version from 0.37.11 to 0.37.12 (#332) * chore: Bump version from 0.37.11 to 0.37.12 * Bump version from 0.37.11 to 0.37.12 --- pyproject.toml | 2 +- src/c2pa/c2pa.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index faf1919f..8b6464b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.11" +version = "0.37.12" 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/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 76cc9306..69b0d4eb 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.11 +# Version: 0.37.12 import ctypes import enum From ca7ea1acd171d90b82926f6931068828f2977b6d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Fri, 25 Sep 2026 06:11:14 +0200 Subject: [PATCH 13/32] feat: sign single-file rendition ladders with one manifest Register the native ladder export as optional, validate path arrays, preserve typed errors and allocation ownership, and use the modern single-sign builder lifecycle. Add focused binding tests and isolated stock/candidate native qualification lanes without changing release pins. Co-authored-by: bibinbaby444 Co-authored-by: Claude Fable 5.1 --- docs/ladder-signing.md | 62 +++++++++++ src/c2pa/c2pa.py | 107 +++++++++++++++++++ tests/ladder_native.py | 211 ++++++++++++++++++++++++++++++++++++++ tests/test_sign_ladder.py | 197 +++++++++++++++++++++++++++++++++++ 4 files changed, 577 insertions(+) create mode 100644 docs/ladder-signing.md create mode 100644 tests/ladder_native.py create mode 100644 tests/test_sign_ladder.py diff --git a/docs/ladder-signing.md b/docs/ladder-signing.md new file mode 100644 index 00000000..7c9424d0 --- /dev/null +++ b/docs/ladder-signing.md @@ -0,0 +1,62 @@ +# Single-File Ladder Signing + +`Builder.sign_ladder(signer, sources, dests)` signs an ordered set of single-file +fragmented MP4 renditions with one shared manifest. Each input must contain its +own initialization and media fragments and one track, without an existing C2PA +manifest. A ladder contains 1 to 256 renditions. Outputs correspond to inputs by +position. Destination paths must be distinct and must not exist, and their parent +directories must exist; the native implementation validates file layouts and +overlap. Errors may leave partial newly created outputs; discard these files. + +```python +with Builder(manifest_definition) as builder: + manifest_bytes = builder.sign_ladder( + signer, + [Path("low.mp4"), Path("high.mp4")], + [Path("signed-low.mp4"), Path("signed-high.mp4")], + ) +``` + +Pass an explicit active `Signer`, created from signing information or a callback. +This method does not fall back to a context signer. Like ordinary signing, an +attempted native call closes the builder on success or failure; the signer remains +usable. Preflight errors leave the builder usable. Paths must be UTF-8 strings +or `Path` objects and cannot contain NUL characters. + +The native export `c2pa_builder_sign_ladder` is optional. A library without it +still imports and supports ordinary signing. Calling `sign_ladder` on that library +raises `C2paError.NotSupported`. No native release pin changes are needed for this +binding addition. + +## Verification + +Use a local virtual environment for dependencies. The focused mock tests run +against an otherwise supported native library: + +```sh +PYTHONPATH=src C2PA_LIBRARY_NAME=/absolute/path/to/stock/libc2pa_c.so \ + .venv/bin/python -m pytest -q tests/test_sign_ladder.py +``` + +Run both real-native lanes explicitly. The harness copies this package and the +specified library into a temporary directory and launches a fresh Python process. +It checks the exact loaded path and SHA-256 and reports the native SDK version. +It uses the existing `C2PA_LIBRARY_NAME` loader seam, not a new loader override. +It does not replace an installed package's library. + +```sh +.venv/bin/python tests/ladder_native.py --lane stock \ + --library /absolute/path/to/stock/libc2pa_c.so +.venv/bin/python tests/ladder_native.py --lane candidate \ + --library /absolute/path/to/candidate/libc2pa_c.so \ + --native-fixtures /absolute/path/to/c2pa-rs/sdk/tests/fixtures +``` + +The stock lane requires the export to be absent, checks the call-time typed error, +then signs and validates an ordinary JPEG with the same builder. The candidate +lane requires the export to be present: missing capability is a failure, never a +skip. It also checks ordinary signing, info and callback signers, both synthetic +`single_file_fragments*.mp4` fixtures, identical embedded manifests, validation, +media tampering, callback failure, and native rejection of source aliases, +duplicate destinations, and existing destinations without overwriting them. +Fixture hashes are included in the output; the source fixtures are never modified. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 69b0d4eb..43997f01 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -1052,6 +1052,21 @@ def _setup_function(func, argtypes, restype=None): ) +# Optional capability: older native libraries still support ordinary signing. +_HAS_SIGN_LADDER = hasattr(_lib, "c2pa_builder_sign_ladder") +if _HAS_SIGN_LADDER: + _setup_function( + _lib.c2pa_builder_sign_ladder, + [ctypes.POINTER(C2paBuilder), + ctypes.POINTER(C2paSigner), + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_char_p), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 + ) + + class C2paError(Exception): """Exception raised for C2PA errors. @@ -3903,6 +3918,98 @@ def sign( "First argument must be a Signer or a format string (MIME type)." ) + def sign_ladder( + self, + signer: Signer, + sources: list[Union[str, Path]], + dests: list[Union[str, Path]], + ) -> bytes: + """Sign single-file fragmented MP4 renditions with one shared manifest. + + Each source must contain its own initialization and media fragments, + with one track per file. This is not an init-segment-plus-fragments + API. Sources must not already contain a C2PA manifest. Destinations + correspond to sources in order; they must be distinct, must not exist, + and their parent directories must exist. Native code validates the file + layout and path overlap. Errors may leave partial newly created outputs; + discard these files. + + Like :py:meth:`sign`, an attempted native signing call closes this + Builder on success or failure. Preflight errors (including unavailable + native capability) leave it usable. The signer is borrowed and remains + usable. A context signer is not used by this method. + + Args: + signer: An explicit, active Signer (from info or a callback). + sources: List of 1 to 256 UTF-8 paths, one per rendition. + dests: Equally sized list of corresponding output paths. + + Returns: + The manifest bytes embedded in every output rendition. + + Raises: + C2paError.NotSupported: If the native library lacks ladder signing. + C2paError.Encoding: If a path is invalid UTF-8 or contains NUL. + C2paError: If inputs are invalid, signing fails, or copying the + returned manifest fails. + """ + self._ensure_valid_state() + if not _HAS_SIGN_LADDER: + raise C2paError.NotSupported( + "This native library does not export c2pa_builder_sign_ladder; " + "use a native library with single-file ladder signing support.") + if not isinstance(signer, Signer): + raise C2paError("An explicit Signer is required for ladder signing") + signer._ensure_valid_state() + if not isinstance(sources, list) or not isinstance(dests, list): + raise C2paError("sources and dests must be lists of paths") + if len(sources) != len(dests): + raise C2paError("sources and dests must have the same length") + if not 1 <= len(sources) <= 256: + raise C2paError("A ladder requires 1 to 256 renditions") + + # Retain both the encoded strings and ordered pointer arrays until + # the borrowed native call returns. + encoded_paths = [] + for paths in (sources, dests): + encoded = [] + for path in paths: + try: + text = os.fspath(path) + if not isinstance(text, str) or "\0" in text: + raise ValueError("paths must be strings without NUL") + encoded.append(text.encode("utf-8")) + except (TypeError, ValueError) as e: + raise C2paError.Encoding( + f"Invalid ladder path: {e}") from e + encoded_paths.append(encoded) + count = len(sources) + source_array = (ctypes.c_char_p * count)(*encoded_paths[0]) + dest_array = (ctypes.c_char_p * count)(*encoded_paths[1]) + manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + try: + result = _lib.c2pa_builder_sign_ladder( + self._handle, signer._handle, source_array, dest_array, + count, ctypes.byref(manifest_bytes_ptr)) + _check_ffi_operation_result( + result, "Error during ladder signing", check=lambda r: r < 0) + if result <= 0 or not manifest_bytes_ptr: + raise C2paError("Ladder signing returned no manifest bytes") + return ctypes.string_at(manifest_bytes_ptr, result) + except C2paError: + raise + except Exception as e: + raise C2paError(f"Error during ladder signing: {e}") from e + finally: + if manifest_bytes_ptr: + try: + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + except Exception: + logger.error("Failed to release native manifest bytes memory") + # Native code borrows both handles. Free our builder, not the signer. + self.close() + @overload def sign_file( self, diff --git a/tests/ladder_native.py b/tests/ladder_native.py new file mode 100644 index 00000000..5cf365e0 --- /dev/null +++ b/tests/ladder_native.py @@ -0,0 +1,211 @@ +"""Explicit real-native lanes, run in a fresh process with an isolated package. + +See docs/ladder-signing.md for commands. Neither lane skips missing capability. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run_lane(args): + import c2pa.c2pa as binding + from c2pa import Builder, C2paError, C2paSignerInfo, Reader, Signer + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + + loaded = Path(binding._lib._name).resolve(strict=True) + expected = Path(os.environ["C2PA_LIBRARY_NAME"]).resolve(strict=True) + assert loaded == expected, (loaded, expected) + assert Path(binding.__file__).resolve().parent == expected.parent + assert digest(loaded) == digest(args.library), "Library changed during staging" + print(json.dumps({ + "lane": args.lane, "source_library": str(args.library), + "loaded_library": str(loaded), "sha256": digest(loaded), + "sdk_version": binding.sdk_version(), + "has_sign_ladder": binding._HAS_SIGN_LADDER, + }), flush=True) + assert binding._HAS_SIGN_LADDER == (args.lane == "candidate"), ( + "Candidate requires c2pa_builder_sign_ladder; stock must lack it") + if args.lane == "candidate": + export = binding._lib.c2pa_builder_sign_ladder + assert export.restype is binding.ctypes.c_int64 + assert export.argtypes == [ + binding.ctypes.POINTER(binding.C2paBuilder), + binding.ctypes.POINTER(binding.C2paSigner), + binding.ctypes.POINTER(binding.ctypes.c_char_p), + binding.ctypes.POINTER(binding.ctypes.c_char_p), + binding.ctypes.c_size_t, + binding.ctypes.POINTER( + binding.ctypes.POINTER(binding.ctypes.c_ubyte)), + ] + + definition = { + "claim_generator_info": [{"name": "ladder-binding-test"}], + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], + } + certs = (FIXTURES / "es256_certs.pem").read_bytes() + key = (FIXTURES / "es256_private.key").read_bytes() + info = C2paSignerInfo(alg=b"es256", sign_cert=certs, + private_key=key, ta_url=None) + with Signer.from_info(info) as signer: + with Builder(definition) as builder: + if args.lane == "stock": + try: + builder.sign_ladder(signer, ["in.mp4"], ["out.mp4"]) + except C2paError.NotSupported as error: + assert "c2pa_builder_sign_ladder" in str(error) + else: + raise AssertionError("Stock capability error was not raised") + builder._ensure_valid_state() + ordinary = Path("ordinary.jpg") + assert builder.sign_file(FIXTURES / "A.jpg", ordinary, signer) + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + signer._ensure_valid_state() + with Reader(ordinary) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + print("ordinary signing and validation passed", flush=True) + if args.lane == "stock": + return + + assert args.native_fixtures is not None, "--native-fixtures is required" + fixtures = [args.native_fixtures / name for name in ( + "single_file_fragments.mp4", "single_file_fragments_absolute.mp4")] + before = [digest(path) for path in fixtures] + print(json.dumps({"fixtures": dict(zip(map(str, fixtures), before))}), + flush=True) + sources = [Path(path.name) for path in fixtures] + for source, fixture in zip(sources, fixtures): + shutil.copy2(fixture, source) + private_key = serialization.load_pem_private_key(key, password=None) + + def callback(data): + return private_key.sign(data, ec.ECDSA(hashes.SHA256())) + + with Signer.from_callback(callback, binding.C2paSigningAlg.ES256, + certs.decode()) as callback_signer: + for label, active_signer in (("info", signer), + ("callback", callback_signer)): + dests = [Path(f"{label}-{i}.mp4") for i in range(len(sources))] + with Builder(definition) as builder: + manifest = builder.sign_ladder(active_signer, sources, dests) + assert manifest + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + active_signer._ensure_valid_state() + manifests = [] + for dest in dests: + assert manifest in dest.read_bytes() + with Reader(dest) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + data = json.loads(reader.json()) + manifests.append(data["manifests"][data["active_manifest"]]) + assert manifests[0] == manifests[1] + + # Change media payload, not box structure or the signed manifest. + tampered = bytearray(dests[0].read_bytes()) + offset = 0 + while tampered[offset + 4:offset + 8] != b"mdat": + size = int.from_bytes(tampered[offset:offset + 4], "big") + assert size >= 8 + offset += size + assert offset + 8 < len(tampered) + tampered[offset + 8] ^= 1 + dests[0].write_bytes(tampered) + with Reader(dests[0]) as reader: + assert reader.get_validation_state() == "Invalid", reader.json() + print(f"{label} ladder signing, shared manifest, and tamper checks passed", + flush=True) + + # Native path validation failure still ends this builder's single use. + existing = Path("existing.mp4") + sentinel = b"existing destination must not be overwritten" + existing.write_bytes(sentinel) + for label, dests in ( + ("source alias", sources), + ("duplicate destination", [Path("duplicate.mp4")] * 2), + ("existing destination", [existing, Path("new.mp4")]), + ): + with Builder(definition) as builder: + try: + builder.sign_ladder(signer, sources, dests) + except C2paError: + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + else: + raise AssertionError(f"Native layer accepted {label}") + signer._ensure_valid_state() + assert existing.read_bytes() == sentinel + assert [digest(path) for path in sources] == before + print(f"{label} refusal and lifecycle checks passed", flush=True) + + calls = [] + + def fail_callback(data): + calls.append(len(data)) + return b"" + + with Signer.from_callback(fail_callback, binding.C2paSigningAlg.ES256, + certs.decode()) as failing_signer: + with Builder(definition) as builder: + try: + builder.sign_ladder(failing_signer, sources, + [Path("failed-0.mp4"), Path("failed-1.mp4")]) + except C2paError: + assert calls, "Signing failed before invoking the callback" + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + else: + raise AssertionError("Native layer accepted a failed callback") + failing_signer._ensure_valid_state() + print("callback failure and lifecycle checks passed", flush=True) + assert [digest(path) for path in sources] == before + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--library", type=Path, required=True) + parser.add_argument("--lane", choices=("stock", "candidate"), required=True) + parser.add_argument("--native-fixtures", type=Path) + parser.add_argument("--child", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + args.library = args.library.resolve(strict=True) + if args.native_fixtures is not None: + args.native_fixtures = args.native_fixtures.resolve(strict=True) + if args.child: + run_lane(args) + return + with tempfile.TemporaryDirectory(prefix="c2pa-ladder-") as temp: + root = Path(temp) + package = root / "c2pa" + shutil.copytree(ROOT / "src" / "c2pa", package, + ignore=shutil.ignore_patterns("libs", "__pycache__")) + staged = package / args.library.name + shutil.copy2(args.library, staged) + env = os.environ.copy() + env["PYTHONPATH"] = str(root) + # Existing loader seam, with an absolute filename and a checked result. + env["C2PA_LIBRARY_NAME"] = str(staged) + command = [sys.executable, str(Path(__file__).resolve()), + "--child", "--lane", args.lane, "--library", str(args.library)] + if args.native_fixtures is not None: + command += ["--native-fixtures", str(args.native_fixtures)] + subprocess.run(command, cwd=root, env=env, check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sign_ladder.py b/tests/test_sign_ladder.py new file mode 100644 index 00000000..a4064ad0 --- /dev/null +++ b/tests/test_sign_ladder.py @@ -0,0 +1,197 @@ +"""Focused binding tests; all ladder FFI calls and handle frees are mocked.""" + +import ctypes +import gc +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import c2pa.c2pa as binding + + +@pytest.fixture +def ladder(monkeypatch): + native = SimpleNamespace( + c2pa_builder_sign_ladder=Mock(), + c2pa_manifest_bytes_free=Mock(), + c2pa_free=Mock(return_value=0), + ) + monkeypatch.setattr(binding, "_lib", native) + monkeypatch.setattr(binding, "_HAS_SIGN_LADDER", True) + builder = binding.Builder._wrap_native_handle( + ctypes.pointer(binding.C2paBuilder())) + signer = binding.Signer._wrap_native_handle( + ctypes.pointer(binding.C2paSigner())) + yield builder, signer, native + builder.close() + signer.close() + + +def manifest_result(native, result=4, error=None): + buffer = (ctypes.c_ubyte * 4)(65, 0, 66, 255) + + def sign(*args): + output = ctypes.cast( + args[-1], ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))) + output[0] = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + if error: + raise error + return result + + native.c2pa_builder_sign_ladder.side_effect = sign + return buffer + + +def assert_closed(builder, signer, native): + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + assert builder._handle is None + signer._ensure_valid_state() + native.c2pa_free.assert_called_once() + builder.close() + native.c2pa_free.assert_called_once() + with pytest.raises(binding.C2paError, match="closed"): + builder.sign_ladder(signer, ["in.mp4"], ["out.mp4"]) + native.c2pa_builder_sign_ladder.assert_called_once() + + +def test_order_utf8_lifetimes_and_binary_copy(ladder): + builder, signer, native = ladder + manifest_result(native) + sign = native.c2pa_builder_sign_ladder.side_effect + builder_handle = builder._handle + signer_handle = signer._handle + + def inspect(*args): + gc.collect() + assert args[0] is builder_handle + assert args[1] is signer_handle + assert list(args[2]) == [b"z.mp4", "\u00e9.mp4".encode()] + assert list(args[3]) == [b"out-z.mp4", b"out-e.mp4"] + assert args[4] == 2 + return sign(*args) + + native.c2pa_builder_sign_ladder.side_effect = inspect + assert builder.sign_ladder( + signer, [Path("z.mp4"), "\u00e9.mp4"], + ["out-z.mp4", Path("out-e.mp4")]) == b"A\0B\xff" + native.c2pa_manifest_bytes_free.assert_called_once() + assert_closed(builder, signer, native) + + +@pytest.mark.parametrize("sources,dests,error", [ + ([], [], binding.C2paError), + (["a"] * 257, ["b"] * 257, binding.C2paError), + (["a"], [], binding.C2paError), + ("a", ["b"], binding.C2paError), + (["a"], "b", binding.C2paError), + (["a\0hidden"], ["b"], binding.C2paError.Encoding), + (["a"], [Path("b\0hidden")], binding.C2paError.Encoding), + (["\ud800"], ["b"], binding.C2paError.Encoding), + (["a"], ["\udfff"], binding.C2paError.Encoding), + ([b"a"], ["b"], binding.C2paError.Encoding), + ([object()], ["b"], binding.C2paError.Encoding), +]) +def test_path_preflight_preserves_builder(ladder, sources, dests, error): + builder, signer, native = ladder + with pytest.raises(error): + builder.sign_ladder(signer, sources, dests) + native.c2pa_builder_sign_ladder.assert_not_called() + native.c2pa_free.assert_not_called() + builder._ensure_valid_state() + manifest_result(native) + assert builder.sign_ladder(signer, ["a"], ["b"]) == b"A\0B\xff" + + +@pytest.mark.parametrize("kind", ["none", "object", "duck", "closed", "uninitialized"]) +def test_requires_active_explicit_signer(ladder, kind): + builder, signer, native = ladder + builder._has_context_signer = True + invalid = {"none": None, "object": object(), + "duck": SimpleNamespace(_handle=signer._handle)} + if kind == "closed": + signer.close() + invalid[kind] = signer + if kind == "uninitialized": + invalid[kind] = binding.Signer.__new__(binding.Signer) + binding.ManagedResource.__init__(invalid[kind]) + with pytest.raises(binding.C2paError): + builder.sign_ladder(invalid[kind], ["a"], ["b"]) + native.c2pa_builder_sign_ladder.assert_not_called() + builder._ensure_valid_state() + + +def test_capability_preflight_preserves_builder(ladder, monkeypatch): + builder, signer, native = ladder + monkeypatch.setattr(binding, "_HAS_SIGN_LADDER", False) + with pytest.raises(binding.C2paError.NotSupported, + match="c2pa_builder_sign_ladder"): + builder.sign_ladder(signer, ["a"], ["b"]) + native.c2pa_builder_sign_ladder.assert_not_called() + native.c2pa_free.assert_not_called() + builder._ensure_valid_state() + + +@pytest.mark.parametrize("allocated", [False, True]) +def test_native_typed_error_and_cleanup(ladder, monkeypatch, allocated): + builder, signer, native = ladder + monkeypatch.setattr(binding, "_read_native_error", + lambda: "Io: cannot write destination") + if allocated: + manifest_result(native, result=-1) + else: + native.c2pa_builder_sign_ladder.return_value = -1 + with pytest.raises(binding.C2paError.Io, match="cannot write"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert native.c2pa_manifest_bytes_free.call_count == int(allocated) + assert_closed(builder, signer, native) + + +@pytest.mark.parametrize("allocated", [False, True]) +def test_call_exception_and_cleanup(ladder, allocated): + builder, signer, native = ladder + error = ctypes.ArgumentError("call failed") + if allocated: + manifest_result(native, error=error) + else: + native.c2pa_builder_sign_ladder.side_effect = error + with pytest.raises(binding.C2paError, match="call failed") as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value.__cause__ is error + assert native.c2pa_manifest_bytes_free.call_count == int(allocated) + assert_closed(builder, signer, native) + + +def test_copy_error_is_not_success(ladder, monkeypatch): + builder, signer, native = ladder + manifest_result(native) + error = MemoryError("copy failed") + monkeypatch.setattr(binding.ctypes, "string_at", Mock(side_effect=error)) + with pytest.raises(binding.C2paError, match="copy failed") as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value.__cause__ is error + native.c2pa_manifest_bytes_free.assert_called_once() + assert_closed(builder, signer, native) + + +@pytest.mark.parametrize("result,allocated", [(0, False), (0, True), (4, False)]) +def test_missing_manifest_is_not_success(ladder, result, allocated): + builder, signer, native = ladder + if allocated: + manifest_result(native, result=result) + else: + native.c2pa_builder_sign_ladder.return_value = result + with pytest.raises(binding.C2paError, match="no manifest bytes"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert native.c2pa_manifest_bytes_free.call_count == int(allocated) + assert_closed(builder, signer, native) + + +def test_free_error_still_closes_builder(ladder, caplog): + builder, signer, native = ladder + manifest_result(native) + native.c2pa_manifest_bytes_free.side_effect = RuntimeError("free failed") + assert builder.sign_ladder(signer, ["a"], ["b"]) == b"A\0B\xff" + assert "Failed to release native manifest bytes memory" in caplog.text + assert_closed(builder, signer, native) From 6e54a5e8fe317f38d088064bf1c704f7a2542045 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Fri, 25 Sep 2026 06:37:44 +0200 Subject: [PATCH 14/32] test: qualify ladder ownership in CI and native verification lanes Use c2pa_free for new manifest allocations, include focused ladder tests in existing CI runs, reject optimized verification harness execution, and document the modern builder lifecycle. --- .github/workflows/build.yml | 10 +-- .../workflows/test-c2pa-rs-source-build.yml | 4 +- README.md | 1 + docs/class-diagram.md | 7 +- docs/ladder-signing.md | 3 + docs/native-resources-management.md | 6 ++ src/c2pa/c2pa.py | 2 +- tests/ladder_native.py | 5 ++ tests/test_sign_ladder.py | 90 +++++++++++++------ 9 files changed, 92 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e970c93a..4012a3b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,7 +116,7 @@ jobs: python3 -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python3 ./tests/test_unit_tests.py + run: python3 -m pytest tests/test_unit_tests.py tests/test_sign_ladder.py tests-windows: name: Unit tests for developer setup (Windows) @@ -202,7 +202,7 @@ jobs: python -c "from c2pa import C2paError; print('C2paError imported successfully')" - name: Run tests - run: python .\tests\test_unit_tests.py + run: python -m pytest .\tests\test_unit_tests.py .\tests\test_sign_ladder.py build-linux-wheel: name: Build Linux wheel @@ -287,7 +287,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/test_unit_tests.py tests/test_sign_ladder.py -v build-windows-wheel: name: Build Windows wheel @@ -379,7 +379,7 @@ jobs: - name: Run tests with pytest (venv) run: | .\venv\Scripts\activate - .\venv\Scripts\pytest .\tests\test_unit_tests.py -v + .\venv\Scripts\pytest .\tests\test_unit_tests.py .\tests\test_sign_ladder.py -v build-macos-wheel: name: Build macOS wheels @@ -468,7 +468,7 @@ jobs: - name: Run tests with pytest (venv) run: | source venv/bin/activate - venv/bin/pytest tests/test_unit_tests.py -v + venv/bin/pytest tests/test_unit_tests.py tests/test_sign_ladder.py -v sdist: runs-on: ubuntu-latest diff --git a/.github/workflows/test-c2pa-rs-source-build.yml b/.github/workflows/test-c2pa-rs-source-build.yml index a6c9ab2d..455c7b3c 100644 --- a/.github/workflows/test-c2pa-rs-source-build.yml +++ b/.github/workflows/test-c2pa-rs-source-build.yml @@ -123,7 +123,7 @@ jobs: working-directory: c2pa-python env: C2PA_PREFLIGHT_RUN: "1" - run: python ./tests/test_unit_tests.py + run: python -m pytest tests/test_unit_tests.py tests/test_sign_ladder.py tests-windows: name: Unit tests (Windows, ${{ matrix.runs-on }}) @@ -205,4 +205,4 @@ jobs: working-directory: c2pa-python env: C2PA_PREFLIGHT_RUN: "1" - run: python .\tests\test_unit_tests.py + run: python -m pytest .\tests\test_unit_tests.py .\tests\test_sign_ladder.py diff --git a/README.md b/README.md index 043cac50..334cb88d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ If you want to view the documentation in GitHub, see: - [Supported formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md) - [Configuring the SDK using `Context` and `Settings`](docs/context-settings.md) - [Using Builder intents](docs/intents.md) to ensure spec-compliant manifests +- [Signing single-file fragmented MP4 ladders](docs/ladder-signing.md) - Using [working stores and archives](docs/working-stores.md) - Selectively constructing manifests by [filtering actions and ingredients](docs/selective-manifests.md) - [Diagram of public classes in the Python library and their relationships](docs/class-diagram.md) diff --git a/docs/class-diagram.md b/docs/class-diagram.md index 33cfd308..3546bbf2 100644 --- a/docs/class-diagram.md +++ b/docs/class-diagram.md @@ -59,6 +59,7 @@ classDiagram +sign(signer, format, source, dest) bytes +sign(format, source, dest) bytes +sign_file(source_path, dest_path, signer) bytes + +sign_ladder(signer, sources, dests) bytes +close() } @@ -131,4 +132,8 @@ classDiagram C2paBuilderIntent --> Builder : set_intent C2paDigitalSourceType --> Builder : set_intent C2paError --> C2paError_Subtypes : subclasses -``` \ No newline at end of file +``` + +[`Builder.sign_ladder`](ladder-signing.md) requires an explicit signer and a native +library with ladder support. An attempted native call closes the builder, not the signer; +preflight errors leave the builder usable. diff --git a/docs/ladder-signing.md b/docs/ladder-signing.md index 7c9424d0..e60c1475 100644 --- a/docs/ladder-signing.md +++ b/docs/ladder-signing.md @@ -44,6 +44,9 @@ It checks the exact loaded path and SHA-256 and reports the native SDK version. It uses the existing `C2PA_LIBRARY_NAME` loader seam, not a new loader override. It does not replace an installed package's library. +Run the harness without `-O`, `-OO`, or `PYTHONOPTIMIZE`: optimized Python is +rejected because it would disable the verification assertions. + ```sh .venv/bin/python tests/ladder_native.py --lane stock \ --library /absolute/path/to/stock/libc2pa_c.so diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 1cf057f0..aa6ef9c6 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -317,6 +317,12 @@ While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repea The native sign call borrows the builder's pointer rather than taking ownership of it, so `Builder` never marks it consumed and the pointer is freed normally through `c2pa_free`. The close enforces single use; it is not a memory-management requirement. +[`sign_ladder()`](ladder-signing.md) follows the same single-use rule after an +attempted native call, while preflight errors (including unavailable capability) +leave the builder usable. The explicit signer is borrowed and remains usable. +Any returned manifest buffer is freed through `ManagedResource._free_native_ptr` +(`c2pa_free`) even when signing or copying fails, without masking the original error. + ## Ownership transfer Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 43997f01..85303441 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -4004,7 +4004,7 @@ def sign_ladder( finally: if manifest_bytes_ptr: try: - _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + ManagedResource._free_native_ptr(manifest_bytes_ptr) except Exception: logger.error("Failed to release native manifest bytes memory") # Native code borrows both handles. Free our builder, not the signer. diff --git a/tests/ladder_native.py b/tests/ladder_native.py index 5cf365e0..9210dea5 100644 --- a/tests/ladder_native.py +++ b/tests/ladder_native.py @@ -177,6 +177,10 @@ def fail_callback(data): def main(): + if sys.flags.optimize: + raise SystemExit( + "Native ladder verification requires assertions; " + "rerun without -O/-OO or PYTHONOPTIMIZE.") parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--library", type=Path, required=True) parser.add_argument("--lane", choices=("stock", "candidate"), required=True) @@ -197,6 +201,7 @@ def main(): staged = package / args.library.name shutil.copy2(args.library, staged) env = os.environ.copy() + env.pop("PYTHONOPTIMIZE", None) env["PYTHONPATH"] = str(root) # Existing loader seam, with an absolute filename and a checked result. env["C2PA_LIBRARY_NAME"] = str(staged) diff --git a/tests/test_sign_ladder.py b/tests/test_sign_ladder.py index a4064ad0..244d9a35 100644 --- a/tests/test_sign_ladder.py +++ b/tests/test_sign_ladder.py @@ -2,7 +2,10 @@ import ctypes import gc +import os from pathlib import Path +import subprocess +import sys from types import SimpleNamespace from unittest.mock import Mock @@ -15,7 +18,6 @@ def ladder(monkeypatch): native = SimpleNamespace( c2pa_builder_sign_ladder=Mock(), - c2pa_manifest_bytes_free=Mock(), c2pa_free=Mock(return_value=0), ) monkeypatch.setattr(binding, "_lib", native) @@ -44,21 +46,26 @@ def sign(*args): return buffer -def assert_closed(builder, signer, native): +def assert_closed(builder, signer, native, manifest=None): assert builder._lifecycle_state == binding.LifecycleState.CLOSED assert builder._handle is None signer._ensure_valid_state() - native.c2pa_free.assert_called_once() + builder_handle = native.c2pa_builder_sign_ladder.call_args.args[0] + expected = [ctypes.addressof(manifest)] if manifest is not None else [] + expected.append(ctypes.addressof(builder_handle.contents)) + calls = native.c2pa_free.call_args_list[:] + assert [ctypes.addressof(call.args[0].contents) for call in calls] == expected builder.close() - native.c2pa_free.assert_called_once() + assert native.c2pa_free.call_args_list == calls with pytest.raises(binding.C2paError, match="closed"): builder.sign_ladder(signer, ["in.mp4"], ["out.mp4"]) native.c2pa_builder_sign_ladder.assert_called_once() + assert native.c2pa_free.call_args_list == calls def test_order_utf8_lifetimes_and_binary_copy(ladder): builder, signer, native = ladder - manifest_result(native) + manifest = manifest_result(native) sign = native.c2pa_builder_sign_ladder.side_effect builder_handle = builder._handle signer_handle = signer._handle @@ -76,8 +83,7 @@ def inspect(*args): assert builder.sign_ladder( signer, [Path("z.mp4"), "\u00e9.mp4"], ["out-z.mp4", Path("out-e.mp4")]) == b"A\0B\xff" - native.c2pa_manifest_bytes_free.assert_called_once() - assert_closed(builder, signer, native) + assert_closed(builder, signer, native, manifest) @pytest.mark.parametrize("sources,dests,error", [ @@ -116,6 +122,7 @@ def test_requires_active_explicit_signer(ladder, kind): if kind == "uninitialized": invalid[kind] = binding.Signer.__new__(binding.Signer) binding.ManagedResource.__init__(invalid[kind]) + invalid[kind]._init_attrs() with pytest.raises(binding.C2paError): builder.sign_ladder(invalid[kind], ["a"], ["b"]) native.c2pa_builder_sign_ladder.assert_not_called() @@ -138,60 +145,89 @@ def test_native_typed_error_and_cleanup(ladder, monkeypatch, allocated): builder, signer, native = ladder monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: cannot write destination") + manifest = None if allocated: - manifest_result(native, result=-1) + manifest = manifest_result(native, result=-1) else: native.c2pa_builder_sign_ladder.return_value = -1 with pytest.raises(binding.C2paError.Io, match="cannot write"): builder.sign_ladder(signer, ["a"], ["b"]) - assert native.c2pa_manifest_bytes_free.call_count == int(allocated) - assert_closed(builder, signer, native) + assert_closed(builder, signer, native, manifest) @pytest.mark.parametrize("allocated", [False, True]) def test_call_exception_and_cleanup(ladder, allocated): builder, signer, native = ladder error = ctypes.ArgumentError("call failed") + manifest = None if allocated: - manifest_result(native, error=error) + manifest = manifest_result(native, error=error) else: native.c2pa_builder_sign_ladder.side_effect = error with pytest.raises(binding.C2paError, match="call failed") as caught: builder.sign_ladder(signer, ["a"], ["b"]) assert caught.value.__cause__ is error - assert native.c2pa_manifest_bytes_free.call_count == int(allocated) - assert_closed(builder, signer, native) + assert_closed(builder, signer, native, manifest) def test_copy_error_is_not_success(ladder, monkeypatch): builder, signer, native = ladder - manifest_result(native) + manifest = manifest_result(native) error = MemoryError("copy failed") - monkeypatch.setattr(binding.ctypes, "string_at", Mock(side_effect=error)) - with pytest.raises(binding.C2paError, match="copy failed") as caught: - builder.sign_ladder(signer, ["a"], ["b"]) + # ctypes is shared process-wide; limit the patch to this mocked call. + with monkeypatch.context() as patch: + patch.setattr(binding.ctypes, "string_at", Mock(side_effect=error)) + with pytest.raises(binding.C2paError, match="copy failed") as caught: + builder.sign_ladder(signer, ["a"], ["b"]) assert caught.value.__cause__ is error - native.c2pa_manifest_bytes_free.assert_called_once() - assert_closed(builder, signer, native) + assert_closed(builder, signer, native, manifest) @pytest.mark.parametrize("result,allocated", [(0, False), (0, True), (4, False)]) def test_missing_manifest_is_not_success(ladder, result, allocated): builder, signer, native = ladder + manifest = None if allocated: - manifest_result(native, result=result) + manifest = manifest_result(native, result=result) else: native.c2pa_builder_sign_ladder.return_value = result with pytest.raises(binding.C2paError, match="no manifest bytes"): builder.sign_ladder(signer, ["a"], ["b"]) - assert native.c2pa_manifest_bytes_free.call_count == int(allocated) - assert_closed(builder, signer, native) + assert_closed(builder, signer, native, manifest) -def test_free_error_still_closes_builder(ladder, caplog): +@pytest.mark.parametrize("result", [4, -1]) +def test_free_error_still_closes_builder(ladder, caplog, monkeypatch, result): builder, signer, native = ladder - manifest_result(native) - native.c2pa_manifest_bytes_free.side_effect = RuntimeError("free failed") - assert builder.sign_ladder(signer, ["a"], ["b"]) == b"A\0B\xff" + manifest = manifest_result(native, result=result) + + def free(pointer): + if ctypes.addressof(pointer.contents) == ctypes.addressof(manifest): + raise RuntimeError("free failed") + return 0 + + native.c2pa_free.side_effect = free + if result < 0: + monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: sign failed") + with pytest.raises(binding.C2paError.Io, match="sign failed"): + builder.sign_ladder(signer, ["a"], ["b"]) + else: + assert builder.sign_ladder(signer, ["a"], ["b"]) == b"A\0B\xff" assert "Failed to release native manifest bytes memory" in caplog.text - assert_closed(builder, signer, native) + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("mode", ["-O", "-OO", "PYTHONOPTIMIZE"]) +def test_native_harness_refuses_optimized_python(mode): + env = os.environ.copy() + env.pop("PYTHONOPTIMIZE", None) + command = [sys.executable] + if mode == "PYTHONOPTIMIZE": + env[mode] = "1" + else: + command.append(mode) + command.append(str(Path(__file__).with_name("ladder_native.py"))) + result = subprocess.run(command, env=env, capture_output=True, text=True) + assert result.returncode != 0 + assert "requires assertions" in result.stderr + assert "rerun without -O/-OO or PYTHONOPTIMIZE" in result.stderr From 780e78f1c42e5107a3f1ff190990e71e42abaf8d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Mon, 28 Sep 2026 07:48:54 +0200 Subject: [PATCH 15/32] feat: bind functional trusted VSI sessions --- .github/workflows/trusted-vsi-paired.yml | 42 +- MANIFEST.in | 3 + docs/context-settings.md | 104 +- docs/release-notes.md | 36 +- docs/trusted-vsi-python-contract.md | 201 ++++ docs/upstream-integration-baseline.md | 131 +++ docs/usage.md | 61 +- requirements-dev.txt | 2 + scripts/build_trusted_vsi_functional.py | 120 ++ scripts/qualify_trusted_vsi_functional.py | 38 + src/c2pa/__init__.py | 16 +- src/c2pa/c2pa.py | 666 +++++++---- tests/test_castlabs_release_tooling.py | 5 + tests/test_trusted_vsi_api.py | 1285 +++++++++++++++++---- tests/test_trusted_vsi_build.py | 71 ++ tests/test_unit_tests.py | 157 ++- tests/trust_config_test_settings.json | 13 +- 17 files changed, 2377 insertions(+), 574 deletions(-) create mode 100644 docs/trusted-vsi-python-contract.md create mode 100644 docs/upstream-integration-baseline.md create mode 100644 scripts/build_trusted_vsi_functional.py create mode 100644 scripts/qualify_trusted_vsi_functional.py create mode 100644 tests/test_trusted_vsi_build.py diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index 553d3372..1e0fa3e3 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -1,4 +1,4 @@ -name: Trusted VSI paired-source API qualification (non-publishing) +name: Trusted VSI functional source and wheel qualification (non-publishing) on: workflow_call: @@ -31,6 +31,8 @@ jobs: C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/${{ matrix.library }} C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev C2PA_TRUSTED_VSI_ABI_REQUIRED: "1" + C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1" + FUNCTIONAL_BUILD_VERSION: 0.37.9.dev0 steps: - name: Require a full Python source SHA shell: bash @@ -44,32 +46,54 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - # Reviewed reduced scaffold, NOT the immutable dev5 release input. - ref: cee86aae03887b5a0dddcd765a39e96360963bb0 + # Reviewed functional trusted VSI native (feat/trusted-vsi-functional). + # Qualification-only pairing; never the immutable dev5 release input. + ref: 1d605b5a2033d5812742e302db3e5f9af347f68d path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.10" + python-version: "3.12" - name: Record paired source identities shell: bash run: | git -C python-source rev-parse HEAD git -C paired-rust rev-parse HEAD - - name: Build isolated paired native scaffold + - name: Build isolated functional native library shell: bash working-directory: paired-rust run: | rustup toolchain install 1.88.0 --profile minimal cargo +1.88.0 build --locked -p c2pa-c-ffi --no-default-features \ --features rust_native_crypto,http,add_thumbnails,file_io,unstable_live_video - - name: Prepare source imports (no wheel or native staging) + - name: Prepare source dependencies shell: bash working-directory: python-source run: | - python -m pip install -r requirements.txt pytest==8.4.1 setuptools==68.0.0 toml==0.10.2 + python -m pip install -r requirements.txt pytest==8.4.1 setuptools==68.0.0 toml==0.10.2 wheel==0.46.2 packaging==26.0 cbor2==5.9.0 python setup.py egg_info - - name: Require reduced trusted ABI and run focused tests without skips + - name: Require functional native ABI and run focused tests without skips shell: bash working-directory: python-source run: python -m pytest -q tests/test_trusted_vsi_api.py -ra - # No artifacts, release metadata, wheels, or publication from this job. + - name: Non-threaded regressions + timeout-minutes: 20 + shell: bash + working-directory: python-source + run: python -m pytest -q --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 + - name: Threaded regressions + timeout-minutes: 15 + shell: bash + working-directory: python-source + run: python -m pytest -q tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 + - name: Build separate development wheel and sdist (no publication) + shell: bash + working-directory: python-source + run: python scripts/build_trusted_vsi_functional.py --library "$C2PA_LIBRARY_NAME" --out build/functional-qualification + - name: Qualify installed functional wheel without source or library overrides + shell: bash + working-directory: python-source + run: | + python scripts/qualify_trusted_vsi_functional.py \ + --wheel build/functional-qualification/*.whl \ + --venv build/functional-installed --version "$FUNCTIONAL_BUILD_VERSION" + # No upload, immutable release evidence changes, credentials, or publication. diff --git a/MANIFEST.in b/MANIFEST.in index c09288e4..b47df2b2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,5 +4,8 @@ include README.md include requirements.txt include scripts/download_artifacts.py include scripts/castlabs_release.py +include scripts/build_trusted_vsi_functional.py +include scripts/qualify_trusted_vsi_functional.py +include docs/trusted-vsi-python-contract.md recursive-include release *.json recursive-include src/c2pa *.py diff --git a/docs/context-settings.md b/docs/context-settings.md index 35692a82..8b095386 100644 --- a/docs/context-settings.md +++ b/docs/context-settings.md @@ -160,9 +160,16 @@ Create and configure settings independently of a `Context`: | `Settings.from_json(json_str)` | Create settings from a JSON string. Raises `C2paError` on parse error. | | `Settings.from_dict(config)` | Create settings from a Python dictionary. | | `set(path, value)` | Set a single value by dot-separated path (for example, `"verify.verify_after_sign"`). Value must be a string. Returns `self` for chaining. | -| `update(data)` | Merge configuration into existing settings. `data` can be a JSON string or a dict. Later keys override earlier ones. | +| `update(data)` | Merge configuration into existing settings. `data` can be a JSON string or a dict. Later scalar values override earlier ones; `trust.anchors` entries accumulate and deduplicate. | -The `set()` and `update()` methods can be chained for incremental configuration. When using multiple configuration methods, later calls override earlier ones (last call wins when the same setting is set multiple times). +The `set()` and `update()` methods can be chained for incremental configuration. +Scalar properties use the last value set. With native SDK 0.91, `update()` merges +`trust.anchors` by complete entry equality, not by `trust_uri`: updating an entry +with different certificates or policy does not remove the old entry. An empty +anchor list does not clear existing trust. For trust removal or replacement, +construct fresh `Settings` from the complete intended configuration and create +a new `Context`. Existing contexts, readers, and builders retain their copied +configuration; do not reuse them when applying a reduced trust policy. ```py from c2pa import Settings @@ -263,7 +270,6 @@ The Settings JSON has this top-level structure: { "version": 1, "trust": { ... }, - "cawg_trust": { ... }, "core": { ... }, "verify": { ... }, "builder": { ... }, @@ -276,7 +282,7 @@ The settings format is **JSON** only. Pass JSON strings to `Settings.from_json() > [!NOTE] > - All properties are optional. If you don't specify a value, the SDK uses the default value. -> - If you specify a value of `null` (or `None` in a dict), the property is explicitly set to `null`, not the default. This distinction is important when you want to override a default behavior. +> - `null` (or `None` in a dict) is accepted only for nullable properties. Do not use it as a general reset operation; use fresh settings when removing trust. > - For Boolean values, use JSON Booleans `true`/`false` in JSON strings, or Python `True`/`False` in dicts. The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, and so on). For a complete reference to all properties, see the [SDK object reference - Settings](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema). @@ -285,11 +291,10 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an |----------|-------------| | `version` | Settings format version (integer). The default and only supported value is 1. | | [`builder`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#buildersettings) | Configuration for Builder. | -| [`cawg_trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for CAWG trust lists. | | [`cawg_x509_signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the CAWG x.509 signer. | | [`core`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#core) | Configuration for core features. | | [`signer`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#signersettings) | Configuration for the base C2PA signer. | -| [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Configuration for C2PA trust lists. | +| [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#trust) | Purpose-tagged manifest, CAWG, and TSA trust lists. | | [`verify`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema#verify) | Configuration for verification (validation). | ### Default configuration @@ -330,13 +335,6 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an "quality": "medium" } }, - "cawg_trust": { - "verify_trust_list": true, - "user_anchors": null, - "trust_anchors": null, - "trust_config": null, - "allowed_list": null - }, "cawg_x509_signer": null, "core": { "merkle_tree_chunk_size_in_kb": null, @@ -347,10 +345,8 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an }, "signer": null, "trust": { - "user_anchors": null, - "trust_anchors": null, - "trust_config": null, - "allowed_list": null + "anchors": null, + "trust_config": null }, "verify": { "verify_after_reading": true, @@ -367,41 +363,77 @@ The settings JSON schema is shared across all C2PA SDKs (Rust, C/C++, Python, an ### Trust -The [`trust` properties](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust) control which certificates are trusted when validating C2PA manifests. +The following schema applies to the upstream-integrated native SDK 0.91 source +baseline. It does not describe or change the immutable dev5 native artifacts. +Use `trust.anchors` for all certificate trust purposes; the old `cawg_trust` +section and top-level `trust.allowed_list` are not the typed configuration. | Property | Type | Description | |----------|------|-------------| -| `trust.user_anchors` | string | Additional user-provided root certificates (PEM format). Adds custom certificate authorities without replacing the SDK's built-in trust anchors. Recommended for development. | -| `trust.trust_anchors` | string | Default trust anchor root certificates (PEM format). **Replaces** the SDK's built-in trust anchors entirely. | -| `trust.trust_config` | string | Allowed Extended Key Usage (EKU) OIDs. Controls which certificate purposes are accepted (for example, `1.3.6.1.4.1.311.76.59.1.9` for document signing). | -| `trust.allowed_list` | string | Explicitly allowed certificates (PEM format). Trusted regardless of chain validation. Use for development/testing to bypass chain validation. | +| `trust.anchors` | array | Purpose-tagged trust entries. No certificate anchors are configured by default in the production library. | +| `trust.trust_config` | string or null | Global allowed Extended Key Usage (EKU) OIDs, newline-separated. Preserve your existing policy during migration. | +| `trust.anchors[].trust_kind` | string | Required purpose: `"manifest"`, `"cawg"`, or `"tsa"` (lowercase). | +| `trust.anchors[].trust_anchors` | string | Required PEM certificate bundle; may be empty for an entry that only configures other trust policy. | +| `trust.anchors[].trust_uri` | string or null | Optional trust-list identifier. Not a replacement key for updates. | +| `trust.anchors[].trust_config` | string or null | Per-entry EKU policy; overlays the global policy for manifest trust and overrides it for CAWG trust. | +| `trust.anchors[].allowed_list` | string or null | Explicitly allowed certificates. Preserve only existing, intentional allow-list membership; do not use this to repair trust failures. | +| `trust.anchors[].trusted_ica_issuers` | array of strings or null | Explicit trusted ICA issuer DIDs for CAWG entries. Empty by default; a valid self-signature alone does not establish issuer trust. | -Use `user_anchors` to add your test root CA without replacing the SDK's default trust store: +Trust your test root CA for manifest signatures: ```py with open("test-ca.pem", "r") as f: test_root_ca = f.read() -ctx = Context.from_dict({"trust": {"user_anchors": test_root_ca}}) +ctx = Context.from_dict({"trust": {"anchors": [{ + "trust_kind": "manifest", + "trust_anchors": test_root_ca, + "trust_uri": "urn:example:test-manifest-roots" +}]}}) reader = Reader("signed_asset.jpg", context=ctx) ``` -Use `allowed_list` to bypass chain validation entirely for quick testing: +Configure each intended purpose explicitly. A publisher identity root should not +implicitly become a claim-signing root or a TSA root: ```py -with open("test_cert.pem", "r") as f: - test_cert = f.read() - -ctx = Context.from_dict({"trust": {"allowed_list": test_cert}}) -reader = Reader("signed_asset.jpg", context=ctx) -``` +with open("publisher-roots.pem", "r") as f: + publisher_roots = f.read() +with open("tsa-roots.pem", "r") as f: + tsa_roots = f.read() + +ctx = Context.from_dict({"trust": {"anchors": [ + {"trust_kind": "manifest", "trust_anchors": test_root_ca}, + {"trust_kind": "cawg", "trust_anchors": publisher_roots}, + {"trust_kind": "tsa", "trust_anchors": tsa_roots} +]}}) +``` + +When migrating legacy `trust.trust_anchors` / `trust.user_anchors`, preserve +the original manifest and timestamp trust memberships and EKUs explicitly. +The native legacy conversion creates manifest entries only. If the old bundle +also authorized TSA chains, retain that membership in a `tsa` entry. Migrate +CAWG roots and allowed certificates to `cawg` entries without widening their +purposes. Do not disable `verify_trust` or `verify_timestamp_trust`, add an +allow-list, or enable revocation fetching merely to make migrated tests pass. + +To remove all explicitly configured trust, use `Settings.from_dict({"trust": +{"anchors": []}})` and construct a new `Context` from it. To remove only some +entries, supply the complete retained list to fresh settings instead. Reapply +the other intended settings too, rather than inheriting old trust accidentally. ### CAWG trust -The `cawg_trust` properties configure CAWG (Creator Assertions Working Group) validation of identity assertions in C2PA manifests. It has the same properties as [`trust`](https://opensource.contentauthenticity.org/docs/manifest/json-ref/settings-schema/#trust). +Entries with `trust_kind: "cawg"` configure CAWG (Creator Assertions Working +Group) identity validation. Certificate trust and explicitly trusted ICA issuer +DIDs are separate policy inputs; include only identities your application +already authorizes. > [!NOTE] -> CAWG trust settings are only used when processing identity assertions with X.509 certificates. If your workflow doesn't use CAWG identity assertions, these settings have no effect. +> Native baseline `e0f980ec` fixes cross-purpose trust leakage found during +> integration. The unchanged Python regressions now reject manifest trust from +> `cawg`-only or `tsa`-only configurations. Earlier 0.91 source snapshots are not +> qualified for this isolation; see [baseline qualification](upstream-integration-baseline.md). ### Core @@ -515,14 +547,14 @@ ctx = Context.from_dict({ ### Development environment with test certificates -During development, you often need to trust self-signed or custom CA certificates with looser verification: +During development, explicitly trust the test CA without disabling verification: ```py with open("test-ca.pem", "r") as f: test_ca = f.read() ctx = Context.from_dict({ - "trust": {"user_anchors": test_ca}, + "trust": {"anchors": [{"trust_kind": "manifest", "trust_anchors": test_ca}]}, "verify": { "verify_after_reading": True, "verify_after_sign": True, @@ -576,7 +608,7 @@ with open("trust-anchors.pem", "r") as f: ctx = Context.from_dict({ "trust": { - "trust_anchors": trust_anchors, + "anchors": [{"trust_kind": "manifest", "trust_anchors": trust_anchors}], "trust_config": "1.3.6.1.5.5.7.3.4\n1.3.6.1.5.5.7.3.36" }, "core": {"backing_store_memory_threshold_in_mb": 1024}, diff --git a/docs/release-notes.md b/docs/release-notes.md index c173edaf..ce7bf54e 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,20 +1,26 @@ # Release notes -## Unreleased: disabled trusted-processor VSI API - -- Replaces the unshipped expert EMSG scaffold with - `TrustedVsiPrehashedSession.sign_sig_structure(sig_structure)` and - `has_live_video_trusted_vsi_expert_sig_structure()`. No old aliases remain. -- Adds frozen `TrustedVsiSignResult`: a 64-byte fixed-format signature, uint32 - sequence number, and optional inclusive uint32 maximum not below that number. -- Mirrors the paired native signature/sequence output ABI. Split-init, - signer-composed EMSG, status, recovery, and V1 callback-context targets remain. -- All trusted capabilities remain false. Import does not invoke the native - trusted capability function; construction and operations reject before - argument inspection, callbacks, native calls, or managed-resource bookkeeping. -- Adds isolated Linux/Windows paired-source API tests. This is not functional - signing, CBOR/COSE validation, or dev5 artifact qualification. The immutable - dev5 release, source pins, version, and publication identity are unchanged. +## Unreleased: functional trusted-processor VSI API + +- Replaces the unshipped counter/result scaffold with + `TrustedVsiSession.sign_sig_structure(sig_structure, sequence_number) + -> bytes`. Expert mode signs supplied sequence metadata without allocating a + sequence or keeping a media journal. `TrustedVsiSignResult` is removed. +- Renames the unshipped `TrustedVsiPrehashedSession` to `TrustedVsiSession` + with the native contract's argument order (`context` first, `callback` after + `validity_period_secs`); `reserve_init_uuid()` returns the UUID box bytes and + `TrustedVsiInitUuidReservation` is removed. No aliases. +- Adds mode-pinned init/composed reserve/finalize, side-effect-free preflight, + canonical input validation/hash templates, and explicit state export/import. + The old unshipped `recover(init_uuid, previous_emsg)` is removed without alias. +- Pins claim/VSI/DA callbacks across context consumption, explicit close and + state import; preserves original callback exceptions and native errors. +- Capability probes require the exact functional symbol set, native version, + and complete capability mask. Older native libraries fail closed. +- Adds non-publishing Linux/Windows source and installed-wheel qualification. + Functional artifacts use a separate staged development version (default + `0.37.9.dev0`). Immutable dev5 release facts, pins and artifact names remain + unchanged. Functional native qualification is required, never an optional skip. ## Version 0.37.8.dev5 diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md new file mode 100644 index 00000000..c4f580aa --- /dev/null +++ b/docs/trusted-vsi-python-contract.md @@ -0,0 +1,201 @@ +# Trusted VSI Python Contract + +Status: implemented and qualified locally (Linux) against the functional native +library built from `castlabs/c2pa-rs@1d605b5a2033d5812742e302db3e5f9af347f68d` +(`feat/trusted-vsi-functional`; debug `libc2pa_c.so` SHA-256 +`6c7ccf4258132df1…`, capability mask 63), following `c2pa-rs` +`docs/trusted-vsi-native-contract.md` (SHA-256 +`74ba08dfdf9aa6256f8ba38cdeed1930e4cbee827a5d67c1bc1f9313a41bf0bf`). Since +`37dc25fe…` the native contract added only the Rust-only `trusted_vsi_compute_hash` +and the version-2 state record; the C ABI and Python API are unchanged. Windows +remains to be qualified in CI. Local qualification-only artifacts built from that +library with `scripts/build_trusted_vsi_functional.py` (never published): +`c2pa_python-0.37.9.dev0-py3-none-linux_x86_64.whl` SHA-256 +`e80e1d78a6de0adb0136063a82aadb1a2d950b6977298aefd4e1ed6a31d85e63`, sdist +`7f471855a22d40c556d2a92b336f11e60c7d28174a994d066a6c055d516114bf`; +installed-wheel qualification 100 passed. This is unreleased API; immutable dev5 release inputs and +artifacts are unchanged. The class is `TrustedVsiSession`, and +`reserve_init_uuid()` returns `bytes` (see below). + +## Availability + +All `has_live_video_trusted_vsi_*()` probes return `True` only when the loaded +library exports every symbol in the contract's C ABI, reports native version +`0.91.0-dev`, and `c2pa_live_video_trusted_vsi_capabilities() == 63`. Missing +symbols, a scaffold/older/partial library, or any other mask disables every +probe. Old scaffold symbol layouts are never bound. When unavailable, the +constructor, `from_callback`, `validate_trusted_vsi_input` and +`trusted_vsi_hash_template` raise `C2paError.NotSupported` before inspecting +arguments, invoking callbacks, touching native code or managed-resource state. + +## Session + +```python +TrustedVsiSession.from_callback( + context, manifest_json, algorithm, public_cose_key, kid, + min_sequence_number, created_at, validity_period_secs, callback, *, + mode, reservation_nonce, signing_time_unix_seconds, sequence_max=None) +``` + +`TrustedVsiSession(...)` takes identical arguments. Argument checks (Python +`TypeError`/`ValueError`, before any native call): + +| Argument | Python type / range | +|---|---| +| `context` | active `Context` created with an explicit claim `Signer` (else `C2paError`) | +| `manifest_json` | `str` or `dict`, nonempty, no NUL | +| `algorithm` | `C2paSigningAlg.ES256`/`ED25519` or `"es256"`/`"ed25519"`/`"eddsa"` | +| `public_cose_key`, `kid` | nonempty `bytes` (public COSE_Key CBOR; private keys rejected natively) | +| `min_sequence_number` | uint32 | +| `created_at` | nonempty RFC 3339 `str` | +| `validity_period_secs` | 1 .. 2**64-1 | +| `callback` | callable `(VsiSigningContextV1, bytes) -> bytes` | +| `mode` | exactly `"expert_sig_structure"` or `"signer_composed_emsg"` | +| `reservation_nonce` | exactly 32 lowercase hex chars (public, coordinator-retained; not a key seed) | +| `signing_time_unix_seconds` | signed int64, pinned init iat | +| `sequence_max` | `None` (= UINT32_MAX) or uint32 >= `min_sequence_number` | + +Keyword options serialize to the native `options_json` +(`mode`, `reservation_nonce`, `signing_time_unix_seconds`, `sequence_max`). +Construction validates configuration natively but never signs. + +Methods (externally serialize calls on one session): + +| Method | Result | +|---|---| +| `reserve_init_uuid(format="video/mp4")` | `bytes`: complete placeholder UUID box; repeat returns the same frozen reservation | +| `reserved_manifest_id()` | `str` | +| `finalize_init_uuid(canonical_bmff_hash: bytes)` | `bytes`: complete signed UUID, same length as reservation; identical-input replay only | +| `commit_init_uuid()` | `None`; durable coordinator activation, NOT a publication ACK | +| `sign_sig_structure(sig_structure: bytes, sequence_number: int)` | `bytes`: exactly 64 raw signature bytes (ES256 P1363 or Ed25519) | +| `reserve_media_emsg_at(sequence_number, signing_time_unix_seconds, timescale, event_duration)` | `TrustedVsiMediaEmsgReservation` | +| `finalize_media_emsg(canonical_bmff_hash: bytes)` | `bytes`: complete signed EMSG, same length as reservation | +| `export_state()` | `bytes`: versioned public JSON record (currently version 2), including pending reservations | +| `import_state(state: bytes)` | `None`; only into a NEW session with identical identity (see State records) | +| `status()` | `TrustedVsiStatus` | +| `preflight(operation, data=b"", *, sequence_number=0, iat=0, timescale=0, event_duration=0, format="video/mp4")` | `None`; no callbacks, key use, reservation or mutation | +| `close()` | idempotent; releases only this session | + +Module functions: `validate_trusted_vsi_input(kind, algorithm, data: bytes) -> None` +and `trusted_vsi_hash_template(kind) -> bytes` (init/media kinds; canonical +zero-digest bmff-hash v3 template). Enums (int values accepted, bools rejected): +`TrustedVsiOperation` RESERVE_INIT=0, FINALIZE_INIT=1, COMMIT_INIT=2, +EXPERT_SIGN=3, RESERVE_MEDIA=4, FINALIZE_MEDIA=5; `TrustedVsiInputKind` +INIT_HASH=0, SIG_STRUCTURE=1, MEDIA_HASH=2. + +### Value types (frozen dataclasses) + +- `VsiSigningContextV1(purpose, sequence_number=None, event_id=None, exhaust_after_sign=False)`. + `purpose` is `"signer_binding"` (no sequence/event, never exhausting) or `"vsi"`. + Expert callbacks: `("vsi", supplied_sequence, None, False)` always, even at UINT32_MAX. +- `TrustedVsiMediaEmsgReservation(placeholder_emsg_box, signing_context, + signing_time_unix_seconds, timescale, event_duration)` with read-only + `sequence_number` / `event_id` properties. `signing_context` is exactly what the + finalize callback will receive (terminal `exhaust_after_sign=True` at the limit). +- `TrustedVsiStatus(init_uuid_committed, init_uuid_pending, media_emsg_pending, + next_sequence_number, next_event_id, exhausted, exhaustion_reason)`; optional + fields are `None` when absent. Expert: counters `None`, `exhausted=False`. + `exhaustion_reason` is `"sequence_max"`, `"event_id_max"` or `"legacy_sentinel"`. + +Removed without aliases: `TrustedVsiPrehashedSession`, `TrustedVsiSignResult`, +`TrustedVsiInitUuidReservation`, `recover(...)`, and the private Python gate. + +## Semantics For The Signer Adapter + +- Expert: the processor supplies the sequence (must equal `moof/mfhd`) and owns + ordering, replay IDs and rollover. Any sequence in `[min, max]` is accepted, + including repeats of older sequences. Native validates canonical framing, + signs the original bytes unchanged, never decodes the payload, keeps no counter. +- Composed: reserve requires `sequence_number == next_sequence_number` + (initially `min`); events start at 1; timing values must be positive. Reserve + signs nothing. Finalize advances counters or exhausts without wrapping. +- The trusted processor hashes final-placement bytes with the reserved box + installed (`trusted_vsi_hash_template` + 32-byte SHA-256 `hash`). Only + `video/mp4` is supported. There is no C/Python hash helper; this is the + BMFF v2+/v3 top-level rule that the native Rust reference `trusted_vsi_compute_hash` + (`BmffHash::gen_hash_from_stream`) implements: + SHA-256 over, for each top-level box in file order except the single excluded + C2PA box (init: `uuid` with the C2PA UUID at offset 8; media: `emsg` with scheme + `urn:c2pa:verifiable-segment-info` at offset 12), the box's big-endian uint64 + file offset followed by its complete bytes. Offsets are those of the FINAL + placement. Native tests place the init UUID directly after `ftyp`, and the media + EMSG at the front of the segment; after a leading `styp` is also valid. The + replacement box has exactly the reserved length, so the hash is unchanged by + finalization. See `_hash_input`/`_place` in `tests/test_trusted_vsi_api.py`. +- Verification: the signed init validates via `Reader("video/mp4", init_stream, + context=...)`. Do NOT use `Reader.from_fragmented_files` / `with_fragment` for + live-video VSI: that Merkle fragmented-BMFF path rejects every section 19.3 init + manifest (`assertion.bmffHash.mismatch`, "Hash value should not be present for a + fragmented BMFF asset"), including output from the shipped complete-buffer + `LiveVideoVsiSession`. Media VSI EMSGs are validated by the Rust-only + `LiveVideoValidator`; no C/Python segment validator exists. Python tests verify + the EMSG independently: version-0 `emsg`, `urn:c2pa:verifiable-segment-info`, + pinned timescale/duration/event ID, tagged COSE_Sign1 with protected + `{1: alg, "iat": iat}`, unprotected `{4: kid}`, payload + `{sequenceNumber, manifestId, bmffHash}`, where `bmffHash` equals the canonical + hash input exactly, plus the signature over `["Signature1", protected, b"", payload]`. +- After a failure once an external signing call began, the session is blocked. + Discard it, construct a NEW session and `import_state()` the durable + PRE-operation record. Operation-ID/same-input retry enforcement belongs to the + coordinator and key provider, not native V1 metadata. + +## State Records + +`export_state()` returns native-owned bytes; Python neither parses nor rewrites +them. Treat them as opaque, persist them atomically and authenticated, and pass +them back byte-for-byte. The native format is +`{"format": "c2pa.trusted-vsi.state", "version": 2, "identity", "state"}`. +Version 1 (unreleased) is rejected; there is no migration. + +`import_state()` succeeds only on a NEW session whose identity matches exactly: +mode, VSI session config/public key/kid, constructor options (including the +reservation nonce and init iat), manifest, claim-signer certificate, claim-signer +**reserve size**, and the ordered DynamicAssertion declarations (label and +reserve size of each, in registration order). Mismatches raise `C2paError` +("state record identity does not match ...") from `import_state` itself, before +any mutation and without invoking the VSI, claim-signer or DynamicAssertion +callbacks; the session remains New and usable. + +Adapter consequence: the claim-signer reserve size depends on how the `Signer` +is built, not only on its certificate. For example, with the ES256 fixture +certificate `Signer.from_info` reserves 2361 bytes and `Signer.from_callback` +11836. The process that imports a record must build its Context signer the same +way (same factory, certificate, TSA setting) and register the same +DynamicAssertions in the same order as the process that exported it. + +## Ownership And Errors + +The caller owns its `Context`; native retains it (Arc). The session separately +pins the Python claim-signer callback, DynamicAssertion callbacks and the VSI +callback, so they survive signer consumption into the Context and caller +`Context.close()`. Returned native byte buffers are initialized to NULL, copied, +and freed exactly once with `c2pa_free`; the manifest-ID string uses +`c2pa_string_free`. Input buffers are borrowed. + +Errors: Python argument problems raise `TypeError`/`ValueError`. Callback results +that are not exactly 64 `bytes` raise `TypeError`/`ValueError`. Any exception +raised by the VSI callback, claim-signer callback or a DynamicAssertion callback +is re-raised with its identity intact. Native validation/state failures raise +typed `C2paError` subclasses from the native error text. Python never rewrites +CBOR, BMFF or validation results. + +## Qualification Identity + +`scripts/build_trusted_vsi_functional.py --library --out +` first requires all probes true against that library, then builds a +NON-PUBLISHING wheel and sdist in a temporary staging copy with +`FUNCTIONAL_BUILD_VERSION` (default `0.37.9.dev0`; rejects `0.37.8.*`, dev5 and +release versions). The checkout's `0.37.8.dev5` metadata is not modified. +`C2PA_SOURCE_BUILD_VERSION=0.91.0-dev` identifies the native library only. + +`scripts/qualify_trusted_vsi_functional.py --wheel --venv +--version 0.37.9.dev0` installs the wheel into an isolated venv, strips +`PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs this test file with +`C2PA_TRUSTED_VSI_ABI_REQUIRED=1`; the paired fixture asserts the imported package +and native library come from that venv with the expected version. + +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` +checks out the reviewed native commit `1d605b5a2033d5812742e302db3e5f9af347f68d` +by full SHA; update that pin (not a branch name) for later native revisions. diff --git a/docs/upstream-integration-baseline.md b/docs/upstream-integration-baseline.md new file mode 100644 index 00000000..896413c2 --- /dev/null +++ b/docs/upstream-integration-baseline.md @@ -0,0 +1,131 @@ +# Upstream Integration Baseline + +Baseline-only Python integration, tested 2026-09-10. Functional trusted-VSI +bindings are deliberately not implemented or enabled. This is not release +qualification and does not change immutable dev5 source/version/artifact facts. + +Subsequent checkpoint: native `e0f980ec` fixes the purpose-isolation blocker +recorded below. The unchanged `TestSettings` plus scaffold ABI checks passed +56 tests and 6 subtests in 1.15s on that baseline. The historical results below +remain unchanged; ongoing functional Python work is documented separately in +`trusted-vsi-python-contract.md` and does not use the disabled scaffold contract. + +## Scope And Pairing + +- Python worktree: `/root/opencode-worktrees/c2pa-python-trusted-vsi-functional`. +- Python starting commit: `0d48e6a09b9d627dcf6385e3e591be8086e9af1f`. +- Native library: `/root/opencode-worktrees/c2pa-rs-trusted-vsi-functional/target/debug/libc2pa_c.so`. +- Native reported version: `0.91.0-dev`, integrating ContentAuth `312491af0e3e9fb5b3ba604d86ef44194ab580d9`. +- Native worktree HEAD at inspection: `cee86aae03887b5a0dddcd765a39e96360963bb0`; + integrated native changes were not yet committed. A later native review/rebuild + must be qualified again; these results do not certify that future binary. +- Tested library SHA-256: `10e6bf5d17d707f7e25d11eba69bce23318d12679a2c7b39046b8d5cdd43eaea`. +- Tested library size: 280930096 bytes; mtime `2026-09-10 13:32:02.274382637 +0200`. + +Every native-backed command used: + +```sh +env PYTHONPATH=/root/opencode-worktrees/c2pa-python-trusted-vsi-functional/src \ + C2PA_LIBRARY_NAME=/root/opencode-worktrees/c2pa-rs-trusted-vsi-functional/target/debug/libc2pa_c.so \ + C2PA_SOURCE_BUILD_VERSION=0.91.0-dev \ + C2PA_TRUSTED_VSI_ABI_REQUIRED=1 \ + python3 -m pytest ... +``` + +No native builds, global pip changes, wheel/sdist builds, publication, commits, +or pushes were performed. `python3 setup.py egg_info` generated ignored metadata +under `src/c2pa_python.egg-info` with the existing `0.37.8.dev5` Python version +solely to run the opt-in smoke tests from source. Otherwise installed metadata +reported unrelated version `0.31.0`. No new native binary was copied or packaged +under a dev5 artifact name. + +## Changes + +Exact edited files: + +- `src/c2pa/c2pa.py`: `Settings.update` documents native additive trust merging + and fresh-settings/context removal semantics; no new FFI bindings or runtime + trust rewriting. +- `docs/context-settings.md`: typed manifest/CAWG/TSA configuration, explicit + memberships, additive updates, removal semantics, and qualification warning. +- `tests/trust_config_test_settings.json`: original bundle retained byte-for-byte + in manifest and TSA entries; original global EKU policy retained. No new roots, + CAWG membership, allowed-list, revocation fetch, or verification bypass. +- `tests/test_unit_tests.py`: fixture membership digests, additive/removal and + purpose-isolation regressions; correct four ingredient MIME arguments and add + an explicit mismatch-rejection regression. +- `docs/upstream-integration-baseline.md`: this qualification record. + +The nine-certificate legacy bundle hashes to +`f3de5e4ea3213319eedc5e3890f0ff615bf0e754323ffd20dcca8a3f1c5ab921`. +The unchanged EKU text hashes to +`174983a609d76784c4ef5e2621740bf32fb615f88412d94f4fc26670365a9b81`. + +## Compatibility Findings + +1. Legacy thread-local `load_settings` with `trust.trust_anchors` returned `Valid` + instead of the original expected `Trusted` for `C.jpg`. Explicit `Context` + still converted the legacy field successfully. Typed entries fix the legacy + test path while keeping all original trust assertions intact. TSA membership + is explicit so new timestamps retain the original bundle authorization. +2. Native ingredient parsing now rejects JPEG bytes declared as `image/png`, + raising `Other: asset could not be parsed: invalid header` with PNG/JPEG magic + bytes in the message. Four multiple-ingredient/resource tests used `A.jpg` + with a PNG MIME. They now declare `image/jpeg`, retaining the same assets and + operations. A separate negative test retains coverage of the rejected input. + Reader MIME autodetection tests remain unchanged and passed. +3. Native settings merges are additive, including changed entries sharing a + `trust_uri`; `anchors: []` does not remove existing entries. Fresh settings + plus a new context remove trust without mutating existing contexts. Tested + with actual native readers, not mocked settings. +4. **Open native security blocker:** a fresh context containing only a `cawg` + anchor or only a `tsa` anchor for the fixture root still reports `C.jpg` as + `Trusted`, including `signingCredential.trusted`, no failure codes, and the + supplied trust-list URI. No manifest-purpose anchor was supplied. The new + `TestSettings.test_settings_typed_trust_purposes_do_not_authorize_other_roles` + preserves the expected `Valid` result for those two cases and currently fails + both subtests. It is not skipped or xfailed. This blocks qualification of + purpose isolation; Python must not hide or relabel native validation results. + +Native evidence for item 4 (read-only inspection): `sdk/src/store.rs:166-198` +loads every purpose into the same certificate trust policy; +`sdk/src/crypto/cose/certificate_trust/openssl.rs:34` iterates all anchor sets; +`sdk/src/crypto/cose/verifier.rs:311-325` logs `signingCredential.trusted` for a +successful result without rejecting the returned non-manifest anchor type. +The Rust-native backend also iterates all sets. Native remediation belongs to +the native owner, not this Python baseline change. + +## Test Evidence + +Commands below were run from the Python worktree with the pairing environment +above. Durations are pytest wall times. No old-library capability skips were +introduced; the paired trusted-VSI scaffold checks ran against the real library. + +| Command / selection | Result | Seconds | +|---|---|---:| +| `--collect-only -q` before changes | 613 tests collected | 0.94 | +| `tests/test_unit_tests.py::TestSettings tests/test_unit_tests.py::TestReader::test_stream_read_get_validation_state_with_trust_config tests/test_trusted_vsi_api.py` before fixture migration | 53 passed, 1 legacy trust failure | 0.68 | +| `--ignore=tests/test_unit_tests_threaded.py --durations=20 -o faulthandler_timeout=120` after fixture migration | 555 passed, 4 ingredient MIME failures, 73 subtests passed; release-smoke module opt-in skip | 504.99 | +| `tests/test_unit_tests_threaded.py -k 'not TestContextualBuilderWithThreads' --durations=15 -o faulthandler_timeout=120` | 41 passed, 13 assigned to next partition | 190.74 | +| `tests/test_unit_tests_threaded.py -k TestContextualBuilderWithThreads --durations=15 -o faulthandler_timeout=120` | 13 passed, 41 already covered | 159.19 | +| `tests/test_unit_tests.py -k 'TestSettings or add_multiple_ingredients or rejects_mismatched_format' --durations=10` after MIME fixes and new regressions | 16 test methods passed; 2 purpose-isolation subtests failed, 4 subtests passed | 11.00 | +| `tests/test_castlabs_release_smoke.py --durations=10` with additional `CASTLABS_RELEASE_SMOKE_REQUIRED=1` | 5 passed, no skips | 1.29 | +| `tests/test_trusted_vsi_api.py tests/test_castlabs_release_smoke.py tests/test_castlabs_release_tooling.py tests/test_unit_tests.py::TestC2paSdk --durations=5` with additional `CASTLABS_RELEASE_SMOKE_REQUIRED=1` | 70 passed, no skips | 2.76 | + +The original suite's failures were rerun after correction; all original tests +have passing coverage across these runs. The newly added purpose-isolation +regression remains failing. The release-smoke module's initial opt-in skip was +subsequently exercised explicitly, not accepted as qualification evidence. + +Timeout allowances: non-threaded 600 seconds; threaded partitions 600 and 480 +seconds. Neither partition timed out. Process inspection found no concurrent +native build/test jobs before starting the long runs. Slowest tests were +non-threaded sign-all-files (83.02s / 71.08s) and threaded async sign-all-files +(79.97s / 58.87s), consistent with these tests' live TSA requests. Tests retained +their original network behavior and contention workloads. + +The trusted-VSI surface remains disabled: Python capability helpers return +false and the paired native scaffold ABI returns disabled outputs. Functional +bindings must wait for the separately assigned functional ABI and qualification. +The native library SHA-256 was unchanged at the final check. `git diff --check` +passed; only the five files listed above are modified/untracked. diff --git a/docs/usage.md b/docs/usage.md index 2bb367e9..605343f5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -24,43 +24,30 @@ from c2pa import Settings, Context, ContextBuilder, ContextProvider All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context managers (the `with` statement) for automatic resource cleanup. -## Trusted-processor VSI (unreleased, disabled) - -This is a separate target API, not the existing complete-buffer -`LiveVideoVsiSession` implementation. Every -`has_live_video_trusted_vsi_*()` probe returns `False`, including -`has_live_video_trusted_vsi_expert_sig_structure()`. Do not enable the private -Python gate: construction and every session operation raise -`C2paError.NotSupported` before inspecting inputs or invoking callbacks/native -code or allocating managed resources. Import does not call the native trusted -capability function while gated. - -The expert target signature is -`TrustedVsiPrehashedSession.sign_sig_structure(sig_structure: bytes) -> TrustedVsiSignResult`. -It accepts only the caller's exact COSE `Sig_structure`, without an EMSG skeleton -or duplicate hash/header envelope. The packager owns EMSG construction and event -IDs. The future signer owns ordered sequence allocation and signs the supplied -bytes without reconstruction. Its frozen result has exactly these fields: - -- `signature: bytes`: exactly 64 fixed-format bytes (not DER ECDSA). -- `sequence_number: int`: uint32, including zero and `2**32 - 1`. -- `sequence_max: Optional[int] = None`: optional inclusive uint32 ceiling, at - least `sequence_number`; absence advertises no ceiling. - -The packager predicts the sequence when composing the payload and treats the -result as confirmation. The future validator is limited to one canonical CBOR -item, an untagged four-element `Signature1` array, protected-header bytes, empty -external-AAD bytes, payload bytes, and a canonical protected-header algorithm -matching the session's fixed signature shape. This scaffold implements **no** -CBOR/COSE validation, signing, persistence, or state transitions. It does not -inspect payload sequence, hash, manifest, timing, or EMSG fields. - -Split-init reservation/finalization/commit, signer-composed EMSG reservation/ -finalization, recovery, status, and `VsiSigningContextV1` remain disabled native -target contracts. Expert callback context uses purpose `vsi`, the assigned -sequence, `event_id=None`, and sequence-derived `exhaust_after_sign`. Existing -complete-buffer VSI APIs are unchanged. Superseded unshipped expert names are -removed, not compatibility aliases. These changes are not in immutable dev5. +## Trusted-processor VSI (unreleased functional API) + +`TrustedVsiSession` is separate from complete-buffer +`LiveVideoVsiSession`. It requires the complete functional 0.91.0-dev native ABI +and capability mask 63. Older libraries import normally but do not advertise +trusted functionality; construction fails before inspecting arguments or +invoking callbacks. These changes are not in immutable dev5 artifacts. + +Expert mode uses `sign_sig_structure(sig_structure: bytes, sequence_number: int) +-> bytes`. The trusted processor supplies the uint32 sequence as metadata and +owns media ordering, MFHD/VSI equality, and EMSG construction. Native validates +canonical framing and signs the original bytes without decoding the opaque +payload. The result is exactly 64 raw signature bytes, not a sequence result. +Expert callback metadata has no event ID or exhaustion, even at UINT32_MAX. + +Composed mode reserves a full EMSG at the supplied sequence/time and finalizes +it against the canonical BMFF hash. Init uses the same reserve/finalize split +for full UUID boxes. Explicit public-state export/import preserves pending +reservations; preflight validates without key use or state mutation. Native +owns all state validation and cryptography, while the coordinator owns durable +operation identities and provider retry enforcement. + +See the [Python contract](trusted-vsi-python-contract.md) for exact constructor +options, mode names, callback ownership, error behavior, and qualification commands. ## Define manifest JSON diff --git a/requirements-dev.txt b/requirements-dev.txt index 083439e7..1e09955b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,6 +6,8 @@ toml==0.10.2 # For reading pyproject.toml files # Testing dependencies pytest>=8.1.0 +cbor2>=5.6.0 # Canonical CBOR fixtures for functional trusted-VSI qualification +packaging>=23.0 # Separate development artifact version validation # for downloading the library artifacts requests>=2.0.0 diff --git a/scripts/build_trusted_vsi_functional.py b/scripts/build_trusted_vsi_functional.py new file mode 100644 index 00000000..23d6aa04 --- /dev/null +++ b/scripts/build_trusted_vsi_functional.py @@ -0,0 +1,120 @@ +"""Build source-paired functional test artifacts, never release/publish dev5. + +The development version is applied only in a temporary source staging tree. +The repository's version and immutable release inputs are left untouched. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import sysconfig + +import toml +from packaging.version import Version + + +ROOT = Path(__file__).resolve().parents[1] + + +def functional_version(value: str) -> str: + version = Version(value) + if (not version.is_devrelease or version <= Version("0.37.8.dev5") + or version.release == (0, 37, 8)): + raise ValueError("functional version must be a newer development series than 0.37.8") + return str(version) + + +PROBES = ( + "split_init", "expert_sig_structure", "composed_emsg", "recovery", + "signing_context_v1", "full_uint32_exhaustion", +) +PROBE_SCRIPT = "import c2pa\n" + "".join( + f"assert c2pa.has_live_video_trusted_vsi_{name}(), {name!r}\n" for name in PROBES) +STAGED_DIRECTORIES = ("src", "scripts", "release", "docs", "tests") +STAGED_FILES = ("pyproject.toml", "setup.py", "MANIFEST.in", "README.md", "LICENSE-MIT", + "LICENSE-APACHE", "requirements.txt", "c2pa-native-version.txt") + + +def stage_source(stage: Path, version: str, root: Path = ROOT) -> None: + """Copy the checkout into ``stage`` and apply ``version`` there only.""" + version = functional_version(version) + for directory in STAGED_DIRECTORIES: + if (root / directory).is_dir(): + shutil.copytree(root / directory, stage / directory, ignore=shutil.ignore_patterns( + "__pycache__", "*.egg-info", "libs", "temp_data", "*.log")) + for name in STAGED_FILES: + shutil.copy2(root / name, stage / name) + project = toml.load(stage / "pyproject.toml") + source_version = project["project"]["version"] + project["project"]["version"] = version + (stage / "pyproject.toml").write_text(toml.dumps(project), encoding="utf-8") + binding = stage / "src/c2pa/c2pa.py" + text = binding.read_text(encoding="utf-8") + marker = f"# Version: {source_version}" + if marker not in text: + raise ValueError("binding version header does not match pyproject.toml") + binding.write_text(text.replace(marker, f"# Version: {version}", 1), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--library", type=Path, required=True) + parser.add_argument("--version", default=os.environ.get("FUNCTIONAL_BUILD_VERSION", "0.37.9.dev0")) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + version = functional_version(args.version) + library = args.library.resolve(strict=True) + expected_name = {"linux": "libc2pa_c.so", "win32": "c2pa_c.dll", "darwin": "libc2pa_c.dylib"}[sys.platform] + if library.name != expected_name: + parser.error(f"expected the native library {expected_name}") + output = args.out.resolve() + output.mkdir(parents=True, exist_ok=True) + if any(output.iterdir()): + parser.error("output directory must be empty") + + # The actual paired library must qualify before any new artifact is built. + env = dict(os.environ, PYTHONPATH=str(ROOT / "src"), C2PA_LIBRARY_NAME=str(library)) + subprocess.run([sys.executable, "-c", PROBE_SCRIPT], env=env, cwd=ROOT, check=True) + + with tempfile.TemporaryDirectory(prefix="functional-build-", dir=output) as temporary: + stage = Path(temporary) + stage_source(stage, version) + + # Existing setup.py stages artifacts into the wheel and removes that + # staging directory afterward. Do not touch the checkout's libs/ at all. + platform_id = subprocess.check_output([ + sys.executable, "-c", "from c2pa.lib import get_platform_identifier; print(get_platform_identifier())", + ], env=env, cwd=ROOT, text=True).strip() + native_dir = stage / "artifacts" / platform_id + native_dir.mkdir(parents=True) + shutil.copy2(library, native_dir / library.name) + build_env = dict(os.environ) + build_env.pop("PYTHONPATH", None) + subprocess.run([sys.executable, "setup.py", "sdist", "--dist-dir", str(output)], + cwd=stage, env=build_env, check=True) + wheel_platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + subprocess.run([sys.executable, "setup.py", "bdist_wheel", "--plat-name", wheel_platform, + "--dist-dir", str(output)], + cwd=stage, env=build_env, check=True) + + artifacts = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in output.iterdir() if p.is_file()} + (output / "functional-build.json").write_text(json.dumps({ + "qualification_only": True, + "python_version": version, + "native_library": str(library), + "native_sha256": hashlib.sha256(library.read_bytes()).hexdigest(), + "artifacts": artifacts, + }, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/qualify_trusted_vsi_functional.py b/scripts/qualify_trusted_vsi_functional.py new file mode 100644 index 00000000..01aa1131 --- /dev/null +++ b/scripts/qualify_trusted_vsi_functional.py @@ -0,0 +1,38 @@ +"""Run functional tests against an installed qualification wheel, not src/.""" + +import argparse +import os +from pathlib import Path +import subprocess +import sys +import venv + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--venv", type=Path, required=True) + parser.add_argument("--version", required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + environment = args.venv.resolve() + if environment.exists(): + parser.error("qualification venv must not already exist") + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment) + python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = dict(os.environ) + for name in ("PYTHONPATH", "C2PA_LIBRARY_NAME", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"): + env.pop(name, None) + env.update(C2PA_TRUSTED_VSI_ABI_REQUIRED="1", + C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED="1", + C2PA_FUNCTIONAL_EXPECTED_VERSION=args.version, + C2PA_FUNCTIONAL_INSTALLED_ROOT=str(environment)) + subprocess.run([str(python), "-m", "pip", "install", "--no-deps", "--ignore-installed", + str(args.wheel.resolve(strict=True))], cwd=environment, env=env, check=True) + subprocess.run([str(python), "-m", "pytest", "-q", + str(root / "tests/test_trusted_vsi_api.py"), "-ra"], + cwd=environment, env=env, check=True, timeout=480) + + +if __name__ == "__main__": + main() diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 1bfb7eaa..9e571e9b 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -32,10 +32,12 @@ ContextBuilder, ContextProvider, LiveVideoVsiSession, - TrustedVsiPrehashedSession, + TrustedVsiSession, VsiSigningContextV1, - TrustedVsiSignResult, - TrustedVsiInitUuidReservation, + TrustedVsiOperation, + TrustedVsiInputKind, + validate_trusted_vsi_input, + trusted_vsi_hash_template, TrustedVsiMediaEmsgReservation, TrustedVsiStatus, has_dynamic_assertions, @@ -72,10 +74,12 @@ 'ContextBuilder', 'ContextProvider', 'LiveVideoVsiSession', - 'TrustedVsiPrehashedSession', + 'TrustedVsiSession', 'VsiSigningContextV1', - 'TrustedVsiSignResult', - 'TrustedVsiInitUuidReservation', + 'TrustedVsiOperation', + 'TrustedVsiInputKind', + 'validate_trusted_vsi_input', + 'trusted_vsi_hash_template', 'TrustedVsiMediaEmsgReservation', 'TrustedVsiStatus', 'has_dynamic_assertions', diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 934ef431..17cb67da 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -149,11 +149,23 @@ 'c2pa_live_video_trusted_vsi_session_finalize_media_emsg', ) _TRUSTED_VSI_RECOVERY_FUNCTIONS = ( - 'c2pa_live_video_trusted_vsi_session_recover', + 'c2pa_live_video_trusted_vsi_session_export_state', + 'c2pa_live_video_trusted_vsi_session_import_state', ) _TRUSTED_VSI_STATUS_FUNCTIONS = ( 'c2pa_live_video_trusted_vsi_session_status_v1', ) +_TRUSTED_VSI_PREFLIGHT_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_session_preflight', + 'c2pa_live_video_trusted_vsi_validate_input', + 'c2pa_live_video_trusted_vsi_hash_template', +) +_TRUSTED_VSI_FUNCTIONS = ( + _TRUSTED_VSI_CAPABILITIES_FUNCTIONS + _TRUSTED_VSI_CREATE_FUNCTIONS + + _TRUSTED_VSI_SPLIT_INIT_FUNCTIONS + _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS + + _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS + _TRUSTED_VSI_RECOVERY_FUNCTIONS + + _TRUSTED_VSI_STATUS_FUNCTIONS + _TRUSTED_VSI_PREFLIGHT_FUNCTIONS +) _TRUSTED_VSI_CAP_SPLIT_INIT = 1 << 0 _TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE = 1 << 1 @@ -161,7 +173,7 @@ _TRUSTED_VSI_CAP_RECOVERY = 1 << 3 _TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1 = 1 << 4 _TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE = 1 << 5 -_TRUSTED_VSI_PYTHON_API_ENABLED = False +_TRUSTED_VSI_REQUIRED_CAPABILITIES = 63 # Castlabs dynamic-assertion extension. Keep this optional so the package can # still be imported with standard upstream native libraries. @@ -282,6 +294,8 @@ def _validate_library_exports(lib): hasattr(_lib, name) for name in _TRUSTED_VSI_STATUS_FUNCTIONS ) _TRUSTED_VSI_CAPABILITIES = 0 +_TRUSTED_VSI_ABI_AVAILABLE = all(hasattr(_lib, name) for name in _TRUSTED_VSI_FUNCTIONS) +_TRUSTED_VSI_VERSION_MATCHES = False _DYNAMIC_ASSERTIONS_AVAILABLE = all( hasattr(_lib, name) for name in _DYNAMIC_ASSERTION_FUNCTIONS ) @@ -1375,23 +1389,24 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_int ) -# Provisional declarations for the separately versioned trusted-processor ABI. -# Calls remain unavailable from this Python scaffold; keeping setup conditional -# ensures older native libraries are never asked for these optional symbols. -if _TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE: +# Only bind the functional ABI when its complete distinguishing symbol set is +# present. The old scaffold reused names with incompatible argument layouts. +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_capabilities, [], ctypes.c_uint64, ) - if _TRUSTED_VSI_PYTHON_API_ENABLED: + native_version_ptr = _lib.c2pa_version() + if native_version_ptr: try: - _TRUSTED_VSI_CAPABILITIES = int( - _lib.c2pa_live_video_trusted_vsi_capabilities() + _TRUSTED_VSI_VERSION_MATCHES = ( + b'c2pa-rs/0.91.0-dev' in ctypes.string_at(native_version_ptr).split() ) - except Exception: # pragma: no cover - defensive import compatibility - _TRUSTED_VSI_CAPABILITIES = 0 -if _TRUSTED_VSI_CREATE_AVAILABLE: + finally: + _lib.c2pa_string_free(native_version_ptr) + if _TRUSTED_VSI_VERSION_MATCHES: + _TRUSTED_VSI_CAPABILITIES = int(_lib.c2pa_live_video_trusted_vsi_capabilities()) _setup_function( _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1, [ctypes.POINTER(C2paContext), @@ -1404,11 +1419,12 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_uint64, ctypes.c_char_p, ctypes.c_uint64, + ctypes.c_char_p, ctypes.c_void_p, TrustedVsiSignCallbackV1], ctypes.POINTER(C2paLiveVideoTrustedVsiSession), ) -if _TRUSTED_VSI_SPLIT_INIT_AVAILABLE: +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_session_reserve_init_uuid, [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), @@ -1434,22 +1450,21 @@ def _setup_function(func, argtypes, restype=None): [ctypes.POINTER(C2paLiveVideoTrustedVsiSession)], ctypes.c_int, ) -if _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_AVAILABLE: +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure, [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), - ctypes.POINTER(ctypes.c_uint32), - ctypes.POINTER(ctypes.c_uint32), - ctypes.POINTER(ctypes.c_bool)], + ctypes.c_uint32, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], ctypes.c_int64, ) -if _TRUSTED_VSI_COMPOSED_MEDIA_AVAILABLE: +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg, [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.c_uint32, ctypes.c_int64, ctypes.c_uint32, ctypes.c_uint32, @@ -1465,23 +1480,44 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], ctypes.c_int64, ) -if _TRUSTED_VSI_RECOVERY_AVAILABLE: +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( - _lib.c2pa_live_video_trusted_vsi_session_recover, + _lib.c2pa_live_video_trusted_vsi_session_export_state, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_import_state, [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), - ctypes.POINTER(ctypes.c_ubyte), - ctypes.c_size_t, ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t], ctypes.c_int, ) -if _TRUSTED_VSI_STATUS_AVAILABLE: +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_session_status_v1, [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), ctypes.POINTER(C2paLiveVideoTrustedVsiStatusV1)], ctypes.c_int, ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_session_preflight, + [ctypes.POINTER(C2paLiveVideoTrustedVsiSession), ctypes.c_uint32, + ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, ctypes.c_uint32, + ctypes.c_int64, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_char_p], + ctypes.c_int, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_validate_input, + [ctypes.c_uint32, ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t], + ctypes.c_int, + ) + _setup_function( + _lib.c2pa_live_video_trusted_vsi_hash_template, + [ctypes.c_uint32, ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64, + ) class C2paError(Exception): @@ -1747,6 +1783,35 @@ def _raise_typed_c2pa_error(error_str: str) -> None: raise C2paError(error_str) +def _claim_signer_error_state(callback_cb): + """Return the thread-local error slot pinned on a claim-signer callback.""" + return getattr(callback_cb, '_error_state', None) + + +def _reraise_callback_errors(states, interrupt_states=()): + """Re-raise the first stored callback exception with its identity intact. + + ``states`` re-raise any stored exception. ``interrupt_states`` (claim signers + on pre-existing Builder/complete-buffer paths, whose ordinary exceptions stay + reported as C2paError) only re-raise non-``Exception`` BaseExceptions such as + KeyboardInterrupt, SystemExit and asyncio.CancelledError. + """ + for state in states: + error = getattr(state, 'exception', None) + if error is not None: + raise error + for state in interrupt_states: + error = getattr(state, 'exception', None) + if error is not None and not isinstance(error, Exception): + raise error + + +def _clear_callback_errors(states): + for state in states: + if state is not None: + state.exception = None + + def _check_ffi_operation_result( result, fallback_msg, @@ -2018,8 +2083,13 @@ def update( self, data: Union[str, dict], ) -> 'Settings': """Update current configuration from a JSON string or dict. - If the updated string overwrite an existing settings value, - the last setting value set for that property wins. + + Scalar properties use the last value set. With native SDK 0.91, + trust.anchors entries are merged and deduplicated, not replaced: + an empty list does not remove existing anchors. Use purpose-tagged + entries (trust_kind: manifest, cawg, or tsa). To remove or replace + trust, create fresh Settings and a new Context with the complete + intended configuration; existing contexts keep their configuration. Args: data: A JSON string or dict with configuration to merge. @@ -2248,52 +2318,27 @@ class VsiSigningContextV1: @dataclass(frozen=True) -class TrustedVsiSignResult: - """Fixed-format signature and signer-assigned uint32 sequence metadata. +class TrustedVsiMediaEmsgReservation: + """Complete placeholder EMSG box and its pinned media facts. - ``sequence_max`` is an optional inclusive ceiling, not an event ID. + ``signing_context`` is the V1 context the finalize callback will receive + (supplied sequence, allocated event ID, terminal ``exhaust_after_sign``). """ - signature: bytes - sequence_number: int - sequence_max: Optional[int] = None - - def __post_init__(self): - if not isinstance(self.signature, bytes): - raise TypeError("signature must be bytes") - if len(self.signature) != 64: - raise ValueError("signature must be exactly 64 bytes") - for name, value in (("sequence_number", self.sequence_number), - ("sequence_max", self.sequence_max)): - if name == "sequence_max" and value is None: - continue - if type(value) is not int: - raise TypeError(f"{name} must be an integer") - if not 0 <= value <= 2**32 - 1: - raise ValueError(f"{name} must be a uint32") - if (self.sequence_max is not None - and self.sequence_max < self.sequence_number): - raise ValueError("sequence_max must not be below sequence_number") - - -@dataclass(frozen=True) -class TrustedVsiInitUuidReservation: - """Opaque complete placeholder UUID box returned by init reservation.""" - - placeholder_uuid_box: bytes - manifest_id: str - - -@dataclass(frozen=True) -class TrustedVsiMediaEmsgReservation: - """Complete placeholder EMSG box and its pinned media facts.""" - placeholder_emsg_box: bytes signing_context: VsiSigningContextV1 signing_time_unix_seconds: int timescale: int event_duration: int + @property + def sequence_number(self) -> int: + return self.signing_context.sequence_number + + @property + def event_id(self) -> int: + return self.signing_context.event_id + @dataclass(frozen=True) class TrustedVsiStatus: @@ -2310,9 +2355,9 @@ class TrustedVsiStatus: def _has_trusted_vsi_capability(bit: int, symbols_available: bool = True) -> bool: return ( - _TRUSTED_VSI_PYTHON_API_ENABLED - and _TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE - and _TRUSTED_VSI_CREATE_AVAILABLE + _TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_VERSION_MATCHES + and _TRUSTED_VSI_CAPABILITIES == _TRUSTED_VSI_REQUIRED_CAPABILITIES and symbols_available and bool(_TRUSTED_VSI_CAPABILITIES & bit) ) @@ -2356,7 +2401,10 @@ def has_live_video_trusted_vsi_signing_context_v1() -> bool: def has_live_video_trusted_vsi_full_uint32_exhaustion() -> bool: - """Return whether VSI supports signing then exhausting at uint32 max.""" + """Return whether composed VSI exhausts safely at uint32 max. + + Expert mode has no sequence counter or exhaustion state. + """ return _has_trusted_vsi_capability(_TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE) @@ -2429,165 +2477,322 @@ def has_fragmented_files() -> bool: ) -class TrustedVsiPrehashedSession(ManagedResource): - """Managed scaffold for trusted-processor prehashed VSI sessions. +class TrustedVsiOperation(enum.IntEnum): + RESERVE_INIT = 0 + FINALIZE_INIT = 1 + COMMIT_INIT = 2 + EXPERT_SIGN = 3 + RESERVE_MEDIA = 4 + FINALIZE_MEDIA = 5 - This unreleased API shape intentionally does not enable trusted signing. - Every entry point fails closed before argument inspection, callbacks, - native operations, or managed-resource bookkeeping. - """ - def __init__( - self, - manifest_json: Union[str, dict], - context: 'Context', - callback: Callable[[VsiSigningContextV1, bytes], bytes], - algorithm: Union[C2paSigningAlg, str], - public_cose_key: bytes, - kid: bytes, - min_sequence_number: int, - created_at: str, - validity_period_secs: int, - ): - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_signing_context_v1(), - "trusted prehashed VSI session creation", - ) +class TrustedVsiInputKind(enum.IntEnum): + INIT_HASH = 0 + SIG_STRUCTURE = 1 + MEDIA_HASH = 2 - @classmethod - def from_callback( - cls, - manifest_json: Union[str, dict], - context: 'Context', - callback: Callable[[VsiSigningContextV1, bytes], bytes], - algorithm: Union[C2paSigningAlg, str], - public_cose_key: bytes, - kid: bytes, - min_sequence_number: int, - created_at: str, - validity_period_secs: int, - ) -> 'TrustedVsiPrehashedSession': - """Create a trusted session backed by a purpose-bound callback.""" - cls._raise_scaffold_unavailable( - has_live_video_trusted_vsi_signing_context_v1(), - "trusted prehashed VSI session creation", - ) - def _cleanup_resources(self): - # Rejected construction owns nothing, including no PID/lifecycle state. - if _TRUSTED_VSI_PYTHON_API_ENABLED: - super()._cleanup_resources() +def _require_trusted_vsi(): + if not has_live_video_trusted_vsi_signing_context_v1(): + raise C2paError.NotSupported( + "Functional trusted VSI requires the complete 0.91.0-dev native ABI " + "and capability mask 63; the loaded library is unavailable") - def close(self) -> None: - """Unavailable while the trusted session scaffold is disabled.""" - self._raise_scaffold_unavailable(False, "trusted VSI close") - def __enter__(self): - self._raise_scaffold_unavailable(False, "trusted VSI context entry") +def _trusted_vsi_integer(value, name, maximum=2**32 - 1, minimum=0): + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}") + return value - @property - def is_valid(self) -> bool: - self._raise_scaffold_unavailable(False, "trusted VSI validity") + +def _trusted_vsi_algorithm(algorithm): + if isinstance(algorithm, str): + try: + algorithm = {"es256": C2paSigningAlg.ES256, + "ed25519": C2paSigningAlg.ED25519, + "eddsa": C2paSigningAlg.ED25519}[algorithm.lower().replace("-", "")] + except KeyError as error: + raise ValueError("algorithm must be ES256 or Ed25519") from error + if not isinstance(algorithm, C2paSigningAlg): + raise TypeError("algorithm must be a C2paSigningAlg or str") + if algorithm not in (C2paSigningAlg.ES256, C2paSigningAlg.ED25519): + raise ValueError("algorithm must be ES256 or Ed25519") + return algorithm + + +def _trusted_vsi_output(call): + output = ctypes.POINTER(ctypes.c_ubyte)() + try: + length = call(ctypes.byref(output)) + _check_ffi_operation_result(length, "Trusted VSI operation failed", check=lambda r: r < 0) + if not output: + if length == 0: + return b"" + raise C2paError("Trusted VSI native output pointer is null") + return ctypes.string_at(output, length) + finally: + if output: + ManagedResource._free_native_ptr(output) + + +def validate_trusted_vsi_input(kind: TrustedVsiInputKind, + algorithm: Union[C2paSigningAlg, str], data: bytes) -> None: + """Native canonical-input validation without a session or signing callback.""" + _require_trusted_vsi() + kind = TrustedVsiInputKind(_trusted_vsi_integer(kind, "kind", 2)) + algorithm = _trusted_vsi_algorithm(algorithm) + array = LiveVideoVsiSession._segment_array(data, "data") + _check_ffi_operation_result( + _lib.c2pa_live_video_trusted_vsi_validate_input(kind, algorithm, array, len(data)), + "Invalid trusted VSI input", check=lambda r: r != 0) + + +def trusted_vsi_hash_template(kind: TrustedVsiInputKind) -> bytes: + """Return the native canonical zero-digest init/media BMFF hash template.""" + _require_trusted_vsi() + kind = TrustedVsiInputKind(_trusted_vsi_integer(kind, "kind", 2)) + return _trusted_vsi_output(lambda output: _lib.c2pa_live_video_trusted_vsi_hash_template(kind, output)) + + +class TrustedVsiSession(ManagedResource): + """Mode-pinned trusted-processor session; externally serialize its calls. + + The caller owns Context. Native retains it and Python separately pins all + callbacks so closing the caller's Context cannot invalidate the session. + Import requires a new session with the same public configuration/options. + """ + + def __init__(self, context: 'Context', manifest_json: Union[str, dict], + algorithm: Union[C2paSigningAlg, str], public_cose_key: bytes, + kid: bytes, min_sequence_number: int, created_at: str, + validity_period_secs: int, + callback: Callable[[VsiSigningContextV1, bytes], bytes], *, + mode: str, reservation_nonce: str, + signing_time_unix_seconds: int, sequence_max: Optional[int] = None): + _require_trusted_vsi() + super().__init__() + self._init_attrs() + if not isinstance(manifest_json, (str, dict)): + raise TypeError("manifest_json must be a str or dict") + manifest_bytes = _to_utf8_bytes(manifest_json, "manifest_json") + if not manifest_bytes or b'\0' in manifest_bytes: + raise ValueError("manifest_json must be nonempty and contain no NUL") + if not isinstance(context, Context): + raise TypeError("context must be a Context") + context._ensure_valid_state() + if not context.has_signer: + raise C2paError("TrustedVsiSession requires a Context with an explicit signer") + if not callable(callback): + raise TypeError("callback must be callable") + algorithm = _trusted_vsi_algorithm(algorithm) + public_key_array = LiveVideoVsiSession._segment_array(public_cose_key, "public_cose_key") + kid_array = LiveVideoVsiSession._segment_array(kid, "kid") + _trusted_vsi_integer(min_sequence_number, "min_sequence_number") + _trusted_vsi_integer(validity_period_secs, "validity_period_secs", 2**64 - 1, 1) + created_at_bytes = self._text(created_at, "created_at") + if not isinstance(mode, str): + raise TypeError("mode must be a str") + if mode not in ("expert_sig_structure", "signer_composed_emsg"): + raise ValueError("mode must be expert_sig_structure or signer_composed_emsg") + if not isinstance(reservation_nonce, str): + raise TypeError("reservation_nonce must be a str") + if len(reservation_nonce) != 32 or any(c not in "0123456789abcdef" for c in reservation_nonce): + raise ValueError("reservation_nonce must contain 32 lowercase hex characters") + _trusted_vsi_integer(signing_time_unix_seconds, "signing_time_unix_seconds", 2**63 - 1, -(2**63)) + if sequence_max is not None: + _trusted_vsi_integer(sequence_max, "sequence_max", minimum=min_sequence_number) + options = json.dumps(dict(mode=mode, reservation_nonce=reservation_nonce, + signing_time_unix_seconds=signing_time_unix_seconds, + sequence_max=sequence_max)).encode() + error_state = threading.local() + error_state.exception = None + + def wrapped_callback(user_data, native_context, tbs, tbs_len, signature, capacity): + error_state.exception = None + try: + if not native_context or not tbs or not tbs_len or not signature or capacity < 64: + raise C2paError("Invalid trusted VSI callback buffers") + signing_context = TrustedVsiSession._signing_context(native_context.contents) + result = callback(signing_context, ctypes.string_at(tbs, tbs_len)) + if not isinstance(result, bytes): + raise TypeError("Trusted VSI callback must return bytes") + if len(result) != 64: + raise ValueError("Trusted VSI callback must return exactly 64 signature bytes") + ctypes.memmove(signature, result, 64) + return 64 + except BaseException as error: + error_state.exception = error + return -1 + + callback_cb = TrustedVsiSignCallbackV1(wrapped_callback) + self._context = context + self._signer_callback_cb = context._signer_callback_cb + self._dynamic_assertion_cbs = list(context._dynamic_assertion_cbs) + self._trusted_vsi_callback = (callback_cb, error_state, callback) + self._create_and_activate( + lambda: _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1( + context.execution_context, manifest_bytes, algorithm, public_key_array, + len(public_cose_key), kid_array, len(kid), min_sequence_number, + created_at_bytes, validity_period_secs, options, None, callback_cb), + "Failed to create trusted VSI session") @classmethod - def _wrap_native_handle(cls, handle): - cls._raise_scaffold_unavailable(False, "trusted VSI handle wrapping") + def from_callback(cls, context, manifest_json, algorithm, public_cose_key, kid, + min_sequence_number, created_at, validity_period_secs, callback, *, + mode, reservation_nonce, signing_time_unix_seconds, sequence_max=None): + """Create a session with the same signature and ownership as the constructor.""" + _require_trusted_vsi() + return cls(context, manifest_json, algorithm, public_cose_key, kid, + min_sequence_number, created_at, validity_period_secs, callback, mode=mode, + reservation_nonce=reservation_nonce, signing_time_unix_seconds=signing_time_unix_seconds, + sequence_max=sequence_max) def _init_attrs(self): super()._init_attrs() + self._context = None + self._signer_callback_cb = None + self._dynamic_assertion_cbs = [] self._trusted_vsi_callback = None def _release(self): + self._context = None + self._signer_callback_cb = None + self._dynamic_assertion_cbs.clear() self._trusted_vsi_callback = None @staticmethod - def _raise_scaffold_unavailable(available: bool, operation: str) -> None: - if not _TRUSTED_VSI_PYTHON_API_ENABLED: - raise C2paError.NotSupported( - f"{operation} is not enabled by this Python API scaffold" - ) - if not available: - raise C2paError.NotSupported( - f"{operation} is unavailable in the loaded native library" - ) - raise C2paError.NotSupported(f"{operation} is unavailable") + def _text(value, name): + if not isinstance(value, str): + raise TypeError(f"{name} must be a str") + if not value or '\0' in value: + raise ValueError(f"{name} must be nonempty and contain no NUL") + return _to_utf8_bytes(value, name) - def reserve_init_uuid( - self, - format: str = "video/mp4", - ) -> TrustedVsiInitUuidReservation: - """Reserve a complete fixed-size placeholder C2PA UUID box.""" - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_split_init(), - "trusted VSI init UUID reservation", - ) + @staticmethod + def _signing_context(native): + if native.purpose == 0: + if native.has_sequence_number or native.has_event_id or native.exhaust_after_sign: + raise C2paError("Invalid signer_binding context") + return VsiSigningContextV1("signer_binding") + if native.purpose != 1 or not native.has_sequence_number: + raise C2paError("Invalid trusted VSI signing context") + return VsiSigningContextV1("vsi", native.sequence_number, + native.event_id if native.has_event_id else None, + native.exhaust_after_sign) + + def _call(self, function, *args): + states = [state for _, state, _ in self._dynamic_assertion_cbs] + if self._trusted_vsi_callback is not None: + states.append(self._trusted_vsi_callback[1]) + claim_state = _claim_signer_error_state(self._signer_callback_cb) + if claim_state is not None: + states.append(claim_state) + _clear_callback_errors(states) + result = function(self._handle, *args) + if result < 0: + _reraise_callback_errors(states) + _check_ffi_operation_result(result, "Trusted VSI operation failed", check=lambda r: r < 0) + return result + + def reserve_init_uuid(self, format: str = "video/mp4") -> bytes: + """Return the complete placeholder UUID box; repeat calls return the same + frozen reservation. No signing or DynamicAssertion content callbacks run.""" + self._ensure_valid_state() + format_bytes = self._text(format, "format") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_reserve_init_uuid, format_bytes, output)) + + def reserved_manifest_id(self) -> str: + self._ensure_valid_state() + pointer = _lib.c2pa_live_video_trusted_vsi_session_reserved_manifest_id(self._handle) + _check_ffi_operation_result(pointer, "No reserved trusted VSI manifest ID") + try: + return ctypes.string_at(pointer).decode('utf-8') + finally: + _lib.c2pa_string_free(pointer) def finalize_init_uuid(self, canonical_hash: bytes) -> bytes: - """Finalize the reserved UUID box with a canonical hard binding.""" - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_split_init(), - "trusted VSI init UUID finalization", - ) + self._ensure_valid_state() + array = LiveVideoVsiSession._segment_array(canonical_hash, "canonical_hash") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_finalize_init_uuid, array, len(canonical_hash), output)) def commit_init_uuid(self) -> None: - """Commit publication of the finalized init UUID box.""" - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_split_init(), - "trusted VSI init publication commit", - ) + """Activate durable coordinator init state, not a public publication ACK.""" + self._ensure_valid_state() + self._call(_lib.c2pa_live_video_trusted_vsi_session_commit_init_uuid) - def sign_sig_structure( - self, - sig_structure: bytes, - ) -> TrustedVsiSignResult: - """Sign exact caller-composed COSE Sig_structure bytes (disabled). + def sign_sig_structure(self, sig_structure: bytes, sequence_number: int) -> bytes: + """Sign original expert bytes with supplied sequence metadata, never a counter.""" + self._ensure_valid_state() + _trusted_vsi_integer(sequence_number, "sequence_number") + array = LiveVideoVsiSession._segment_array(sig_structure, "sig_structure") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure, + array, len(sig_structure), sequence_number, output)) + + def reserve_media_emsg_at(self, sequence_number: int, signing_time_unix_seconds: int, + timescale: int, event_duration: int) -> TrustedVsiMediaEmsgReservation: + self._ensure_valid_state() + _trusted_vsi_integer(sequence_number, "sequence_number") + _trusted_vsi_integer(signing_time_unix_seconds, "signing_time_unix_seconds", 2**63 - 1, -(2**63)) + _trusted_vsi_integer(timescale, "timescale", minimum=1) + _trusted_vsi_integer(event_duration, "event_duration", minimum=1) + context = C2paLiveVideoTrustedVsiSigningContextV1() + data = _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg, sequence_number, + signing_time_unix_seconds, timescale, event_duration, output, ctypes.byref(context))) + return TrustedVsiMediaEmsgReservation(data, self._signing_context(context), + signing_time_unix_seconds, timescale, event_duration) - The caller owns EMSG construction, event IDs, and payload semantics. - The future signer owns sequence allocation and signs without rebuilding - these bytes. No CBOR/COSE validator is implemented by this scaffold. - """ - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_expert_sig_structure(), - "trusted VSI expert Sig_structure signing", - ) + def finalize_media_emsg(self, canonical_hash: bytes) -> bytes: + self._ensure_valid_state() + array = LiveVideoVsiSession._segment_array(canonical_hash, "canonical_hash") + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_finalize_media_emsg, array, len(canonical_hash), output)) - def reserve_media_emsg_at( - self, - signing_time_unix_seconds: int, - timescale: int, - event_duration: int, - ) -> TrustedVsiMediaEmsgReservation: - """Reserve a complete fixed-size placeholder VSI EMSG box.""" - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_composed_emsg(), - "trusted VSI media EMSG reservation", - ) + def export_state(self) -> bytes: + """Export exact versioned public state, including pending reservations.""" + self._ensure_valid_state() + return _trusted_vsi_output(lambda output: self._call( + _lib.c2pa_live_video_trusted_vsi_session_export_state, output)) - def finalize_media_emsg(self, canonical_hash: bytes) -> bytes: - """Finalize the reserved EMSG with a canonical BMFF hard binding.""" - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_composed_emsg(), - "trusted VSI media EMSG finalization", - ) + def import_state(self, state: bytes) -> None: + """Import into a new session with matching config, options, and claim signer.""" + self._ensure_valid_state() + array = LiveVideoVsiSession._segment_array(state, "state") + self._call(_lib.c2pa_live_video_trusted_vsi_session_import_state, array, len(state)) - def recover( - self, - signed_init_uuid: bytes, - previous_emsg: Optional[bytes] = None, - ) -> None: - """Restore trusted session state from published complete boxes.""" - self._raise_scaffold_unavailable( - has_live_video_trusted_vsi_recovery(), - "trusted prehashed VSI recovery", - ) + def preflight(self, operation: TrustedVsiOperation, data: bytes = b"", *, + sequence_number: int = 0, iat: int = 0, timescale: int = 0, + event_duration: int = 0, format: str = "video/mp4") -> None: + """Validate state and input without mutation, reservation, or callbacks.""" + self._ensure_valid_state() + operation = TrustedVsiOperation(_trusted_vsi_integer(operation, "operation", 5)) + if not isinstance(data, bytes): + raise TypeError("data must be bytes") + array = LiveVideoVsiSession._segment_array(data, "data") if data else None + _trusted_vsi_integer(sequence_number, "sequence_number") + _trusted_vsi_integer(iat, "iat", 2**63 - 1, -(2**63)) + _trusted_vsi_integer(timescale, "timescale") + _trusted_vsi_integer(event_duration, "event_duration") + self._call(_lib.c2pa_live_video_trusted_vsi_session_preflight, operation, + array, len(data), sequence_number, iat, timescale, event_duration, + self._text(format, "format")) def status(self) -> TrustedVsiStatus: - """Return the trusted session's public transaction state.""" - available = ( - has_live_video_trusted_vsi_signing_context_v1() - and _TRUSTED_VSI_STATUS_AVAILABLE - ) - self._raise_scaffold_unavailable(available, "trusted VSI status") + self._ensure_valid_state() + native = C2paLiveVideoTrustedVsiStatusV1() + self._call(_lib.c2pa_live_video_trusted_vsi_session_status_v1, ctypes.byref(native)) + reasons = {1: "sequence_max", 2: "event_id_max", 3: "legacy_sentinel"} + if native.has_exhaustion_reason and native.exhaustion_reason not in reasons: + raise C2paError("Unknown trusted VSI exhaustion reason") + return TrustedVsiStatus(native.init_uuid_committed, native.init_uuid_pending, + native.media_emsg_pending, native.next_sequence_number if native.has_next_sequence_number else None, + native.next_event_id if native.has_next_event_id else None, native.exhausted, + reasons[native.exhaustion_reason] if native.has_exhaustion_reason else None) class LiveVideoVsiSession(ManagedResource): @@ -2849,7 +3054,7 @@ def wrapped_callback( "VSI callback must return exactly 64 signature bytes") ctypes.memmove(signature, result, len(result)) return len(result) - except Exception as error: + except BaseException as error: error_state.exception = error logger.error( "Error in live-video VSI %s callback: %s", @@ -2928,22 +3133,16 @@ def _segment_array(segment: bytes, name: str): return (ctypes.c_ubyte * len(segment)).from_buffer_copy(segment) def _copy_signed_output(self, ffi_call, error_message: str) -> bytes: + states = [state for _, state, _ in self._dynamic_assertion_cbs] if self._vsi_callback is not None: - self._vsi_callback[1].exception = None - for _, error_state, _ in self._dynamic_assertion_cbs: - error_state.exception = None + states.insert(0, self._vsi_callback[1]) + claim_state = _claim_signer_error_state(self._signer_callback_cb) + _clear_callback_errors(states + [claim_state]) output = ctypes.POINTER(ctypes.c_ubyte)() length = ffi_call(ctypes.byref(output)) if length < 0: - if self._vsi_callback is not None: - callback_error = getattr( - self._vsi_callback[1], 'exception', None) - if callback_error is not None: - raise callback_error - for _, error_state, _ in self._dynamic_assertion_cbs: - callback_error = getattr(error_state, 'exception', None) - if callback_error is not None: - raise callback_error + _reraise_callback_errors( + states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( length, error_message, check=lambda result: result < 0) @@ -4534,6 +4733,11 @@ def from_callback( ) ) + # Retained on the ctypes callback itself so Context consumption and + # session borrowing preserve the original Python exception state. + callback_error_state = threading.local() + callback_error_state.exception = None + # Create a wrapper callback that handles errors and memory management def wrapped_callback( context, @@ -4541,6 +4745,7 @@ def wrapped_callback( data_len, signed_bytes_ptr, signed_len): + callback_error_state.exception = None # Returns -1 on error as it is what the native code expects. # The reason is that otherwise we ping-pong errors # between native code and Python code, @@ -4585,7 +4790,11 @@ def wrapped_callback( # Native code expects the signed len to be returned, we oblige return actual_len - except Exception as e: + except BaseException as e: + # Store every original exception (including KeyboardInterrupt, + # SystemExit and CancelledError) so callers can re-raise it; + # an exception escaping into ctypes would be discarded. + callback_error_state.exception = e logger.error( cls._ERROR_MESSAGES['callback_error'].format( str(e))) @@ -4607,6 +4816,7 @@ def wrapped_callback( # Create the callback object using the callback function callback_cb = SignerCallback(wrapped_callback) + callback_cb._error_state = callback_error_state # Create the signer with the wrapped callback signer_ptr = _lib.c2pa_signer_create( @@ -4788,7 +4998,9 @@ def wrapped_callback( if result_size: ctypes.memmove(out_data, result, result_size) return result_size - except Exception as error: + except BaseException as error: + # See Signer.from_callback: never let an exception escape into + # ctypes, where it is ignored and the original is lost. error_state.exception = error logger.error( "Error in dynamic assertion callback for '%s': %s", @@ -5333,8 +5545,11 @@ def _sign_internal( if signer is not None else self._dynamic_assertion_cbs ) - for _, error_state, _ in dynamic_assertion_cbs: - error_state.exception = None + claim_state = _claim_signer_error_state( + signer._callback_cb if signer is not None + else self._signer_callback_cb) + da_states = [state for _, state, _ in dynamic_assertion_cbs] + _clear_callback_errors(da_states + [claim_state]) # allow_autodetect=False, so this never returns None (raises instead). format_arg = _format_ffi_arg( @@ -5368,10 +5583,8 @@ def _sign_internal( raise C2paError(f"Error during signing: {e}") from e if result < 0: - for _, error_state, _ in dynamic_assertion_cbs: - callback_error = getattr(error_state, 'exception', None) - if callback_error is not None: - raise callback_error + _reraise_callback_errors( + da_states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( result, @@ -5579,8 +5792,9 @@ def sign_fragmented( "asset_path must identify an existing regular file") dynamic_assertion_cbs = list(signer._dynamic_assertion_cbs) - for _, error_state, _ in dynamic_assertion_cbs: - error_state.exception = None + claim_state = _claim_signer_error_state(signer._callback_cb) + da_states = [state for _, state, _ in dynamic_assertion_cbs] + _clear_callback_errors(da_states + [claim_state]) manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() try: @@ -5598,10 +5812,8 @@ def sign_fragmented( f"Error during fragmented signing: {error}") from error if result < 0: - for _, error_state, _ in dynamic_assertion_cbs: - callback_error = getattr(error_state, 'exception', None) - if callback_error is not None: - raise callback_error + _reraise_callback_errors( + da_states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( result, @@ -5879,10 +6091,12 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'Builder', 'Signer', 'LiveVideoVsiSession', - 'TrustedVsiPrehashedSession', + 'TrustedVsiSession', 'VsiSigningContextV1', - 'TrustedVsiSignResult', - 'TrustedVsiInitUuidReservation', + 'TrustedVsiOperation', + 'TrustedVsiInputKind', + 'validate_trusted_vsi_input', + 'trusted_vsi_hash_template', 'TrustedVsiMediaEmsgReservation', 'TrustedVsiStatus', 'has_dynamic_assertions', diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index bb0d6628..f1b4f4de 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -72,6 +72,7 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): assert " prepare:\n if: ${{ !inputs.trusted_vsi_only }}" in dedicated assert dedicated.count(f"ref: {release.RUST_COMMIT}") == 3 assert 'C2PA_TRUSTED_VSI_ABI_REQUIRED: "1"' in paired + assert 'C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1"' in paired assert "python -m pytest -q tests/test_trusted_vsi_api.py -ra" in paired assert "-k " not in paired assert "ubuntu-24.04" in paired and "windows-2022" in paired @@ -79,8 +80,12 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): assert "PYTHONPATH: ${{ github.workspace }}/python-source/src" in paired assert "python setup.py egg_info" in paired assert "cargo +1.88.0 build --locked" in paired + # Paired native is pinned to a reviewed full SHA, never a moving branch. assert re.search(r"^\s+ref: [0-9a-f]{40}$", paired, re.MULTILINE) assert "ref: feat/" not in paired + assert "FUNCTIONAL_BUILD_VERSION: 0.37.9.dev0" in paired + assert "scripts/build_trusted_vsi_functional.py" in paired + assert "scripts/qualify_trusted_vsi_functional.py" in paired for forbidden in ("download_artifacts.py", "castlabs_release.py", "upload-artifact@", "bdist_wheel", "contents: write", "id-token: write", "continue-on-error", "gh release"): diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py index 2a8dcb4a..abf6c293 100644 --- a/tests/test_trusted_vsi_api.py +++ b/tests/test_trusted_vsi_api.py @@ -1,264 +1,1071 @@ -from dataclasses import FrozenInstanceError, fields +"""Trusted VSI Python bindings. + +Tests named ``paired`` require the complete functional native library and FAIL +(never skip or pass) under C2PA_TRUSTED_VSI_ABI_REQUIRED=1. Legacy dev5 jobs +select only ``not paired``. Non-paired tests exercise Python gating and +marshalling against scripted native entry points; they are not functional +native qualification. +""" + +import asyncio import ctypes -import importlib.util +from dataclasses import FrozenInstanceError +import gc +import hashlib import inspect +import io +import json import os -import sys +from pathlib import Path from unittest.mock import Mock +import cbor2 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature import pytest import c2pa import c2pa.c2pa as bindings -import c2pa.lib as library_loader - - -def test_trusted_vsi_scaffold_exports_and_capabilities_are_unavailable(): - assert c2pa.has_live_video_trusted_vsi_split_init() is False - assert c2pa.has_live_video_trusted_vsi_expert_sig_structure() is False - assert c2pa.has_live_video_trusted_vsi_composed_emsg() is False - assert c2pa.has_live_video_trusted_vsi_recovery() is False - assert c2pa.has_live_video_trusted_vsi_signing_context_v1() is False - assert c2pa.has_live_video_trusted_vsi_full_uint32_exhaustion() is False - assert bindings._TRUSTED_VSI_PYTHON_API_ENABLED is False - assert bindings._TRUSTED_VSI_CAP_EXPERT_SIG_STRUCTURE == 2 + + +FIXTURES = Path(__file__).parent / "fixtures" +IAT = 1789041600 +OP = c2pa.TrustedVsiOperation +KIND = c2pa.TrustedVsiInputKind +PROBES = [getattr(c2pa, name) for name in ( + "has_live_video_trusted_vsi_split_init", "has_live_video_trusted_vsi_expert_sig_structure", + "has_live_video_trusted_vsi_composed_emsg", "has_live_video_trusted_vsi_recovery", + "has_live_video_trusted_vsi_signing_context_v1", "has_live_video_trusted_vsi_full_uint32_exhaustion", +)] + + +def _fixture_claim_signature(data): + """Healthy ES256 claim-signer callback for the es256 fixture certificate.""" + key = serialization.load_pem_private_key( + (FIXTURES / "es256_private.key").read_bytes(), password=None) + return key.sign(data, ec.ECDSA(hashes.SHA256())) + + +def _exact_dynamic_assertion(label, reserve_size, partial_claim): + """Canonical CBOR {"pad": h'58..'} of exactly the 64 reserved bytes.""" + assert reserve_size == 64 + return b"\xa1\x63pad\x58\x39" + b"X" * 57 + + +# BaseException subclasses that are not Exception must also be stored by the +# ctypes wrappers and re-raised with identity; escaping into ctypes loses them. +CALLBACK_FAILURES = [ + pytest.param(lambda: RuntimeError("provider failure"), id="RuntimeError"), + pytest.param(lambda: KeyboardInterrupt("operator interrupt"), id="KeyboardInterrupt"), + pytest.param(lambda: SystemExit(3), id="SystemExit"), + pytest.param(lambda: asyncio.CancelledError("worker cancelled"), id="CancelledError"), +] + + +def test_unshipped_counter_result_and_recovery_are_removed(): + assert not hasattr(c2pa, "TrustedVsiSignResult") + assert not hasattr(bindings, "TrustedVsiSignResult") + assert not hasattr(c2pa.TrustedVsiSession, "recover") + assert not hasattr(bindings, "_TRUSTED_VSI_PYTHON_API_ENABLED") + signature = inspect.signature(c2pa.TrustedVsiSession.sign_sig_structure) + assert list(signature.parameters) == ["self", "sig_structure", "sequence_number"] + assert signature.return_annotation is bytes for module in (c2pa, bindings): - assert "TrustedVsiSignResult" in module.__all__ - assert "has_live_video_trusted_vsi_expert_sig_structure" in module.__all__ - assert "has_live_video_trusted_vsi_expert_emsg" not in module.__all__ - assert not hasattr(module, "has_live_video_trusted_vsi_expert_emsg") assert all(hasattr(module, name) for name in module.__all__) - for name in ("_TRUSTED_VSI_CAP_EXPERT_MEDIA", - "_TRUSTED_VSI_EXPERT_MEDIA_FUNCTIONS", - "_TRUSTED_VSI_EXPERT_MEDIA_AVAILABLE"): - assert not hasattr(bindings, name) - assert not hasattr(c2pa.TrustedVsiPrehashedSession, "sign_emsg_sig_structure") - method = inspect.signature(c2pa.TrustedVsiPrehashedSession.sign_sig_structure) - assert list(method.parameters) == ["self", "sig_structure"] - assert method.return_annotation is c2pa.TrustedVsiSignResult - - -def test_trusted_vsi_public_values_are_immutable(): - context = c2pa.VsiSigningContextV1( - purpose="vsi", - sequence_number=7, - event_id=None, - exhaust_after_sign=False, - ) - reservation = c2pa.TrustedVsiMediaEmsgReservation( - placeholder_emsg_box=b"emsg", - signing_context=context, - signing_time_unix_seconds=1_700_000_000, - timescale=1_000, - event_duration=2_000, - ) - status = c2pa.TrustedVsiStatus( - init_uuid_committed=False, - init_uuid_pending=False, - media_emsg_pending=False, - next_sequence_number=7, - next_event_id=1, - exhausted=False, - ) - init_reservation = c2pa.TrustedVsiInitUuidReservation( - placeholder_uuid_box=b"uuid", - manifest_id="urn:c2pa:manifest-1", - ) - assert context.sequence_number == reservation.signing_context.sequence_number == 7 - assert status.next_event_id == 1 - assert init_reservation.manifest_id == "urn:c2pa:manifest-1" + + +@pytest.mark.parametrize("mask", [0, 1, 31, 62, 64, 127]) +def test_partial_or_unknown_capability_mask_never_advertises_functionality(monkeypatch, mask): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", mask) + assert not any(probe() for probe in PROBES) + + +@pytest.mark.parametrize("missing", ["_TRUSTED_VSI_ABI_AVAILABLE", "_TRUSTED_VSI_VERSION_MATCHES"]) +def test_missing_symbols_or_wrong_native_version_fail_closed(monkeypatch, missing): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) + monkeypatch.setattr(bindings, missing, False) + assert not any(probe() for probe in PROBES) + + +@pytest.mark.parametrize("factory", [c2pa.TrustedVsiSession, c2pa.TrustedVsiSession.from_callback]) +def test_disabled_constructor_gates_before_arguments_callbacks_or_bookkeeping(monkeypatch, factory): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 0) + forbidden = Mock(side_effect=AssertionError("side effect before capability gate")) + monkeypatch.setattr(bindings.ManagedResource, "__init__", forbidden) + monkeypatch.setattr(bindings, "_lib", forbidden) + args = [object() for _ in range(9)] + with pytest.raises(c2pa.C2paError.NotSupported, match="Functional trusted VSI"): + factory(*args, mode=object(), reservation_nonce=object(), signing_time_unix_seconds=object()) + forbidden.assert_not_called() + assert forbidden.mock_calls == [] + + +def test_value_wrappers_are_frozen_and_v1_layouts_exact(): + context = c2pa.VsiSigningContextV1("vsi", 2**32 - 1) with pytest.raises(FrozenInstanceError): - context.event_id = 2 + context.sequence_number = 0 + native = bindings.C2paLiveVideoTrustedVsiSigningContextV1 + assert ctypes.sizeof(native) == 20 and ctypes.alignment(native) == 4 + assert [getattr(native, name).offset for name, _ in native._fields_] == [0, 4, 8, 12, 16, 17] + status = bindings.C2paLiveVideoTrustedVsiStatusV1 + assert ctypes.sizeof(status) == 24 and ctypes.alignment(status) == 4 + assert [getattr(status, name).offset for name, _ in status._fields_] == [0, 1, 2, 3, 4, 8, 12, 16, 17, 20] + + +def test_python_mapping_matches_native_contract_signatures(): + expected = ["context", "manifest_json", "algorithm", "public_cose_key", "kid", + "min_sequence_number", "created_at", "validity_period_secs", "callback", + "mode", "reservation_nonce", "signing_time_unix_seconds", "sequence_max"] + for factory in (c2pa.TrustedVsiSession.from_callback, c2pa.TrustedVsiSession.__init__): + names = [n for n in inspect.signature(factory).parameters if n not in ("self", "cls")] + assert names == expected + params = inspect.signature(factory).parameters + assert all(params[n].kind is inspect.Parameter.KEYWORD_ONLY for n in expected[9:]) + session = c2pa.TrustedVsiSession + assert inspect.signature(session.reserve_init_uuid).return_annotation is bytes + assert list(inspect.signature(session.reserve_media_emsg_at).parameters) == [ + "self", "sequence_number", "signing_time_unix_seconds", "timescale", "event_duration"] + assert list(inspect.signature(session.preflight).parameters) == [ + "self", "operation", "data", "sequence_number", "iat", "timescale", "event_duration", "format"] + for name in ("export_state", "import_state", "status", "reserved_manifest_id", + "finalize_init_uuid", "commit_init_uuid", "finalize_media_emsg"): + assert callable(getattr(session, name)) + assert [m.value for m in c2pa.TrustedVsiOperation] == list(range(6)) + assert [m.value for m in c2pa.TrustedVsiInputKind] == [0, 1, 2] + assert not hasattr(c2pa, "TrustedVsiInitUuidReservation") + assert not hasattr(c2pa, "TrustedVsiPrehashedSession") + + +@pytest.mark.parametrize("function,args", [ + ("validate_trusted_vsi_input", (object(), object(), object())), + ("trusted_vsi_hash_template", (object(),)), +]) +def test_static_helpers_gate_before_arguments_or_native(monkeypatch, function, args): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 0) + forbidden = Mock(side_effect=AssertionError("native touched")) + monkeypatch.setattr(bindings, "_lib", forbidden) + with pytest.raises(c2pa.C2paError.NotSupported): + getattr(c2pa, function)(*args) + forbidden.assert_not_called() + + +class _ScriptedNative: + """Records calls to scripted trusted-VSI entry points installed on _lib.""" + + def __init__(self, monkeypatch): + self.calls = [] + self.freed = [] + self.buffers = [] + self.callback = None + self.handle = ctypes.cast(ctypes.pointer(ctypes.c_int(7)), + ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession)) + self._keep = self.handle._objects + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) + real_free = bindings.ManagedResource._free_native_ptr + + def free(ptr): + # Record scripted pointers only; real native handles (e.g. the + # caller's Context) are released by the real c2pa_free. + address = ctypes.addressof(ptr.contents) + scripted = {ctypes.addressof(b) for b in self.buffers} + scripted.add(ctypes.addressof(self.handle.contents)) + if address in scripted: + self.freed.append(address) + return 0 + return real_free(ptr) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + for name in bindings._TRUSTED_VSI_FUNCTIONS: + monkeypatch.setattr(bindings._lib, name, self._unexpected(name), raising=False) + def _unexpected(self, name): + def call(*args): + raise AssertionError(f"unexpected native call {name}") + return call -@pytest.mark.parametrize("sequence,maximum", [ - (0, None), (0, 0), (7, 8), (2**32 - 1, None), (2**32 - 1, 2**32 - 1), + def install(self, monkeypatch, name, function): + def recorded(*args): + self.calls.append((name, args)) + return function(*args) + monkeypatch.setattr(bindings._lib, "c2pa_live_video_trusted_vsi_" + name, recorded) + + def output(self, output_arg, data): + buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) + self.buffers.append(buffer) + target = ctypes.cast(output_arg, ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))) + assert not target[0], "output must be initialized to NULL" + target[0] = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + return len(data) + + def invoke(self, purpose=1, sequence=5, has_sequence=True, event=0, has_event=False, + exhaust=False, data=b"tbs"): + context = bindings.C2paLiveVideoTrustedVsiSigningContextV1( + purpose, sequence, has_sequence, event, has_event, exhaust) + tbs = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) + signature = (ctypes.c_ubyte * 64)() + result = self.callback(None, ctypes.pointer(context), tbs, len(data), signature, 64) + return result, bytes(signature) + + +@pytest.fixture +def scripted(monkeypatch): + native = _ScriptedNative(monkeypatch) + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + context = c2pa.Context(signer=signer) + calls = [] + behaviour = {"result": lambda ctx, data: b"S" * 64} + + def callback(ctx, data): + calls.append((ctx, data)) + return behaviour["result"](ctx, data) + + def create(*args): + native.callback = args[-1] + return native.handle + native.install(monkeypatch, "session_create_callback_v1", create) + session = c2pa.TrustedVsiSession( + context, {"format": "video/mp4"}, "es256", b"\xa1\x01\x02", b"kid", 3, + "2026-09-10T00:00:00Z", 86400, callback, mode="expert_sig_structure", + reservation_nonce="0" * 32, signing_time_unix_seconds=IAT, sequence_max=9) + yield native, session, context, calls, behaviour + session.close() + context.close() + + +def test_scripted_constructor_marshals_exact_order_and_options(scripted): + native, session, context, calls, _ = scripted + name, args = native.calls[0] + assert name == "session_create_callback_v1" and len(args) == 13 + assert args[0] is not None and args[1] == b'{"format": "video/mp4"}' + assert args[2] == c2pa.C2paSigningAlg.ES256 + assert bytes(args[3]) == b"\xa1\x01\x02" and args[4] == 3 + assert bytes(args[5]) == b"kid" and args[6] == 3 + assert args[7:10] == (3, b"2026-09-10T00:00:00Z", 86400) + assert json.loads(args[10]) == {"mode": "expert_sig_structure", "reservation_nonce": "0" * 32, + "signing_time_unix_seconds": IAT, "sequence_max": 9} + assert args[11] is None and isinstance(args[12], bindings.TrustedVsiSignCallbackV1) + assert calls == [], "construction must not sign" + assert session.is_valid + + +def test_scripted_expert_sign_supplied_sequence_bytes_and_single_free(monkeypatch, scripted): + native, session, _, calls, _ = scripted + def sign(handle, data, length, sequence, output): + assert ctypes.addressof(handle.contents) == ctypes.addressof(native.handle.contents) + assert ctypes.string_at(data, length) == b"exact-sig-structure" + result, signature = native.invoke(sequence=sequence, data=ctypes.string_at(data, length)) + assert result == 64 and signature == b"S" * 64 + return native.output(output, signature) + native.install(monkeypatch, "session_sign_sig_structure", sign) + for sequence in (9, 3, 2**32 - 1, 3): + assert session.sign_sig_structure(b"exact-sig-structure", sequence) == b"S" * 64 + assert calls[-1] == (c2pa.VsiSigningContextV1("vsi", sequence), b"exact-sig-structure") + assert [call[1][3] for call in native.calls[1:]] == [9, 3, 2**32 - 1, 3] + assert len(native.freed) == 4 + for bad, error in ((-1, ValueError), (2**32, ValueError), (True, TypeError), ("1", TypeError)): + with pytest.raises(error): + session.sign_sig_structure(b"x", bad) + with pytest.raises(TypeError): + session.sign_sig_structure(bytearray(b"x"), 1) + assert len(native.calls) == 5 + + +@pytest.mark.parametrize("result,error", [ + (lambda ctx, data: b"short", ValueError), + (lambda ctx, data: bytearray(64), TypeError), + (None, RuntimeError), ]) -def test_trusted_vsi_sign_result_is_frozen(sequence, maximum): - result = c2pa.TrustedVsiSignResult(b"s" * 64, sequence, maximum) - assert [field.name for field in fields(result)] == [ - "signature", "sequence_number", "sequence_max", - ] - assert result.signature == b"s" * 64 - assert result.sequence_number == sequence - assert result.sequence_max == maximum - for name in ("signature", "sequence_number", "sequence_max"): - with pytest.raises(FrozenInstanceError): - setattr(result, name, None) - assert c2pa.TrustedVsiSignResult(b"s" * 64, 0).sequence_max is None - - -@pytest.mark.parametrize("signature,sequence,maximum,error", [ - (b"", 0, None, ValueError), - (b"s" * 63, 0, None, ValueError), - (b"s" * 65, 0, None, ValueError), - (bytearray(64), 0, None, TypeError), - ("s" * 64, 0, None, TypeError), - (None, 0, None, TypeError), - (b"s" * 64, -1, None, ValueError), - (b"s" * 64, 2**32, None, ValueError), - (b"s" * 64, True, None, TypeError), - (b"s" * 64, None, None, TypeError), - (b"s" * 64, 1.0, None, TypeError), - (b"s" * 64, "1", None, TypeError), - (b"s" * 64, 0, -1, ValueError), - (b"s" * 64, 0, 2**32, ValueError), - (b"s" * 64, 0, False, TypeError), - (b"s" * 64, 0, 1.0, TypeError), - (b"s" * 64, 0, "1", TypeError), - (b"s" * 64, 7, 6, ValueError), +def test_scripted_callback_errors_keep_identity_and_free_nothing(monkeypatch, scripted, result, error): + native, session, _, _, behaviour = scripted + failure = RuntimeError("provider unavailable") + def raising(ctx, data): + raise failure + behaviour["result"] = result or raising + def sign(handle, data, length, sequence, output): + assert native.invoke(sequence=sequence)[0] == -1 + return -1 + native.install(monkeypatch, "session_sign_sig_structure", sign) + with pytest.raises(error) as caught: + session.sign_sig_structure(b"x", 5) + if result is None: + assert caught.value is failure + assert native.freed == [] + + +@pytest.mark.parametrize("native_context,valid", [ + ((0, 0, False, 0, False, False), True), + ((0, 5, True, 0, False, False), False), + ((1, 0, False, 0, False, False), False), + ((2, 1, True, 0, False, False), False), ]) -def test_trusted_vsi_sign_result_rejects_invalid_values( - signature, sequence, maximum, error, -): - with pytest.raises(error): - c2pa.TrustedVsiSignResult(signature, sequence, maximum) - - -def test_trusted_vsi_import_never_invokes_native_while_gated(monkeypatch): - functions = {} - - class NativeDeclarationsOnly: - def __getattr__(self, name): - if not (name.startswith("c2pa_live_video_trusted_vsi_") - or hasattr(bindings._lib, name)): - raise AttributeError(name) - return functions.setdefault(name, Mock( - side_effect=AssertionError("native call during gated import"))) - - monkeypatch.setattr(library_loader, "dynamically_load_library", - lambda _name: NativeDeclarationsOnly()) - spec = importlib.util.spec_from_file_location( - "c2pa._trusted_gate_test", bindings.__file__) - module = importlib.util.module_from_spec(spec) - monkeypatch.setitem(sys.modules, spec.name, module) - spec.loader.exec_module(module) - assert module._TRUSTED_VSI_CAPABILITIES == 0 - # Even a future native library advertising every bit cannot enable Python. - module._TRUSTED_VSI_CAPABILITIES = (1 << 6) - 1 - for name in c2pa.__all__: - if name.startswith("has_live_video_trusted_vsi_"): - assert getattr(module, name)() is False - for function in functions.values(): - function.assert_not_called() - - -class Uninspectable: - def __getattribute__(self, name): - raise AssertionError("argument inspected") - - def __len__(self): - raise AssertionError("argument length inspected") - - def __bool__(self): - raise AssertionError("argument truth inspected") - - def __bytes__(self): - raise AssertionError("argument converted") - - def __call__(self, *args, **kwargs): - raise AssertionError("callback invoked") - - -@pytest.mark.parametrize("operation,arity", [ - ("__init__", 9), ("from_callback", 9), - ("reserve_init_uuid", 1), ("finalize_init_uuid", 1), - ("commit_init_uuid", 0), ("sign_sig_structure", 1), - ("reserve_media_emsg_at", 3), ("finalize_media_emsg", 1), - ("recover", 2), ("status", 0), ("close", 0), - ("__enter__", 0), ("__exit__", 3), ("is_valid", 0), - ("_wrap_native_handle", 1), +def test_scripted_callback_context_validation(scripted, native_context, valid): + native, _, _, calls, _ = scripted + purpose, sequence, has_sequence, event, has_event, exhaust = native_context + result, _ = native.invoke(purpose, sequence, has_sequence, event, has_event, exhaust) + assert (result == 64) is valid + assert bool(calls) is valid + if valid: + assert calls[0][0] == c2pa.VsiSigningContextV1("signer_binding") + + +def test_scripted_native_error_without_callback_is_typed_and_output_absent(monkeypatch, scripted): + native, session, _, calls, _ = scripted + native.install(monkeypatch, "session_sign_sig_structure", lambda *args: -1) + monkeypatch.setattr(bindings, "_read_native_error", lambda: "NotSupported: mode-pinned") + with pytest.raises(c2pa.C2paError.NotSupported): + session.sign_sig_structure(b"x", 5) + assert calls == [] and native.freed == [] + + +def test_scripted_media_reservation_status_preflight_and_state(monkeypatch, scripted): + native, session, _, _, _ = scripted + def reserve(handle, sequence, iat, timescale, duration, output, context): + assert (sequence, iat, timescale, duration) == (4, IAT, 1000, 2000) + target = ctypes.cast(context, ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSigningContextV1)) + target[0] = bindings.C2paLiveVideoTrustedVsiSigningContextV1(1, 4, True, 1, True, True) + return native.output(output, b"emsg-box") + native.install(monkeypatch, "session_reserve_media_emsg", reserve) + reservation = session.reserve_media_emsg_at(4, IAT, 1000, 2000) + assert reservation.placeholder_emsg_box == b"emsg-box" + assert (reservation.sequence_number, reservation.event_id) == (4, 1) + assert reservation.signing_context.exhaust_after_sign is True + for args in ((4, IAT, 0, 1), (4, IAT, 1, 0), (2**32, IAT, 1, 1), (4, 2**63, 1, 1)): + with pytest.raises(ValueError): + session.reserve_media_emsg_at(*args) + + def status(handle, output): + target = ctypes.cast(output, ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiStatusV1)) + target[0] = bindings.C2paLiveVideoTrustedVsiStatusV1( + True, False, False, True, 9, True, 2, True, True, 2) + return 0 + native.install(monkeypatch, "session_status_v1", status) + assert session.status() == c2pa.TrustedVsiStatus(True, False, False, 9, 2, True, "event_id_max") + + def preflight(handle, operation, data, length, sequence, iat, timescale, duration, format): + assert (operation, data, length, format) == (OP.RESERVE_INIT, None, 0, b"video/mp4") + return 0 + native.install(monkeypatch, "session_preflight", preflight) + assert session.preflight(OP.RESERVE_INIT) is None + with pytest.raises(ValueError): + session.preflight(6) + + native.install(monkeypatch, "session_export_state", lambda handle, output: native.output(output, b'{"v":1}')) + native.install(monkeypatch, "session_import_state", + lambda handle, data, length: 0 if ctypes.string_at(data, length) == b'{"v":1}' else -1) + state = session.export_state() + assert state == b'{"v":1}' + session.import_state(state) + freed = len(native.freed) + assert freed == 2 # reservation + exported state; import borrows input + + +@pytest.mark.parametrize("kwargs,error", [ + ({"mode": "expert"}, ValueError), + ({"mode": "composed"}, ValueError), + ({"reservation_nonce": "A" * 32}, ValueError), + ({"reservation_nonce": "0" * 31}, ValueError), + ({"signing_time_unix_seconds": 1.5}, TypeError), + ({"sequence_max": 2}, ValueError), + ({"sequence_max": 2**32}, ValueError), ]) -def test_trusted_vsi_all_operations_gate_before_side_effects( - monkeypatch, operation, arity, -): - forbidden = Mock(side_effect=AssertionError("resource/native side effect")) - monkeypatch.setattr(bindings, "_lib", forbidden) - monkeypatch.setattr(bindings.ManagedResource, "__init__", forbidden) - monkeypatch.setattr(bindings.ManagedResource, "_cleanup_resources", forbidden) - monkeypatch.setattr(bindings, "record_owner_pid", forbidden) - monkeypatch.setattr(bindings.TrustedVsiPrehashedSession, "_init_attrs", forbidden) - session = object.__new__(c2pa.TrustedVsiPrehashedSession) - with pytest.raises(c2pa.C2paError.NotSupported, match="not enabled"): - if operation == "is_valid": - session.is_valid +def test_scripted_constructor_rejects_bad_options_before_native(monkeypatch, scripted, kwargs, error): + native, _, context, _, _ = scripted + count = len(native.calls) + options = dict(mode="expert_sig_structure", reservation_nonce="0" * 32, + signing_time_unix_seconds=IAT, sequence_max=None) + options.update(kwargs) + with pytest.raises(error): + c2pa.TrustedVsiSession(context, {"format": "video/mp4"}, "es256", b"k", b"kid", 3, + "2026-09-10T00:00:00Z", 86400, lambda *a: b"", **options) + assert len(native.calls) == count + + +def test_scripted_session_pins_callbacks_after_caller_context_close(monkeypatch, scripted): + native, session, context, calls, _ = scripted + context.close() + gc.collect() + assert session._context is context and session._trusted_vsi_callback is not None + assert native.invoke()[0] == 64 and len(calls) == 1 + session.close() + session.close() + assert session._trusted_vsi_callback is None and not session.is_valid + with pytest.raises(c2pa.C2paError): + session.export_state() + + +@pytest.mark.parametrize("which", ["claim", "dynamic", "vsi"]) +@pytest.mark.parametrize("make_error", CALLBACK_FAILURES) +def test_scripted_wrappers_store_base_exceptions_return_minus_one_and_reraise( + monkeypatch, which, make_error): + """Real ctypes wrappers + real session plumbing, scripted native calls.""" + error = make_error() + + def fail(*args): + raise error + certs = (FIXTURES / "es256_certs.pem").read_bytes() + if which == "claim": + signer = c2pa.Signer.from_callback(fail, c2pa.C2paSigningAlg.ES256, certs, None) + else: + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + if which == "dynamic": + signer.add_dynamic_assertion(fail, label="com.example.functional", reserve_size=64) + context = c2pa.Context(signer=signer) + native = _ScriptedNative(monkeypatch) + native.install(monkeypatch, "session_create_callback_v1", + lambda *args: setattr(native, "callback", args[-1]) or native.handle) + session = c2pa.TrustedVsiSession( + context, {"format": "video/mp4"}, "es256", b"k", b"kid", 1, + "2026-09-10T00:00:00Z", 86400, fail if which == "vsi" else (lambda *a: b"S" * 64), + mode="expert_sig_structure", reservation_nonce="0" * 32, signing_time_unix_seconds=IAT) + context.close() + gc.collect() + results = [] + + def finalize(handle, data, length, output): + buffer = (ctypes.c_ubyte * 64)() + if which == "claim": + tbs = (ctypes.c_ubyte * 3)(1, 2, 3) + results.append(session._signer_callback_cb(None, tbs, 3, buffer, 64)) + elif which == "dynamic": + callback = session._dynamic_assertion_cbs[0][0] + results.append(callback(None, b"com.example.functional", 64, b"[]", buffer, 64)) else: - getattr(session, operation)(*[Uninspectable() for _ in range(arity)]) - assert vars(session) == {} - session.__del__() - forbidden.assert_not_called() - assert forbidden.mock_calls == [] + results.append(native.invoke()[0]) + return -1 + native.install(monkeypatch, "session_finalize_init_uuid", finalize) + try: + with pytest.raises(type(error)) as caught: + session.finalize_init_uuid(b"hash") + assert caught.value is error + assert results == [-1] + assert native.freed == [] + finally: + session.close() + + +@pytest.mark.parametrize("fails", [False, True]) +def test_output_is_initialized_and_freed_once_even_on_copy_failure(monkeypatch, fails): + buffer = (ctypes.c_ubyte * 3)(1, 2, 3) + free = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", free) + def call(output): + pointer = ctypes.cast(output, ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))) + assert not pointer[0] + pointer[0] = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + return 3 + if fails: + monkeypatch.setattr(bindings.ctypes, "string_at", Mock(side_effect=MemoryError("copy"))) + with pytest.raises(MemoryError): + bindings._trusted_vsi_output(call) + else: + assert bindings._trusted_vsi_output(call) == b"\x01\x02\x03" + free.assert_called_once() -def test_trusted_vsi_ctypes_declarations_match_v1_native_abi(): - context_fields = bindings.C2paLiveVideoTrustedVsiSigningContextV1._fields_ - assert [name for name, _type in context_fields] == [ - "purpose", - "sequence_number", - "has_sequence_number", - "event_id", - "has_event_id", - "exhaust_after_sign", - ] - assert ctypes.sizeof(bindings.C2paLiveVideoTrustedVsiSigningContextV1) == 20 - assert ctypes.sizeof(bindings.C2paLiveVideoTrustedVsiStatusV1) == 24 +def _qualification_required(): + return "1" in (os.environ.get("C2PA_TRUSTED_VSI_ABI_REQUIRED"), + os.environ.get("C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED")) @pytest.fixture def paired_native(): - groups = ( - bindings._TRUSTED_VSI_CAPABILITIES_FUNCTIONS, - bindings._TRUSTED_VSI_CREATE_FUNCTIONS, - bindings._TRUSTED_VSI_SPLIT_INIT_FUNCTIONS, - bindings._TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS, - bindings._TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS, - bindings._TRUSTED_VSI_RECOVERY_FUNCTIONS, - bindings._TRUSTED_VSI_STATUS_FUNCTIONS, - ) - missing = [name for group in groups for name in group - if not hasattr(bindings._lib, name)] + """Require the complete functional native library. + + Under C2PA_TRUSTED_VSI_ABI_REQUIRED=1 (all paired qualification jobs) an + old/scaffold/partial library is a hard FAILURE, never a skip or a pass. + Outside qualification (e.g. an ad-hoc local run) it is reported as a skip; + legacy dev5 jobs deselect these tests with ``-k "not paired"``. + """ + missing = [name for name in bindings._TRUSTED_VSI_FUNCTIONS if not hasattr(bindings._lib, name)] + problems = [] if missing: - message = "paired trusted VSI ABI missing: " + ", ".join(missing) - if os.environ.get("C2PA_TRUSTED_VSI_ABI_REQUIRED") == "1": - pytest.fail(message) - pytest.skip(message + " (not paired qualification)") - - -def test_trusted_vsi_paired_expert_prototype_and_disabled_outputs(paired_native): - assert not hasattr(bindings._lib, - "c2pa_live_video_trusted_vsi_session_sign_emsg_sig_structure") - assert bindings._lib.c2pa_live_video_trusted_vsi_capabilities() == 0 - sign = bindings._lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure - assert sign.restype is ctypes.c_int64 - assert sign.argtypes == [ - ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession), - ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), - ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), - ctypes.POINTER(ctypes.c_bool), - ] - sentinel = ctypes.c_ubyte(7) - output = ctypes.pointer(sentinel) - sequence = ctypes.c_uint32(42) - maximum = ctypes.c_uint32(43) - has_maximum = ctypes.c_bool(True) - assert sign(None, None, 0, ctypes.byref(output), ctypes.byref(sequence), - ctypes.byref(maximum), ctypes.byref(has_maximum)) == -1 - assert not output - assert sequence.value == maximum.value == 0 - assert has_maximum.value is False - - -def test_trusted_vsi_paired_composed_prototype_matches_native_abi(paired_native): - reserve = bindings._lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg - assert reserve.argtypes[-1] == ctypes.POINTER( - bindings.C2paLiveVideoTrustedVsiSigningContextV1 - ) - - -def test_trusted_vsi_paired_status_prototype_matches_native_abi(paired_native): - status = bindings._lib.c2pa_live_video_trusted_vsi_session_status_v1 - assert status.restype is ctypes.c_int - assert status.argtypes[-1] == ctypes.POINTER( - bindings.C2paLiveVideoTrustedVsiStatusV1 - ) + problems.append("missing symbols: " + ", ".join(missing)) + if c2pa.sdk_version() != "0.91.0-dev": + problems.append(f"native version {c2pa.sdk_version()!r} != '0.91.0-dev'") + if not missing: + mask = int(bindings._lib.c2pa_live_video_trusted_vsi_capabilities()) + if mask != 63: + problems.append(f"capability mask {mask} != 63") + if not problems and not all(probe() for probe in PROBES): + problems.append("Python capability probes are not all true") + if problems: + message = "Functional trusted VSI native unavailable: " + "; ".join(problems) + if _qualification_required(): + pytest.fail(message + " (required qualification; never skipped)") + pytest.skip(message + " (not a qualification run)") + installed = os.environ.get("C2PA_FUNCTIONAL_INSTALLED_ROOT") + if installed: + root = Path(installed).resolve() + assert Path(c2pa.__file__).resolve().is_relative_to(root) + assert Path(bindings._lib._name).resolve().is_relative_to(root) + assert c2pa.__version__ == os.environ["C2PA_FUNCTIONAL_EXPECTED_VERSION"] + assert "C2PA_LIBRARY_NAME" not in os.environ and "PYTHONPATH" not in os.environ + + +@pytest.fixture +def sessions(paired_native): + resources = [] + def create(mode="expert_sig_structure", algorithm="ed25519", minimum=1, + maximum=None, callback=None, claim_callback=None, dynamic=None, + public_cose_key=None, **overrides): + kid = b"functional-python-session" + if algorithm == "ed25519": + key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + cose = {1: 1, 2: kid, 3: -8, -1: 6, -2: public} + sign = key.sign + else: + key = ec.derive_private_key(7, ec.SECP256R1()) + public = key.public_key().public_numbers() + cose = {1: 2, 2: kid, 3: -7, -1: 1, -2: public.x.to_bytes(32, "big"), -3: public.y.to_bytes(32, "big")} + def sign(data): + r, s = decode_dss_signature(key.sign(data, ec.ECDSA(hashes.SHA256()))) + return r.to_bytes(32, "big") + s.to_bytes(32, "big") + calls = [] + def signing(context, data): + calls.append((context, data)) + return callback(context, data) if callback else sign(data) + certs = (FIXTURES / "es256_certs.pem").read_bytes() + if claim_callback is None: + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + else: + signer = c2pa.Signer.from_callback(claim_callback, c2pa.C2paSigningAlg.ES256, certs, None) + resources.append(signer) + if dynamic: + signer.add_dynamic_assertion(dynamic, label="com.example.functional", reserve_size=64) + config = json.loads((Path(__file__).parent / "trust_config_test_settings.json").read_text()) + config["builder"] = {"thumbnail": {"enabled": False}} + context = c2pa.Context.from_dict(config, signer=signer) + resources.append(context) + assert not signer.is_valid + options = dict(mode=mode, reservation_nonce="0123456789abcdef0123456789abcdef", + signing_time_unix_seconds=IAT, sequence_max=maximum) + options.update(overrides) + session = c2pa.TrustedVsiSession.from_callback( + context, + {"claim_version": 2, "format": "video/mp4", "assertions": [{"label": "c2pa.actions", "data": { + "actions": [{"action": "c2pa.created", "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty"}] + }}]}, algorithm, + public_cose_key if public_cose_key is not None else cbor2.dumps(cose, canonical=True), kid, + minimum, "2026-09-10T00:00:00Z", 86400, signing, **options) + resources.append(session) + assert calls == [] + return session, calls, key, context + yield create + for resource in reversed(resources): + resource.close() + + +def _sig_structure(algorithm="ed25519", payload=b"opaque, not a SegmentInfoMap"): + protected = cbor2.dumps({1: -8 if algorithm == "ed25519" else -7, "iat": IAT}, canonical=True) + return cbor2.dumps(["Signature1", protected, b"", payload], canonical=True) + + +def _boxes(data): + offset = 0 + while offset < len(data): + size = int.from_bytes(data[offset:offset + 4], "big") + if size == 1: + size = int.from_bytes(data[offset + 8:offset + 16], "big") + if size == 0: + size = len(data) - offset + assert size >= 8 and offset + size <= len(data) + yield offset, data[offset + 4:offset + 8], data[offset:offset + size] + offset += size + + +def _hash_input(kind, asset): + """Trusted-processor side: hash FINAL-placement bytes (reserved box installed). + + C2PA BMFF v2+/v3 hashing inserts each included top-level box's big-endian + uint64 offset before its bytes; fully excluded boxes contribute nothing. + Test assets contain exactly one C2PA UUID/EMSG, which is the exclusion. + """ + template = cbor2.loads(c2pa.trusted_vsi_hash_template(kind)) + hasher = hashlib.sha256() + excluded = b"uuid" if kind == KIND.INIT_HASH else b"emsg" + for offset, box_type, box in _boxes(asset): + if box_type != excluded: + hasher.update(offset.to_bytes(8, "big")) + hasher.update(box) + template["hash"] = hasher.digest() + return cbor2.dumps(template, canonical=True) + + +def _place(segment, box): + """Install a reserved box after a leading ftyp/styp, else at the front.""" + first = next(_boxes(segment)) + if first[1] in (b"ftyp", b"styp"): + split = len(first[2]) + return segment[:split] + box + segment[split:] + return box + segment + + +def _unsigned_init(): + return b"".join(box for _, kind, box in _boxes((FIXTURES / "dashinit.mp4").read_bytes()) + if kind in (b"ftyp", b"moov")) + + +def _unsigned_media(): + return b"".join(box for _, kind, box in _boxes((FIXTURES / "dash1.m4s").read_bytes()) + if kind not in (b"uuid", b"emsg")) + + +def _init(session): + raw = _unsigned_init() + reservation = session.reserve_init_uuid() + canonical = _hash_input(KIND.INIT_HASH, _place(raw, reservation)) + final = session.finalize_init_uuid(canonical) + assert len(final) == len(reservation) + assert final[4:8] == b"uuid" + # Placeholder replacement is an exact in-place substitution. + signed = _place(raw, final) + assert len(signed) == len(_place(raw, reservation)) + session.commit_init_uuid() + return signed, canonical + + +def test_paired_ctypes_exact_functional_contract_and_error_outputs(paired_native): + lib = bindings._lib + session = ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession) + byte = ctypes.POINTER(ctypes.c_ubyte) + output = ctypes.POINTER(byte) + assert lib.c2pa_live_video_trusted_vsi_session_create_callback_v1.argtypes == [ + ctypes.POINTER(bindings.C2paContext), ctypes.c_char_p, ctypes.c_int, byte, ctypes.c_size_t, + byte, ctypes.c_size_t, ctypes.c_uint64, ctypes.c_char_p, ctypes.c_uint64, + ctypes.c_char_p, ctypes.c_void_p, bindings.TrustedVsiSignCallbackV1] + assert lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure.argtypes == [session, byte, ctypes.c_size_t, ctypes.c_uint32, output] + assert lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg.argtypes == [ + session, ctypes.c_uint32, ctypes.c_int64, ctypes.c_uint32, ctypes.c_uint32, + output, ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSigningContextV1)] + assert lib.c2pa_live_video_trusted_vsi_session_preflight.argtypes == [ + session, ctypes.c_uint32, byte, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_int64, + ctypes.c_uint32, ctypes.c_uint32, ctypes.c_char_p] + sentinel = ctypes.c_ubyte(42) + ptr = ctypes.pointer(sentinel) + assert lib.c2pa_live_video_trusted_vsi_session_sign_sig_structure(None, None, 0, 7, ctypes.byref(ptr)) == -1 + assert not ptr + context = bindings.C2paLiveVideoTrustedVsiSigningContextV1(1, 2, True, 3, True, True) + ptr = ctypes.pointer(sentinel) + assert lib.c2pa_live_video_trusted_vsi_session_reserve_media_emsg(None, 7, IAT, 1, 1, ctypes.byref(ptr), ctypes.byref(context)) == -1 + assert not ptr and bytes(context) == bytes(ctypes.sizeof(context)) + + +@pytest.mark.parametrize("algorithm", ["ed25519", "es256"]) +def test_paired_expert_exact_bytes_supplied_sequence_retry_and_no_counter(sessions, algorithm): + session, calls, key, context = sessions(algorithm=algorithm) + asset, _ = _init(session) + with c2pa.Reader("video/mp4", io.BytesIO(asset), context=context) as reader: + assert reader.get_validation_state() == "Trusted" + assert reader.get_validation_results()["activeManifest"]["failure"] == [] + before = session.export_state() + original = _sig_structure(algorithm) + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, algorithm, original) + for sequence in (1, 37, 2**32 - 1, 1): + session.preflight(OP.EXPERT_SIGN, original, sequence_number=sequence) + signature = session.sign_sig_structure(original, sequence) + assert isinstance(signature, bytes) and len(signature) == 64 + if algorithm == "ed25519": + key.public_key().verify(signature, original) + else: + der = encode_dss_signature(int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")) + key.public_key().verify(der, original, ec.ECDSA(hashes.SHA256())) + assert calls[-1] == (c2pa.VsiSigningContextV1("vsi", sequence), original) + assert session.export_state() == before + status = session.status() + assert status.init_uuid_committed and status.next_sequence_number is None + assert status.next_event_id is None and not status.exhausted and status.exhaustion_reason is None + context.close() + gc.collect() + assert len(session.sign_sig_structure(original, 2)) == 64 + + +def test_paired_init_pending_export_import_preflight_and_identical_replay(sessions): + first, calls, _, _ = sessions() + new = first.export_state() + first.preflight(OP.RESERVE_INIT) + assert new == first.export_state() and calls == [] + reserved = first.reserve_init_uuid() + assert first.reserve_init_uuid() == reserved and calls == [] + pending = first.export_state() + restored, restored_calls, _, context = sessions() + restored.import_state(pending) + assert restored_calls == [] and restored.reserve_init_uuid() == reserved + context.close() + gc.collect() + canonical = c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) + restored.preflight(OP.FINALIZE_INIT, canonical) + assert restored.export_state() == pending and restored_calls == [] + final = restored.finalize_init_uuid(canonical) + count = len(restored_calls) + assert len(final) == len(reserved) + assert restored.finalize_init_uuid(canonical) == final + assert len(restored_calls) == count + modified = cbor2.loads(canonical) + modified["hash"] = b"X" * 32 + with pytest.raises(c2pa.C2paError): + restored.finalize_init_uuid(cbor2.dumps(modified, canonical=True)) + final_state = restored.export_state() + committed, replay_calls, _, _ = sessions() + committed.import_state(final_state) + committed.preflight(OP.COMMIT_INIT) + committed.commit_init_uuid() + assert replay_calls == [] and committed.status().init_uuid_committed + + +def test_paired_composed_pending_import_and_uint32_exhaustion(sessions): + session, calls, _, _ = sessions(mode="signer_composed_emsg", minimum=2**32 - 1) + _init(session) + before = session.export_state() + count = len(calls) + session.preflight(OP.RESERVE_MEDIA, sequence_number=2**32 - 1, iat=IAT, timescale=1000, event_duration=2000) + assert session.export_state() == before and len(calls) == count + reserved = session.reserve_media_emsg_at(2**32 - 1, IAT, 1000, 2000) + assert len(calls) == count + assert reserved.signing_context == c2pa.VsiSigningContextV1("vsi", 2**32 - 1, 1, True) + pending = session.export_state() + restored, restored_calls, _, context = sessions(mode="signer_composed_emsg", minimum=2**32 - 1) + restored.import_state(pending) + context.close() + assert restored_calls == [] + assert restored.reserve_media_emsg_at(2**32 - 1, IAT, 1000, 2000) == reserved + canonical = c2pa.trusted_vsi_hash_template(KIND.MEDIA_HASH) + restored.preflight(OP.FINALIZE_MEDIA, canonical) + assert restored.export_state() == pending and restored_calls == [] + final = restored.finalize_media_emsg(canonical) + assert final[4:8] == b"emsg" and len(final) == len(reserved.placeholder_emsg_box) + assert restored_calls[-1][0] == reserved.signing_context + assert restored.status().exhausted and restored.status().exhaustion_reason == "sequence_max" + with pytest.raises(c2pa.C2paError): + restored.reserve_media_emsg_at(0, IAT, 1000, 2000) + + +def _parse_vsi_emsg(box): + """Parse an ISO/IEC 23009-1 version-0 EMSG carrying a C2PA VSI COSE_Sign1.""" + assert box[4:8] == b"emsg" and box[8] == 0 + position = 12 + fields = [] + for _ in range(2): + end = box.index(b"\0", position) + fields.append(box[position:end]) + position = end + 1 + timescale, delta, duration, event_id = ( + int.from_bytes(box[position + 4 * i:position + 4 * i + 4], "big") for i in range(4)) + cose = cbor2.loads(box[position + 16:]) + return fields, (timescale, delta, duration, event_id), cose + + +def test_paired_composed_real_fragment_native_verification(sessions): + """Native-verified init plus independent verification of the composed EMSG. + + The Python SDK exposes no live-video segment validator (LiveVideoValidator is + Rust-only). ``Reader.from_fragmented_files`` is NOT applicable: it implements + the Merkle fragmented-BMFF model and rejects any init bmff hash that carries a + top-level ``hash`` ("Hash value should not be present for a fragmented BMFF + asset"), which C2PA 2.4 section 19.3 live-video init manifests always carry, + including those from the shipped complete-buffer ``LiveVideoVsiSession``. + """ + raw = _unsigned_media() + sequence = c2pa.moof_sequence_number(raw) + session, calls, key, context = sessions(mode="signer_composed_emsg", minimum=sequence) + init, _ = _init(session) + manifest_id = session.reserved_manifest_id() + with c2pa.Reader("video/mp4", io.BytesIO(init), context=context) as reader: + assert reader.get_validation_results()["activeManifest"]["failure"] == [] + assert reader.get_validation_state() == "Trusted" + assert json.loads(reader.json())["active_manifest"] == manifest_id + + reserved = session.reserve_media_emsg_at(sequence, IAT, 1000, 2000) + assert (reserved.sequence_number, reserved.event_id) == (sequence, 1) + canonical = _hash_input(KIND.MEDIA_HASH, _place(raw, reserved.placeholder_emsg_box)) + final = session.finalize_media_emsg(canonical) + assert len(final) == len(reserved.placeholder_emsg_box) + signed_media = _place(raw, final) + # The EMSG is excluded, so the final-placement hash equals the reserved one. + assert _hash_input(KIND.MEDIA_HASH, signed_media) == canonical + assert calls[-1][0] == reserved.signing_context + + (scheme, value), timing, cose = _parse_vsi_emsg(final) + assert scheme == b"urn:c2pa:verifiable-segment-info" + assert timing == (1000, 0, 2000, 1) + assert cose.tag == 18 and len(cose.value) == 4 + protected, unprotected, payload, signature = cose.value + assert cbor2.loads(protected) == {1: -8, "iat": IAT} + assert unprotected == {4: b"functional-python-session"} + info = cbor2.loads(payload) + assert info["sequenceNumber"] == sequence + assert info["manifestId"] == manifest_id + assert cbor2.dumps(info["bmffHash"], canonical=True) == canonical + sig_structure = cbor2.dumps(["Signature1", protected, b"", payload]) + assert calls[-1][1] == sig_structure + key.public_key().verify(signature, sig_structure) + assert value # non-empty EMSG value per native framing + + +@pytest.mark.parametrize("data", [ + b"garbage", b"\x9f\xff", _sig_structure() + b"\x00", + cbor2.dumps(["Signature1", b"\xa2\x01\x27\x01\x27", b"", b"opaque"]), + cbor2.dumps(["Signature1", b"\xa1\x01\x38\x07", b"", b"opaque"]), + cbor2.dumps(["Signature1", b"\xa1\x01\x27", b"aad", b"opaque"]), + _sig_structure("es256"), +]) +def test_paired_invalid_expert_framing_before_callback_and_no_mutation(sessions, data): + session, calls, _, _ = sessions() + _init(session) + count, state = len(calls), session.export_state() + with pytest.raises(c2pa.C2paError): + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", data) + with pytest.raises(c2pa.C2paError): + session.preflight(OP.EXPERT_SIGN, data, sequence_number=1) + with pytest.raises(c2pa.C2paError): + session.sign_sig_structure(data, 1) + assert len(calls) == count and session.export_state() == state + + +def test_paired_mode_and_sequence_limits_precede_callback(sessions): + expert, calls, _, _ = sessions(minimum=7, maximum=9) + _init(expert) + count = len(calls) + for value in (6, 10): + with pytest.raises(c2pa.C2paError): + expert.sign_sig_structure(_sig_structure(), value) + for value in (-1, 2**32, True, 1.0): + with pytest.raises((TypeError, ValueError)): + expert.sign_sig_structure(_sig_structure(), value) + with pytest.raises(c2pa.C2paError): + expert.reserve_media_emsg_at(7, IAT, 1, 1) + assert len(calls) == count + composed, calls, _, _ = sessions(mode="signer_composed_emsg") + _init(composed) + count = len(calls) + with pytest.raises(c2pa.C2paError): + composed.sign_sig_structure(_sig_structure(), 1) + with pytest.raises(c2pa.C2paError): + composed.reserve_media_emsg_at(2, IAT, 1, 1) + assert len(calls) == count + + +def test_paired_callback_exception_identity_blocked_state_and_durable_retry(sessions): + error = RuntimeError("provider lost response") + def callback(context, data): + if context.purpose == "vsi": + raise error + return ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32).sign(data) + session, calls, _, _ = sessions(callback=callback) + _init(session) + before = session.export_state() + with pytest.raises(RuntimeError) as caught: + session.sign_sig_structure(_sig_structure(), 1) + assert caught.value is error + assert session.export_state() != before + count = len(calls) + with pytest.raises(c2pa.C2paError): + session.sign_sig_structure(_sig_structure(), 1) + assert len(calls) == count + restored, _, _, _ = sessions() + restored.import_state(before) + assert len(restored.sign_sig_structure(_sig_structure(), 1)) == 64 + + + + +@pytest.mark.parametrize("scenario", ["direct", "context_closed", "imported"]) +@pytest.mark.parametrize("which", ["claim", "dynamic"]) +@pytest.mark.parametrize("make_error", CALLBACK_FAILURES) +def test_paired_claim_and_da_errors_keep_identity_and_block(sessions, which, scenario, make_error): + error = make_error() + failures = [] + + def fail(*args): + failures.append(args) + raise error + failing = {"claim_callback": fail} if which == "claim" else {"dynamic": fail} + if scenario == "imported": + # Pending state from a healthy session with the same claim certificate and + # DynamicAssertion declaration (label/reserve size), finalized by a NEW + # session whose Context signer/DA fails and whose Context is closed. + # Declarations must be identical (native v2 state pins the claim + # signer's reserve size and ordered DA label/reserve size), so the + # healthy claim signer is also a from_callback signer with the same + # certificate; only its signing behavior differs. + healthy = ({"claim_callback": _fixture_claim_signature} if which == "claim" + else {"dynamic": _exact_dynamic_assertion}) + original, _, _, _ = sessions(**healthy) + original.reserve_init_uuid() + pending = original.export_state() + session, calls, _, context = sessions(**failing) + session.import_state(pending) + else: + session, calls, _, context = sessions(**failing) + session.reserve_init_uuid() + if scenario != "direct": + context.close() + gc.collect() + canonical = c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) + with pytest.raises(type(error)) as caught: + session.finalize_init_uuid(canonical) + assert caught.value is error + assert len(failures) == 1 + # The external signing attempt blocks the local session: no retry, no + # further callback, and init is never committed. + vsi_calls = len(calls) + with pytest.raises(c2pa.C2paError): + session.finalize_init_uuid(canonical) + with pytest.raises(c2pa.C2paError): + session.commit_init_uuid() + assert len(failures) == 1 and len(calls) == vsi_calls + assert not session.status().init_uuid_committed + + +@pytest.mark.parametrize("reserving,importing", [ + pytest.param({}, {"claim_callback": "callback"}, id="claim-reserve-size-2361-vs-11836"), + pytest.param({"claim_callback": "callback"}, {}, id="claim-reserve-size-11836-vs-2361"), + pytest.param({}, {"dynamic": [("com.example.a", 64)]}, id="da-added"), + pytest.param({"dynamic": [("com.example.a", 64)]}, {}, id="da-removed"), + pytest.param({"dynamic": [("com.example.a", 64)]}, {"dynamic": [("com.example.b", 64)]}, id="da-label"), + pytest.param({"dynamic": [("com.example.a", 64)]}, {"dynamic": [("com.example.a", 96)]}, id="da-reserve-size"), + pytest.param({"dynamic": [("com.example.a", 64), ("com.example.b", 64)]}, + {"dynamic": [("com.example.b", 64), ("com.example.a", 64)]}, id="da-order"), +]) +def test_paired_import_rejects_mismatched_claim_and_da_declarations(paired_native, reserving, importing): + """v2 identity pins claim reserve size and ordered DA declarations. + + A mismatch must be rejected by import_state itself, before mutation and + without invoking the VSI session key, claim signer, or any DA callback. + """ + certs = (FIXTURES / "es256_certs.pem").read_bytes() + touched = [] + resources = [] + + def build(declarations, record): + def claim(data): + touched.append(("claim", record)) + return _fixture_claim_signature(data) + if declarations.get("claim_callback"): + signer = c2pa.Signer.from_callback(claim, c2pa.C2paSigningAlg.ES256, certs, None) + else: + signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=certs, + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + for label, size in declarations.get("dynamic", ()): + def dynamic(*args, label=label): + touched.append(("dynamic", record, label)) + return _exact_dynamic_assertion(*args) + signer.add_dynamic_assertion(dynamic, label=label, reserve_size=size) + context = c2pa.Context(signer=signer) + resources.append(context) + key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + kid = b"functional-python-session" + + def vsi(ctx, data): + touched.append(("vsi", record)) + return key.sign(data) + session = c2pa.TrustedVsiSession( + context, {"claim_version": 2, "format": "video/mp4"}, "ed25519", + cbor2.dumps({1: 1, 2: kid, 3: -8, -1: 6, -2: public}, canonical=True), kid, + 1, "2026-09-10T00:00:00Z", 86400, vsi, mode="expert_sig_structure", + reservation_nonce="0123456789abcdef0123456789abcdef", signing_time_unix_seconds=IAT) + resources.append(session) + return session + + try: + reserving_session = build(reserving, "reserving") + reserving_session.reserve_init_uuid() + record = reserving_session.export_state() + state = json.loads(record) + assert (state["format"], state["version"]) == ("c2pa.trusted-vsi.state", 2) + assert [(d["label"], d["reserve_size"]) for d in state["identity"]["dynamic_assertions"]] == \ + list(reserving.get("dynamic", ())) + importing_session = build(importing, "importing") + fresh = importing_session.export_state() + with pytest.raises(c2pa.C2paError, match="identity does not match"): + importing_session.import_state(record) + assert importing_session.export_state() == fresh + status = importing_session.status() + assert not status.init_uuid_pending and not status.init_uuid_committed + assert touched == [] + # Still a usable New session: its own reservation works afterwards. + importing_session.reserve_init_uuid() + assert touched == [] + finally: + for resource in reversed(resources): + resource.close() + + +def test_paired_import_rejects_mismatched_mode_options_and_corruption(sessions): + session, _, _, _ = sessions() + session.reserve_init_uuid() + state = session.export_state() + for options in ({"mode": "signer_composed_emsg"}, {"reservation_nonce": "a" * 32}): + other, calls, _, _ = sessions(**options) + before = other.export_state() + with pytest.raises(c2pa.C2paError): + other.import_state(state) + assert other.export_state() == before and calls == [] + other, calls, _, _ = sessions() + with pytest.raises(c2pa.C2paError): + other.import_state(state[:-1]) + assert calls == [] + session.close() + session.close() + assert not session.is_valid + with pytest.raises(c2pa.C2paError): + session.export_state() + + +def test_paired_invalid_public_key_and_algorithm_fail_before_callback(sessions): + callback = Mock(side_effect=AssertionError("key used before validation")) + for key in (b"invalid", cbor2.dumps({1: 1, 2: b"functional-python-session", 3: -8, + -1: 6, -2: b"X" * 32, -4: b"secret" * 6}, canonical=True)): + with pytest.raises(c2pa.C2paError): + sessions(public_cose_key=key, callback=callback) + with pytest.raises(ValueError): + sessions(algorithm="ps256", callback=callback) + callback.assert_not_called() + + +@pytest.mark.parametrize("signature,error", [(b"bad", ValueError), (bytearray(64), TypeError), (bytes(64), c2pa.C2paError)]) +def test_paired_invalid_signatures_preserve_errors_and_block_session(sessions, signature, error): + session, calls, _, _ = sessions(callback=lambda *_: signature) + session.reserve_init_uuid() + with pytest.raises(error): + session.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + assert len(calls) == 1 + with pytest.raises(c2pa.C2paError): + session.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + assert len(calls) == 1 + + +def test_paired_claim_and_dynamic_callbacks_survive_pending_import_and_close(sessions): + claim_calls, da_calls = [], [] + key = serialization.load_pem_private_key((FIXTURES / "es256_private.key").read_bytes(), password=None) + def claim(data): + claim_calls.append(data) + return key.sign(data, ec.ECDSA(hashes.SHA256())) + def dynamic(label, size, partial_claim): + da_calls.append((label, size, partial_claim)) + return b"\xa1\x63pad\x58\x39" + b"X" * 57 + original, _, _, context = sessions(claim_callback=claim, dynamic=dynamic) + reservation = original.reserve_init_uuid() + pending = original.export_state() + assert claim_calls == da_calls == [] + restored, _, _, restored_context = sessions(claim_callback=claim, dynamic=dynamic) + restored.import_state(pending) + context.close() + restored_context.close() + gc.collect() + final = restored.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + assert len(final) == len(reservation) + assert len(da_calls) == 1 and len(claim_calls) >= 1 + assert da_calls[0][0:2] == ("com.example.functional", 64) diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py new file mode 100644 index 00000000..4d60615c --- /dev/null +++ b/tests/test_trusted_vsi_build.py @@ -0,0 +1,71 @@ +"""Functional build identity is separate from immutable dev5 release inputs.""" + +import importlib.util +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "functional_build", ROOT / "scripts/build_trusted_vsi_functional.py") +functional_build = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(functional_build) + + +@pytest.mark.parametrize("version", ["0.37.9.dev0", "0.38.0.dev1", "1.0.dev0+local"]) +def test_new_functional_development_version(version): + assert functional_build.functional_version(version) == version + + +@pytest.mark.parametrize("version", [ + "0.37.8.dev5", "0.37.8.dev6", "0.37.8.dev5+functional", "0.37.7.dev1", + "0.37.9", "0.38.0rc1", "invalid", +]) +def test_functional_build_rejects_release_or_dev5_identity(version): + with pytest.raises(ValueError): + functional_build.functional_version(version) + + +def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): + import subprocess + import sys + import tarfile + + before = {name: (ROOT / name).read_bytes() for name in ("pyproject.toml", "src/c2pa/c2pa.py")} + stage = tmp_path / "stage" + stage.mkdir() + functional_build.stage_source(stage, "0.37.9.dev0") + assert 'version = "0.37.9.dev0"' in (stage / "pyproject.toml").read_text() + assert "# Version: 0.37.9.dev0" 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) + (sdist,) = (tmp_path / "dist").iterdir() + assert sdist.name == "c2pa_python-0.37.9.dev0.tar.gz" + with tarfile.open(sdist) as archive: + names = archive.getnames() + info = archive.extractfile("c2pa_python-0.37.9.dev0/PKG-INFO").read().decode() + assert "Version: 0.37.9.dev0" in info + for member in ("scripts/build_trusted_vsi_functional.py", + "scripts/qualify_trusted_vsi_functional.py", + "docs/trusted-vsi-python-contract.md", "src/c2pa/c2pa.py"): + assert f"c2pa_python-0.37.9.dev0/{member}" in names + assert not any(name.endswith((".so", ".dll", ".dylib")) for name in names) + after = {name: (ROOT / name).read_bytes() for name in before} + assert after == before + assert 'version = "0.37.8.dev5"' in before["pyproject.toml"].decode() + + +def test_probe_script_requires_every_functional_capability(): + assert len(functional_build.PROBES) == 6 + for name in functional_build.PROBES: + assert f"has_live_video_trusted_vsi_{name}()" in functional_build.PROBE_SCRIPT + + +def test_functional_installed_qualification_removes_source_overrides(): + source = (ROOT / "scripts/qualify_trusted_vsi_functional.py").read_text() + assert '"PYTHONPATH", "C2PA_LIBRARY_NAME"' in source + assert 'C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED="1"' in source + assert 'C2PA_FUNCTIONAL_INSTALLED_ROOT=str(environment)' in source + assert "timeout=480" in source diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index fea79f54..6eb10bbd 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -11,7 +11,9 @@ # specific language governing permissions and limitations under # each license. +import asyncio import gc +import hashlib import inspect import os import io @@ -64,7 +66,7 @@ def load_test_settings_json(): """ - Load default (legacy) trust configuration test settings from a + Load purpose-tagged trust configuration test settings from a JSON config file and return its content as JSON-compatible dict. The return value is used to load settings (thread_local) in tests. @@ -3508,7 +3510,7 @@ def test_builder_add_multiple_ingredients(self): # Test adding another ingredient ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3528,7 +3530,7 @@ def test_builder_add_multiple_ingredients_2(self): # Test adding another ingredient with a JSON string ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3557,7 +3559,7 @@ def test_builder_add_multiple_ingredients_and_resources(self): ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() @@ -3615,10 +3617,17 @@ def test_builder_add_multiple_ingredients_and_resources_interleaved(self): ingredient_json = '{"test": "ingredient2"}' with open(self.testPath2, 'rb') as f: - builder.add_ingredient(ingredient_json, "image/png", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) builder.close() + def test_builder_add_ingredient_rejects_mismatched_format(self): + # Unlike Reader autodetection, ingredient parsing requires the real MIME. + with Builder(self.manifestDefinition) as builder: + with open(self.testPath2, "rb") as source: + with self.assertRaisesRegex(Error, "invalid header"): + builder.add_ingredient({}, "image/png", source) + def test_builder_sign_with_ingredient(self): builder = Builder.from_json(self.manifestDefinition) assert builder._handle is not None @@ -6634,6 +6643,56 @@ def test_settings_update_dict(self): self.assertIs(result, settings) settings.close() + def test_typed_trust_fixture_preserves_legacy_memberships(self): + trust = load_test_settings_json()["trust"] + self.assertEqual(set(trust), {"anchors", "trust_config"}) + self.assertEqual( + {entry["trust_kind"] for entry in trust["anchors"]}, + {"manifest", "tsa"}, + ) + self.assertEqual(len(trust["anchors"]), 2) + # Digests pin the original nine-certificate bundle and EKU policy. + for entry in trust["anchors"]: + self.assertEqual(set(entry), {"trust_kind", "trust_uri", "trust_anchors"}) + self.assertEqual( + hashlib.sha256(entry["trust_anchors"].encode()).hexdigest(), + "f3de5e4ea3213319eedc5e3890f0ff615bf0e754323ffd20dcca8a3f1c5ab921", + ) + self.assertEqual( + hashlib.sha256(trust["trust_config"].encode()).hexdigest(), + "174983a609d76784c4ef5e2621740bf32fb615f88412d94f4fc26670365a9b81", + ) + + def test_settings_typed_trust_purposes_do_not_authorize_other_roles(self): + trust = load_test_settings_json()["trust"] + for kind in ("manifest", "cawg", "tsa"): + with self.subTest(kind=kind): + entry = dict(trust["anchors"][0], trust_kind=kind) + config = {"trust": {"anchors": [entry], "trust_config": trust["trust_config"]}} + with Settings() as settings: + self.assertIs(settings.update(config), settings) + with Context(settings) as ctx, Reader(DEFAULT_TEST_FILE, context=ctx) as reader: + self.assertEqual( + reader.get_validation_state(), + "Trusted" if kind == "manifest" else "Valid", + ) + + def test_settings_trust_updates_are_additive_and_removal_needs_fresh_context(self): + config = load_test_settings_json() + with Settings.from_dict(config) as settings, Context(settings) as original: + # Neither an empty update nor a changed entry with the same URI + # replaces the previously authorized certificates. + settings.update({"trust": {"anchors": []}}) + entries = [dict(entry, trust_anchors="") for entry in config["trust"]["anchors"]] + settings.update(json.dumps({"trust": {"anchors": entries}})) + with Context(settings) as updated: + with Settings.from_dict({"trust": {"anchors": []}}) as fresh: + with Context(fresh) as removed: + for ctx, expected in ((original, "Trusted"), (updated, "Trusted"), (removed, "Valid")): + with self.subTest(expected=expected, context=ctx): + with Reader(DEFAULT_TEST_FILE, context=ctx) as reader: + self.assertEqual(reader.get_validation_state(), expected) + def test_settings_is_valid_after_close(self): settings = Settings() settings.close() @@ -7088,6 +7147,47 @@ def callback(label, reserve_size, partial_claim): self.assertIs(raised.exception, failure) + def test_base_exceptions_from_callbacks_are_reraised_during_builder_signing(self): + # KeyboardInterrupt/SystemExit/CancelledError must not escape into + # ctypes (where they are ignored); the original object is re-raised. + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + certs = f.read() + for make in (lambda: KeyboardInterrupt("stop"), lambda: SystemExit(3), + lambda: asyncio.CancelledError("cancelled")): + for which in ("dynamic", "claim"): + failure = make() + with self.subTest(error=type(failure).__name__, callback=which): + def fail(*_): + raise failure + if which == "claim": + signer = Signer.from_callback(fail, SigningAlg.ES256, certs, None) + else: + signer = self._make_signer() + signer.add_dynamic_assertion( + fail, label="com.example.interrupt", reserve_size=64) + self.addCleanup(signer.close) + builder = Builder(self.test_manifest) + with open(DEFAULT_TEST_FILE, "rb") as source: + with self.assertRaises(type(failure)) as raised: + builder.sign(signer, "image/jpeg", source, io.BytesIO()) + self.assertIs(raised.exception, failure) + + def test_ordinary_claim_signer_exception_is_still_reported_as_c2pa_error(self): + # Pre-existing Builder behavior for Exception subclasses is unchanged. + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + certs = f.read() + + def fail(_): + raise RuntimeError("remote signer unavailable") + signer = Signer.from_callback(fail, SigningAlg.ES256, certs, None) + self.addCleanup(signer.close) + builder = Builder(self.test_manifest) + with open(DEFAULT_TEST_FILE, "rb") as source: + with self.assertRaises(Error) as raised: + builder.sign(signer, "image/jpeg", source, io.BytesIO()) + self.assertNotIsInstance(raised.exception, RuntimeError) + self.assertIsInstance(signer._callback_cb._error_state.exception, RuntimeError) + def test_closed_and_uninitialized_resources(self): signer = self._make_signer() signer.close() @@ -7819,6 +7919,23 @@ def callback(*_): self.assertIsNone(session.active_manifest_id) self.assertEqual(session.next_sequence_number, 1) + def test_callback_base_exceptions_keep_identity(self): + context = self._make_context() + self.addCleanup(context.close) + private_key, kid, created_at, _ = self._callback_session_material() + for failure in (KeyboardInterrupt("stop"), SystemExit(3), + asyncio.CancelledError("cancelled")): + with self.subTest(error=type(failure).__name__): + def callback(*_): + raise failure + with self._make_callback_session( + context, callback, private_key, kid, created_at, + ) as session: + with self.assertRaises(type(failure)) as raised: + session.sign_init_segment(self.init_segment) + self.assertIs(raised.exception, failure) + self.assertIsNone(session.active_manifest_id) + def test_callback_recovery_does_not_sign_and_resumes(self): context = self._make_context() self.addCleanup(context.close) @@ -8083,6 +8200,36 @@ class CallbackFailure(RuntimeError): session.sign_init_segment(self.init_segment) self.assertIs(raised.exception, failure) + def test_dynamic_assertion_and_claim_base_exceptions_during_init_signing(self): + with open(os.path.join(FIXTURES_DIR, "es256_certs.pem"), "rb") as f: + certs = f.read() + with open(os.path.join(FIXTURES_DIR, "es256_private.key"), "rb") as f: + key = f.read() + for which in ("dynamic", "claim"): + for failure in (KeyboardInterrupt("stop"), SystemExit(3), + asyncio.CancelledError("cancelled")): + with self.subTest(callback=which, error=type(failure).__name__): + def fail(*_): + raise failure + if which == "claim": + signer = Signer.from_callback(fail, SigningAlg.ES256, certs, None) + else: + signer = Signer.from_info(C2paSignerInfo(b"es256", certs, key, None)) + signer.add_dynamic_assertion( + fail, label="com.example.live-interrupt", reserve_size=64) + settings = Settings() + settings.set("verify.verify_trust", "false") + try: + context = Context(settings=settings, signer=signer) + finally: + settings.close() + self.addCleanup(context.close) + with self._make_session(context) as session: + context.close() + with self.assertRaises(type(failure)) as raised: + session.sign_init_segment(self.init_segment) + self.assertIs(raised.exception, failure) + class TestReaderWithContext(TestContextAPIs): diff --git a/tests/trust_config_test_settings.json b/tests/trust_config_test_settings.json index 00fe1815..b2410257 100644 --- a/tests/trust_config_test_settings.json +++ b/tests/trust_config_test_settings.json @@ -1,7 +1,18 @@ { "version": 1, "trust": { - "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\nd3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg\nRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV\nUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu\nY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG\nSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y\nithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If\nxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV\nySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO\nDCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ\njdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/\nCNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi\nEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM\nfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY\nuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK\nchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t\n9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD\nggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2\nSV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd\n+SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc\nfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa\nsjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N\ncCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N\n0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie\n4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI\nr/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1\n/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm\ngKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+\n-----END CERTIFICATE-----\n", + "anchors": [ + { + "trust_kind": "manifest", + "trust_uri": "urn:c2pa-python:test-manifest-roots", + "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\nd3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg\nRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV\nUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu\nY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG\nSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y\nithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If\nxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV\nySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO\nDCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ\njdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/\nCNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi\nEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM\nfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY\nuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK\nchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t\n9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD\nggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2\nSV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd\n+SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc\nfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa\nsjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N\ncCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N\n0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie\n4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI\nr/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1\n/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm\ngKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+\n-----END CERTIFICATE-----\n" + }, + { + "trust_kind": "tsa", + "trust_uri": "urn:c2pa-python:test-tsa-roots", + "trust_anchors": "-----BEGIN CERTIFICATE-----\nMIICEzCCAcWgAwIBAgIUW4fUnS38162x10PCnB8qFsrQuZgwBQYDK2VwMHcxCzAJ\nBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJlMRowGAYD\nVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05M\nWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2NDFaFw0zMjA2MDcxODQ2\nNDFaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdo\nZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9SIFRF\nU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAqMAUGAytlcAMhAGPUgK9q1H3D\neKMGqLGjTXJSpsrLpe0kpxkaFMe7KUAuo2MwYTAdBgNVHQ4EFgQUXuZWArP1jiRM\nfgye6ZqRyGupTowwHwYDVR0jBBgwFoAUXuZWArP1jiRMfgye6ZqRyGupTowwDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwBQYDK2VwA0EA8E79g54u2fUy\ndfVLPyqKmtjenOUMvVQD7waNbetLY7kvUJZCd5eaDghk30/Q1RaNjiP/2RfA/it8\nzGxQnM2hCA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIC2jCCAjygAwIBAgIUYm+LFaltpWbS9kED6RRAamOdUHowCgYIKoZIzj0EAwQw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIGbMBAGByqGSM49AgEG\nBSuBBAAjA4GGAAQBaifSYJBkf5fgH3FWPxRdV84qwIsLd7RcIDcRJrRkan0xUYP5\nzco7R4fFGaQ9YJB8dauyqiNg00LVuPajvKmhgEMAT4eSfEhYC25F2ggXQlBIK3Q7\nmkXwJTIJSObnbw4S9Jy3W6OVKq351VpgWUcmhvGRRejW7S/D8L2tzqRW7JPI2uSj\nYzBhMB0GA1UdDgQWBBS6OykommTmfYoLJuPN4OU83wjPqjAfBgNVHSMEGDAWgBS6\nOykommTmfYoLJuPN4OU83wjPqjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE\nAwIBhjAKBggqhkjOPQQDBAOBiwAwgYcCQV4B6uKKoCWecEDlzj2xQLFPmnBQIOzD\nnyiSEcYyrCKwMV+HYS39oM+T53NvukLKUTznHwdWc9++HNaqc+IjsDl6AkIB2lXd\n5+s3xf0ioU91GJ4E13o5rpAULDxVSrN34A7BlsaXYQLnSkLMqva6E7nq2JBYjkqf\niwNQm1DDcQPtPTnddOs=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICkTCCAhagAwIBAgIUIngKvNC/BMF3TRIafgweprIbGgAwCgYIKoZIzj0EAwMw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEX3FzSTnCcEAP3wteNaiy4GZzZ+ABd2Y7gJpfyZf3kkCuX/I3psFq\nQBRvb3/FEBaDT4VbDNlZ0WLwtw5d3PI42Zufgpxemgfjf31d8H51eU3/IfAz5AFX\ny/OarhObHgVvo2MwYTAdBgNVHQ4EFgQUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wHwYD\nVR0jBBgwFoAUe+FK5t6/bQGIcGY6kkeIKTX/bJ0wDwYDVR0TAQH/BAUwAwEB/zAO\nBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPOgmJbVdhDh9KlgQXqE\nFzHiCt347JG4strk22MXzOgxQ0LnXStIh+viC3S1INzuBgIxAI1jiUBX/V7Gg0y6\nY/p6a63Xp2w+ia7vlUaUBWsR3ex9NNSTPLNoDkoTCSDOE2O20w==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw\ndzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx\nGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO\nR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw\nNzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT\nb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG\nT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye\nPcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh\neBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM\nMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA\nMEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9\njgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nxPd7wFhjRZHfuWb2cs63xjAGjQ==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi\nMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\nd3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg\nRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV\nUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu\nY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG\nSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y\nithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If\nxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV\nySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO\nDCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ\njdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/\nCNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi\nEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM\nfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY\nuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK\nchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t\n9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB\nhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD\nggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2\nSV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd\n+SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc\nfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa\nsjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N\ncCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N\n0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie\n4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI\nr/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1\n/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm\ngKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+\n-----END CERTIFICATE-----\n" + } + ], "trust_config": "//id-kp-emailProtection\n1.3.6.1.5.5.7.3.4\n//id-kp-documentSigning\n1.3.6.1.5.5.7.3.36\n//id-kp-timeStamping\n1.3.6.1.5.5.7.3.8\n//id-kp-OCSPSigning\n1.3.6.1.5.5.7.3.9\n// MS C2PA Signing\n1.3.6.1.4.1.311.76.59.1.9\n// c2pa-kp-claimSigning\n1.3.6.1.4.1.62558.2.1\n" } } From 502b8bb2aa4d3d62425d2ecd76b596691dea2298 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Mon, 28 Sep 2026 14:36:17 +0200 Subject: [PATCH 16/32] test: exercise ladder marshalling and native signing in pytest Include a documented synthetic fixture and offline native smoke in the existing CI selection. Enforce candidate capability on request, verify the native signature and shared manifest, and cover typed ctypes conversion without changing stock native pins or builder semantics. Co-authored-by: bibinbaby444 --- docs/ladder-signing.md | 22 +++- .../fixtures/single-file-fragmented/README.md | 25 +++++ .../single_file_fragments.mp4 | Bin 0 -> 3812 bytes tests/test_sign_ladder.py | 104 +++++++++++++++++- 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/single-file-fragmented/README.md create mode 100644 tests/fixtures/single-file-fragmented/single_file_fragments.mp4 diff --git a/docs/ladder-signing.md b/docs/ladder-signing.md index e60c1475..ec278918 100644 --- a/docs/ladder-signing.md +++ b/docs/ladder-signing.md @@ -30,14 +30,32 @@ binding addition. ## Verification -Use a local virtual environment for dependencies. The focused mock tests run -against an otherwise supported native library: +Use a local virtual environment for dependencies. The existing CI commands run +`tests/test_unit_tests.py tests/test_sign_ladder.py`. The focused ladder file +includes mock tests, typed `CFUNCTYPE` marshalling/cleanup tests, and a real-native +smoke using the committed tiny fixture and existing offline test key/certificates +(no TSA). Fixture provenance is in +`tests/fixtures/single-file-fragmented/README.md`. ```sh PYTHONPATH=src C2PA_LIBRARY_NAME=/absolute/path/to/stock/libc2pa_c.so \ .venv/bin/python -m pytest -q tests/test_sign_ladder.py +PYTHONPATH=src C2PA_LIBRARY_NAME=/absolute/path/to/candidate/libc2pa_c.so \ + C2PA_REQUIRE_SIGN_LADDER=1 .venv/bin/python -m pytest -q tests/test_sign_ladder.py ``` +Only the native smoke skips when the loaded library lacks the optional symbol. +`C2PA_REQUIRE_SIGN_LADDER=1` makes that absence fail instead; it is a test-only +capability requirement, not a loader override or a change to the binding API. +When the symbol is present, the smoke always executes, regardless of the flag. +It checks the production export's six argument types and return type before signing. +Missing committed fixture data fails even on stock. The smoke signs two copies +of the same fixture (not different resolution encodes), checks that the returned +manifest is embedded byte-for-byte in both outputs, compares their active +manifests, requires Reader state `Valid`, and checks inputs remain unchanged. +It also checks empty-list Python preflight preserves the builder and native +signing closes it. It does not replace the broader isolated harness below. + Run both real-native lanes explicitly. The harness copies this package and the specified library into a temporary directory and launches a fresh Python process. It checks the exact loaded path and SHA-256 and reports the native SDK version. diff --git a/tests/fixtures/single-file-fragmented/README.md b/tests/fixtures/single-file-fragmented/README.md new file mode 100644 index 00000000..058f0abf --- /dev/null +++ b/tests/fixtures/single-file-fragmented/README.md @@ -0,0 +1,25 @@ +# Single-file fragmented fixture + +`single_file_fragments.mp4` contains an initialization prefix (`ftyp`/`moov`) +and three H.264 `moof`/`mdat` fragments in one file. It is synthetic test media, +not separate resolution encodes. The Python smoke signs two copies of these +same bytes; it does not claim multi-resolution coverage. + +The bytes match c2pa-rs `sdk/tests/fixtures/single_file_fragments.mp4` exactly. + +- Size: 3,812 bytes +- Git blob: `1a4813c4c60473c619665e130c75f4f63b071b01` +- SHA-256: `0dcc2720b3c217e192b2bf7205f8f3675eac417f02f306db3dfe73713660f968` + +The source README records generation with FFmpeg 6.1.1 and its synthetic +`testsrc2` source: + +```sh +ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc2=size=32x32:rate=2 -t 3 \ + -c:v libx264 -threads 1 -g 2 -bf 0 \ + -movflags +empty_moov+frag_keyframe+default_base_moof+global_sidx \ + -y single_file_fragments.mp4 +``` + +Tests use the committed bytes and existing test-only ES256 key/certificates in +the parent directory. They need neither FFmpeg nor an external timestamp server. diff --git a/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 b/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1a4813c4c60473c619665e130c75f4f63b071b01 GIT binary patch literal 3812 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 B3upiU literal 0 HcmV?d00001 diff --git a/tests/test_sign_ladder.py b/tests/test_sign_ladder.py index 244d9a35..45a6fbd3 100644 --- a/tests/test_sign_ladder.py +++ b/tests/test_sign_ladder.py @@ -1,9 +1,11 @@ -"""Focused binding tests; all ladder FFI calls and handle frees are mocked.""" +"""Focused binding tests and an optional-capability, offline native smoke.""" import ctypes import gc +import json import os from pathlib import Path +import shutil import subprocess import sys from types import SimpleNamespace @@ -14,8 +16,20 @@ import c2pa.c2pa as binding +_NATIVE_SIGNATURE = ctypes.CFUNCTYPE( + ctypes.c_int64, + ctypes.POINTER(binding.C2paBuilder), + ctypes.POINTER(binding.C2paSigner), + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_char_p), + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), +) + + @pytest.fixture def ladder(monkeypatch): + # Mock retains call arguments; gc.collect() below is not a lifetime proof. native = SimpleNamespace( c2pa_builder_sign_ladder=Mock(), c2pa_free=Mock(return_value=0), @@ -63,7 +77,7 @@ def assert_closed(builder, signer, native, manifest=None): assert native.c2pa_free.call_args_list == calls -def test_order_utf8_lifetimes_and_binary_copy(ladder): +def test_order_utf8_marshalling_and_binary_copy(ladder): builder, signer, native = ladder manifest = manifest_result(native) sign = native.c2pa_builder_sign_ladder.side_effect @@ -86,6 +100,40 @@ def inspect(*args): assert_closed(builder, signer, native, manifest) +@pytest.mark.parametrize("result", [4, -1]) +def test_typed_callback_marshalling_and_cleanup(ladder, monkeypatch, result): + builder, signer, native = ladder + manifest = (ctypes.c_ubyte * 4)(65, 0, 66, 255) + calls = [] + + @_NATIVE_SIGNATURE + def sign(builder_ptr, signer_ptr, sources, dests, count, output): + gc.collect() + calls.append((ctypes.addressof(builder_ptr.contents), + ctypes.addressof(signer_ptr.contents), + [sources[i] for i in range(count)], + [dests[i] for i in range(count)], count)) + output[0] = ctypes.cast(manifest, ctypes.POINTER(ctypes.c_ubyte)) + return result + + native.c2pa_builder_sign_ladder.side_effect = sign + expected = (ctypes.addressof(builder._handle.contents), + ctypes.addressof(signer._handle.contents), + [b"z.mp4", "\u00e9.mp4".encode()], + [b"out-z.mp4", "out-\u00e9.mp4".encode()], 2) + sources = [Path("z.mp4"), "\u00e9.mp4"] + dests = ["out-z.mp4", Path("out-\u00e9.mp4")] + if result < 0: + monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: sign failed") + with pytest.raises(binding.C2paError.Io, match="sign failed"): + builder.sign_ladder(signer, sources, dests) + else: + assert builder.sign_ladder(signer, sources, dests) == b"A\0B\xff" + # Assert outside the ctypes callback, which would swallow assertion errors. + assert calls == [expected] + assert_closed(builder, signer, native, manifest) + + @pytest.mark.parametrize("sources,dests,error", [ ([], [], binding.C2paError), (["a"] * 257, ["b"] * 257, binding.C2paError), @@ -231,3 +279,55 @@ def test_native_harness_refuses_optimized_python(mode): assert result.returncode != 0 assert "requires assertions" in result.stderr assert "rerun without -O/-OO or PYTHONOPTIMIZE" in result.stderr + + +def test_real_native_ladder_signs_and_validates(tmp_path): + fixtures = Path(__file__).parent / "fixtures" + fixture = fixtures / "single-file-fragmented" / "single_file_fragments.mp4" + original = fixture.read_bytes() # Missing committed test data is never a skip. + if not binding._HAS_SIGN_LADDER: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 but the loaded native library " + "lacks c2pa_builder_sign_ladder") + pytest.skip("native library lacks c2pa_builder_sign_ladder") + + export = binding._lib.c2pa_builder_sign_ladder + assert export.restype is _NATIVE_SIGNATURE._restype_ + assert tuple(export.argtypes) == _NATIVE_SIGNATURE._argtypes_ + + # Two copies exercise the ordered multi-file API, not different encodes. + sources = [tmp_path / f"copy-{i}.mp4" for i in range(2)] + dests = [tmp_path / f"signed-{i}.mp4" for i in range(2)] + for source in sources: + shutil.copy2(fixture, source) + definition = { + "claim_generator_info": [{"name": "ladder-binding-test"}], + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], + } + info = binding.C2paSignerInfo( + alg=b"es256", sign_cert=(fixtures / "es256_certs.pem").read_bytes(), + private_key=(fixtures / "es256_private.key").read_bytes(), ta_url=None) + with binding.Signer.from_info(info) as signer: + with binding.Builder(definition) as builder: + with pytest.raises(binding.C2paError, match="1 to 256"): + builder.sign_ladder(signer, [], []) + builder._ensure_valid_state() + manifest = builder.sign_ladder(signer, sources, dests) + assert manifest + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + assert builder._handle is None + signer._ensure_valid_state() + + manifests = [] + for dest in dests: + assert manifest in dest.read_bytes(), "returned manifest not embedded byte-for-byte" + with binding.Reader(dest) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + report = json.loads(reader.json()) + manifests.append(report["manifests"][report["active_manifest"]]) + assert manifests[0] == manifests[1] + assert [source.read_bytes() for source in sources] == [original, original] + assert fixture.read_bytes() == original From 7b17b3671e67aee1ca983037455cfd65b1d157c4 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Mon, 28 Sep 2026 21:55:31 +0200 Subject: [PATCH 17/32] fix(ci): accept setuptools sdist naming in functional build test --- .github/workflows/trusted-vsi-paired.yml | 6 +++++- tests/test_trusted_vsi_build.py | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index 1e0fa3e3..c0a2a5b1 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -79,7 +79,11 @@ jobs: timeout-minutes: 20 shell: bash working-directory: python-source - run: python -m pytest -q --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 + # The release smoke is required here (no module-level skip): it exercises + # dev5 capabilities against the paired functional native library. + env: + CASTLABS_RELEASE_SMOKE_REQUIRED: "1" + run: python -m pytest -q -ra --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 - name: Threaded regressions timeout-minutes: 15 shell: bash diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index 4d60615c..fb83cbf7 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -4,6 +4,8 @@ from pathlib import Path import pytest +from packaging.utils import canonicalize_name, parse_sdist_filename +from packaging.version import Version ROOT = Path(__file__).resolve().parents[1] @@ -41,16 +43,21 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): assert not (stage / "src/c2pa/libs").exists() subprocess.run([sys.executable, "setup.py", "-q", "sdist", "--dist-dir", str(tmp_path / "dist")], cwd=stage, check=True, capture_output=True) + # setuptools < 69 names sdists "c2pa-python-", newer "c2pa_python-"; + # compare canonicalized name/version instead of one spelling. (sdist,) = (tmp_path / "dist").iterdir() - assert sdist.name == "c2pa_python-0.37.9.dev0.tar.gz" + name, version = parse_sdist_filename(sdist.name) + assert canonicalize_name(name) == "c2pa-python" + assert version == Version("0.37.9.dev0") + root = sdist.name[:-len(".tar.gz")] with tarfile.open(sdist) as archive: names = archive.getnames() - info = archive.extractfile("c2pa_python-0.37.9.dev0/PKG-INFO").read().decode() - assert "Version: 0.37.9.dev0" in info + info = archive.extractfile(f"{root}/PKG-INFO").read().decode() + assert "Version: 0.37.9.dev0" in info.splitlines() for member in ("scripts/build_trusted_vsi_functional.py", "scripts/qualify_trusted_vsi_functional.py", "docs/trusted-vsi-python-contract.md", "src/c2pa/c2pa.py"): - assert f"c2pa_python-0.37.9.dev0/{member}" in names + assert f"{root}/{member}" in names assert not any(name.endswith((".so", ".dll", ".dylib")) for name in names) after = {name: (ROOT / name).read_bytes() for name in before} assert after == before From 9f13fae96466c455702445fc0c83b0765acddd7d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Tue, 29 Sep 2026 00:14:10 +0200 Subject: [PATCH 18/32] fix(ci): run release workflow shell helpers with Git bash on Windows --- tests/test_castlabs_release_tooling.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index f1b4f4de..b121130f 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -7,6 +7,7 @@ import json import os import re +import shutil import subprocess import tarfile import tempfile @@ -28,6 +29,25 @@ SPEC.loader.exec_module(release) +def _posix_bash() -> str: + """Return a POSIX bash for workflow shell snippets. + + On Windows a bare ``bash`` resolves to ``System32\\bash.exe`` (the WSL + launcher, unusable without a distribution). GitHub's ``shell: bash`` uses + Git for Windows' bash, so use the same one; fail rather than skip if absent. + """ + if os.name != "nt": + return "bash" + git = shutil.which("git") + assert git, "Git for Windows is required to run workflow shell helpers" + # git.exe may live in \\cmd, \\bin or \\mingw64\\bin. + for root in Path(git).resolve().parents: + for candidate in (root / "bin" / "bash.exe", root / "usr" / "bin" / "bash.exe"): + if candidate.is_file() and "system32" not in str(candidate).lower(): + return str(candidate) + raise AssertionError(f"Git for Windows bash not found near {git}") + + def test_prerelease_version_is_consistent(): assert release.project_version(ROOT) == "0.37.8.dev5" first_line = ( @@ -319,7 +339,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_workflow[shell_helpers_start:shell_helpers_end] ) subprocess.run( - ["bash"], + [_posix_bash()], cwd=tmp_path, input=( "set -euo pipefail\n" From 364a17ec78da87246b53bea197e73bec10d52d7d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Tue, 29 Sep 2026 04:22:24 +0200 Subject: [PATCH 19/32] ci: pin paired trusted VSI native to 3569fb86 --- .github/workflows/trusted-vsi-paired.yml | 2 +- docs/trusted-vsi-python-contract.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index c0a2a5b1..18295116 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -48,7 +48,7 @@ jobs: repository: castlabs/c2pa-rs # Reviewed functional trusted VSI native (feat/trusted-vsi-functional). # Qualification-only pairing; never the immutable dev5 release input. - ref: 1d605b5a2033d5812742e302db3e5f9af347f68d + ref: 3569fb860babe52db778f82e3fe80e8371fe2a08 path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index c4f580aa..4bda76a5 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,7 +1,7 @@ # Trusted VSI Python Contract Status: implemented and qualified locally (Linux) against the functional native -library built from `castlabs/c2pa-rs@1d605b5a2033d5812742e302db3e5f9af347f68d` +library built from `castlabs/c2pa-rs@3569fb860babe52db778f82e3fe80e8371fe2a08` (`feat/trusted-vsi-functional`; debug `libc2pa_c.so` SHA-256 `6c7ccf4258132df1…`, capability mask 63), following `c2pa-rs` `docs/trusted-vsi-native-contract.md` (SHA-256 @@ -197,5 +197,5 @@ and native library come from that venv with the expected version. 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` -checks out the reviewed native commit `1d605b5a2033d5812742e302db3e5f9af347f68d` +checks out the reviewed native commit `3569fb860babe52db778f82e3fe80e8371fe2a08` by full SHA; update that pin (not a branch name) for later native revisions. From f48fbccec9c6442ca0366a34a3e6300bcae96b08 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 06:23:47 +0200 Subject: [PATCH 20/32] fix: pair trusted VSI gate with exact consolidated native 0.92.0-dev --- README.md | 4 ++-- docs/trusted-vsi-python-contract.md | 4 ++-- docs/usage.md | 2 +- src/c2pa/c2pa.py | 16 ++++++++++++---- tests/test_trusted_vsi_api.py | 25 +++++++++++++++++++++++-- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 32413481..da1d8517 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH EXTRA_BUILD_ARGS="--debug" When running the tests against an unreleased source build whose SDK version differs from `c2pa-native-version.txt`, explicitly provide the expected source version: ```sh -C2PA_SOURCE_BUILD_VERSION=0.91.0-dev python3 tests/test_unit_tests.py +C2PA_SOURCE_BUILD_VERSION=0.92.0-dev python3 tests/test_unit_tests.py ``` The version test validates the loaded library against this value. When the variable is unset, it continues to validate downloaded release artifacts against `c2pa-native-version.txt`. @@ -133,7 +133,7 @@ To test a locally built paired native without reinstalling the Python package: ```sh PYTHONPATH="$PWD/src" \ C2PA_LIBRARY_NAME=/absolute/path/to/paired-c2pa-rs/target/debug/libc2pa_c.so \ -C2PA_SOURCE_BUILD_VERSION=0.91.0-dev \ +C2PA_SOURCE_BUILD_VERSION=0.92.0-dev \ C2PA_TRUSTED_VSI_ABI_REQUIRED=1 \ python -m pytest -q tests/test_trusted_vsi_api.py -ra ``` diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 4bda76a5..4a2d1c49 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -21,7 +21,7 @@ artifacts are unchanged. The class is `TrustedVsiSession`, and All `has_live_video_trusted_vsi_*()` probes return `True` only when the loaded library exports every symbol in the contract's C ABI, reports native version -`0.91.0-dev`, and `c2pa_live_video_trusted_vsi_capabilities() == 63`. Missing +`0.92.0-dev`, and `c2pa_live_video_trusted_vsi_capabilities() == 63`. Missing symbols, a scaffold/older/partial library, or any other mask disables every probe. Old scaffold symbol layouts are never bound. When unavailable, the constructor, `from_callback`, `validate_trusted_vsi_input` and @@ -186,7 +186,7 @@ CBOR, BMFF or validation results. NON-PUBLISHING wheel and sdist in a temporary staging copy with `FUNCTIONAL_BUILD_VERSION` (default `0.37.9.dev0`; rejects `0.37.8.*`, dev5 and release versions). The checkout's `0.37.8.dev5` metadata is not modified. -`C2PA_SOURCE_BUILD_VERSION=0.91.0-dev` identifies the native library only. +`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native library only. `scripts/qualify_trusted_vsi_functional.py --wheel --venv --version 0.37.9.dev0` installs the wheel into an isolated venv, strips diff --git a/docs/usage.md b/docs/usage.md index 605343f5..c3b93050 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,7 +27,7 @@ All of `Builder`, `Reader`, `Signer`, `Context`, and `Settings` support context ## Trusted-processor VSI (unreleased functional API) `TrustedVsiSession` is separate from complete-buffer -`LiveVideoVsiSession`. It requires the complete functional 0.91.0-dev native ABI +`LiveVideoVsiSession`. It requires the complete functional 0.92.0-dev native ABI and capability mask 63. Older libraries import normally but do not advertise trusted functionality; construction fails before inspecting arguments or invoking callbacks. These changes are not in immutable dev5 artifacts. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 5b5f8cf1..4a233fe7 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -296,6 +296,15 @@ def _validate_library_exports(lib): _TRUSTED_VSI_CAPABILITIES = 0 _TRUSTED_VSI_ABI_AVAILABLE = all(hasattr(_lib, name) for name in _TRUSTED_VSI_FUNCTIONS) _TRUSTED_VSI_VERSION_MATCHES = False +# Exact paired native version for the functional trusted-VSI ABI. Qualification +# pins the native commit separately; this is deliberately not a range. +_TRUSTED_VSI_NATIVE_VERSION = "0.92.0-dev" + + +def _trusted_vsi_version_matches(native_version: bytes) -> bool: + """Return whether c2pa_version() names exactly the paired c2pa-rs build.""" + expected = b"c2pa-rs/" + _TRUSTED_VSI_NATIVE_VERSION.encode("ascii") + return expected in native_version.split() _DYNAMIC_ASSERTIONS_AVAILABLE = all( hasattr(_lib, name) for name in _DYNAMIC_ASSERTION_FUNCTIONS ) @@ -1400,9 +1409,8 @@ def _setup_function(func, argtypes, restype=None): native_version_ptr = _lib.c2pa_version() if native_version_ptr: try: - _TRUSTED_VSI_VERSION_MATCHES = ( - b'c2pa-rs/0.91.0-dev' in ctypes.string_at(native_version_ptr).split() - ) + _TRUSTED_VSI_VERSION_MATCHES = _trusted_vsi_version_matches( + ctypes.string_at(native_version_ptr)) finally: _lib.c2pa_string_free(native_version_ptr) if _TRUSTED_VSI_VERSION_MATCHES: @@ -2510,7 +2518,7 @@ class TrustedVsiInputKind(enum.IntEnum): def _require_trusted_vsi(): if not has_live_video_trusted_vsi_signing_context_v1(): raise C2paError.NotSupported( - "Functional trusted VSI requires the complete 0.91.0-dev native ABI " + f"Functional trusted VSI requires the complete {_TRUSTED_VSI_NATIVE_VERSION} native ABI " "and capability mask 63; the loaded library is unavailable") diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py index abf6c293..092903d9 100644 --- a/tests/test_trusted_vsi_api.py +++ b/tests/test_trusted_vsi_api.py @@ -90,6 +90,26 @@ def test_missing_symbols_or_wrong_native_version_fail_closed(monkeypatch, missin assert not any(probe() for probe in PROBES) +def test_paired_native_version_is_exact_consolidated_release_line(): + # Changing this pin requires re-pairing qualification with a reviewed native SHA. + assert bindings._TRUSTED_VSI_NATIVE_VERSION == "0.92.0-dev" + + +@pytest.mark.parametrize("native_version, expected", [ + (b"c2pa-c-ffi/0.92.0-dev c2pa-rs/0.92.0-dev", True), + (b"c2pa-rs/0.92.0-dev", True), + (b"c2pa-c-ffi/0.91.0-dev c2pa-rs/0.91.0-dev", False), + (b"c2pa-c-ffi/0.92.0 c2pa-rs/0.92.0", False), + (b"c2pa-c-ffi/0.92.0-dev c2pa-rs/0.92.0-dev.1", False), + (b"c2pa-c-ffi/0.92.0-dev c2pa-rs/0.93.0-dev", False), + (b"c2pa-c-ffi/0.92.0-dev", False), + (b"xc2pa-rs/0.92.0-dev", False), + (b"", False), +]) +def test_native_version_gate_accepts_only_exact_paired_token(native_version, expected): + assert bindings._trusted_vsi_version_matches(native_version) is expected + + @pytest.mark.parametrize("factory", [c2pa.TrustedVsiSession, c2pa.TrustedVsiSession.from_callback]) def test_disabled_constructor_gates_before_arguments_callbacks_or_bookkeeping(monkeypatch, factory): monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 0) @@ -488,8 +508,9 @@ def paired_native(): problems = [] if missing: problems.append("missing symbols: " + ", ".join(missing)) - if c2pa.sdk_version() != "0.91.0-dev": - problems.append(f"native version {c2pa.sdk_version()!r} != '0.91.0-dev'") + expected = bindings._TRUSTED_VSI_NATIVE_VERSION + if c2pa.sdk_version() != expected: + problems.append(f"native version {c2pa.sdk_version()!r} != {expected!r}") if not missing: mask = int(bindings._lib.c2pa_live_video_trusted_vsi_capabilities()) if mask != 63: From 0047425062e5403e2b14fa0f446a03632a8a8e7b Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 06:26:08 +0200 Subject: [PATCH 21/32] fix: propagate ladder callback errors and guard fragmented output cleanup --- docs/ladder-signing.md | 7 + src/c2pa/c2pa.py | 38 ++++-- tests/test_fragmented_files.py | 55 ++++++++ tests/test_sign_ladder.py | 229 +++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 9 deletions(-) diff --git a/docs/ladder-signing.md b/docs/ladder-signing.md index ec278918..0489f468 100644 --- a/docs/ladder-signing.md +++ b/docs/ladder-signing.md @@ -23,6 +23,13 @@ attempted native call closes the builder on success or failure; the signer remai usable. Preflight errors leave the builder usable. Paths must be UTF-8 strings or `Path` objects and cannot contain NUL characters. +Dynamic assertions registered on the signer run once for the shared manifest. +Callback errors follow the other Builder signing paths: an exception raised by a +dynamic-assertion callback is re-raised unchanged; a claim-signer callback +propagates only interrupts such as `KeyboardInterrupt`, `SystemExit` and +`asyncio.CancelledError`, while its ordinary exceptions are reported as +`C2paError`. Error state left by a previous operation is cleared before signing. + The native export `c2pa_builder_sign_ladder` is optional. A library without it still imports and supports ordinary signing. Calling `sign_ladder` on that library raises `C2paError.NotSupported`. No native release pin changes are needed for this diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 4a233fe7..362bec3c 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -5855,7 +5855,13 @@ def sign_fragmented( # closes after an attempted sign; Signer remains caller-owned. self.close() if manifest_bytes_ptr: - ManagedResource._free_native_ptr(manifest_bytes_ptr) + # A cleanup failure must not replace the original callback or + # native exception (or a successful result). + try: + ManagedResource._free_native_ptr(manifest_bytes_ptr) + except Exception: + logger.error( + "Failed to release native manifest bytes memory") manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() def sign_ladder( @@ -5928,19 +5934,33 @@ def sign_ladder( dest_array = (ctypes.c_char_p * count)(*encoded_paths[1]) manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + # Pin the explicit signer's callbacks for the borrowed native call and + # drop error state left by an earlier operation, as sign_fragmented does. + dynamic_assertion_cbs = list(signer._dynamic_assertion_cbs) + claim_state = _claim_signer_error_state(signer._callback_cb) + da_states = [state for _, state, _ in dynamic_assertion_cbs] + _clear_callback_errors(da_states + [claim_state]) + try: - result = _lib.c2pa_builder_sign_ladder( - self._handle, signer._handle, source_array, dest_array, - count, ctypes.byref(manifest_bytes_ptr)) + try: + result = _lib.c2pa_builder_sign_ladder( + self._handle, signer._handle, source_array, dest_array, + count, ctypes.byref(manifest_bytes_ptr)) + except Exception as e: + raise C2paError(f"Error during ladder signing: {e}") from e + if result < 0: + # Dynamic-assertion exceptions keep their identity; claim-signer + # callbacks only propagate interrupts (KeyboardInterrupt, ...). + _reraise_callback_errors( + da_states, [claim_state] if claim_state is not None else []) _check_ffi_operation_result( result, "Error during ladder signing", check=lambda r: r < 0) if result <= 0 or not manifest_bytes_ptr: raise C2paError("Ladder signing returned no manifest bytes") - return ctypes.string_at(manifest_bytes_ptr, result) - except C2paError: - raise - except Exception as e: - raise C2paError(f"Error during ladder signing: {e}") from e + try: + return ctypes.string_at(manifest_bytes_ptr, result) + except Exception as e: + raise C2paError(f"Error during ladder signing: {e}") from e finally: if manifest_bytes_ptr: try: diff --git a/tests/test_fragmented_files.py b/tests/test_fragmented_files.py index 86e76162..99d33e94 100644 --- a/tests/test_fragmented_files.py +++ b/tests/test_fragmented_files.py @@ -535,6 +535,61 @@ def record_free(pointer): self.assertFalse(builder.is_valid) self.assertTrue(signer.is_valid) + def test_output_free_failure_does_not_replace_callback_exception(self): + class CallbackFailure(RuntimeError): + pass + + failure = CallbackFailure("fragmented dynamic assertion failed") + signer = self._make_signer() + self.addCleanup(signer.close) + state = type("State", (), {"exception": None})() + signer._dynamic_assertion_cbs.append((object(), state, object())) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + init_path, _ = self._prepare_input(root) + builder = Builder(_manifest_definition()) + builder_handle = builder._handle + output_buffer = (ctypes.c_ubyte * 4)(1, 2, 3, 4) + output_address = ctypes.addressof(output_buffer) + real_call = c2pa_module._lib.c2pa_builder_sign_fragmented + real_free = ManagedResource._free_native_ptr + + def failed_call(*args): + output = ctypes.cast( + args[-1], + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ) + output[0] = ctypes.cast( + output_buffer, ctypes.POINTER(ctypes.c_ubyte)) + state.exception = failure + return -1 + + def failing_free(pointer): + if ctypes.cast(pointer, ctypes.c_void_p).value == output_address: + raise RuntimeError("free failed") + return real_free(pointer) + + c2pa_module._lib.c2pa_builder_sign_fragmented = failed_call + ManagedResource._free_native_ptr = staticmethod(failing_free) + try: + with self.assertLogs("c2pa", level="ERROR") as logs: + with self.assertRaises(CallbackFailure) as raised: + builder.sign_fragmented( + signer, init_path, "segment-*.m4s", + root / "output") + finally: + c2pa_module._lib.c2pa_builder_sign_fragmented = real_call + ManagedResource._free_native_ptr = real_free + + self.assertIs(raised.exception, failure) + self.assertTrue(any( + "Failed to release native manifest bytes memory" in line + for line in logs.output)) + self.assertIsNone(builder._handle) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + del builder_handle + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sign_ladder.py b/tests/test_sign_ladder.py index 45a6fbd3..b2757750 100644 --- a/tests/test_sign_ladder.py +++ b/tests/test_sign_ladder.py @@ -1,5 +1,6 @@ """Focused binding tests and an optional-capability, offline native smoke.""" +import asyncio import ctypes import gc import json @@ -8,9 +9,11 @@ import shutil import subprocess import sys +import threading from types import SimpleNamespace from unittest.mock import Mock +import cbor2 import pytest import c2pa.c2pa as binding @@ -331,3 +334,229 @@ def test_real_native_ladder_signs_and_validates(tmp_path): assert manifests[0] == manifests[1] assert [source.read_bytes() for source in sources] == [original, original] assert fixture.read_bytes() == original + + +class _DynamicAssertionFailure(RuntimeError): + pass + + +def _callback_state(): + state = threading.local() + state.exception = None + return state + + +_BASE_EXCEPTIONS = [ + pytest.param(lambda: _DynamicAssertionFailure("assertion failed"), id="RuntimeError"), + pytest.param(lambda: KeyboardInterrupt("operator interrupt"), id="KeyboardInterrupt"), + pytest.param(lambda: SystemExit(3), id="SystemExit"), + pytest.param(lambda: asyncio.CancelledError("worker cancelled"), id="CancelledError"), +] + + +@pytest.mark.parametrize("make_error", _BASE_EXCEPTIONS) +def test_dynamic_assertion_error_keeps_identity_and_cleans_up(ladder, monkeypatch, make_error): + builder, signer, native = ladder + error = make_error() + state = _callback_state() + pinned = object() + signer._dynamic_assertion_cbs.append((pinned, state, pinned)) + manifest = manifest_result(native, result=-1) + sign = native.c2pa_builder_sign_ladder.side_effect + + def failing(*args): + state.exception = error + return sign(*args) + + native.c2pa_builder_sign_ladder.side_effect = failing + monkeypatch.setattr(binding, "_read_native_error", lambda: "Other: callback failed") + with pytest.raises(BaseException) as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value is error + assert_closed(builder, signer, native, manifest) + + +@pytest.mark.parametrize("make_error,propagates", [ + (lambda: _DynamicAssertionFailure("claim callback failed"), False), + (lambda: KeyboardInterrupt("operator interrupt"), True), + (lambda: SystemExit(3), True), + (lambda: asyncio.CancelledError("worker cancelled"), True), +]) +def test_claim_signer_interrupts_propagate_ordinary_errors_stay_typed( + ladder, monkeypatch, make_error, propagates): + builder, signer, native = ladder + error = make_error() + state = _callback_state() + signer._callback_cb = SimpleNamespace(_error_state=state) + + def failing(*_args): + state.exception = error + return -1 + + native.c2pa_builder_sign_ladder.side_effect = failing + monkeypatch.setattr(binding, "_read_native_error", lambda: "Signature: claim signer failed") + if propagates: + with pytest.raises(BaseException) as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value is error + else: + with pytest.raises(binding.C2paError.Signature, match="claim signer failed"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert_closed(builder, signer, native) + + +def test_stale_callback_errors_are_cleared_before_native_call(ladder, monkeypatch): + builder, signer, native = ladder + da_state = _callback_state() + claim_state = _callback_state() + da_state.exception = _DynamicAssertionFailure("stale assertion failure") + claim_state.exception = KeyboardInterrupt("stale interrupt") + signer._dynamic_assertion_cbs.append((object(), da_state, object())) + signer._callback_cb = SimpleNamespace(_error_state=claim_state) + seen = [] + + def failing(*_args): + seen.append((da_state.exception, claim_state.exception)) + return -1 + + native.c2pa_builder_sign_ladder.side_effect = failing + monkeypatch.setattr(binding, "_read_native_error", lambda: "Io: never entered callbacks") + with pytest.raises(binding.C2paError.Io, match="never entered callbacks"): + builder.sign_ladder(signer, ["a"], ["b"]) + assert seen == [(None, None)] + assert_closed(builder, signer, native) + + +def test_callback_error_survives_manifest_free_failure(ladder, caplog, monkeypatch): + builder, signer, native = ladder + error = _DynamicAssertionFailure("assertion failed") + state = _callback_state() + signer._dynamic_assertion_cbs.append((object(), state, object())) + manifest = manifest_result(native, result=-1) + sign = native.c2pa_builder_sign_ladder.side_effect + + def failing(*args): + state.exception = error + return sign(*args) + + def free(pointer): + if ctypes.addressof(pointer.contents) == ctypes.addressof(manifest): + raise RuntimeError("free failed") + return 0 + + native.c2pa_builder_sign_ladder.side_effect = failing + native.c2pa_free.side_effect = free + with pytest.raises(_DynamicAssertionFailure) as caught: + builder.sign_ladder(signer, ["a"], ["b"]) + assert caught.value is error + assert "Failed to release native manifest bytes memory" in caplog.text + assert_closed(builder, signer, native, manifest) + + +def _exact_size_cbor(size, label): + for pad in range(size): + encoded = cbor2.dumps({"note": label, "pad": "x" * pad}) + if len(encoded) == size: + return encoded + if len(encoded) > size: + break + raise AssertionError(f"cannot encode exactly {size} bytes") + + +def _require_real_ladder_with_dynamic_assertions(): + if not binding._HAS_SIGN_LADDER: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 but the loaded native library " + "lacks c2pa_builder_sign_ladder") + pytest.skip("native library lacks c2pa_builder_sign_ladder") + if not binding.has_dynamic_assertions(): + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 requires dynamic assertions") + pytest.skip("native library lacks dynamic assertions") + + +def _ladder_inputs(tmp_path, count=2): + fixture = (Path(__file__).parent / "fixtures" / "single-file-fragmented" + / "single_file_fragments.mp4") + sources = [tmp_path / f"copy-{i}.mp4" for i in range(count)] + dests = [tmp_path / f"signed-{i}.mp4" for i in range(count)] + for source in sources: + shutil.copy2(fixture, source) + return sources, dests + + +def _real_signer(): + fixtures = Path(__file__).parent / "fixtures" + return binding.Signer.from_info(binding.C2paSignerInfo( + alg=b"es256", sign_cert=(fixtures / "es256_certs.pem").read_bytes(), + private_key=(fixtures / "es256_private.key").read_bytes(), ta_url=None)) + + +_LADDER_DEFINITION = { + "claim_generator_info": [{"name": "ladder-binding-test"}], + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], +} + + +def test_real_native_ladder_includes_exact_size_dynamic_assertion(tmp_path): + _require_real_ladder_with_dynamic_assertions() + sources, dests = _ladder_inputs(tmp_path) + label = "com.example.ladder" + reserve = 96 + content = _exact_size_cbor(reserve, "ladder dynamic assertion") + calls = [] + + def callback(callback_label, reserve_size, partial_claim): + calls.append((callback_label, reserve_size, partial_claim)) + return content + + with _real_signer() as signer: + signer.add_dynamic_assertion(callback, label=label, reserve_size=reserve) + del callback + gc.collect() # Registration, not the local name, must pin the callback. + with binding.Builder(_LADDER_DEFINITION) as builder: + manifest = builder.sign_ladder(signer, sources, dests) + signer._ensure_valid_state() + + assert len(calls) == 1, "one shared manifest invokes the assertion once" + callback_label, reserve_size, partial_claim = calls[0] + assert (callback_label, reserve_size) == (label, reserve) + urls = [entry["url"] for entry in partial_claim] + # The dynamic assertion endorses the rendition hard binding it is signed with. + assert any("c2pa.hash.bmff" in url for url in urls), urls + assert all({"url", "alg", "hash"} <= set(entry) for entry in partial_claim) + + manifests = [] + for dest in dests: + assert manifest in dest.read_bytes() + with binding.Reader(dest) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + report = json.loads(reader.json()) + active = report["manifests"][report["active_manifest"]] + manifests.append(active) + dynamic = [a for a in active["assertions"] if a["label"] == label] + assert [a["data"] for a in dynamic] == [cbor2.loads(content)] + assert manifests[0] == manifests[1] + + +@pytest.mark.parametrize("make_error", _BASE_EXCEPTIONS) +def test_real_native_ladder_reraises_dynamic_assertion_exception(tmp_path, make_error): + _require_real_ladder_with_dynamic_assertions() + sources, dests = _ladder_inputs(tmp_path, count=1) + error = make_error() + + def callback(*_args): + raise error + + with _real_signer() as signer: + signer.add_dynamic_assertion(callback, label="com.example.ladder", reserve_size=64) + builder = binding.Builder(_LADDER_DEFINITION) + with pytest.raises(BaseException) as caught: + builder.sign_ladder(signer, sources, dests) + assert caught.value is error + assert builder._lifecycle_state == binding.LifecycleState.CLOSED + signer._ensure_valid_state() + assert signer._dynamic_assertion_cbs[0][1].exception is error From c3085bda5a74ea02471cbb21b9574e14d2aa8cea Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 06:35:40 +0200 Subject: [PATCH 22/32] fix: triage consume-first FFI rejections by the rejected handle id --- docs/native-resources-management.md | 30 +++-- src/c2pa/c2pa.py | 115 +++++++++++----- tests/test_native_ownership.py | 197 ++++++++++++++++++++++++++++ tests/test_unit_tests.py | 12 +- 4 files changed, 310 insertions(+), 44 deletions(-) create mode 100644 tests/test_native_ownership.py diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 56878a60..d3691747 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -111,7 +111,7 @@ Three distinct risks. Two have a mechanism in this layer; the third is the calle | Hazard | Covered by | How | | --- | --- | --- | -| Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | The consumed pointer is abandoned, never freed. The retained-vs-consumed decision reads the native error tag (`UntrackedPointer:` / `WrongPointerType:` mean not taken). | +| Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | The consumed pointer is abandoned, never freed. The retained-vs-consumed decision reads the native error tag and, for consume-first calls, compares the rejected id with the managed handle (see [the triage table](#why-an-ownership-taken-failure-does-not-free)). | | A forked child freeing a pointer its parent owns | PID stamp (`record_owner_pid` / `is_foreign_process`) | Cleanup in a process that did not allocate the pointer nulls the handle and marks `CLOSED` without freeing (see [Fork safety](#fork-safety)). | | Two **threads** in one process racing frees on distinct objects, where the allocator recycles a just-freed address | Not covered here | `ManagedResource` has no lock and no thread stamping. The PID stamp cannot see it: sibling threads share a PID. Safety for genuinely shared handles must come from the caller's own synchronization or from the native registry, not this layer. | @@ -433,11 +433,21 @@ flowchart TD The two failure paths are indistinguishable from the return value alone. Only the native error message set alongside them tells the phases apart: -| Native error | Who owns the handle | What the helper does | -| --- | --- | --- | -| `UntrackedPointer:` or `WrongPointerType:` | Still ours: rejected before ownership moved | Handle kept, resource stays `ACTIVE`, typed error raised. Normal cleanup frees it later. | -| Any other error | Taken, then the operation failed | `_teardown(free_handle=False)`: the native side already dropped the value, so nothing is freed here. Resource goes `CLOSED`, error typed from the native message. | -| No error at all | Unknown | `_release_handle()` guarded free, the caller's message is raised with `"Unknown error"` filled in. | +The meaning of a registry rejection depends on the native call's validation order, which each call site declares with `consumes_first`: + +- **Validate-first** calls (`c2pa_context_builder_set_signer`): every other argument is checked before the consumed handle is untracked, so any registry rejection means the handle was never taken. +- **Consume-first** calls (`c2pa_reader_with_stream`, `c2pa_reader_with_manifest_data_and_stream`, `c2pa_reader_with_fragment`, `c2pa_builder_with_definition`, `c2pa_builder_with_archive`, `c2pa_context_builder_build`): native untracks the handle *before* validating the remaining arguments, so a rejection of another argument (for example a stream) arrives after the managed handle was already dropped. + +| Native error | Call order | Who owns the handle | What the helper does | +| --- | --- | --- | --- | +| `UntrackedPointer:` / `WrongPointerType:` / `PointerInUse:` / `WrongWrapperKind:` | validate-first | Still ours: rejected before ownership moved | Handle kept, resource stays `ACTIVE`, typed error raised. Normal cleanup frees it later. | +| `UntrackedPointer: 0x…` / `WrongPointerType: 0x…` naming **this** handle | consume-first | Still ours | Handle kept, resource stays `ACTIVE`, typed error raised. | +| `UntrackedPointer: 0x…` / `WrongPointerType: 0x…` naming **another** handle | consume-first | Taken, then another argument was rejected | `_teardown(free_handle=False)`, resource `CLOSED`, typed error raised. | +| Registry rejection with no parseable address (`PointerInUse:`, `WrongWrapperKind:`) | consume-first | Ambiguous | Error snapshotted first, then `_release_handle()` guarded free and `CLOSED`. The snapshotted error is raised. | +| Any other error | either | Taken, then the operation failed | `_teardown(free_handle=False)`: the native side already dropped the value, so nothing is freed here. Resource goes `CLOSED`, error typed from the native message. | +| No error at all | either | Unknown | `_release_handle()` guarded free, the caller's message is raised with `"Unknown error"` filled in. | + +The registry hands out odd, never-reused ids for object handles (even addresses are reserved for returned strings and byte buffers), so the guarded free on the ambiguous branch cannot hit a recycled id: it is a real free if the handle is still tracked and a `-1` no-op if native already dropped it. This error and ownership triage relies on the native error still being readable (and correctly being the last error encountered) after the call returns. Reading an error copies the message out and frees the copy, but leaves the native slot set until the next error overwrites it. @@ -455,17 +465,17 @@ Three consume helpers share this triage; they differ only in what the FFI call r A consuming FFI call can fail. It may reject the borrowed pointer before taking it, or it may take ownership first and then, on a later failure, drop the value itself. -The native error message indicates which of the errors happened. A rejection carries one of the `_PRE_CONSUME_ERROR_TAGS` (`UntrackedPointer:` or `WrongPointerType:`), which means the handle was never taken and is retained. Any other error message means the native side may have taken ownership and already dropped the value. On top of those, preparing the call's own arguments can fail in Python before the native function ever runs (for example, encoding a bad value or a ctypes marshalling error other than `ArgumentError`), and that outcome is handled separately. +The native error message indicates which of the errors happened. For validate-first calls a registry rejection means the handle was never taken and is retained. For consume-first calls it is retained only when the rejection names this handle's id (see the table above). Any other error message means the native side may have taken ownership and already dropped the value. On top of those, preparing the call's own arguments can fail in Python before the native function ever runs (for example, encoding a bad value or a ctypes marshalling error other than `ArgumentError`), and that outcome is handled separately. -The two settled branches each take the exact action their ownership implies. A pre-consume rejection (an error prefixed `UntrackedPointer:` or `WrongPointerType:`) means the handle is still the caller's, so it is retained and freed later by normal cleanup. Any other native error means the value is already gone, so `_teardown(free_handle=False)` runs the Python-side cleanup without freeing anything. +The two settled branches each take the exact action their ownership implies. A pre-consume rejection means the handle is still the caller's, so it is retained and freed later by normal cleanup. Any other native error means the value is already gone, so `_teardown(free_handle=False)` runs the Python-side cleanup without freeing anything. Always calling the guarded free instead, even where the value is known to be gone, is tempting because a stale free looks like a harmless `-1` no-op. It is only harmless while the freed address stays unclaimed. The native registry rejects an address it no longer tracks, but once another thread allocates a fresh tracked object at that recycled address, the registry does track it again — and a stale free aimed at the old value would now find a live entry and destroy a different thread's object. The scenario is unlikely, but not unreachable: it needs a second thread inside its own FFI call, an allocator that hands back the exact address just freed, and that reuse to happen during the (narrow) window between the native drop and this free. But the window is real under concurrent use. The failure is a silent cross-thread corruption rather than a clean error, and the free is not needed in the first place on this branch. So where the value is known to be consumed, the free is skipped rather than issued and left to the registry to reject. The native error slot stays sticky: it holds whatever it last held until the next error overwrites it, and nothing clears it in between. Issuing an unneeded free would set an untracked-pointer error there that a later caller could mistake for the failure it actually asked about, so skipping the free keeps the slot free for the next real error. -`_release_handle()` (a guarded free) is reserved for the two branches where ownership is not known for certain: a Python exception raised before native reports anything, and a failure that leaves the error slot empty (which no defined native failure is expected to produce). In both, a guarded free is a good default, since it is a real free when the handle is still ours and a `-1` no-op when the native side already took it. +`_release_handle()` (a guarded free) is reserved for the branches where ownership is not known for certain: a Python exception raised before native reports anything, an address-less registry rejection on a consume-first call, and a failure that leaves the error slot empty (which no defined native failure is expected to produce). In both, a guarded free is a good default, since it is a real free when the handle is still ours and a `-1` no-op when the native side already took it. None of this is protected by a lock on the Python side: `ManagedResource` has no thread-safety mechanism of its own, and the retained-vs-consumed guarantee comes entirely from the native pointer registry and its thread-local error slot. As noted under [Which double-free risks this layer guards](#double-free-risk-mitigations), sharing one instance across threads without external synchronization is the caller's responsibility. This is a different hazard from [Fork safety](#fork-safety), which concerns a forked child process, not a thread within the same process. -A consuming C FFI function first removes the pointer from its registry, then reconstructs the owned value from it. `untrack_or_return!` runs ahead of `Box::from_raw` in `c2pa_c_ffi`. If the address is unknown or the wrong type, the untrack step fails before ownership is taken and sets an error whose prefix (`UntrackedPointer:` or `WrongPointerType:`) identifies it as a pre-consume rejection. Once the value has been reconstructed, a later failure simply drops it, the same as any owned value going out of scope. The Python side stays defensive (and as generic as possible) rather than assuming any exact behavior: it retains the handle when it recognizes one of those rejection prefixes, and where the outcome is unclear it falls back to the guarded free. A native side that behaved differently would degrade in one of two bounded ways: If it kept a pointer the Python side treated as consumed, nothing would free that pointer and it would leak. If it had already released a pointer the Python side then tried to free, the registry would not find the address and the free would return `-1` without touching memory. +A consuming C FFI function first removes the pointer from its registry, then reconstructs the owned value from it. `untrack_or_return!` runs ahead of `Box::from_raw` in `c2pa_c_ffi`. If the address is unknown or the wrong type, the untrack step fails before ownership is taken and sets an error whose prefix (`UntrackedPointer:` or `WrongPointerType:`) identifies it as a pre-consume rejection. Once the value has been reconstructed, a later failure simply drops it, the same as any owned value going out of scope. Consume-first calls then validate their other arguments, whose own registry rejections use the same prefixes, which is why the Python side compares the named id with its own handle. The Python side stays defensive rather than assuming any exact behavior: where the outcome is unclear it falls back to the guarded free. A native side that behaved differently would degrade in one of two bounded ways: If it kept a pointer the Python side treated as consumed, nothing would free that pointer and it would leak. If it had already released a pointer the Python side then tried to free, the registry would not find the address and the free would return `-1` without touching memory. ### Adopting the handle before giving it away diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 362bec3c..bc16f3ef 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -17,6 +17,7 @@ import enum import json import logging +import re import sys import os import threading @@ -573,10 +574,15 @@ def _swap_handle(self, new_handle): self._handle = new_handle - # Errors set by native lib, hinting at the cause of the error - # These errors here means the pointer got somehow rejected by the lib, - # so it is still ours to deal with. + # Registry rejections set by the native lib. They are raised before the + # native call takes ownership of the handle they name, but consume-first + # calls may raise them for a *different* argument after taking this handle. + # UntrackedPointer/WrongPointerType name the rejected handle; the others + # carry no address. _PRE_CONSUME_ERROR_TAGS = ("UntrackedPointer:", "WrongPointerType:") + _ADDRESSLESS_REJECTION_TAGS = ("PointerInUse:", "WrongWrapperKind:") + _REJECTED_HANDLE_RE = re.compile( + r"(?:UntrackedPointer|WrongPointerType): 0x([0-9a-fA-F]+)") def _invoke_consume(self, ffi_call, error_message): """Run an FFI call that consumes this handle, returning its raw result. @@ -607,7 +613,14 @@ def _invoke_consume(self, ffi_call, error_message): self._release_handle() raise C2paError(error_message.format(e)) from e - def _raise_consume_failure(self, error_message): + def _handle_value(self): + """Return this handle's integer value without a ctypes.cast cycle.""" + handle = self._handle + if not handle: + return None + return ctypes.c_void_p.from_buffer(handle).value + + def _raise_consume_failure(self, error_message, *, consumes_first=False): """Raise the error from an FFI handler consuming call. The native error is read before any free so a free's own @@ -617,33 +630,65 @@ def _raise_consume_failure(self, error_message): That ordering is required: c2pa_free on a handle the registry no longer tracks returns -1 and - overwrites the slot with its own "Other: UntrackedPointer: 0x..." - message. Freeing first would therefore replace the real failure - with another one and, because that substitute carries a pre-consume - tag, invert the retain/consume decision made below. + overwrites the slot with its own "UntrackedPointer: 0x..." message. + Freeing first would therefore replace the real failure with another + one and could invert the retain/consume decision made below. + + Ownership triage depends on the call's validation order: + + * ``consumes_first=False`` (e.g. context_builder_set_signer): every + other argument is validated before this handle is untracked, so any + registry rejection means this handle was retained. + * ``consumes_first=True`` (Reader/Builder ``with_*``, context build): + native untracks this handle before validating the other arguments. + A rejection naming this handle retains it; one naming another + handle means this one was already consumed. A rejection without an + address (PointerInUse, WrongWrapperKind) is ambiguous: free + defensively (a no-op when native already dropped it; handle ids are + never reused) and close. Args: error_message: Format string with one placeholder, used when the native layer offers no error of its own. + consumes_first: Whether native takes this handle before validating + other arguments. Raises: C2paError: Always; typed by the native error when there is one. """ error = _read_native_error() if error: - if any(tag in error - for tag in ManagedResource._PRE_CONSUME_ERROR_TAGS): + name = type(self).__name__ + rejected = any( + tag in error + for tag in (ManagedResource._PRE_CONSUME_ERROR_TAGS + + ManagedResource._ADDRESSLESS_REJECTION_TAGS)) + match = ManagedResource._REJECTED_HANDLE_RE.search(error) + if not consumes_first and rejected: logger.warning( - "%s: native call rejected the handle before taking " - "ownership (%s); handle retained", - type(self).__name__, - error) + "%s: native call rejected an argument before taking " + "ownership (%s); handle retained", name, error) + _raise_typed_c2pa_error(error) + if consumes_first and match: + if int(match.group(1), 16) == self._handle_value(): + logger.warning( + "%s: native call rejected the handle before taking " + "ownership (%s); handle retained", name, error) + _raise_typed_c2pa_error(error) + # Another argument was rejected after native took this handle + # and dropped it on the early return. + self._teardown(free_handle=False) + _raise_typed_c2pa_error(error) + if consumes_first and rejected: + # No parseable address (PointerInUse, WrongWrapperKind, ...). + logger.warning( + "%s: ambiguous registry rejection (%s); releasing the " + "handle defensively", name, error) + self._release_handle() _raise_typed_c2pa_error(error) - # A non-tag error means the native side took ownership then failed, - # dropping the value itself: mark consumed, do not free (a free here - # would be a guarded no-op that dirties the error slot and races a - # recycled address in other threads). + # A non-registry error means the native side took ownership then + # failed, dropping the value itself: mark consumed, do not free. self._teardown(free_handle=False) _raise_typed_c2pa_error(error) @@ -651,7 +696,7 @@ def _raise_consume_failure(self, error_message): self._release_handle() raise C2paError(error_message.format("Unknown error")) - def _consume_and_swap(self, ffi_call, error_message): + def _consume_and_swap(self, ffi_call, error_message, *, consumes_first=False): """Run an FFI call that consumes this handle and returns a replacement. On success the native lib consumed the handle and returned a new one, which we swap in. A null return is a failure. @@ -660,9 +705,11 @@ def _consume_and_swap(self, ffi_call, error_message): if new_ptr: self._swap_handle(new_ptr) return - self._raise_consume_failure(error_message) + self._raise_consume_failure( + error_message, consumes_first=consumes_first) - def _consume_no_replacement(self, ffi_call, error_message): + def _consume_no_replacement(self, ffi_call, error_message, *, + consumes_first=False): """Run an FFI call that consumes this handle on success, when the native call returns a status code (0 = success) rather than a replacement handle. A non-zero status is a failure routed to @@ -672,9 +719,10 @@ def _consume_no_replacement(self, ffi_call, error_message): if result == 0: self._teardown(free_handle=False) return - self._raise_consume_failure(error_message) + self._raise_consume_failure( + error_message, consumes_first=consumes_first) - def _consume_into(self, ffi_call, error_message): + def _consume_into(self, ffi_call, error_message, *, consumes_first=False): """Run an FFI call that consumes this handle and returns a *different* object's pointer. On success this handle is consumed (mark, don't free) and the new pointer is returned for the caller to own. A null return is @@ -684,7 +732,8 @@ def _consume_into(self, ffi_call, error_message): if result: self._teardown(free_handle=False) return result - self._raise_consume_failure(error_message) + self._raise_consume_failure( + error_message, consumes_first=consumes_first) @classmethod def _wrap_native_handle(cls, handle): @@ -2260,7 +2309,8 @@ def __init__( context_ptr = nb._consume_into( lambda h: _lib.c2pa_context_builder_build(h), - "Failed to build Context: {}") + "Failed to build Context: {}", + consumes_first=True) self._activate(context_ptr) @@ -4311,7 +4361,8 @@ def _init_from_context(self, context, format_or_path, len(manifest_data), ) ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], + consumes_first=True) else: # Consume reader with stream self._consume_and_swap( @@ -4319,7 +4370,8 @@ def _init_from_context(self, context, format_or_path, handle, format_arg, self._own_stream._stream, ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], + consumes_first=True) except Exception: self._close_streams() raise @@ -4432,7 +4484,8 @@ def with_fragment(self, format: Optional[str], stream, main_obj._stream, frag_obj._stream, ), - Reader._ERROR_MESSAGES['fragment_error']) + Reader._ERROR_MESSAGES['fragment_error'], + consumes_first=True) # Invalidate caches: processing a new BMFF fragment updates the native # reader's state, which can change the manifest data it returns. @@ -5234,7 +5287,8 @@ def _init_from_context(self, context, json_str): self._consume_and_swap( lambda handle: _lib.c2pa_builder_with_definition( handle, json_str), - Builder._ERROR_MESSAGES['builder_error']) + Builder._ERROR_MESSAGES['builder_error'], + consumes_first=True) def _init_attrs(self): super()._init_attrs() @@ -5525,7 +5579,8 @@ def with_archive(self, stream: Any) -> 'Builder': self._consume_and_swap( lambda handle: _lib.c2pa_builder_with_archive( handle, stream_obj._stream), - Builder._ERROR_MESSAGES['archive_load_error']) + Builder._ERROR_MESSAGES['archive_load_error'], + consumes_first=True) return self diff --git a/tests/test_native_ownership.py b/tests/test_native_ownership.py new file mode 100644 index 00000000..dddaf3c1 --- /dev/null +++ b/tests/test_native_ownership.py @@ -0,0 +1,197 @@ +"""Ownership triage for consuming FFI calls against the opaque-handle registry. + +Consume-first native calls (Reader/Builder ``with_*``) untrack the managed +handle before validating their other arguments, so a registry rejection of a +different argument arrives after the managed handle was already dropped. These +tests use the real native library; only the rejected *argument* is synthetic. +""" + +import ctypes +import io +import json +import os +from pathlib import Path + +import pytest + +import c2pa.c2pa as binding +from c2pa import Builder, C2paError, Context, Reader, Signer, C2paSignerInfo +from c2pa.c2pa import LifecycleState, ManagedResource + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _addr(pointer): + return ctypes.cast(pointer, ctypes.c_void_p).value + + +def _untracked_stream(): + # Never handed out by the registry, so every lookup rejects it by address. + buffer = ctypes.create_string_buffer(64) + return ctypes.cast(buffer, ctypes.POINTER(binding.C2paStream)), buffer + + +@pytest.fixture +def reader(): + with open(FIXTURES / "dashinit.mp4", "rb") as init: + value = Reader("video/mp4", init) + yield value + value.close() + + +@pytest.fixture +def frees(monkeypatch): + calls = [] + real_free = ManagedResource._free_native_ptr + + def record(pointer): + calls.append(_addr(pointer)) + return real_free(pointer) + + monkeypatch.setattr(ManagedResource, "_free_native_ptr", staticmethod(record)) + return calls + + +def _assert_closed(resource): + assert resource._handle is None + assert resource._lifecycle_state == LifecycleState.CLOSED + resource.close() + resource.close() + + +def _still_tracked(handle_value): + """Return whether the registry still tracks a handle id (frees it if so).""" + return binding._lib.c2pa_free(ctypes.c_void_p(handle_value)) == 0 + + +def test_rejected_fragment_stream_after_reader_consumed_closes_reader( + reader, monkeypatch, frees): + consumed = reader._handle_value() + bogus, _keep = _untracked_stream() + real_call = binding._lib.c2pa_reader_with_fragment + monkeypatch.setattr( + binding._lib, "c2pa_reader_with_fragment", + lambda handle, fmt, stream, fragment: real_call(handle, fmt, stream, bogus)) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + reader.with_fragment("video/mp4", init, fragment) + assert f"0x{_addr(bogus):x}" in str(caught.value) + _assert_closed(reader) + assert consumed not in frees, "consumed handle must not be freed again" + assert not _still_tracked(consumed), "native already dropped the reader" + + +def test_rejected_stream_after_context_reader_consumed_is_not_freed( + monkeypatch, frees): + bogus, _keep = _untracked_stream() + real_call = binding._lib.c2pa_reader_with_stream + seen = [] + + def call(handle, fmt, stream): + seen.append(_addr(handle)) + return real_call(handle, fmt, bogus) + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_stream", call) + with Context() as context, open(FIXTURES / "dashinit.mp4", "rb") as init: + with pytest.raises(C2paError, match="UntrackedPointer"): + Reader("video/mp4", init, context=context) + assert len(seen) == 1, "the consume-first native call was not reached" + assert seen[0] not in frees, "consumed reader must not be freed again" + assert not _still_tracked(seen[0]) + + +def test_rejection_naming_the_managed_handle_retains_it(reader): + stale_owner, _keep = _untracked_stream() + real_handle = reader._handle + reader._handle = ctypes.cast(stale_owner, ctypes.POINTER(binding.C2paReader)) + try: + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer"): + reader.with_fragment("video/mp4", init, fragment) + assert reader._lifecycle_state == LifecycleState.ACTIVE + finally: + reader._handle = real_handle + assert reader.json() + + +def test_successful_fragment_swap_replaces_without_freeing_consumed(reader, frees): + consumed = reader._handle_value() + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + reader.with_fragment("video/mp4", init, fragment) + assert reader._handle_value() not in (None, consumed) + assert consumed not in frees + assert not _still_tracked(consumed) + replacement = reader._handle_value() + reader.close() + reader.close() + assert frees.count(replacement) == 1 + + +@pytest.mark.parametrize("tag", [ + "Other: PointerInUse: handle already in (exclusive) use", + "Other: WrongWrapperKind: Arc-backed handle can't have single ownership", +]) +def test_addressless_rejection_on_consume_first_call_releases_defensively( + reader, monkeypatch, frees, tag): + managed = reader._handle_value() + + def rejected(*_args): + binding._lib.c2pa_error_set_last(tag.encode()) + return None + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_fragment", rejected) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError) as caught: + reader.with_fragment("video/mp4", init, fragment) + # The original rejection survives the defensive free's own error slot. + assert tag.split(": ", 1)[1].split(":")[0] in str(caught.value) + assert frees.count(managed) == 1 + _assert_closed(reader) + assert frees.count(managed) == 1 + assert not _still_tracked(managed) + + +def test_invalid_archive_stream_after_builder_consumed_closes_builder( + monkeypatch, frees): + builder = Builder({"claim_generator_info": [{"name": "ownership-test"}], + "assertions": []}) + consumed = builder._handle_value() + bogus, _keep = _untracked_stream() + real_call = binding._lib.c2pa_builder_with_archive + monkeypatch.setattr(binding._lib, "c2pa_builder_with_archive", + lambda handle, stream: real_call(handle, bogus)) + with pytest.raises(C2paError, match="UntrackedPointer"): + builder.with_archive(io.BytesIO(b"unused")) + _assert_closed(builder) + assert consumed not in frees + assert not _still_tracked(consumed) + + +def _signer(): + return Signer.from_info(C2paSignerInfo( + alg=b"es256", sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + + +@pytest.mark.parametrize("tag", [ + "Other: PointerInUse: handle already in (exclusive) use", + "Other: UntrackedPointer: 0x3", +]) +def test_set_signer_validates_builder_first_so_rejection_retains_signer( + monkeypatch, tag): + signer = _signer() + + def rejected(*_args): + binding._lib.c2pa_error_set_last(tag.encode()) + return -1 + + monkeypatch.setattr(binding._lib, "c2pa_context_builder_set_signer", rejected) + with pytest.raises(C2paError): + Context(signer=signer) + assert signer._lifecycle_state == LifecycleState.ACTIVE + assert signer.reserve_size() > 0 + signer.close() diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 06a450fd..506e5062 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -10564,6 +10564,7 @@ def test_mocked_null_without_error_is_a_known_limitation(self): with open(init_path, "rb") as init: reader = Reader("video/mp4", init) + leaked = ctypes.c_void_p(reader._handle_value()) real_call = c2pa_module._lib.c2pa_reader_with_fragment c2pa_module._lib.c2pa_reader_with_fragment = ( @@ -10580,10 +10581,13 @@ def test_mocked_null_without_error_is_a_known_limitation(self): c2pa_module._lib.c2pa_error_set_last( b"Other: cleared by test teardown") - # The stale tag wins, so the handle is kept. Safe here (the mock - # consumed nothing), and the reader is still usable. - self.assertIsNotNone(reader._handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + # The stale tag wins. with_fragment consumes the reader before + # validating its other arguments, and the stale tag names a different + # handle, so the reader is classified as consumed and closed without a + # free. The mock consumed nothing, so release the id the test leaked. + self.assertIsNone(reader._handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertEqual(c2pa_module._lib.c2pa_free(leaked), 0) reader.close() # Backfilling a pointer minted by a direct FFI call. Builder.from_archive From f4f7dac84a3ebef0f736b32c0a9d5f335e6ec60f Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 06:36:34 +0200 Subject: [PATCH 23/32] fix: declare C2paStream as the opaque native handle it is --- src/c2pa/c2pa.py | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index bc16f3ef..40e8d8bd 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -917,40 +917,15 @@ class C2paSigner(ctypes.Structure): class C2paStream(ctypes.Structure): - """A C2paStream is a Rust Read/Write/Seek stream that can be created in C. - - This class represents a low-level stream interface that bridges Python - and Rust/C code. It implements the Rust Read/Write/Seek traits in C, - allowing for efficient data transfer between Python and the C2PA library - without unnecessary copying. - - The stream is used for various operations including: - - Reading manifest data from files - - Writing signed content to files - - Handling binary resources - - Managing ingredient data - - The structure contains function pointers that implement stream operations: - - reader: Function to read data from the stream - - seeker: Function to change the stream position - - writer: Function to write data to the stream - - flusher: Function to flush any buffered data - - This is a critical component for performance as it allows direct memory - access between Python and the C2PA library without intermediate copies. + """Opaque handle to a native Rust Read/Write/Seek stream. + + Created by ``c2pa_create_stream`` from Python read/seek/write/flush + callbacks and released with ``c2pa_release_stream``. The native header + declares it opaque: the returned value is a registry id, not the address + of a readable structure, so Python must only pass it back to native calls + and never dereference it or assume a field layout. """ - _fields_ = [ - # Opaque context pointer for the stream - ("context", ctypes.POINTER(StreamContext)), - # Function to read data from the stream - ("reader", ReadCallback), - # Function to change stream position - ("seeker", SeekCallback), - # Function to write data to the stream - ("writer", WriteCallback), - # Function to flush buffered data - ("flusher", FlushCallback), - ] + _fields_ = [] def _read_native_error() -> Optional[str]: From 4568ecd153bdfe1108c545867288ea64c4843f6f Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 06:39:09 +0200 Subject: [PATCH 24/32] chore: pair functional qualification with consolidated native 5c186c07 as 0.37.13.dev0 --- .github/workflows/trusted-vsi-paired.yml | 31 +++++++++++++++++------ docs/release-notes.md | 14 +++++++--- docs/trusted-vsi-python-contract.md | 24 ++++++++++++------ pyproject.toml | 2 +- scripts/build_trusted_vsi_functional.py | 16 +++++++++--- scripts/qualify_trusted_vsi_functional.py | 14 +++++++++- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_tooling.py | 25 ++++++++++++++---- tests/test_fragmented_files.py | 11 ++++++++ tests/test_trusted_vsi_build.py | 28 +++++++++++++++----- 10 files changed, 130 insertions(+), 37 deletions(-) diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index 18295116..8abcf3bd 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -29,10 +29,14 @@ jobs: CARGO_TARGET_DIR: ${{ github.workspace }}/paired-rust/target PYTHONPATH: ${{ github.workspace }}/python-source/src C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/${{ matrix.library }} - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev C2PA_TRUSTED_VSI_ABI_REQUIRED: "1" C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1" - FUNCTIONAL_BUILD_VERSION: 0.37.9.dev0 + C2PA_REQUIRE_SIGN_LADDER: "1" + C2PA_REQUIRE_FRAGMENTED_FILES: "1" + FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0 + # The release smoke asserts the installed distribution version. + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev0 steps: - name: Require a full Python source SHA shell: bash @@ -46,9 +50,10 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - # Reviewed functional trusted VSI native (feat/trusted-vsi-functional). - # Qualification-only pairing; never the immutable dev5 release input. - ref: 3569fb860babe52db778f82e3fe80e8371fe2a08 + # Reviewed consolidated trusted VSI native (feat/trusted-vsi-functional, + # Linux/Windows qualification run 36659887610). Qualification-only + # pairing; never the immutable dev5 release input. + ref: 5c186c07ac9b432d3b8f1336ecb6ee09518408de path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -62,8 +67,8 @@ jobs: shell: bash working-directory: paired-rust run: | - rustup toolchain install 1.88.0 --profile minimal - cargo +1.88.0 build --locked -p c2pa-c-ffi --no-default-features \ + rustup toolchain install 1.96.0 --profile minimal + cargo +1.96.0 build --locked -p c2pa-c-ffi --no-default-features \ --features rust_native_crypto,http,add_thumbnails,file_io,unstable_live_video - name: Prepare source dependencies shell: bash @@ -74,7 +79,17 @@ jobs: - name: Require functional native ABI and run focused tests without skips shell: bash working-directory: python-source - run: python -m pytest -q tests/test_trusted_vsi_api.py -ra + run: >- + python -m pytest -q tests/test_trusted_vsi_api.py + tests/test_fragmented_files.py tests/test_sign_ladder.py + tests/test_native_ownership.py -ra + - name: Real-native ladder harness (candidate lane) + shell: bash + working-directory: python-source + run: >- + python tests/ladder_native.py --lane candidate + --library "$C2PA_LIBRARY_NAME" + --native-fixtures "${{ github.workspace }}/paired-rust/sdk/tests/fixtures" - name: Non-threaded regressions timeout-minutes: 20 shell: bash diff --git a/docs/release-notes.md b/docs/release-notes.md index ce7bf54e..58ddf0c8 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -18,9 +18,17 @@ - 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 a separate staged development version (default - `0.37.9.dev0`). Immutable dev5 release facts, pins and artifact names remain - unchanged. Functional native qualification is required, never an optional skip. + 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@5c186c07`, Rust 1.96.0) and integrates single-file ladder + signing (`Builder.sign_ladder`). Ladder signing now propagates + DynamicAssertion and claim-signer interrupt exceptions like the other Builder + paths. Consume-first FFI calls (Reader/Builder `with_*`) no longer treat a + registry rejection of another argument as proof the managed handle was + retained. ## Version 0.37.8.dev5 diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 4a2d1c49..383bfbee 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -184,18 +184,26 @@ CBOR, BMFF or validation results. `scripts/build_trusted_vsi_functional.py --library --out ` first requires all probes true against that library, then builds a NON-PUBLISHING wheel and sdist in a temporary staging copy with -`FUNCTIONAL_BUILD_VERSION` (default `0.37.9.dev0`; rejects `0.37.8.*`, dev5 and -release versions). The checkout's `0.37.8.dev5` metadata is not modified. -`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native library only. +`FUNCTIONAL_BUILD_VERSION` (default: the checkout's source identity +`0.37.13.dev0`; rejects `0.37.8.*`, dev5, release versions and anything older +than the source identity, including the historical `0.37.9.dev0`). +`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native library only. The +immutable dev5 release lock and tooling are unchanged and refuse this source, +whose `pyproject.toml` version differs from the lock. `scripts/qualify_trusted_vsi_functional.py --wheel --venv ---version 0.37.9.dev0` installs the wheel into an isolated venv, strips -`PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs this test file with -`C2PA_TRUSTED_VSI_ABI_REQUIRED=1`; the paired fixture asserts the imported package -and native library come from that venv with the expected version. +--version 0.37.13.dev0` installs the wheel into an isolated venv, strips +`PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, +`test_fragmented_files.py`, `test_sign_ladder.py` and +`test_native_ownership.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, +`C2PA_REQUIRE_SIGN_LADDER=1` and `C2PA_REQUIRE_FRAGMENTED_FILES=1`; the paired +fixture asserts the imported package and native library come from that venv +with the expected version, and missing ladder or fragmented capabilities fail +rather than skip. Paired tests require the full native library and FAIL under `C2PA_TRUSTED_VSI_ABI_REQUIRED=1` (all Linux/Windows qualification jobs); they skip only in ad-hoc local runs. `.github/workflows/trusted-vsi-paired.yml` -checks out the reviewed native commit `3569fb860babe52db778f82e3fe80e8371fe2a08` +builds the native with Rust 1.96.0 from the reviewed consolidated commit +`5c186c07ac9b432d3b8f1336ecb6ee09518408de` by full SHA; update that pin (not a branch name) for later native revisions. diff --git a/pyproject.toml b/pyproject.toml index 6a039647..9eb5b58f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.8.dev5" +version = "0.37.13.dev0" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/scripts/build_trusted_vsi_functional.py b/scripts/build_trusted_vsi_functional.py index 23d6aa04..7d59c798 100644 --- a/scripts/build_trusted_vsi_functional.py +++ b/scripts/build_trusted_vsi_functional.py @@ -24,11 +24,21 @@ ROOT = Path(__file__).resolve().parents[1] -def functional_version(value: str) -> str: +def source_version(root: Path = ROOT) -> str: + """Return the checkout's unreleased functional source identity.""" + return toml.load(root / "pyproject.toml")["project"]["version"] + + +def functional_version(value: str, root: Path = ROOT) -> str: version = Version(value) if (not version.is_devrelease or version <= Version("0.37.8.dev5") or version.release == (0, 37, 8)): raise ValueError("functional version must be a newer development series than 0.37.8") + minimum = Version(source_version(root)) + if version < minimum: + raise ValueError( + f"functional version {version} must not be older than the source " + f"identity {minimum}") return str(version) @@ -45,7 +55,7 @@ def functional_version(value: str) -> str: def stage_source(stage: Path, version: str, root: Path = ROOT) -> None: """Copy the checkout into ``stage`` and apply ``version`` there only.""" - version = functional_version(version) + version = functional_version(version, root) for directory in STAGED_DIRECTORIES: if (root / directory).is_dir(): shutil.copytree(root / directory, stage / directory, ignore=shutil.ignore_patterns( @@ -67,7 +77,7 @@ def stage_source(stage: Path, version: str, root: Path = ROOT) -> None: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--library", type=Path, required=True) - parser.add_argument("--version", default=os.environ.get("FUNCTIONAL_BUILD_VERSION", "0.37.9.dev0")) + parser.add_argument("--version", default=os.environ.get("FUNCTIONAL_BUILD_VERSION") or source_version()) parser.add_argument("--out", type=Path, required=True) args = parser.parse_args() version = functional_version(args.version) diff --git a/scripts/qualify_trusted_vsi_functional.py b/scripts/qualify_trusted_vsi_functional.py index 01aa1131..fbbdf8f1 100644 --- a/scripts/qualify_trusted_vsi_functional.py +++ b/scripts/qualify_trusted_vsi_functional.py @@ -8,6 +8,16 @@ import venv +# test_trusted_vsi_api.py asserts the import resolves inside the venv, so the +# whole session is proven to exercise the installed wheel rather than src/. +INSTALLED_TESTS = ( + "test_trusted_vsi_api.py", + "test_fragmented_files.py", + "test_sign_ladder.py", + "test_native_ownership.py", +) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--wheel", type=Path, required=True) @@ -25,12 +35,14 @@ def main(): env.pop(name, None) env.update(C2PA_TRUSTED_VSI_ABI_REQUIRED="1", C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED="1", + C2PA_REQUIRE_SIGN_LADDER="1", + C2PA_REQUIRE_FRAGMENTED_FILES="1", C2PA_FUNCTIONAL_EXPECTED_VERSION=args.version, C2PA_FUNCTIONAL_INSTALLED_ROOT=str(environment)) subprocess.run([str(python), "-m", "pip", "install", "--no-deps", "--ignore-installed", str(args.wheel.resolve(strict=True))], cwd=environment, env=env, check=True) subprocess.run([str(python), "-m", "pytest", "-q", - str(root / "tests/test_trusted_vsi_api.py"), "-ra"], + *(str(root / "tests" / name) for name in INSTALLED_TESTS), "-ra"], cwd=environment, env=env, check=True, timeout=480) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 40e8d8bd..1b18f607 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.8.dev5 +# Version: 0.37.13.dev0 import ctypes import enum diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index b121130f..3de08778 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -49,11 +49,19 @@ def _posix_bash() -> str: def test_prerelease_version_is_consistent(): - assert release.project_version(ROOT) == "0.37.8.dev5" + # The immutable dev5 release identity stays frozen in the tooling and lock. + assert release.RELEASE_VERSION == "0.37.8.dev5" + assert release.load_lock()["package"]["version"] == "0.37.8.dev5" + # This checkout is the unreleased consolidated functional source. Its + # identity is consistent and deliberately differs from dev5, so + # validate-sources refuses to release it under the dev5 lock. + source = release.project_version(ROOT) + assert source == "0.37.13.dev0" + assert source != release.RELEASE_VERSION first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) - assert first_line == "# Version: 0.37.8.dev5" + assert first_line == f"# Version: {source}" def test_release_lock_and_schemas_are_valid_json(): @@ -93,17 +101,24 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): assert dedicated.count(f"ref: {release.RUST_COMMIT}") == 3 assert 'C2PA_TRUSTED_VSI_ABI_REQUIRED: "1"' in paired assert 'C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1"' in paired - assert "python -m pytest -q tests/test_trusted_vsi_api.py -ra" in paired + focused = ("python -m pytest -q tests/test_trusted_vsi_api.py\n" + " tests/test_fragmented_files.py tests/test_sign_ladder.py\n" + " tests/test_native_ownership.py -ra") + assert focused in paired assert "-k " not in paired assert "ubuntu-24.04" in paired and "windows-2022" in paired assert "C2PA_LIBRARY_NAME: ${{ github.workspace }}/paired-rust/target/debug/" in paired assert "PYTHONPATH: ${{ github.workspace }}/python-source/src" in paired assert "python setup.py egg_info" in paired - assert "cargo +1.88.0 build --locked" in paired + assert "cargo +1.96.0 build --locked" in paired # Paired native is pinned to a reviewed full SHA, never a moving branch. assert re.search(r"^\s+ref: [0-9a-f]{40}$", paired, re.MULTILINE) assert "ref: feat/" not in paired - assert "FUNCTIONAL_BUILD_VERSION: 0.37.9.dev0" in paired + assert "FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0" in paired + assert "C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev" in paired + assert 'C2PA_REQUIRE_SIGN_LADDER: "1"' in paired + assert 'C2PA_REQUIRE_FRAGMENTED_FILES: "1"' in paired + assert "tests/ladder_native.py --lane candidate" in paired assert "scripts/build_trusted_vsi_functional.py" in paired assert "scripts/qualify_trusted_vsi_functional.py" in paired for forbidden in ("download_artifacts.py", "castlabs_release.py", diff --git a/tests/test_fragmented_files.py b/tests/test_fragmented_files.py index 99d33e94..c945ed98 100644 --- a/tests/test_fragmented_files.py +++ b/tests/test_fragmented_files.py @@ -5,6 +5,7 @@ import ctypes import gc import json +import os import tempfile import unittest import weakref @@ -225,6 +226,16 @@ def test_missing_capability_has_clear_errors(self): context.close() +class TestFragmentedCapabilityRequired(unittest.TestCase): + def test_required_fragmented_capability_is_present(self): + if os.environ.get("C2PA_REQUIRE_FRAGMENTED_FILES") != "1": + self.skipTest("C2PA_REQUIRE_FRAGMENTED_FILES is not set") + self.assertTrue( + has_fragmented_files(), + "C2PA_REQUIRE_FRAGMENTED_FILES=1 but the loaded native library " + "lacks the fragmented BMFF file APIs") + + @unittest.skipUnless( has_fragmented_files(), "native library does not provide fragmented BMFF file APIs", diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index fb83cbf7..e7a3be77 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.9.dev0", "0.38.0.dev1", "1.0.dev0+local"]) +@pytest.mark.parametrize("version", ["0.37.13.dev0", "0.37.13.dev1", "0.38.0.dev1", "1.0.dev0+local"]) def test_new_functional_development_version(version): assert functional_build.functional_version(version) == version @@ -23,6 +23,8 @@ def test_new_functional_development_version(version): @pytest.mark.parametrize("version", [ "0.37.8.dev5", "0.37.8.dev6", "0.37.8.dev5+functional", "0.37.7.dev1", "0.37.9", "0.38.0rc1", "invalid", + # Historical functional qualification identities are older than the source. + "0.37.9.dev0", "0.37.12.dev0", ]) def test_functional_build_rejects_release_or_dev5_identity(version): with pytest.raises(ValueError): @@ -37,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.9.dev0") - assert 'version = "0.37.9.dev0"' in (stage / "pyproject.toml").read_text() - assert "# Version: 0.37.9.dev0" in (stage / "src/c2pa/c2pa.py").read_text() + functional_build.stage_source(stage, "0.37.13.dev1") + assert 'version = "0.37.13.dev1"' in (stage / "pyproject.toml").read_text() + assert "# Version: 0.37.13.dev1" in (stage / "src/c2pa/c2pa.py").read_text() assert not (stage / "src/c2pa/libs").exists() subprocess.run([sys.executable, "setup.py", "-q", "sdist", "--dist-dir", str(tmp_path / "dist")], cwd=stage, check=True, capture_output=True) @@ -48,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.9.dev0") + assert version == Version("0.37.13.dev1") root = sdist.name[:-len(".tar.gz")] with tarfile.open(sdist) as archive: names = archive.getnames() info = archive.extractfile(f"{root}/PKG-INFO").read().decode() - assert "Version: 0.37.9.dev0" in info.splitlines() + assert "Version: 0.37.13.dev1" in info.splitlines() for member in ("scripts/build_trusted_vsi_functional.py", "scripts/qualify_trusted_vsi_functional.py", "docs/trusted-vsi-python-contract.md", "src/c2pa/c2pa.py"): @@ -61,7 +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 - assert 'version = "0.37.8.dev5"' in before["pyproject.toml"].decode() + # The checkout carries the unreleased functional source identity, never dev5. + assert 'version = "0.37.13.dev0"' in before["pyproject.toml"].decode() + + +def test_default_build_version_is_the_source_identity(monkeypatch): + monkeypatch.delenv("FUNCTIONAL_BUILD_VERSION", raising=False) + assert functional_build.source_version() == "0.37.13.dev0" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev0" def test_probe_script_requires_every_functional_capability(): @@ -76,3 +85,8 @@ def test_functional_installed_qualification_removes_source_overrides(): assert 'C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED="1"' in source assert 'C2PA_FUNCTIONAL_INSTALLED_ROOT=str(environment)' in source assert "timeout=480" in source + for required in ('C2PA_REQUIRE_SIGN_LADDER="1"', 'C2PA_REQUIRE_FRAGMENTED_FILES="1"'): + assert required in source + for name in ("test_trusted_vsi_api.py", "test_fragmented_files.py", + "test_sign_ladder.py", "test_native_ownership.py"): + assert f'"{name}"' in source From 941c2ad5b57d23f31dbabf9fbef4776878cf630c Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 06:57:49 +0200 Subject: [PATCH 25/32] fix: address review of consolidated Python deltas --- docs/native-resources-management.md | 4 ++-- src/c2pa/c2pa.py | 11 ++++++++++- tests/test_native_ownership.py | 8 ++++++++ tests/test_sign_ladder.py | 16 ++++++++++++++-- tests/test_unit_tests.py | 3 ++- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index d3691747..b6429af0 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -436,7 +436,7 @@ The two failure paths are indistinguishable from the return value alone. Only th The meaning of a registry rejection depends on the native call's validation order, which each call site declares with `consumes_first`: - **Validate-first** calls (`c2pa_context_builder_set_signer`): every other argument is checked before the consumed handle is untracked, so any registry rejection means the handle was never taken. -- **Consume-first** calls (`c2pa_reader_with_stream`, `c2pa_reader_with_manifest_data_and_stream`, `c2pa_reader_with_fragment`, `c2pa_builder_with_definition`, `c2pa_builder_with_archive`, `c2pa_context_builder_build`): native untracks the handle *before* validating the remaining arguments, so a rejection of another argument (for example a stream) arrives after the managed handle was already dropped. +- **Consume-first** calls (`c2pa_reader_with_stream`, `c2pa_reader_with_manifest_data_and_stream`, `c2pa_reader_with_fragment`, `c2pa_builder_with_definition`, `c2pa_builder_with_archive`, `c2pa_context_builder_build`): native untracks the handle *before* validating the remaining arguments, so a rejection of another argument (for example a stream) arrives after the managed handle was already dropped. This ordering was verified against `castlabs/c2pa-rs@5c186c07`; a native that validated first would only make the Python side misclassify a rejected argument as consumed (a bounded leak, never a double free, because object ids are not reused). | Native error | Call order | Who owns the handle | What the helper does | | --- | --- | --- | --- | @@ -471,7 +471,7 @@ The two settled branches each take the exact action their ownership implies. A p Always calling the guarded free instead, even where the value is known to be gone, is tempting because a stale free looks like a harmless `-1` no-op. It is only harmless while the freed address stays unclaimed. The native registry rejects an address it no longer tracks, but once another thread allocates a fresh tracked object at that recycled address, the registry does track it again — and a stale free aimed at the old value would now find a live entry and destroy a different thread's object. The scenario is unlikely, but not unreachable: it needs a second thread inside its own FFI call, an allocator that hands back the exact address just freed, and that reuse to happen during the (narrow) window between the native drop and this free. But the window is real under concurrent use. The failure is a silent cross-thread corruption rather than a clean error, and the free is not needed in the first place on this branch. So where the value is known to be consumed, the free is skipped rather than issued and left to the registry to reject. The native error slot stays sticky: it holds whatever it last held until the next error overwrites it, and nothing clears it in between. Issuing an unneeded free would set an untracked-pointer error there that a later caller could mistake for the failure it actually asked about, so skipping the free keeps the slot free for the next real error. -`_release_handle()` (a guarded free) is reserved for the branches where ownership is not known for certain: a Python exception raised before native reports anything, an address-less registry rejection on a consume-first call, and a failure that leaves the error slot empty (which no defined native failure is expected to produce). In both, a guarded free is a good default, since it is a real free when the handle is still ours and a `-1` no-op when the native side already took it. +`_release_handle()` (a guarded free) is reserved for the branches where ownership is not known for certain: a Python exception raised before native reports anything, an address-less registry rejection on a consume-first call, and a failure that leaves the error slot empty (which no defined native failure is expected to produce). In each, a guarded free is a good default, since it is a real free when the handle is still ours and a `-1` no-op when the native side already took it. None of this is protected by a lock on the Python side: `ManagedResource` has no thread-safety mechanism of its own, and the retained-vs-consumed guarantee comes entirely from the native pointer registry and its thread-local error slot. As noted under [Which double-free risks this layer guards](#double-free-risk-mitigations), sharing one instance across threads without external synchronization is the caller's responsibility. This is a different hazard from [Fork safety](#fork-safety), which concerns a forked child process, not a thread within the same process. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 1b18f607..63dfc91b 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -306,6 +306,8 @@ def _trusted_vsi_version_matches(native_version: bytes) -> bool: """Return whether c2pa_version() names exactly the paired c2pa-rs build.""" expected = b"c2pa-rs/" + _TRUSTED_VSI_NATIVE_VERSION.encode("ascii") return expected in native_version.split() + + _DYNAMIC_ASSERTIONS_AVAILABLE = all( hasattr(_lib, name) for name in _DYNAMIC_ASSERTION_FUNCTIONS ) @@ -618,7 +620,14 @@ def _handle_value(self): handle = self._handle if not handle: return None - return ctypes.c_void_p.from_buffer(handle).value + if isinstance(handle, int): + return handle + try: + return ctypes.c_void_p.from_buffer(handle).value + except (TypeError, ValueError): + # Unknown handle representation: callers treat None as "not this + # handle", which routes consume-first triage to the guarded free. + return None def _raise_consume_failure(self, error_message, *, consumes_first=False): """Raise the error from an FFI handler consuming call. diff --git a/tests/test_native_ownership.py b/tests/test_native_ownership.py index dddaf3c1..1f6e202f 100644 --- a/tests/test_native_ownership.py +++ b/tests/test_native_ownership.py @@ -31,6 +31,14 @@ def _untracked_stream(): return ctypes.cast(buffer, ctypes.POINTER(binding.C2paStream)), buffer +@pytest.fixture(autouse=True) +def _clear_native_error_slot(): + # These tests plant or provoke registry errors; the slot is sticky and + # thread-local, so a stale tag must not follow later tests around. + yield + binding._lib.c2pa_error_set_last(b"Other: cleared by test teardown") + + @pytest.fixture def reader(): with open(FIXTURES / "dashinit.mp4", "rb") as init: diff --git a/tests/test_sign_ladder.py b/tests/test_sign_ladder.py index b2757750..24342b9f 100644 --- a/tests/test_sign_ladder.py +++ b/tests/test_sign_ladder.py @@ -13,7 +13,6 @@ from types import SimpleNamespace from unittest.mock import Mock -import cbor2 import pytest import c2pa.c2pa as binding @@ -453,7 +452,20 @@ def free(pointer): assert_closed(builder, signer, native, manifest) +def _cbor2(): + # Only the real-native DynamicAssertion test needs cbor2; build.yml's + # installed-wheel jobs run this file without test-only dependencies. + try: + import cbor2 + except ImportError: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER") == "1": + pytest.fail("C2PA_REQUIRE_SIGN_LADDER=1 requires cbor2") + pytest.skip("cbor2 is not installed") + return cbor2 + + def _exact_size_cbor(size, label): + cbor2 = _cbor2() for pad in range(size): encoded = cbor2.dumps({"note": label, "pad": "x" * pad}) if len(encoded) == size: @@ -538,7 +550,7 @@ def callback(callback_label, reserve_size, partial_claim): active = report["manifests"][report["active_manifest"]] manifests.append(active) dynamic = [a for a in active["assertions"] if a["label"] == label] - assert [a["data"] for a in dynamic] == [cbor2.loads(content)] + assert [a["data"] for a in dynamic] == [_cbor2().loads(content)] assert manifests[0] == manifests[1] diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 506e5062..aa172545 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -9757,7 +9757,8 @@ def test_construction_failure_leaves_nothing_to_free(self): c2pa_module._lib.c2pa_builder_from_json = real_json def test_context_build_null_return_frees_builder(self): - # Set a pre-consume tag in the error slot to mock a pointer rejection. + # Build is consume-first. A registry tag without a parseable address is + # ambiguous there, so the builder is released by the guarded free. settings = Settings() c2pa_module._lib.c2pa_error_set_last( b"UntrackedPointer: mocked pre-consume rejection") From 8fbaf2164be182513c1b2e2c99e88a045280ee31 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 07:16:49 +0200 Subject: [PATCH 26/32] docs: record consolidated trusted VSI Python qualification --- docs/trusted-vsi-python-contract.md | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 383bfbee..d3b0c867 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,21 +1,23 @@ # Trusted VSI Python Contract -Status: implemented and qualified locally (Linux) against the functional native -library built from `castlabs/c2pa-rs@3569fb860babe52db778f82e3fe80e8371fe2a08` -(`feat/trusted-vsi-functional`; debug `libc2pa_c.so` SHA-256 -`6c7ccf4258132df1…`, capability mask 63), following `c2pa-rs` -`docs/trusted-vsi-native-contract.md` (SHA-256 -`74ba08dfdf9aa6256f8ba38cdeed1930e4cbee827a5d67c1bc1f9313a41bf0bf`). Since -`37dc25fe…` the native contract added only the Rust-only `trusted_vsi_compute_hash` -and the version-2 state record; the C ABI and Python API are unchanged. Windows -remains to be qualified in CI. Local qualification-only artifacts built from that -library with `scripts/build_trusted_vsi_functional.py` (never published): -`c2pa_python-0.37.9.dev0-py3-none-linux_x86_64.whl` SHA-256 -`e80e1d78a6de0adb0136063a82aadb1a2d950b6977298aefd4e1ed6a31d85e63`, sdist -`7f471855a22d40c556d2a92b336f11e60c7d28174a994d066a6c055d516114bf`; -installed-wheel qualification 100 passed. This is unreleased API; immutable dev5 release inputs and -artifacts are unchanged. The class is `TrustedVsiSession`, and -`reserve_init_uuid()` returns `bytes` (see below). +Status: implemented and qualified against the consolidated functional native +library built from `castlabs/c2pa-rs@5c186c07ac9b432d3b8f1336ecb6ee09518408de` +(`feat/trusted-vsi-functional`, native `0.92.0-dev`, Rust 1.96.0; debug +`libc2pa_c.so` SHA-256 `dc79e81a084fc7b25e12423539b137f24d69693da46cb0166cb04538bd5589f9`, +capability mask 63), following `c2pa-rs` `docs/trusted-vsi-native-contract.md`. +The Python source integrates single-file ladder signing (`Builder.sign_ladder`) +and carries the unreleased identity `0.37.13.dev0`. Qualification of source +`941c2ad5b57d23f31dbabf9fbef4776878cf630c`: local Linux focused 179 passed, +real-native ladder harness passed, non-threaded 714 passed, threaded 54 passed, +installed-wheel 179 passed; hosted Linux/Windows paired run +`castlabs/c2pa-python` Actions 36671268428 passed on both. Local qualification-only artifacts +(never published): `c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` SHA-256 +`8731d135ce2c1db61b061e1f2c76272a55b9f7e7e2e2ea8769b10b5fd4a8707f`, sdist +`e846e5688d07bcf5959885c0c1728afde4ec89bbb7cf2b80a665956056b8b5ab`. The earlier +`0.37.9.dev0` artifacts paired with native `3569fb86` are historical evidence +only. This is unreleased API; immutable dev5 release inputs and artifacts are +unchanged. The class is `TrustedVsiSession`, and `reserve_init_uuid()` returns +`bytes` (see below). ## Availability From 7ba8615bbe2e9a2c6cda63917d224cf0f05135ce Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 12:49:02 +0200 Subject: [PATCH 27/32] fix: preserve native ownership across consume-first failures --- docs/native-resources-management.md | 91 ++++++----- src/c2pa/c2pa.py | 119 ++++++++------ tests/test_native_ownership.py | 232 ++++++++++++++++++++++++++++ tests/test_unit_tests.py | 156 +++++++++++++++++-- 4 files changed, 490 insertions(+), 108 deletions(-) create mode 100644 tests/test_native_ownership.py diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index aa6ef9c6..820d9866 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -19,7 +19,7 @@ A **native pointer** is an address that says where a piece of memory lives. The The **native side** of the Rust library is reached through its C FFI. -A **handle** is a single native pointer a `ManagedResource` object holds and manages, stored in its `_handle` attribute. Each object owns one handle at a time. The lifecycle machinery is mostly about tracking that one handle: creating it, swapping it, and freeing it. +A **handle** is the native pointer-shaped value a `ManagedResource` object holds in its `_handle` attribute. Stock 0.91.0 uses real allocation addresses, which can be reused; newer native registries use opaque, non-reused IDs. Python treats both as handles to pass back to native, not memory to dereference. Each object owns one handle at a time. **Ownership** answers one question: who is responsible for freeing a piece of native memory. Native memory has to be freed (exactly once). The owner is whoever must free it. If nobody frees it, the memory leaks. If two owners each free it, the same memory is freed twice, which corrupts the allocator and can crash the process. So exactly one side owns each pointer at any moment, and that side frees it. @@ -93,7 +93,7 @@ Notes: ## Python frees only what Python owns -The C FFI is consistent about one thing that shapes this whole layer: some calls consume the pointer passed to them and hand back a replacement, because the native side may free and reallocate the underlying value. A pointer that went into a consuming call must never be freed by Python afterwards. Its address may already have been reallocated to a different object (address space is not infinite, so addresses get reused). +Some C FFI calls take ownership of a handle and return a replacement. Once ownership actually moves, Python must not free the old handle. A rejected call may leave ownership unchanged, but rejection of a later argument may happen after the managed handle was consumed. On stock 0.91.0, the old allocation address may already belong to a different object; newer opaque IDs are not reused. Python owns and frees two kinds of things: the **single current native handle** for each object, and **Python-side resources it created itself** (stream wrappers, callbacks pinned so the native side can call back into them, caches). It swaps that one tracked handle to whatever a consuming call returns and, on the success path, never frees the value the call took. Beyond those owned resources it also carries bookkeeping it never frees (lifecycle state, the owning process ID, a borrowed reference to a caller-supplied `Context`), and it does not manage native reallocation itself: it swaps handles and, on the ambiguous failure paths, reads the native error tags to decide who still owns the pointer rather than assuming. @@ -101,7 +101,7 @@ Therefore, the managed resources have the following principles: - Each `ManagedResource` holds exactly one `_handle`. `_swap_handle()` replaces it with the pointer a consuming call returned and does not free the old value, since the native side took it (see [Consume-and-swap](#consume-and-swap)). - `_teardown(free_handle=False)`, `_consume_no_replacement()`, and `_consume_into()` all close or advance the object without calling `c2pa_free`, because ownership moved to the native side. -- Only a few sites free a live handle. Two of them free a pointer this layer still provably owns: normal teardown (`_teardown(free_handle=True)`), and the create-then-validate path, which frees a freshly created pointer if activation fails. The third, `_release_handle()`, is a *guarded* free used only when ownership is genuinely unknown (a consuming call failed without setting an error, or a Python exception was raised before the native side reported anything): if the native side already took the pointer, its address is no longer in the registry and `c2pa_free` is a `-1` no-op, so the free touches no memory. No path frees a pointer known to have been consumed and reallocated (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). +- Normal teardown (`_teardown(free_handle=True)`) and create-then-validate failure free handles Python still owns. `_release_handle()` issues a *guarded* free when ownership cannot be established: a missing native error, an exception other than `ctypes.ArgumentError`, or a consume-first registry rejection without a comparable handle value. The registry rejects an untracked value, but a stale stock address may have been reused; this is not a universal stale-free guarantee. Known-consumed handles are closed without freeing (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). - `_release()` drops stream wrappers, callbacks, and caches before the native pointer is freed (see [Subclass-specific cleanup with `_release()`](#subclass-specific-cleanup)). ### Double-free risk mitigations @@ -110,7 +110,7 @@ Three distinct risks. Two have a mechanism in this layer; the third is the calle | Hazard | Covered by | How | | --- | --- | --- | -| Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | The consumed pointer is abandoned, never freed. The retained-vs-consumed decision reads the native error tag (`UntrackedPointer:` / `WrongPointerType:` mean not taken). | +| Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | Known-consumed handles are never freed. Failure triage uses call order and, for consume-first registry rejections, compares the rejected value with the managed handle; the tag alone does not establish retention. | | A forked child freeing a pointer its parent owns | PID stamp (`record_owner_pid` / `is_foreign_process`) | Cleanup in a process that did not allocate the pointer nulls the handle and marks `CLOSED` without freeing (see [Fork safety](#fork-safety)). | | Two **threads** in one process racing frees on distinct objects, where the allocator recycles a just-freed address | Not covered here | `ManagedResource` has no lock and no thread stamping. The PID stamp cannot see it: sibling threads share a PID. Safety for genuinely shared handles must come from the caller's own synchronization or from the native registry, not this layer. | @@ -122,7 +122,7 @@ The PID stamp is fork-only: it compares process IDs, and two threads in the same | Guarantee | Description | | --- | --- | -| **Pointer freed exactly once** | Each native pointer is passed to `c2pa_free` at most once. No leak (zero frees) and no double-free. | +| **Ownership-aware release** | Python frees handles it still owns and does not free handles known to have been consumed. Unknown-ownership failures use the guarded-free fallback described below. | | **Cleanup is idempotent** | Calling `close()` (or exiting a `with` block) multiple times is safe; after the first successful cleanup, further calls do nothing. | | **Cleanup never raises (ordinary errors)** | The cleanup path catches and logs `Exception`, never re-raising it. `_release()` runs inside `_safe_release()`, which logs and swallows; the `c2pa_free` call has its own handler; and `_cleanup_resources()` wraps both. The original exception from the `with` block (if any) is never masked. **Asynchronous interrupts are the deliberate exception.** The cleanup handlers catch `Exception`, which excludes the `BaseException` signals the interpreter raises to unwind a process (a cancellation request or an exit in progress). Those propagate through cleanup untouched, and the remaining free may not run. Such a signal means the process is being torn down and its address space, native allocations included, is about to be reclaimed as a whole. Catching it would suppress a shutdown the caller asked for in order to complete a free that is about to become irrelevant, so the handlers stay scoped to `Exception`. | | **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED to ACTIVE to CLOSED. A closed resource cannot be reactivated. | @@ -150,9 +150,9 @@ def _free_native_ptr(ptr): All native pointers are freed through this single path, regardless of which constructor created them (`c2pa_reader_from_stream`, `c2pa_builder_from_json`, `c2pa_signer_from_info`, etc.). No explicit `ctypes.cast` is needed: `c2pa_free`'s declared argtype is `c_void_p`, so ctypes converts any pointer instance on the way in. Casting explicitly with `ctypes.cast(ptr, c_void_p)` performs the same conversion but leaves a reference cycle behind on every call, which creates additional load on the (Python) garbage collector. -It returns `c2pa_free`'s status code: `0` when the pointer was really freed, `-1` when the native registry rejected an already-consumed or untracked address. That `-1` is expected on the guarded-free paths and is handled gracefully by the native lib too. +It returns `c2pa_free`'s status code: `0` when a tracked value was freed, `-1` when the registry rejected it. An untracked handle is rejected, but a stale stock address that has been reused can identify a different live allocation. -`ManagedResource` guarantees that `c2pa_free` is called exactly once per pointer: not zero times (leak), not twice (double-free). +Normal owned cleanup calls `c2pa_free` once; consumed handles are not freed by Python. Unknown-ownership failures use the guarded-free fallback. ## Lifecycle states @@ -185,14 +185,14 @@ Each transition has one method that performs it, and subclasses must go through Because activation is the only way in, no code path can leave an object ACTIVE while holding a null handle. -Two terms recur throughout this document. An **owned free** calls `c2pa_free` on a pointer this layer still provably holds: the normal `close()` / `__del__` path and the create-then-validate failure path both do this. A **guarded free** is the same call made when ownership is uncertain, which is what `_release_handle()` does: the native pointer registry tolerates being asked to free an address it no longer tracks, returning `-1` instead of crashing, so the free does not double-free a pointer the native side already took. That tolerance makes a guarded free safe to *issue*, but it is not free of consequence under concurrency — on a branch where the value is already known to be consumed, the layer skips the free rather than relying on the `-1`, because a stale free can race a recycled address (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). +An **owned free** calls `c2pa_free` on a handle Python still owns (normal cleanup or create-then-validate failure). A **guarded free** makes that call when ownership is uncertain, via `_release_handle()`. The registry rejects untracked values, but stock allocation addresses can be recycled, so generic guarded frees are not guaranteed harmless. Known-consumed branches skip the free (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). `_teardown(free_handle)` is the one method that performs the ACTIVE to CLOSED transition, and the boolean decides the only thing that varies between the two exit paths: whether the native pointer is freed. Both paths run `_release()`, set `CLOSED`, and null the handle. | `free_handle` | When | What it does with the pointer | | --- | --- | --- | -| `True` | Either the pointer is still provably ours (normal `close()`, `__del__`) — an owned free — or ownership is unknown after a failure (`_release_handle()`) — a guarded free. | Calls `c2pa_free`. On the owned paths the pointer is really freed; on the unknown-ownership path the registry returns `-1` without touching memory if the native side already took it. | -| `False` | The native side already took ownership: a consuming FFI call swallowed the pointer, or it passed to another object. | Frees nothing; the new owner does. A `c2pa_free` here would double-free (or hit the guarded `-1` no-op that dirties the error slot and risks racing a recycled address). | +| `True` | Python still owns the handle, or ownership is unknown after a failure (`_release_handle()`). | Calls `c2pa_free`; an untracked value is rejected, but a reused stock address need not be untracked. | +| `False` | The native side took ownership. | Frees nothing; native now owns or has dropped the value. Avoids an unnecessary free, error-slot overwrite, and stock address-reuse risk. | Every public method calls `_ensure_valid_state()` before doing any work, which raises `C2paError` unless the resource is ACTIVE with a non-null handle. @@ -352,14 +352,14 @@ sequenceDiagram alt status 0 (success) S->>S: _teardown(free_handle=False) Note right of S: Consumed: native took the signer - else pre-consume rejection (UntrackedPointer / WrongPointerType) + else registry rejection (UntrackedPointer / WrongPointerType / PointerInUse / WrongWrapperKind) Note right of S: Rejected before ownership moved:
Signer retained, typed error raised else other error S->>S: _teardown(free_handle=False) Note right of S: Native took it then failed and dropped it end - X->>B: _consume_into(build) + X->>B: _consume_into(build, consumes_first=True) B->>N: c2pa_context_builder_build(builder_ptr) N-->>X: context_ptr (builder consumed) X->>X: _activate(context_ptr) (outside the with) @@ -368,7 +368,7 @@ sequenceDiagram Details in that sequence that are easy to get wrong: - The callback is copied to the Context *before* the transfer. A successful consume runs `_release()`, which drops the Signer's reference to the callback; a Context that copied it afterwards would be pointing at a callback nothing keeps alive. -- `set_signer` does not always take the pointer. A pre-consume rejection (`UntrackedPointer:` / `WrongPointerType:`) leaves the Signer `ACTIVE` and retained, so the triage must read the native error before deciding to close it. Treating every failure as "consumed" would close a signer the native side never took. +- `set_signer` validates before consuming the signer. Any registry rejection retains the Signer, whether it identifies the signer, the builder, or no address. This uses the helpers' default `consumes_first=False`. `PointerInUse:` and `WrongWrapperKind:` occur only with newer opaque registries. - A `ctypes.ArgumentError` from `set_signer` is re-raised untouched by `_invoke_consume`: marshalling failed, the native function never ran, and the Signer still owns its handle. Only calls that reached native go through the consumed/retained triage. - The builder is never held as a raw local across the signer and build calls. `_NativeBuilder`'s `with` block owns it: a settings error, a retained-signer error, a build rejection, or an async interrupt all free it through `close()`, and a successful build consumes it so `close()` is then a no-op. The old raw-pointer recovery block that used to free `builder_ptr` on the un-reached-build path is gone. @@ -409,36 +409,29 @@ Every call of this shape goes through one helper, which takes the FFI call as a ```python # Reader.with_fragment() internally does: self._consume_and_swap( - lambda handle: _lib.c2pa_reader_with_fragment(handle, format_bytes, stream), - Reader._ERROR_MESSAGES['reader_error']) + lambda handle: _lib.c2pa_reader_with_fragment( + handle, format_arg, main_obj._stream, frag_obj._stream), + Reader._ERROR_MESSAGES['fragment_error'], consumes_first=True) ``` The call is passed as a lambda because the helper supplies the handle and, on success, replaces it via `_swap_handle()`. -The helper exists because a failed return can be ambiguous. The native functions run in phases: it validates the **borrowed pointer** (passed in without transferring ownership; the caller still owns it unless the callee explicitly takes it over), then takes ownership, then does the work. A failure in the first phase and a failure after the second come back to Python as the same value (a null pointer, or a non-zero status), but they leave ownership in opposite places. - -```mermaid -flowchart TD - CALL["FFI call(handle)"] --> V{"validate borrowed handle"} - V -->|invalid| R["reject: handle NOT taken
sets UntrackedPointer / WrongPointerType"] --> F1["returns a failure value
(null, or non-zero status)"] - V -->|valid| TAKE["take ownership of handle"] - TAKE --> WORK{"execute function logic"} - WORK -->|fails| DROP["native drops the value itself
sets some other error"] --> F2["returns a failure value
(null, or non-zero status)"] - WORK -->|succeeds| OK["returns replacement / 0 / new pointer"] - - F1 -.failure value returned to Python.- AMB(["needs to consult error to know failure mode from Python"]) - F2 -.failure value returned to Python.- AMB -``` +The return value alone cannot establish ownership. All three consume helpers accept the keyword-only `consumes_first=False`. Validate-first `c2pa_context_builder_set_signer` retains the signer on any registry rejection. These six calls instead take ownership of the managed handle before validating later arguments and use `consumes_first=True`: `c2pa_context_builder_build`, `c2pa_reader_with_stream`, `c2pa_reader_with_manifest_data_and_stream`, `c2pa_reader_with_fragment`, `c2pa_builder_with_definition`, and `c2pa_builder_with_archive`. -The two failure paths are indistinguishable from the return value alone. Only the native error message set alongside them tells the phases apart: +Registry rejections are `UntrackedPointer:`, `WrongPointerType:`, `PointerInUse:`, or `WrongWrapperKind:`. The last two are addressless and exist only in newer opaque registries. Failure triage is: -| Native error | Who owns the handle | What the helper does | +| Native error / call order | Ownership decision | What the helper does | | --- | --- | --- | -| `UntrackedPointer:` or `WrongPointerType:` | Still ours: rejected before ownership moved | Handle kept, resource stays `ACTIVE`, typed error raised. Normal cleanup frees it later. | -| Any other error | Taken, then the operation failed | `_teardown(free_handle=False)`: the native side already dropped the value, so nothing is freed here. Resource goes `CLOSED`, error typed from the native message. | -| No error at all | Unknown | `_release_handle()` guarded free, the caller's message is raised with `"Unknown error"` filled in. | +| Any registry rejection, validate-first | Managed handle not taken | Retains the handle and `ACTIVE` state; raises the typed native error. | +| Addressed rejection, consume-first, rejected value equals managed value | Managed handle not taken | Retains the handle and `ACTIVE` state; raises the typed native error. | +| Addressed rejection, consume-first, known rejected value differs from managed value | Managed handle consumed before another argument was rejected | `_teardown(free_handle=False)`; closes without freeing, raises the typed native error. | +| Registry rejection, consume-first, missing rejected address or unreadable managed value (`None`) | Cannot establish ownership | `_release_handle()` guarded free and close, then raises the saved typed native error. | +| Any non-registry native error | Native took and dropped the value | Closes without freeing; raises the typed native error. | +| No native error | Unknown | Guarded free and close; raises the caller's message with `"Unknown error"`. | -This error and ownership triage relies on the native error still being readable (and correctly being the last error encountered) after the call returns. Reading an error copies the message out and frees the copy, but leaves the native slot set until the next error overwrites it. +`_handle_value()` reads the Python handle representation without dereferencing native memory: integers are used directly, otherwise `ctypes.c_void_p.from_buffer(handle).value` reads the stored pointer value. It avoids `ctypes.cast` and its reference cycle; an unreadable representation returns `None`. + +Triage saves the native error before cleanup can overwrite it. It trusts the failing path to have set its own error: the thread-local slot is sticky and is not cleared before the call. Reading an error copies the message out and frees the copy, but leaves the slot set until the next error overwrites it. Three consume helpers share this triage; they differ only in what the FFI call returns on success: @@ -448,23 +441,21 @@ Three consume helpers share this triage; they differ only in what the FFI call r | `_consume_no_replacement()` | a status code (`0` = ok) | `_teardown(free_handle=False)`, resource `CLOSED` | | `_consume_into()` | a *different* object's pointer | `_teardown(free_handle=False)`, the pointer returned for the caller to own | -`_consume_no_replacement()` is how a `Signer` is fed to a `Context` (`set_signer` returns a status code); `_consume_into()` is how that same `Context` build returns the new context pointer. A failure in any of the three is handled by the same native-error triage, so a pre-consume rejection retains the handle rather than assuming it was taken. +`_consume_no_replacement()` feeds a `Signer` to a `Context` with the validate-first default; `_consume_into(..., consumes_first=True)` builds the context. All three helpers share the failure triage above. #### Why an ownership-taken failure does not free -A consuming FFI call can fail. It may reject the borrowed pointer before taking it, or it may take ownership first and then, on a later failure, drop the value itself. - -The native error message indicates which of the errors happened. A rejection carries one of the `_PRE_CONSUME_ERROR_TAGS` (`UntrackedPointer:` or `WrongPointerType:`), which means the handle was never taken and is retained. Any other error message means the native side may have taken ownership and already dropped the value. On top of those, preparing the call's own arguments can fail in Python before the native function ever runs (for example, encoding a bad value or a ctypes marshalling error other than `ArgumentError`), and that outcome is handled separately. +A consume-first call can reject its own handle before taking it, or consume it and then reject another argument. Only an addressed rejection matching the managed value proves retention on this path. A known different rejected value or a non-registry error closes without freeing: native already owns or has dropped the managed value. -The two settled branches each take the exact action their ownership implies. A pre-consume rejection (an error prefixed `UntrackedPointer:` or `WrongPointerType:`) means the handle is still the caller's, so it is retained and freed later by normal cleanup. Any other native error means the value is already gone, so `_teardown(free_handle=False)` runs the Python-side cleanup without freeing anything. +An unnecessary free can overwrite the native error slot. On stock 0.91.0 it can also free an unrelated allocation if the old address has been recycled. Newer opaque IDs avoid address reuse, but that does not justify issuing generic guarded frees for known-consumed handles. -Always calling the guarded free instead, even where the value is known to be gone, is tempting because a stale free looks like a harmless `-1` no-op. It is only harmless while the freed address stays unclaimed. The native registry rejects an address it no longer tracks, but once another thread allocates a fresh tracked object at that recycled address, the registry does track it again — and a stale free aimed at the old value would now find a live entry and destroy a different thread's object. The scenario is unlikely, but not unreachable: it needs a second thread inside its own FFI call, an allocator that hands back the exact address just freed, and that reuse to happen during the (narrow) window between the native drop and this free. But the window is real under concurrent use. The failure is a silent cross-thread corruption rather than a clean error, and the free is not needed in the first place on this branch. So where the value is known to be consumed, the free is skipped rather than issued and left to the registry to reject. The native error slot stays sticky: it holds whatever it last held until the next error overwrites it, and nothing clears it in between. Issuing an unneeded free would set an untracked-pointer error there that a later caller could mistake for the failure it actually asked about, so skipping the free keeps the slot free for the next real error. +`_release_handle()` is the fallback for a missing native error, an exception other than `ctypes.ArgumentError` from `_invoke_consume`, or a consume-first registry rejection without a comparable address. `ctypes.ArgumentError` retains the handle and propagates unchanged because native was not called. For native failures the original error is saved before any guarded free, so cleanup cannot replace the reported failure. -`_release_handle()` (a guarded free) is reserved for the two branches where ownership is not known for certain: a Python exception raised before native reports anything, and a failure that leaves the error slot empty (which no defined native failure is expected to produce). In both, a guarded free is a good default, since it is a real free when the handle is still ours and a `-1` no-op when the native side already took it. +The addressless `PointerInUse:` / `WrongWrapperKind:` fallback is safe with the newer opaque registry: it frees a releasable entry or rejects cleanup without targeting a different allocation. These errors do not exist on stock 0.91.0. Stock emitted addressed-rejection paths provide a comparable managed value, so the guarded-free rejection fallback is unreachable there; this is not a claim that arbitrary stale raw-address frees are safe. None of this is protected by a lock on the Python side: `ManagedResource` has no thread-safety mechanism of its own, and the retained-vs-consumed guarantee comes entirely from the native pointer registry and its thread-local error slot. As noted under [Which double-free risks this layer guards](#double-free-risk-mitigations), sharing one instance across threads without external synchronization is the caller's responsibility. This is a different hazard from [Fork safety](#fork-safety), which concerns a forked child process, not a thread within the same process. -A consuming C FFI function first removes the pointer from its registry, then reconstructs the owned value from it. `untrack_or_return!` runs ahead of `Box::from_raw` in `c2pa_c_ffi`. If the address is unknown or the wrong type, the untrack step fails before ownership is taken and sets an error whose prefix (`UntrackedPointer:` or `WrongPointerType:`) identifies it as a pre-consume rejection. Once the value has been reconstructed, a later failure simply drops it, the same as any owned value going out of scope. The Python side stays defensive (and as generic as possible) rather than assuming any exact behavior: it retains the handle when it recognizes one of those rejection prefixes, and where the outcome is unclear it falls back to the guarded free. A native side that behaved differently would degrade in one of two bounded ways: If it kept a pointer the Python side treated as consumed, nothing would free that pointer and it would leak. If it had already released a pointer the Python side then tried to free, the registry would not find the address and the free would return `-1` without touching memory. +Registry rejection describes the rejected argument, not necessarily the managed handle. Correct helper configuration therefore depends on the native call's ownership order, not just an error prefix. A native implementation with a different ownership contract could leak or free the wrong allocation; the fallback is not a general compatibility guarantee. ### Adopting the handle before giving it away @@ -477,9 +468,9 @@ self._create_and_activate( self._consume_and_swap( lambda handle: _lib.c2pa_reader_with_stream( - handle, format_bytes, self._own_stream._stream, + handle, format_arg, self._own_stream._stream, ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], consumes_first=True) ``` Activating a handle that is about to be handed to the native library looks backwards, and there are two reasons for it. `_consume_and_swap` needs an active resource to read the handle from and swap the result into. It also puts the intermediate pointer under normal cleanup before anything can go wrong with it: whichever way the consuming call goes, `close()` and `__del__` will free the pointer if the native side did not take it. The alternative, holding the pointer in a local variable across the call, means every failure path has to decide for itself whether to free it. @@ -550,6 +541,8 @@ The reason is that ownership runs in the opposite direction. A `Reader` or `Buil `Stream` tracks its own state with `_closed` and `_initialized` flags rather than `LifecycleState`, but it supports the same three cleanup paths: context manager, explicit `.close()`, and `__del__` fallback. +Python's `C2paStream` declaration is opaque (`_fields_ = []`): it only passes the pointer back to native and never reads its fields. This is safe with both stock 0.91.0's C-layout stream and newer opaque stream handles. `Stream` keeps the Python callback references separately. + ## Which method to use when? `_create_and_activate`, `_consume_and_swap`, `_consume_no_replacement`, @@ -562,13 +555,17 @@ different situation when writing a new subclass: | An FFI call consumes the current handle and returns a replacement for the same object | `_consume_and_swap(ffi_call, error_message)` | | An FFI call consumes the current handle to configure or feed another object, returning only a status code | `_consume_no_replacement(ffi_call, error_message)` | | An FFI call consumes the current handle and returns a *different* object's pointer, for that object to own | `_consume_into(ffi_call, error_message)` | -| A call fails and it is unclear whether native took the handle first (a Python exception before native reported anything, or an empty error slot) | `_release_handle()` | +| Ownership cannot be established after a failure (an exception other than `ctypes.ArgumentError`, an empty error slot, or a consume-first rejection without a comparable address) | `_release_handle()` | | A Python instance needs to wrap a handle a native call already returned, without creating a new one | `_wrap_native_handle(handle)` (classmethod) | | Ordinary teardown (`close()`, `__del__`) | Neither: these already route through `_cleanup_resources()` and `_teardown()`. Nothing outside `ManagedResource` itself calls `_teardown()` directly. | `_activate()` and `_swap_handle()` are two low-level primitives this situation table builds on. +The three consume helpers default to keyword-only `consumes_first=False`. +Pass `consumes_first=True` when native takes the managed handle before +validating later arguments, as in the fragment and stream examples above. + ## Implementing a subclass of `ManagedResource` To wrap a new native resource, inherit from `ManagedResource` and follow these rules: @@ -639,7 +636,7 @@ class NativeResource(ManagedResource): - `_release()` can be called more than once (via `close()` then `__del__`, or multiple `close()` calls), so it must handle being called on an already-cleaned-up object. Setting attributes to `None` after closing them is the standard pattern. -- Calling `c2pa_free` directly is not recommended. `ManagedResource` handles this. A redundant free of an already-released pointer is not a crash: the native pointer registry rejects an untracked address without touching memory and returns `-1`. `ManagedResource` relies on this guard so the unknown-ownership failure paths can issue a guarded free without risking a double-free. A manual free is still wrong — the lifecycle owns the pointer and bypassing it defeats the state checks. +- Calling `c2pa_free` directly bypasses `ManagedResource` ownership and state checks. The registry rejects untracked values, but a stale stock address may have been reused for another allocation; a redundant raw-address free is not guaranteed harmless. Use the lifecycle helpers instead. - When a subclass inherits from both `ManagedResource` and an ABC like `ContextProvider`, and both define a property with the same name (e.g. `is_valid`), Python resolves it using the MRO. The parent listed first in the class definition wins. With the ABC listed first, Python finds the abstract property before the concrete one and raises `TypeError: Can't instantiate abstract class`. The class with the concrete implementation therefore comes first (e.g. `class Context(ManagedResource, ContextProvider)`, not `class Context(ContextProvider, ManagedResource)`). diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 85303441..e49c29a7 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -17,6 +17,7 @@ import enum import json import logging +import re import sys import os import warnings @@ -421,9 +422,11 @@ def _swap_handle(self, new_handle): self._handle = new_handle # Errors set by native lib, hinting at the cause of the error - # These errors here means the pointer got somehow rejected by the lib, - # so it is still ours to deal with. + # The rejected handle may be this resource or another argument. _PRE_CONSUME_ERROR_TAGS = ("UntrackedPointer:", "WrongPointerType:") + _ADDRESSLESS_REJECTION_TAGS = ("PointerInUse:", "WrongWrapperKind:") + _REJECTED_HANDLE_RE = re.compile( + r"(?:UntrackedPointer|WrongPointerType):\s*(0x[0-9a-fA-F]+)\b") def _invoke_consume(self, ffi_call, error_message): """Run an FFI call that consumes this handle, returning its raw result. @@ -454,7 +457,20 @@ def _invoke_consume(self, ffi_call, error_message): self._release_handle() raise C2paError(error_message.format(e)) from e - def _raise_consume_failure(self, error_message): + def _handle_value(self): + """Read the handle value without dereferencing native memory.""" + try: + handle = self._handle + if not handle: + return None + if isinstance(handle, int): + return handle + return ctypes.c_void_p.from_buffer(handle).value + except Exception: + # An unfamiliar representation cannot establish ownership. + return None + + def _raise_consume_failure(self, error_message, *, consumes_first=False): """Raise the error from an FFI handler consuming call. The native error is read before any free so a free's own @@ -469,24 +485,51 @@ def _raise_consume_failure(self, error_message): with another one and, because that substitute carries a pre-consume tag, invert the retain/consume decision made below. + Validate-first calls retain the resource on any registry rejection. + Consume-first calls retain it only when the rejected address matches + its handle. A different known address means it was consumed; an absent + address or unreadable handle value needs a guarded free and close. + Address-less registry rejections are safe to clean up with the newer + opaque registry and are not emitted by stock 0.91.0. + Args: error_message: Format string with one placeholder, used when the native layer offers no error of its own. + consumes_first: Whether native takes this handle before validating + its other arguments. Raises: C2paError: Always; typed by the native error when there is one. """ error = _read_native_error() if error: - if any(tag in error - for tag in ManagedResource._PRE_CONSUME_ERROR_TAGS): + # Stock wraps registry errors in Other; tags quoted inside another + # error's payload are not evidence of handle rejection. + rejection = error.removeprefix("Other: ") + rejected = rejection.startswith( + self._PRE_CONSUME_ERROR_TAGS + self._ADDRESSLESS_REJECTION_TAGS) + if rejected and not consumes_first: logger.warning( - "%s: native call rejected the handle before taking " + "%s: native call rejected an argument before taking " "ownership (%s); handle retained", type(self).__name__, error) _raise_typed_c2pa_error(error) + if rejected: + match = self._REJECTED_HANDLE_RE.match(rejection) + managed = self._handle_value() + if match and managed is not None: + if int(match.group(1), 16) == managed: + logger.warning( + "%s: native call rejected the managed handle " + "(%s); handle retained", type(self).__name__, error) + _raise_typed_c2pa_error(error) + self._teardown(free_handle=False) + else: + self._release_handle() + _raise_typed_c2pa_error(error) + # A non-tag error means the native side took ownership then failed, # dropping the value itself: mark consumed, do not free (a free here # would be a guarded no-op that dirties the error slot and races a @@ -498,7 +541,7 @@ def _raise_consume_failure(self, error_message): self._release_handle() raise C2paError(error_message.format("Unknown error")) - def _consume_and_swap(self, ffi_call, error_message): + def _consume_and_swap(self, ffi_call, error_message, *, consumes_first=False): """Run an FFI call that consumes this handle and returns a replacement. On success the native lib consumed the handle and returned a new one, which we swap in. A null return is a failure. @@ -507,9 +550,10 @@ def _consume_and_swap(self, ffi_call, error_message): if new_ptr: self._swap_handle(new_ptr) return - self._raise_consume_failure(error_message) + self._raise_consume_failure(error_message, consumes_first=consumes_first) - def _consume_no_replacement(self, ffi_call, error_message): + def _consume_no_replacement(self, ffi_call, error_message, *, + consumes_first=False): """Run an FFI call that consumes this handle on success, when the native call returns a status code (0 = success) rather than a replacement handle. A non-zero status is a failure routed to @@ -519,9 +563,9 @@ def _consume_no_replacement(self, ffi_call, error_message): if result == 0: self._teardown(free_handle=False) return - self._raise_consume_failure(error_message) + self._raise_consume_failure(error_message, consumes_first=consumes_first) - def _consume_into(self, ffi_call, error_message): + def _consume_into(self, ffi_call, error_message, *, consumes_first=False): """Run an FFI call that consumes this handle and returns a *different* object's pointer. On success this handle is consumed (mark, don't free) and the new pointer is returned for the caller to own. A null return is @@ -531,7 +575,7 @@ def _consume_into(self, ffi_call, error_message): if result: self._teardown(free_handle=False) return result - self._raise_consume_failure(error_message) + self._raise_consume_failure(error_message, consumes_first=consumes_first) @classmethod def _wrap_native_handle(cls, handle): @@ -657,40 +701,13 @@ class C2paSigner(ctypes.Structure): class C2paStream(ctypes.Structure): - """A C2paStream is a Rust Read/Write/Seek stream that can be created in C. - - This class represents a low-level stream interface that bridges Python - and Rust/C code. It implements the Rust Read/Write/Seek traits in C, - allowing for efficient data transfer between Python and the C2PA library - without unnecessary copying. - - The stream is used for various operations including: - - Reading manifest data from files - - Writing signed content to files - - Handling binary resources - - Managing ingredient data - - The structure contains function pointers that implement stream operations: - - reader: Function to read data from the stream - - seeker: Function to change the stream position - - writer: Function to write data to the stream - - flusher: Function to flush any buffered data - - This is a critical component for performance as it allows direct memory - access between Python and the C2PA library without intermediate copies. + """Opaque native stream handle. + + Python only passes this pointer back to native and never reads its fields. + This works with both stock 0.91.0's C-layout stream and newer opaque handles. + Stream retains the callbacks separately for their required lifetime. """ - _fields_ = [ - # Opaque context pointer for the stream - ("context", ctypes.POINTER(StreamContext)), - # Function to read data from the stream - ("reader", ReadCallback), - # Function to change stream position - ("seeker", SeekCallback), - # Function to write data to the stream - ("writer", WriteCallback), - # Function to flush buffered data - ("flusher", FlushCallback), - ] + _fields_ = [] def _read_native_error() -> Optional[str]: @@ -1725,7 +1742,7 @@ def __init__( context_ptr = nb._consume_into( lambda h: _lib.c2pa_context_builder_build(h), - "Failed to build Context: {}") + "Failed to build Context: {}", consumes_first=True) self._activate(context_ptr) @@ -2644,7 +2661,7 @@ def _init_from_context(self, context, format_or_path, len(manifest_data), ) ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], consumes_first=True) else: # Consume reader with stream self._consume_and_swap( @@ -2652,7 +2669,7 @@ def _init_from_context(self, context, format_or_path, handle, format_arg, self._own_stream._stream, ), - Reader._ERROR_MESSAGES['reader_error']) + Reader._ERROR_MESSAGES['reader_error'], consumes_first=True) except Exception: self._close_streams() raise @@ -2761,7 +2778,7 @@ def with_fragment(self, format: Optional[str], stream, main_obj._stream, frag_obj._stream, ), - Reader._ERROR_MESSAGES['fragment_error']) + Reader._ERROR_MESSAGES['fragment_error'], consumes_first=True) # Invalidate caches: processing a new BMFF fragment updates the native # reader's state, which can change the manifest data it returns. @@ -3412,7 +3429,7 @@ def _init_from_context(self, context, json_str): self._consume_and_swap( lambda handle: _lib.c2pa_builder_with_definition( handle, json_str), - Builder._ERROR_MESSAGES['builder_error']) + Builder._ERROR_MESSAGES['builder_error'], consumes_first=True) def _init_attrs(self): super()._init_attrs() @@ -3699,7 +3716,7 @@ def with_archive(self, stream: Any) -> 'Builder': self._consume_and_swap( lambda handle: _lib.c2pa_builder_with_archive( handle, stream_obj._stream), - Builder._ERROR_MESSAGES['archive_load_error']) + Builder._ERROR_MESSAGES['archive_load_error'], consumes_first=True) return self diff --git a/tests/test_native_ownership.py b/tests/test_native_ownership.py new file mode 100644 index 00000000..f1dc59fd --- /dev/null +++ b/tests/test_native_ownership.py @@ -0,0 +1,232 @@ +"""Native ownership regressions for raw-address and opaque-handle registries. + +Consume-first calls can reject another argument after dropping their managed +handle. Never probe a consumed address with c2pa_free: on the raw-address +registry it may already belong to a new allocation. +""" + +import ctypes +import io +from pathlib import Path + +import pytest + +import c2pa.c2pa as binding +from c2pa import Builder, C2paError, C2paSignerInfo, Context, Reader, Signer +from c2pa.c2pa import LifecycleState, ManagedResource + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _addr(pointer): + return ctypes.cast(pointer, ctypes.c_void_p).value + + +def _untracked_pointer(pointer_type): + # Keep the buffer alive so its address cannot become a native allocation. + buffer = ctypes.create_string_buffer(64) + return ctypes.cast(buffer, ctypes.POINTER(pointer_type)), buffer + + +@pytest.fixture(autouse=True) +def _restore_native_error_slot(): + # The slot is sticky and thread-local. Neutral text works on both libraries. + binding._lib.c2pa_error_set_last(b"Other: native ownership test setup") + yield + binding._lib.c2pa_error_set_last(b"Other: native ownership test teardown") + + +@pytest.fixture +def reader(): + with open(FIXTURES / "dashinit.mp4", "rb") as init: + value = Reader("video/mp4", init) + try: + yield value + finally: + value.close() + + +@pytest.fixture +def frees(monkeypatch): + calls = [] + real_free = ManagedResource._free_native_ptr + + def record(pointer): + calls.append(_addr(pointer)) + return real_free(pointer) + + monkeypatch.setattr(ManagedResource, "_free_native_ptr", staticmethod(record)) + return calls + + +def _assert_closed(resource): + assert resource._handle is None + assert resource._lifecycle_state == LifecycleState.CLOSED + resource.close() + resource.close() + + +def test_rejected_fragment_stream_after_reader_consumed_closes_reader( + reader, monkeypatch, frees): + consumed = _addr(reader._handle) + bogus, _keep = _untracked_pointer(binding.C2paStream) + real_call = binding._lib.c2pa_reader_with_fragment + monkeypatch.setattr( + binding._lib, "c2pa_reader_with_fragment", + lambda handle, fmt, stream, fragment: real_call(handle, fmt, stream, bogus)) + before = len(frees) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + reader.with_fragment("video/mp4", init, fragment) + assert f"0x{_addr(bogus):x}" in str(caught.value) + _assert_closed(reader) + assert consumed not in frees[before:] + + +def test_rejected_stream_after_context_reader_consumed_is_not_freed( + monkeypatch, frees): + bogus, _keep = _untracked_pointer(binding.C2paStream) + real_call = binding._lib.c2pa_reader_with_stream + seen = [] + + def call(handle, fmt, stream): + seen.append((_addr(handle), len(frees))) + return real_call(handle, fmt, bogus) + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_stream", call) + with Context() as context, open(FIXTURES / "dashinit.mp4", "rb") as init: + partial_reader = Reader.__new__(Reader) + with pytest.raises(C2paError, match="UntrackedPointer"): + partial_reader.__init__("video/mp4", init, context=context) + assert len(seen) == 1, "the consume-first native call was not reached" + consumed, before = seen[0] + _assert_closed(partial_reader) + # Check before context cleanup or any later allocation can reuse it. + assert consumed not in frees[before:] + + +def test_rejection_naming_the_managed_handle_retains_it(reader, frees): + bogus, _keep = _untracked_pointer(binding.C2paReader) + real_handle = reader._handle + reader._handle = bogus + before = len(frees) + try: + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + reader.with_fragment("video/mp4", init, fragment) + assert f"0x{_addr(bogus):x}" in str(caught.value) + assert reader._handle is bogus + assert reader._lifecycle_state == LifecycleState.ACTIVE + assert _addr(bogus) not in frees[before:] + finally: + reader._handle = real_handle + assert reader.json() + + +def test_successful_fragment_swap_does_not_free_old_and_closes_replacement_once( + reader, frees): + consumed = _addr(reader._handle) + before = len(frees) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + assert reader.with_fragment("video/mp4", init, fragment) is reader + assert reader._lifecycle_state == LifecycleState.ACTIVE + assert reader._handle + assert consumed not in frees[before:], "swap must not free the consumed handle" + replacement = _addr(reader._handle) + # The replacement may have the same address as the consumed reader. + before_close = len(frees) + reader.close() + _assert_closed(reader) + assert frees[before_close:] == [replacement] + + +@pytest.mark.parametrize("tag", [ + "Other: PointerInUse: handle already in (exclusive) use", + "Other: WrongWrapperKind: Arc-backed handle can't have single ownership", +]) +def test_addressless_rejection_on_consume_first_call_releases_defensively( + reader, monkeypatch, frees, tag): + managed = _addr(reader._handle) + + def rejected(*_args): + # Stock does not emit these tags, but can hold them in its error slot. + binding._lib.c2pa_error_set_last(tag.encode()) + return None + + monkeypatch.setattr(binding._lib, "c2pa_reader_with_fragment", rejected) + before = len(frees) + with open(FIXTURES / "dashinit.mp4", "rb") as init, \ + open(FIXTURES / "dash1.m4s", "rb") as fragment: + with pytest.raises(C2paError) as caught: + reader.with_fragment("video/mp4", init, fragment) + assert tag.split(": ", 1)[1].split(":")[0] in str(caught.value) + _assert_closed(reader) + assert frees[before:] == [managed] + + +def test_rejected_archive_stream_after_builder_consumed_closes_without_free( + monkeypatch, frees): + builder = Builder({"claim_generator_info": [{"name": "ownership-test"}], + "assertions": []}) + consumed = _addr(builder._handle) + bogus, _keep = _untracked_pointer(binding.C2paStream) + real_call = binding._lib.c2pa_builder_with_archive + monkeypatch.setattr(binding._lib, "c2pa_builder_with_archive", + lambda handle, stream: real_call(handle, bogus)) + before = len(frees) + try: + with pytest.raises(C2paError, match="UntrackedPointer"): + builder.with_archive(io.BytesIO(b"unused")) + _assert_closed(builder) + assert consumed not in frees[before:] + finally: + builder.close() + + +def test_set_signer_validates_builder_first_and_retains_signer(monkeypatch, frees): + signer = Signer.from_info(C2paSignerInfo( + alg=b"es256", sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) + bogus, _keep = _untracked_pointer(binding.C2paContextBuilder) + # Do not allocate a real native builder that a failing constructor could leak. + monkeypatch.setattr(binding._lib, "c2pa_context_builder_new", lambda: bogus) + managed = _addr(signer._handle) + before = len(frees) + try: + with pytest.raises(C2paError, match="UntrackedPointer") as caught: + Context(signer=signer) + assert f"0x{_addr(bogus):x}" in str(caught.value) + assert signer._lifecycle_state == LifecycleState.ACTIVE + assert _addr(signer._handle) == managed + assert managed not in frees[before:] + assert signer.reserve_size() > 0 + finally: + signer.close() + + +@pytest.mark.parametrize("tag", [ + "UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind", +]) +def test_invalid_definition_quoting_registry_tag_does_not_free_consumed_builder( + monkeypatch, frees, tag): + real_call = binding._lib.c2pa_builder_with_definition + seen = [] + + def call(handle, definition): + seen.append((_addr(handle), len(frees))) + return real_call(handle, definition) + + monkeypatch.setattr(binding._lib, "c2pa_builder_with_definition", call) + with Context() as context: + builder = Builder.__new__(Builder) + with pytest.raises(C2paError, match="Json") as caught: + builder.__init__({"claim_version": f"{tag}: 0xcafe"}, context=context) + assert tag in str(caught.value) + assert len(seen) == 1 + consumed, before = seen[0] + _assert_closed(builder) + assert consumed not in frees[before:] diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index bc925aad..6c4dd39b 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -21,6 +21,7 @@ import json import re import unittest +from unittest.mock import patch import ctypes import warnings from cryptography.hazmat.primitives import hashes, serialization @@ -8439,6 +8440,142 @@ def test_consume_no_replacement_marks_consumed_on_other_error(self): self.assertIsNone(res._handle) self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + def test_consume_first_integer_handle_rejection_retains_own_handle(self): + for tag in ("UntrackedPointer", "WrongPointerType"): + with self.subTest(tag=tag): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f"Other: {tag}: 0xcafe" + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error) as caught: + res._consume_and_swap( + lambda h: None, "swap failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertEqual(res._handle_value(), 0xCAFE) + self.assertTrue(res.is_valid) + self.assertEqual(self.freed, []) + res.close() + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_consume_first_integer_handle_rejection_closes_other_handle(self): + for tag in ("UntrackedPointer", "WrongPointerType"): + with self.subTest(tag=tag): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f"Other: {tag}: 0xbeef" + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error) as caught: + res._consume_and_swap( + lambda h: None, "swap failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertIsNone(res._handle) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, []) + + def test_consume_first_unreadable_handle_uses_guarded_free(self): + res = self._FakeHandleResource() + handle = object() + res._activate(handle) + error = "Other: WrongPointerType: 0xbeef" + state = [error] + + def free(pointer): + self.freed.append(pointer) + state[0] = "Other: UntrackedPointer: cleanup error" + + with patch.object(c2pa_module, "_read_native_error", side_effect=lambda: state[0]), \ + patch.object(ManagedResource, "_free_native_ptr", side_effect=free): + self.assertIsNone(res._handle_value()) + with self.assertRaises(Error) as caught: + res._consume_into( + lambda h: None, "build failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertNotIn("cleanup error", str(caught.exception)) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, [handle]) + + def test_handle_value_accepts_pointer_integer_and_unreadable_handles(self): + class Unreadable: + def __bool__(self): + raise ValueError("unreadable handle") + + res = self._FakeHandleResource() + for handle, expected in [(0xCAFE, 0xCAFE), + (ctypes.c_void_p(0xCAFE), 0xCAFE), + (ctypes.POINTER(c2pa_module.C2paReader)(), None), + (None, None), (object(), None), (Unreadable(), None)]: + with self.subTest(handle=type(handle).__name__): + res._handle = handle + self.assertEqual(res._handle_value(), expected) + res._handle = None + + def test_consume_first_addressless_rejection_preserves_error(self): + for tag in ("UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind"): + with self.subTest(tag=tag): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f"Other: {tag}: rejection without an address" + state = [error] + + def free(pointer): + self.freed.append(pointer) + state[0] = "Other: UntrackedPointer: cleanup error" + + with patch.object(c2pa_module, "_read_native_error", side_effect=lambda: state[0]), \ + patch.object(ManagedResource, "_free_native_ptr", side_effect=free): + with self.assertRaises(Error) as caught: + res._consume_no_replacement( + lambda h: -1, "set failed: {}", consumes_first=True) + self.assertIn(error, str(caught.exception)) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_validate_first_registry_rejections_retain_handle(self): + for error in ("Other: UntrackedPointer: 0xcafe", + "Other: WrongPointerType: 0xbeef", + "Other: PointerInUse: exclusive use", + "Other: WrongWrapperKind: shared wrapper"): + with self.subTest(error=error): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error): + res._consume_no_replacement(lambda h: -1, "set failed: {}") + self.assertTrue(res.is_valid) + self.assertEqual(self.freed, []) + res.close() + self.assertEqual(self.freed, [0xCAFE]) + + def test_stream_layout_is_opaque(self): + self.assertEqual(c2pa_module.C2paStream._fields_, []) + + def test_payload_registry_tags_do_not_establish_ownership(self): + for consumes_first in (False, True): + for tag in ("UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind"): + for prefix in ("Json", "Other"): + with self.subTest(consumes_first=consumes_first, tag=tag, prefix=prefix): + res = self._FakeHandleResource() + res._activate(0xCAFE) + self.freed.clear() + error = f'{prefix}: invalid input "{tag}: 0xcafe"' + with patch.object(c2pa_module, "_read_native_error", return_value=error): + with self.assertRaises(Error) as caught: + res._consume_into( + lambda h: None, "build failed: {}", + consumes_first=consumes_first) + self.assertIn(error, str(caught.exception)) + self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) + res.close() + self.assertEqual(self.freed, []) + class TestManagedResourceObjects(TestContextAPIs): """Tests native resource handling management when managed manually. @@ -8585,14 +8722,12 @@ def test_builder_with_archive_swaps_the_handle(self): context = Context() self.addCleanup(context.close) builder = Builder(self.test_manifest, context=context) - original_handle = builder._handle original_stamp = builder._owner_pid result = builder.with_archive(self._make_archive()) self.assertIs(result, builder, "with_archive should return self") - self.assertNotEqual(builder._handle, original_handle, - "the native handle was not replaced") + self.assertTrue(builder._handle) self.assertEqual(builder._lifecycle_state, LifecycleState.ACTIVE) # The replacement came from this process, the stamp still applies. self.assertEqual(builder._owner_pid, original_stamp) @@ -8608,15 +8743,13 @@ def test_reader_with_fragment_swaps_the_handle(self): with open(init_path, "rb") as init: reader = Reader("video/mp4", init, context=context) self.addCleanup(reader.close) - original_handle = reader._handle # The Reader consumed the first handle, so the init stream is reopened. with open(init_path, "rb") as init, open(fragment_path, "rb") as frag: result = reader.with_fragment("video/mp4", init, frag) self.assertIs(result, reader, "with_fragment should return self") - self.assertNotEqual(reader._handle, original_handle, - "the native handle was not replaced") + self.assertTrue(reader._handle) self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) self.assertEqual(reader._owner_pid, os.getpid()) @@ -9184,6 +9317,7 @@ def test_mocked_null_without_error_is_a_known_limitation(self): with open(init_path, "rb") as init: reader = Reader("video/mp4", init) + retained_handle = reader._handle real_call = c2pa_module._lib.c2pa_reader_with_fragment c2pa_module._lib.c2pa_reader_with_fragment = ( @@ -9200,10 +9334,12 @@ def test_mocked_null_without_error_is_a_known_limitation(self): c2pa_module._lib.c2pa_error_set_last( b"Other: cleared by test teardown") - # The stale tag wins, so the handle is kept. Safe here (the mock - # consumed nothing), and the reader is still usable. - self.assertIsNotNone(reader._handle) - self.assertEqual(reader._lifecycle_state, LifecycleState.ACTIVE) + # The stale tag names another handle, so consume-first triage closes + # the reader without a free. The mock consumed nothing: the test must + # release the still-live handle that this unsupported failure leaked. + self.assertIsNone(reader._handle) + self.assertEqual(reader._lifecycle_state, LifecycleState.CLOSED) + self.assertEqual(c2pa_module._lib.c2pa_free(retained_handle), 0) reader.close() # Backfilling a pointer minted by a direct FFI call. Builder.from_archive From 7058a8a684d95f71ddec4b10db8aa7333f8ee14d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 19:32:36 +0200 Subject: [PATCH 28/32] test: fail opaque ownership checks under paired qualification instead of skipping Require the opaque registry (odd object ids) with a fixture that fails when C2PA_TRUSTED_VSI_ABI_REQUIRED/FUNCTIONAL_REQUIRED is set, list the new file in the installed-wheel contract, and mark the 203dc08d pairing qualification as pending in the contract status. --- docs/trusted-vsi-python-contract.md | 7 ++++--- tests/test_native_ownership_opaque.py | 26 +++++++++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index bd220a55..58ae0017 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -196,8 +196,8 @@ whose `pyproject.toml` version differs from the lock. `scripts/qualify_trusted_vsi_functional.py --wheel --venv --version 0.37.13.dev0` installs the wheel into an isolated venv, strips `PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, -`test_fragmented_files.py`, `test_sign_ladder.py` and -`test_native_ownership.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, +`test_fragmented_files.py`, `test_sign_ladder.py`, +`test_native_ownership.py` and `test_native_ownership_opaque.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, `C2PA_REQUIRE_SIGN_LADDER=1` and `C2PA_REQUIRE_FRAGMENTED_FILES=1`; the paired fixture asserts the imported package and native library come from that venv with the expected version, and missing ladder or fragmented capabilities fail @@ -208,5 +208,6 @@ Paired tests require the full native library and FAIL under 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 `203dc08db2bc9548a739bf209e6b510a546d70db` (ContentAuth main `69907b5a` merged; -previously `5c186c07`) +previously `5c186c07`). The status block at the top records the `5c186c07` qualification; +qualification of the `203dc08d` pairing is recorded once its paired run passes by full SHA; update that pin (not a branch name) for later native revisions. diff --git a/tests/test_native_ownership_opaque.py b/tests/test_native_ownership_opaque.py index 55c7fb05..fbfa292d 100644 --- a/tests/test_native_ownership_opaque.py +++ b/tests/test_native_ownership_opaque.py @@ -9,6 +9,7 @@ import ctypes import io +import os from pathlib import Path import pytest @@ -18,14 +19,29 @@ FIXTURES = Path(__file__).parent / "fixtures" -pytestmark = pytest.mark.skipif( - not binding.has_live_video_trusted_vsi_signing_context_v1(), - reason="requires the paired opaque-registry native", -) +def _qualification_required(): + return "1" in (os.environ.get("C2PA_TRUSTED_VSI_ABI_REQUIRED"), + os.environ.get("C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED")) @pytest.fixture(autouse=True) -def _clear_native_error_slot(): +def _require_opaque_registry(): + """Fail (never skip) under paired qualification if not the opaque native.""" + problem = None + if not binding.has_live_video_trusted_vsi_signing_context_v1(): + problem = "paired trusted-VSI native unavailable" + else: + with open(FIXTURES / "dashinit.mp4", "rb") as init: + probe = Reader("video/mp4", init) + try: + if probe._handle_value() & 1 != 1: + problem = "object handles are not odd opaque registry ids" + finally: + probe.close() + if problem: + if _qualification_required(): + pytest.fail("opaque-registry ownership checks: " + problem) + pytest.skip(problem) yield binding._lib.c2pa_error_set_last(b"Other: cleared by test teardown") From a303db8dfcdd9bc493497d098bb6f0ce9249c7ed Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 30 Sep 2026 22:42:59 +0200 Subject: [PATCH 29/32] test: clarify ownership cleanup and cover native pointer values --- docs/native-resources-management.md | 8 ++++---- src/c2pa/c2pa.py | 13 +++++++------ tests/test_unit_tests.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 820d9866..84e796cb 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -418,20 +418,20 @@ The call is passed as a lambda because the helper supplies the handle and, on su The return value alone cannot establish ownership. All three consume helpers accept the keyword-only `consumes_first=False`. Validate-first `c2pa_context_builder_set_signer` retains the signer on any registry rejection. These six calls instead take ownership of the managed handle before validating later arguments and use `consumes_first=True`: `c2pa_context_builder_build`, `c2pa_reader_with_stream`, `c2pa_reader_with_manifest_data_and_stream`, `c2pa_reader_with_fragment`, `c2pa_builder_with_definition`, and `c2pa_builder_with_archive`. -Registry rejections are `UntrackedPointer:`, `WrongPointerType:`, `PointerInUse:`, or `WrongWrapperKind:`. The last two are addressless and exist only in newer opaque registries. Failure triage is: +Registry rejections start with `UntrackedPointer:`, `WrongPointerType:`, `PointerInUse:`, or `WrongWrapperKind:`, optionally wrapped in stock native's `Other: ` prefix. Tags quoted inside another error's payload do not establish ownership. The last two tags are addressless and exist only in newer opaque registries. Failure triage is: | Native error / call order | Ownership decision | What the helper does | | --- | --- | --- | | Any registry rejection, validate-first | Managed handle not taken | Retains the handle and `ACTIVE` state; raises the typed native error. | | Addressed rejection, consume-first, rejected value equals managed value | Managed handle not taken | Retains the handle and `ACTIVE` state; raises the typed native error. | | Addressed rejection, consume-first, known rejected value differs from managed value | Managed handle consumed before another argument was rejected | `_teardown(free_handle=False)`; closes without freeing, raises the typed native error. | -| Registry rejection, consume-first, missing rejected address or unreadable managed value (`None`) | Cannot establish ownership | `_release_handle()` guarded free and close, then raises the saved typed native error. | +| Registry rejection, consume-first, missing rejected address or unreadable managed value (`None`) | Cannot establish ownership | Requests native release through `_release_handle()` and closes the Python resource; raises the saved typed native error. | | Any non-registry native error | Native took and dropped the value | Closes without freeing; raises the typed native error. | | No native error | Unknown | Guarded free and close; raises the caller's message with `"Unknown error"`. | `_handle_value()` reads the Python handle representation without dereferencing native memory: integers are used directly, otherwise `ctypes.c_void_p.from_buffer(handle).value` reads the stored pointer value. It avoids `ctypes.cast` and its reference cycle; an unreadable representation returns `None`. -Triage saves the native error before cleanup can overwrite it. It trusts the failing path to have set its own error: the thread-local slot is sticky and is not cleared before the call. Reading an error copies the message out and frees the copy, but leaves the slot set until the next error overwrites it. +Triage saves the native error before cleanup can overwrite it, preserving the exception raised by the wrapper, not restoring the slot itself. A defensive free can leave its own error in the sticky thread-local slot. The wrapper does not clear the slot before the call and trusts each failing native path to set its own error. Reading an error copies the message out and frees the copy, but leaves the slot set until the next error overwrites it. Three consume helpers share this triage; they differ only in what the FFI call returns on success: @@ -451,7 +451,7 @@ An unnecessary free can overwrite the native error slot. On stock 0.91.0 it can `_release_handle()` is the fallback for a missing native error, an exception other than `ctypes.ArgumentError` from `_invoke_consume`, or a consume-first registry rejection without a comparable address. `ctypes.ArgumentError` retains the handle and propagates unchanged because native was not called. For native failures the original error is saved before any guarded free, so cleanup cannot replace the reported failure. -The addressless `PointerInUse:` / `WrongWrapperKind:` fallback is safe with the newer opaque registry: it frees a releasable entry or rejects cleanup without targeting a different allocation. These errors do not exist on stock 0.91.0. Stock emitted addressed-rejection paths provide a comparable managed value, so the guarded-free rejection fallback is unreachable there; this is not a claim that arbitrary stale raw-address frees are safe. +The addressless `PointerInUse:` / `WrongWrapperKind:` fallback is safe with the newer opaque registry: release removes a tracked entry or rejects an already untracked handle without targeting a different allocation. Removal is not necessarily immediate destruction. Outstanding checkout guards retain the entry, so actual cleanup waits until the last guard is dropped. These errors do not exist on stock 0.91.0. Stock emitted addressed-rejection paths provide a comparable managed value, so the guarded-free rejection fallback is unreachable there; this is not a claim that arbitrary stale raw-address frees are safe. None of this is protected by a lock on the Python side: `ManagedResource` has no thread-safety mechanism of its own, and the retained-vs-consumed guarantee comes entirely from the native pointer registry and its thread-local error slot. As noted under [Which double-free risks this layer guards](#double-free-risk-mitigations), sharing one instance across threads without external synchronization is the caller's responsibility. This is a different hazard from [Fork safety](#fork-safety), which concerns a forked child process, not a thread within the same process. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index e49c29a7..a30666c1 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -473,8 +473,8 @@ def _handle_value(self): def _raise_consume_failure(self, error_message, *, consumes_first=False): """Raise the error from an FFI handler consuming call. - The native error is read before any free so a free's own - pointer-tracking error cannot overwrite it: the native error slot is + The native error is copied before any free so the raised exception + preserves it even if cleanup changes the native error slot. The slot is sticky and thread-local and the SDK does not clear it before the call, so this trusts that the failing native path set its own error. @@ -490,7 +490,8 @@ def _raise_consume_failure(self, error_message, *, consumes_first=False): its handle. A different known address means it was consumed; an absent address or unreadable handle value needs a guarded free and close. Address-less registry rejections are safe to clean up with the newer - opaque registry and are not emitted by stock 0.91.0. + opaque registry and are not emitted by stock 0.91.0. Release removes + the registry entry; outstanding guards can defer the actual drop. Args: error_message: Format string with one placeholder, used when the @@ -531,9 +532,9 @@ def _raise_consume_failure(self, error_message, *, consumes_first=False): _raise_typed_c2pa_error(error) # A non-tag error means the native side took ownership then failed, - # dropping the value itself: mark consumed, do not free (a free here - # would be a guarded no-op that dirties the error slot and races a - # recycled address in other threads). + # dropping the value itself: mark consumed, do not free. An extra + # free can dirty the error slot and, on stock native, race a reused + # address in another thread. self._teardown(free_handle=False) _raise_typed_c2pa_error(error) diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 6c4dd39b..24decb84 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -8495,6 +8495,7 @@ def free(pointer): lambda h: None, "build failed: {}", consumes_first=True) self.assertIn(error, str(caught.exception)) self.assertNotIn("cleanup error", str(caught.exception)) + self.assertEqual(state[0], "Other: UntrackedPointer: cleanup error") self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) res.close() self.assertEqual(self.freed, [handle]) @@ -8514,6 +8515,16 @@ def __bool__(self): self.assertEqual(res._handle_value(), expected) res._handle = None + def test_handle_value_reads_non_null_native_pointer(self): + self._use_real_frees() + with open(os.path.join(FIXTURES_DIR, "dashinit.mp4"), "rb") as init: + with Reader("video/mp4", init) as reader: + self.assertTrue(reader._handle) + # Cast reads the pointer value, not the native object's memory. + expected = ctypes.cast(reader._handle, ctypes.c_void_p).value + self.assertEqual(reader._handle_value(), expected) + self.assertTrue(reader.json()) + def test_consume_first_addressless_rejection_preserves_error(self): for tag in ("UntrackedPointer", "WrongPointerType", "PointerInUse", "WrongWrapperKind"): with self.subTest(tag=tag): @@ -8533,6 +8544,7 @@ def free(pointer): res._consume_no_replacement( lambda h: -1, "set failed: {}", consumes_first=True) self.assertIn(error, str(caught.exception)) + self.assertEqual(state[0], "Other: UntrackedPointer: cleanup error") self.assertEqual(res._lifecycle_state, LifecycleState.CLOSED) res.close() self.assertEqual(self.freed, [0xCAFE]) From bc9e99d1016d0b31a8163500303e84ebcadf338d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 1 Oct 2026 01:52:26 +0200 Subject: [PATCH 30/32] docs: track remaining native ownership qualification follow-ups --- docs/native-resources-management.md | 3 +++ docs/roadmap.md | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/roadmap.md diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index 6eac4ac2..b1d2bc16 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -458,6 +458,9 @@ None of this is protected by a lock on the Python side: `ManagedResource` has no Registry rejection describes the rejected argument, not necessarily the managed handle. Correct helper configuration therefore depends on the native call's ownership order, not just an error prefix. A native implementation with a different ownership contract could leak or free the wrong allocation; the fallback is not a general compatibility guarantee. +The remaining real-native concurrency and sticky-error checks are tracked in +[the roadmap](roadmap.md#native-resource-ownership-follow-ups). + ### Adopting the handle before giving it away `Reader._init_from_context` and `Builder._init_from_context` both create a native object, immediately activate it, and only then make the consuming call. `_create_and_activate()` handles the create-then-activate half: it calls the FFI constructor, validates the result with `_check_ffi_operation_result`, and `_activate()`s it, freeing the pointer if either step fails so a rejected creation leaks nothing. Reduced to its shape: diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..edc54375 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,27 @@ +# Roadmap + +## Native Resource Ownership Follow-Ups + +The generic consume-first ownership fix is in `7ba8615` and is integrated into +the functional Python branch. Follow-up `a303db8` documents that guarded +cleanup can change the sticky native error slot even though Python raises the +original snapshotted error; it adds a direct non-NULL typed-pointer test and +clarifies that a `PointerInUse` release can defer the native object's drop. +These are documentation and test improvements, not a new ABI or signing policy. + +Remaining work is separate from this integration: + +- Exercise a real opaque-native `PointerInUse` with an outstanding checkout + guard. Confirm that guarded release removes only that handle's registry entry, + defers the object drop until the last guard exits, and does not change the + Python exception. The existing sticky-slot assertions are mocked; add a + real-native check that distinguishes the raised exception from the native + error slot after cleanup. Do not generalize the opaque-ID guarantee to stock + native, whose raw allocation addresses can be reused. +- Stock c2pa-rs 0.91.0 Windows x64 ownership, unit/ladder and threaded suites + passed in mstattma/c2pa-python Actions run 36775608167. Stock Windows ARM64 + was not qualified; evaluate it separately if that target becomes supported. + +Qualify each new merged Python head against the pinned consolidated native +`203dc08d` before updating the functional branch. These follow-ups do not +authorize a dev5 release or change immutable historical release evidence. From 5c64f2cc090eeb29506bc766faa69b959e4ed982 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 1 Oct 2026 01:56:10 +0200 Subject: [PATCH 31/32] docs: scope stock Windows ARM64 ownership gap to this qualification --- docs/roadmap.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index edc54375..adf4d96e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,8 +19,9 @@ Remaining work is separate from this integration: error slot after cleanup. Do not generalize the opaque-ID guarantee to stock native, whose raw allocation addresses can be reused. - Stock c2pa-rs 0.91.0 Windows x64 ownership, unit/ladder and threaded suites - passed in mstattma/c2pa-python Actions run 36775608167. Stock Windows ARM64 - was not qualified; evaluate it separately if that target becomes supported. + passed in [mstattma/c2pa-python Actions run 36775608167](https://github.com/mstattma/c2pa-python/actions/runs/36775608167). + Stock Windows ARM64 was outside this ownership patch's qualification scope; + evaluate that ownership lane separately from legacy Windows ARM64 wheel jobs. Qualify each new merged Python head against the pinned consolidated native `203dc08d` before updating the functional branch. These follow-ups do not From 12d265db92e8dcbf80b8255278e9a7fc5945f750 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 1 Oct 2026 05:02:57 +0200 Subject: [PATCH 32/32] ci: pair trusted VSI qualification with native 6b506352 --- .github/workflows/trusted-vsi-paired.yml | 9 ++++--- docs/release-notes.md | 2 +- docs/roadmap.md | 2 +- docs/trusted-vsi-python-contract.md | 31 ++++++++++++++++-------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index d58c687f..7c4e5bcd 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -51,10 +51,11 @@ jobs: with: repository: castlabs/c2pa-rs # Reviewed consolidated trusted VSI native with ContentAuth main 69907b5a - # (feat/trusted-vsi-functional, Linux/Windows qualification run - # 36682530756). Qualification-only - # pairing; never the immutable dev5 release input. - ref: 203dc08db2bc9548a739bf209e6b510a546d70db + # (feat/trusted-vsi-functional): 203dc08d plus CI-only fixes (rustls + # 0.23.45 for RUSTSEC-2026-0285, lint/rustdoc/Wasm test scoping), no C + # ABI change. Tier 1A run 36806044529. Qualification-only pairing; + # never the immutable dev5 release input. + ref: 6b506352800c8225cf5564ce99c726aaa71039f4 path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/docs/release-notes.md b/docs/release-notes.md index 8f2196d4..bb3a9d31 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -23,7 +23,7 @@ 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@203dc08d`, Rust 1.96.0) and integrates single-file ladder + (`castlabs/c2pa-rs@6b506352`, Rust 1.96.0) and integrates single-file ladder signing (`Builder.sign_ladder`). Ladder signing now propagates DynamicAssertion and claim-signer interrupt exceptions like the other Builder paths. Consume-first FFI calls (Reader/Builder `with_*`) no longer treat a diff --git a/docs/roadmap.md b/docs/roadmap.md index adf4d96e..c1a0981f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,5 +24,5 @@ Remaining work is separate from this integration: evaluate that ownership lane separately from legacy Windows ARM64 wheel jobs. Qualify each new merged Python head against the pinned consolidated native -`203dc08d` before updating the functional branch. These follow-ups do not +`6b506352` before updating the functional branch. These follow-ups do not authorize a dev5 release or change immutable historical release evidence. diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 58ae0017..ba3a13ff 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,12 +1,23 @@ # Trusted VSI Python Contract Status: implemented and qualified against the consolidated functional native -library built from `castlabs/c2pa-rs@5c186c07ac9b432d3b8f1336ecb6ee09518408de` -(`feat/trusted-vsi-functional`, native `0.92.0-dev`, Rust 1.96.0; debug -`libc2pa_c.so` SHA-256 `dc79e81a084fc7b25e12423539b137f24d69693da46cb0166cb04538bd5589f9`, -capability mask 63), following `c2pa-rs` `docs/trusted-vsi-native-contract.md`. -The Python source integrates single-file ladder signing (`Builder.sign_ladder`) -and carries the unreleased identity `0.37.13.dev0`. Qualification of source +library from `castlabs/c2pa-rs` `feat/trusted-vsi-functional` (native +`0.92.0-dev`, Rust 1.96.0, capability mask 63), following `c2pa-rs` +`docs/trusted-vsi-native-contract.md`. The paired native is pinned to +`6b506352800c8225cf5564ce99c726aaa71039f4`: `203dc08d` (ContentAuth main +`69907b5a` merged) plus CI-only fixes (rustls `0.23.45` / rustls-webpki +`0.103.15` for RUSTSEC-2026-0285, test-only lint scopes, a feature gate on a +crate-private helper, rustdoc) with no C ABI or capability change. The Python +source integrates single-file ladder signing (`Builder.sign_ladder`) and +carries the unreleased identity `0.37.13.dev0`. The `203dc08d` pairing was +qualified at source `5c64f2cc090eeb29506bc766faa69b959e4ed982` by hosted +Linux/Windows paired run `castlabs/c2pa-python` Actions 36793704783 (focused +186, real-native ladder harness, non-threaded 730, threaded 54, installed-wheel +186). Qualification of the `6b506352` pairing is recorded on +castlabs/c2pa-python#4 by run ID. + +Earlier evidence: native `5c186c07` (debug `libc2pa_c.so` SHA-256 +`dc79e81a084fc7b25e12423539b137f24d69693da46cb0166cb04538bd5589f9`) at source `941c2ad5b57d23f31dbabf9fbef4776878cf630c`: local Linux focused 179 passed, real-native ladder harness passed, non-threaded 714 passed, threaded 54 passed, installed-wheel 179 passed; hosted Linux/Windows paired run @@ -207,7 +218,7 @@ 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 -`203dc08db2bc9548a739bf209e6b510a546d70db` (ContentAuth main `69907b5a` merged; -previously `5c186c07`). The status block at the top records the `5c186c07` qualification; -qualification of the `203dc08d` pairing is recorded once its paired run passes -by full SHA; update that pin (not a branch name) for later native revisions. +`6b506352800c8225cf5564ce99c726aaa71039f4` (ContentAuth main `69907b5a` merged +plus CI-only fixes; previously `203dc08d`, before that `5c186c07`). The status +block at the top records the qualified pairings; update that pin by full SHA +(not a branch name) for later native revisions.