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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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 53ed86269d44763956bcc3e245befb442b844004 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 24 Sep 2026 07:15:37 +0200 Subject: [PATCH 13/46] test: native validation time, checked crJSON, merged fixture fixes --- src/c2pa/c2pa.py | 5 +++-- tests/test_unit_tests.py | 27 +++++++++++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 4801b8cf..150a1f89 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3776,8 +3776,9 @@ def detailed_json(self) -> str: def crjson(self) -> str: """Get the manifest store as a crJSON string. - crJSON is a standardized JSON format for C2PA manifest data. This - call yields empty JSON ("{}") when there are no Content Credentials. + crJSON is a standardized JSON format for C2PA manifest data. The + native export is checked: exporter failures raise instead of + returning an empty ``{}`` document. Returns: The manifest store as a crJSON string. diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 962d12cd..b4595993 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -5622,10 +5622,6 @@ def test_link_archive_label_on_signing_builder_placed(self): "data": { "actions": [ _empty_created_action(), - { - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }, { "action": "c2pa.placed", "parameters": { @@ -5862,10 +5858,6 @@ def test_link_archive_multiple_ingredients_in_one_placed_action(self): "data": { "actions": [ _empty_created_action(), - { - "action": "c2pa.created", - "digitalSourceType": "http://c2pa.org/digitalsourcetype/empty", - }, { "action": "c2pa.placed", "parameters": { @@ -10895,3 +10887,22 @@ def test_ed25519_sign_with_empty_data_raises(self): if __name__ == '__main__': unittest.main(warnings='ignore') + + +class TestConformanceValidationSettings(unittest.TestCase): + """Native caller-controlled validation time and checked crJSON.""" + + def test_invalid_validation_time_is_rejected(self): + with self.assertRaises(Exception): + Context.from_dict({"verify": {"validation_time": "2027-01-15 08:00:00"}}) + + def test_validation_time_is_reported_in_crjson(self): + context = Context.from_dict({ + "verify": {"validation_time": "2027-01-15T10:00:00+02:00"}, + }) + fixture = os.path.join(FIXTURES_DIR, "C.jpg") + with open(fixture, "rb") as stream: + reader = Reader("image/jpeg", stream, context=context) + document = json.loads(reader.crjson()) + results = document["manifests"][0]["validationResults"] + self.assertEqual(results["validationTime"], "2027-01-15T08:00:00Z") From 58b2ce667bc453c3bf0cbeb9d324abb3bbd98660 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 24 Sep 2026 07:40:47 +0200 Subject: [PATCH 14/46] chore(release): prepare Castlabs conformance candidate 0.37.12.dev1 Pin native castlabs/c2pa-rs a2739fd6 (VSI + contentauth main 6c92bc32 + fMP4/TFRA fixes + validation time/trust purposes), Rust 1.96.0, and release from feat/conformance-validation-v02. --- .github/workflows/castlabs-vsi-pypi.yml | 38 +++++++------- .github/workflows/castlabs-vsi-release.yml | 52 +++++++++---------- docs/release-notes.md | 15 ++++++ release/castlabs-release-evidence.schema.json | 12 ++--- release/castlabs-vsi-inputs.lock.json | 14 ++--- release/castlabs-vsi-inputs.schema.json | 16 +++--- scripts/castlabs_release.py | 25 ++++----- tests/test_castlabs_release_smoke.py | 2 +- tests/test_castlabs_release_tooling.py | 26 +++++----- 9 files changed, 108 insertions(+), 92 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 99814e93..959cdcb9 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.8.dev5 + default: castlabs-v0.37.12.dev1 type: string concurrency: - group: castlabs-vsi-pypi-0.37.8.dev5 + group: castlabs-vsi-pypi-0.37.12.dev1 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.8.dev5 + ref: castlabs-v0.37.12.dev1 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.8.dev5 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.8.dev5 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.8.dev5 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev1 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev1 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev1 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.8.dev5)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev1)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.8.dev5 \ + gh release download castlabs-v0.37.12.dev1 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.8.dev5 \ + --source-ref refs/tags/castlabs-v0.37.12.dev1 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev1-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.12.dev1-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.8.dev5"); assert v.is_prerelease and str(v)=="0.37.8.dev5"' + python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev1"); assert v.is_prerelease and str(v)=="0.37.12.dev1"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.8.dev5/json) + https://pypi.org/pypi/c2pa-python/0.37.12.dev1/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 5dd4c696..40d2e9c1 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,16 +3,16 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.8.dev5 + - castlabs-v0.37.12.dev1 workflow_dispatch: inputs: source_sha: - description: Full feat/live-video-vsi branch-tip SHA to release + description: Full feat/conformance-validation-v02 branch-tip SHA to release required: true type: string concurrency: - group: castlabs-vsi-release-0.37.8.dev5 + group: castlabs-vsi-release-0.37.12.dev1 cancel-in-progress: false permissions: @@ -60,7 +60,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: a2739fd66092c410773bdc6881474ec34fb8ea16 fetch-depth: 1 path: c2pa-rs @@ -68,7 +68,7 @@ jobs: shell: bash run: | git -C c2pa-python fetch --no-tags origin \ - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/conformance-validation-v02:refs/remotes/origin/feat/conformance-validation-v02 EVENT=manual if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: a2739fd66092c410773bdc6881474ec34fb8ea16 fetch-depth: 1 path: c2pa-rs @@ -119,7 +119,7 @@ jobs: shell: bash run: | git -C c2pa-python fetch --no-tags origin \ - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/conformance-validation-v02:refs/remotes/origin/feat/conformance-validation-v02 EVENT=manual if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ @@ -183,7 +183,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -203,7 +203,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -266,7 +266,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: a2739fd66092c410773bdc6881474ec34fb8ea16 fetch-depth: 1 path: c2pa-rs @@ -290,7 +290,7 @@ jobs: throw "checked-out Cargo.lock SHA-256 changed" } git -C c2pa-python fetch --no-tags origin ` - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/conformance-validation-v02:refs/remotes/origin/feat/conformance-validation-v02 $event = if ($env:GITHUB_REF.StartsWith("refs/tags/")) { "tag" } else { "manual" } python c2pa-python/scripts/castlabs_release.py validate-sources ` --python-root c2pa-python --rust-root c2pa-rs ` @@ -339,7 +339,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -359,7 +359,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -412,9 +412,9 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev1 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | test "$(find release-out -maxdepth 1 -name '*.whl' | wc -l)" -eq 1 python -m venv "${RUNNER_TEMP}/wheel-test" @@ -452,9 +452,9 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev1 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true @@ -475,7 +475,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.8.dev5' + if: github.ref == 'refs/tags/castlabs-v0.37.12.dev1' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -510,7 +510,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.12.dev1-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -527,13 +527,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -547,7 +547,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.8.dev5 + TAG=castlabs-v0.37.12.dev1 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -563,7 +563,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.8.dev5 + TAG=castlabs-v0.37.12.dev1 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -589,7 +589,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.8.dev5")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev1")' > "$1" return 0 } query_release_id() { diff --git a/docs/release-notes.md b/docs/release-notes.md index 06f799e7..bffacf54 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,20 @@ # Release notes +## Version 0.37.12.dev1 + +Castlabs Conformance Program 0.2 / C2PA 2.4 candidate prerelease. + +- Native `castlabs/c2pa-rs` `a2739fd66092c410773bdc6881474ec34fb8ea16`: the + live-video VSI line merged with contentauth main `6c92bc32` (C2PA 2.4 + validation), plus the single-file fMP4 Merkle and TFRA fixes. +- `verify.validation_time` (RFC 3339) selects the evaluation instant for + certificate, chain and OCSP decisions and the reported `validationTime`. +- Purpose-scoped trust anchors (`trust.anchors` with `trust_kind` + `manifest`/`tsa`/`cawg`) and `verify.strict_trust_purposes`. +- `Reader.crjson()` is checked: exporter failures raise instead of `{}`. +- 2.4 `specVersion` in `claim_generator_info` serializes correctly, and the + default EKU allow-list survives context trust settings. + ## Version 0.37.8.dev5 ### Breaking changes diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index b9703c71..56a3df05 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/castlabs/c2pa-python/blob/feat/live-video-vsi/release/castlabs-release-evidence.schema.json", + "$id": "https://github.com/castlabs/c2pa-python/blob/feat/conformance-validation-v02/release/castlabs-release-evidence.schema.json", "title": "Castlabs c2pa-python release evidence", "type": "object", "additionalProperties": false, @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.8.dev5"}, + "version": {"const": "0.37.12.dev1"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,9 +49,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "9b80687f386f2b373fd1ac9c705d8e61a07b608e"}, - "version": {"const": "0.91.0-dev"}, - "cargoLockSha256": {"const": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f"} + "commit": {"const": "a2739fd66092c410773bdc6881474ec34fb8ea16"}, + "version": {"const": "0.92.0-dev"}, + "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } } } @@ -90,7 +90,7 @@ "type": "object", "additionalProperties": false, "required": ["target", "rustToolchain", "cargoCommand", "cargoTreeCommand", "noDefaultFeatures", "features", "resolvedFeatureReportSha256", "sourceDateEpoch", "cargoBuildJobs", "cargoIncremental", "pythonHashSeed", "container"], "properties": { - "target": {"enum": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]}, "rustToolchain": {"const": "1.88.0"}, + "target": {"enum": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]}, "rustToolchain": {"const": "1.96.0"}, "cargoCommand": {"type": "array", "minItems": 12, "items": {"type": "string"}}, "noDefaultFeatures": {"const": true}, "cargoTreeCommand": {"type": "array", "minItems": 16, "items": {"type": "string"}}, "features": {"const": ["rust_native_crypto", "http", "add_thumbnails", "file_io", "unstable_live_video"]}, "resolvedFeatureReportSha256": {"$ref": "#/$defs/sha256"}, diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index c5e082f2..3f1d72c8 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,23 +2,23 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.8.dev5" + "version": "0.37.12.dev1" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", - "releaseBranch": "feat/live-video-vsi", - "releaseTag": "castlabs-v0.37.8.dev5" + "releaseBranch": "feat/conformance-validation-v02", + "releaseTag": "castlabs-v0.37.12.dev1" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "9b80687f386f2b373fd1ac9c705d8e61a07b608e", - "version": "0.91.0-dev", - "cargoLockSha256": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f" + "commit": "a2739fd66092c410773bdc6881474ec34fb8ea16", + "version": "0.92.0-dev", + "cargoLockSha256": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" }, "rustToolchain": { - "channel": "1.88.0", + "channel": "1.96.0", "rustupVersion": "1.28.2", "installers": { "x86_64-unknown-linux-gnu": { diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 7fdb3df7..77aeef19 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/castlabs/c2pa-python/blob/feat/live-video-vsi/release/castlabs-vsi-inputs.schema.json", + "$id": "https://github.com/castlabs/c2pa-python/blob/feat/conformance-validation-v02/release/castlabs-vsi-inputs.schema.json", "title": "Castlabs VSI c2pa-python release input lock", "type": "object", "additionalProperties": false, @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.8.dev5"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev1"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -38,8 +38,8 @@ "properties": { "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, - "releaseBranch": {"const": "feat/live-video-vsi"}, - "releaseTag": {"const": "castlabs-v0.37.8.dev5"} + "releaseBranch": {"const": "feat/conformance-validation-v02"}, + "releaseTag": {"const": "castlabs-v0.37.12.dev1"} } }, "rustSource": { @@ -48,9 +48,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "9b80687f386f2b373fd1ac9c705d8e61a07b608e"}, - "version": {"const": "0.91.0-dev"}, - "cargoLockSha256": {"const": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f"} + "commit": {"const": "a2739fd66092c410773bdc6881474ec34fb8ea16"}, + "version": {"const": "0.92.0-dev"}, + "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } }, "installer": { @@ -62,7 +62,7 @@ "type": "object", "additionalProperties": false, "required": ["channel", "rustupVersion", "installers"], "properties": { - "channel": {"const": "1.88.0"}, + "channel": {"const": "1.96.0"}, "rustupVersion": {"const": "1.28.2"}, "installers": { "type": "object", "additionalProperties": false, diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index a280922f..236c2872 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,23 +26,24 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.8.dev5" -RUST_COMMIT = "9b80687f386f2b373fd1ac9c705d8e61a07b608e" -CARGO_LOCK_SHA256 = "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f" -RUST_TOOLCHAIN = "1.88.0" +RELEASE_VERSION = "0.37.12.dev1" +RUST_COMMIT = "a2739fd66092c410773bdc6881474ec34fb8ea16" +CARGO_LOCK_SHA256 = "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" +RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.8.dev5" +RELEASE_TAG = "castlabs-v0.37.12.dev1" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" - f"{RELEASE_IDENTITY_TEXT}. Successor to immutable dev4; enforces exact " - "DynamicAssertion callback sizing for every signing path. See the attached " - "schema-2 evidence and SHA-256 sidecars." + f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate: " + "live-video VSI merged with contentauth main 6c92bc32, single-file fMP4 " + "Merkle and TFRA fixes, caller-controlled validation time and purpose-scoped " + "trust. See the attached schema-2 evidence and SHA-256 sidecars." ) @@ -218,12 +219,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.8.dev5") + fail("release lock package identity is not 0.37.12.dev1") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", - "releaseBranch": "feat/live-video-vsi", - "releaseTag": "castlabs-v0.37.8.dev5", + "releaseBranch": "feat/conformance-validation-v02", + "releaseTag": "castlabs-v0.37.12.dev1", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] @@ -231,7 +232,7 @@ def validate_lock(lock: dict[str, Any]) -> None: "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", "commit": RUST_COMMIT, - "version": "0.91.0-dev", + "version": "0.92.0-dev", "cargoLockSha256": CARGO_LOCK_SHA256, }: fail("unexpected c2pa-rs release source policy") diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 0058218b..9d9714c7 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -45,7 +45,7 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.8.dev5") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev1") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 43f11fc6..0d0f965c 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -29,11 +29,11 @@ def test_prerelease_version_is_consistent(): - assert release.project_version(ROOT) == "0.37.8.dev5" + assert release.project_version(ROOT) == "0.37.12.dev1" 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 == "# Version: 0.37.12.dev1" def test_release_lock_and_schemas_are_valid_json(): @@ -41,7 +41,7 @@ def test_release_lock_and_schemas_are_valid_json(): release.validate_lock(lock) assert lock["rustSource"]["commit"] == release.RUST_COMMIT assert lock["rustSource"]["cargoLockSha256"] == release.CARGO_LOCK_SHA256 - assert lock["rustToolchain"]["channel"] == "1.88.0" + assert lock["rustToolchain"]["channel"] == "1.96.0" for name in ( "castlabs-vsi-inputs.schema.json", "castlabs-release-evidence.schema.json", @@ -113,7 +113,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -164,7 +164,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.8.dev5'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev1'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -238,9 +238,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.8.dev5" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.12.dev1" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.8.dev5/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.12.dev1/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -269,7 +269,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.8.dev5' not in legacy_workflow + assert 'test "$VERSION" != 0.37.12.dev1' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -299,7 +299,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev1") == 2 assert "pytest.skip(" in smoke assert "unittest.skip" not in smoke dynamic_start = smoke.index( @@ -362,7 +362,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.8.dev5" in release_notes + assert "## Version 0.37.12.dev1" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -557,7 +557,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -568,7 +568,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: @@ -996,7 +996,7 @@ def test_release_response_validators_cover_owned_empty_and_complete_states(tmp_p ) == release_id ) - for allowed_target in ("main", "feat/live-video-vsi", TEST_SOURCE_SHA): + for allowed_target in ("main", "feat/conformance-validation-v02", TEST_SOURCE_SHA): target_response = copy.deepcopy(create_response) target_response["target_commitish"] = allowed_target assert ( From d813dc8646c4814c7c6338fb5562e3f9eb945f2b Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 24 Sep 2026 14:07:02 +0200 Subject: [PATCH 15/46] chore(release): prepare 0.37.12.dev2 after failed dev1 installed-wheel tests Install pytest explicitly in the installed-wheel test environments and pin native castlabs/c2pa-rs 6ca7fb16. --- .github/workflows/castlabs-vsi-pypi.yml | 38 ++++++++-------- .github/workflows/castlabs-vsi-release.yml | 43 ++++++++++--------- docs/release-notes.md | 10 +++-- pyproject.toml | 2 +- release/castlabs-release-evidence.schema.json | 4 +- release/castlabs-vsi-inputs.lock.json | 6 +-- release/castlabs-vsi-inputs.schema.json | 6 +-- scripts/castlabs_release.py | 12 +++--- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_smoke.py | 2 +- tests/test_castlabs_release_tooling.py | 22 +++++----- 11 files changed, 77 insertions(+), 70 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 959cdcb9..1d8b4c93 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.12.dev1 + default: castlabs-v0.37.12.dev2 type: string concurrency: - group: castlabs-vsi-pypi-0.37.12.dev1 + group: castlabs-vsi-pypi-0.37.12.dev2 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.12.dev1 + ref: castlabs-v0.37.12.dev2 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev1 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev1 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev1 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev2 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev2 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev2 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev1)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev2)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.12.dev1 \ + gh release download castlabs-v0.37.12.dev2 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.12.dev1 \ + --source-ref refs/tags/castlabs-v0.37.12.dev2 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev1-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev2-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.12.dev1-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.12.dev2-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev1"); assert v.is_prerelease and str(v)=="0.37.12.dev1"' + python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev2"); assert v.is_prerelease and str(v)=="0.37.12.dev2"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.12.dev1/json) + https://pypi.org/pypi/c2pa-python/0.37.12.dev2/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 40d2e9c1..86c0d405 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,7 +3,7 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.12.dev1 + - castlabs-v0.37.12.dev2 workflow_dispatch: inputs: source_sha: @@ -12,7 +12,7 @@ on: type: string concurrency: - group: castlabs-vsi-release-0.37.12.dev1 + group: castlabs-vsi-release-0.37.12.dev2 cancel-in-progress: false permissions: @@ -60,7 +60,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: a2739fd66092c410773bdc6881474ec34fb8ea16 + ref: 6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7 fetch-depth: 1 path: c2pa-rs @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: a2739fd66092c410773bdc6881474ec34fb8ea16 + ref: 6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7 fetch-depth: 1 path: c2pa-rs @@ -183,7 +183,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -203,7 +203,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -266,7 +266,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: a2739fd66092c410773bdc6881474ec34fb8ea16 + ref: 6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7 fetch-depth: 1 path: c2pa-rs @@ -339,7 +339,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -359,7 +359,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -412,7 +412,7 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev1 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev2 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -420,6 +420,8 @@ jobs: python -m venv "${RUNNER_TEMP}/wheel-test" "${RUNNER_TEMP}/wheel-test/bin/python" -m pip install --upgrade pip "${RUNNER_TEMP}/wheel-test/bin/python" -m pip install release-out/*.whl + # pytest is no longer a runtime dependency of the wheel (upstream 4d909cf). + "${RUNNER_TEMP}/wheel-test/bin/python" -m pip install pytest==8.4.1 "${RUNNER_TEMP}/wheel-test/bin/python" -m pytest -q \ c2pa-python/tests/test_castlabs_release_smoke.py if [[ "${{ matrix.python }}" == "3.10" ]]; then @@ -452,7 +454,7 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev1 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev2 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -464,6 +466,7 @@ jobs: & "$env:RUNNER_TEMP\wheel-test\Scripts\python.exe" -m pip install --upgrade pip $wheel = (Get-ChildItem release-out/*.whl).FullName & "$env:RUNNER_TEMP\wheel-test\Scripts\python.exe" -m pip install $wheel + & "$env:RUNNER_TEMP\wheel-test\Scripts\python.exe" -m pip install pytest==8.4.1 & "$env:RUNNER_TEMP\wheel-test\Scripts\python.exe" -m pytest -q ` c2pa-python/tests/test_castlabs_release_smoke.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -475,7 +478,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.12.dev1' + if: github.ref == 'refs/tags/castlabs-v0.37.12.dev2' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -510,7 +513,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.12.dev1-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.12.dev2-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -527,13 +530,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.12.dev1-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.12.dev1-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -547,7 +550,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.12.dev1 + TAG=castlabs-v0.37.12.dev2 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -563,7 +566,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.12.dev1 + TAG=castlabs-v0.37.12.dev2 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -589,7 +592,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev1")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev2")' > "$1" return 0 } query_release_id() { diff --git a/docs/release-notes.md b/docs/release-notes.md index bffacf54..886b17e9 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,10 +1,14 @@ # Release notes -## Version 0.37.12.dev1 +## Version 0.37.12.dev2 -Castlabs Conformance Program 0.2 / C2PA 2.4 candidate prerelease. +Castlabs Conformance Program 0.2 / C2PA 2.4 candidate prerelease. Successor to +0.37.12.dev1, whose tag-triggered run `35961050991` built both platforms but +failed installed-wheel tests (pytest no longer a runtime dependency) and whose +native source failed one upstream test under `http_reqwest_blocking`; no dev1 +draft or GitHub release was created. -- Native `castlabs/c2pa-rs` `a2739fd66092c410773bdc6881474ec34fb8ea16`: the +- Native `castlabs/c2pa-rs` `6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7`: the live-video VSI line merged with contentauth main `6c92bc32` (C2PA 2.4 validation), plus the single-file fMP4 Merkle and TFRA fixes. - `verify.validation_time` (RFC 3339) selects the evaluation instant for diff --git a/pyproject.toml b/pyproject.toml index 8554b7b1..7800e4c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.12.dev1" +version = "0.37.12.dev2" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index 56a3df05..1dfcf36c 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.12.dev1"}, + "version": {"const": "0.37.12.dev2"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,7 +49,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "a2739fd66092c410773bdc6881474ec34fb8ea16"}, + "commit": {"const": "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index 3f1d72c8..d259a384 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,18 +2,18 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.12.dev1" + "version": "0.37.12.dev2" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/conformance-validation-v02", - "releaseTag": "castlabs-v0.37.12.dev1" + "releaseTag": "castlabs-v0.37.12.dev2" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "a2739fd66092c410773bdc6881474ec34fb8ea16", + "commit": "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7", "version": "0.92.0-dev", "cargoLockSha256": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" }, diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 77aeef19..380d086b 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev1"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev2"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, "releaseBranch": {"const": "feat/conformance-validation-v02"}, - "releaseTag": {"const": "castlabs-v0.37.12.dev1"} + "releaseTag": {"const": "castlabs-v0.37.12.dev2"} } }, "rustSource": { @@ -48,7 +48,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "a2739fd66092c410773bdc6881474ec34fb8ea16"}, + "commit": {"const": "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index 236c2872..26971218 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,21 +26,21 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.12.dev1" -RUST_COMMIT = "a2739fd66092c410773bdc6881474ec34fb8ea16" +RELEASE_VERSION = "0.37.12.dev2" +RUST_COMMIT = "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7" CARGO_LOCK_SHA256 = "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.12.dev1" +RELEASE_TAG = "castlabs-v0.37.12.dev2" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" - f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate: " + f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate (successor to dev1 run 35961050991; no dev1 release): " "live-video VSI merged with contentauth main 6c92bc32, single-file fMP4 " "Merkle and TFRA fixes, caller-controlled validation time and purpose-scoped " "trust. See the attached schema-2 evidence and SHA-256 sidecars." @@ -219,12 +219,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.12.dev1") + fail("release lock package identity is not 0.37.12.dev2") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/conformance-validation-v02", - "releaseTag": "castlabs-v0.37.12.dev1", + "releaseTag": "castlabs-v0.37.12.dev2", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 150a1f89..b4df734a 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.12.dev1 +# Version: 0.37.12.dev2 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 9d9714c7..db3df384 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -45,7 +45,7 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev1") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev2") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 0d0f965c..fc0d9d3b 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -29,11 +29,11 @@ def test_prerelease_version_is_consistent(): - assert release.project_version(ROOT) == "0.37.12.dev1" + assert release.project_version(ROOT) == "0.37.12.dev2" first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) - assert first_line == "# Version: 0.37.12.dev1" + assert first_line == "# Version: 0.37.12.dev2" def test_release_lock_and_schemas_are_valid_json(): @@ -113,7 +113,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -164,7 +164,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev1'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev2'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -238,9 +238,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.12.dev1" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.12.dev2" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.12.dev1/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.12.dev2/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -269,7 +269,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.12.dev1' not in legacy_workflow + assert 'test "$VERSION" != 0.37.12.dev2' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -299,7 +299,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev1") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev2") == 2 assert "pytest.skip(" in smoke assert "unittest.skip" not in smoke dynamic_start = smoke.index( @@ -362,7 +362,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.12.dev1" in release_notes + assert "## Version 0.37.12.dev2" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -557,7 +557,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.12.dev1-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -568,7 +568,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.12.dev1-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: From 83cdc4758ed3cd1c375b189980508e2a38a37c84 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 24 Sep 2026 14:42:22 +0200 Subject: [PATCH 16/46] chore(release): prepare 0.37.12.dev3 on qualified native 6fa3e010 --- .github/workflows/castlabs-vsi-pypi.yml | 38 +++++++++--------- .github/workflows/castlabs-vsi-release.yml | 40 +++++++++---------- docs/release-notes.md | 11 ++++- pyproject.toml | 2 +- release/castlabs-release-evidence.schema.json | 4 +- release/castlabs-vsi-inputs.lock.json | 6 +-- release/castlabs-vsi-inputs.schema.json | 6 +-- scripts/castlabs_release.py | 12 +++--- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_smoke.py | 2 +- tests/test_castlabs_release_tooling.py | 22 +++++----- 11 files changed, 76 insertions(+), 69 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 1d8b4c93..dcfc4d60 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.12.dev2 + default: castlabs-v0.37.12.dev3 type: string concurrency: - group: castlabs-vsi-pypi-0.37.12.dev2 + group: castlabs-vsi-pypi-0.37.12.dev3 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.12.dev2 + ref: castlabs-v0.37.12.dev3 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev2 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev2 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev2 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev3 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev3 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev3 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev2)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev3)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.12.dev2 \ + gh release download castlabs-v0.37.12.dev3 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.12.dev2 \ + --source-ref refs/tags/castlabs-v0.37.12.dev3 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev2-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev3-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.12.dev2-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.12.dev3-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev2"); assert v.is_prerelease and str(v)=="0.37.12.dev2"' + python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev3"); assert v.is_prerelease and str(v)=="0.37.12.dev3"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.12.dev2/json) + https://pypi.org/pypi/c2pa-python/0.37.12.dev3/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 86c0d405..d9c1f141 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,7 +3,7 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.12.dev2 + - castlabs-v0.37.12.dev3 workflow_dispatch: inputs: source_sha: @@ -12,7 +12,7 @@ on: type: string concurrency: - group: castlabs-vsi-release-0.37.12.dev2 + group: castlabs-vsi-release-0.37.12.dev3 cancel-in-progress: false permissions: @@ -60,7 +60,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7 + ref: 6fa3e01097a87ee9265c97e00d9ec12be3052288 fetch-depth: 1 path: c2pa-rs @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7 + ref: 6fa3e01097a87ee9265c97e00d9ec12be3052288 fetch-depth: 1 path: c2pa-rs @@ -183,7 +183,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -203,7 +203,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -266,7 +266,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7 + ref: 6fa3e01097a87ee9265c97e00d9ec12be3052288 fetch-depth: 1 path: c2pa-rs @@ -339,7 +339,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -359,7 +359,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -412,7 +412,7 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev3 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -454,7 +454,7 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev3 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -478,7 +478,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.12.dev2' + if: github.ref == 'refs/tags/castlabs-v0.37.12.dev3' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -513,7 +513,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.12.dev2-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.12.dev3-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -530,13 +530,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.12.dev2-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.12.dev2-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -550,7 +550,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.12.dev2 + TAG=castlabs-v0.37.12.dev3 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -566,7 +566,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.12.dev2 + TAG=castlabs-v0.37.12.dev3 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -592,7 +592,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev2")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev3")' > "$1" return 0 } query_release_id() { diff --git a/docs/release-notes.md b/docs/release-notes.md index 886b17e9..89e96094 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,13 @@ # Release notes -## Version 0.37.12.dev2 +## Version 0.37.12.dev3 + +Supersedes 0.37.12.dev2 (run `35997070721`, cancelled before publication): the +native source is re-pinned to `6fa3e01097a87ee9265c97e00d9ec12be3052288`, whose +only change over dev2's `6ca7fb16` aligns c2patool CLI tests with contentauth +main, so native qualification passes on both platforms. + +### 0.37.12.dev2 (not released) Castlabs Conformance Program 0.2 / C2PA 2.4 candidate prerelease. Successor to 0.37.12.dev1, whose tag-triggered run `35961050991` built both platforms but @@ -8,7 +15,7 @@ failed installed-wheel tests (pytest no longer a runtime dependency) and whose native source failed one upstream test under `http_reqwest_blocking`; no dev1 draft or GitHub release was created. -- Native `castlabs/c2pa-rs` `6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7`: the +- Native `castlabs/c2pa-rs` `6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7` (dev3: `6fa3e010`): the live-video VSI line merged with contentauth main `6c92bc32` (C2PA 2.4 validation), plus the single-file fMP4 Merkle and TFRA fixes. - `verify.validation_time` (RFC 3339) selects the evaluation instant for diff --git a/pyproject.toml b/pyproject.toml index 7800e4c7..2dacec09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.12.dev2" +version = "0.37.12.dev3" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index 1dfcf36c..33ec7349 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.12.dev2"}, + "version": {"const": "0.37.12.dev3"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,7 +49,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7"}, + "commit": {"const": "6fa3e01097a87ee9265c97e00d9ec12be3052288"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index d259a384..1822c6c8 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,18 +2,18 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.12.dev2" + "version": "0.37.12.dev3" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/conformance-validation-v02", - "releaseTag": "castlabs-v0.37.12.dev2" + "releaseTag": "castlabs-v0.37.12.dev3" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7", + "commit": "6fa3e01097a87ee9265c97e00d9ec12be3052288", "version": "0.92.0-dev", "cargoLockSha256": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" }, diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 380d086b..e48c9d19 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev2"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev3"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, "releaseBranch": {"const": "feat/conformance-validation-v02"}, - "releaseTag": {"const": "castlabs-v0.37.12.dev2"} + "releaseTag": {"const": "castlabs-v0.37.12.dev3"} } }, "rustSource": { @@ -48,7 +48,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7"}, + "commit": {"const": "6fa3e01097a87ee9265c97e00d9ec12be3052288"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index 26971218..2a79c423 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,21 +26,21 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.12.dev2" -RUST_COMMIT = "6ca7fb168b10bde7a1ef8bb8e577ffbae73233c7" +RELEASE_VERSION = "0.37.12.dev3" +RUST_COMMIT = "6fa3e01097a87ee9265c97e00d9ec12be3052288" CARGO_LOCK_SHA256 = "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.12.dev2" +RELEASE_TAG = "castlabs-v0.37.12.dev3" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" - f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate (successor to dev1 run 35961050991; no dev1 release): " + f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate (successor to dev1 run 35961050991 and dev2 run 35997070721; neither was released): " "live-video VSI merged with contentauth main 6c92bc32, single-file fMP4 " "Merkle and TFRA fixes, caller-controlled validation time and purpose-scoped " "trust. See the attached schema-2 evidence and SHA-256 sidecars." @@ -219,12 +219,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.12.dev2") + fail("release lock package identity is not 0.37.12.dev3") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/conformance-validation-v02", - "releaseTag": "castlabs-v0.37.12.dev2", + "releaseTag": "castlabs-v0.37.12.dev3", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b4df734a..6fc29e8a 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.12.dev2 +# Version: 0.37.12.dev3 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index db3df384..749795e6 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -45,7 +45,7 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev2") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev3") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index fc0d9d3b..9e6df1a1 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -29,11 +29,11 @@ def test_prerelease_version_is_consistent(): - assert release.project_version(ROOT) == "0.37.12.dev2" + assert release.project_version(ROOT) == "0.37.12.dev3" first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) - assert first_line == "# Version: 0.37.12.dev2" + assert first_line == "# Version: 0.37.12.dev3" def test_release_lock_and_schemas_are_valid_json(): @@ -113,7 +113,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -164,7 +164,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev2'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev3'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -238,9 +238,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.12.dev2" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.12.dev3" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.12.dev2/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.12.dev3/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -269,7 +269,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.12.dev2' not in legacy_workflow + assert 'test "$VERSION" != 0.37.12.dev3' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -299,7 +299,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev2") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev3") == 2 assert "pytest.skip(" in smoke assert "unittest.skip" not in smoke dynamic_start = smoke.index( @@ -362,7 +362,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.12.dev2" in release_notes + assert "## Version 0.37.12.dev3" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -557,7 +557,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.12.dev2-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -568,7 +568,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.12.dev2-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: From f55a1fb7f05de005eeca580b023d97b104e411ef Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 24 Sep 2026 16:54:45 +0200 Subject: [PATCH 17/46] chore(release): prepare 0.37.12.dev4 with crJSON 2.4 conformance (native e1cc897d) --- .github/workflows/castlabs-vsi-pypi.yml | 38 +++++++++--------- .github/workflows/castlabs-vsi-release.yml | 40 +++++++++---------- docs/release-notes.md | 9 +++++ pyproject.toml | 2 +- release/castlabs-release-evidence.schema.json | 4 +- release/castlabs-vsi-inputs.lock.json | 6 +-- release/castlabs-vsi-inputs.schema.json | 6 +-- scripts/castlabs_release.py | 12 +++--- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_smoke.py | 2 +- tests/test_castlabs_release_tooling.py | 22 +++++----- tests/test_unit_tests.py | 5 +++ 12 files changed, 81 insertions(+), 67 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index dcfc4d60..f0681254 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.12.dev3 + default: castlabs-v0.37.12.dev4 type: string concurrency: - group: castlabs-vsi-pypi-0.37.12.dev3 + group: castlabs-vsi-pypi-0.37.12.dev4 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.12.dev3 + ref: castlabs-v0.37.12.dev4 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev3 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev3 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev3 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.12.dev4 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.12.dev4 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.12.dev4 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev3)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.12.dev4)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.12.dev3 \ + gh release download castlabs-v0.37.12.dev4 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.12.dev3 \ + --source-ref refs/tags/castlabs-v0.37.12.dev4 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev4-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev4-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.12.dev3-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.12.dev4-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.12.dev3-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.12.dev4-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.12.dev4-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.12.dev4-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.12.dev4-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.12.dev4-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev3"); assert v.is_prerelease and str(v)=="0.37.12.dev3"' + python3 -c 'from packaging.version import Version; v=Version("0.37.12.dev4"); assert v.is_prerelease and str(v)=="0.37.12.dev4"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.12.dev3/json) + https://pypi.org/pypi/c2pa-python/0.37.12.dev4/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index d9c1f141..36d1b493 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,7 +3,7 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.12.dev3 + - castlabs-v0.37.12.dev4 workflow_dispatch: inputs: source_sha: @@ -12,7 +12,7 @@ on: type: string concurrency: - group: castlabs-vsi-release-0.37.12.dev3 + group: castlabs-vsi-release-0.37.12.dev4 cancel-in-progress: false permissions: @@ -60,7 +60,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 6fa3e01097a87ee9265c97e00d9ec12be3052288 + ref: e1cc897d5c04d11e7773f54a659f98ec89ae224f fetch-depth: 1 path: c2pa-rs @@ -111,7 +111,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 6fa3e01097a87ee9265c97e00d9ec12be3052288 + ref: e1cc897d5c04d11e7773f54a659f98ec89ae224f fetch-depth: 1 path: c2pa-rs @@ -183,7 +183,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.12.dev4-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -203,7 +203,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.12.dev4-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -266,7 +266,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 6fa3e01097a87ee9265c97e00d9ec12be3052288 + ref: e1cc897d5c04d11e7773f54a659f98ec89ae224f fetch-depth: 1 path: c2pa-rs @@ -339,7 +339,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.12.dev4-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -359,7 +359,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.12.dev4-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -412,7 +412,7 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev3 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev4 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -454,7 +454,7 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev3 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev4 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -478,7 +478,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.12.dev3' + if: github.ref == 'refs/tags/castlabs-v0.37.12.dev4' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -513,7 +513,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.12.dev3-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.12.dev4-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -530,13 +530,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.12.dev4-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.12.dev4-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.12.dev3-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev4-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.12.dev3-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.12.dev4-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -550,7 +550,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.12.dev3 + TAG=castlabs-v0.37.12.dev4 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -566,7 +566,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.12.dev3 + TAG=castlabs-v0.37.12.dev4 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -592,7 +592,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev3")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.12.dev4")' > "$1" return 0 } query_release_id() { diff --git a/docs/release-notes.md b/docs/release-notes.md index 89e96094..21abf2c4 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,14 @@ # Release notes +## Version 0.37.12.dev4 + +Supersedes the published 0.37.12.dev3 (which remains unchanged). Native +`castlabs/c2pa-rs` `e1cc897d5c04d11e7773f54a659f98ec89ae224f` makes `Reader.crjson()` +conform to the published crJSON 2.4 schema: `validationResults.specVersion` +reports `2.4.0` (the validator's C2PA version) instead of `2.3.0`, and the +post-2.4 `isUpdateManifest` / `isCompressedManifest` manifest fields are no +longer emitted. + ## Version 0.37.12.dev3 Supersedes 0.37.12.dev2 (run `35997070721`, cancelled before publication): the diff --git a/pyproject.toml b/pyproject.toml index 2dacec09..f57adc9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.12.dev3" +version = "0.37.12.dev4" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index 33ec7349..fa1c5cee 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.12.dev3"}, + "version": {"const": "0.37.12.dev4"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,7 +49,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "6fa3e01097a87ee9265c97e00d9ec12be3052288"}, + "commit": {"const": "e1cc897d5c04d11e7773f54a659f98ec89ae224f"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index 1822c6c8..1f9f269c 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,18 +2,18 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.12.dev3" + "version": "0.37.12.dev4" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/conformance-validation-v02", - "releaseTag": "castlabs-v0.37.12.dev3" + "releaseTag": "castlabs-v0.37.12.dev4" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "6fa3e01097a87ee9265c97e00d9ec12be3052288", + "commit": "e1cc897d5c04d11e7773f54a659f98ec89ae224f", "version": "0.92.0-dev", "cargoLockSha256": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" }, diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index e48c9d19..8a5ccb0a 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev3"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.12.dev4"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, "releaseBranch": {"const": "feat/conformance-validation-v02"}, - "releaseTag": {"const": "castlabs-v0.37.12.dev3"} + "releaseTag": {"const": "castlabs-v0.37.12.dev4"} } }, "rustSource": { @@ -48,7 +48,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "6fa3e01097a87ee9265c97e00d9ec12be3052288"}, + "commit": {"const": "e1cc897d5c04d11e7773f54a659f98ec89ae224f"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2"} } diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index 2a79c423..544207ed 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,21 +26,21 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.12.dev3" -RUST_COMMIT = "6fa3e01097a87ee9265c97e00d9ec12be3052288" +RELEASE_VERSION = "0.37.12.dev4" +RUST_COMMIT = "e1cc897d5c04d11e7773f54a659f98ec89ae224f" CARGO_LOCK_SHA256 = "e074f31ad8c7d4d4278967bb0aaf12bde0013a5f9cb7517cc7af462dbed962e2" RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.12.dev3" +RELEASE_TAG = "castlabs-v0.37.12.dev4" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" - f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate (successor to dev1 run 35961050991 and dev2 run 35997070721; neither was released): " + f"{RELEASE_IDENTITY_TEXT}. Conformance Program 0.2 / C2PA 2.4 candidate (successor to the published dev3; crJSON 2.4 conformance): " "live-video VSI merged with contentauth main 6c92bc32, single-file fMP4 " "Merkle and TFRA fixes, caller-controlled validation time and purpose-scoped " "trust. See the attached schema-2 evidence and SHA-256 sidecars." @@ -219,12 +219,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.12.dev3") + fail("release lock package identity is not 0.37.12.dev4") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/conformance-validation-v02", - "releaseTag": "castlabs-v0.37.12.dev3", + "releaseTag": "castlabs-v0.37.12.dev4", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 6fc29e8a..fb73c633 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.12.dev3 +# Version: 0.37.12.dev4 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 749795e6..a4c3469b 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -45,7 +45,7 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev3") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.12.dev4") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 9e6df1a1..0aca17b8 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -29,11 +29,11 @@ def test_prerelease_version_is_consistent(): - assert release.project_version(ROOT) == "0.37.12.dev3" + assert release.project_version(ROOT) == "0.37.12.dev4" first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) - assert first_line == "# Version: 0.37.12.dev3" + assert first_line == "# Version: 0.37.12.dev4" def test_release_lock_and_schemas_are_valid_json(): @@ -113,7 +113,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.12.dev4-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -164,7 +164,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev3'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.12.dev4'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -238,9 +238,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.12.dev3" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.12.dev4" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.12.dev3/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.12.dev4/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -269,7 +269,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.12.dev3' not in legacy_workflow + assert 'test "$VERSION" != 0.37.12.dev4' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -299,7 +299,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev3") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.12.dev4") == 2 assert "pytest.skip(" in smoke assert "unittest.skip" not in smoke dynamic_start = smoke.index( @@ -362,7 +362,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.12.dev3" in release_notes + assert "## Version 0.37.12.dev4" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -557,7 +557,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.12.dev3-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.12.dev4-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -568,7 +568,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.12.dev3-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.12.dev4-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index b4595993..7cba06f1 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -10906,3 +10906,8 @@ def test_validation_time_is_reported_in_crjson(self): document = json.loads(reader.crjson()) results = document["manifests"][0]["validationResults"] self.assertEqual(results["validationTime"], "2027-01-15T08:00:00Z") + # crJSON 2.4: the validator version, and no post-2.4 manifest fields. + self.assertEqual(results["specVersion"], "2.4.0") + for manifest in document["manifests"]: + self.assertNotIn("isUpdateManifest", manifest) + self.assertNotIn("isCompressedManifest", manifest) From ca7ea1acd171d90b82926f6931068828f2977b6d Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Fri, 25 Sep 2026 06:11:14 +0200 Subject: [PATCH 18/46] 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 19/46] 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 20/46] 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-----\nMIIGezCCBC+gAwIBAgIUIYAhaM4iRhACFliU3bfLnLDvj3wwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMF\nAKIDAgFAMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MzVa\nFw0zMjA2MDcxODQ2MzVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgMFAKIDAgFAA4ICDwAwggIKAoICAQCrjxW/KXQdtwOPKxjDFDxJaLvF\nJz8EIG6EZZ1JG+SVo8FJlYjazbJWmyCEtmoKCb4pgeeLSltty+pgKHFqZug19eKk\njb/fobN32iF3F3mKJ4/r9+VR5DSiXVMUGSI8i9s72OJu9iCGRsHftufDDVe+jGix\nBmacQMqYtmysRqo7tcAUPY8W4hrw5UhykjvJRNi9//nAMMm2BQdWyQj7JN4qnuhL\n1qtBZHJbNpo9U7DGHiZ5vE6rsJv68f1gM3RiVJsc71vm6gEDN5Rz3kXd1oMzsXwH\n8915SSx1hdmIwcikG5pZU4l9vBB+jTuev5Nm9u+WsMVYk6SE6fsTV3zKKQS67WKZ\nXvRkJmbkJf2xZgvUfPHuShQn0k810EFwimoA7kJtrzVE40PECHQwoq2kAs5M+6VY\nW2J1s1FQ49GaRH78WARSkV7SSpK+H1/L1oMbavtAoei81oLVrjPdCV4SoixSBzoR\n+64aQuSsBJD5vVjL1o37oizsc00mas+mR98TswAHtU4nVSxgZAPp9UuO64YdJ8e8\nbftwsoBKI+DTS+4xjQJhvYxI0Jya42PmP7mlwf7g8zTde1unI6TkaUnlvXdb3+2v\nEhhIQCKSN6HdXHQba9Q6/D1PhIaXBmp8ejziSXOoLfSKJ6cMsDOjIxyuM98admN6\nxjZJljVHAqZQynA2KQIDAQABo2MwYTAdBgNVHQ4EFgQUoa/88nSjWTf9DrvK0Imo\nkARXMYwwHwYDVR0jBBgwFoAUoa/88nSjWTf9DrvK0ImokARXMYwwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMFAKIDAgFAA4ICAQAH\nSCSccH59/JvIMh92cvudtZ4tFzk0+xHWtDqsWxAyYWV009Eg3T6ps/bVbWkiLxCW\ncuExWjQ6yLKwJxegSvTRzwJ4H5xkP837UYIWNRoR3rgPrysm1im3Hjo/3WRCfOJp\nPtgkiPbDn2TzsJQcBpfc7RIdx2bqX41Uz9/nfeQn60MUVJUbvCtCBIV30UfR+z3k\n+w4G5doB4nq6jvQHI364L0gSQcdVdvqgjGyarNTdMHpWFYoN9gPBMoVqSNs2U75d\nLrEQkOhjkE/Akw6q+biFmRWymCHjAU9l7qGEvVxLjFGc+DumCJ6gTunMz8GiXgbd\n9oiqTyanY8VPzr98MZpo+Ga4OiwiIAXAJExN2vCZVco2Tg5AYESpWOqoHlZANdlQ\n4bI25LcZUKuXe+NGRgFY0/8iSvy9Cs44uprUcjAMITODqYj8fCjF2P6qqKY2keGW\nmYBtNJqyYGBg6h+90o88XkgemeGX5vhpRLWyBaYpxanFDkXjmGN1QqjAE/x95Q/u\ny9McE9m1mxUQPJ3vnZRB6cCQBI95ZkTiJPEO8/eSD+0VWVJwLS2UrtWzCbJ+JPKF\nYxtj/MRT8epTRPMpNZwUEih7MEby+05kziKmYF13OOu+K3jjM0rb7sVoFBSzpISC\nr9Fa3LCdekoRZAnjQHXUWko7zo6BLLnCgld97Yem1A==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUA9/dd4gqhU9+6ncE2uFrS3s5xg8wQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIF\nAKIDAgEwMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2Mjla\nFw0zMjA2MDcxODQ2MjlaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgIFAKIDAgEwA4ICDwAwggIKAoICAQCpWg62bB2Dn3W9PtLtkJivh8ng\n31ekgz0FYzelDag4gQkmJFkiWBiIbVTj3aJUt+1n5PrxkamzANq+xKxhP49/IbHF\nVptmHuGORtvGi5qa51i3ZRYeUPekqKIGY0z6t3CGmJxYt1mMsvY6L67/3AATGrsK\nUbf+FFls+3FqbaWXL/oRuuBk6S2qH8NCfSMpaoQN9v0wipL2cl9XZrL1W/DzwQXT\nKIin/DdWhCFDRWwI6We3Pu52k/AH5VFHrJMLmm5dVnMvQQDxf/08ULQAbISPkOMm\nIk3Wtn8xRAbnsw4BQw3RcaxYZHSikm5JA4AJcPMb8J/cfn5plXLoH0nJUAJfV+y5\nzVm6kshhDhfkOkJ0822B54yFfI1lkyFw9mmHt0cNkSHODbMmPbq78DZILA9RWubO\n3m7j8T3OmrilcH6S6BId1G/9mAzjhVSP9P/d/QJhADgWKjcQZQPHadaMbTFHpCFb\nklIOwqraYhxQt3E8yWjkgEjhfkAGwvp/bO8XMcu4XL6Z0uHtKiBFncASrgsR7/yN\nTpO0A6Grr9DTGFcwvvgvRmMPVntiCP+dyVv1EzlsYG/rkI79UJOg/UqyB2voshsI\nmFBuvvWcJYws87qZ6ZhEKuS9yjyTObOcXi0oYvAxDfv10mSjat3Uohm7Bt9VI1Xr\nnUBx0EhMKkhtUDaDzQIDAQABo2MwYTAdBgNVHQ4EFgQU1onD7yR1uK85o0RFeVCE\nQM11S58wHwYDVR0jBBgwFoAU1onD7yR1uK85o0RFeVCEQM11S58wDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIFAKIDAgEwA4ICAQBd\nN+WgIQV4l+U/qLoWZYoTXmxg6rzTl2zr4s2goc6CVYXXKoDkap8y4zZ9AdH8pbZn\npMZrJSmNdfuNUFjnJAyKyOJWyx1oX2NCg8voIAdJxhPJNn4bRhDQ8gFv7OEhshEm\nV0O0xXc08473fzLJEq8hYPtWuPEtS65umJh4A0dENYsm50rnIut9bacmBXJjGgwe\n3sz5oCr9YVCNDG7JDfaMuwWWZKhKZBbY0DsacxSV7AYz/DoYdZ9qLCNNuMmLuV6E\nlrHo5imbQdcsBt11Fxq1AFz3Bfs9r6xBsnn7vGT6xqpBJIivo3BahsOI8Bunbze8\nN4rJyxbsJE3MImyBaYiwkh+oV5SwMzXQe2DUj4FWR7DfZNuwS9qXpaVQHRR74qfr\nw2RSj6nbxlIt/X193d8rqJDpsa/eaHiv2ihhvwnhI/c4TjUvDIefMmcNhqiH7A2G\nFwlsaCV6ngT1IyY8PT+Fb97f5Bzvwwfr4LfWsLOiY8znFcJ28YsrouJdca4Zaa7Q\nXwepSPbZ7rDvlVETM7Ut5tymDR3+7of47qIPLuCGxo21FELseJ+hYhSRXSgvMzDG\nsUxc9Tb1++E/Qf3bFfG5S2NSKkUuWtAveblQPfqDcyBhXDaC8qwuknb5gs1jNOku\n4NWbaM874WvCgmv8TLcqpR0n76bTkfppMRcD5MEFug==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF\nAKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa\nFw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU\nbZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6\nTFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP\nDla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq\nElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99\nNe5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid\nVTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0\nsa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD\nblCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm\nyusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi\nsIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX\nEFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy\nTZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB\nWnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4\nBpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM\npLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9\naT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS\na7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F\nqYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w\nfvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9\nqQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs\nq6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg\nzR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y\nMAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIF3zCCA8egAwIBAgIUfPyUDhze4auMF066jChlB9aD2yIwDQYJKoZIhvcNAQEL\nBQAwdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hl\ncmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVT\nVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTI0MDczMTE5MDUwMVoXDTM0\nMDcyOTE5MDUwMVowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQH\nDAlTb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQL\nDBBGT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIICIjANBgkqhkiG\n9w0BAQEFAAOCAg8AMIICCgKCAgEAkBSlOCwlWBgbqLxFu99ERwU23D/V7qBs7GsA\nZPaAvwCKf7FgVTpkzz6xsgArQU6MVo8n1tXUWWThB81xTXwqbWINP0pl5RnZKFxH\nTmloE2VEMrEK3q4W6gqMjyiG+hPkwUK450WdJGkUkYi2rp6YF9YWJHv7YqYodz+u\nmkIRcsczwRPDaJ7QA6pu3V4YlwrFXZu7jMHHMju02emNoiI8n7QZBJXpRr4C87jT\nAd+aNJQZ1DJ/S/QfiYpaXQ2xNH/Wq7zNXXIMs/LU0kUCggFIj+k6tmaYIAYKJR6o\ndmV3anBTF8iSuAqcUXvM4IYMXSqMgzot3MYPYPdC+rj+trQ9bCPOkMAp5ySx8pYr\nUpo79FOJvG8P9JzuFRsHBobYjtQqJnn6OczM69HVXCQn4H4tBpotASjT2gc6sHYv\na7YreKCbtFLpJhslNysIzVOxlnDbsugbq1gK8mAwG48ttX15ZUdX10MDTpna1FWu\nJnqa6K9NUfrvoW97ff9itca5NDRmm/K5AVA801NHFX1ApVty9lilt+DFDtaJd7zy\n9w0+8U1sZ4+sc8moFRPqvEZZ3gdFtDtVjShcwdbqHZdSNU2lNbVCiycjLs/5EMRO\nWfAxNZaKUreKGfOZkvQNqBhuebF3AfgmP6iP1qtO8aSilC1/43DjVRx3SZ1eecO6\nn0VGjgcCAwEAAaNjMGEwHQYDVR0OBBYEFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMB8G\nA1UdIwQYMBaAFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMA8GA1UdEwEB/wQFMAMBAf8w\nDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQCLexj0luEpQh/LEB14\nARG/yQ8iqW2FMonQsobrDQSI4BhrQ4ak5I892MQX9xIoUpRAVp8GkJ/eXM6ChmXa\nwMJSkfrPGIvES4TY2CtmXDNo0UmHD1GDfHKQ06FJtRJWpn9upT/9qTclTNtvwxQ8\nbKl/y7lrFsn+fQsKL2i5uoQ9nGpXG7WPirJEt9jcld2yylWSStTS4MXJIZSlALIA\nmBTkbzEpzBOLHRRezdfoV4hyL/tWyiXa799436kO48KtwEzvYzC5cZ4bqvM5BXQf\n6aiIYZT7VypFwJQtpTgnfrsjr2Y8q/+N7FoMpLfFO4eeqtwWPiP/47/lb9np/WQq\niO/yyIwYVwiqVG0AyzA5Z4pdke1t93y3UuhXgxevJ7GqGXuLCM0iMqFrAkPlLJzI\n84THLJzFy+wEKH+/L1Zi94cHNj3WvablAMG5v/Kfr6k+KueNQzrY4jZrQPUEdxjv\nxk/1hyZg+khAPVKRxhWeIr6/KIuQYu6kJeTqmXKafx5oHAS6OqcK7G1KbEa1bWMV\nK0+GGwenJOzSTKWKtLO/6goBItGnhyQJCjwiBKOvcW5yfEVjLT+fJ7dkvlSzFMaM\nOZIbev39n3rQTWb4ORq1HIX2JwNsEQX+gBv6aGjMT2a88QFS0TsAA5LtFl8xeVgt\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-----\nMIIGezCCBC+gAwIBAgIUIYAhaM4iRhACFliU3bfLnLDvj3wwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMF\nAKIDAgFAMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MzVa\nFw0zMjA2MDcxODQ2MzVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgMFAKIDAgFAA4ICDwAwggIKAoICAQCrjxW/KXQdtwOPKxjDFDxJaLvF\nJz8EIG6EZZ1JG+SVo8FJlYjazbJWmyCEtmoKCb4pgeeLSltty+pgKHFqZug19eKk\njb/fobN32iF3F3mKJ4/r9+VR5DSiXVMUGSI8i9s72OJu9iCGRsHftufDDVe+jGix\nBmacQMqYtmysRqo7tcAUPY8W4hrw5UhykjvJRNi9//nAMMm2BQdWyQj7JN4qnuhL\n1qtBZHJbNpo9U7DGHiZ5vE6rsJv68f1gM3RiVJsc71vm6gEDN5Rz3kXd1oMzsXwH\n8915SSx1hdmIwcikG5pZU4l9vBB+jTuev5Nm9u+WsMVYk6SE6fsTV3zKKQS67WKZ\nXvRkJmbkJf2xZgvUfPHuShQn0k810EFwimoA7kJtrzVE40PECHQwoq2kAs5M+6VY\nW2J1s1FQ49GaRH78WARSkV7SSpK+H1/L1oMbavtAoei81oLVrjPdCV4SoixSBzoR\n+64aQuSsBJD5vVjL1o37oizsc00mas+mR98TswAHtU4nVSxgZAPp9UuO64YdJ8e8\nbftwsoBKI+DTS+4xjQJhvYxI0Jya42PmP7mlwf7g8zTde1unI6TkaUnlvXdb3+2v\nEhhIQCKSN6HdXHQba9Q6/D1PhIaXBmp8ejziSXOoLfSKJ6cMsDOjIxyuM98admN6\nxjZJljVHAqZQynA2KQIDAQABo2MwYTAdBgNVHQ4EFgQUoa/88nSjWTf9DrvK0Imo\nkARXMYwwHwYDVR0jBBgwFoAUoa/88nSjWTf9DrvK0ImokARXMYwwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMFAKIDAgFAA4ICAQAH\nSCSccH59/JvIMh92cvudtZ4tFzk0+xHWtDqsWxAyYWV009Eg3T6ps/bVbWkiLxCW\ncuExWjQ6yLKwJxegSvTRzwJ4H5xkP837UYIWNRoR3rgPrysm1im3Hjo/3WRCfOJp\nPtgkiPbDn2TzsJQcBpfc7RIdx2bqX41Uz9/nfeQn60MUVJUbvCtCBIV30UfR+z3k\n+w4G5doB4nq6jvQHI364L0gSQcdVdvqgjGyarNTdMHpWFYoN9gPBMoVqSNs2U75d\nLrEQkOhjkE/Akw6q+biFmRWymCHjAU9l7qGEvVxLjFGc+DumCJ6gTunMz8GiXgbd\n9oiqTyanY8VPzr98MZpo+Ga4OiwiIAXAJExN2vCZVco2Tg5AYESpWOqoHlZANdlQ\n4bI25LcZUKuXe+NGRgFY0/8iSvy9Cs44uprUcjAMITODqYj8fCjF2P6qqKY2keGW\nmYBtNJqyYGBg6h+90o88XkgemeGX5vhpRLWyBaYpxanFDkXjmGN1QqjAE/x95Q/u\ny9McE9m1mxUQPJ3vnZRB6cCQBI95ZkTiJPEO8/eSD+0VWVJwLS2UrtWzCbJ+JPKF\nYxtj/MRT8epTRPMpNZwUEih7MEby+05kziKmYF13OOu+K3jjM0rb7sVoFBSzpISC\nr9Fa3LCdekoRZAnjQHXUWko7zo6BLLnCgld97Yem1A==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUA9/dd4gqhU9+6ncE2uFrS3s5xg8wQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIF\nAKIDAgEwMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2Mjla\nFw0zMjA2MDcxODQ2MjlaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgIFAKIDAgEwA4ICDwAwggIKAoICAQCpWg62bB2Dn3W9PtLtkJivh8ng\n31ekgz0FYzelDag4gQkmJFkiWBiIbVTj3aJUt+1n5PrxkamzANq+xKxhP49/IbHF\nVptmHuGORtvGi5qa51i3ZRYeUPekqKIGY0z6t3CGmJxYt1mMsvY6L67/3AATGrsK\nUbf+FFls+3FqbaWXL/oRuuBk6S2qH8NCfSMpaoQN9v0wipL2cl9XZrL1W/DzwQXT\nKIin/DdWhCFDRWwI6We3Pu52k/AH5VFHrJMLmm5dVnMvQQDxf/08ULQAbISPkOMm\nIk3Wtn8xRAbnsw4BQw3RcaxYZHSikm5JA4AJcPMb8J/cfn5plXLoH0nJUAJfV+y5\nzVm6kshhDhfkOkJ0822B54yFfI1lkyFw9mmHt0cNkSHODbMmPbq78DZILA9RWubO\n3m7j8T3OmrilcH6S6BId1G/9mAzjhVSP9P/d/QJhADgWKjcQZQPHadaMbTFHpCFb\nklIOwqraYhxQt3E8yWjkgEjhfkAGwvp/bO8XMcu4XL6Z0uHtKiBFncASrgsR7/yN\nTpO0A6Grr9DTGFcwvvgvRmMPVntiCP+dyVv1EzlsYG/rkI79UJOg/UqyB2voshsI\nmFBuvvWcJYws87qZ6ZhEKuS9yjyTObOcXi0oYvAxDfv10mSjat3Uohm7Bt9VI1Xr\nnUBx0EhMKkhtUDaDzQIDAQABo2MwYTAdBgNVHQ4EFgQU1onD7yR1uK85o0RFeVCE\nQM11S58wHwYDVR0jBBgwFoAU1onD7yR1uK85o0RFeVCEQM11S58wDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIFAKIDAgEwA4ICAQBd\nN+WgIQV4l+U/qLoWZYoTXmxg6rzTl2zr4s2goc6CVYXXKoDkap8y4zZ9AdH8pbZn\npMZrJSmNdfuNUFjnJAyKyOJWyx1oX2NCg8voIAdJxhPJNn4bRhDQ8gFv7OEhshEm\nV0O0xXc08473fzLJEq8hYPtWuPEtS65umJh4A0dENYsm50rnIut9bacmBXJjGgwe\n3sz5oCr9YVCNDG7JDfaMuwWWZKhKZBbY0DsacxSV7AYz/DoYdZ9qLCNNuMmLuV6E\nlrHo5imbQdcsBt11Fxq1AFz3Bfs9r6xBsnn7vGT6xqpBJIivo3BahsOI8Bunbze8\nN4rJyxbsJE3MImyBaYiwkh+oV5SwMzXQe2DUj4FWR7DfZNuwS9qXpaVQHRR74qfr\nw2RSj6nbxlIt/X193d8rqJDpsa/eaHiv2ihhvwnhI/c4TjUvDIefMmcNhqiH7A2G\nFwlsaCV6ngT1IyY8PT+Fb97f5Bzvwwfr4LfWsLOiY8znFcJ28YsrouJdca4Zaa7Q\nXwepSPbZ7rDvlVETM7Ut5tymDR3+7of47qIPLuCGxo21FELseJ+hYhSRXSgvMzDG\nsUxc9Tb1++E/Qf3bFfG5S2NSKkUuWtAveblQPfqDcyBhXDaC8qwuknb5gs1jNOku\n4NWbaM874WvCgmv8TLcqpR0n76bTkfppMRcD5MEFug==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF\nAKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa\nFw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU\nbZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6\nTFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP\nDla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq\nElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99\nNe5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid\nVTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0\nsa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD\nblCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm\nyusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi\nsIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX\nEFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy\nTZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB\nWnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4\nBpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM\npLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9\naT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS\na7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F\nqYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w\nfvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9\nqQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs\nq6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg\nzR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y\nMAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIF3zCCA8egAwIBAgIUfPyUDhze4auMF066jChlB9aD2yIwDQYJKoZIhvcNAQEL\nBQAwdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hl\ncmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVT\nVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTI0MDczMTE5MDUwMVoXDTM0\nMDcyOTE5MDUwMVowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQH\nDAlTb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQL\nDBBGT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIICIjANBgkqhkiG\n9w0BAQEFAAOCAg8AMIICCgKCAgEAkBSlOCwlWBgbqLxFu99ERwU23D/V7qBs7GsA\nZPaAvwCKf7FgVTpkzz6xsgArQU6MVo8n1tXUWWThB81xTXwqbWINP0pl5RnZKFxH\nTmloE2VEMrEK3q4W6gqMjyiG+hPkwUK450WdJGkUkYi2rp6YF9YWJHv7YqYodz+u\nmkIRcsczwRPDaJ7QA6pu3V4YlwrFXZu7jMHHMju02emNoiI8n7QZBJXpRr4C87jT\nAd+aNJQZ1DJ/S/QfiYpaXQ2xNH/Wq7zNXXIMs/LU0kUCggFIj+k6tmaYIAYKJR6o\ndmV3anBTF8iSuAqcUXvM4IYMXSqMgzot3MYPYPdC+rj+trQ9bCPOkMAp5ySx8pYr\nUpo79FOJvG8P9JzuFRsHBobYjtQqJnn6OczM69HVXCQn4H4tBpotASjT2gc6sHYv\na7YreKCbtFLpJhslNysIzVOxlnDbsugbq1gK8mAwG48ttX15ZUdX10MDTpna1FWu\nJnqa6K9NUfrvoW97ff9itca5NDRmm/K5AVA801NHFX1ApVty9lilt+DFDtaJd7zy\n9w0+8U1sZ4+sc8moFRPqvEZZ3gdFtDtVjShcwdbqHZdSNU2lNbVCiycjLs/5EMRO\nWfAxNZaKUreKGfOZkvQNqBhuebF3AfgmP6iP1qtO8aSilC1/43DjVRx3SZ1eecO6\nn0VGjgcCAwEAAaNjMGEwHQYDVR0OBBYEFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMB8G\nA1UdIwQYMBaAFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMA8GA1UdEwEB/wQFMAMBAf8w\nDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQCLexj0luEpQh/LEB14\nARG/yQ8iqW2FMonQsobrDQSI4BhrQ4ak5I892MQX9xIoUpRAVp8GkJ/eXM6ChmXa\nwMJSkfrPGIvES4TY2CtmXDNo0UmHD1GDfHKQ06FJtRJWpn9upT/9qTclTNtvwxQ8\nbKl/y7lrFsn+fQsKL2i5uoQ9nGpXG7WPirJEt9jcld2yylWSStTS4MXJIZSlALIA\nmBTkbzEpzBOLHRRezdfoV4hyL/tWyiXa799436kO48KtwEzvYzC5cZ4bqvM5BXQf\n6aiIYZT7VypFwJQtpTgnfrsjr2Y8q/+N7FoMpLfFO4eeqtwWPiP/47/lb9np/WQq\niO/yyIwYVwiqVG0AyzA5Z4pdke1t93y3UuhXgxevJ7GqGXuLCM0iMqFrAkPlLJzI\n84THLJzFy+wEKH+/L1Zi94cHNj3WvablAMG5v/Kfr6k+KueNQzrY4jZrQPUEdxjv\nxk/1hyZg+khAPVKRxhWeIr6/KIuQYu6kJeTqmXKafx5oHAS6OqcK7G1KbEa1bWMV\nK0+GGwenJOzSTKWKtLO/6goBItGnhyQJCjwiBKOvcW5yfEVjLT+fJ7dkvlSzFMaM\nOZIbev39n3rQTWb4ORq1HIX2JwNsEQX+gBv6aGjMT2a88QFS0TsAA5LtFl8xeVgt\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-----\nMIIGezCCBC+gAwIBAgIUIYAhaM4iRhACFliU3bfLnLDvj3wwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMF\nAKIDAgFAMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MzVa\nFw0zMjA2MDcxODQ2MzVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgMFAKIDAgFAA4ICDwAwggIKAoICAQCrjxW/KXQdtwOPKxjDFDxJaLvF\nJz8EIG6EZZ1JG+SVo8FJlYjazbJWmyCEtmoKCb4pgeeLSltty+pgKHFqZug19eKk\njb/fobN32iF3F3mKJ4/r9+VR5DSiXVMUGSI8i9s72OJu9iCGRsHftufDDVe+jGix\nBmacQMqYtmysRqo7tcAUPY8W4hrw5UhykjvJRNi9//nAMMm2BQdWyQj7JN4qnuhL\n1qtBZHJbNpo9U7DGHiZ5vE6rsJv68f1gM3RiVJsc71vm6gEDN5Rz3kXd1oMzsXwH\n8915SSx1hdmIwcikG5pZU4l9vBB+jTuev5Nm9u+WsMVYk6SE6fsTV3zKKQS67WKZ\nXvRkJmbkJf2xZgvUfPHuShQn0k810EFwimoA7kJtrzVE40PECHQwoq2kAs5M+6VY\nW2J1s1FQ49GaRH78WARSkV7SSpK+H1/L1oMbavtAoei81oLVrjPdCV4SoixSBzoR\n+64aQuSsBJD5vVjL1o37oizsc00mas+mR98TswAHtU4nVSxgZAPp9UuO64YdJ8e8\nbftwsoBKI+DTS+4xjQJhvYxI0Jya42PmP7mlwf7g8zTde1unI6TkaUnlvXdb3+2v\nEhhIQCKSN6HdXHQba9Q6/D1PhIaXBmp8ejziSXOoLfSKJ6cMsDOjIxyuM98admN6\nxjZJljVHAqZQynA2KQIDAQABo2MwYTAdBgNVHQ4EFgQUoa/88nSjWTf9DrvK0Imo\nkARXMYwwHwYDVR0jBBgwFoAUoa/88nSjWTf9DrvK0ImokARXMYwwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgMFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgMFAKIDAgFAA4ICAQAH\nSCSccH59/JvIMh92cvudtZ4tFzk0+xHWtDqsWxAyYWV009Eg3T6ps/bVbWkiLxCW\ncuExWjQ6yLKwJxegSvTRzwJ4H5xkP837UYIWNRoR3rgPrysm1im3Hjo/3WRCfOJp\nPtgkiPbDn2TzsJQcBpfc7RIdx2bqX41Uz9/nfeQn60MUVJUbvCtCBIV30UfR+z3k\n+w4G5doB4nq6jvQHI364L0gSQcdVdvqgjGyarNTdMHpWFYoN9gPBMoVqSNs2U75d\nLrEQkOhjkE/Akw6q+biFmRWymCHjAU9l7qGEvVxLjFGc+DumCJ6gTunMz8GiXgbd\n9oiqTyanY8VPzr98MZpo+Ga4OiwiIAXAJExN2vCZVco2Tg5AYESpWOqoHlZANdlQ\n4bI25LcZUKuXe+NGRgFY0/8iSvy9Cs44uprUcjAMITODqYj8fCjF2P6qqKY2keGW\nmYBtNJqyYGBg6h+90o88XkgemeGX5vhpRLWyBaYpxanFDkXjmGN1QqjAE/x95Q/u\ny9McE9m1mxUQPJ3vnZRB6cCQBI95ZkTiJPEO8/eSD+0VWVJwLS2UrtWzCbJ+JPKF\nYxtj/MRT8epTRPMpNZwUEih7MEby+05kziKmYF13OOu+K3jjM0rb7sVoFBSzpISC\nr9Fa3LCdekoRZAnjQHXUWko7zo6BLLnCgld97Yem1A==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUA9/dd4gqhU9+6ncE2uFrS3s5xg8wQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIF\nAKIDAgEwMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2Mjla\nFw0zMjA2MDcxODQ2MjlaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgIFAKIDAgEwA4ICDwAwggIKAoICAQCpWg62bB2Dn3W9PtLtkJivh8ng\n31ekgz0FYzelDag4gQkmJFkiWBiIbVTj3aJUt+1n5PrxkamzANq+xKxhP49/IbHF\nVptmHuGORtvGi5qa51i3ZRYeUPekqKIGY0z6t3CGmJxYt1mMsvY6L67/3AATGrsK\nUbf+FFls+3FqbaWXL/oRuuBk6S2qH8NCfSMpaoQN9v0wipL2cl9XZrL1W/DzwQXT\nKIin/DdWhCFDRWwI6We3Pu52k/AH5VFHrJMLmm5dVnMvQQDxf/08ULQAbISPkOMm\nIk3Wtn8xRAbnsw4BQw3RcaxYZHSikm5JA4AJcPMb8J/cfn5plXLoH0nJUAJfV+y5\nzVm6kshhDhfkOkJ0822B54yFfI1lkyFw9mmHt0cNkSHODbMmPbq78DZILA9RWubO\n3m7j8T3OmrilcH6S6BId1G/9mAzjhVSP9P/d/QJhADgWKjcQZQPHadaMbTFHpCFb\nklIOwqraYhxQt3E8yWjkgEjhfkAGwvp/bO8XMcu4XL6Z0uHtKiBFncASrgsR7/yN\nTpO0A6Grr9DTGFcwvvgvRmMPVntiCP+dyVv1EzlsYG/rkI79UJOg/UqyB2voshsI\nmFBuvvWcJYws87qZ6ZhEKuS9yjyTObOcXi0oYvAxDfv10mSjat3Uohm7Bt9VI1Xr\nnUBx0EhMKkhtUDaDzQIDAQABo2MwYTAdBgNVHQ4EFgQU1onD7yR1uK85o0RFeVCE\nQM11S58wHwYDVR0jBBgwFoAU1onD7yR1uK85o0RFeVCEQM11S58wDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgIFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgIFAKIDAgEwA4ICAQBd\nN+WgIQV4l+U/qLoWZYoTXmxg6rzTl2zr4s2goc6CVYXXKoDkap8y4zZ9AdH8pbZn\npMZrJSmNdfuNUFjnJAyKyOJWyx1oX2NCg8voIAdJxhPJNn4bRhDQ8gFv7OEhshEm\nV0O0xXc08473fzLJEq8hYPtWuPEtS65umJh4A0dENYsm50rnIut9bacmBXJjGgwe\n3sz5oCr9YVCNDG7JDfaMuwWWZKhKZBbY0DsacxSV7AYz/DoYdZ9qLCNNuMmLuV6E\nlrHo5imbQdcsBt11Fxq1AFz3Bfs9r6xBsnn7vGT6xqpBJIivo3BahsOI8Bunbze8\nN4rJyxbsJE3MImyBaYiwkh+oV5SwMzXQe2DUj4FWR7DfZNuwS9qXpaVQHRR74qfr\nw2RSj6nbxlIt/X193d8rqJDpsa/eaHiv2ihhvwnhI/c4TjUvDIefMmcNhqiH7A2G\nFwlsaCV6ngT1IyY8PT+Fb97f5Bzvwwfr4LfWsLOiY8znFcJ28YsrouJdca4Zaa7Q\nXwepSPbZ7rDvlVETM7Ut5tymDR3+7of47qIPLuCGxo21FELseJ+hYhSRXSgvMzDG\nsUxc9Tb1++E/Qf3bFfG5S2NSKkUuWtAveblQPfqDcyBhXDaC8qwuknb5gs1jNOku\n4NWbaM874WvCgmv8TLcqpR0n76bTkfppMRcD5MEFug==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK\nMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF\nAKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t\nZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S\nIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa\nFw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG\nA1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG\nA1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ\nKoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg\nhkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU\nbZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6\nTFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP\nDla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq\nElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99\nNe5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid\nVTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0\nsa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD\nblCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm\nyusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi\nsIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX\nEFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy\nTZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB\nZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB\nWnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4\nBpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM\npLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9\naT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS\na7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F\nqYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w\nfvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9\nqQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs\nq6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg\nzR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y\nMAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIF3zCCA8egAwIBAgIUfPyUDhze4auMF066jChlB9aD2yIwDQYJKoZIhvcNAQEL\nBQAwdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hl\ncmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVT\nVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTI0MDczMTE5MDUwMVoXDTM0\nMDcyOTE5MDUwMVowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQH\nDAlTb21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQL\nDBBGT1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMIICIjANBgkqhkiG\n9w0BAQEFAAOCAg8AMIICCgKCAgEAkBSlOCwlWBgbqLxFu99ERwU23D/V7qBs7GsA\nZPaAvwCKf7FgVTpkzz6xsgArQU6MVo8n1tXUWWThB81xTXwqbWINP0pl5RnZKFxH\nTmloE2VEMrEK3q4W6gqMjyiG+hPkwUK450WdJGkUkYi2rp6YF9YWJHv7YqYodz+u\nmkIRcsczwRPDaJ7QA6pu3V4YlwrFXZu7jMHHMju02emNoiI8n7QZBJXpRr4C87jT\nAd+aNJQZ1DJ/S/QfiYpaXQ2xNH/Wq7zNXXIMs/LU0kUCggFIj+k6tmaYIAYKJR6o\ndmV3anBTF8iSuAqcUXvM4IYMXSqMgzot3MYPYPdC+rj+trQ9bCPOkMAp5ySx8pYr\nUpo79FOJvG8P9JzuFRsHBobYjtQqJnn6OczM69HVXCQn4H4tBpotASjT2gc6sHYv\na7YreKCbtFLpJhslNysIzVOxlnDbsugbq1gK8mAwG48ttX15ZUdX10MDTpna1FWu\nJnqa6K9NUfrvoW97ff9itca5NDRmm/K5AVA801NHFX1ApVty9lilt+DFDtaJd7zy\n9w0+8U1sZ4+sc8moFRPqvEZZ3gdFtDtVjShcwdbqHZdSNU2lNbVCiycjLs/5EMRO\nWfAxNZaKUreKGfOZkvQNqBhuebF3AfgmP6iP1qtO8aSilC1/43DjVRx3SZ1eecO6\nn0VGjgcCAwEAAaNjMGEwHQYDVR0OBBYEFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMB8G\nA1UdIwQYMBaAFBTOcmBU5xp7Jfn4Nzyw+kIc73yHMA8GA1UdEwEB/wQFMAMBAf8w\nDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQCLexj0luEpQh/LEB14\nARG/yQ8iqW2FMonQsobrDQSI4BhrQ4ak5I892MQX9xIoUpRAVp8GkJ/eXM6ChmXa\nwMJSkfrPGIvES4TY2CtmXDNo0UmHD1GDfHKQ06FJtRJWpn9upT/9qTclTNtvwxQ8\nbKl/y7lrFsn+fQsKL2i5uoQ9nGpXG7WPirJEt9jcld2yylWSStTS4MXJIZSlALIA\nmBTkbzEpzBOLHRRezdfoV4hyL/tWyiXa799436kO48KtwEzvYzC5cZ4bqvM5BXQf\n6aiIYZT7VypFwJQtpTgnfrsjr2Y8q/+N7FoMpLfFO4eeqtwWPiP/47/lb9np/WQq\niO/yyIwYVwiqVG0AyzA5Z4pdke1t93y3UuhXgxevJ7GqGXuLCM0iMqFrAkPlLJzI\n84THLJzFy+wEKH+/L1Zi94cHNj3WvablAMG5v/Kfr6k+KueNQzrY4jZrQPUEdxjv\nxk/1hyZg+khAPVKRxhWeIr6/KIuQYu6kJeTqmXKafx5oHAS6OqcK7G1KbEa1bWMV\nK0+GGwenJOzSTKWKtLO/6goBItGnhyQJCjwiBKOvcW5yfEVjLT+fJ7dkvlSzFMaM\nOZIbev39n3rQTWb4ORq1HIX2JwNsEQX+gBv6aGjMT2a88QFS0TsAA5LtFl8xeVgt\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 21/46] 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 zcmbssc|28V`&_q$tXW13I+Q5xb+4V=&?1@=l~feRz2|bd=WgfV@=c*cVp0t?HAx#S zG+DBwjiNiYX_^*GO$pOfmWV7ZeD6VJ`t{8}-}AfYefH;h&+|U-d)^B{kRF$a67g`M z4Ft(SBnFQyCbDJ%z6%oyBOnOkV-X=7fF}X<`4~fiAn^MP|6qXl3*P0k<<}@V;P8r& zAYnl1PlS!-q)syYBHF)bzOZy5P2q_Lq9kJ+9zt>$=gaqU0b&U9sT@PJ5Ds55Mu8!M z$3Z{RBwi1Ap*ter@KMr+VvF$vTtH|>U}HfiPhl=+T&Ks8prj26q+Tgp!iOj50h|cp z1Nt0J;2(=k+8jnA*kCcnU~JG5QqJ4hA^4K)q2u7J{e{Imk^<(wIwuriFCetTihCFf>HEs)-O> z1TZ-Z@=?PH0tfgb03K(5WB}F#eDoxqB!LZEI7w!TMW9l^!-QlEld6yLCz(M(;T!@1 zf=w7YhDn{_*bEvOTNeL8N>G>yNQo7bVGmm|QKkde0Vv$*z`$aa*Rtg_YuI)D z5@5+e*+8*GD2n2vTmrUaFsx{n3`;8@3?T@SqlHCeWF(#35<)&ApbI6z7GxHB2!Zi| zjZj4JgaX_VW+Ooeo5h4d+q0}-4jRN4vcp(RM}{K7|IGk zr7#{I1SmULghv5aFa}6CEGC@+oWKBMJSoZvAR{t?M}Pzo1fwjbEzAy)2r(o8xH8FZ z;`4bppzNjg95w+6wishEz!VTpv`~PuEScs^Cd@@}B0z+P@kFG>cmS~|K*;6dD8Zsx z!bFG!c#whkLSYyZ0+@i$GMvw2gSmVZFkpdXEC-v1Ap|)Oo`66ld;}N)c@RHbf`34)D87LgQ>yfU?60 zmNkP+BSFZLNKiZk_)6FTpQa1^By1L2!iBLQP!@7lfMHo$(HXFqoC}LVw*{n#3_FGq zDa+OY2yg-wv8-X92-Fy?Cs+cI3K4_lBNv0*1C)p8uK3YACH&XPj%s zo#OVWuQ45jd;XfCqZMuulN#kMp!?51z2E_JxJzd!uCjTrV#=yy1GBcBXFHBi67<@f z7&bX)>O}qFtoo#vU2P49{+bsYEHf@LYnYt#{WduFR(;!nd#{)=sYlc8ertaA!fa<} zwS9K|RW0#3g+z_qgrgDIqr{(*x*>nJt3&p0`h)kr3ab?x_{^gzZogNZrm}C7uX$2I zud}c-q*8;DA)7Z%5#{7u>9x#WB&t2+>Aky>vaLUBoyKhwbxE5i=3OK;kGk%m^{spg zT5EVmVK_C$^Jx6>Ju-KFc2<_!+{T%%1v+mtOLD4RO!|nFJ-;?yf34Sj@o>#a4ckzw z*t}@o!l$YQ3ru83w0ieZued1B3Q;mzEI5{RGbhHm%e71|^LyNhZeFDp`b*TzQfkB5 zhNvL}q&yYA?)PgBPxy1_yy8B`3x^bUvl8rv_1P_RWhdfXw{8w}*S|@n4Q%^6cuB{7 zzPqJ@#h|gHirkijCXMV=wa^>kuwBgHnbQVtb`XDt`RU{^uh}gs+IDKz1N&J+`&g#} zJ>Fju%r*&)zFn4bZf492L0t6mb+yVRC(d_2uhZZs# zO>a2v0_nbXY?3G4zZ{!#)UO|#F2IfasC@0%lq2sH;9D*y8b3BEqv)>filc11(sQV>dY<_>!PL)NWa*sw; zlrlI2``;y3u2|ZZohdc0l=U~QEtOTDGEc4-{BX=oAF3L zGZo_B(mM)#W~AR9tPeevxkS@jD9d5Vc8lK@GW5z~l&-t_9X2`BSmi7JR@b5XR$Zsp zo&KyX8a|ti=NBtBtQxkk%AdY6M@^};N!)tkmi7-Rsd3`M;6IJM(vxoNDClWREj+i1 zm{GiDqeN=X72dtmGf;6s&8Fn&hYicsv`mJb(%{Uw%x#Oi3^#V}@}<2tb-dq)ss{cj z^VHVqI2gAUJJ>Qs#o0}-&}2=|hOAihM~_wCJM~OmEI+;G!eTF{k*c01&hv7|_?qDi z`lh@750HK@AHc9wqD3MUK_Q&xCmL-h(?{PI&$N0&x=nkxzc!AJs`=~`kU;K-uEkXzy0-qwnRZ%X&&#vz>p}~}{vE0&2;J?Wa~?t3cJ+L ztf+kI+9VZMQ@2KIpJ;v3oNI3$`HJl9R@(ZZ@(-u+z*tJLkF(K~4|Sopg6^}q#(JVF zMg7!GPvjFq65F>X>in3TYIZ1b%e6DUU1idWH}&x&<$4??<;S;8AInqa{M*ZjIdiL~ zP)f1#@3K29oYKBmjnzD8V=pvWtx}Vw%dI=&3mFWH&3=%lIxIerUXu9uj%+xq56gdN z@F=ji_-47luZY!(u-+=3+7hdF?0czNNhS1fwV}nK3Jg7thf>zsyae@YM0% z4+qasM`<4_@~?*+pI-gkr!MdOPdnCcdoEXzU$jESXIMO3AWQkr$kD{1nRgX54D^pd zQN?q@;q<-F_wQH@+GqpV7wR#qr%AQk-nD7)@u7jG6?7$}J?MXlc!^ z0I_)(xaO+~>2jKEO4)Q6VM6nYhMgF)5T)QRYeV+v0e*u7h 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 22/46] 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 23/46] 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 24/46] 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 25/46] 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 26/46] 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 27/46] 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 28/46] 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 29/46] 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 30/46] 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 31/46] 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 32/46] 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 33/46] 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 34/46] 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 35/46] 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 36/46] 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 37/46] 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. From 3e3cc461e0a148ac62f1e8b799f25f30da3c7950 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 7 Oct 2026 15:13:51 +0200 Subject: [PATCH 38/46] fix: retain signing resources through callback-initiated close Defer physical teardown until admitted native calls drain, preserve callback pins through free, and expose trusted VSI blocked status. Cover reentrant close and stock-native behavior, adapt paired tests to the hardened native contract, and clarify output cleanup and qualification holds. --- .../trusted-vsi-review-verification.md | 208 ++++++ docs/ladder-signing.md | 12 +- docs/native-resources-management.md | 101 ++- docs/trusted-vsi-python-contract.md | 124 ++-- src/c2pa/c2pa.py | 177 +++-- tests/test_trusted_vsi_api.py | 614 +++++++++++++++++- tests/test_trusted_vsi_build.py | 11 + 7 files changed, 1105 insertions(+), 142 deletions(-) create mode 100644 docs/archive/trusted-vsi-review-verification.md diff --git a/docs/archive/trusted-vsi-review-verification.md b/docs/archive/trusted-vsi-review-verification.md new file mode 100644 index 00000000..f9fe5c78 --- /dev/null +++ b/docs/archive/trusted-vsi-review-verification.md @@ -0,0 +1,208 @@ +# Trusted VSI Review Verification + +Archived verification/provenance, not the product contract or authorization to +merge, repin, label or dispatch CI. See the current +[Python contract](../trusted-vsi-python-contract.md) for the deliberate integration +hold. Historical counts below precede the subsequent review follow-ups. + +## Earlier Pairings + +The former product-contract status described qualification against the +consolidated functional native from `castlabs/c2pa-rs` +`feat/trusted-vsi-functional` (`0.92.0-dev`, Rust 1.96.0, mask 63). The current +workflow pin `6b506352800c8225cf5564ce99c726aaa71039f4` is `203dc08d` +(ContentAuth main `69907b5a` merged) plus CI-only fixes: rustls `0.23.45` / +rustls-webpki `0.103.15` for RUSTSEC-2026-0285, test-only lint scopes, a feature +gate on a crate-private helper and rustdoc, with no C ABI/capability change. +The Python source integrates `Builder.sign_ladder` and carries unreleased +identity `0.37.13.dev0`. + +The `203dc08d` pairing was qualified at Python +`5c64f2cc090eeb29506bc766faa69b959e4ed982` by hosted Linux/Windows paired run +`castlabs/c2pa-python` Actions `36793704783`: focused 186, ladder harness, +non-threaded 730, threaded 54, installed-wheel 186. Qualification of the +`6b506352` pairing was recorded on castlabs/c2pa-python#4 by run ID. These +pairings do not qualify the new state-v3/input contract. + +Earlier native `5c186c07` (debug library SHA-256 +`dc79e81a084fc7b25e12423539b137f24d69693da46cb0166cb04538bd5589f9`) at Python +`941c2ad5b57d23f31dbabf9fbef4776878cf630c`: local Linux focused 179, +ladder harness, non-threaded 714, threaded 54, installed-wheel 179 passed; +hosted Linux/Windows paired run `36671268428` passed on both. Local +qualification-only artifacts (never published): +`c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` SHA-256 +`8731d135ce2c1db61b061e1f2c76272a55b9f7e7e2e2ea8769b10b5fd4a8707f`, sdist +`e846e5688d07bcf5959885c0c1728afde4ec89bbb7cf2b80a665956056b8b5ab`. +The earlier `0.37.9.dev0` artifacts paired with native `3569fb86` are historical +only. Immutable dev5 release inputs/artifacts are unchanged. + +## Initial Local Review + +Python worktree: `fix/trusted-vsi-review-python`, base +`12d265db92e8dcbf80b8255278e9a7fc5945f750`, uncommitted review changes. +Native input: `/root/opencode-worktrees/c2pa-rs-trusted-vsi-review-native`, +uncommitted step2 changes atop `d6e7b529581a4dfc0fd5585773d246ee2c191378`. +Only the cdylib was built, reusing the existing +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target` with Rust 1.96.0, +one Cargo job, incremental disabled, dev debug info disabled, and features +`rust_native_crypto,http,add_thumbnails,file_io,unstable_live_video`. +The loaded `debug/libc2pa_c.so` reports `0.92.0-dev`, mask 63, SHA-256 +`149f4b250a5d697e38355a3f3f08ce1abf61c7e2b0418175153f690a456eb357`. +This records a local binary, not a new native pin or published revision. + +The initial frozen-source tests used these selections without an editable install: + +```sh +export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src +export C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so +export C2PA_SOURCE_BUILD_VERSION=0.92.0-dev C2PA_TRUSTED_VSI_ABI_REQUIRED=1 +export C2PA_REQUIRE_SIGN_LADDER=1 C2PA_REQUIRE_FRAGMENTED_FILES=1 +python3 -m pytest -q tests/test_trusted_vsi_api.py tests/test_fragmented_files.py tests/test_sign_ladder.py tests/test_native_ownership.py tests/test_native_ownership_opaque.py tests/test_trusted_vsi_build.py -ra +python3 -m pytest -q -ra --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 +python3 -m pytest -q tests/test_unit_tests_threaded.py -ra -o faulthandler_timeout=120 +``` + +Initial results: focused 228 passed / 11 subtests; non-threaded 750 passed / +128 subtests / one module skip; threaded 54 passed. The skip is the existing +release-smoke module requiring an explicitly qualified installed wheel; no wheel +qualification or immutable dev5 evidence is claimed. An earlier run during edits +had four source-introspection subtest failures from changed source line offsets; +the frozen-source rerun passed them without changing those assertions. +Both `tests/ladder_native.py` candidate and stock harnesses passed. + +Stock compatibility used the official ContentAuth `c2pa-v0.91.0` Linux x86-64 +release library (SDK `0.91.0`, SHA-256 +`dfa68d2a8ee739bb75919dcb5300f2cb289e1b51ecce94b0f339ff287ebe0703`), which lacks +dynamic-assertion registration, ladder signing and live-video callback exports. +With `C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/python-review-stock/lib/libc2pa_c.so`, +`C2PA_SOURCE_BUILD_VERSION=0.91.0`, and required-capability flags unset: +`pytest -q tests/test_trusted_vsi_api.py tests/test_sign_ladder.py tests/test_fragmented_files.py tests/test_native_ownership.py -k 'not paired' -ra` +passed 117 / skipped 19 / deselected 70. Exactly four skips are the scripted +dynamic wrapper cases; required qualification makes that missing export fail. +The same stock library passed 101 lifecycle/object/fork tests plus 49 subtests +(the two ManagedResource unit classes and ManagedResourceForkGuard). +Actual-native Builder callback-close subprocesses run in the stock lane too. +The initially suggested prior packaged library reports `0.80.0` and lacks the +required `c2pa_reader_crjson` export, so it was not stock-0.91 evidence. + +## Native Status Compatibility + +Read-only inspection of +`6b506352800c8225cf5564ce99c726aaa71039f4:c2pa_c_ffi/src/live_video.rs` confirmed +`pub blocked: bool` at line 214, native conversion `blocked: rust.blocked()` at +line 616, and the offset-18 assertion at line 2035. The Python status fix fills +the missing bridge, not a missing native field in the actual workflow pin. +Arbitrary same-version/mask library identity remains a step3 concern, not a +demonstrated missing-`blocked` ABI mismatch against `6b506352`. + +## Review Follow-Up Checks + +The follow-up changed Python preflight/admission cleanup, documentation and +tests only. No native build, installation, full-suite rerun, target modification +or artifact cleanup was performed. The preserved step2 cdylib still has SHA-256 +`149f4b250a5d697e38355a3f3f08ce1abf61c7e2b0418175153f690a456eb357`. +Focused pytest used `PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=src` and +`-p no:cacheprovider`; small generated fixtures used fresh, explicitly owned +`python-review-followups-20261007-v*` scratch directories under the approved temp +root. The native target was never used as scratch or changed. + +With the preserved step2 library and the required-capability flags from the +initial local section: + +```sh +python3 -m pytest -q -p no:cacheprovider --basetemp=/tmp/opencode-v1.18.29/opencode/python-review-followups-20261007-v3 tests/test_trusted_vsi_api.py tests/test_fragmented_files.py tests/test_sign_ladder.py tests/test_native_ownership.py tests/test_native_ownership_opaque.py tests/test_trusted_vsi_build.py -ra +``` + +Result: **241 passed / 11 subtests, no skips**. New coverage includes Context +close inside a context-signing Builder callback, two admitted bookkeeping guards +with staggered drain, CLOSED-pending `_release_handle()` preservation, logical +Signer close before preflight vs during admission, interrupted admission with +exception identity, and non-consuming bad-format preflight. The subprocess +lifetime proof has a bounded 90-second timeout. These checks do not authorize +concurrent native operations or establish generic thread safety. + +With the official stock `0.91.0` library/path from the initial local section, +`C2PA_SOURCE_BUILD_VERSION=0.91.0` and required-capability flags unset: + +```sh +python3 -m pytest -q -p no:cacheprovider --basetemp=/tmp/opencode-v1.18.29/opencode/python-review-followups-20261007-v4 tests/test_trusted_vsi_api.py tests/test_sign_ladder.py tests/test_fragmented_files.py tests/test_native_ownership.py tests/test_trusted_vsi_build.py -k 'not paired' -ra +python3 -m pytest -q -p no:cacheprovider tests/test_unit_tests.py::TestManagedResourceObjects tests/test_unit_tests.py::TestManagedResourceLifecycle tests/test_unit_tests_threaded.py::TestManagedResourceForkGuard -ra +``` + +Results: **148 passed / 19 capability skips / 70 deselected**, including the +renamed non-native CI-gate test; **101 passed / 49 subtests** for stock +lifecycle/object/fork checks. The stock subprocess proof now also covers closing +Context inside the signing callback. + +Selective older-library compatibility used +`C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/libc2pa_c-203dc08d.so`, +`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`: + +```sh +python3 -m pytest -q -p no:cacheprovider tests/test_trusted_vsi_api.py -k 'callback_exception_identity_blocked_state_and_durable_retry or reentrant_close_native_subprocess or value_wrappers_are_frozen_and_v1_layouts_exact' -ra +``` + +Result: **7 passed / 140 deselected**. This older artifact reports `0.92.0-dev` +and mask 63, SHA-256 +`0401bf3da2060fbdae3223ec0feb926f836b59604d9d4fe9c993122e29d5b6fe`. +The checks exercise healthy/failed `blocked` status, exact layout and actual +native callback-close paths without requiring state version 3. The artifact's +filename is not an embedded revision attestation. The read-only `6b506352` +source inspection above independently confirms that the actual CI pin writes +`blocked`. Neither narrow compatibility evidence nor the older artifact's +version/mask qualifies the new state-v3 contract. Repin/integration, the deferred +step3 revision probe, hosted paired qualification and any maintainer CI label +remain on hold; no merge-readiness claim is made. + +## Final Admission Review + +The final follow-up documents the deliberate foreign-PID restriction on guarded +admission, not a universal SDK fork ban. It adds rejection-before-lock/FFI and +pending consumed-teardown regressions, clarifies `_release_handle()`'s deferred +semantics, and explains why Builder keeps both close points. No runtime guard +policy was broadened, native files changed, or native/full-suite builds run. + +The admission test follows the existing PID-simulation convention: an inherited +lock fails if touched, an FFI spy must not run, and rejection must leave the +handle, callback pins and lifecycle unchanged without release/free. No actual +fork fixture exists in the current suites, so no actual-fork subprocess proof is +claimed. The consumed teardown test requests `free_handle=False` inside an +admitted bookkeeping guard, preserves the False pending decision and pins until +exit, then requires one release and zero native frees. + +With the preserved step2 library, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, +`C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, `PYTHONDONTWRITEBYTECODE=1` and `PYTHONPATH=src`: + +```sh +python3 -m pytest -q -p no:cacheprovider tests/test_trusted_vsi_api.py -k 'call_guard or admitted_call_guards or release_handle or consumed_teardown_inside or teardown_copies or scripted_reentrant_close or reentrant_close_native_subprocess' -ra +``` + +Result: **17 passed / 132 deselected**, with no new disk-backed fixture outputs. + +## Final Source Regression + +After the review follow-ups, an independent frozen-source run used the same +patched native library above, Python 3.12, `PYTHONDONTWRITEBYTECODE=1`, +`PYTHONPATH=src`, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and all four required +functional capability flags. The final run on 2026-10-07 passed: + +- non-threaded source suite: **765 passed, 128 subtests passed, one module skip**; +- threaded source suite: **54 passed**; +- `git diff --check`: passed. + +The module skip is the installed-release smoke described above, not a skipped +trusted-VSI capability. No installed-wheel or hosted qualification is claimed. +The first independent runner omitted `C2PA_SOURCE_BUILD_VERSION`, making the +version test compare this `0.92.0-dev` source-build library with the stock +`0.91.0` artifact pin. The runner was corrected to use the same explicit source +version as paired CI; no product code or version assertion was weakened. + +The preserved same-key init-epoch probe also passed all six cases against this +patched library and binding. It is compatibility evidence, not a lifecycle or +counter-carry implementation. +On official stock `0.91.0` with its library selection and required flags unset, +the same selection prefixed by `not paired and (...)` passed **15 / deselected +134**. The unchanged stock lifecycle/object/fork command above passed **101 / +49 subtests** again. These focused selections overlap prior evidence; their +counts must not be summed as unique qualification. Native integration/repin, +the step3 revision probe and hosted qualification remain pending. diff --git a/docs/ladder-signing.md b/docs/ladder-signing.md index 0489f468..2703a76e 100644 --- a/docs/ladder-signing.md +++ b/docs/ladder-signing.md @@ -6,7 +6,11 @@ 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. +overlap, and refuses to overwrite existing destinations. Errors may leave partial +outputs. Never delete sources or preexisting destinations while cleaning up a +failed operation. Delete only newly created files you positively own. Use a fresh, +exclusively owned staging directory per operation so output ownership is clear; +do not infer ownership just because a path was supplied in `dests`. ```python with Builder(manifest_definition) as builder: @@ -23,6 +27,12 @@ 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. +After validation, failure to admit the signing borrow (for example, an explicit +Signer closing between preflight and admission) closes the Builder too, matching +ordinary and fragmented signing. This does not change non-consuming preflight +validation failures. The call guard is a lifetime mechanism, not permission to +run concurrent native operations on either borrowed resource. + Dynamic assertions registered on the signer run once for the shared manifest. Callback errors follow the other Builder signing paths: an exception raised by a dynamic-assertion callback is re-raised unchanged; a claim-signer callback diff --git a/docs/native-resources-management.md b/docs/native-resources-management.md index b1d2bc16..cab18ef8 100644 --- a/docs/native-resources-management.md +++ b/docs/native-resources-management.md @@ -7,9 +7,9 @@ `ManagedResource` is the internal base class responsible for managing native pointers owned by the C2PA Python SDK. It guarantees: - Native memory is freed exactly once (no double-free). -- Resources are cleaned up deterministically via context managers or explicit `close()`. +- Context managers or explicit `close()` close resources logically; physical cleanup waits for any admitted guarded signing calls to drain. - Ownership transfers (e.g. signer to context) are handled so the same pointer is not freed twice (and the objects/classes know which one owns what). -- Cleanup never raises (trade-off to avoid raising errors on clean-up only, but errors are logged). +- Ordinary cleanup exceptions are logged/suppressed; `BaseException` interrupts are not suppressed. A new wrapper around a native resource inherits from `ManagedResource` and follows the documented lifecycle rules. @@ -103,19 +103,20 @@ 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. - 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)). +- Physical teardown runs `_release()` before native free. A separate snapshot keeps callback objects alive through that free even when `_release()` clears the instance's pins (see [Subclass-specific cleanup with `_release()`](#subclass-specific-cleanup)). ### Double-free risk mitigations -Three distinct risks. Two have a mechanism in this layer; the third is the caller's to synchronize: +Ownership, fork, guarded signing-borrow and unguarded-operation risks have different protections: | Hazard | Covered by | How | | --- | --- | --- | | Freeing a pointer a consuming call already took (single flow) | `_swap_handle` / `_teardown(free_handle=False)` triage | Known-consumed handles are never freed. Failure triage uses call order and, for consume-first registry rejections, compares the rejected value with the managed handle; the tag alone does not establish retention. | | A forked child freeing a pointer its parent owns | PID stamp (`record_owner_pid` / `is_foreign_process`) | Cleanup in a process that did not allocate the pointer nulls the handle and marks `CLOSED` without freeing (see [Fork safety](#fork-safety)). | -| Two **threads** in one process racing frees on distinct objects, where the allocator recycles a just-freed address | Not covered here | `ManagedResource` has no lock and no thread stamping. The PID stamp cannot see it: sibling threads share a PID. Safety for genuinely shared handles must come from the caller's own synchronization or from the native registry, not this layer. | +| Close during an admitted borrowed signing call, including from a callback or another thread | `_native_call()` / deferred teardown | A short per-resource RLock protects admission/counting and close bookkeeping, never native code or callbacks. Physical cleanup waits for the last admitted call. | +| Concurrent unguarded operations, shared/aliased handles, or allocator address reuse across distinct objects | Caller/native synchronization required | The bookkeeping lock does not serialize native operations or protect distinct wrappers sharing a handle. The PID stamp cannot distinguish sibling threads. | -The PID stamp is fork-only: it compares process IDs, and two threads in the same process always match. Sharing one `ManagedResource` instance across threads without external synchronization is outside what this layer protects against. +The PID stamp is fork-only: sibling threads share a PID. Selected signing paths support close during an admitted call, not concurrent native operations in general. Unguarded methods, including Reader calls, `Builder.with_archive()` and complete-buffer VSI `recover()`, must be externally serialized with all other operations and close on the same resource. A lifecycle check alone is not a borrow guard. ## Guarantees provided by ManagedResource @@ -124,20 +125,20 @@ The PID stamp is fork-only: it compares process IDs, and two threads in the same | Guarantee | Description | | --- | --- | | **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`. | +| **Cleanup is idempotent** | The first `close()` marks the resource logically closed; repeats do not free it twice or discard a pending teardown. Physical cleanup may finish later when admitted calls drain. | +| **Cleanup never raises (ordinary errors)** | Cleanup catches/logs `Exception` at the release/free boundaries and does not mask the original operation exception. Cleanup handlers do not suppress `BaseException`; an interrupt during physical cleanup may prevent the remaining free. This is distinct from an operation/callback raising `BaseException`: the call guard still drains in `finally`. | | **State transitions are one-way** | Lifecycle moves only from UNINITIALIZED to ACTIVE to CLOSED. A closed resource cannot be reactivated. | | **Transitions go through helper methods** | Subclasses call `_activate()`, `_swap_handle()` or `_teardown()` and never assign `_handle` or `_lifecycle_state` directly. `_activate()` and `_swap_handle()` validate before mutating, so an object cannot end up active with a null handle. | | **Ownership transfer is safe** | When a pointer is transferred elsewhere (e.g. via `_teardown(free_handle=False)`), the object stops managing it and does not call `c2pa_free` on it. | -| **Public methods validate lifecycle state** | Every public API calls `_ensure_valid_state()` before use; closed or invalid state yields `C2paError` instead of undefined behavior or crashes. | +| **Native operations validate lifecycle state** | Methods requiring a live handle check state before use; logical close rejects new operations even if the handle is retained for an admitted call. This check alone does not prevent an unguarded operation racing with close. | ## Preventing garbage collection of live references When a Python object passes a callback or pointer to the native library, that reference must stay alive for as long as the native side might use it. Python's garbage collector has no way to know that native code is still holding a reference to a Python callback. -The SDK solves this by storing these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`) as instance attributes. As long as the `Stream` object is alive, its callbacks have a nonzero reference count and will not be collected. Similarly, when a `Signer` is consumed by a `Context`, the Context copies the signer's claim callback and DynamicAssertion registrations before consumption, so the ctypes callbacks, original Python callbacks, and per-thread exception state survive even though the Signer object is now closed. A `Builder`, `LiveVideoVsiSession`, or fragmented-file `Reader` created from that Context pins its own copies for its native lifetime, including when the caller explicitly closes the Context. A callback-backed `LiveVideoVsiSession` additionally pins its VSI callback, the original Python callable, and thread-local exception state until the session closes. Any explicit-time clock is likewise pinned until the session closes. +The SDK stores these references as instance attributes on the owning object. For example, `Stream` stores its four callback objects (`_read_cb`, `_seek_cb`, `_write_cb`, `_flush_cb`). When a `Signer` is consumed by a `Context`, the Context copies the claim callback and DynamicAssertion registrations before consumption, preserving actual ctypes thunks, Python callbacks and exception state after the Signer closes. A `Builder`, `LiveVideoVsiSession`, `TrustedVsiSession`, or fragmented-file `Reader` created from that Context pins its own copies for its native lifetime, including after caller `Context.close()`. Callback-backed VSI sessions also pin their VSI callable/thunk and exception state; complete-buffer sessions pin their explicit-time clock. For guarded signing, these references remain until the admitted call drains and physical teardown finishes, not merely until logical close. -During cleanup, `_release()` sets these attributes to `None`, which drops the reference count on the callback objects and allows them to be collected. In the cleanup sequence, `_release()` runs first, then `c2pa_free` frees the native pointer. `_release()` goes first so that subclass-specific resources (open file handles, stream wrappers) are torn down before the native pointer they depend on is freed. +Physical cleanup copies callback pins (including the DA list) before `_release()` clears the instance attributes. The snapshot retains the actual callback objects through `c2pa_free`; clearing the attributes does not yet make those objects collectible. `_release()` still precedes native free, preserving subclass stream/file ordering. Pins become collectible after physical teardown returns, unless another owner or exception traceback legitimately retains them. ## How native memory is freed @@ -171,7 +172,7 @@ stateDiagram-v2 - `UNINITIALIZED`: The Python object exists but the native pointer has not been set yet. This is a transient state during construction. - `ACTIVE`: The native pointer is valid. The object can be used. -- `CLOSED`: The native pointer has been freed (or ownership was transferred). Any further use raises `C2paError`. +- `CLOSED`: Logically closed: new native operations are rejected. With an admitted call, the handle and pins remain until the last call drains; otherwise physical cleanup has run or ownership was transferred. `CLOSED` does not imply `_handle is None` during deferred teardown. `CLOSED` is a one-way state: once closed, an object cannot be reactivated. It is normally reached from `ACTIVE`, but a construction that fails before `_activate()` closes straight from `UNINITIALIZED` when `close()` or `__del__` runs (nothing to free, just marked closed). @@ -181,21 +182,21 @@ Each transition has one method that performs it, and subclasses must go through | --- | --- | --- | | `_activate(handle)` | UNINITIALIZED to ACTIVE | Rejects a null handle, and refuses to run on an already-activated resource. A rejected activation leaves the object exactly as it was. | | `_swap_handle(new_handle)` | ACTIVE to ACTIVE | Requires the resource to already be active and the replacement to be non-null. Used when an FFI call consumed the old handle and returned a new one. | -| `_teardown(free_handle=False)` | ACTIVE to CLOSED | Drops the handle without freeing it, for when ownership passed to the native side (e.g. `Signer` into `Context`). Runs `_release()` first, so subclass cleanup still happens. Unlike the other two, it validates nothing. | -| `_release_handle()` | ACTIVE to CLOSED | Frees the handle (guarded, via `_teardown(free_handle=True)`) and closes the object. Same post-state as the consumed teardown. | +| `_teardown(free_handle=False)` | ACTIVE to CLOSED | Closes logically and schedules release/nulling without native free when ownership passed to native. Physical cleanup is deferred if calls are admitted; consuming operations themselves require external serialization. | +| `_release_handle()` | ACTIVE to CLOSED | Requests a guarded free via `_teardown(free_handle=True)`. If already CLOSED with pending teardown, it leaves the retained handle intact for the last admitted call to drain. | Because activation is the only way in, no code path can leave an object ACTIVE while holding a null handle. An **owned free** calls `c2pa_free` on a handle Python still owns (normal cleanup or create-then-validate failure). A **guarded free** makes that call when ownership is uncertain, via `_release_handle()`. The registry rejects untracked values, but stock allocation addresses can be recycled, so generic guarded frees are not guaranteed harmless. Known-consumed branches skip the free (see [Why an ownership-taken failure does not free](#why-an-ownership-taken-failure-does-not-free)). -`_teardown(free_handle)` is the one method that performs the ACTIVE to CLOSED transition, and the boolean decides the only thing that varies between the two exit paths: whether the native pointer is freed. Both paths run `_release()`, set `CLOSED`, and null the handle. +`_teardown(free_handle)` marks CLOSED first. If calls are admitted, it records pending teardown and retains the handle/pins. Otherwise, or when the last admitted call drains, `_finish_teardown()` snapshots callback pins, runs `_release()`, nulls the handle, and optionally frees it. The boolean chooses whether native free is requested, not whether logical close is immediate. Foreign-process cleanup only marks CLOSED/nulls the copied handle, without release/free or acquiring a possibly inherited lock. | `free_handle` | When | What it does with the pointer | | --- | --- | --- | | `True` | Python still owns the handle, or ownership is unknown after a failure (`_release_handle()`). | Calls `c2pa_free`; an untracked value is rejected, but a reused stock address need not be untracked. | | `False` | The native side took ownership. | Frees nothing; native now owns or has dropped the value. Avoids an unnecessary free, error-slot overwrite, and stock address-reuse risk. | -Every public method calls `_ensure_valid_state()` before doing any work, which raises `C2paError` unless the resource is ACTIVE with a non-null handle. +Methods requiring a live native handle check for ACTIVE with a non-null handle. This is not synchronization for unguarded calls; callers must also serialize them with close. ## Ways to clean up @@ -211,7 +212,7 @@ with Reader("image.jpg") as reader: # reader is automatically closed here, even if an exception occurs ``` -When the `with` block exits, `__exit__` calls `close()`, which frees the native pointer. This is the safest approach because cleanup happens even if the code inside the block raises an exception. +When the `with` block exits, `__exit__` calls `close()`. Logical close is immediate; physical cleanup is immediate only if no guarded calls are admitted. A context exit during a signing callback is not cancellation: the admitted call may complete before cleanup finishes. Unguarded calls must not overlap context exit. ### Explicit close @@ -223,7 +224,7 @@ finally: reader.close() ``` -Calling `close()` directly is equivalent to exiting a `with` block. It is idempotent: calling it multiple times is safe and does nothing after the first call. +Calling `close()` directly has the same logical/deferred behavior as context exit. Repeated close is idempotent and does not discard pending teardown. Close may run during an admitted guarded signing call, but must be externally serialized with unguarded operations. ### Destructor fallback @@ -233,15 +234,15 @@ If neither the context manager nor an explicit `.close()` is used, `__del__` att Cleanup must not raise an *ordinary* exception. A failure during cleanup (for example, the native library crashing on free) should not mask the original exception that caused the `with` block to exit. `ManagedResource` enforces this: -- `close()` delegates to `_cleanup_resources()`, which wraps the entire cleanup sequence in a try/except that catches and silences `Exception`. +- `close()` delegates to `_cleanup_resources()`, which catches/silences ordinary `Exception`. Admitted calls can defer physical cleanup until their guard drains; `_safe_release()` and native-free handlers also suppress ordinary exceptions on that deferred path. - `_release()` is never called directly during cleanup. It runs inside `_safe_release()`, which logs any `Exception` with a traceback and returns normally, so a subclass whose `_release()` raises an ordinary error cannot stop the native pointer from being freed afterwards. - If freeing the native pointer fails, the error is logged via Python's `logging` module but not re-raised. - The state is set to `CLOSED` as the very first step, before attempting to free anything. If cleanup fails halfway, the object is still marked closed, preventing a second attempt from doing further damage. - Cleanup is idempotent. Calling `close()` on an already-closed object returns immediately. -These handlers catch `Exception`, not `BaseException`. The signals the interpreter raises to unwind a process (a cancellation request, or an exit already in progress) are `BaseException`, so they pass through cleanup untouched and the remaining free may not run. That is intentional: the signal means the whole process is going away, and its address space, native allocations included, is reclaimed on exit. Holding the interpreter in cleanup to finish a free that is about to become irrelevant would only delay the shutdown the caller asked for. +These handlers catch `Exception`, not `BaseException`; an interrupt during physical cleanup can prevent the remaining free, and cancellation does not necessarily terminate the process. Separately, the call guard's `finally` always drains admitted-call bookkeeping when an operation unwinds, including a callback's re-raised `BaseException`. -All three cleanup entry points converge on the same method, and the exception handling sits at three different levels inside it: +All three close entry points converge on `_cleanup_resources()`. Physical release/free may instead run later from the last admitted call's guard: ```mermaid flowchart TD @@ -251,10 +252,14 @@ flowchart TD FP -->|no| ST{"already CLOSED?"} ST -->|yes| DONE ST -->|no| SET["set CLOSED first"] - SET --> REL["_safe_release()
logs and swallows"] - REL --> H{"handle set?"} + SET --> AC{"admitted calls?"} + AC -->|yes| WAIT["record pending teardown,
retain handle and pins"] --> DONE + LAST["last admitted call drains"] --> FIN["_finish_teardown()
snapshot callback pins"] + AC -->|no| FIN + FIN --> REL["_safe_release()
logs ordinary errors"] + REL --> NULL["_handle = None"] --> H{"free_handle and saved handle?"} H -->|no| DONE - H -->|yes| FREE["_free_native_ptr()
logs on failure"] --> NULL["_handle = None"] --> DONE + H -->|yes| FREE["_free_native_ptr()
pins retained through free"] --> DONE ``` The `foreign process` branch is explained under [Fork safety](#fork-safety). @@ -298,7 +303,7 @@ When the Reader is closed, it first releases its own resources (open file handle ## Builder lifecycle -A `Builder` follows the same pattern as Reader, with one difference: **signing closes the builder**. A Builder is single-use, so after `sign()` or `sign_fragmented()` attempts native signing it cannot be reused. Preflight validation failures leave it active because no native sign was attempted. +A `Builder` follows the same pattern as Reader, with one difference: **signing closes the builder**. A Builder is single-use, so after validated `sign()`, `sign_fragmented()` or `sign_ladder()` enters call admission it closes on admission failure or an attempted native sign. Earlier preflight validation failures (including a logically closed Signer or invalid format) leave it active. Signing guards do not protect other Builder operations from racing with close. ```mermaid stateDiagram-v2 @@ -314,9 +319,9 @@ stateDiagram-v2 end note ``` -While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly. `.sign()` closes the Builder when it returns, on both the success and the failure path. Closing without signing frees the pointer the same way. +While `ACTIVE`, callers can use `.add_ingredient()`, `.add_action()`, etc. repeatedly with external serialization. After successful preflight, signing closes the Builder on admission/native failure or success; preflight validation failures leave it active. Closing without signing frees immediately unless an admitted call must first drain. -The native sign calls borrow the builder's pointer rather than taking ownership of it, so `Builder` never marks it consumed and the pointer is freed normally through `c2pa_free`. The close enforces single use; it is not a memory-management requirement. `sign_fragmented()` also borrows its explicit Signer, which remains active, and copies its tracked output buffer before freeing that buffer once through `c2pa_free`. +Native sign calls borrow the Builder rather than consuming it. Single-use close is library policy; the call guard makes callback-initiated close safe while native still borrows the handle. `sign_fragmented()` also guards its explicit borrowed Signer, which remains active unless the caller closes it, and copies/frees its tracked output buffer once through `c2pa_free`. [`sign_ladder()`](ladder-signing.md) follows the same single-use rule after an attempted native call, while preflight errors (including unavailable capability) @@ -328,7 +333,7 @@ Any returned manifest buffer is freed through `ManagedResource._free_native_ptr` Some operations transfer a native pointer from one object to another. When this happens, the original object must stop managing the pointer (e.g. so it is not freed twice). -`_teardown(free_handle=False)` handles this. It runs `_release()`, then sets `_handle = None` and `_lifecycle_state = CLOSED` without freeing the pointer. +`_teardown(free_handle=False)` marks CLOSED and schedules `_release()` then handle nulling without native free. Ownership-transfer calls are unguarded and must be externally serialized, including with close; they do not acquire an operation-long Python lock. In the SDK this happens in one place: passing a `Signer` to a `Context`. The Context runs a short-lived native context builder, feeds the signer into it, builds the context, and activates the result. The builder itself is wrapped in `_NativeBuilder` (a small `ManagedResource`), so every failure inside the `with` block frees it through `close()` unless a consuming call already took it. There is no raw pointer held across the calls and no bespoke error handler. @@ -454,7 +459,7 @@ An unnecessary free can overwrite the native error slot. On stock 0.91.0 it can 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. +The per-resource RLock protects close/admission bookkeeping, not these consuming operations or their retained-vs-consumed triage. Their ownership guarantees depend on the native call order, pointer registry and thread-local error slot. Caller synchronization must cover unguarded operations and close; the selected signing-call guards do not make Reader, `with_archive()` or `recover()` thread-safe. This is distinct from [Fork safety](#fork-safety), which concerns a copied resource in a child process. 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. @@ -493,7 +498,7 @@ Examples from the codebase: | Signer | Drops the reference to the signing callback | | Settings | (no override, nothing extra to clean up) | -The cleanup order matters: `_release()` runs first (closing streams, dropping callbacks), then `c2pa_free` frees the native pointer. This order prevents the native library from accessing Python objects that no longer exist. +At physical teardown, `_release()` runs before `c2pa_free`, preserving stream/file ordering. A copied callback snapshot, not that ordering alone, prevents callbacks from becoming collectible before native destruction returns. Logical close during an admitted call does neither release nor free yet. ### Dropping a Context reference @@ -532,6 +537,20 @@ sequenceDiagram Both `_cleanup_resources()` and the consumed teardown take this branch. Neither simply skips the work: they null the handle and mark the object `CLOSED` so the child cannot go on to use it or try to free it later. Mutating the child's copy has no effect on the parent's, which is untouched and still valid. +The signing-call guard deliberately adds a limited admission restriction to the +previous cleanup-only PID behavior: `_native_call()` rejects a resource created +in another PID before acquiring its inherited lock or reaching the guarded FFI +call. Rejected admission does not close the object, clear its callback pins, or +release/free the parent's resource. Create each worker's signers, Contexts and +sessions within that worker process rather than inheriting them for signing. + +This is not a universal SDK-wide fork ban. Unguarded constructors using an +inherited Context are not uniformly blocked, and other unguarded methods do not +all perform this admission check. Lack of a check is not a guarantee that an +inherited native object is safe to use. External serialization of unguarded +operations and close remains the caller's responsibility; serialization does +not repair native mutex state inherited from vanished threads. + The memory the child skips is not lost for good. A child that calls `exec()` replaces its address space; a child that exits has its memory reclaimed by the OS. Even a long-lived child (a `multiprocessing` worker using the fork start method) retains at most the objects it inherited at fork time, which is a bounded, one-off amount rather than a growing leak. Anything the child allocates itself carries the child's own PID and is freed normally. > [!NOTE] @@ -625,7 +644,10 @@ class NativeResource(ManagedResource): # 5. Check state at the start of every public method. # This raises C2paError if the resource is closed. self._ensure_valid_state() - return _lib.c2pa_my_resource_do_something(self._handle) + # 6. For a borrowed call that must survive callback close, admit it. + # This does not serialize native operations with each other. + with self._native_call(): + return _lib.c2pa_my_resource_do_something(self._handle) ``` ### Troubleshooting @@ -645,3 +667,20 @@ class NativeResource(ManagedResource): - When a subclass inherits from both `ManagedResource` and an ABC like `ContextProvider`, and both define a property with the same name (e.g. `is_valid`), Python resolves it using the MRO. The parent listed first in the class definition wins. With the ABC listed first, Python finds the abstract property before the concrete one and raises `TypeError: Can't instantiate abstract class`. The class with the concrete implementation therefore comes first (e.g. `class Context(ManagedResource, ContextProvider)`, not `class Context(ContextProvider, ManagedResource)`). - When two parent classes define the same method or property with different concrete implementations, the MRO silently picks the first one, which can cause subtle bugs where the wrong implementation is used. With shared property names across multiple inheritance, `ClassName.__mro__` or `ClassName.mro()` confirms the expected resolution order. + +## Signing Calls And Reentrant Close + +An admitted borrowed signing call defers physical teardown of its resource until +the last admitted call returns. `close()` is immediately logical and rejects new +operations; it is not cancellation. Trusted VSI `_call()` and its manifest-ID +getter, complete-buffer LiveVideo VSI `_copy_signed_output()`, and Builder native +signing calls use the guard; Builder also guards its explicit borrowed Signer. +Other methods are not implicitly guarded: Reader, `with_archive()`, `recover()`, +constructors and other unguarded operations require caller serialization with +close. Concurrent native operations remain externally serialized even on the +guarded paths. The per-resource RLock protects admission/teardown bookkeeping only, +never a native call or user callback, so callback-initiated close cannot deadlock. +The guard drains in `finally`, including BaseException paths. Actual teardown +retains copied callback objects across `_release()` and native free, even though +`_release()` clears the resource's pin attributes and DA list. Consuming FFI +ownership rules and Reader stream release ordering remain unchanged. diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index ba3a13ff..9b8b6595 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,34 +1,15 @@ # Trusted VSI Python Contract -Status: implemented and qualified against the consolidated functional native -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 -`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). +Status: unreleased PR4 review changes requiring the native step2 contract +(state version 3 and typed expert VSI payloads), following `c2pa-rs` +`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev0`; +immutable dev5 release inputs and artifacts are unchanged. No native pin is +changed here. Select a reviewed local library with `C2PA_LIBRARY_NAME` for +integration tests. Version `0.92.0-dev` and mask 63 do not establish revision +identity; the explicit native revision probe is deferred step3 work, not a new +API in step1/2. Hosted qualification is on hold until final native integration. +Prior results and machine-local provenance are archived in +[review verification](archive/trusted-vsi-review-verification.md). ## Availability @@ -83,7 +64,7 @@ Methods (externally serialize calls on one session): | `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 | +| `export_state()` | `bytes`: versioned public JSON record (currently version 3), including pending reservations | | `import_state(state: bytes)` | `None`; only into a NEW session with identical identity (see State records) | | `status()` | `TrustedVsiStatus` | | `preflight(operation, data=b"", *, sequence_number=0, iat=0, timescale=0, event_duration=0, format="video/mp4")` | `None`; no callbacks, key use, reservation or mutation | @@ -106,9 +87,15 @@ INIT_HASH=0, SIG_STRUCTURE=1, MEDIA_HASH=2. `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 + next_sequence_number, next_event_id, exhausted, exhaustion_reason=None, blocked=False)`; optional fields are `None` when absent. Expert: counters `None`, `exhausted=False`. `exhaustion_reason` is `"sequence_max"`, `"event_id_max"` or `"legacy_sentinel"`. + `blocked` reports an external-signing failure, independently of exhaustion. + The C V1 layout has `blocked: bool` at offset 18; `exhaustion_reason: uint32` + remains at offset 20 (24-byte size, 4-byte alignment). + This native field already exists in the current `6b506352` pairing, including + its `blocked: rust.blocked()` conversion; the Python bridge now exposes it. + The integration hold is not a known missing-`blocked` ABI problem in that pin. Removed without aliases: `TrustedVsiPrehashedSession`, `TrustedVsiSignResult`, `TrustedVsiInitUuidReservation`, `recover(...)`, and the private Python gate. @@ -117,8 +104,16 @@ Removed without aliases: `TrustedVsiPrehashedSession`, `TrustedVsiSignResult`, - 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. + including repeats of older sequences. Native validates framing and a typed, + untagged VSI payload map: `sequenceNumber` (uint32 matching the supplied + sequence), `manifestId` (the pinned init ID), and `bmffHash` (the exact native + SHA-256 media-template map with a 32-byte hash), plus optional valid + `manifestUri` hashed-URI map. Protected headers require canonical matching + integer `alg` and integer `iat` within the configured key window. Payload + field order need not be canonical. Native signs the original bytes unchanged + and keeps no expert media counter. A detached signerBinding certificate bstr + is not an expert VSI payload. Static validation checks shape/types but cannot + check a particular session's manifest ID or key validity window. - Composed: reserve requires `sequence_number == next_sequence_number` (initially `min`); events start at 1; timing values must be positive. Reserve signs nothing. Finalize advances counters or exhausts without wrapping. @@ -157,8 +152,14 @@ Removed without aliases: `TrustedVsiPrehashedSession`, `TrustedVsiSignResult`, `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. +`{"format": "c2pa.trusted-vsi.state", "version": 3, "identity", "state"}`. +Versions 1 and 2 (unreleased) are rejected; there is no migration. +Trusted reservation salts are deterministically derived from the public nonce +and versioned artifact/label domains, not private key material. Import and init +finalize preflight reconstruct the entire expected reservation (including static +assertions, resources and DA slots) and require byte-for-byte equality; matching +the editable identity alone is not sufficient. Signed-init imports additionally +validate full finalized-store consistency. Python treats these bytes as opaque. `import_state()` succeeds only on a NEW session whose identity matches exactly: mode, VSI session config/public key/kid, constructor options (including the @@ -185,6 +186,32 @@ callback, so they survive signer consumption into the Context and caller and freed exactly once with `c2pa_free`; the manifest-ID string uses `c2pa_string_free`. Input buffers are borrowed. +Signing calls on TrustedVsiSession, complete-buffer LiveVideoVsiSession and +Builder admit a native borrow under a short per-resource lock. `close()` makes +the resource logically closed immediately and rejects new operations, but does +not cancel an admitted operation. Physical `_release` and handle free wait for +the last admitted call to return, including when close is called reentrantly +from a callback or from another thread. Builder also guards its explicit borrowed +Signer. Locks are never held across native calls or user callbacks. Callback +objects (including a copied DA list) remain pinned through `_release` and native +destruction, then become collectible. Consuming-handle ownership and Reader +stream cleanup ordering are unchanged. Builder still closes automatically after +an attempted sign, on success or failure including BaseException. If a borrowed +Signer closes after preflight and admission fails, Builder closes too; earlier +validation failures remain non-consuming. These guards do not make all resource +methods thread-safe: unguarded Reader, `with_archive()` and complete-buffer +`recover()` calls must be externally serialized with close and other operations. +Concurrent native operations also require serialization on guarded paths. + +Guarded admission deliberately rejects foreign-PID inherited resources before +touching an inherited lock or making the guarded FFI call. This is a limited +behavior change from cleanup-only suppression of inherited native frees, not an +SDK-wide fork ban. Create worker-owned signers, Contexts and sessions in the +worker. Unguarded constructors accepting inherited Contexts are not uniformly +blocked; their existence does not establish safe inherited-object use. Caller +serialization obligations for unguarded operations and close remain unchanged. +See [fork safety](native-resources-management.md#fork-safety). + Errors: Python argument problems raise `TypeError`/`ValueError`. Callback results that are not exactly 64 `bytes` raise `TypeError`/`ValueError`. Any exception raised by the VSI callback, claim-signer callback or a DynamicAssertion callback @@ -219,6 +246,27 @@ 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 `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. +plus CI-only fixes; previously `203dc08d`, before that `5c186c07`). That older +pin emits state version 2, so the required version-3 tests deliberately fail +against it. No tests or capability gates are weakened to accommodate this hold. +Final native integration/qualification and a full-SHA repin belong to a separate +authorized step, not these Python-only review changes. + +## PR4 CI Gate + +At head `12d265db92e8dcbf80b8255278e9a7fc5945f750`, Build run +`36810322522` completed its version/format/tooling jobs but skipped the paired +and platform test jobs. The current PR API reports author association `MEMBER` +and no labels; the source gate already accepts `COLLABORATOR` and `MEMBER`. +The retained run metadata does not establish the event-time association, so this +is not evidence that the current MEMBER condition is wrong. No author gate is +broadened here (in particular no speculative OWNER exception or fork secrets). +Do NOT apply the maintainer `safe to test` label or run paired CI expecting it to +pass until the final native revision is integrated and pinned in the separate +authorized integration step. The unchanged older native pin and the new +required state-v3 tests are intentionally incompatible. Only after integration +should a maintainer review the head, apply the existing label to trigger the +`labeled` event, and confirm required jobs actually ran. This task does not post +labels, dispatch CI, or repin. A green workflow with skipped test jobs is not +qualification, and local targeted older-library compatibility checks do not lift +the integration hold. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 8a87d04a..41a593f2 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -25,6 +25,7 @@ import weakref from abc import ABC, abstractmethod from collections.abc import Sequence +from contextlib import contextmanager, nullcontext from dataclasses import dataclass from pathlib import Path from typing import Optional, Union, Callable, Any, overload @@ -396,8 +397,12 @@ class ManagedResource: the handle without returning a replacement: the new owner frees it, so this does not. - Call `_release_handle()` when a consuming FFI call fails with ownership - unknown: it frees eagerly (guarded), then closes. `_consume_and_swap` - uses it on that failure path. + unknown: it closes and requests a guarded free. `_consume_and_swap` + uses it on that failure path; admitted calls defer physical cleanup. + - Use `_native_call()` around borrowed signing FFI calls that must survive + callback-initiated close, also guarding any explicitly borrowed Signer. + This is not generic thread safety: unguarded operations (Reader calls, + with_archive, recover, etc.) and close must be externally serialized. - Override `_release()` to free class-specific resources (streams, caches, callbacks, etc.), called before the native pointer is freed. @@ -420,6 +425,9 @@ def _init_attrs(self): def __init__(self): self._lifecycle_state = LifecycleState.UNINITIALIZED self._handle = None + self._call_lock = threading.RLock() + self._active_calls = 0 + self._pending_teardown = None record_owner_pid(self) @staticmethod @@ -475,7 +483,7 @@ def _safe_release(self): ) def _teardown(self, free_handle: bool): - """Close the object: run _release, optionally free the handle, null it. + """Close logically; release/null/free after admitted calls drain, if any. free_handle=False (consumed) frees nothing, the new owner needs to free. """ if is_foreign_process(self): @@ -483,10 +491,28 @@ def _teardown(self, free_handle: bool): self._lifecycle_state = LifecycleState.CLOSED return - self._lifecycle_state = LifecycleState.CLOSED - self._safe_release() + with getattr(self, '_call_lock', nullcontext()): + if self._lifecycle_state == LifecycleState.CLOSED: + return + self._lifecycle_state = LifecycleState.CLOSED + if getattr(self, '_active_calls', 0): + self._pending_teardown = free_handle + return + handle = self._handle + self._finish_teardown(handle, free_handle) - handle, self._handle = self._handle, None + def _finish_teardown(self, handle, free_handle): + if is_foreign_process(self): + self._handle = None + return + # _release clears these attributes/lists. Retain the actual ctypes + # thunks, not just their error states, until native destruction returns. + callback_pins = [getattr(self, name, None) for name in ( + '_callback_cb', '_signer_callback_cb', '_vsi_callback', + '_trusted_vsi_callback')] + callback_pins.extend(list(getattr(self, '_dynamic_assertion_cbs', ()))) + self._safe_release() + self._handle = None if free_handle and handle: try: ManagedResource._free_native_ptr(handle) @@ -494,13 +520,47 @@ def _teardown(self, free_handle: bool): logger.error("Failed to free native %s resources", type(self).__name__, exc_info=True) + @contextmanager + def _native_call(self): + """Admit a borrowed native call; close is logical, not cancellation. + + No lock is held across native code or user callbacks. This guards the + selected signing paths, not all resource methods or concurrent native + operations. Unguarded calls and close require external serialization. + Foreign-PID resources are rejected before touching an inherited lock; + create worker-owned signers/sessions in the worker. This admission rule + is deliberately limited to guarded calls, not an SDK-wide fork ban. + """ + if is_foreign_process(self): + raise C2paError("Native resources cannot be used after fork") + with self._call_lock: + self._ensure_valid_state() + self._active_calls += 1 + try: + yield + finally: + teardown = None + if is_foreign_process(self): + self._cleanup_resources() + else: + with self._call_lock: + self._active_calls -= 1 + if not self._active_calls and self._pending_teardown is not None: + teardown = (self._handle, self._pending_teardown) + self._pending_teardown = None + if teardown is not None: + self._finish_teardown(*teardown) + def _release_handle(self): - """Free this handle, then close the object. Used only where ownership is - unknown (a guarded free is a real free if ours, a no-op if not). + """Request guarded free/close when ownership is unknown. + + Admitted calls defer physical cleanup. Already-CLOSED resources retain + their pending cleanup decision, handle and pins until the guard drains. """ if self._lifecycle_state != LifecycleState.ACTIVE: - self._handle = None - self._lifecycle_state = LifecycleState.CLOSED + if self._lifecycle_state != LifecycleState.CLOSED: + self._handle = None + self._lifecycle_state = LifecycleState.CLOSED return self._teardown(free_handle=True) @@ -789,7 +849,7 @@ def is_valid(self) -> bool: ) def close(self) -> None: - """Release the resource (idempotent, never raises).""" + """Close logically; admitted calls defer physical release (idempotent).""" self._cleanup_resources() def __enter__(self): @@ -889,6 +949,7 @@ class C2paLiveVideoTrustedVsiStatusV1(ctypes.Structure): ("next_event_id", ctypes.c_uint32), ("exhausted", ctypes.c_bool), ("has_exhaustion_reason", ctypes.c_bool), + ("blocked", ctypes.c_bool), ("exhaustion_reason", ctypes.c_uint32), ] @@ -2395,6 +2456,7 @@ class TrustedVsiStatus: next_event_id: Optional[int] exhausted: bool exhaustion_reason: Optional[str] = None + blocked: bool = False def _has_trusted_vsi_capability(bit: int, symbols_available: bool = True) -> bool: @@ -2734,7 +2796,8 @@ def _call(self, function, *args): if claim_state is not None: states.append(claim_state) _clear_callback_errors(states) - result = function(self._handle, *args) + with self._native_call(): + result = function(self._handle, *args) if result < 0: _reraise_callback_errors(states) _check_ffi_operation_result(result, "Trusted VSI operation failed", check=lambda r: r < 0) @@ -2750,7 +2813,8 @@ def reserve_init_uuid(self, format: str = "video/mp4") -> bytes: def reserved_manifest_id(self) -> str: self._ensure_valid_state() - pointer = _lib.c2pa_live_video_trusted_vsi_session_reserved_manifest_id(self._handle) + with self._native_call(): + pointer = _lib.c2pa_live_video_trusted_vsi_session_reserved_manifest_id(self._handle) _check_ffi_operation_result(pointer, "No reserved trusted VSI manifest ID") try: return ctypes.string_at(pointer).decode('utf-8') @@ -2836,7 +2900,8 @@ def status(self) -> TrustedVsiStatus: 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) + reasons[native.exhaustion_reason] if native.has_exhaustion_reason else None, + blocked=native.blocked) class LiveVideoVsiSession(ManagedResource): @@ -3183,7 +3248,8 @@ def _copy_signed_output(self, ffi_call, error_message: str) -> bytes: claim_state = _claim_signer_error_state(self._signer_callback_cb) _clear_callback_errors(states + [claim_state]) output = ctypes.POINTER(ctypes.c_ubyte)() - length = ffi_call(ctypes.byref(output)) + with self._native_call(): + length = ffi_call(ctypes.byref(output)) if length < 0: _reraise_callback_errors( states, [claim_state] if claim_state is not None else []) @@ -5586,7 +5652,7 @@ def _sign_internal( self._ensure_valid_state() if signer is not None: - if not hasattr(signer, '_handle') or not signer._handle: + if not hasattr(signer, '_handle') or not signer.is_valid: raise C2paError("Invalid or closed signer") dynamic_assertion_cbs = list( @@ -5606,30 +5672,35 @@ def _sign_internal( manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() try: - if signer is not None: - result = _lib.c2pa_builder_sign( - self._handle, - format_arg, - source_stream._stream, - dest_stream._stream, - signer._handle, - ctypes.byref(manifest_bytes_ptr) - ) - else: - result = _lib.c2pa_builder_sign_context( - self._handle, - format_arg, - source_stream._stream, - dest_stream._stream, - ctypes.byref(manifest_bytes_ptr), - ) - # Sign borrows the Builder without taking ownership. - # Closing here ensures resources clean up, - # and single use/single sign done by a Builder. - self.close() + with self._native_call(), signer._native_call() if signer is not None else nullcontext(): + try: + if signer is not None: + result = _lib.c2pa_builder_sign( + self._handle, + format_arg, + source_stream._stream, + dest_stream._stream, + signer._handle, + ctypes.byref(manifest_bytes_ptr) + ) + else: + result = _lib.c2pa_builder_sign_context( + self._handle, + format_arg, + source_stream._stream, + dest_stream._stream, + ctypes.byref(manifest_bytes_ptr), + ) + finally: + # Close logically inside the borrows; physical teardown runs + # as the guards drain after the native call has returned. + self.close() except Exception as e: - self.close() raise C2paError(f"Error during signing: {e}") from e + finally: + # Admission can fail after preflight if the borrowed Signer closes. + # Match the other signing paths without consuming preflight errors. + self.close() if result < 0: _reraise_callback_errors( @@ -5848,14 +5919,15 @@ def sign_fragmented( manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() try: try: - result = _lib.c2pa_builder_sign_fragmented( - self._handle, - signer._handle, - asset_path_bytes, - fragments_glob_bytes, - output_dir_bytes, - ctypes.byref(manifest_bytes_ptr), - ) + with self._native_call(), signer._native_call(): + result = _lib.c2pa_builder_sign_fragmented( + self._handle, + signer._handle, + asset_path_bytes, + fragments_glob_bytes, + output_dir_bytes, + ctypes.byref(manifest_bytes_ptr), + ) except Exception as error: raise C2paError( f"Error during fragmented signing: {error}") from error @@ -5903,8 +5975,10 @@ def sign_ladder( 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. + layout and path overlap and refuses to overwrite existing destinations. + Errors may leave partial outputs. Delete only newly created files you + positively own, never sources or preexisting destinations. Prefer a + fresh, exclusively owned staging directory for every operation. Like :py:meth:`sign`, an attempted native signing call closes this Builder on success or failure. Preflight errors (including unavailable @@ -5969,9 +6043,10 @@ def sign_ladder( try: try: - result = _lib.c2pa_builder_sign_ladder( - self._handle, signer._handle, source_array, dest_array, - count, ctypes.byref(manifest_bytes_ptr)) + with self._native_call(), signer._native_call(): + result = _lib.c2pa_builder_sign_ladder( + self._handle, signer._handle, source_array, dest_array, + count, ctypes.byref(manifest_bytes_ptr)) except Exception as e: raise C2paError(f"Error during ladder signing: {e}") from e if result < 0: diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py index 092903d9..0c97d248 100644 --- a/tests/test_trusted_vsi_api.py +++ b/tests/test_trusted_vsi_api.py @@ -17,6 +17,10 @@ import json import os from pathlib import Path +import subprocess +import sys +import threading +import weakref from unittest.mock import Mock import cbor2 @@ -132,7 +136,14 @@ def test_value_wrappers_are_frozen_and_v1_layouts_exact(): 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] + assert status._fields_ == [ + ("init_uuid_committed", ctypes.c_bool), ("init_uuid_pending", ctypes.c_bool), + ("media_emsg_pending", ctypes.c_bool), ("has_next_sequence_number", ctypes.c_bool), + ("next_sequence_number", ctypes.c_uint32), ("has_next_event_id", ctypes.c_bool), + ("next_event_id", ctypes.c_uint32), ("exhausted", ctypes.c_bool), + ("has_exhaustion_reason", ctypes.c_bool), ("blocked", ctypes.c_bool), + ("exhaustion_reason", ctypes.c_uint32)] + assert [getattr(status, name).offset for name, _ in status._fields_] == [0, 1, 2, 3, 4, 8, 12, 16, 17, 18, 20] def test_python_mapping_matches_native_contract_signatures(): @@ -362,7 +373,7 @@ def reserve(handle, sequence, iat, timescale, duration, output, context): 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) + True, False, False, True, 9, True, 2, True, True, False, 2) return 0 native.install(monkeypatch, "session_status_v1", status) assert session.status() == c2pa.TrustedVsiStatus(True, False, False, 9, 2, True, "event_id_max") @@ -436,6 +447,11 @@ def fail(*args): alg=b"es256", sign_cert=certs, private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None)) if which == "dynamic": + if not c2pa.has_dynamic_assertions(): + message = "scripted dynamic wrapper needs native registration export" + if _qualification_required(): + pytest.fail(message) + pytest.skip(message) signer.add_dynamic_assertion(fail, label="com.example.functional", reserve_size=64) context = c2pa.Context(signer=signer) native = _ScriptedNative(monkeypatch) @@ -587,8 +603,15 @@ def signing(context, data): 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) +def _sig_structure(algorithm="ed25519", payload=None, *, session=None, sequence=1, iat=IAT): + protected = cbor2.dumps({1: -8 if algorithm == "ed25519" else -7, "iat": iat}, canonical=True) + if payload is None: + media_hash = {"alg": "sha256", "name": "jumbf manifest", "hash": bytes(32), + "exclusions": [{"xpath": "/emsg", "data": [ + {"offset": 12, "value": b"urn:c2pa:verifiable-segment-info"}]}]} + payload = cbor2.dumps({"sequenceNumber": sequence, + "manifestId": session.reserved_manifest_id() if session else "test-manifest", + "bmffHash": media_hash}) return cbor2.dumps(["Signature1", protected, b"", payload], canonical=True) @@ -690,9 +713,9 @@ def test_paired_expert_exact_bytes_supplied_sequence_retry_and_no_counter(sessio 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): + original = _sig_structure(algorithm, session=session, sequence=sequence) + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, algorithm, original) session.preflight(OP.EXPERT_SIGN, original, sequence_number=sequence) signature = session.sign_sig_structure(original, sequence) assert isinstance(signature, bytes) and len(signature) == 64 @@ -708,7 +731,7 @@ def test_paired_expert_exact_bytes_supplied_sequence_retry_and_no_counter(sessio 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 + assert len(session.sign_sig_structure(_sig_structure(algorithm, session=session, sequence=2), 2)) == 64 def test_paired_init_pending_export_import_preflight_and_identical_replay(sessions): @@ -718,6 +741,8 @@ def test_paired_init_pending_export_import_preflight_and_identical_replay(sessio assert new == first.export_state() and calls == [] reserved = first.reserve_init_uuid() assert first.reserve_init_uuid() == reserved and calls == [] + independent, independent_calls, _, _ = sessions() + assert independent.reserve_init_uuid() == reserved and independent_calls == [] pending = first.export_state() restored, restored_calls, _, context = sessions() restored.import_state(pending) @@ -767,6 +792,7 @@ def test_paired_composed_pending_import_and_uint32_exhaustion(sessions): assert final[4:8] == b"emsg" and len(final) == len(reserved.placeholder_emsg_box) assert restored_calls[-1][0] == reserved.signing_context assert restored.status().exhausted and restored.status().exhaustion_reason == "sequence_max" + assert not restored.status().blocked with pytest.raises(c2pa.C2paError): restored.reserve_media_emsg_at(0, IAT, 1000, 2000) @@ -833,17 +859,31 @@ def test_paired_composed_real_fragment_native_verification(sessions): 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"), +@pytest.mark.parametrize("invalid", [ + "garbage", "indefinite", "trailing", "duplicate_alg", "nonminimal_alg", "aad", "wrong_algorithm", ]) -def test_paired_invalid_expert_framing_before_callback_and_no_mutation(sessions, data): +def test_paired_invalid_expert_framing_before_callback_and_no_mutation(sessions, invalid): session, calls, _, _ = sessions() _init(session) count, state = len(calls), session.export_state() + data = _sig_structure(session=session) + framing = cbor2.loads(data) + if invalid == "garbage": + data = b"garbage" + elif invalid == "indefinite": + data = b"\x9f\xff" + elif invalid == "trailing": + data += b"\x00" + elif invalid == "wrong_algorithm": + data = _sig_structure("es256", session=session) + else: + if invalid == "duplicate_alg": + framing[1] = b"\xa3\x01\x27\x01\x27\x63iat" + cbor2.dumps(IAT) + elif invalid == "nonminimal_alg": + framing[1] = b"\xa2\x01\x38\x07\x63iat" + cbor2.dumps(IAT) + else: + framing[2] = b"aad" + data = cbor2.dumps(framing) with pytest.raises(c2pa.C2paError): c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", data) with pytest.raises(c2pa.C2paError): @@ -859,7 +899,7 @@ def test_paired_mode_and_sequence_limits_precede_callback(sessions): count = len(calls) for value in (6, 10): with pytest.raises(c2pa.C2paError): - expert.sign_sig_structure(_sig_structure(), value) + expert.sign_sig_structure(_sig_structure(session=expert, sequence=value), value) for value in (-1, 2**32, True, 1.0): with pytest.raises((TypeError, ValueError)): expert.sign_sig_structure(_sig_structure(), value) @@ -885,17 +925,21 @@ def callback(context, data): session, calls, _, _ = sessions(callback=callback) _init(session) before = session.export_state() + assert not session.status().blocked and not session.status().exhausted + tbs = _sig_structure(session=session) with pytest.raises(RuntimeError) as caught: - session.sign_sig_structure(_sig_structure(), 1) + session.sign_sig_structure(tbs, 1) assert caught.value is error + assert session.status().blocked and not session.status().exhausted assert session.export_state() != before count = len(calls) with pytest.raises(c2pa.C2paError): - session.sign_sig_structure(_sig_structure(), 1) + session.sign_sig_structure(tbs, 1) assert len(calls) == count restored, _, _, _ = sessions() restored.import_state(before) - assert len(restored.sign_sig_structure(_sig_structure(), 1)) == 64 + assert not restored.status().blocked + assert len(restored.sign_sig_structure(tbs, 1)) == 64 @@ -915,7 +959,7 @@ def fail(*args): # 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 + # Declarations must be identical (native v3 state pins the claim # signer's reserve size and ordered DA label/reserve size), so the # healthy claim signer is also a from_callback signer with the same # certificate; only its signing behavior differs. @@ -933,9 +977,11 @@ def fail(*args): context.close() gc.collect() canonical = c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) + assert not session.status().blocked with pytest.raises(type(error)) as caught: session.finalize_init_uuid(canonical) assert caught.value is error + assert session.status().blocked and not session.status().exhausted assert len(failures) == 1 # The external signing attempt blocks the local session: no retry, no # further callback, and init is never committed. @@ -959,7 +1005,7 @@ def fail(*args): {"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. + """v3 identity pins claim reserve size and ordered DA declarations. A mismatch must be rejected by import_state itself, before mutation and without invoking the VSI session key, claim signer, or any DA callback. @@ -1005,7 +1051,7 @@ def vsi(ctx, data): 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 (state["format"], state["version"]) == ("c2pa.trusted-vsi.state", 3) assert [(d["label"], d["reserve_size"]) for d in state["identity"]["dynamic_assertions"]] == \ list(reserving.get("dynamic", ())) importing_session = build(importing, "importing") @@ -1090,3 +1136,529 @@ def dynamic(label, size, partial_claim): assert len(final) == len(reservation) assert len(da_calls) == 1 and len(claim_calls) >= 1 assert da_calls[0][0:2] == ("com.example.functional", 64) + + +@pytest.mark.parametrize("field", ["sequence", "manifest", "iat", "binding_payload"]) +def test_paired_expert_rejects_signed_identity_and_time_mismatch_without_callbacks(sessions, field): + session, calls, _, _ = sessions() + _init(session) + before, count = session.export_state(), len(calls) + tbs = _sig_structure(session=session, sequence=2 if field == "sequence" else 1, + iat=IAT + 86400 if field == "iat" else IAT) + framing = cbor2.loads(tbs) + if field == "manifest": + payload = cbor2.loads(framing[3]) + payload["manifestId"] = "foreign-manifest" + framing[3] = cbor2.dumps(payload) + elif field == "binding_payload": + framing[3] = cbor2.dumps(b"detached signerBinding certificate") + tbs = cbor2.dumps(framing) + # Static validation cannot know session identity or its validity window. + if field == "binding_payload": + with pytest.raises(c2pa.C2paError): + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", tbs) + else: + c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", tbs) + with pytest.raises(c2pa.C2paError): + session.preflight(OP.EXPERT_SIGN, tbs, sequence_number=1) + with pytest.raises(c2pa.C2paError): + session.sign_sig_structure(tbs, 1) + assert session.export_state() == before and len(calls) == count + assert not session.status().blocked + + +@pytest.mark.parametrize("version", [1, 2]) +def test_paired_old_state_versions_rejected_before_mutation(sessions, version): + original, _, _, _ = sessions() + original.reserve_init_uuid() + state = json.loads(original.export_state()) + assert state["version"] == 3 + state["version"] = version + new, calls, _, _ = sessions() + before = new.export_state() + with pytest.raises(c2pa.C2paError): + new.import_state(json.dumps(state).encode()) + assert new.export_state() == before and calls == [] + + +@pytest.mark.parametrize("make_error", [None] + [p.values[0] for p in CALLBACK_FAILURES]) +def test_scripted_reentrant_close_retains_thunks_until_free_and_then_collects(monkeypatch, make_error): + native = _ScriptedNative(monkeypatch) + certs = (FIXTURES / "es256_certs.pem").read_bytes() + signer = c2pa.Signer.from_callback(_fixture_claim_signature, c2pa.C2paSigningAlg.ES256, certs, None) + context = c2pa.Context(signer=signer) + error = make_error() if make_error else None + holder = {} + + def callback(ctx, data): + session = holder["session"] + session.close() + session.close() + assert not session.is_valid and session._handle is not None + with pytest.raises(c2pa.C2paError, match="closed"): + session.export_state() + gc.collect() + assert claim_ref() is not None and vsi_ref() is not None + assert native.freed == [] + if error is not None: + raise error + return b"S" * 64 + + native.install(monkeypatch, "session_create_callback_v1", + lambda *args: setattr(native, "callback", weakref.proxy(args[-1])) or native.handle) + session = c2pa.TrustedVsiSession( + context, {"format": "video/mp4"}, "es256", b"k", b"kid", 1, + "2026-09-10T00:00:00Z", 86400, callback, mode="expert_sig_structure", + reservation_nonce="0" * 32, signing_time_unix_seconds=IAT) + holder["session"] = session + claim_ref = weakref.ref(session._signer_callback_cb) + vsi_ref = weakref.ref(session._trusted_vsi_callback[0]) + # Recorded constructor arguments would otherwise be artificial strong pins. + native.calls.clear() + context.close() + real_free = bindings.ManagedResource._free_native_ptr + + def free(ptr): + if ctypes.addressof(ptr.contents) == ctypes.addressof(native.handle.contents): + assert session._trusted_vsi_callback is None # _release already ran + gc.collect() + assert claim_ref() is not None and vsi_ref() is not None + return real_free(ptr) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + + def sign(handle, data, length, sequence, output): + result, signature = native.invoke() + if error is not None: + assert result == -1 + return -1 + assert result == 64 + return native.output(output, signature) + native.install(monkeypatch, "session_sign_sig_structure", sign) + if error is not None: + with pytest.raises(type(error)) as caught: + session.sign_sig_structure(b"tbs", 1) + assert caught.value is error + # Exception tracebacks legitimately retain callback frames. + error.__traceback__ = None + del caught + else: + assert session.sign_sig_structure(b"tbs", 1) == b"S" * 64 + session.close() + assert native.freed.count(ctypes.addressof(native.handle.contents)) == 1 + gc.collect() + assert claim_ref() is None and vsi_ref() is None + + +def test_call_guard_close_from_other_thread_is_nonblocking_and_drains_once(monkeypatch): + resource = bindings.ManagedResource() + resource._activate(ctypes.c_void_p(123)) + freed = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + with resource._native_call(): + closer = threading.Thread(target=resource.close) + closer.start() + closer.join(timeout=2) + assert not closer.is_alive(), "close must not wait for a callback/native call" + assert not resource.is_valid and resource._handle is not None + freed.assert_not_called() + with pytest.raises(c2pa.C2paError, match="closed"): + with resource._native_call(): + pytest.fail("closed resource admitted another operation") + freed.assert_called_once() + assert resource._handle is None + resource.close() + freed.assert_called_once() + + +def test_two_admitted_call_guards_close_and_staggered_drain(monkeypatch): + # Exercise bookkeeping, not concurrent native operations (which require + # external serialization even when both calls have a lifetime guard). + resource = bindings.ManagedResource() + handle = ctypes.c_void_p(123) + resource._activate(handle) + freed = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + with resource._native_call(): + with resource._native_call(): + assert resource._active_calls == 2 + resource.close() + assert not resource.is_valid and resource._handle is handle + freed.assert_not_called() + assert resource._active_calls == 1 and resource._handle is handle + freed.assert_not_called() + assert resource._active_calls == 0 and resource._handle is None + freed.assert_called_once_with(handle) + resource.close() + freed.assert_called_once() + + +def test_release_handle_preserves_closed_pending_borrow(monkeypatch): + resource = bindings.ManagedResource() + handle = ctypes.c_void_p(123) + resource._activate(handle) + freed = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + with resource._native_call(): + resource.close() + resource._release_handle() + assert resource._handle is handle and resource._pending_teardown is True + assert resource._active_calls == 1 and not resource.is_valid + freed.assert_not_called() + assert resource._handle is None and resource._pending_teardown is None + freed.assert_called_once_with(handle) + + +def test_call_guard_foreign_pid_drain_never_locks_releases_or_frees(monkeypatch): + resource = bindings.ManagedResource() + resource._activate(ctypes.c_void_p(123)) + freed = Mock() + release = Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + resource._release = release + with resource._native_call(): + resource.close() + resource._owner_pid = os.getpid() + 1 + # A copied lock might have belonged to a vanished thread at fork. + resource._call_lock = Mock(side_effect=AssertionError("foreign lock touched")) + assert not resource.is_valid and resource._handle is None + release.assert_not_called() + freed.assert_not_called() + + +def test_call_guard_foreign_pid_admission_rejects_before_lock_or_ffi_without_cleanup(monkeypatch): + resource = bindings.Signer._wrap_native_handle(ctypes.pointer(bindings.C2paSigner())) + handle, parent_pid, parent_lock = resource._handle, resource._owner_pid, resource._call_lock + callback_pin = bindings.SignerCallback(lambda *args: -1) + resource._callback_cb = callback_pin + dynamic_pins = resource._dynamic_assertion_cbs + dynamic_pins.append((bindings.DynamicAssertionCallback(lambda *args: -1), + threading.local(), lambda *args: b"")) + dynamic_pin = dynamic_pins[0] + freed, release, ffi = Mock(), Mock(), Mock() + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + monkeypatch.setattr(resource, "_release", release) + + class InheritedLock: + def __enter__(self): + raise AssertionError("foreign admission touched an inherited lock") + + def __exit__(self, *args): + raise AssertionError("foreign admission touched an inherited lock") + + resource._owner_pid = parent_pid + 1 + resource._call_lock = InheritedLock() + try: + with pytest.raises(c2pa.C2paError, match="after fork"): + with resource._native_call(): + ffi(handle) + ffi.assert_not_called() + release.assert_not_called() + freed.assert_not_called() + assert resource.is_valid and resource._handle is handle + assert resource._active_calls == 0 and resource._pending_teardown is None + assert resource._callback_cb is callback_pin + assert resource._dynamic_assertion_cbs is dynamic_pins + assert dynamic_pins == [dynamic_pin] + finally: + # Restore the simulated parent's identity/lock for its own cleanup. + resource._owner_pid, resource._call_lock = parent_pid, parent_lock + resource.close() + + +def test_consumed_teardown_inside_call_guard_retains_pins_then_releases_without_free(monkeypatch): + # Bookkeeping-only: actual consuming FFI operations still require external + # serialization and must not consume a handle another native call borrows. + resource = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + handle = resource._handle + callback_pin = bindings.SignerCallback(lambda *args: -1) + resource._signer_callback_cb = callback_pin + dynamic_pin = (bindings.DynamicAssertionCallback(lambda *args: -1), + threading.local(), lambda *args: b"") + resource._dynamic_assertion_cbs.append(dynamic_pin) + freed = Mock() + release = Mock(wraps=resource._release) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + monkeypatch.setattr(resource, "_release", release) + with resource._native_call(): + resource._teardown(free_handle=False) + resource.close() + resource._release_handle() + assert not resource.is_valid and resource._handle is handle + assert resource._pending_teardown is False and resource._active_calls == 1 + assert resource._signer_callback_cb is callback_pin + assert resource._dynamic_assertion_cbs == [dynamic_pin] + release.assert_not_called() + freed.assert_not_called() + assert resource._handle is None and resource._pending_teardown is None + assert resource._active_calls == 0 and not resource.is_valid + assert resource._signer_callback_cb is None and resource._dynamic_assertion_cbs == [] + release.assert_called_once() + resource.close() + release.assert_called_once() + freed.assert_not_called() + + +def test_teardown_copies_dynamic_thunks_through_release_and_free(monkeypatch): + resource = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + resource._dynamic_assertion_cbs.append(( + bindings.DynamicAssertionCallback(lambda *args: -1), threading.local(), lambda *args: b"")) + callback_ref = weakref.ref(resource._dynamic_assertion_cbs[0][0]) + + def free(ptr): + assert resource._dynamic_assertion_cbs == [] + gc.collect() + assert callback_ref() is not None + return 0 + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + resource.close() + gc.collect() + assert callback_ref() is None + + +def _actual_native_reentrant_close(kind): + """Executed only in a subprocess: a stale ctypes thunk can crash Python.""" + certs = (FIXTURES / "es256_certs.pem").read_bytes() + seen, churn, holder = [], [], {} + manifest = {"claim_version": 2, "assertions": [{"label": "c2pa.actions", "data": { + "actions": [{"action": "c2pa.created", "digitalSourceType": + "http://c2pa.org/digitalsourcetype/empty"}]}}]} + + def impostor(*args): + seen.append("IMPOSTOR") + return -1 + + def close_and_churn(): + holder["resource"].close() + if "signer" in holder: + holder["signer"].close() + gc.collect() + churn.extend(bindings.SignerCallback(impostor) for _ in range(512)) + + def claim(data): + seen.append("claim") + if kind.startswith("builder"): + if kind == "builder_context_callback_close": + holder["context"].close() + assert not holder["context"].is_valid + close_and_churn() + return _fixture_claim_signature(data) + + signer = c2pa.Signer.from_callback(claim, c2pa.C2paSigningAlg.ES256, certs, None) + if kind.startswith("builder"): + if kind == "builder_explicit": + builder = c2pa.Builder(manifest) + holder["signer"] = signer + else: + context = c2pa.Context(signer=signer) + builder = c2pa.Builder(manifest, context=context) + if kind == "builder_context_callback_close": + holder["context"] = context + else: + context.close() + holder["resource"] = builder + source = io.BytesIO((FIXTURES / "A.jpg").read_bytes()) + dest = io.BytesIO() + result = (builder.sign(signer, "image/jpeg", source, dest) + if kind == "builder_explicit" else builder.sign("image/jpeg", source, dest)) + assert result and dest.getvalue() and not builder.is_valid + signer.close() + else: + config = json.loads((Path(__file__).parent / "trust_config_test_settings.json").read_text()) + context = c2pa.Context.from_dict(config, signer=signer) + key = ed25519.Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32) + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + cose = cbor2.dumps({1: 1, 2: b"kid", 3: -8, -1: 6, -2: public}, canonical=True) + + def vsi(*args): + seen.append("binding") + close_and_churn() + return key.sign(args[-1]) + + if kind == "trusted": + resource = c2pa.TrustedVsiSession( + context, manifest, "ed25519", cose, b"kid", 1, + "2026-09-10T00:00:00Z", 86400, vsi, mode="expert_sig_structure", + reservation_nonce="0" * 32, signing_time_unix_seconds=IAT) + resource.reserve_init_uuid() + else: + resource = c2pa.LiveVideoVsiSession.from_callback( + manifest, context, vsi, "ed25519", cose, + b"kid", 1, "2026-09-10T00:00:00Z", 86400, clock=lambda: IAT) + holder["resource"] = resource + context.close() + if kind == "trusted": + result = resource.finalize_init_uuid(c2pa.trusted_vsi_hash_template(KIND.INIT_HASH)) + else: + result = resource.sign_init_segment(_unsigned_init()) + assert result and not resource.is_valid + assert seen[0] == "binding" + resource.close() + assert "claim" in seen and "IMPOSTOR" not in seen + print("actual native reentrant close:", kind, seen) + + +def _check_native_reentrant_close_subprocess(kind): + result = subprocess.run( + [sys.executable, "-c", "from tests.test_trusted_vsi_api import _actual_native_reentrant_close; " + f"_actual_native_reentrant_close({kind!r})"], + cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stdout + result.stderr + assert "actual native reentrant close:" in result.stdout + + +@pytest.mark.parametrize("kind", ["trusted", "live"]) +def test_paired_reentrant_close_native_subprocess(paired_native, kind): + _check_native_reentrant_close_subprocess(kind) + + +@pytest.mark.parametrize("kind", ["builder_context", "builder_explicit", "builder_context_callback_close"]) +def test_builder_reentrant_close_native_subprocess(kind): + _check_native_reentrant_close_subprocess(kind) + + +@pytest.mark.parametrize("kind", ["ordinary", "fragmented", "ladder"]) +@pytest.mark.parametrize("interrupt", [False, True]) +def test_builder_borrowed_signer_reentrant_close_and_automatic_close(monkeypatch, tmp_path, kind, interrupt): + """Scripted native call: both borrowed handles survive callback close.""" + error = SystemExit(7) + holder = {} + + def claim(data): + holder["builder"].close() + holder["signer"].close() + if interrupt: + raise error + return _fixture_claim_signature(data) + + signer = c2pa.Signer.from_callback(claim, c2pa.C2paSigningAlg.ES256, + (FIXTURES / "es256_certs.pem").read_bytes(), None) + builder = c2pa.Builder({}) + holder.update(builder=builder, signer=signer) + builder_handle, signer_handle = builder._handle, signer._handle + freed = [] + real_free = bindings.ManagedResource._free_native_ptr + + def free(ptr): + freed.append(ctypes.cast(ptr, ctypes.c_void_p).value) + return real_free(ptr) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", staticmethod(free)) + callback_ref = weakref.ref(signer._callback_cb) + + def sign(*args): + tbs = (ctypes.c_ubyte * 3)(1, 2, 3) + signature = (ctypes.c_ubyte * 2048)() + result = signer._callback_cb(None, tbs, 3, signature, len(signature)) + gc.collect() + assert builder._handle is builder_handle and signer._handle is signer_handle + assert not builder.is_valid and not signer.is_valid + assert freed == [] and callback_ref() is not None + if interrupt: + assert result == -1 + return -1 + assert result > 0 + # Failure without a callback error is sufficient to exercise cleanup; + # no fake byte allocation should reach the real native deallocator. + return -1 + + monkeypatch.setattr(bindings, "_read_native_error", lambda: "Other: scripted signing failure") + if kind == "ordinary": + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign", sign) + operation = lambda: builder.sign(signer, "image/jpeg", io.BytesIO(b"input"), io.BytesIO()) + elif kind == "fragmented": + monkeypatch.setattr(bindings, "_FRAGMENTED_SIGN_AVAILABLE", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_fragmented", sign, raising=False) + source = tmp_path / "init.mp4" + source.write_bytes(b"input") + operation = lambda: builder.sign_fragmented(signer, source, "*.m4s", tmp_path) + else: + monkeypatch.setattr(bindings, "_HAS_SIGN_LADDER", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_ladder", sign, raising=False) + operation = lambda: builder.sign_ladder(signer, ["input.mp4"], ["output.mp4"]) + with pytest.raises(SystemExit if interrupt else c2pa.C2paError) as caught: + operation() + if interrupt: + assert caught.value is error + error.__traceback__ = None + del caught + assert builder._handle is None and signer._handle is None + for handle in (builder_handle, signer_handle): + assert freed.count(ctypes.cast(handle, ctypes.c_void_p).value) == 1 + builder.close() + signer.close() + gc.collect() + assert callback_ref() is None + + +@pytest.mark.parametrize("kind", ["ordinary", "fragmented", "ladder"]) +@pytest.mark.parametrize("when", ["preflight", "admission", "interrupted_admission"]) +def test_builder_rejects_logically_closed_signer_preflight_or_admission(monkeypatch, tmp_path, kind, when): + builder = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + signer = bindings.Signer._wrap_native_handle(ctypes.pointer(bindings.C2paSigner())) + handle = signer._handle + freed = Mock(return_value=0) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + native = Mock(side_effect=AssertionError("closed Signer reached native signing")) + interruption = KeyboardInterrupt("admission interrupted") + if kind == "ordinary": + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign", native) + operation = lambda: builder.sign(signer, "image/jpeg", io.BytesIO(b"input"), io.BytesIO()) + elif kind == "fragmented": + monkeypatch.setattr(bindings, "_FRAGMENTED_SIGN_AVAILABLE", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_fragmented", native, raising=False) + source = tmp_path / "init.mp4" + source.write_bytes(b"input") + operation = lambda: builder.sign_fragmented(signer, source, "*.m4s", tmp_path) + else: + monkeypatch.setattr(bindings, "_HAS_SIGN_LADDER", True) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign_ladder", native, raising=False) + operation = lambda: builder.sign_ladder(signer, ["input.mp4"], ["output.mp4"]) + try: + # A simulated close between preflight and call admission, not permission + # to run concurrent native operations on the borrowed Signer. + with signer._native_call(): + if when == "preflight": + signer.close() + else: + admit = signer._native_call + + def close_then_admit(): + signer.close() + if when == "interrupted_admission": + raise interruption + return admit() + monkeypatch.setattr(signer, "_native_call", close_then_admit) + expected = KeyboardInterrupt if when == "interrupted_admission" else c2pa.C2paError + with pytest.raises(expected) as caught: + operation() + if when == "interrupted_admission": + assert caught.value is interruption + else: + assert "closed" in str(caught.value) + native.assert_not_called() + assert not signer.is_valid and signer._handle is handle + assert builder.is_valid is (when == "preflight") + assert freed.call_count == (0 if when == "preflight" else 1) + assert signer._handle is None + finally: + builder.close() + signer.close() + assert freed.call_count == 2 + + +def test_builder_bad_format_preflight_keeps_builder_and_signer_active(monkeypatch): + builder = bindings.Builder._wrap_native_handle(ctypes.pointer(bindings.C2paBuilder())) + signer = bindings.Signer._wrap_native_handle(ctypes.pointer(bindings.C2paSigner())) + freed = Mock(return_value=0) + monkeypatch.setattr(bindings.ManagedResource, "_free_native_ptr", freed) + native = Mock(side_effect=AssertionError("bad format reached native signing")) + monkeypatch.setattr(bindings._lib, "c2pa_builder_sign", native) + try: + with pytest.raises(c2pa.C2paError.NotSupported): + builder.sign(signer, "", io.BytesIO(b"input"), io.BytesIO()) + assert builder.is_valid and signer.is_valid + assert builder._active_calls == signer._active_calls == 0 + native.assert_not_called() + freed.assert_not_called() + finally: + builder.close() + signer.close() diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index d95b3f29..f4df4c03 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -91,3 +91,14 @@ def test_functional_installed_qualification_removes_source_overrides(): "test_sign_ladder.py", "test_native_ownership.py", "test_native_ownership_opaque.py"): assert f'"{name}"' in source + + +def test_ci_keeps_existing_trusted_author_and_label_gate(): + workflow = (ROOT / ".github/workflows/build.yml").read_text() + gate = workflow.split(" trusted-vsi-paired:", 1)[1].split(" read-version:", 1)[0] + assert "author_association == 'COLLABORATOR'" in gate + assert "author_association == 'MEMBER'" in gate + assert "contains(github.event.pull_request.labels.*.name, 'safe to test')" in gate + assert "pull_request_target" not in workflow + assert " - labeled" in workflow + assert "secrets: inherit" not in gate From 97c820efde96af75c900c02e05457aab1ba2e1e1 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Wed, 7 Oct 2026 22:23:24 +0200 Subject: [PATCH 39/46] fix: qualify revision-3 trusted VSI and native init globs --- .github/workflows/trusted-vsi-paired.yml | 9 +- .../trusted-vsi-review-verification.md | 395 ++++++++++++++++++ docs/trusted-vsi-python-contract.md | 55 ++- docs/usage.md | 41 +- src/c2pa/c2pa.py | 82 ++-- tests/test_fragmented_files.py | 162 ++++++- tests/test_trusted_vsi_api.py | 193 ++++++++- 7 files changed, 874 insertions(+), 63 deletions(-) diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index 7c4e5bcd..cfdd83d7 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -50,12 +50,9 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - # Reviewed consolidated trusted VSI native with ContentAuth main 69907b5a - # (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 + # Integrated native review fixes plus the explicit revision-3 gate. + # Qualification-only source pairing; never an immutable dev5 input. + ref: a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/docs/archive/trusted-vsi-review-verification.md b/docs/archive/trusted-vsi-review-verification.md index f9fe5c78..10b63325 100644 --- a/docs/archive/trusted-vsi-review-verification.md +++ b/docs/archive/trusted-vsi-review-verification.md @@ -5,6 +5,15 @@ merge, repin, label or dispatch CI. See the current [Python contract](../trusted-vsi-python-contract.md) for the deliberate integration hold. Historical counts below precede the subsequent review follow-ups. +The revision-3 gate was added after the Python review merge `71d7cf9` on +`qualification/trusted-vsi-integrated-review`. Earlier results below predate that +gate: neither SDK `0.92.0-dev` nor mask 63 identifies a trusted-VSI contract. +The preserved step2 cdylib `149f4b25...` and older workflow pairing lack the new +stateless revision probe and now fail closed before operational ABI binding. +References below to a deferred step3 probe describe the historical review, not +the current Python contract. Final committed-native/hosted qualification remains +pending; the integrated local revision-3 qualification is recorded below. + ## Earlier Pairings The former product-contract status described qualification against the @@ -206,3 +215,389 @@ the same selection prefixed by `not paired and (...)` passed **15 / deselected 49 subtests** again. These focused selections overlap prior evidence; their counts must not be summed as unique qualification. Native integration/repin, the step3 revision probe and hosted qualification remain pending. + +## Revision Gate Stock Checks + +After merge `71d7cf9`, the integrated Python worktree adds the exact revision-3 +gate. No native build was performed; the preserved step2 cdylib lacks the probe, +so real paired tests await the native agent's final library handoff. The workflow +pin, build helper and build metadata are unchanged. No commit, push, repin, CI +label or dispatch was performed for this gate. + +Using the official stock `0.91.0` library at +`/tmp/opencode-v1.18.29/opencode/python-review-stock/lib/libc2pa_c.so`, +`C2PA_SOURCE_BUILD_VERSION=0.91.0`, `PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=src`, +and all four required-capability flags unset: + +```sh +python3 -m pytest -q -p no:cacheprovider --basetemp=/tmp/opencode-v1.18.29/opencode/python-contract-revision-stock-20261007 tests/test_trusted_vsi_api.py tests/test_sign_ladder.py tests/test_fragmented_files.py tests/test_native_ownership.py tests/test_trusted_vsi_build.py -k 'not paired' -ra +python3 -m pytest -q -p no:cacheprovider tests/test_unit_tests.py::TestManagedResourceObjects tests/test_unit_tests.py::TestManagedResourceLifecycle tests/test_unit_tests_threaded.py::TestManagedResourceForkGuard -ra +``` + +Results: **208 passed / 19 expected stock capability skips / 70 deselected**; +**101 passed / 49 subtests passed**. The trusted-VSI-only non-paired selection +passed **133 / four expected dynamic-registration skips / 70 deselected** and +overlaps the broader command. Mocked raw-loader subprocesses verify that missing +or non-3 revisions cannot bind or call operational trusted ABI despite matching +SDK/symbol/mask claims. They also cover wrong SDK versions, missing symbols, +partial/unknown masks, exact revision-3 probe ordering, early constructor/helper +rejection and independent ordinary/legacy callback binding. Both required flags +are tested to make paired-fixture revision diagnostics fail, never skip. These +are scripted/stock compatibility checks, not real revision-3 native qualification. + +## Integrated Revision-3 Qualification + +Local Linux/Python 3.12 qualification on 2026-10-07 used +`qualification/trusted-vsi-integrated-review`, HEAD +`71d7cf958a41be0f40c2a350b7bcdd6027b55170`, plus the uncommitted revision-gate +changes. The supplied native library was built separately from native integrated +HEAD `859a8584e13c7767e16fe12257e3ad69523ed7cf` plus the uncommitted revision +probe. No native build was performed by this Python qualification runner. +Preflight required SDK `0.92.0-dev`, exact contract revision 3, mask 63 and all +six Python availability probes true against +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so`. +Its SHA-256 was +`585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950`, checked again +at completion. This is local binary provenance, not an embedded commit identity +or a final native pin. + +The first installed run found a test-only assumption in the raw-loader matrix: +its subprocess forced the checkout's `src/` even in an installed-wheel lane, +where no source-local library is staged. `test_trusted_vsi_api.py` now selects +the parent's actual package directory and asserts the child imports that same +binding file. Production code and qualification gates were not changed. A +separate first ladder child had transient Python-startup `ENOMEM` while scanning +an unrelated editable-package path on `/mnt/c`; the identical harness passed +on retry. The original logs/artifacts were retained in +`/tmp/opencode-v1.18.29/opencode/python-revision3-qualification-20261007/`. + +Final frozen-source results, rerun in a fresh directory after the test fix: + +| Lane | Result | +|---|---| +| Actual-native focused source (trusted VSI, fragmented, ladder, ownership, opaque ownership, build tooling) | 301 passed, 11 subtests passed | +| Candidate ladder harness with native integrated `sdk/tests/fixtures` | Passed signing, shared-manifest/tamper, path-refusal and callback-lifecycle checks | +| Installed-wheel functional qualifier | 283 passed, 11 subtests passed | +| Installed-wheel release smoke | 5 passed | +| Full non-threaded source, including release smoke | 828 passed, 128 subtests passed; 39 existing deprecation warnings | +| Full threaded source | 54 passed | +| `git diff --check` | Passed | + +There were **no skips or deselections** in these final lanes. Counts overlap and +must not be summed as unique qualification. Source runs used +`PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=/src`, the explicit library +above, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and all four flags set to `1`: +`C2PA_TRUSTED_VSI_ABI_REQUIRED`, `C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED`, +`C2PA_REQUIRE_SIGN_LADDER`, `C2PA_REQUIRE_FRAGMENTED_FILES`. + +The existing build helper created qualification-only `0.37.13.dev0` artifacts +using the explicit library. The existing qualifier created a fresh isolated venv +with system dependencies and installed the wheel with `--no-deps`; the runner +set `PIP_NO_INDEX=1`. Installed identity checks proved both `c2pa` and its bundled +library reside under that venv, with no `PYTHONPATH`, `C2PA_LIBRARY_NAME`, +`LD_LIBRARY_PATH` or `DYLD_LIBRARY_PATH` overrides. The bundled library hash, +revision, mask and all six probes were checked. Installed binding bytes matched +the source binding SHA-256 below. Only after installed qualification passed did +the full source lanes enable `CASTLABS_RELEASE_SMOKE_REQUIRED=1` with +`CASTLABS_RELEASE_EXPECTED_VERSION=0.37.13.dev0`, using the venv interpreter for +matching distribution metadata while explicitly selecting the frozen source and +reviewed native library. This is not immutable dev5 release evidence. + +Final evidence root: +`/tmp/opencode-v1.18.29/opencode/python-revision3-qualification-20261007-v2/`. +It contains `status.json` (all final steps successful), `runner.log`, +`preflight.log`, `source-focused.log`, `ladder-candidate.log`, `build.log`, +`installed-focused.log`, `installed-identity.log`, `installed-release-smoke.log`, +`source-metadata.log`, `source-nonthreaded.log`, `source-threaded.log`, +`source-before.json`, `source-after.json`, `source-before.patch`, and +`dist/functional-build.json`. The before/after source snapshots were identical +through qualification; this archive section was appended afterward. + +| Qualified input/artifact | SHA-256 | +|---|---| +| Uncommitted source patch snapshot (`source-before.patch`) | `166cb29c578081a9046ac5413569a8de5ff5f3ec9646a2bbfd063a90350dcf48` | +| Source/installed `c2pa.py` | `928ffc0871bf49fd8bf805c7a672499b6ea7de4cba3844968dd5832f684925bb` | +| `tests/test_trusted_vsi_api.py` | `eadf6f0088b71285e7cd8c36caff47af8fdaac4cf0a710a68058a6ccf63eca89` | +| `dist/c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` | `1b04b29aaf921f17bf3d81899e81101f10c4958c8224e3d541e86373158eb20e` | +| `dist/c2pa_python-0.37.13.dev0.tar.gz` | `dd290b7b4aec9e0af61b1fe12980770e1d0c5f9720f832df88bcecca0a2b1b8f` | + +No build-helper metadata fact or public revision getter was added. No native +build, other-worktree edit, pin/workflow change, commit, push, CI label or +dispatch occurred. Nothing was staged. Final native commit integration/repin and +hosted qualification remain separate authorized work. + +## Segmented ABR Init-Glob Integration Fix + +On 2026-10-07 the signer integrated caller at +`src/stardustproof_c2pa_signer/manifest.py:2431` was inspected read-only. It calls +`Builder.sign_fragmented` with keywords `signer`, `asset_path` (a staged +`*/init` glob), `fragments_glob` (init-relative), and `output_dir`. Python's +literal-file/glob ban prevented this native-supported operation before FFI. +Only that filesystem preflight was removed; text/path-like, UTF-8, empty/NUL, +capability, ownership, callback and admission checks are unchanged. There is no +compatibility shim, alternate native path, per-rendition signing loop, new API, +descriptor JSON, filepath alias or source/output policy relaxation. + +### Native Contract Proof + +Read-only inspection used clean native integrated HEAD +`a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`, tree +`f8335291eb744790c10fc71000e5b6116f95e213`: + +- `c2pa_c_ffi/src/c_api.rs:2436-2551`: `reserve_fragmented_output` expands the + native init glob, joins fragment globs to each init parent, checks empty matches + and flattened-name collisions, rejects existing rendition output entries, then + exclusively reserves output directories and init files. Reservation/signing + failures can leave partial outputs; there is no automatic cleanup. +- `c2pa_c_ffi/src/c_api.rs:2554-2627`: the public six-argument C ABI accepts an + init path/glob, fragment glob and output root, passes the DA-aware borrowed + signer to `sign_fragmented_files`, then returns manifest bytes read from the + first output through the Builder Context. No multi-asset descriptor mode is + necessary. Python continues to use this exact ABI. +- `sdk/src/builder.rs:3483-3551`: `Builder::sign_fragmented_files` documents + multi-init globs and passes the expanded paths to a single Store operation. +- `sdk/src/store.rs:2982-3271`: `save_to_bmff_fragmented` validates fragment + matches, flattened layouts and source/output separation, assigns a distinct + `uniqueId`/`localId` pair to each rendition, and signs one shared manifest. + The Rust SDK allows existing non-source output inits; the C/Python ABI does + **not** inherit that overwrite allowance, because its reservation preflight + rejects existing rendition directories. +- `docs/archive/vsi-consolidation-merges.md:74-82` records integration of #17's + glob-aware, exclusively reserved FFI contract, superseding the older literal + compatibility shape. Native tests `fragmented::sign_bunny_segmented_renditions` + and `fragmented::rejects_collisions_before_writes` at + `c2pa_c_ffi/src/c_api.rs:3994` and `:4091` corroborate the documented contract; + those native tests were inspected, not rerun here. + +| Inspected native/SDK file | SHA-256 | +|---|---| +| `c2pa_c_ffi/src/c_api.rs` | `024e069363f1ff16cb3392afaf66bdeaeb5c415498d903a3ed3d440dfa3e546a` | +| `sdk/src/builder.rs` | `b4de95c42eeac9a80d251e16c30a9535bacd2abafcef9bfd68a89ecba7f66ff3` | +| `sdk/src/store.rs` | `d30fa3b8b8c02728fbd084feff28af3b809f1299f143b5a44a5ce33283c8beb2` | + +These three files are unchanged between `859a8584` and `a6d4cdcc`. The supplied +binary's earlier build provenance is `859a8584` plus the then-uncommitted +revision probe, as recorded above. The probe was subsequently committed in +`a6d4cdcc`; inspecting the current clean tree is not a claim that an embedded +version/probe attests that commit, nor an independent reconstruction of the +original build. No native source, target, library or ABI was changed in place, +and no native build or new target prefix was used. + +### Python Source Verification + +Python integrated HEAD remains `71d7cf958a41be0f40c2a350b7bcdd6027b55170`, tree +`80edbf39dcfcd504e9d1e66af7e8869d695c06f9`, plus the preexisting revision-gate +changes and this uncommitted wrapper/test delta. Existing dirty changes in +`tests/test_trusted_vsi_api.py` and `docs/trusted-vsi-python-contract.md` were +preserved; neither file was edited for this fix. Source patch fingerprint +(`git diff --binary -- src tests | sha256sum`) for this initial verification was +`30545de8d5371532fea4730143d2fbd63ac00b1bf1944251dfeb767dbd6cd52e`. + +| Tested Python source | SHA-256 | +|---|---| +| `src/c2pa/c2pa.py` | `dd46aac5c2d22927c442affcda873edfe87112f91d5cf3c9822d68b5c9dcebc6` | +| `tests/test_fragmented_files.py` | `b05942c3283f05e146c0e1d536e1af862458924903def1207299409db4fba5b9` | +| Unchanged preexisting `tests/test_trusted_vsi_api.py` | `eadf6f0088b71285e7cd8c36caff47af8fdaac4cf0a710a68058a6ccf63eca89` | + +The explicit loaded native library remains +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so`, +SHA-256 `585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950`, +checked before and after testing. Preflight confirmed the source binding path, +SDK `0.92.0-dev`, revision 3, mask 63, all six trusted-VSI probes, fragmented +capability and ladder capability. Tests used Python 3.12 and: + +```sh +export PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src +export C2PA_LIBRARY_NAME=/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so +export C2PA_SOURCE_BUILD_VERSION=0.92.0-dev +export C2PA_TRUSTED_VSI_ABI_REQUIRED=1 C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED=1 +export C2PA_REQUIRE_SIGN_LADDER=1 C2PA_REQUIRE_FRAGMENTED_FILES=1 +python3 -m pytest -q -p no:cacheprovider tests/test_fragmented_files.py -ra +python3 -m pytest -q -p no:cacheprovider tests/test_trusted_vsi_api.py tests/test_fragmented_files.py tests/test_sign_ladder.py tests/test_native_ownership.py tests/test_native_ownership_opaque.py tests/test_trusted_vsi_build.py -ra +python3 -m pytest -q -p no:cacheprovider --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 -ra +python3 -m pytest -q -p no:cacheprovider tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 -ra +``` + +| Source lane | Result | +|---|---| +| Fragmented file APIs | 14 passed, 18 subtests passed | +| Focused native/ownership/build suite | 304 passed, 18 subtests passed, no skips | +| Full non-threaded source | 826 passed, 135 subtests passed, one existing installed-release-smoke module skip, 39 deprecation warnings | +| Full threaded source | 54 passed | + +The final focused run (26.83s) includes an added explicit nonempty-manifest +assertion, preventing a vacuous empty-byte containment check. The full runs and +standalone fragmented run preceded that one-line test strengthening: their +fragmented test file SHA-256 was +`850aacae08cac6ebdc021e440a12c4382a749d8a02ab04e472b50bee6e2ba533`, with source +patch fingerprint `f647e32a5cc56742ad7dce598ee972fc79a8ae5644e6ee68895fd6601a68cc8c`. +The production binding and native binary were identical in all runs. + +The first full non-threaded/threaded attempts exceeded the shell's 120-second +limit and were terminated, not counted as passes. Identical commands rerun with +a 600-second shell limit completed in 377.01s and 179.62s. No test expectations +or unrelated gate-test source were changed to obtain these results. + +The new native-backed fragmented tests exercise the unchanged keyword interface: +two synthetic renditions derived from the committed DASH fixtures, identical +returned JUMBF bytes in both signed inits, distinct fragment selectors `(1,1)` / +`(2,2)` matching the two maps in the shared manifest, successful validation of +both renditions, rejection of changed media retaining the wrong selector, +unmodified sources, preservation of an existing destination and rejection of +an existing source-directory output at the C ABI's existing-directory preflight +(not an exercise of the SDK's filesystem identity guard), malformed init/fragment +globs, and empty matches. +They establish wrapper/native multi-init behavior, not encoding/playback of a +real ABR ladder or the signer's full regression gate. Documentation now describes +the segmented shape and distinguishes it from single-file `sign_ladder`. + +This delta is source-qualified only. No wheel/sdist rebuild, installed-wheel +qualification, release-version/pin change, signer/native/B edit, commit, push, +repin, CI label or dispatch occurred; nothing was staged. Earlier wheel results +above do not qualify this new source delta. Fresh main-agent code review and +signer segmented-ABR regression remain the handoff; compatibility with the +released `0.31+stardustproof6` library was not tested here. + +### Minor Review Follow-Up + +The 2026-10-07 follow-up changed only four files in this Python worktree: +`tests/test_fragmented_files.py`, the `Builder.sign_fragmented` docstring in +`src/c2pa/c2pa.py`, `docs/usage.md`, and this archive. Production runtime behavior +and all preexisting revision-gate hunks remain unchanged; nothing was staged. + +`cbor2` is no longer a module-level dependency of the fragmented tests. Only the +Merkle-selector test calls `pytest.importorskip("cbor2")`, lazily; missing CBOR +skips that test in optional mode but produces a clear `pytest.fail` with the +`requirements-dev.txt` install instruction when +`C2PA_REQUIRE_FRAGMENTED_FILES=1`. The fixture-only inline walker now explicitly +documents its nonzero 32-bit box-size/8-byte-header constraint; it is not a +general BMFF parser. The existing source-directory test is named/commented to +identify its earlier C ABI existing-directory rejection, not imply SDK identity +guard coverage. + +The docstring and usage documentation scope init-glob and exclusive-output +guarantees to the paired glob-aware Castlabs `0.92.0-dev` native including #17. +Export presence alone does not prove those semantics in older lineages. Older +releases can contain backports (including stable Castlabs `0.80`); no blanket +pre-`0.92` rejection, fallback or new capability/version gate was added. Named +init parent directories and the `C2paError.Encoding` exception are documented. + +Using the same source environment/required flags and unchanged SHA-256 +`585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950` native library +above, `pytest -q -p no:cacheprovider tests/test_fragmented_files.py -ra` passed +**14 tests / 18 subtests** in 0.73s. + +A separate source-only missing-dependency simulation used an import finder to +raise `ModuleNotFoundError` for `cbor2` and `pytest`, then imported the module +with `runpy.run_path` successfully without either dependency. After unblocking +pytest (but retaining the CBOR block), with `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`: + +- `--collect-only tests/test_fragmented_files.py`: **14 tests collected**. +- The module excluding the Merkle-selector test, with fragmented required: + **13 passed / 18 subtests / one deselected**. +- The Merkle-selector test with fragmented required unset: **one expected skip**. +- The same test with `C2PA_REQUIRE_FRAGMENTED_FILES=1`: **one expected failure**, + with the missing-dev-dependency install diagnostic. The outer simulation + asserted pytest's exit code was 1 and completed successfully; this is negative + coverage, not a failed qualification lane. + +| Final follow-up source | SHA-256 | +|---|---| +| `src/c2pa/c2pa.py` (docstring-only follow-up) | `1f9096ff4dcc0d66835d177c1b6fffc4c9f54df6b03e46b2b165d9cabea0673f` | +| `tests/test_fragmented_files.py` | `2f9615d71777e8586d2a58e04745f0029a66d8efcd3fb9aad247e39c28f61a1a` | +| `git diff --binary -- src tests` | `5dbfc02dbf128803727848a9d26b8c304d3854fb03d5e49554d3a60fdb8bd743` | + +Earlier full-suite counts apply to the source snapshots identified above, not a +rerun of this follow-up. No installed-wheel qualification or rebuild is claimed; +main will rebuild/qualify the final source separately. No native/signer/B edits, +builds, commits, pushes, repins, CI changes or staging occurred. + +## Final Glob-Aware Wheel Qualification + +The final local qualification on 2026-10-07 supersedes the pre-glob installed +coverage above. In particular, the older `1b04b29a...` wheel does **not** qualify +the current wrapper or lazy-CBOR test follow-up. This run rebuilt both +qualification-only `0.37.13.dev0` artifacts from the latest frozen source, +including the glob-aware wrapper, final docstring, lazy `cbor2` fixture and +main's final full native workflow pin +`a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`. + +Python HEAD remained `71d7cf958a41be0f40c2a350b7bcdd6027b55170` plus uncommitted +integration changes. The supplied same-production native library remained +`/tmp/opencode-v1.18.29/opencode/trusted-review-native-target/debug/libc2pa_c.so`, +SHA-256 `585464e737b5affd580bdf203b53920cff77075cf89e92b81c3661a9918b1950`. +Preflight and installed checks required SDK `0.92.0-dev`, exact contract revision +3, mask 63 and all six trusted-VSI probes true. The native production-tree/pin +handoff is provenance supplied by main; the runtime probe is not an embedded +commit attestation. No native build or target modification was performed here. + +The existing build/qualifier scripts were used unchanged, offline with +`PIP_NO_INDEX=1`, a fresh output directory and a fresh system-dependency venv. +Both the source and installed dependency metadata reported `cbor2==5.9.0`. +The wheel's `c2pa/c2pa.py` bytes were hashed and compared with the frozen source +before qualification. Installed identity subsequently proved the imported +package, binding and bundled library reside under the fresh venv, with no +`PYTHONPATH`, `C2PA_LIBRARY_NAME`, `LD_LIBRARY_PATH` or `DYLD_LIBRARY_PATH` +overrides. Installed binding bytes matched that same frozen source hash. + +| Final lane | Result | +|---|---| +| Installed functional qualifier (including current fragmented ABI tests) | 286 passed, 18 subtests passed | +| Explicit installed two-rendition glob/selector and destination-refusal cases | 2 passed, 2 subtests passed | +| Installed release smoke | 5 passed | +| Workflow paired/dev5 isolation test, run separately with required flags | 1 passed | +| Full non-threaded source with release smoke enabled | 831 passed, 135 subtests passed; 39 existing deprecation warnings | +| Full threaded source | 54 passed | +| `git diff --check` | Passed | + +These functional lanes had **zero skips and zero deselections**. Counts overlap +and must not be summed as unique qualification. Source runs used +`PYTHONDONTWRITEBYTECODE=1`, `PYTHONPATH=/src`, the explicit library +above, `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev`, and all four required flags set to +`1`: `C2PA_TRUSTED_VSI_ABI_REQUIRED`, `C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED`, +`C2PA_REQUIRE_SIGN_LADDER`, `C2PA_REQUIRE_FRAGMENTED_FILES`. Only after fresh +installed identity/qualification passed did full source runs enable +`CASTLABS_RELEASE_SMOKE_REQUIRED=1` and +`CASTLABS_RELEASE_EXPECTED_VERSION=0.37.13.dev0`, using the qualified venv's +matching metadata while explicitly loading the frozen source/native input. +The full jobs ran in separate processes under a detached, logged runner. + +A separate installed-wheel missing-CBOR simulation blocked `cbor2` imports only +inside fresh child processes; it did not uninstall shared dependencies or alter +the wheel. Package-location checks proved these children used the installed +wheel with no source/library overrides. With plugin autoload disabled: + +- Fragmented-module collection succeeded: **14 tests collected**, pytest exit 0. +- Optional Merkle-selector execution produced **one intentional skip**, exit 0. +- Required Merkle-selector execution produced **one expected failure**, exit 1, + with the `C2PA_REQUIRE_FRAGMENTED_FILES=1 requires cbor2` diagnostic. The outer + harness asserted that exit code and passed. This is negative dependency + coverage, not a failed or skipped functional qualification lane. + +Evidence/artifact root: +`/tmp/opencode-v1.18.29/opencode/python-final-glob-qualification-20261007/`. +Logs include `preflight.log`, `build.log`, `installed-focused.log`, +`installed-identity.log`, `installed-glob-abi.log`, `installed-release-smoke.log`, +`cbor-absent-collection.log`, `cbor-absent-normal.log`, +`cbor-absent-required.log`, `source-identity.log`, `workflow-isolation.log`, +`source-nonthreaded.log`, `source-threaded.log` and `runner.log`. +`status.json` reports all steps passed. `wheel-source-identity.json` and +`dist/functional-build.json` record byte/artifact identities. Before/after +snapshots in `source-before.json` and `source-after.json` are identical, excluding +only this informational archive file; staged diffs were empty. This final section +was appended afterward. No production, test, helper, workflow or pin edits were +made during this qualification. + +| Final input/artifact | SHA-256 | +|---|---| +| Frozen source patch excluding this archive (`source-before.patch`) | `8d6124397272a18ad23df89444a7e4a5c0a4705a952796dd1f63b91fc0207691` | +| Source, wheel and installed `c2pa.py` | `1f9096ff4dcc0d66835d177c1b6fffc4c9f54df6b03e46b2b165d9cabea0673f` | +| `tests/test_fragmented_files.py` | `2f9615d71777e8586d2a58e04745f0029a66d8efcd3fb9aad247e39c28f61a1a` | +| `dist/c2pa_python-0.37.13.dev0-py3-none-linux_x86_64.whl` | `847666afc16fd448e0e2bbf8fc408134f3ad87dea3486c63bc45680edb80f08b` | +| `dist/c2pa_python-0.37.13.dev0.tar.gz` | `0f58c7bbdc4313c69ab7abb91ead30893308126bd52cb0d714d718f3b1066e36` | + +Only this archive verification record was updated. Old artifacts/logs and +immutable dev5 inputs/evidence were preserved. Nothing was staged or committed; +no push, CI label/dispatch, publication, other-worktree edit, native build or +native-target/pin modification occurred. This is local Linux qualification, not +hosted Linux/Windows CI evidence. diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 9b8b6595..7d308ced 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,26 +1,41 @@ # Trusted VSI Python Contract -Status: unreleased PR4 review changes requiring the native step2 contract -(state version 3 and typed expert VSI payloads), following `c2pa-rs` +Status: integrated, unreleased PR4 review changes requiring native trusted-VSI +contract revision 3 (state version 3 and typed expert VSI payloads), following +`c2pa-rs` `docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev0`; -immutable dev5 release inputs and artifacts are unchanged. No native pin is -changed here. Select a reviewed local library with `C2PA_LIBRARY_NAME` for -integration tests. Version `0.92.0-dev` and mask 63 do not establish revision -identity; the explicit native revision probe is deferred step3 work, not a new -API in step1/2. Hosted qualification is on hold until final native integration. +immutable dev5 release inputs and artifacts are unchanged. The paired source +workflow pins native `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c` for the +integrated revision-3 qualification batch. Select a reviewed local library with +`C2PA_LIBRARY_NAME` for integration tests. Version `0.92.0-dev` and mask 63 do not establish contract +identity; Python now requires the explicit native contract-revision probe. +Local source and installed-wheel checks passed; hosted results must be recorded +against the final source SHAs before claiming Linux/Windows qualification. Prior results and machine-local provenance are archived in [review verification](archive/trusted-vsi-review-verification.md). ## Availability All `has_live_video_trusted_vsi_*()` probes return `True` only when the loaded -library exports every symbol in the contract's C ABI, reports native version -`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 +library exports every symbol in the contract's C ABI, reports the exact SDK +release token `c2pa-rs/0.92.0-dev`, returns +`c2pa_live_video_trusted_vsi_contract_revision() == 3`, and returns +`c2pa_live_video_trusted_vsi_capabilities() == 63`. The revision probe has the +safe stateless signature `uint32_t c2pa_live_video_trusted_vsi_contract_revision(void)`. +Only stateless probes are bound before these checks; operational trusted-VSI +ctypes signatures are bound only after every check passes. A missing revision +probe, revisions 0/1/2/4 (or any revision other than exactly 3), missing symbols, +wrong SDK version or any other mask disables all six availability probes. +There is no trial construction, status/state inspection or fallback to infer +compatibility. The SDK version is a release-line check, not a native commit or +generic SDK identity attestation. Old scaffold symbol layouts are never bound. +When unavailable, the constructor, `from_callback`, `validate_trusted_vsi_input` and `trusted_vsi_hash_template` raise `C2paError.NotSupported` before inspecting -arguments, invoking callbacks, touching native code or managed-resource state. +arguments, allocating buffers, registering/invoking callbacks, touching +operational native code or managed-resource state. Ordinary SDK signing and +legacy complete-buffer `LiveVideoVsiSession` callback bindings remain independent; +they are not feature fallbacks for trusted VSI. The observed revision is internal, +not a new public Python availability API. ## Session @@ -93,9 +108,10 @@ INIT_HASH=0, SIG_STRUCTURE=1, MEDIA_HASH=2. `blocked` reports an external-signing failure, independently of exhaustion. The C V1 layout has `blocked: bool` at offset 18; `exhaustion_reason: uint32` remains at offset 20 (24-byte size, 4-byte alignment). - This native field already exists in the current `6b506352` pairing, including + This native field already exists in the older workflow pin `6b506352`, including its `blocked: rust.blocked()` conversion; the Python bridge now exposes it. - The integration hold is not a known missing-`blocked` ABI problem in that pin. + The integration hold is not a known missing-`blocked` ABI problem in that pin; + the new revision gate nevertheless rejects that older library. Removed without aliases: `TrustedVsiPrehashedSession`, `TrustedVsiSignResult`, `TrustedVsiInitUuidReservation`, `recover(...)`, and the private Python gate. @@ -247,8 +263,11 @@ skip only in ad-hoc local runs. `.github/workflows/trusted-vsi-paired.yml` builds the native with Rust 1.96.0 from the reviewed consolidated commit `6b506352800c8225cf5564ce99c726aaa71039f4` (ContentAuth main `69907b5a` merged plus CI-only fixes; previously `203dc08d`, before that `5c186c07`). That older -pin emits state version 2, so the required version-3 tests deliberately fail -against it. No tests or capability gates are weakened to accommodate this hold. +pin emits state version 2 and lacks the required revision probe, so Python +disables trusted VSI before binding its operational ABI. Required qualification +fails with missing-symbol/revision diagnostics rather than skipping. The preserved +step2 review cdylib (`149f4b25...`) also lacks the new probe and cannot qualify +this gate. No tests or capability gates are weakened to accommodate this hold. Final native integration/qualification and a full-SHA repin belong to a separate authorized step, not these Python-only review changes. @@ -264,7 +283,7 @@ broadened here (in particular no speculative OWNER exception or fork secrets). Do NOT apply the maintainer `safe to test` label or run paired CI expecting it to pass until the final native revision is integrated and pinned in the separate authorized integration step. The unchanged older native pin and the new -required state-v3 tests are intentionally incompatible. Only after integration +revision-3 gate are intentionally incompatible. Only after integration should a maintainer review the head, apply the existing label to trigger the `labeled` event, and confirm required jobs actually ran. This task does not post labels, dispatch CI, or repin. A green workflow with skipped test jobs is not diff --git a/docs/usage.md b/docs/usage.md index c3b93050..ddadae57 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -344,6 +344,15 @@ Recovery remains artifact-driven and does not invoke the configured clock. The Castlabs native fork can sign and validate DASH/HLS-style fragmented BMFF file sets. Check the native capability before using these file-based APIs: +The glob-aware signing and exclusive-output guarantees below are verified with +the paired Castlabs `0.92.0-dev` native including #17. `has_fragmented_files()` +checks export presence, not that semantic contract. Older native lineages may +accept only literal init paths or have different overwrite semantics; older +Castlabs builds (including stable `0.80` releases) may contain backports. No +blanket pre-`0.92` rejection or fallback is imposed by this wrapper. Check the +exact native implementation/provenance before relying on these guarantees with +a different library. + ```py from pathlib import Path @@ -371,7 +380,37 @@ with Reader.from_fragmented_files( print(reader.json()) ``` -`asset_path` must be one literal existing initialization-segment file; only `fragments_glob` is a glob. A native sign attempt closes the single-use `Builder` but borrows and leaves the explicit `Signer` active. The returned manifest buffer is copied into Python-owned `bytes` and released natively. +`asset_path` is an init path or native glob matching one or more initialization +segments. For segmented ABR, use e.g. `asset_path="renditions/*/init.mp4"` with +`fragments_glob="segment-*.m4s"`. The native operation signs all matched +renditions with **one shared manifest**, with a separate Merkle map and media +selectors for each rendition. `fragments_glob` is evaluated relative to each +init's parent, including subdirectories; output flattens fragment subdirectories +under `//`. Init parent directory names must be +distinct and each matched init must have a named parent directory (e.g. +`video/init.mp4`, not bare `init.mp4`). This input shape differs from +`Builder.sign_ladder`, which accepts single-file fragmented MP4 renditions, not +separate inits and media segments. + +Paths use native glob syntax even for a single init; escape literal +metacharacters with bracket expressions (e.g. `[[]` for `[`). Matched init parent +paths must not contain literal glob metacharacters, because native reuses them +in fragment globs. Native rejects malformed or empty-match globs, output name +collisions, and existing rendition output directories before writing. Use fresh, +exclusively owned destinations outside input globs, and do not modify inputs or +outputs concurrently. Reservation or signing errors can leave empty or partial +outputs; remove only newly created files you positively own, never sources or +preexisting destinations. The C ABI reserves directories/inits exclusively; the +lower-level Rust SDK's allowance for existing non-source output inits is **not** +the Python/C ABI contract. + +Python validates text/path-like input types, UTF-8, nonempty paths and NULs; +invalid UTF-8 raises `C2paError.Encoding`, while empty/NUL paths raise `ValueError`. +Native validates glob syntax, matches and layouts. A native sign attempt +(including native validation failure) closes the single-use `Builder` but borrows +and leaves the explicit `Signer` active. Python preflight failures remain +non-consuming. The returned shared manifest buffer is copied into Python-owned +`bytes` and released natively. Pass an active `Context` to `Reader.from_fragmented_files()` to use explicit verification and trust settings. Omitting `context` preserves the legacy thread-local settings behavior. The reader requires at least one explicit fragment path and retains the supplied Context and callback references until the reader closes. diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 41a593f2..986f6199 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -130,7 +130,11 @@ # 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. +# surface fails closed unless the exact contract revision, version, mask and +# complete symbol set match. Only stateless probes are safe before that gate. +_TRUSTED_VSI_CONTRACT_REVISION_FUNCTIONS = ( + 'c2pa_live_video_trusted_vsi_contract_revision', +) _TRUSTED_VSI_CAPABILITIES_FUNCTIONS = ( 'c2pa_live_video_trusted_vsi_capabilities', ) @@ -163,7 +167,8 @@ 'c2pa_live_video_trusted_vsi_hash_template', ) _TRUSTED_VSI_FUNCTIONS = ( - _TRUSTED_VSI_CAPABILITIES_FUNCTIONS + _TRUSTED_VSI_CREATE_FUNCTIONS + _TRUSTED_VSI_CONTRACT_REVISION_FUNCTIONS + + _TRUSTED_VSI_CAPABILITIES_FUNCTIONS + _TRUSTED_VSI_CREATE_FUNCTIONS + _TRUSTED_VSI_SPLIT_INIT_FUNCTIONS + _TRUSTED_VSI_EXPERT_SIG_STRUCTURE_FUNCTIONS + _TRUSTED_VSI_COMPOSED_MEDIA_FUNCTIONS + _TRUSTED_VSI_RECOVERY_FUNCTIONS + _TRUSTED_VSI_STATUS_FUNCTIONS + _TRUSTED_VSI_PREFLIGHT_FUNCTIONS @@ -176,6 +181,7 @@ _TRUSTED_VSI_CAP_SIGNING_CONTEXT_V1 = 1 << 4 _TRUSTED_VSI_CAP_FULL_UINT32_SEQUENCE = 1 << 5 _TRUSTED_VSI_REQUIRED_CAPABILITIES = 63 +_TRUSTED_VSI_REQUIRED_CONTRACT_REVISION = 3 # Castlabs dynamic-assertion extension. Keep this optional so the package can # still be imported with standard upstream native libraries. @@ -296,15 +302,16 @@ def _validate_library_exports(lib): hasattr(_lib, name) for name in _TRUSTED_VSI_STATUS_FUNCTIONS ) _TRUSTED_VSI_CAPABILITIES = 0 +_TRUSTED_VSI_CONTRACT_REVISION = 0 _TRUSTED_VSI_ABI_AVAILABLE = all(hasattr(_lib, name) for name in _TRUSTED_VSI_FUNCTIONS) _TRUSTED_VSI_VERSION_MATCHES = False -# Exact paired native version for the functional trusted-VSI ABI. Qualification -# pins the native commit separately; this is deliberately not a range. +# Required SDK release line, not contract identity. The revision probe gates the +# trusted-VSI contract; qualification pins the native commit separately. _TRUSTED_VSI_NATIVE_VERSION = "0.92.0-dev" def _trusted_vsi_version_matches(native_version: bytes) -> bool: - """Return whether c2pa_version() names exactly the paired c2pa-rs build.""" + """Return whether c2pa_version() names the required SDK release line.""" expected = b"c2pa-rs/" + _TRUSTED_VSI_NATIVE_VERSION.encode("ascii") return expected in native_version.split() @@ -1479,14 +1486,24 @@ def _setup_function(func, argtypes, restype=None): ctypes.c_int ) -# 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: +# Bind/call only safe, stateless probes before admitting the operational ABI. +# Older contracts reused symbols and SDK versions with incompatible semantics. +if hasattr(_lib, 'c2pa_live_video_trusted_vsi_contract_revision'): + _setup_function( + _lib.c2pa_live_video_trusted_vsi_contract_revision, + [], + ctypes.c_uint32, + ) + _TRUSTED_VSI_CONTRACT_REVISION = int( + _lib.c2pa_live_video_trusted_vsi_contract_revision()) +if _TRUSTED_VSI_CAPABILITIES_FUNCTION_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_capabilities, [], ctypes.c_uint64, ) +if (_TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_CONTRACT_REVISION == _TRUSTED_VSI_REQUIRED_CONTRACT_REVISION): native_version_ptr = _lib.c2pa_version() if native_version_ptr: try: @@ -1496,6 +1513,13 @@ def _setup_function(func, argtypes, restype=None): _lib.c2pa_string_free(native_version_ptr) if _TRUSTED_VSI_VERSION_MATCHES: _TRUSTED_VSI_CAPABILITIES = int(_lib.c2pa_live_video_trusted_vsi_capabilities()) +_TRUSTED_VSI_ABI_AVAILABLE = ( + _TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_CONTRACT_REVISION == _TRUSTED_VSI_REQUIRED_CONTRACT_REVISION + and _TRUSTED_VSI_VERSION_MATCHES + and _TRUSTED_VSI_CAPABILITIES == _TRUSTED_VSI_REQUIRED_CAPABILITIES +) +if _TRUSTED_VSI_ABI_AVAILABLE: _setup_function( _lib.c2pa_live_video_trusted_vsi_session_create_callback_v1, [ctypes.POINTER(C2paContext), @@ -2462,6 +2486,7 @@ class TrustedVsiStatus: def _has_trusted_vsi_capability(bit: int, symbols_available: bool = True) -> bool: return ( _TRUSTED_VSI_ABI_AVAILABLE + and _TRUSTED_VSI_CONTRACT_REVISION == _TRUSTED_VSI_REQUIRED_CONTRACT_REVISION and _TRUSTED_VSI_VERSION_MATCHES and _TRUSTED_VSI_CAPABILITIES == _TRUSTED_VSI_REQUIRED_CAPABILITIES and symbols_available @@ -2602,7 +2627,7 @@ def _require_trusted_vsi(): if not has_live_video_trusted_vsi_signing_context_v1(): raise C2paError.NotSupported( f"Functional trusted VSI requires the complete {_TRUSTED_VSI_NATIVE_VERSION} native ABI " - "and capability mask 63; the loaded library is unavailable") + "with contract revision 3 and capability mask 63; the loaded library is unavailable") def _trusted_vsi_integer(value, name, maximum=2**32 - 1, minimum=0): @@ -5865,17 +5890,29 @@ def sign_fragmented( ) -> bytes: """Sign a fragmented BMFF file set and return its manifest bytes. - The native library writes signed files below - ``//``. This compatibility API accepts - one literal existing initialization segment and an explicit Signer. + With the paired glob-aware Castlabs 0.92.0-dev native including #17, + the native library writes signed files below + ``//``. An init path or glob selects one + or more renditions, all signed with one shared manifest and an explicit + Signer. Native code validates globs and output collisions and refuses + existing rendition output directories. Use fresh, exclusively owned + outputs outside the input globs; failures may leave partial outputs. + These glob/output guarantees are verified for that pairing. Export + presence alone does not establish them for older native lineages, which + may be literal-only or have different overwrite semantics. Older builds + may contain backports; no version cutoff is imposed here. Like :meth:`sign`, an attempted native signing operation closes this single-use Builder while leaving the borrowed Signer active. Args: signer: Active Signer borrowed for this operation. - asset_path: Literal path to an existing initialization segment. - fragments_glob: Fragment filename glob relative to the asset's - parent directory. + asset_path: Init path or glob matching one or more initialization + segments, each with a named parent directory (e.g. + ``video/init.mp4``, not bare ``init.mp4``). Parent directory names + must be distinct. Paths use native glob syntax even for a single + init in the paired native. + fragments_glob: Fragment glob relative to each init's parent + directory. Fragment subdirectories are flattened in output. output_dir: Root directory for the nested signed output. Returns: @@ -5884,9 +5921,9 @@ def sign_fragmented( Raises: C2paError.NotSupported: If the native API is unavailable. TypeError: If signer or path inputs have invalid types. - ValueError: If a path is empty, contains a NUL character, or - ``asset_path`` is not one literal existing file. - C2paError: If signing fails. + ValueError: If a path is empty or contains a NUL character. + C2paError.Encoding: If a path cannot be encoded as UTF-8. + C2paError: If native glob/layout validation or signing fails. """ self._ensure_valid_state() if not isinstance(signer, Signer): @@ -5899,18 +5936,11 @@ def sign_fragmented( "file_io feature" ) - asset_path_str, asset_path_bytes = _encode_path( + _, asset_path_bytes = _encode_path( asset_path, "asset_path") _, fragments_glob_bytes = _encode_path( fragments_glob, "fragments_glob") _, output_dir_bytes = _encode_path(output_dir, "output_dir") - if any(character in asset_path_str for character in "*?[]"): - raise ValueError( - "asset_path must be one literal initialization-segment path") - if not Path(asset_path_str).is_file(): - raise ValueError( - "asset_path must identify an existing regular file") - dynamic_assertion_cbs = list(signer._dynamic_assertion_cbs) claim_state = _claim_signer_error_state(signer._callback_cb) da_states = [state for _, state, _ in dynamic_assertion_cbs] diff --git a/tests/test_fragmented_files.py b/tests/test_fragmented_files.py index c945ed98..00dcbee8 100644 --- a/tests/test_fragmented_files.py +++ b/tests/test_fragmented_files.py @@ -138,8 +138,8 @@ def callback(data: bytes) -> bytes: None, ) - def _prepare_input(self, root: Path) -> tuple[Path, Path]: - input_dir = root / "input" + def _prepare_input(self, root: Path, name: str = "input") -> tuple[Path, Path]: + input_dir = root / name input_dir.mkdir() init_path = input_dir / "init.mp4" with open(FIXTURES_DIR / "dashinit.mp4", "rb") as file: @@ -241,6 +241,159 @@ def test_required_fragmented_capability_is_present(self): "native library does not provide fragmented BMFF file APIs", ) class TestFragmentedFiles(FragmentedTestCase): + def test_init_glob_signs_two_renditions_with_one_manifest_and_selectors(self): + import pytest + + try: + cbor2 = pytest.importorskip( + "cbor2", reason="Merkle selector checks require the cbor2 dev dependency") + except pytest.skip.Exception: + if os.environ.get("C2PA_REQUIRE_FRAGMENTED_FILES") == "1": + pytest.fail( + "C2PA_REQUIRE_FRAGMENTED_FILES=1 requires cbor2 for Merkle " + "selector checks; install requirements-dev.txt") + raise + + signer = self._make_signer() + self.addCleanup(signer.close) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + inits = [] + fragments = [] + for index in range(2): + # Distinct parent names match the segmented ABR caller's staging. + init, fragment = self._prepare_input(root, f"rendition-{index}") + # Different media bytes make cross-rendition selection observable. + media = bytearray(fragment.read_bytes()) + media[-1] ^= index + fragment.write_bytes(media) + inits.append(init) + fragments.append(fragment) + sources = {path: path.read_bytes() for path in inits + fragments} + output = root / "signed" + builder = Builder(_manifest_definition()) + manifest = builder.sign_fragmented( + signer=signer, + asset_path=root / "rendition-*" / "init.mp4", + fragments_glob=Path("segment-*.m4s"), + output_dir=output, + ) + self.assertGreater(len(manifest), 0) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + self.assertEqual( + sorted(path.name for path in output.iterdir()), + ["rendition-0", "rendition-1"], + ) + selectors = [] + reports = [] + signed_fragments = [] + for init, fragment in zip(inits, fragments): + signed_dir = output / init.parent.name + signed_init = signed_dir / init.name + signed_fragment = signed_dir / fragment.name + signed_fragments.append(signed_fragment) + self.assertEqual( + sorted(path.name for path in signed_dir.iterdir()), + [init.name, fragment.name], + ) + # Exact returned JUMBF bytes must occur in BOTH init segments. + self.assertIn(manifest, signed_init.read_bytes()) + media = signed_fragment.read_bytes() + offset = 0 + fragment_selectors = [] + # These tiny fixture outputs use nonzero 32-bit box sizes and + # 8-byte headers; the UUID offsets below assume that layout. + while offset < len(media): + size = int.from_bytes(media[offset:offset + 4], "big") + self.assertGreaterEqual(size, 8) + self.assertLessEqual(offset + size, len(media)) + box = media[offset:offset + size] + if box[4:8] == b"uuid" and box[28:35] == b"merkle\0": + merkle = cbor2.loads(box[35:]) + fragment_selectors.append( + (merkle["uniqueId"], merkle["localId"])) + offset += size + self.assertEqual(len(fragment_selectors), 1) + selectors.extend(fragment_selectors) + with Reader.from_fragmented_files( + signed_init, [signed_fragment], + ) as reader: + self.assertEqual(reader.get_validation_state(), "Valid") + report = json.loads(reader.json()) + reports.append(report["manifests"][report["active_manifest"]]) + self.assertEqual(selectors, [(1, 1), (2, 2)]) + self.assertEqual(reports[0], reports[1]) + bmff = next(assertion["data"] for assertion in reports[0]["assertions"] + if assertion["label"].startswith("c2pa.hash.bmff")) + self.assertEqual( + [(entry["uniqueId"], entry["localId"]) for entry in bmff["merkle"]], + selectors, + ) + # Keep rendition 1's selector but replace its media with rendition 2's. + wrong_media = bytearray(signed_fragments[0].read_bytes()) + wrong_media[-1] ^= 1 + signed_fragments[0].write_bytes(wrong_media) + with Reader.from_fragmented_files( + output / inits[0].parent.name / inits[0].name, + [signed_fragments[0]], + ) as reader: + self.assertEqual(reader.get_validation_state(), "Invalid") + self.assertEqual({path: path.read_bytes() for path in sources}, sources) + + def test_init_glob_preserves_existing_destinations_including_source_directory(self): + signer = self._make_signer() + self.addCleanup(signer.close) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + init, fragment = self._prepare_input(root) + sources = {path: path.read_bytes() for path in (init, fragment)} + output = root / "output" + destination = output / init.parent.name + destination.mkdir(parents=True) + sentinel = destination / init.name + sentinel.write_bytes(b"do not overwrite") + # The source-directory case fails at the C ABI's existing-directory + # preflight, before the SDK's filesystem identity checks. + for target in (output, root): + with self.subTest(output=target): + builder = Builder(_manifest_definition()) + with self.assertRaisesRegex(C2paError, "already exists"): + builder.sign_fragmented( + signer=signer, + asset_path=root / "*" / "init.mp4", + fragments_glob="segment-*.m4s", + output_dir=target, + ) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + self.assertEqual(sentinel.read_bytes(), b"do not overwrite") + self.assertEqual(list(destination.iterdir()), [sentinel]) + self.assertEqual( + {path: path.read_bytes() for path in sources}, sources) + + def test_native_rejects_invalid_and_unmatched_globs_without_outputs(self): + signer = self._make_signer() + self.addCleanup(signer.close) + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + init, _ = self._prepare_input(root) + for asset, fragments, message in ( + (root / "[", "segment-*.m4s", "Invalid glob pattern"), + (root / "*" / "missing*.mp4", "segment-*.m4s", "No init segments"), + (root / "missing-init.mp4", "segment-*.m4s", "No init segments"), + (init, "[", "Invalid glob pattern"), + (init, "missing*.m4s", "No fragments"), + ): + with self.subTest(asset=asset, fragments=fragments): + output = root / "output" + builder = Builder(_manifest_definition()) + with self.assertRaisesRegex(C2paError, message): + builder.sign_fragmented(signer, asset, fragments, output) + self.assertFalse(output.exists()) + self.assertFalse(builder.is_valid) + self.assertTrue(signer.is_valid) + def test_successful_sign_and_legacy_read_round_trip(self): signer = self._make_signer() self.addCleanup(signer.close) @@ -353,11 +506,6 @@ def test_invalid_inputs_are_rejected_before_native_calls(self): with self.subTest(asset_path=value): with self.assertRaises((TypeError, ValueError)): builder.sign_fragmented(signer, value, "*.m4s", "out") - with self.assertRaises(ValueError): - builder.sign_fragmented(signer, "*.mp4", "*.m4s", "out") - with self.assertRaises(ValueError): - builder.sign_fragmented( - signer, "missing-init.mp4", "*.m4s", "out") self.assertTrue(builder.is_valid) self.assertTrue(signer.is_valid) diff --git a/tests/test_trusted_vsi_api.py b/tests/test_trusted_vsi_api.py index 0c97d248..b14c1563 100644 --- a/tests/test_trusted_vsi_api.py +++ b/tests/test_trusted_vsi_api.py @@ -83,20 +83,25 @@ def test_unshipped_counter_result_and_recovery_are_removed(): def test_partial_or_unknown_capability_mask_never_advertises_functionality(monkeypatch, mask): monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", mask) assert not any(probe() for probe in PROBES) @pytest.mark.parametrize("missing", ["_TRUSTED_VSI_ABI_AVAILABLE", "_TRUSTED_VSI_VERSION_MATCHES"]) def test_missing_symbols_or_wrong_native_version_fail_closed(monkeypatch, missing): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) monkeypatch.setattr(bindings, missing, False) assert not any(probe() for probe in PROBES) def test_paired_native_version_is_exact_consolidated_release_line(): - # Changing this pin requires re-pairing qualification with a reviewed native SHA. + # This release line is necessary but does not identify the trusted contract. assert bindings._TRUSTED_VSI_NATIVE_VERSION == "0.92.0-dev" + assert bindings._TRUSTED_VSI_REQUIRED_CONTRACT_REVISION == 3 @pytest.mark.parametrize("native_version, expected", [ @@ -114,9 +119,26 @@ def test_native_version_gate_accepts_only_exact_paired_token(native_version, exp assert bindings._trusted_vsi_version_matches(native_version) is expected +@pytest.fixture(params=[ + ("_TRUSTED_VSI_CONTRACT_REVISION", revision) for revision in (0, 1, 2, 4) +] + [ + ("_TRUSTED_VSI_ABI_AVAILABLE", False), + ("_TRUSTED_VSI_VERSION_MATCHES", False), + ("_TRUSTED_VSI_CAPABILITIES", 0), + ("_TRUSTED_VSI_CAPABILITIES", 62), + ("_TRUSTED_VSI_CAPABILITIES", 127), +]) +def disabled_gate(monkeypatch, request): + monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) + monkeypatch.setattr(bindings, *request.param) + assert not any(probe() for probe in PROBES) + + @pytest.mark.parametrize("factory", [c2pa.TrustedVsiSession, c2pa.TrustedVsiSession.from_callback]) -def test_disabled_constructor_gates_before_arguments_callbacks_or_bookkeeping(monkeypatch, factory): - monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 0) +def test_disabled_constructor_gates_before_arguments_callbacks_or_bookkeeping(monkeypatch, disabled_gate, factory): forbidden = Mock(side_effect=AssertionError("side effect before capability gate")) monkeypatch.setattr(bindings.ManagedResource, "__init__", forbidden) monkeypatch.setattr(bindings, "_lib", forbidden) @@ -174,8 +196,7 @@ def test_python_mapping_matches_native_contract_signatures(): ("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) +def test_static_helpers_gate_before_arguments_or_native(monkeypatch, disabled_gate, function, args): forbidden = Mock(side_effect=AssertionError("native touched")) monkeypatch.setattr(bindings, "_lib", forbidden) with pytest.raises(c2pa.C2paError.NotSupported): @@ -183,6 +204,121 @@ def test_static_helpers_gate_before_arguments_or_native(monkeypatch, function, a forbidden.assert_not_called() +@pytest.mark.parametrize("revision,version,mask,missing", [ + (revision, "0.92.0-dev", 63, None) for revision in (None, 0, 1, 2, 3, 4) +] + [ + (3, "0.91.0", 63, None), + (3, "0.92.0-dev", 63, "c2pa_live_video_trusted_vsi_session_status_v1"), + (3, "0.92.0-dev", 63, "c2pa_live_video_trusted_vsi_capabilities"), +] + [ + (3, "0.92.0-dev", mask, None) for mask in (0, 1, 31, 62, 64, 127) +]) +def test_raw_loader_contract_gate_precedes_operational_ffi(revision, version, mask, missing): + """A fresh import must not bind/call old ABI even with matching names/mask.""" + program = r''' +import ctypes +import json +from pathlib import Path +import sys + +revision, version, mask, missing = json.loads(sys.argv[1]) +prefix = "c2pa_live_video_trusted_vsi_" +probe_names = {prefix + "contract_revision", prefix + "capabilities"} +events = [] +version_buffer = ctypes.create_string_buffer(("c2pa-rs/" + version).encode()) + +class Function: + def __init__(self, name): + self.name = name + + def __setattr__(self, name, value): + if name in ("argtypes", "restype"): + events.append(("bind", self.name, name)) + object.__setattr__(self, name, value) + + def __call__(self, *args): + events.append(("call", self.name)) + if self.name in probe_names: + assert args == () and self.argtypes == [] + assert self.restype is (ctypes.c_uint32 if self.name.endswith("revision") else ctypes.c_uint64) + return revision if self.name.endswith("revision") else mask + if self.name == "c2pa_version": + return ctypes.addressof(version_buffer) + if self.name == "c2pa_string_free": + return None + raise AssertionError("unsafe native call: " + self.name) + +class Library: + def __getattr__(self, name): + if name == missing or (name == prefix + "contract_revision" and revision is None): + raise AttributeError(name) + function = Function(name) + setattr(self, name, function) + return function + +library = Library() +ctypes.CDLL = lambda *args, **kwargs: library +import c2pa +import c2pa.c2pa as bindings +assert Path(bindings.__file__).resolve() == Path(sys.argv[2]).resolve() + +accepted = revision == 3 and version == "0.92.0-dev" and mask == 63 and missing is None +probes = [getattr(c2pa, name) for name in c2pa.__all__ if name.startswith("has_live_video_trusted_vsi_")] +assert len(probes) == 6 and [probe() for probe in probes] == [accepted] * 6 +assert bindings._TRUSTED_VSI_ABI_AVAILABLE is accepted +assert bindings._TRUSTED_VSI_CONTRACT_REVISION == (revision or 0) +operational = set(bindings._TRUSTED_VSI_FUNCTIONS) - probe_names +for name in operational - {missing}: + assert ("argtypes" in vars(getattr(library, name))) is accepted, name + assert ("restype" in vars(getattr(library, name))) is accepted, name +if accepted: + revision_call = events.index(("call", prefix + "contract_revision")) + version_call = events.index(("call", "c2pa_version")) + mask_call = events.index(("call", prefix + "capabilities")) + assert revision_call < version_call < mask_call + assert all(mask_call < i for i, event in enumerate(events) + if event[0] == "bind" and event[1] in operational) +else: + def forbidden(*args, **kwargs): + raise AssertionError("argument processing/buffer/callback/bookkeeping before gate") + bindings.ManagedResource.__init__ = forbidden + bindings.ctypes.create_string_buffer = forbidden + bindings.TrustedVsiSignCallbackV1 = forbidden + bindings._trusted_vsi_algorithm = forbidden + for factory in (c2pa.TrustedVsiSession, c2pa.TrustedVsiSession.from_callback): + try: + factory(*[object() for _ in range(9)], mode=object(), + reservation_nonce=object(), signing_time_unix_seconds=object()) + except c2pa.C2paError.NotSupported: + pass + else: + raise AssertionError("constructor accepted rejected contract") + for function, args in ((c2pa.validate_trusted_vsi_input, (object(), object(), object())), + (c2pa.trusted_vsi_hash_template, (object(),))): + try: + function(*args) + except c2pa.C2paError.NotSupported: + pass + else: + raise AssertionError("helper accepted rejected contract") +assert not any(event[0] == "call" and event[1] in operational for event in events) +assert c2pa.has_live_video_vsi() and c2pa.has_live_video_vsi_callbacks() +assert library.c2pa_live_video_vsi_signer_create_callback.argtypes +assert library.c2pa_builder_sign.argtypes +print("raw loader contract gate verified") +''' + env = os.environ.copy() + # Keep raw-loader checks on the parent's selected source or installed package. + env["PYTHONPATH"] = str(Path(bindings.__file__).resolve().parent.parent) + env["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run( + [sys.executable, "-c", program, json.dumps([revision, version, mask, missing]), bindings.__file__], + cwd=Path(__file__).resolve().parents[1], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert "raw loader contract gate verified" in result.stdout + + class _ScriptedNative: """Records calls to scripted trusted-VSI entry points installed on _lib.""" @@ -196,6 +332,7 @@ def __init__(self, monkeypatch): self._keep = self.handle._objects monkeypatch.setattr(bindings, "_TRUSTED_VSI_ABI_AVAILABLE", True) monkeypatch.setattr(bindings, "_TRUSTED_VSI_VERSION_MATCHES", True) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", 3) monkeypatch.setattr(bindings, "_TRUSTED_VSI_CAPABILITIES", 63) real_free = bindings.ManagedResource._free_native_ptr @@ -242,6 +379,22 @@ def invoke(self, purpose=1, sequence=5, has_sequence=True, event=0, has_event=Fa return result, bytes(signature) +def test_scripted_static_helpers_use_current_contract(monkeypatch): + native = _ScriptedNative(monkeypatch) + + def validate(kind, algorithm, data, length): + assert (kind, algorithm, ctypes.string_at(data, length)) == ( + KIND.SIG_STRUCTURE, c2pa.C2paSigningAlg.ED25519, b"exact-input") + return 0 + + native.install(monkeypatch, "validate_input", validate) + native.install(monkeypatch, "hash_template", lambda kind, output: native.output(output, b"template")) + assert c2pa.validate_trusted_vsi_input(KIND.SIG_STRUCTURE, "ed25519", b"exact-input") is None + assert c2pa.trusted_vsi_hash_template(KIND.INIT_HASH) == b"template" + assert [name for name, args in native.calls] == ["validate_input", "hash_template"] + assert len(native.freed) == 1 + + @pytest.fixture def scripted(monkeypatch): native = _ScriptedNative(monkeypatch) @@ -524,6 +677,9 @@ def paired_native(): problems = [] if missing: problems.append("missing symbols: " + ", ".join(missing)) + revision = bindings._TRUSTED_VSI_CONTRACT_REVISION + if revision != 3: + problems.append(f"contract revision {revision} != 3") expected = bindings._TRUSTED_VSI_NATIVE_VERSION if c2pa.sdk_version() != expected: problems.append(f"native version {c2pa.sdk_version()!r} != {expected!r}") @@ -547,6 +703,28 @@ def paired_native(): assert "C2PA_LIBRARY_NAME" not in os.environ and "PYTHONPATH" not in os.environ +@pytest.mark.parametrize("flag", ["C2PA_TRUSTED_VSI_ABI_REQUIRED", "C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED"]) +@pytest.mark.parametrize("revision", [None, 0, 1, 2, 4]) +def test_required_qualification_revision_diagnostic_fails_never_skips(monkeypatch, flag, revision): + library = Mock() + if revision is None: + del library.c2pa_live_video_trusted_vsi_contract_revision + library.c2pa_live_video_trusted_vsi_capabilities.return_value = 63 + monkeypatch.setattr(bindings, "_lib", library) + monkeypatch.setattr(bindings, "_TRUSTED_VSI_CONTRACT_REVISION", revision or 0) + monkeypatch.setattr(c2pa, "sdk_version", lambda: "0.92.0-dev") + monkeypatch.delenv("C2PA_TRUSTED_VSI_ABI_REQUIRED", raising=False) + monkeypatch.delenv("C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED", raising=False) + monkeypatch.setenv(flag, "1") + with pytest.raises(pytest.fail.Exception, match=f"contract revision {revision or 0} != 3") as caught: + paired_native.__wrapped__() + assert "required qualification; never skipped" in str(caught.value) + if revision is None: + assert "missing symbols: c2pa_live_video_trusted_vsi_contract_revision" in str(caught.value) + library.c2pa_live_video_trusted_vsi_session_create_callback_v1.assert_not_called() + library.c2pa_live_video_trusted_vsi_session_status_v1.assert_not_called() + + @pytest.fixture def sessions(paired_native): resources = [] @@ -681,6 +859,11 @@ def _init(session): def test_paired_ctypes_exact_functional_contract_and_error_outputs(paired_native): lib = bindings._lib + assert lib.c2pa_live_video_trusted_vsi_contract_revision.argtypes == [] + assert lib.c2pa_live_video_trusted_vsi_contract_revision.restype is ctypes.c_uint32 + assert lib.c2pa_live_video_trusted_vsi_contract_revision() == 3 + assert lib.c2pa_live_video_trusted_vsi_capabilities.argtypes == [] + assert lib.c2pa_live_video_trusted_vsi_capabilities.restype is ctypes.c_uint64 session = ctypes.POINTER(bindings.C2paLiveVideoTrustedVsiSession) byte = ctypes.POINTER(ctypes.c_ubyte) output = ctypes.POINTER(byte) From d4782fa367836796e0cde17dfef1df5118ff9764 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 15 Oct 2026 17:39:24 +0200 Subject: [PATCH 40/46] feat: Reader offset-aware fragment verification (c2pa_reader_with_fragment_at_offset) Bind the Castlabs native c2pa_reader_with_fragment_at_offset(reader, format, stream, fragment, uint64 offset) and the c2pa_reader_fragment_at_offset_supported probe as optional symbols, so older native libraries still import. - has_fragment_at_offset(): true only when both exports exist and the probe returns true. The probe is called lazily on first use, never at import. - Reader.from_fragment_at_offset(format, init_stream, fragment_stream, fragment_offset, context=None) and Reader.with_fragment_at_offset(format, stream, fragment_stream, fragment_offset). They follow with_fragment ownership: the native call consumes the reader on success and failure. Gating raises C2paError.NotSupported before any argument or native work. The format must be a non-empty str (native fragment loading cannot auto-detect). The offset must be an int in 0..2**64-1 (bool rejected). The streams must be stream-like and distinct objects, and the context valid; all of this is checked before any native call. A wrong cut or offset gives an Invalid reader with assertion.bmffHash.mismatch. - Tests: scripted gating, validation and marshalling, plus real-native checks. Those sign the per-fragment-sidx single-file fixture (copied from castlabs/c2pa-rs ec9ac2f6), cut it at merkle uuid boundaries, verify each segment Trusted with bmffHash.match, and check wrong offsets (0, +/-1, sibling) are a mismatch. The real-native tests skip when the API is absent and fail under C2PA_FRAGMENT_OFFSET_REQUIRED=1. - Docs: README, usage and release notes. --- README.md | 4 + docs/release-notes.md | 9 + docs/usage.md | 47 ++ src/c2pa/__init__.py | 2 + src/c2pa/c2pa.py | 235 ++++++++ .../fixtures/single-file-fragmented/README.md | 22 + .../single_file_fragments_sidx.mp4 | Bin 0 -> 3892 bytes tests/test_fragment_at_offset.py | 537 ++++++++++++++++++ 8 files changed, 856 insertions(+) create mode 100644 tests/fixtures/single-file-fragmented/single_file_fragments_sidx.mp4 create mode 100644 tests/test_fragment_at_offset.py diff --git a/README.md b/README.md index da1d8517..1282af31 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,10 @@ can restore a callback-backed session without invoking the key. See `has_fragmented_files()` reports whether the loaded native library contains the Castlabs fragmented file APIs. When available, `Builder.sign_fragmented()` signs an initialization segment and its media-fragment glob, while `Reader.from_fragmented_files()` validates the signed file set. Standard upstream native binaries remain importable; unavailable operations raise `C2paError.NotSupported`. See [Using the Python library](docs/usage.md#fragmented-bmff-file-sets). +## Byte-range fragments of single-file fragmented MP4 + +`has_fragment_at_offset()` reports whether the loaded native library exports `c2pa_reader_with_fragment_at_offset` and its probe returns true. When available, `Reader.from_fragment_at_offset(format, init_stream, fragment_stream, fragment_offset, context=None)` (or `Reader.with_fragment_at_offset(...)` on an existing reader) verifies one byte-range slice of a signed single-file fragmented MP4 at its absolute offset in the original file. A wrong cut or offset yields an `Invalid` reader with `assertion.bmffHash.mismatch` rather than an exception. Older native libraries remain importable; the methods raise `C2paError.NotSupported` before touching native state. See [Using the Python library](docs/usage.md#byte-range-fragments-of-single-file-fragmented-mp4). + ## Examples See the [`examples` directory](https://github.com/castlabs/c2pa-python/tree/feat/live-video-vsi/examples) for some helpful examples: diff --git a/docs/release-notes.md b/docs/release-notes.md index bb3a9d31..2d5bae3c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,14 @@ # Release notes +## Unreleased: offset-aware fragment verification + +- Adds optional bindings for `c2pa_reader_with_fragment_at_offset` and + `c2pa_reader_fragment_at_offset_supported`, `has_fragment_at_offset()`, and + `Reader.from_fragment_at_offset()` / `Reader.with_fragment_at_offset()` to + verify a byte-range fragment of a single-file fragmented MP4 at its absolute + offset. Native libraries without the exports still import; the API raises + `C2paError.NotSupported`. + ## Unreleased: functional trusted-processor VSI API - Replaces the unshipped counter/result scaffold with diff --git a/docs/usage.md b/docs/usage.md index ddadae57..4a1ea458 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -414,6 +414,53 @@ non-consuming. The returned shared manifest buffer is copied into Python-owned Pass an active `Context` to `Reader.from_fragmented_files()` to use explicit verification and trust settings. Omitting `context` preserves the legacy thread-local settings behavior. The reader requires at least one explicit fragment path and retains the supplied Context and callback references until the reader closes. +## Byte-range fragments of single-file fragmented MP4 + +A single-file fragmented MP4 signed with fragment Merkle bindings hashes +absolute root-box offsets, so one byte-range slice of it (for example an HLS +`EXT-X-BYTERANGE` segment) verifies only at the offset where it sat in the +original file. Check `has_fragment_at_offset()` first; it requires both native +exports (`c2pa_reader_with_fragment_at_offset`, +`c2pa_reader_fragment_at_offset_supported`) and a true probe, independent of +the trusted VSI revision/capability mask. The probe is called on first use, +never at import. + +```py +import io +from c2pa import Reader, has_fragment_at_offset + +if not has_fragment_at_offset(): + raise RuntimeError("loaded native library cannot verify fragments at an offset") + +# init_bytes: every byte before the first C2PA `merkle` uuid box. +# segment: from a `merkle` uuid box up to the next one (or end of file), +# found at absolute offset `segment_offset` in the signed file. +with Reader.from_fragment_at_offset( + "video/mp4", + io.BytesIO(init_bytes), + io.BytesIO(segment), + segment_offset, + context=verification_context, +) as reader: + print(reader.get_validation_state()) # "Trusted" with matching trust anchors +``` + +A wrong cut, or the right cut at the wrong offset, does not raise: the reader +reports validation state `Invalid` with `assertion.bmffHash.mismatch`. The +offset is caller-supplied; it proves the bytes belong to the Merkle leaf they +name, not where they were fetched from. `fragment_offset=0` matches +`with_fragment()`. + +Python validates the format (a non-empty `str`; no auto-detection), offset (an `int` in `0..2**64-1`, `bool` +rejected), stream-likeness, and that the two streams are distinct objects +before any native call; unavailable native support raises +`C2paError.NotSupported` with no side effects. Settings and trust come from +`context`; without one, a fresh default Context (default SDK settings, no +custom trust anchors) is used. `Reader.with_fragment_at_offset()` applies the +same operation to an existing reader (create it with a Context): like +`with_fragment()`, the native call consumes the underlying reader on success +and failure, so a native failure closes that Reader. + ## File-based operation ### Read and validate C2PA data diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py index 9e571e9b..0fa9d8f1 100644 --- a/src/c2pa/__init__.py +++ b/src/c2pa/__init__.py @@ -42,6 +42,7 @@ TrustedVsiStatus, has_dynamic_assertions, has_fragmented_files, + has_fragment_at_offset, has_live_video_vsi, has_live_video_vsi_callbacks, has_live_video_vsi_explicit_time, @@ -84,6 +85,7 @@ 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', + 'has_fragment_at_offset', 'has_live_video_vsi', 'has_live_video_vsi_callbacks', 'has_live_video_vsi_explicit_time', diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 986f6199..b73751c2 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -200,6 +200,14 @@ _FRAGMENTED_CONTEXT_READER_FUNCTIONS = ( 'c2pa_reader_from_fragmented_files_context', ) +# Castlabs offset-aware single-file fragment verification. Optional so older +# native libraries still import; admitted only when the stateless probe also +# reports support. +_FRAGMENT_AT_OFFSET_FUNCTIONS = ( + 'c2pa_reader_with_fragment_at_offset', + 'c2pa_reader_fragment_at_offset_supported', +) +_FRAGMENT_OFFSET_MAX = 2**64 - 1 def _validate_library_exports(lib): @@ -328,6 +336,12 @@ def _trusted_vsi_version_matches(native_version: bytes) -> bool: _FRAGMENTED_CONTEXT_READER_AVAILABLE = all( hasattr(_lib, name) for name in _FRAGMENTED_CONTEXT_READER_FUNCTIONS ) +_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE = all( + hasattr(_lib, name) for name in _FRAGMENT_AT_OFFSET_FUNCTIONS +) +# Cached result of c2pa_reader_fragment_at_offset_supported(); None until +# has_fragment_at_offset() first runs. Import never calls the probe. +_FRAGMENT_AT_OFFSET_PROBE = None class C2paSeekMode(enum.IntEnum): @@ -1359,6 +1373,19 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream)], ctypes.POINTER(C2paReader) ) +if _FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE: + _setup_function( + _lib.c2pa_reader_with_fragment_at_offset, + [ctypes.POINTER(C2paReader), ctypes.c_char_p, + ctypes.POINTER(C2paStream), ctypes.POINTER(C2paStream), + ctypes.c_uint64], + ctypes.POINTER(C2paReader) + ) + _setup_function( + _lib.c2pa_reader_fragment_at_offset_supported, + [], + ctypes.c_bool + ) _setup_function( _lib.c2pa_builder_from_context, [ctypes.POINTER(C2paContext)], @@ -2608,6 +2635,61 @@ def has_fragmented_files() -> bool: ) +def has_fragment_at_offset() -> bool: + """Return whether offset-aware single-file fragment verification works. + + Requires both ``c2pa_reader_with_fragment_at_offset`` and + ``c2pa_reader_fragment_at_offset_supported`` exports, and the probe must + report ``True``. Independent of the trusted VSI revision and capability + mask. The stateless probe is called on first use (never at import) and + its result is cached. + """ + global _FRAGMENT_AT_OFFSET_PROBE + if not _FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE: + return False + if _FRAGMENT_AT_OFFSET_PROBE is None: + _FRAGMENT_AT_OFFSET_PROBE = bool( + _lib.c2pa_reader_fragment_at_offset_supported()) + return _FRAGMENT_AT_OFFSET_PROBE + + +def _require_fragment_at_offset(): + if not has_fragment_at_offset(): + raise C2paError.NotSupported( + "Offset-aware fragment verification is unavailable in the loaded " + "native library; it requires the Castlabs c2pa-rs fork exporting " + "c2pa_reader_with_fragment_at_offset") + + +def _validate_fragment_at_offset_args(format, stream, fragment_stream, + fragment_offset): + """Validate offset-aware fragment arguments without touching native code. + + Returns the native format argument and the offset. + """ + if not isinstance(format, str): + raise TypeError("format must be a str (MIME type)") + if not format.strip(): + # Native fragment loading cannot auto-detect the container. + raise ValueError("format must be a non-empty MIME type") + if "\0" in format: + raise ValueError("format must not contain a NUL character") + if isinstance(fragment_offset, bool) or not isinstance( + fragment_offset, int): + raise TypeError("fragment_offset must be an integer") + if not 0 <= fragment_offset <= _FRAGMENT_OFFSET_MAX: + raise ValueError( + f"fragment_offset must be between 0 and {_FRAGMENT_OFFSET_MAX}") + if not Stream.is_read_stream(stream): + raise TypeError("stream must be a stream-like object") + if not Stream.is_read_stream(fragment_stream): + raise TypeError("fragment_stream must be a stream-like object") + if stream is fragment_stream: + raise ValueError( + "stream and fragment_stream must be distinct stream objects") + return _format_ffi_arg(_encode_format(format, "Reader")), fragment_offset + + class TrustedVsiOperation(enum.IntEnum): RESERVE_INIT = 0 FINALIZE_INIT = 1 @@ -4202,6 +4284,91 @@ def from_fragmented_files( context, '_dynamic_assertion_cbs', ())) return reader + @classmethod + def from_fragment_at_offset( + cls, + format: str, + init_stream: Any, + fragment_stream: Any, + fragment_offset: int, + context: Optional['ContextProvider'] = None, + ) -> 'Reader': + """Create a Reader verifying one byte-range fragment of a single-file + fragmented MP4 at its absolute offset. + + See :meth:`with_fragment_at_offset` for the cut rules and result + semantics: a wrong cut or offset yields a Reader whose validation + state is ``Invalid`` with ``assertion.bmffHash.mismatch``, not an + exception. + + Args: + format: Non-empty MIME type (e.g. "video/mp4"). + init_stream: Stream-like object with every byte before the first + C2PA ``merkle`` uuid box. + fragment_stream: Stream-like object with the fragment bytes; must + be a different object from ``init_stream``. + fragment_offset: Absolute byte offset of the fragment's first byte + in the original asset (0 to 2**64-1). + context: Optional active ContextProvider supplying verification + and trust settings. When omitted, a fresh default Context is + used (default SDK settings, no custom trust anchors). + + Returns: + A Reader owning the configured native handle. + + Raises: + C2paError.NotSupported: If the loaded native library lacks the + API. Raised before arguments are inspected or any native + object is created. + TypeError, ValueError: For invalid arguments, before any native + call. + C2paError: If the native reader cannot be created (for example + when the init stream carries no manifest). + """ + _require_fragment_at_offset() + format_arg, offset = _validate_fragment_at_offset_args( + format, init_stream, fragment_stream, fragment_offset) + if context is not None: + if not isinstance(context, ContextProvider): + raise TypeError("context must implement ContextProvider") + if not context.is_valid: + raise C2paError("Context is not valid") + + owned_context = Context() if context is None else None + active_context = context if context is not None else owned_context + try: + reader_ptr = _lib.c2pa_reader_from_context( + active_context.execution_context) + _check_ffi_operation_result( + reader_ptr, Reader._ERROR_MESSAGES['reader_error']) + try: + reader = cls._wrap_native_handle(reader_ptr) + except Exception: + ManagedResource._free_native_ptr(reader_ptr) + raise + + if context is not None: + # The native Reader shares the Context; pin the Python + # Context and its callback state for the same lifetime. + reader._context = context + reader._signer_callback_cb = getattr( + context, '_signer_callback_cb', None) + reader._dynamic_assertion_cbs = list(getattr( + context, '_dynamic_assertion_cbs', ())) + + try: + reader._apply_fragment_at_offset( + format_arg, init_stream, fragment_stream, offset) + except Exception: + reader.close() + raise + return reader + finally: + # A default Context carries no Python callbacks; the native + # Reader keeps its own reference, so it can be released here. + if owned_context is not None: + owned_context.close() + @overload def __init__( self, @@ -4558,6 +4725,73 @@ def with_fragment(self, format: Optional[str], stream, return self + def with_fragment_at_offset(self, format: str, stream, + fragment_stream, + fragment_offset: int) -> "Reader": + """Verify one byte-range fragment of a single-file fragmented MP4. + + A single-file fragmented MP4 signed with fragment Merkle bindings + hashes absolute root-box offsets, so a byte-range slice of it (for + example an HLS ``EXT-X-BYTERANGE`` segment) only verifies at the + offset where it sat in the original file. ``stream`` holds every byte + before the first C2PA ``merkle`` uuid box; ``fragment_stream`` starts + at a ``merkle`` uuid box and ends right before the next one (or at the + end of the file). ``fragment_offset=0`` behaves like + :meth:`with_fragment`. + + A wrong cut or offset does not raise: the reader reports + ``assertion.bmffHash.mismatch`` and validation state ``Invalid``. The + offset is caller-supplied; it proves the bytes belong to the Merkle + leaf they name, not where they were fetched from. + + Settings and trust come from this reader, so create it with a + Context (or use :meth:`from_fragment_at_offset`). + + Args: + format: Non-empty MIME type (e.g. "video/mp4"). + stream: Stream-like object with the initialization bytes. + fragment_stream: Stream-like object with the fragment bytes; must + be a different object from ``stream``. + fragment_offset: Absolute byte offset of the fragment's first byte + in the original single-file asset (0 to 2**64-1). + + Returns: + This reader instance, for method chaining. + + Raises: + C2paError.NotSupported: If the loaded native library lacks the API + (this reader is left unchanged). + TypeError, ValueError: For invalid arguments (reader unchanged). + C2paError: If the native call fails. The native call consumes the + underlying reader on failure too, so this Reader is then + closed and cannot be reused. + """ + _require_fragment_at_offset() + format_arg, offset = _validate_fragment_at_offset_args( + format, stream, fragment_stream, fragment_offset) + self._ensure_valid_state() + self._apply_fragment_at_offset( + format_arg, stream, fragment_stream, offset) + return self + + def _apply_fragment_at_offset(self, format_arg, stream, fragment_stream, + offset): + with Stream(stream) as main_obj, Stream(fragment_stream) as frag_obj: + self._consume_and_swap( + lambda handle: _lib.c2pa_reader_with_fragment_at_offset( + handle, + format_arg, + main_obj._stream, + frag_obj._stream, + offset, + ), + Reader._ERROR_MESSAGES['fragment_error'], + consumes_first=True) + + # The native reader now holds a different store; drop stale caches. + self._manifest_json_str_cache = None + self._manifest_data_cache = None + def json(self) -> str: """Get the manifest store as a JSON string. @@ -6367,6 +6601,7 @@ def ed25519_sign(data: bytes, private_key: str) -> bytes: 'TrustedVsiStatus', 'has_dynamic_assertions', 'has_fragmented_files', + 'has_fragment_at_offset', 'has_live_video_vsi', 'has_live_video_vsi_callbacks', 'has_live_video_vsi_explicit_time', diff --git a/tests/fixtures/single-file-fragmented/README.md b/tests/fixtures/single-file-fragmented/README.md index 058f0abf..e2a84c71 100644 --- a/tests/fixtures/single-file-fragmented/README.md +++ b/tests/fixtures/single-file-fragmented/README.md @@ -23,3 +23,25 @@ ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc2=size=32x32:rate=2 -t 3 Tests use the committed bytes and existing test-only ES256 key/certificates in the parent directory. They need neither FFmpeg nor an external timestamp server. + +## Per-fragment `sidx` fixture + +`single_file_fragments_sidx.mp4` is the same synthetic source muxed with a +per-fragment `sidx` before every `moof` (version 1 boxes), the layout +byte-range HLS packagers cut at. `tests/test_fragment_at_offset.py` signs it and +verifies each Merkle-uuid-delimited slice at its absolute offset. + +The bytes match c2pa-rs `sdk/tests/fixtures/single_file_fragments_sidx.mp4` +exactly (castlabs/c2pa-rs commit `ec9ac2f6917201a5cc69c0ef12f756228e359da2`, +branch `feat/trusted-vsi-fragment-offset`). + +- Size: 3,892 bytes +- Git blob: `3158e05962c808fd25be713bb8d7f84214275233` +- SHA-256: `8d95ae1959740b8fd02d30ad9e4eb04da7dc26869c14605677c084ce9b03ccff` + +```sh +ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc2=size=32x32:rate=2 -t 3 \ + -c:v libx264 -threads 1 -g 2 -bf 0 \ + -movflags +empty_moov+frag_keyframe+default_base_moof+dash \ + -y single_file_fragments_sidx.mp4 +``` diff --git a/tests/fixtures/single-file-fragmented/single_file_fragments_sidx.mp4 b/tests/fixtures/single-file-fragmented/single_file_fragments_sidx.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..3158e05962c808fd25be713bb8d7f84214275233 GIT binary patch literal 3892 zcmbssc|29w|30sUtXW13x|As1^PZi&&?1@=l~fehd-w5p_igv$F;gg!G^vJ~nxu^s zO_nTaqv(xonx@54Q^GWrB_c};zjJ9ZeWsZ|e&5f#=evKu=YG%mo_ihyL3&&wO2os3 zHV`BSkr+I-n8=z5_%2K+jDR4Bk41!V0Ga!X$rr8AWAaE;UOf4aXx=97a)cppUN>r3*qo3V-y%7 zcpUTtP2#nH7rG+?4j(0LD7F|+zy*Y61U43Q;uL0c#&voe2};_KKgE`cE!at9>&kU-H|4(x3ibSoyE$*_d^ydbHit@XHa zY@9JV3a~Y>T14SM(?%ad+Q9(l7N|GM$3hSlA_uvtNgA^$(3Fsl>$OJZ?}mm*S2YoW zivT8PK|X3YLEr#?0Knty4-CMXfRCQQlO(WV3n$1-u?SQOc$kokVN&%W{sc29D4ate zK(GNr$1tfgoVYw-NhOF#L{{)4(0F{D0G#n@LP#dn=OAOwWC94(i3{q)v;}oy0Nxts zain9Ku^G6KHlG7fPqu>~7qUhqh(Lpo>#2ruKW-#4#5%BvNx2?D%H<$?Km$1p&?GMk zX#uK3a5=;{ImVL%s6w~^G~Q%DgJyuF0ldH@83x_}sIV!MM@M-NmOQ)sq{+Qx zzj-6vG^_gsxzI2TW(y@K%mk#wipj8tt(Yj&f$IPi?sQ;avBGP~QkpgFx^@w;gotGchoMnG{gDU&2MP#{vrz%c4kuXF3^I)b zAxk1b@etrEVF!GiF7T7ES!@Xx#)3du$XNk~Wobocz+!SPEC$^ckRmed7)GQlTL&P( z2~@{v405ki?xQ>6M{kwzmo`LZ#=a`@@5-8XrWtpN+pWIJ zbP(?ObDEA;xJ67#l(&HHKljwUd(7c3ouRnO<~@qLRwNskwQWD$ahQ^z*XG2q$;qx0 z^@p?Slb(0AH5mG9o^!BFKhLaTa?bYK;M^PaZTs)MV8)~zNwfR4`N=c0?VZ*3S@oB- z#Ag%|HF6V*m!p@LN z4NAIf&J;zIlXI!pGHZdT_MoTt&PvLb{>(KRH%-(fZJwBSk<>ius)yFs^1IMl!&?f& zDKVZ$;*af?x$CntGu37{PIoQPd7V*`Q|)5XN9@}DOXJm-dfn#_)tu0<4Yi8Ri{{OL ztXeS7M0QxKcQ5sli}H*RC8LFcqnX!pVw}5N%k(n7!=32nRcfI>M@=uKHk@vV8Zto2 zQ{bz9zvS?QKZVXI?sGhMP;nr~pnmcN1*b=>8ZO4FTKicBj$Sk4BZ_`3qiVg0m&e_0as84bdyR zj?;hD4;F9LeQs{NQ@KAXu``|PzAeQ_tIy&3uYPhb&v*P@xwU^};jL2Hs*|Pcl}q6# z&kUG*eujQGS-nbmwPsyt+&6}vnijRVeof|?C6Ah&4DT#r=|gRD09nfvO6i<|eQ%O0mo09~%8(jY%KDpDm&&S7nkUx_zCY@wk63RQ$(0_L*UF%M-G4xJ zb#y^jRU{+!jtN#sBq(oIzI}Shl>z7F{jSD82TrbAf9CFPY(>)3`^@OnxmAjmYO>2a zYYQ)AsW+#1T{$*v-f&%<)pT3k41chfyI-D`@9kG)^hQx?^pKvQ!GD9bm~FlPK-=w* z`VF(L=?d|$=^X_=)6#AZ)`y^R|4w+;(R{4s*)^+H< zQP=5pt3Pv-hR;Uhxy6bND~2ts@~1A(QBx{y61N_|q5b`?lsIu=@E^urX-U_%74)>F z6rNc@Oea$H-MXb}S|-Czsc^<@=9YzBhU+_b_|jgQI^OL= zRRe#Jd1~u)9Ee+u9cY=P;_RkZXtJtjU1lu$gU5>RoO&iNl%HC2ZlRacNL5c0=V`fP ze9dq=eM4TL(WqW+d7J)B)-{dwXhcnG!xMaoXoRpRbG1W%EV3We)920`G;J@KbH1YEg0AX%`=9(Ie21wQ-4@$WO}{Fe;&`xH_qWGjJ0N6t zG#AZOt1$1olRC}o&jS}rY)h*npDeMd&RsX_mHl=@eWm5{UWCfA`>L|-gO{sk-0a;K zv$cxUiq8d&_lEm5OXnD@u#s{EGD z&B_IB+;f+T{2Wl7HxG$mk!0`7hq-UD*71IWTByuHw|&9I6N2SME?WW(v!)xLPg|H7ak)?4)c0T2Z6oC*UJTdMXXkY^;+@drdWl=)sd7)e}{(lP36_c8AWl) zj2TJ1c#iJbC2kspCy({MJCIEsrM;`jzZ!CEYV}i}y1cVLZd<$Msa!>V(J~dEVexQ* zY}dCVM-qpo-&W8t&_4=A70(KX)Al^ww{0cZM(aQ?smH9GBGqz#ZMI`g>#g6WJI31z zo}b<8ThZ@*Z+4B6Um4$P(QwDe!P~`_h(}DS%~u{R8hJ-!G@U)tYZ4eLH;jBvXg8#K zoIO(P`BjZmv;wA}r8Tnx#O7h(QU$O#0Wjzl65b>5x8`p^Wl`Su0l3VMgM9W!QS5t> R;-zsBxZX?1{|_Ya{s-hE5WoNc literal 0 HcmV?d00001 diff --git a/tests/test_fragment_at_offset.py b/tests/test_fragment_at_offset.py new file mode 100644 index 00000000..445743c4 --- /dev/null +++ b/tests/test_fragment_at_offset.py @@ -0,0 +1,537 @@ +"""Offset-aware single-file fragment verification (Castlabs native API). + +Scripted tests exercise Python gating, argument validation and marshalling +without the native entry point (they delegate to ``c2pa_reader_with_fragment``, +which every supported native library exports). Tests marked ``real native`` +need ``c2pa_reader_with_fragment_at_offset``: they skip when the loaded library +lacks it, and FAIL instead under ``C2PA_FRAGMENT_OFFSET_REQUIRED=1``. +""" + +import ctypes +import gc +import io +import json +import os +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import c2pa +import c2pa.c2pa as bindings +from c2pa import C2paError, Context, Reader, Settings + + +FIXTURES = Path(__file__).parent / "fixtures" +SIDX_FIXTURE = FIXTURES / "single-file-fragmented" / "single_file_fragments_sidx.mp4" +C2PA_BOX_UUID = bytes([ + 0xd8, 0xfe, 0xc3, 0xd6, 0x1b, 0x0e, 0x48, 0x3c, + 0x92, 0x97, 0x58, 0x28, 0x87, 0x7e, 0xc4, 0x81, +]) +MATCH = "assertion.bmffHash.match" +MISMATCH = "assertion.bmffHash.mismatch" +U64_MAX = 2**64 - 1 + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +def _forbid_native(monkeypatch): + """Replace _lib and Context so any native touch fails the test.""" + forbidden = Mock(side_effect=AssertionError("native touched")) + monkeypatch.setattr(bindings, "_lib", forbidden) + monkeypatch.setattr(bindings, "Context", forbidden) + return forbidden + + +def _enable_gate(monkeypatch): + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", True) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", True) + + +def _signed_reader(): + """A real Reader over a signed asset (no offset API involved).""" + return Reader(str(FIXTURES / "C.jpg")) + + +def _trust_context(): + settings = Settings.from_dict(json.loads( + (Path(__file__).parent / "trust_config_test_settings.json").read_text())) + try: + return Context(settings=settings) + finally: + settings.close() + + +def _merkle_uuid_starts(data: bytes): + """Start offsets of root C2PA ``merkle`` uuid boxes, in file order.""" + starts, pos = [], 0 + while pos + 8 <= len(data): + size = int.from_bytes(data[pos:pos + 4], "big") + kind = data[pos + 4:pos + 8] + header = 8 + if size == 0: + size = len(data) - pos + elif size == 1: + size = int.from_bytes(data[pos + 8:pos + 16], "big") + header = 16 + assert header <= size and pos + size <= len(data), f"bad box at {pos}" + payload = pos + header + if (kind == b"uuid" and pos + size >= payload + 16 + 4 + 7 + and data[payload:payload + 16] == C2PA_BOX_UUID + and data[payload + 20:payload + 27] == b"merkle\0"): + starts.append(pos) + pos += size + return starts + + +def _codes(reader, kind): + results = reader.get_validation_results() or {} + active = results.get("activeManifest") or {} + return {entry.get("code") for entry in active.get(kind, [])} + + +# -------------------------------------------------------------------------- +# Gating +# -------------------------------------------------------------------------- + +def test_exported_from_package(): + assert c2pa.has_fragment_at_offset is bindings.has_fragment_at_offset + assert "has_fragment_at_offset" in c2pa.__all__ + assert "has_fragment_at_offset" in bindings.__all__ + assert bindings._FRAGMENT_AT_OFFSET_FUNCTIONS == ( + "c2pa_reader_with_fragment_at_offset", + "c2pa_reader_fragment_at_offset_supported", + ) + # Optional: never part of the import-time required export set. + for name in bindings._FRAGMENT_AT_OFFSET_FUNCTIONS: + assert name not in bindings._REQUIRED_FUNCTIONS + + +@pytest.mark.parametrize("symbols, probe, expected", [ + (False, False, False), + (False, True, False), + (True, False, False), + (True, True, True), +]) +def test_gate_requires_symbols_and_probe(monkeypatch, symbols, probe, expected): + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", symbols) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", probe) + assert c2pa.has_fragment_at_offset() is expected + + +def test_gate_matches_loaded_library(): + present = all(hasattr(bindings._lib, name) + for name in bindings._FRAGMENT_AT_OFFSET_FUNCTIONS) + assert bindings._FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE is present + if not present: + assert c2pa.has_fragment_at_offset() is False + assert bindings._FRAGMENT_AT_OFFSET_PROBE is None, "probe never called" + + +@pytest.mark.parametrize("native_result", [True, False]) +def test_probe_is_lazy_and_cached(monkeypatch, native_result): + calls = [] + + class Library: + def c2pa_reader_fragment_at_offset_supported(self): + calls.append(1) + return native_result + monkeypatch.setattr(bindings, "_lib", Library()) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", True) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", None) + assert c2pa.has_fragment_at_offset() is native_result + assert c2pa.has_fragment_at_offset() is native_result + assert calls == [1] + + +def test_missing_symbols_never_call_probe(monkeypatch): + forbidden = Mock(side_effect=AssertionError("probe called")) + monkeypatch.setattr(bindings, "_lib", forbidden) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", False) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", None) + assert c2pa.has_fragment_at_offset() is False + assert forbidden.mock_calls == [] + + +@pytest.mark.parametrize("symbols, probe", [(False, False), (True, False), (False, True)]) +def test_unavailable_classmethod_raises_before_arguments_or_native( + monkeypatch, symbols, probe): + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_SYMBOLS_AVAILABLE", symbols) + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", probe) + forbidden = _forbid_native(monkeypatch) + # Arguments are deliberately invalid: gating must come first. + with pytest.raises(C2paError.NotSupported, match="fragment"): + Reader.from_fragment_at_offset(5, object(), object(), -1, context=object()) + assert forbidden.mock_calls == [] + + +def test_unavailable_instance_method_leaves_reader_unchanged(monkeypatch): + reader = _signed_reader() + try: + handle = reader._handle + json_before = reader.json() + monkeypatch.setattr(bindings, "_FRAGMENT_AT_OFFSET_PROBE", False) + real_lib = bindings._lib + forbidden = _forbid_native(monkeypatch) + with pytest.raises(C2paError.NotSupported): + reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(b"a"), io.BytesIO(b"b"), 0) + assert forbidden.mock_calls == [] + monkeypatch.setattr(bindings, "_lib", real_lib) + assert reader.is_valid + assert reader._handle is handle + assert reader.json() == json_before + finally: + reader.close() + + +# -------------------------------------------------------------------------- +# Argument validation (gate forced open, native forbidden) +# -------------------------------------------------------------------------- + +def _valid_args(): + return ["video/mp4", io.BytesIO(b"init"), io.BytesIO(b"fragment"), 0] + + +BAD_ARGS = [ + pytest.param(3, -1, ValueError, id="negative-offset"), + pytest.param(3, 2**64, ValueError, id="offset-over-u64"), + pytest.param(3, True, TypeError, id="bool-offset"), + pytest.param(3, 1.0, TypeError, id="float-offset"), + pytest.param(3, "0", TypeError, id="str-offset"), + pytest.param(3, None, TypeError, id="none-offset"), + pytest.param(0, b"video/mp4", TypeError, id="bytes-format"), + pytest.param(0, 7, TypeError, id="int-format"), + pytest.param(0, "video/mp4\0x", ValueError, id="nul-format"), + pytest.param(0, None, TypeError, id="none-format"), + pytest.param(0, "", ValueError, id="empty-format"), + pytest.param(0, " ", ValueError, id="blank-format"), + pytest.param(1, object(), TypeError, id="non-stream-init"), + pytest.param(1, "init.mp4", TypeError, id="path-init"), + pytest.param(2, b"fragment", TypeError, id="bytes-fragment"), + pytest.param(2, None, TypeError, id="none-fragment"), +] + + +@pytest.mark.parametrize("index, value, error", BAD_ARGS) +def test_classmethod_rejects_invalid_arguments_before_native( + monkeypatch, index, value, error): + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + args = _valid_args() + args[index] = value + with pytest.raises(error): + Reader.from_fragment_at_offset(*args) + assert forbidden.mock_calls == [] + + +def test_classmethod_rejects_same_stream_object(monkeypatch): + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + shared = io.BytesIO(b"x") + with pytest.raises(ValueError, match="distinct"): + Reader.from_fragment_at_offset("video/mp4", shared, shared, 0) + assert forbidden.mock_calls == [] + + +@pytest.mark.parametrize("context", [object(), "context"]) +def test_classmethod_rejects_non_context(monkeypatch, context): + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + with pytest.raises(TypeError, match="ContextProvider"): + Reader.from_fragment_at_offset(*_valid_args(), context=context) + assert forbidden.mock_calls == [] + + +def test_classmethod_rejects_closed_context(monkeypatch): + context = Context() + context.close() + _enable_gate(monkeypatch) + forbidden = _forbid_native(monkeypatch) + with pytest.raises(C2paError, match="Context is not valid"): + Reader.from_fragment_at_offset(*_valid_args(), context=context) + assert forbidden.mock_calls == [] + + +@pytest.mark.parametrize("index, value, error", BAD_ARGS) +def test_instance_method_rejects_invalid_arguments_and_keeps_reader( + monkeypatch, index, value, error): + reader = _signed_reader() + try: + handle = reader._handle + _enable_gate(monkeypatch) + real_lib = bindings._lib + forbidden = _forbid_native(monkeypatch) + args = _valid_args() + args[index] = value + with pytest.raises(error): + reader.with_fragment_at_offset(*args) + shared = io.BytesIO(b"x") + with pytest.raises(ValueError, match="distinct"): + reader.with_fragment_at_offset("video/mp4", shared, shared, 0) + assert forbidden.mock_calls == [] + monkeypatch.setattr(bindings, "_lib", real_lib) + assert reader.is_valid and reader._handle is handle + assert reader.json() + finally: + reader.close() + + +def test_instance_method_on_closed_reader_raises(monkeypatch): + _enable_gate(monkeypatch) + reader = _signed_reader() + reader.close() + with pytest.raises(C2paError, match="closed"): + reader.with_fragment_at_offset(*_valid_args()) + + +# -------------------------------------------------------------------------- +# Scripted marshalling: the scripted entry point records its arguments and +# delegates to the real c2pa_reader_with_fragment (same ownership contract). +# -------------------------------------------------------------------------- + +class _ScriptedOffsetCall: + def __init__(self, monkeypatch): + self.calls = [] + _enable_gate(monkeypatch) + real = bindings._lib.c2pa_reader_with_fragment + + def call(handle, fmt, stream, fragment, offset): + self.calls.append((fmt, stream, fragment, offset)) + return real(handle, fmt, stream, fragment) + monkeypatch.setattr( + bindings._lib, "c2pa_reader_with_fragment_at_offset", call, + raising=False) + + +def _dash_streams(): + return (io.BytesIO((FIXTURES / "dashinit.mp4").read_bytes()), + io.BytesIO((FIXTURES / "dash1.m4s").read_bytes())) + + +@pytest.mark.parametrize("offset", [0, 1, 2**32, U64_MAX]) +def test_scripted_classmethod_marshals_offset_and_pins_context(monkeypatch, offset): + scripted = _ScriptedOffsetCall(monkeypatch) + init, fragment = _dash_streams() + context = Context() + try: + with Reader.from_fragment_at_offset( + " Video/MP4 ", init, fragment, offset, context=context) as reader: + assert reader.is_valid + assert reader._context is context + assert reader.json() + finally: + context.close() + assert len(scripted.calls) == 1 + fmt, stream, frag, seen_offset = scripted.calls[0] + assert fmt == b"video/mp4" + assert seen_offset == offset + assert ctypes.cast(stream, ctypes.c_void_p).value != ctypes.cast( + frag, ctypes.c_void_p).value + + +def test_scripted_classmethod_without_context_uses_default_context(monkeypatch): + scripted = _ScriptedOffsetCall(monkeypatch) + created = [] + real_context = bindings.Context + + def tracking_context(*args, **kwargs): + context = real_context(*args, **kwargs) + created.append(context) + return context + monkeypatch.setattr(bindings, "Context", tracking_context) + init, fragment = _dash_streams() + with Reader.from_fragment_at_offset("video/mp4", init, fragment, 7) as reader: + assert reader.json() + assert reader._context is None + assert len(created) == 1 and not created[0].is_valid, \ + "the temporary default Context must be released" + assert scripted.calls[0][3] == 7 + + +def test_scripted_instance_method_swaps_handle_and_clears_caches(monkeypatch): + scripted = _ScriptedOffsetCall(monkeypatch) + with Context() as context: + init, fragment = _dash_streams() + with Reader("video/mp4", io.BytesIO(init.getvalue()), context=context) as reader: + reader.json() + reader._manifest_data_cache = {"stale": True} + result = reader.with_fragment_at_offset( + "video/mp4", init, fragment, 1234) + assert result is reader + assert reader.is_valid + assert reader._manifest_data_cache is None + assert reader._manifest_json_str_cache is None + assert reader.json() + assert scripted.calls[0][3] == 1234 + + +def test_scripted_native_failure_closes_instance_reader(monkeypatch): + scripted = _ScriptedOffsetCall(monkeypatch) + with Context() as context: + init, fragment = _dash_streams() + reader = Reader("video/mp4", io.BytesIO(init.getvalue()), context=context) + # An init stream without a manifest fails after native took the reader. + with pytest.raises(C2paError): + reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), fragment, 0) + assert not reader.is_valid + reader.close() + assert len(scripted.calls) == 1 + + +def test_scripted_native_failure_from_classmethod_raises_without_reader(monkeypatch): + _ScriptedOffsetCall(monkeypatch) + created = [] + real_wrap = bindings.Reader._wrap_native_handle.__func__ + + def tracking_wrap(cls, handle): + reader = real_wrap(cls, handle) + created.append(reader) + return reader + monkeypatch.setattr(bindings.Reader, "_wrap_native_handle", + classmethod(tracking_wrap)) + _, fragment = _dash_streams() + with Context() as context: + with pytest.raises(C2paError): + Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), fragment, 0, context=context) + assert len(created) == 1 + assert not created[0].is_valid + assert created[0]._context is None, "failed reader must release its pins" + + +# -------------------------------------------------------------------------- +# Real native +# -------------------------------------------------------------------------- + +def _require_native(): + if c2pa.has_fragment_at_offset(): + return + if os.environ.get("C2PA_FRAGMENT_OFFSET_REQUIRED") == "1": + pytest.fail("C2PA_FRAGMENT_OFFSET_REQUIRED=1 but the loaded native " + "library lacks c2pa_reader_with_fragment_at_offset or its " + "probe") + pytest.skip("native library lacks offset-aware fragment verification") + + +_DEFINITION = { + "claim_generator_info": [{"name": "fragment-at-offset-test", "version": "0.0.1"}], + "title": "fragment at offset", + "assertions": [{"label": "c2pa.actions", "data": {"actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation", + }]}}], +} + + +@pytest.fixture(scope="module") +def signed_cut(): + """Sign the per-fragment-sidx fixture; cut it merkle uuid to merkle uuid.""" + source = SIDX_FIXTURE.read_bytes() # Missing committed data is never a skip. + _require_native() + info = c2pa.C2paSignerInfo( + alg=b"es256", sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), ta_url=None) + dest = io.BytesIO() + with c2pa.Signer.from_info(info) as signer, \ + c2pa.Builder(_DEFINITION) as builder: + builder.sign(signer, "video/mp4", io.BytesIO(source), dest) + signed = dest.getvalue() + starts = _merkle_uuid_starts(signed) + assert len(starts) >= 2, f"expected several merkle uuid boxes, got {starts}" + init = signed[:starts[0]] + segments = [(start, signed[start:(starts[i + 1] if i + 1 < len(starts) + else len(signed))]) + for i, start in enumerate(starts)] + return signed, init, segments + + +@pytest.fixture +def trust_context(): + context = _trust_context() + yield context + context.close() + + +def test_real_native_export_signatures(): + _require_native() + call = bindings._lib.c2pa_reader_with_fragment_at_offset + assert call.argtypes == [ + ctypes.POINTER(bindings.C2paReader), ctypes.c_char_p, + ctypes.POINTER(bindings.C2paStream), ctypes.POINTER(bindings.C2paStream), + ctypes.c_uint64] + assert call.restype is ctypes.POINTER(bindings.C2paReader) + probe = bindings._lib.c2pa_reader_fragment_at_offset_supported + assert probe.argtypes == [] and probe.restype is ctypes.c_bool + assert probe() is True + + +def test_real_native_whole_file_is_trusted(signed_cut, trust_context): + signed, _, _ = signed_cut + with Reader("video/mp4", io.BytesIO(signed), context=trust_context) as reader: + assert reader.get_validation_state() == "Trusted", reader.json() + + +def test_real_native_each_segment_verifies_at_its_offset(signed_cut, trust_context): + _, init, segments = signed_cut + for offset, segment in segments: + with Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset, + context=trust_context) as reader: + assert reader.get_validation_state() == "Trusted", (offset, reader.json()) + assert MATCH in _codes(reader, "success"), (offset, reader.json()) + assert MISMATCH not in _codes(reader, "failure") + + +def test_real_native_segments_valid_without_trust_anchors(signed_cut): + _, init, segments = signed_cut + offset, segment = segments[-1] + with Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset) as reader: + assert reader.get_validation_state() == "Valid", reader.json() + assert MATCH in _codes(reader, "success") + + +def test_real_native_wrong_offset_reports_mismatch(signed_cut, trust_context): + _, init, segments = signed_cut + offset, segment = segments[1] + other_offset = segments[0][0] + for wrong in (0, offset + 1, offset - 1, other_offset): + with Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), wrong, + context=trust_context) as reader: + assert reader.get_validation_state() == "Invalid", (wrong, reader.json()) + assert MISMATCH in _codes(reader, "failure"), (wrong, reader.json()) + + +def test_real_native_instance_method_on_context_reader(signed_cut, trust_context): + signed, init, segments = signed_cut + offset, segment = segments[-1] + with Reader("video/mp4", io.BytesIO(signed), context=trust_context) as reader: + assert reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset) is reader + assert reader.get_validation_state() == "Trusted", reader.json() + assert MATCH in _codes(reader, "success") + assert reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(init), io.BytesIO(segment), offset + 1) is reader + assert reader.get_validation_state() == "Invalid" + assert MISMATCH in _codes(reader, "failure") + + +def test_real_native_unsigned_init_raises_and_closes(signed_cut, trust_context): + _, _, segments = signed_cut + offset, segment = segments[0] + with pytest.raises(C2paError): + Reader.from_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), io.BytesIO(segment), offset, + context=trust_context) + reader = Reader("video/mp4", io.BytesIO(signed_cut[0]), context=trust_context) + with pytest.raises(C2paError): + reader.with_fragment_at_offset( + "video/mp4", io.BytesIO(b"\0" * 8), io.BytesIO(segment), offset) + assert not reader.is_valid + reader.close() + gc.collect() From fb089822fe0cc70b675a5fe32b78458edad010eb Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 15 Oct 2026 17:57:21 +0200 Subject: [PATCH 41/46] chore(release): prepare 0.37.13.dev1 on qualified native 3328b2b4 --- .github/workflows/castlabs-vsi-pypi.yml | 38 ++++++------- .github/workflows/castlabs-vsi-release.yml | 54 +++++++++---------- .github/workflows/trusted-vsi-paired.yml | 14 ++--- README.md | 28 +++++----- docs/release-notes.md | 49 +++++++++++++---- docs/trusted-vsi-python-contract.md | 10 ++-- pyproject.toml | 2 +- release/castlabs-release-evidence.schema.json | 12 ++--- release/castlabs-vsi-inputs.lock.json | 14 ++--- release/castlabs-vsi-inputs.schema.json | 16 +++--- scripts/castlabs_release.py | 26 ++++----- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_smoke.py | 4 +- tests/test_castlabs_release_tooling.py | 53 ++++++++++-------- tests/test_trusted_vsi_build.py | 22 ++++---- 15 files changed, 197 insertions(+), 147 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 99814e93..45c81f59 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.8.dev5 + default: castlabs-v0.37.13.dev1 type: string concurrency: - group: castlabs-vsi-pypi-0.37.8.dev5 + group: castlabs-vsi-pypi-0.37.13.dev1 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.8.dev5 + ref: castlabs-v0.37.13.dev1 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.8.dev5 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.8.dev5 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.8.dev5 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.13.dev1 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.13.dev1 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.13.dev1 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.8.dev5)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.13.dev1)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.8.dev5 \ + gh release download castlabs-v0.37.13.dev1 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.8.dev5 \ + --source-ref refs/tags/castlabs-v0.37.13.dev1 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.8.dev5"); assert v.is_prerelease and str(v)=="0.37.8.dev5"' + python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev1"); assert v.is_prerelease and str(v)=="0.37.13.dev1"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.8.dev5/json) + https://pypi.org/pypi/c2pa-python/0.37.13.dev1/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 128ca2b2..7a2165fe 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,21 +3,21 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.8.dev5 + - castlabs-v0.37.13.dev1 workflow_dispatch: inputs: source_sha: - description: Full Python SHA (feat/live-video-vsi tip for dev5; paired source for trusted_vsi_only) + description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13.dev1; paired source for trusted_vsi_only) required: true type: string trusted_vsi_only: - description: Only non-publishing paired-source trusted API tests; bypass all dev5 jobs + description: Only non-publishing paired-source trusted API tests; bypass all release jobs required: false type: boolean default: false concurrency: - group: castlabs-vsi-release-0.37.8.dev5 + group: castlabs-vsi-release-0.37.13.dev1 cancel-in-progress: false permissions: @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de fetch-depth: 1 path: c2pa-rs @@ -80,7 +80,7 @@ jobs: shell: bash run: | git -C c2pa-python fetch --no-tags origin \ - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de fetch-depth: 1 path: c2pa-rs @@ -131,7 +131,7 @@ jobs: shell: bash run: | git -C c2pa-python fetch --no-tags origin \ - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ @@ -195,7 +195,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -215,7 +215,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -278,7 +278,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 9b80687f386f2b373fd1ac9c705d8e61a07b608e + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de fetch-depth: 1 path: c2pa-rs @@ -302,7 +302,7 @@ jobs: throw "checked-out Cargo.lock SHA-256 changed" } git -C c2pa-python fetch --no-tags origin ` - refs/heads/feat/live-video-vsi:refs/remotes/origin/feat/live-video-vsi + refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional $event = if ($env:GITHUB_REF.StartsWith("refs/tags/")) { "tag" } else { "manual" } python c2pa-python/scripts/castlabs_release.py validate-sources ` --python-root c2pa-python --rust-root c2pa-rs ` @@ -351,7 +351,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -371,7 +371,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -424,9 +424,9 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | test "$(find release-out -maxdepth 1 -name '*.whl' | wc -l)" -eq 1 python -m venv "${RUNNER_TEMP}/wheel-test" @@ -464,9 +464,9 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" - C2PA_SOURCE_BUILD_VERSION: 0.91.0-dev + C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true @@ -487,7 +487,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.8.dev5' + if: github.ref == 'refs/tags/castlabs-v0.37.13.dev1' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -522,7 +522,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.8.dev5-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -539,13 +539,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.8.dev5-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.8.dev5-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -559,7 +559,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.8.dev5 + TAG=castlabs-v0.37.13.dev1 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -575,7 +575,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.8.dev5 + TAG=castlabs-v0.37.13.dev1 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -601,7 +601,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.8.dev5")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.13.dev1")' > "$1" return 0 } query_release_id() { diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index cfdd83d7..fad74d99 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -34,9 +34,10 @@ jobs: C2PA_TRUSTED_VSI_FUNCTIONAL_REQUIRED: "1" C2PA_REQUIRE_SIGN_LADDER: "1" C2PA_REQUIRE_FRAGMENTED_FILES: "1" - FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0 + C2PA_FRAGMENT_OFFSET_REQUIRED: "1" + FUNCTIONAL_BUILD_VERSION: 0.37.13.dev1 # The release smoke asserts the installed distribution version. - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev0 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 steps: - name: Require a full Python source SHA shell: bash @@ -50,9 +51,10 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - # Integrated native review fixes plus the explicit revision-3 gate. - # Qualification-only source pairing; never an immutable dev5 input. - ref: a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c + # Revision-3 trusted VSI plus offset-aware fragment verification: + # the same native commit the 0.37.13.dev1 release lock pins. This job + # is qualification-only and never produces release artifacts. + ref: 3328b2b440fd07d9f857cbefa3c6d565093425de path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -94,7 +96,7 @@ jobs: shell: bash working-directory: python-source # The release smoke is required here (no module-level skip): it exercises - # dev5 capabilities against the paired functional native library. + # release capabilities against the paired functional native library. env: CASTLABS_RELEASE_SMOKE_REQUIRED: "1" run: python -m pytest -q -ra --ignore=tests/test_unit_tests_threaded.py -o faulthandler_timeout=120 diff --git a/README.md b/README.md index 1282af31..e9a26199 100644 --- a/README.md +++ b/README.md @@ -116,17 +116,17 @@ library (`-k "not paired"`). Separately, both it and the dedicated workflow use Linux/Windows source builds and the full focused `tests/test_trusted_vsi_api.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`. Missing reduced ABI symbols fail rather than skip. The paired Rust source is pinned to -`castlabs/c2pa-rs@cee86aae03887b5a0dddcd765a39e96360963bb0`; the job logs resolved -Python/Rust SHAs. This disabled-scaffold qualification pin is separate from -immutable dev5 release inputs and is not release-artifact evidence. +`castlabs/c2pa-rs@3328b2b440fd07d9f857cbefa3c6d565093425de`, the same commit as +the 0.37.13.dev1 release lock; the job logs resolved Python/Rust SHAs. The +paired job is qualification only and is not release-artifact evidence. For the dedicated workflow, manually select the trusted-API workflow revision, set `trusted_vsi_only=true`, and supply its full Python `source_sha`. This path -skips **all** dev5 prepare/build/test/publish jobs. The reusable job builds only -an isolated native library and source-import metadata, never stages a release -native, builds/uploads a wheel, or publishes anything. Ordinary dev5 dispatch -and tag behavior, native pins, evidence, and release identity remain unchanged. -Do not package this new ABI under a dev5 artifact name. +skips **all** release prepare/build/test/publish jobs. The reusable job builds +only an isolated native library and source-import metadata, never stages a +release native, uploads a wheel, or publishes anything. Ordinary release +dispatch and tag behavior, native pins, evidence, and release identity remain +unchanged. Do not package this ABI under the immutable dev5 artifact names. To test a locally built paired native without reinstalling the Python package: @@ -143,9 +143,9 @@ skips, or use `-k "not paired"` for baseline-only coverage. Neither is paired qualification. A fresh source checkout also needs distribution metadata for imports (`python setup.py egg_info`, without a native download or wheel build). -### Immutable dev5 process +### Immutable Castlabs VSI prerelease process -The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.8.dev5` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.88.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. +The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.13.dev1` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. The immutable `castlabs-v0.37.8.dev1` tag records failed prerelease workflow run `34030865864`. Linux compiled successfully but its combined DynamicAssertion-plus-VSI smoke used a 5-byte callback result for a 64-byte reservation and later failed with `assertion.bmffHash.mismatch`; Windows compiled successfully and failed only in platform-sensitive wheel metadata parsing. The Linux failure was an undersized callback contract violation, not an inherent incompatibility between DynamicAssertions and VSI. No dev1 draft or GitHub release was created, and the tag remains unchanged. @@ -157,17 +157,17 @@ The published `castlabs-v0.37.8.dev4` prerelease and its tag are immutable histo Release controls: -- Configure `castlabs-v0.37.8.dev5` as a protected tag and require approval for the `castlabs-vsi-release` environment. -- A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/live-video-vsi` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. +- Configure `castlabs-v0.37.13.dev1` as a protected tag and require approval for the `castlabs-vsi-release` environment. +- A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/trusted-vsi-functional` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. - Source checkout URLs, full SHAs, package/native versions, and the pinned `Cargo.lock` SHA-256 are asserted before each build. - The Linux wheel is built and repaired in the digest-pinned `manylinux_2_28_x86_64` image. Every wheel is tested without capability skips on clean Python 3.10 through 3.13; Python 3.10 also runs the broader unit and fragmented-file suites. - Draft release creation happens only after both platform builds and all wheel tests. Every Action is pinned by full commit SHA, every bundle and evidence document has a SHA-256 sidecar, and all release files are individually covered by GitHub build provenance. The full REST create response provides the candidate ID; an immediate exact-ID GET must confirm the expected tag, draft state, empty assets, and normalized ownership marker before cleanup can be armed. Strict validation then checks prerelease state, name, normalized release identity text, and documented `target_commitish` forms. The immutable tag is independently resolved to the source SHA immediately before creation and final verification, so GitHub's ignored/defaulted `target_commitish` is never the source authority. A rerun may discover a prior draft by list only when exactly one match exists; after discovery, all queries, uploads, and downloads use the exact release or asset ID. Missing assets upload through the exact `uploads.github.com` release-ID endpoint without clobber. Immediate upload digests are checked when available; final success always requires exact API digests and downloaded byte equality. Published, mismatched, duplicate, or unexpected state fails. Cleanup uses only exact ID, tag, empty draft state, and ownership marker, and is disabled before the first upload, so response loss and all asset-bearing or reconciled drafts are preserved for manual inspection. The completed output remains a draft intentionally for operator finalization. - [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) is a separate manual workflow protected by the `pypipublish` environment. It requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. - PyPI does not provide a multi-file transaction. Promotion therefore queries the version JSON API first. A fresh release uploads both platform wheels together; a retry accepts an existing wheel only when PyPI's SHA-256 exactly matches the local attested wheel, then uploads all missing wheels together. A mismatched or unexpected PyPI file fails. This policy intentionally publishes no source distribution for the two-platform prerelease and safely recovers from an unavoidable prior partial wheel upload. -Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.8.dev5 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. +Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.13.dev1 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. -`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to dev5. +`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13.dev1`. At the pinned c2pa-rs commit, VSI initialization signs through `Builder::from_shared_context`, so it inherits DynamicAssertions registered on the Context's claim signer. The common Python callback bridge enforces `reserve_size` as the exact serialized CBOR size for every signing path. The earlier `assertion.bmffHash.mismatch` came from returning only 5 bytes for a 64-byte reservation; it was not an inherent DynamicAssertion-plus-VSI incompatibility. The release smoke now returns valid exact-size CBOR, requires clean Reader validation for both standalone image and combined VSI init signing, and exercises combined media signing and recovery without an xfail. diff --git a/docs/release-notes.md b/docs/release-notes.md index 2d5bae3c..c8302e20 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,36 @@ # Release notes -## Unreleased: offset-aware fragment verification +## Version 0.37.13.dev1 + +Castlabs VSI prerelease (tag `castlabs-v0.37.13.dev1`, released from +`feat/trusted-vsi-functional`). It is the first release of the trusted-processor +VSI line. The immutable `0.37.8.dev5` release inputs and artifacts +(`feat/live-video-vsi`) stay unchanged. + +- Native `castlabs/c2pa-rs` `3328b2b440fd07d9f857cbefa3c6d565093425de` + (workspace `0.92.0-dev`, `Cargo.lock` SHA-256 + `f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370`), built + with Rust `1.96.0`. The previous line used `9b80687f` (`0.91.0-dev`, Rust + `1.88.0`). +- Native trusted-processor VSI line at contract revision 3 with the complete + capability mask 63. The Python probes require the explicit revision-3 probe, + the exact functional symbol set and mask 63, and otherwise fail closed. +- Dependency advisories fixed in the native lockfile: `event-listener` 5.4.2, + `rustls` 0.23.46 (RUSTSEC-2026-0346 to -0349) and `quinn-proto` 0.11.18 + (RUSTSEC-2026-0338 to -0342). +- Context trust policies keep the built-in default EKU allow-list. Before the + fix, `Store::from_context` cleared it, so `documentSigning`-only leaf + certificates failed with `signingCredential.invalid`. Default trust anchors + and private credentials are still not restored. +- Offset-aware fragment verification for single-file fragmented MP4: + `has_fragment_at_offset()`, `Reader.from_fragment_at_offset()` and + `Reader.with_fragment_at_offset()` (details below). The release smoke now + requires `has_fragment_at_offset()`. +- `c2pa-native-version.txt` stays `c2pa-v0.91.0`. It pins the upstream + prebuilt library that `build.yml` downloads. Source-built Castlabs libraries + are checked against `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` instead. + +### Offset-aware fragment verification - Adds optional bindings for `c2pa_reader_with_fragment_at_offset` and `c2pa_reader_fragment_at_offset_supported`, `has_fragment_at_offset()`, and @@ -9,7 +39,7 @@ offset. Native libraries without the exports still import; the API raises `C2paError.NotSupported`. -## Unreleased: functional trusted-processor VSI API +### Functional trusted-processor VSI API - Replaces the unshipped counter/result scaffold with `TrustedVsiSession.sign_sig_structure(sig_structure, sequence_number) @@ -27,13 +57,14 @@ - Capability probes require the exact functional symbol set, native version, and complete capability mask. Older native libraries fail closed. - Adds non-publishing Linux/Windows source and installed-wheel qualification. - Functional artifacts use the unreleased source identity `0.37.13.dev0` - (historically staged as `0.37.9.dev0` against native `3569fb86`). Immutable - dev5 release facts, pins and artifact names remain unchanged. Functional - native qualification is required, never an optional skip. -- Pairs with the consolidated native `0.92.0-dev` - (`castlabs/c2pa-rs@6b506352`, Rust 1.96.0) and integrates single-file ladder - signing (`Builder.sign_ladder`). Ladder signing now propagates + Functional artifacts use the source identity, `0.37.13.dev1` for this + release (before it `0.37.13.dev0`, and historically `0.37.9.dev0` against + native `3569fb86`). Immutable dev5 release facts, pins and artifact names + remain unchanged. Functional native qualification is required, never an + optional skip. +- First paired with the consolidated native `0.92.0-dev` + (`castlabs/c2pa-rs@6b506352`, Rust 1.96.0), now `3328b2b4`. Integrates + single-file ladder signing (`Builder.sign_ladder`). Ladder signing now propagates DynamicAssertion and claim-signer interrupt exceptions like the other Builder paths. Consume-first FFI calls (Reader/Builder `with_*`) no longer treat a registry rejection of another argument as proof the managed handle was diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 7d308ced..d711d7aa 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -3,10 +3,12 @@ Status: integrated, unreleased PR4 review changes requiring native trusted-VSI contract revision 3 (state version 3 and typed expert VSI payloads), following `c2pa-rs` -`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev0`; -immutable dev5 release inputs and artifacts are unchanged. The paired source -workflow pins native `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c` for the -integrated revision-3 qualification batch. Select a reviewed local library with +`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev1`, +prepared as the Castlabs VSI prerelease on native +`3328b2b440fd07d9f857cbefa3c6d565093425de`; immutable dev5 release inputs and +artifacts are unchanged. The paired source workflow pins the same native commit +(previously `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`) for revision-3 +qualification. Select a reviewed local library with `C2PA_LIBRARY_NAME` for integration tests. Version `0.92.0-dev` and mask 63 do not establish contract identity; Python now requires the explicit native contract-revision probe. Local source and installed-wheel checks passed; hosted results must be recorded diff --git a/pyproject.toml b/pyproject.toml index 9eb5b58f..3c49910a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.13.dev0" +version = "0.37.13.dev1" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index b9703c71..6a3d73b8 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/castlabs/c2pa-python/blob/feat/live-video-vsi/release/castlabs-release-evidence.schema.json", + "$id": "https://github.com/castlabs/c2pa-python/blob/feat/trusted-vsi-functional/release/castlabs-release-evidence.schema.json", "title": "Castlabs c2pa-python release evidence", "type": "object", "additionalProperties": false, @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.8.dev5"}, + "version": {"const": "0.37.13.dev1"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,9 +49,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "9b80687f386f2b373fd1ac9c705d8e61a07b608e"}, - "version": {"const": "0.91.0-dev"}, - "cargoLockSha256": {"const": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f"} + "commit": {"const": "3328b2b440fd07d9f857cbefa3c6d565093425de"}, + "version": {"const": "0.92.0-dev"}, + "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} } } } @@ -90,7 +90,7 @@ "type": "object", "additionalProperties": false, "required": ["target", "rustToolchain", "cargoCommand", "cargoTreeCommand", "noDefaultFeatures", "features", "resolvedFeatureReportSha256", "sourceDateEpoch", "cargoBuildJobs", "cargoIncremental", "pythonHashSeed", "container"], "properties": { - "target": {"enum": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]}, "rustToolchain": {"const": "1.88.0"}, + "target": {"enum": ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]}, "rustToolchain": {"const": "1.96.0"}, "cargoCommand": {"type": "array", "minItems": 12, "items": {"type": "string"}}, "noDefaultFeatures": {"const": true}, "cargoTreeCommand": {"type": "array", "minItems": 16, "items": {"type": "string"}}, "features": {"const": ["rust_native_crypto", "http", "add_thumbnails", "file_io", "unstable_live_video"]}, "resolvedFeatureReportSha256": {"$ref": "#/$defs/sha256"}, diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index c5e082f2..cfa8d565 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,23 +2,23 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.8.dev5" + "version": "0.37.13.dev1" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", - "releaseBranch": "feat/live-video-vsi", - "releaseTag": "castlabs-v0.37.8.dev5" + "releaseBranch": "feat/trusted-vsi-functional", + "releaseTag": "castlabs-v0.37.13.dev1" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "9b80687f386f2b373fd1ac9c705d8e61a07b608e", - "version": "0.91.0-dev", - "cargoLockSha256": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f" + "commit": "3328b2b440fd07d9f857cbefa3c6d565093425de", + "version": "0.92.0-dev", + "cargoLockSha256": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" }, "rustToolchain": { - "channel": "1.88.0", + "channel": "1.96.0", "rustupVersion": "1.28.2", "installers": { "x86_64-unknown-linux-gnu": { diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 7fdb3df7..e0460536 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/castlabs/c2pa-python/blob/feat/live-video-vsi/release/castlabs-vsi-inputs.schema.json", + "$id": "https://github.com/castlabs/c2pa-python/blob/feat/trusted-vsi-functional/release/castlabs-vsi-inputs.schema.json", "title": "Castlabs VSI c2pa-python release input lock", "type": "object", "additionalProperties": false, @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.8.dev5"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13.dev1"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -38,8 +38,8 @@ "properties": { "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, - "releaseBranch": {"const": "feat/live-video-vsi"}, - "releaseTag": {"const": "castlabs-v0.37.8.dev5"} + "releaseBranch": {"const": "feat/trusted-vsi-functional"}, + "releaseTag": {"const": "castlabs-v0.37.13.dev1"} } }, "rustSource": { @@ -48,9 +48,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "9b80687f386f2b373fd1ac9c705d8e61a07b608e"}, - "version": {"const": "0.91.0-dev"}, - "cargoLockSha256": {"const": "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f"} + "commit": {"const": "3328b2b440fd07d9f857cbefa3c6d565093425de"}, + "version": {"const": "0.92.0-dev"}, + "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} } }, "installer": { @@ -62,7 +62,7 @@ "type": "object", "additionalProperties": false, "required": ["channel", "rustupVersion", "installers"], "properties": { - "channel": {"const": "1.88.0"}, + "channel": {"const": "1.96.0"}, "rustupVersion": {"const": "1.28.2"}, "installers": { "type": "object", "additionalProperties": false, diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index a280922f..a093f741 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,23 +26,25 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.8.dev5" -RUST_COMMIT = "9b80687f386f2b373fd1ac9c705d8e61a07b608e" -CARGO_LOCK_SHA256 = "c4554b8fd3a1d3a1dc00482546750a40b06d0e5a86c9e66f4830c63fbd0df70f" -RUST_TOOLCHAIN = "1.88.0" +RELEASE_VERSION = "0.37.13.dev1" +RUST_COMMIT = "3328b2b440fd07d9f857cbefa3c6d565093425de" +CARGO_LOCK_SHA256 = "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" +RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.8.dev5" +RELEASE_TAG = "castlabs-v0.37.13.dev1" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" - f"{RELEASE_IDENTITY_TEXT}. Successor to immutable dev4; enforces exact " - "DynamicAssertion callback sizing for every signing path. See the attached " - "schema-2 evidence and SHA-256 sidecars." + f"{RELEASE_IDENTITY_TEXT}. Trusted-processor VSI line (native contract " + "revision 3, capability mask 63) on castlabs/c2pa-rs 3328b2b4 (0.92.0-dev, " + "Rust 1.96.0): offset-aware single-file fMP4 fragment verification, default " + "EKU trust-policy fix and patched event-listener/rustls/quinn-proto. See " + "the attached schema-2 evidence and SHA-256 sidecars." ) @@ -218,12 +220,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.8.dev5") + fail("release lock package identity is not 0.37.13.dev1") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", - "releaseBranch": "feat/live-video-vsi", - "releaseTag": "castlabs-v0.37.8.dev5", + "releaseBranch": "feat/trusted-vsi-functional", + "releaseTag": "castlabs-v0.37.13.dev1", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] @@ -231,7 +233,7 @@ def validate_lock(lock: dict[str, Any]) -> None: "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", "commit": RUST_COMMIT, - "version": "0.91.0-dev", + "version": "0.92.0-dev", "cargoLockSha256": CARGO_LOCK_SHA256, }: fail("unexpected c2pa-rs release source policy") diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b73751c2..57f61898 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -11,7 +11,7 @@ # specific language governing permissions and limitations under # each license. -# Version: 0.37.13.dev0 +# Version: 0.37.13.dev1 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 0058218b..95c1fd33 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -34,6 +34,7 @@ Settings, Signer, has_dynamic_assertions, + has_fragment_at_offset, has_fragmented_files, has_live_video_vsi, has_live_video_vsi_callbacks, @@ -45,10 +46,11 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.8.dev5") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13.dev1") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, + "fragment at offset": has_fragment_at_offset, "live-video VSI": has_live_video_vsi, "VSI callbacks": has_live_video_vsi_callbacks, "VSI recovery": has_live_video_vsi_recovery, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index d383cf25..6f160c4e 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -49,15 +49,13 @@ def _posix_bash() -> str: def test_prerelease_version_is_consistent(): - # The immutable dev5 release identity stays frozen in the tooling and lock. - assert release.RELEASE_VERSION == "0.37.8.dev5" - assert release.load_lock()["package"]["version"] == "0.37.8.dev5" - # This checkout is the unreleased consolidated functional source. Its - # identity is consistent and deliberately differs from dev5, so - # validate-sources refuses to release it under the dev5 lock. + # The release identity is consistent across the tooling, lock and source, + # so validate-sources accepts this checkout under the 0.37.13.dev1 lock. + assert release.RELEASE_VERSION == "0.37.13.dev1" + assert release.RELEASE_TAG == "castlabs-v0.37.13.dev1" + assert release.load_lock()["package"]["version"] == "0.37.13.dev1" source = release.project_version(ROOT) - assert source == "0.37.13.dev0" - assert source != release.RELEASE_VERSION + assert source == release.RELEASE_VERSION first_line = ( (ROOT / "src" / "c2pa" / "c2pa.py").read_text(encoding="utf-8").splitlines()[13] ) @@ -69,7 +67,13 @@ def test_release_lock_and_schemas_are_valid_json(): release.validate_lock(lock) assert lock["rustSource"]["commit"] == release.RUST_COMMIT assert lock["rustSource"]["cargoLockSha256"] == release.CARGO_LOCK_SHA256 - assert lock["rustToolchain"]["channel"] == "1.88.0" + assert lock["rustToolchain"]["channel"] == "1.96.0" + assert release.RUST_COMMIT == "3328b2b440fd07d9f857cbefa3c6d565093425de" + assert release.CARGO_LOCK_SHA256 == ( + "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" + ) + assert lock["rustSource"]["version"] == "0.92.0-dev" + assert lock["pythonSource"]["releaseBranch"] == "feat/trusted-vsi-functional" for name in ( "castlabs-vsi-inputs.schema.json", "castlabs-release-evidence.schema.json", @@ -88,7 +92,7 @@ def test_release_lock_and_schemas_are_valid_json(): jsonschema.validate(lock, schema) -def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): +def test_trusted_vsi_workflows_isolate_paired_abi_from_release(): workflow_dir = ROOT / ".github" / "workflows" paired = (workflow_dir / "trusted-vsi-paired.yml").read_text(encoding="utf-8") legacy = (workflow_dir / "build.yml").read_text(encoding="utf-8") @@ -114,7 +118,11 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_dev5(): # Paired native is pinned to a reviewed full SHA, never a moving branch. assert re.search(r"^\s+ref: [0-9a-f]{40}$", paired, re.MULTILINE) assert "ref: feat/" not in paired - assert "FUNCTIONAL_BUILD_VERSION: 0.37.13.dev0" in paired + assert f"FUNCTIONAL_BUILD_VERSION: {release.RELEASE_VERSION}" in paired + assert f"CASTLABS_RELEASE_EXPECTED_VERSION: {release.RELEASE_VERSION}" in paired + # The paired qualification uses the release-locked native commit. + assert f"ref: {release.RUST_COMMIT}" in paired + assert 'C2PA_FRAGMENT_OFFSET_REQUIRED: "1"' in paired assert "C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev" in paired assert 'C2PA_REQUIRE_SIGN_LADDER: "1"' in paired assert 'C2PA_REQUIRE_FRAGMENTED_FILES: "1"' in paired @@ -187,7 +195,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -238,7 +246,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.8.dev5'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.13.dev1'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -312,9 +320,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.8.dev5" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.13.dev1" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.8.dev5/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.13.dev1/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -343,7 +351,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.8.dev5' not in legacy_workflow + assert 'test "$VERSION" != 0.37.13.dev1' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -373,7 +381,10 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.8.dev5") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1") == 2 + assert release_workflow.count("C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev") == 2 + # The pinned native exports offset-aware fragment verification. + assert '"fragment at offset": has_fragment_at_offset,' in smoke assert "pytest.skip(" in smoke assert "unittest.skip" not in smoke dynamic_start = smoke.index( @@ -436,7 +447,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.8.dev5" in release_notes + assert "## Version 0.37.13.dev1" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -631,7 +642,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.8.dev5-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -642,7 +653,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.8.dev5-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: @@ -1070,7 +1081,7 @@ def test_release_response_validators_cover_owned_empty_and_complete_states(tmp_p ) == release_id ) - for allowed_target in ("main", "feat/live-video-vsi", TEST_SOURCE_SHA): + for allowed_target in ("main", "feat/trusted-vsi-functional", TEST_SOURCE_SHA): target_response = copy.deepcopy(create_response) target_response["target_commitish"] = allowed_target assert ( diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index f4df4c03..704a43a0 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -15,7 +15,7 @@ SPEC.loader.exec_module(functional_build) -@pytest.mark.parametrize("version", ["0.37.13.dev0", "0.37.13.dev1", "0.38.0.dev1", "1.0.dev0+local"]) +@pytest.mark.parametrize("version", ["0.37.13.dev1", "0.37.13.dev2", "0.38.0.dev1", "1.0.dev0+local"]) def test_new_functional_development_version(version): assert functional_build.functional_version(version) == version @@ -24,7 +24,7 @@ def test_new_functional_development_version(version): "0.37.8.dev5", "0.37.8.dev6", "0.37.8.dev5+functional", "0.37.7.dev1", "0.37.9", "0.38.0rc1", "invalid", # Historical functional qualification identities are older than the source. - "0.37.9.dev0", "0.37.12.dev0", + "0.37.9.dev0", "0.37.12.dev0", "0.37.13.dev0", ]) def test_functional_build_rejects_release_or_dev5_identity(version): with pytest.raises(ValueError): @@ -39,9 +39,9 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): before = {name: (ROOT / name).read_bytes() for name in ("pyproject.toml", "src/c2pa/c2pa.py")} stage = tmp_path / "stage" stage.mkdir() - functional_build.stage_source(stage, "0.37.13.dev1") - assert 'version = "0.37.13.dev1"' in (stage / "pyproject.toml").read_text() - assert "# Version: 0.37.13.dev1" in (stage / "src/c2pa/c2pa.py").read_text() + functional_build.stage_source(stage, "0.37.13.dev2") + assert 'version = "0.37.13.dev2"' in (stage / "pyproject.toml").read_text() + assert "# Version: 0.37.13.dev2" in (stage / "src/c2pa/c2pa.py").read_text() assert not (stage / "src/c2pa/libs").exists() subprocess.run([sys.executable, "setup.py", "-q", "sdist", "--dist-dir", str(tmp_path / "dist")], cwd=stage, check=True, capture_output=True) @@ -50,12 +50,12 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): (sdist,) = (tmp_path / "dist").iterdir() name, version = parse_sdist_filename(sdist.name) assert canonicalize_name(name) == "c2pa-python" - assert version == Version("0.37.13.dev1") + assert version == Version("0.37.13.dev2") root = sdist.name[:-len(".tar.gz")] with tarfile.open(sdist) as archive: names = archive.getnames() info = archive.extractfile(f"{root}/PKG-INFO").read().decode() - assert "Version: 0.37.13.dev1" in info.splitlines() + assert "Version: 0.37.13.dev2" in info.splitlines() for member in ("scripts/build_trusted_vsi_functional.py", "scripts/qualify_trusted_vsi_functional.py", "docs/trusted-vsi-python-contract.md", "src/c2pa/c2pa.py"): @@ -63,14 +63,14 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): assert not any(name.endswith((".so", ".dll", ".dylib")) for name in names) after = {name: (ROOT / name).read_bytes() for name in before} assert after == before - # The checkout carries the unreleased functional source identity, never dev5. - assert 'version = "0.37.13.dev0"' in before["pyproject.toml"].decode() + # The checkout keeps its own source identity; staging never rewrites it. + assert 'version = "0.37.13.dev1"' in before["pyproject.toml"].decode() def test_default_build_version_is_the_source_identity(monkeypatch): monkeypatch.delenv("FUNCTIONAL_BUILD_VERSION", raising=False) - assert functional_build.source_version() == "0.37.13.dev0" - assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev0" + assert functional_build.source_version() == "0.37.13.dev1" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev1" def test_probe_script_requires_every_functional_capability(): From 361fb30c4e1500543e284503f3bd40af0487b6eb Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 15 Oct 2026 18:06:46 +0200 Subject: [PATCH 42/46] docs: align trusted VSI contract and README with the 0.37.13.dev1 pins --- README.md | 14 ++++++++------ docs/trusted-vsi-python-contract.md | 30 ++++++++++++++--------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e9a26199..537eaab6 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,14 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH ### Unreleased trusted-processor API qualification -The [trusted-processor target API](docs/usage.md#trusted-processor-vsi-unreleased-disabled) -is disabled and separate from complete-buffer VSI and immutable dev5. Expert -signing accepts only exact `Sig_structure` bytes and targets a frozen -`TrustedVsiSignResult` (64-byte signature, uint32 sequence, optional inclusive -maximum). Every trusted probe remains false, import makes no native trusted -capability call, and session construction/operations fail before side effects. +The [trusted-processor API](docs/usage.md#trusted-processor-vsi-unreleased-functional-api) +is separate from complete-buffer VSI and immutable dev5. It is first released in +`0.37.13.dev1`. Its probes are true only on a native library reporting trusted +contract revision 3 and capability mask 63 (the pinned release native does); +otherwise every probe is false and session construction/operations fail before +side effects. Expert signing accepts only exact `Sig_structure` bytes and returns +the 64-byte signature; the earlier `TrustedVsiSignResult` was removed. Import +makes no native trusted capability call. `build.yml` runs baseline-gated tests against its upstream downloaded native library (`-k "not paired"`). Separately, both it and the dedicated workflow use diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index d711d7aa..cd3c4f51 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -243,14 +243,14 @@ CBOR, BMFF or validation results. ` first requires all probes true against that library, then builds a NON-PUBLISHING wheel and sdist in a temporary staging copy with `FUNCTIONAL_BUILD_VERSION` (default: the checkout's source identity -`0.37.13.dev0`; rejects `0.37.8.*`, dev5, release versions and anything older -than the source identity, including the historical `0.37.9.dev0`). -`C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native library only. The -immutable dev5 release lock and tooling are unchanged and refuse this source, -whose `pyproject.toml` version differs from the lock. +`0.37.13.dev1`; rejects `0.37.8.*`, dev5, release versions and anything older +than the source identity, including `0.37.13.dev0` and the historical +`0.37.9.dev0`). `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native +library only. The release lock and tooling now match this source +(`castlabs-v0.37.13.dev1`); the immutable dev5 release remains historical. `scripts/qualify_trusted_vsi_functional.py --wheel --venv ---version 0.37.13.dev0` installs the wheel into an isolated venv, strips +--version 0.37.13.dev1` installs the wheel into an isolated venv, strips `PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, `test_fragmented_files.py`, `test_sign_ladder.py`, `test_native_ownership.py` and `test_native_ownership_opaque.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, @@ -262,16 +262,14 @@ rather than skip. Paired tests require the full native library and FAIL under `C2PA_TRUSTED_VSI_ABI_REQUIRED=1` (all Linux/Windows qualification jobs); they skip only in ad-hoc local runs. `.github/workflows/trusted-vsi-paired.yml` -builds the native with Rust 1.96.0 from the reviewed consolidated commit -`6b506352800c8225cf5564ce99c726aaa71039f4` (ContentAuth main `69907b5a` merged -plus CI-only fixes; previously `203dc08d`, before that `5c186c07`). That older -pin emits state version 2 and lacks the required revision probe, so Python -disables trusted VSI before binding its operational ABI. Required qualification -fails with missing-symbol/revision diagnostics rather than skipping. The preserved -step2 review cdylib (`149f4b25...`) also lacks the new probe and cannot qualify -this gate. No tests or capability gates are weakened to accommodate this hold. -Final native integration/qualification and a full-SHA repin belong to a separate -authorized step, not these Python-only review changes. +builds the native with Rust 1.96.0 from castlabs/c2pa-rs +`3328b2b440fd07d9f857cbefa3c6d565093425de`, the same commit as the +`castlabs-v0.37.13.dev1` release lock (trusted contract revision 3, mask 63, +offset-aware fragment exports). It sets `C2PA_FRAGMENT_OFFSET_REQUIRED=1`, so a +native library without the fragment-offset probe fails rather than skips. Older +pins (`6b506352`, `203dc08d`, `5c186c07`) emitted state version 2 and lacked the +revision probe; they cannot qualify this gate. No tests or capability gates are +weakened. ## PR4 CI Gate From 563e37b94580dd8da8db08a43b9d411734ff9755 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 15 Oct 2026 23:13:10 +0200 Subject: [PATCH 43/46] chore(release): prepare 0.37.13.dev2 on qualified native afd6fffe Pin native castlabs/c2pa-rs afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 (the 1.0.8 trusted VSI native line merged with the conformance line; workspace 0.92.0-dev, Cargo.lock SHA-256 f7022b3e..., Rust 1.96.0) and release 0.37.13.dev2 from feat/trusted-vsi-functional as castlabs-v0.37.13.dev2. castlabs-v0.37.13.dev1 (run 38786913569) was not published: its installed-wheel test jobs lacked pytest. The paired qualification uses the same native commit; functional build identities dev1 and older are rejected. --- .github/workflows/castlabs-vsi-pypi.yml | 38 ++++++++-------- .github/workflows/castlabs-vsi-release.yml | 42 +++++++++--------- .github/workflows/trusted-vsi-paired.yml | 8 ++-- README.md | 16 ++++--- docs/release-notes.md | 44 ++++++++++++++++--- docs/trusted-vsi-python-contract.md | 19 ++++---- pyproject.toml | 2 +- release/castlabs-release-evidence.schema.json | 4 +- release/castlabs-vsi-inputs.lock.json | 6 +-- release/castlabs-vsi-inputs.schema.json | 6 +-- scripts/castlabs_release.py | 18 ++++---- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_smoke.py | 2 +- tests/test_castlabs_release_tooling.py | 32 ++++++++------ tests/test_trusted_vsi_build.py | 22 +++++----- 15 files changed, 153 insertions(+), 108 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 45c81f59..724c73df 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -6,11 +6,11 @@ on: release_tag: description: Existing immutable Castlabs prerelease tag required: true - default: castlabs-v0.37.13.dev1 + default: castlabs-v0.37.13.dev2 type: string concurrency: - group: castlabs-vsi-pypi-0.37.13.dev1 + group: castlabs-vsi-pypi-0.37.13.dev2 cancel-in-progress: false permissions: @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python - ref: castlabs-v0.37.13.dev1 + ref: castlabs-v0.37.13.dev2 fetch-depth: 0 path: c2pa-python @@ -45,13 +45,13 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_RELEASE_TAG: ${{ inputs.release_tag }} run: | - test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.13.dev1 - test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.13.dev1 - test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.13.dev1 --jq '(.draft == false and .prerelease == true)')" = true + test "${INPUT_RELEASE_TAG}" = castlabs-v0.37.13.dev2 + test "$(python3 c2pa-python/scripts/castlabs_release.py lock-value package.version)" = 0.37.13.dev2 + test "$(gh api repos/castlabs/c2pa-python/releases/tags/castlabs-v0.37.13.dev2 --jq '(.draft == false and .prerelease == true)')" = true SOURCE_SHA=$(git -C c2pa-python rev-parse HEAD) - test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.13.dev1)" = "$SOURCE_SHA" + test "$(git -C c2pa-python rev-list -n 1 castlabs-v0.37.13.dev2)" = "$SOURCE_SHA" mkdir -p download dist - gh release download castlabs-v0.37.13.dev1 \ + gh release download castlabs-v0.37.13.dev2 \ --repo castlabs/c2pa-python --dir download python3 c2pa-python/scripts/castlabs_release.py validate-release-assets \ --assets-dir download @@ -63,29 +63,29 @@ jobs: --repo castlabs/c2pa-python \ --signer-workflow castlabs/c2pa-python/.github/workflows/castlabs-vsi-release.yml \ --signer-digest "$SOURCE_SHA" \ - --source-ref refs/tags/castlabs-v0.37.13.dev1 \ + --source-ref refs/tags/castlabs-v0.37.13.dev2 \ --source-digest "$SOURCE_SHA" \ --deny-self-hosted-runners done python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence download/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz.evidence.json \ + --evidence download/c2pa-python-0.37.13.dev2-py3-none-wheels.tar.gz.evidence.json \ --artifact-dir download --source-sha "$SOURCE_SHA" python3 c2pa-python/scripts/castlabs_release.py extract \ - --archive download/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz \ + --archive download/c2pa-python-0.37.13.dev2-py3-none-wheels.tar.gz \ --destination dist test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 2 - cmp dist/c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl \ - download/c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl - cmp dist/c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl \ - download/c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl + cmp dist/c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl \ + download/c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl + cmp dist/c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl \ + download/c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev1"); assert v.is_prerelease and str(v)=="0.37.13.dev1"' + python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev2"); assert v.is_prerelease and str(v)=="0.37.13.dev2"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set @@ -94,7 +94,7 @@ jobs: run: | STATUS=$(curl --silent --show-error --location \ --output pypi-response.json --write-out '%{http_code}' \ - https://pypi.org/pypi/c2pa-python/0.37.13.dev1/json) + https://pypi.org/pypi/c2pa-python/0.37.13.dev2/json) case "$STATUS" in 200|404) ;; *) echo "unexpected PyPI JSON API status: $STATUS" >&2; exit 1 ;; diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 7e30dbca..772a7e50 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -3,11 +3,11 @@ name: Castlabs VSI prerelease on: push: tags: - - castlabs-v0.37.13.dev1 + - castlabs-v0.37.13.dev2 workflow_dispatch: inputs: source_sha: - description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13.dev1; paired source for trusted_vsi_only) + description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13.dev2; paired source for trusted_vsi_only) required: true type: string trusted_vsi_only: @@ -17,7 +17,7 @@ on: default: false concurrency: - group: castlabs-vsi-release-0.37.13.dev1 + group: castlabs-vsi-release-0.37.13.dev2 cancel-in-progress: false permissions: @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 3328b2b440fd07d9f857cbefa3c6d565093425de + ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 fetch-depth: 1 path: c2pa-rs @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 3328b2b440fd07d9f857cbefa3c6d565093425de + ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 fetch-depth: 1 path: c2pa-rs @@ -195,7 +195,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -215,7 +215,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -278,7 +278,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: 3328b2b440fd07d9f857cbefa3c6d565093425de + ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 fetch-depth: 1 path: c2pa-rs @@ -351,7 +351,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -371,7 +371,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -424,7 +424,7 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -466,7 +466,7 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -490,7 +490,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.13.dev1' + if: github.ref == 'refs/tags/castlabs-v0.37.13.dev2' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -525,7 +525,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.13.dev1-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.13.dev2-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -542,13 +542,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.13.dev1-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.13.dev1-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -562,7 +562,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.13.dev1 + TAG=castlabs-v0.37.13.dev2 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -578,7 +578,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.13.dev1 + TAG=castlabs-v0.37.13.dev2 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -604,7 +604,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.13.dev1")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.13.dev2")' > "$1" return 0 } query_release_id() { diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index fad74d99..7983e116 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -35,9 +35,9 @@ jobs: C2PA_REQUIRE_SIGN_LADDER: "1" C2PA_REQUIRE_FRAGMENTED_FILES: "1" C2PA_FRAGMENT_OFFSET_REQUIRED: "1" - FUNCTIONAL_BUILD_VERSION: 0.37.13.dev1 + FUNCTIONAL_BUILD_VERSION: 0.37.13.dev2 # The release smoke asserts the installed distribution version. - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 steps: - name: Require a full Python source SHA shell: bash @@ -52,9 +52,9 @@ jobs: with: repository: castlabs/c2pa-rs # Revision-3 trusted VSI plus offset-aware fragment verification: - # the same native commit the 0.37.13.dev1 release lock pins. This job + # the same native commit the 0.37.13.dev2 release lock pins. This job # is qualification-only and never produces release artifacts. - ref: 3328b2b440fd07d9f857cbefa3c6d565093425de + ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/README.md b/README.md index 537eaab6..cfbda439 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH The [trusted-processor API](docs/usage.md#trusted-processor-vsi-unreleased-functional-api) is separate from complete-buffer VSI and immutable dev5. It is first released in -`0.37.13.dev1`. Its probes are true only on a native library reporting trusted +`0.37.13.dev2`. Its probes are true only on a native library reporting trusted contract revision 3 and capability mask 63 (the pinned release native does); otherwise every probe is false and session construction/operations fail before side effects. Expert signing accepts only exact `Sig_structure` bytes and returns @@ -118,8 +118,8 @@ library (`-k "not paired"`). Separately, both it and the dedicated workflow use Linux/Windows source builds and the full focused `tests/test_trusted_vsi_api.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`. Missing reduced ABI symbols fail rather than skip. The paired Rust source is pinned to -`castlabs/c2pa-rs@3328b2b440fd07d9f857cbefa3c6d565093425de`, the same commit as -the 0.37.13.dev1 release lock; the job logs resolved Python/Rust SHAs. The +`castlabs/c2pa-rs@afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`, the same commit as +the 0.37.13.dev2 release lock; the job logs resolved Python/Rust SHAs. The paired job is qualification only and is not release-artifact evidence. For the dedicated workflow, manually select the trusted-API workflow revision, @@ -147,7 +147,7 @@ imports (`python setup.py egg_info`, without a native download or wheel build). ### Immutable Castlabs VSI prerelease process -The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.13.dev1` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. +The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.13.dev2` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. The immutable `castlabs-v0.37.8.dev1` tag records failed prerelease workflow run `34030865864`. Linux compiled successfully but its combined DynamicAssertion-plus-VSI smoke used a 5-byte callback result for a 64-byte reservation and later failed with `assertion.bmffHash.mismatch`; Windows compiled successfully and failed only in platform-sensitive wheel metadata parsing. The Linux failure was an undersized callback contract violation, not an inherent incompatibility between DynamicAssertions and VSI. No dev1 draft or GitHub release was created, and the tag remains unchanged. @@ -157,9 +157,11 @@ The immutable `castlabs-v0.37.8.dev3` tag records failed prerelease workflow run The published `castlabs-v0.37.8.dev4` prerelease and its tag are immutable historical release artifacts and remain unchanged. Dev4 used the earlier maximum-size DynamicAssertion callback contract, which accepted outputs shorter than `reserve_size`. Dev5 intentionally makes that contract stricter: exact callback sizing is enforced for every signing path, and both undersized and oversized outputs are rejected. +The immutable `castlabs-v0.37.13.dev1` tag (source `78771fa`, native `3328b2b4`) records failed prerelease workflow run `38786913569`. The Linux build succeeded, but every Linux installed-wheel test job (Python 3.10 through 3.13) failed with `No module named pytest`: pytest is no longer a runtime dependency of the wheel (upstream `4d909cf`) and the clean test venv did not install it. The Windows jobs and the release job were cancelled. No dev1 draft or GitHub release was created, and the tag remains unchanged. Dev2 installs `pytest==8.4.1` after the wheel in both the Linux and Windows installed-wheel steps, and pairs with native `afd6fffe`, which merges the Conformance Program 0.2 / C2PA 2.4 line into the 1.0.8 trusted VSI native line. + Release controls: -- Configure `castlabs-v0.37.13.dev1` as a protected tag and require approval for the `castlabs-vsi-release` environment. +- Configure `castlabs-v0.37.13.dev2` as a protected tag and require approval for the `castlabs-vsi-release` environment. - A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/trusted-vsi-functional` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. - Source checkout URLs, full SHAs, package/native versions, and the pinned `Cargo.lock` SHA-256 are asserted before each build. - The Linux wheel is built and repaired in the digest-pinned `manylinux_2_28_x86_64` image. Every wheel is tested without capability skips on clean Python 3.10 through 3.13; Python 3.10 also runs the broader unit and fragmented-file suites. @@ -167,9 +169,9 @@ Release controls: - [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) is a separate manual workflow protected by the `pypipublish` environment. It requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. - PyPI does not provide a multi-file transaction. Promotion therefore queries the version JSON API first. A fresh release uploads both platform wheels together; a retry accepts an existing wheel only when PyPI's SHA-256 exactly matches the local attested wheel, then uploads all missing wheels together. A mismatched or unexpected PyPI file fails. This policy intentionally publishes no source distribution for the two-platform prerelease and safely recovers from an unavoidable prior partial wheel upload. -Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.13.dev1 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. +Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.13.dev2 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. -`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13.dev1`. +`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13.dev2`. At the pinned c2pa-rs commit, VSI initialization signs through `Builder::from_shared_context`, so it inherits DynamicAssertions registered on the Context's claim signer. The common Python callback bridge enforces `reserve_size` as the exact serialized CBOR size for every signing path. The earlier `assertion.bmffHash.mismatch` came from returning only 5 bytes for a 64-byte reservation; it was not an inherent DynamicAssertion-plus-VSI incompatibility. The release smoke now returns valid exact-size CBOR, requires clean Reader validation for both standalone image and combined VSI init signing, and exercises combined media signing and recovery without an xfail. diff --git a/docs/release-notes.md b/docs/release-notes.md index 345e3a94..a5da5177 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,11 +1,45 @@ # Release notes -## Version 0.37.13.dev1 +## Version 0.37.13.dev2 -Castlabs VSI prerelease (tag `castlabs-v0.37.13.dev1`, released from -`feat/trusted-vsi-functional`). It is the first release of the trusted-processor -VSI line. The immutable `0.37.8.dev5` release inputs and artifacts -(`feat/live-video-vsi`) stay unchanged. +Castlabs VSI prerelease (tag `castlabs-v0.37.13.dev2`, released from +`feat/trusted-vsi-functional`). It is the first published release of the +trusted-processor VSI line and true-merges the Conformance Program 0.2 / +C2PA 2.4 line (`feat/conformance-validation-v02`, published as +`0.37.12.dev3`/`0.37.12.dev4`) into it. The immutable `0.37.8.dev5` and +`0.37.12.dev*` release inputs and artifacts stay unchanged. + +- Native `castlabs/c2pa-rs` `afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95` + (workspace `0.92.0-dev`, `Cargo.lock` SHA-256 + `f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370`, + unchanged from dev1), built with Rust `1.96.0`. It is the 1.0.8 trusted VSI + native line (`3328b2b4`) merged with the native conformance line. +- `Reader.crjson()` emits crJSON 2.4: `validationResults.specVersion` reports + `2.4.0`, the post-2.4 `isUpdateManifest` / `isCompressedManifest` manifest + fields are not emitted, and `specVersion` is placed per 2.4 in + `claim_generator_info`. +- `Reader.crjson()` is checked: the native export returns NULL with an error + instead of `"{}"`, so exporter failures raise `C2paError`. +- `verify.validation_time` (RFC 3339) selects the evaluation instant for + certificate, chain and OCSP decisions and the reported `validationTime`. + Malformed values are rejected when the context is built. +- Purpose-scoped trust anchors (`trust.anchors` with `trust_kind` + `manifest`/`tsa`/`cawg`) are always strict on this line: an anchor + authorizes only its declared purpose. The conformance line's + `verify.strict_trust_purposes` setting (and its non-strict fallback) does + not exist here. +- The installed-wheel release tests install `pytest==8.4.1` after the wheel + on Linux and Windows; pytest is no longer a runtime dependency of the wheel + (upstream `4d909cf`). + +### 0.37.13.dev1 (not released) + +Tag `castlabs-v0.37.13.dev1` (source `78771fa`, native `3328b2b4`) ran +workflow `38786913569`: the Linux build succeeded but every Linux +installed-wheel test job failed with `No module named pytest` because the +clean test venv did not install pytest; the Windows and release jobs were +cancelled. No dev1 draft or GitHub release was created and the tag remains +unchanged. Everything below ships first in dev2. - Native `castlabs/c2pa-rs` `3328b2b440fd07d9f857cbefa3c6d565093425de` (workspace `0.92.0-dev`, `Cargo.lock` SHA-256 diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index cd3c4f51..600df64f 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -3,11 +3,12 @@ Status: integrated, unreleased PR4 review changes requiring native trusted-VSI contract revision 3 (state version 3 and typed expert VSI payloads), following `c2pa-rs` -`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev1`, +`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev2`, prepared as the Castlabs VSI prerelease on native -`3328b2b440fd07d9f857cbefa3c6d565093425de`; immutable dev5 release inputs and +`afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`; immutable dev5 release inputs and artifacts are unchanged. The paired source workflow pins the same native commit -(previously `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c`) for revision-3 +(previously `3328b2b440fd07d9f857cbefa3c6d565093425de` for the unpublished +0.37.13.dev1 and `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c` before it) for revision-3 qualification. Select a reviewed local library with `C2PA_LIBRARY_NAME` for integration tests. Version `0.92.0-dev` and mask 63 do not establish contract identity; Python now requires the explicit native contract-revision probe. @@ -243,14 +244,14 @@ CBOR, BMFF or validation results. ` first requires all probes true against that library, then builds a NON-PUBLISHING wheel and sdist in a temporary staging copy with `FUNCTIONAL_BUILD_VERSION` (default: the checkout's source identity -`0.37.13.dev1`; rejects `0.37.8.*`, dev5, release versions and anything older -than the source identity, including `0.37.13.dev0` and the historical +`0.37.13.dev2`; rejects `0.37.8.*`, dev5, release versions and anything older +than the source identity, including `0.37.13.dev1`, `0.37.13.dev0` and the historical `0.37.9.dev0`). `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native library only. The release lock and tooling now match this source -(`castlabs-v0.37.13.dev1`); the immutable dev5 release remains historical. +(`castlabs-v0.37.13.dev2`); the immutable dev5 release remains historical. `scripts/qualify_trusted_vsi_functional.py --wheel --venv ---version 0.37.13.dev1` installs the wheel into an isolated venv, strips +--version 0.37.13.dev2` installs the wheel into an isolated venv, strips `PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, `test_fragmented_files.py`, `test_sign_ladder.py`, `test_native_ownership.py` and `test_native_ownership_opaque.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, @@ -263,8 +264,8 @@ 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 castlabs/c2pa-rs -`3328b2b440fd07d9f857cbefa3c6d565093425de`, the same commit as the -`castlabs-v0.37.13.dev1` release lock (trusted contract revision 3, mask 63, +`afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`, the same commit as the +`castlabs-v0.37.13.dev2` release lock (trusted contract revision 3, mask 63, offset-aware fragment exports). It sets `C2PA_FRAGMENT_OFFSET_REQUIRED=1`, so a native library without the fragment-offset probe fails rather than skips. Older pins (`6b506352`, `203dc08d`, `5c186c07`) emitted state version 2 and lacked the diff --git a/pyproject.toml b/pyproject.toml index 3c49910a..827f4385 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.13.dev1" +version = "0.37.13.dev2" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index 6a3d73b8..18d07134 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.13.dev1"}, + "version": {"const": "0.37.13.dev2"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,7 +49,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "3328b2b440fd07d9f857cbefa3c6d565093425de"}, + "commit": {"const": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} } diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index cfa8d565..5e8a982c 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,18 +2,18 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.13.dev1" + "version": "0.37.13.dev2" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/trusted-vsi-functional", - "releaseTag": "castlabs-v0.37.13.dev1" + "releaseTag": "castlabs-v0.37.13.dev2" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "3328b2b440fd07d9f857cbefa3c6d565093425de", + "commit": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95", "version": "0.92.0-dev", "cargoLockSha256": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" }, diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index e0460536..6e0a84bd 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13.dev1"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13.dev2"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, "releaseBranch": {"const": "feat/trusted-vsi-functional"}, - "releaseTag": {"const": "castlabs-v0.37.13.dev1"} + "releaseTag": {"const": "castlabs-v0.37.13.dev2"} } }, "rustSource": { @@ -48,7 +48,7 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "3328b2b440fd07d9f857cbefa3c6d565093425de"}, + "commit": {"const": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95"}, "version": {"const": "0.92.0-dev"}, "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} } diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index a093f741..35995525 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -26,24 +26,26 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.13.dev1" -RUST_COMMIT = "3328b2b440fd07d9f857cbefa3c6d565093425de" +RELEASE_VERSION = "0.37.13.dev2" +RUST_COMMIT = "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95" CARGO_LOCK_SHA256 = "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.13.dev1" +RELEASE_TAG = "castlabs-v0.37.13.dev2" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" f"{RELEASE_IDENTITY_TEXT}. Trusted-processor VSI line (native contract " - "revision 3, capability mask 63) on castlabs/c2pa-rs 3328b2b4 (0.92.0-dev, " - "Rust 1.96.0): offset-aware single-file fMP4 fragment verification, default " - "EKU trust-policy fix and patched event-listener/rustls/quinn-proto. See " + "revision 3, capability mask 63) on castlabs/c2pa-rs afd6fffe (0.92.0-dev, " + "Rust 1.96.0), merged with the Conformance Program 0.2 / C2PA 2.4 line: " + "crJSON 2.4 output with checked export, caller-controlled validation time, " + "offset-aware single-file fMP4 fragment verification and the default EKU " + "trust-policy fix. Successor to dev1 run 38786913569; no dev1 release. See " "the attached schema-2 evidence and SHA-256 sidecars." ) @@ -220,12 +222,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.13.dev1") + fail("release lock package identity is not 0.37.13.dev2") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/trusted-vsi-functional", - "releaseTag": "castlabs-v0.37.13.dev1", + "releaseTag": "castlabs-v0.37.13.dev2", }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 60d5e05a..654330ab 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -11,7 +11,7 @@ # specific language governing permissions and limitations under # each license. -# Version: 0.37.13.dev1 +# Version: 0.37.13.dev2 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 95c1fd33..8dd940a6 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -46,7 +46,7 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13.dev1") +EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13.dev2") REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index a2915b6f..09f7cadf 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -50,10 +50,10 @@ def _posix_bash() -> str: def test_prerelease_version_is_consistent(): # The release identity is consistent across the tooling, lock and source, - # so validate-sources accepts this checkout under the 0.37.13.dev1 lock. - assert release.RELEASE_VERSION == "0.37.13.dev1" - assert release.RELEASE_TAG == "castlabs-v0.37.13.dev1" - assert release.load_lock()["package"]["version"] == "0.37.13.dev1" + # so validate-sources accepts this checkout under the 0.37.13.dev2 lock. + assert release.RELEASE_VERSION == "0.37.13.dev2" + assert release.RELEASE_TAG == "castlabs-v0.37.13.dev2" + assert release.load_lock()["package"]["version"] == "0.37.13.dev2" source = release.project_version(ROOT) assert source == release.RELEASE_VERSION first_line = ( @@ -68,7 +68,7 @@ def test_release_lock_and_schemas_are_valid_json(): assert lock["rustSource"]["commit"] == release.RUST_COMMIT assert lock["rustSource"]["cargoLockSha256"] == release.CARGO_LOCK_SHA256 assert lock["rustToolchain"]["channel"] == "1.96.0" - assert release.RUST_COMMIT == "3328b2b440fd07d9f857cbefa3c6d565093425de" + assert release.RUST_COMMIT == "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95" assert release.CARGO_LOCK_SHA256 == ( "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" ) @@ -195,7 +195,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -246,7 +246,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.13.dev1'" in release_workflow + assert "if: github.ref == 'refs/tags/castlabs-v0.37.13.dev2'" in release_workflow assert "-F draft=true -F prerelease=true" in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow @@ -320,9 +320,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in pypi_workflow ) assert '--signer-digest "$SOURCE_SHA"' in pypi_workflow - assert "--source-ref refs/tags/castlabs-v0.37.13.dev1" in pypi_workflow + assert "--source-ref refs/tags/castlabs-v0.37.13.dev2" in pypi_workflow assert '--source-digest "$SOURCE_SHA"' in pypi_workflow - assert "pypi.org/pypi/c2pa-python/0.37.13.dev1/json" in pypi_workflow + assert "pypi.org/pypi/c2pa-python/0.37.13.dev2/json" in pypi_workflow assert "pypi-plan" in pypi_workflow assert "packages-dir: publish-dist/" in pypi_workflow assert "if: steps.pypi.outputs.upload == 'true'" in pypi_workflow @@ -351,7 +351,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "manual legacy publishing accepts final X.Y.Z versions only" in ( legacy_release ) - assert 'test "$VERSION" != 0.37.13.dev1' not in legacy_workflow + assert 'test "$VERSION" != 0.37.13.dev2' not in legacy_workflow assert legacy_workflow.count("final X.Y.Z versions only") == 2 assert "tests/test_castlabs_release_tooling.py" in legacy_workflow shell_helpers_start = release_workflow.index(" download_existing() {") @@ -381,7 +381,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev1") == 2 + assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2") == 2 assert release_workflow.count("C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev") == 2 # pytest is not a wheel runtime dependency: both clean installed-wheel # environments install it after the wheel, before running pytest. @@ -445,6 +445,9 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "castlabs-v0.37.8.dev3" not in pypi_workflow assert "castlabs-v0.37.8.dev4" not in release_workflow assert "castlabs-v0.37.8.dev4" not in pypi_workflow + # The unpublished dev1 tag is never a trigger, gate or source ref again. + assert "0.37.13.dev1" not in release_workflow + assert "0.37.13.dev1" not in pypi_workflow readme = (ROOT / "README.md").read_text(encoding="utf-8") assert "failed prerelease workflow run `34030865864`" in readme assert "No dev1 draft or GitHub release was created" in readme @@ -461,7 +464,8 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.13.dev1" in release_notes + assert "## Version 0.37.13.dev2" in release_notes + assert "### 0.37.13.dev1 (not released)" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes @@ -656,7 +660,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.13.dev1-py3-none-win_amd64.whl" + wheel = root / "c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -667,7 +671,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.13.dev1-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / "c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index 704a43a0..587a4161 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -15,7 +15,7 @@ SPEC.loader.exec_module(functional_build) -@pytest.mark.parametrize("version", ["0.37.13.dev1", "0.37.13.dev2", "0.38.0.dev1", "1.0.dev0+local"]) +@pytest.mark.parametrize("version", ["0.37.13.dev2", "0.37.13.dev3", "0.38.0.dev1", "1.0.dev0+local"]) def test_new_functional_development_version(version): assert functional_build.functional_version(version) == version @@ -24,7 +24,9 @@ def test_new_functional_development_version(version): "0.37.8.dev5", "0.37.8.dev6", "0.37.8.dev5+functional", "0.37.7.dev1", "0.37.9", "0.38.0rc1", "invalid", # Historical functional qualification identities are older than the source. - "0.37.9.dev0", "0.37.12.dev0", "0.37.13.dev0", + "0.37.9.dev0", "0.37.12.dev0", "0.37.13.dev0", "0.37.13.dev1", + # Conformance-line prereleases merged into this line are older too. + "0.37.12.dev4", ]) def test_functional_build_rejects_release_or_dev5_identity(version): with pytest.raises(ValueError): @@ -39,9 +41,9 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): before = {name: (ROOT / name).read_bytes() for name in ("pyproject.toml", "src/c2pa/c2pa.py")} stage = tmp_path / "stage" stage.mkdir() - functional_build.stage_source(stage, "0.37.13.dev2") - assert 'version = "0.37.13.dev2"' in (stage / "pyproject.toml").read_text() - assert "# Version: 0.37.13.dev2" in (stage / "src/c2pa/c2pa.py").read_text() + functional_build.stage_source(stage, "0.37.13.dev3") + assert 'version = "0.37.13.dev3"' in (stage / "pyproject.toml").read_text() + assert "# Version: 0.37.13.dev3" in (stage / "src/c2pa/c2pa.py").read_text() assert not (stage / "src/c2pa/libs").exists() subprocess.run([sys.executable, "setup.py", "-q", "sdist", "--dist-dir", str(tmp_path / "dist")], cwd=stage, check=True, capture_output=True) @@ -50,12 +52,12 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): (sdist,) = (tmp_path / "dist").iterdir() name, version = parse_sdist_filename(sdist.name) assert canonicalize_name(name) == "c2pa-python" - assert version == Version("0.37.13.dev2") + assert version == Version("0.37.13.dev3") root = sdist.name[:-len(".tar.gz")] with tarfile.open(sdist) as archive: names = archive.getnames() info = archive.extractfile(f"{root}/PKG-INFO").read().decode() - assert "Version: 0.37.13.dev2" in info.splitlines() + assert "Version: 0.37.13.dev3" 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"): @@ -64,13 +66,13 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): after = {name: (ROOT / name).read_bytes() for name in before} assert after == before # The checkout keeps its own source identity; staging never rewrites it. - assert 'version = "0.37.13.dev1"' in before["pyproject.toml"].decode() + assert 'version = "0.37.13.dev2"' 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.dev1" - assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev1" + assert functional_build.source_version() == "0.37.13.dev2" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev2" def test_probe_script_requires_every_functional_capability(): From e87d3e335c6dab7395509793c176d7f57c1956a2 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Thu, 15 Oct 2026 23:32:05 +0200 Subject: [PATCH 44/46] test: run native validation-time tests only on the Castlabs source build The upstream prebuilt library used by build.yml has no verify.validation_time or crJSON 2.4. Document that strict_trust_purposes is accepted and ignored. --- docs/release-notes.md | 3 ++- tests/test_unit_tests.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index a5da5177..71162078 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -27,7 +27,8 @@ C2PA 2.4 line (`feat/conformance-validation-v02`, published as `manifest`/`tsa`/`cawg`) are always strict on this line: an anchor authorizes only its declared purpose. The conformance line's `verify.strict_trust_purposes` setting (and its non-strict fallback) does - not exist here. + not exist here: the key is accepted and silently ignored, so anchors must + carry the correct `trust_kind`. - The installed-wheel release tests install `pytest==8.4.1` after the wheel on Linux and Windows; pytest is no longer a runtime dependency of the wheel (upstream `4d909cf`). diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 77a899f7..210fa2fe 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -11181,13 +11181,21 @@ def test_ed25519_sign_with_empty_data_raises(self): c2pa_module.ed25519_sign(b"", "not a key") +# Validation time and crJSON 2.4 exist only in the Castlabs source-built native +# (C2PA_SOURCE_BUILD_VERSION set by the paired/release jobs), not in the upstream +# prebuilt library that build.yml downloads. +_CASTLABS_SOURCE_NATIVE = bool(os.environ.get("C2PA_SOURCE_BUILD_VERSION")) + + class TestConformanceValidationSettings(unittest.TestCase): """Native caller-controlled validation time and checked crJSON.""" + @unittest.skipUnless(_CASTLABS_SOURCE_NATIVE, "requires the Castlabs source-built native") def test_invalid_validation_time_is_rejected(self): with self.assertRaises(Exception): Context.from_dict({"verify": {"validation_time": "2027-01-15 08:00:00"}}) + @unittest.skipUnless(_CASTLABS_SOURCE_NATIVE, "requires the Castlabs source-built native") def test_validation_time_is_reported_in_crjson(self): context = Context.from_dict({ "verify": {"validation_time": "2027-01-15T10:00:00+02:00"}, From 95955037fc474ebbefd0a5e945e9657903bc0151 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Fri, 16 Oct 2026 08:45:09 +0200 Subject: [PATCH 45/46] docs: plan signer-aligned +signer release versions --- docs/release-notes.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/release-notes.md b/docs/release-notes.md index 71162078..f5e5d145 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,18 @@ # Release notes +## Planned: signer-aligned release versions + +Decided 2026-10-16, effective from the next release built for a signer release +(signer `1.0.8` uses `0.37.13.dev2`). Such releases use a PEP 440 local version +naming the signer version, `+signer.[.]`, for +example `0.37.14+signer.1.0.9`, tagged `castlabs-v0.37.14+signer.1.0.9`. They are +final, immutable GitHub releases (`prerelease=false`), not published to PyPI +(local versions are rejected there). Development builds keep `.devN` versions, +and the development line moves to the next `.devN` series after each aligned +release. A signer retry under a new version gets a new `+signer` release. +Release-tooling changes (escaped tag trigger, prerelease flag, artifact names, +PyPI workflow limited to `.devN`) land with that release. + ## Version 0.37.13.dev2 Castlabs VSI prerelease (tag `castlabs-v0.37.13.dev2`, released from From 44b4bf4be1ec3706ec51b60374001159987c4ba5 Mon Sep 17 00:00:00 2001 From: Michael Stattmann Date: Sat, 17 Oct 2026 07:12:17 +0200 Subject: [PATCH 46/46] chore(release): prepare signer-aligned 1.0.8 dependency on native 6021100c --- .github/workflows/castlabs-vsi-pypi.yml | 11 +- .github/workflows/castlabs-vsi-release.yml | 58 +++--- .github/workflows/trusted-vsi-paired.yml | 10 +- README.md | 57 ++++-- docs/release-notes.md | 46 ++++- docs/trusted-vsi-python-contract.md | 70 ++++--- pyproject.toml | 2 +- release/castlabs-release-evidence.schema.json | 6 +- release/castlabs-vsi-inputs.lock.json | 8 +- release/castlabs-vsi-inputs.schema.json | 8 +- scripts/build_trusted_vsi_functional.py | 10 +- scripts/castlabs_release.py | 32 ++-- src/c2pa/c2pa.py | 2 +- tests/test_castlabs_release_smoke.py | 4 +- tests/test_castlabs_release_tooling.py | 178 +++++++++++++++--- tests/test_trusted_vsi_build.py | 34 ++-- 16 files changed, 385 insertions(+), 151 deletions(-) diff --git a/.github/workflows/castlabs-vsi-pypi.yml b/.github/workflows/castlabs-vsi-pypi.yml index 724c73df..a2ddb541 100644 --- a/.github/workflows/castlabs-vsi-pypi.yml +++ b/.github/workflows/castlabs-vsi-pypi.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: release_tag: - description: Existing immutable Castlabs prerelease tag + description: Existing immutable Castlabs dev-only prerelease tag (no +signer releases) required: true default: castlabs-v0.37.13.dev2 type: string @@ -26,6 +26,13 @@ jobs: contents: read id-token: write steps: + - name: Reject non-development release tags before checkout + shell: bash + env: + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + run: | + [[ "$INPUT_RELEASE_TAG" =~ ^castlabs-v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ ]] + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-python @@ -85,7 +92,7 @@ jobs: cmp dist/c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl \ download/c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl python3 -m pip install packaging==25.0 twine==6.2.0 - python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev2"); assert v.is_prerelease and str(v)=="0.37.13.dev2"' + python3 -c 'from packaging.version import Version; v=Version("0.37.13.dev2"); assert v.is_devrelease and v.local is None and str(v)=="0.37.13.dev2"' python3 -m twine check dist/*.whl - name: Preflight retry-safe PyPI upload set diff --git a/.github/workflows/castlabs-vsi-release.yml b/.github/workflows/castlabs-vsi-release.yml index 772a7e50..079a7d43 100644 --- a/.github/workflows/castlabs-vsi-release.yml +++ b/.github/workflows/castlabs-vsi-release.yml @@ -1,13 +1,13 @@ -name: Castlabs VSI prerelease +name: Castlabs VSI release on: push: tags: - - castlabs-v0.37.13.dev2 + - 'castlabs-v0.37.13\+signer.1.0.8' workflow_dispatch: inputs: source_sha: - description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13.dev2; paired source for trusted_vsi_only) + description: Full Python SHA (feat/trusted-vsi-functional tip for 0.37.13+signer.1.0.8; paired source for trusted_vsi_only) required: true type: string trusted_vsi_only: @@ -17,7 +17,7 @@ on: default: false concurrency: - group: castlabs-vsi-release-0.37.13.dev2 + group: castlabs-vsi-release-0.37.13+signer.1.0.8 cancel-in-progress: false permissions: @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 fetch-depth: 1 path: c2pa-rs @@ -82,7 +82,7 @@ jobs: git -C c2pa-python fetch --no-tags origin \ refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi + if [[ "${GITHUB_EVENT_NAME}" == push ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ --python-root c2pa-python \ --rust-root c2pa-rs \ @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 fetch-depth: 1 path: c2pa-rs @@ -133,7 +133,7 @@ jobs: git -C c2pa-python fetch --no-tags origin \ refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional EVENT=manual - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then EVENT=tag; fi + if [[ "${GITHUB_EVENT_NAME}" == push ]]; then EVENT=tag; fi python3 c2pa-python/scripts/castlabs_release.py validate-sources \ --python-root c2pa-python --rust-root c2pa-rs \ --source-sha "${{ needs.prepare.outputs.source_sha }}" \ @@ -195,7 +195,7 @@ jobs: test "$(find wheelhouse -maxdepth 1 -name "*.whl" | wc -l)" -eq 1 WHEEL=$(find wheelhouse -maxdepth 1 -name "*.whl") case "$(basename "$WHEEL")" in - c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl) ;; + c2pa_python-0.37.13+signer.1.0.8-py3-none-manylinux_2_28_x86_64.whl) ;; *) echo "unexpected Linux wheel: $WHEEL" >&2; exit 1 ;; esac /opt/python/cp310-cp310/bin/python -m auditwheel show "$WHEEL" @@ -215,7 +215,7 @@ jobs: SOURCE_DATE_EPOCH: ${{ needs.prepare.outputs.source_epoch }} run: | cd c2pa-python - BUNDLE=release-out/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz + BUNDLE=release-out/c2pa-python-0.37.13+signer.1.0.8-native-linux-x86_64.tar.gz python3 scripts/castlabs_release.py pack --output "$BUNDLE" \ --epoch "$SOURCE_DATE_EPOCH" \ --member x86_64-unknown-linux-gnu/libc2pa_c.so=release-out/libc2pa_c.so @@ -278,7 +278,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: castlabs/c2pa-rs - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 fetch-depth: 1 path: c2pa-rs @@ -303,7 +303,7 @@ jobs: } git -C c2pa-python fetch --no-tags origin ` refs/heads/feat/trusted-vsi-functional:refs/remotes/origin/feat/trusted-vsi-functional - $event = if ($env:GITHUB_REF.StartsWith("refs/tags/")) { "tag" } else { "manual" } + $event = if ($env:GITHUB_EVENT_NAME -eq "push") { "tag" } else { "manual" } python c2pa-python/scripts/castlabs_release.py validate-sources ` --python-root c2pa-python --rust-root c2pa-rs ` --source-sha "${{ needs.prepare.outputs.source_sha }}" ` @@ -351,7 +351,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $wheels = @(Get-ChildItem dist/*.whl) if ($wheels.Count -ne 1) { throw "expected exactly one Windows wheel" } - if ($wheels[0].Name -ne "c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl") { + if ($wheels[0].Name -ne "c2pa_python-0.37.13+signer.1.0.8-py3-none-win_amd64.whl") { throw "unexpected Windows wheel $($wheels[0].Name)" } python -m twine check $wheels[0].FullName @@ -371,7 +371,7 @@ jobs: $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true Push-Location c2pa-python - $bundle = "release-out/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz" + $bundle = "release-out/c2pa-python-0.37.13+signer.1.0.8-native-windows-x86_64.tar.gz" python scripts/castlabs_release.py pack --output $bundle ` --epoch $env:SOURCE_DATE_EPOCH ` --member x86_64-pc-windows-msvc/c2pa_c.dll=release-out/c2pa_c.dll @@ -424,7 +424,7 @@ jobs: - name: Test installed wheel in a clean environment shell: bash env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13+signer.1.0.8 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -466,7 +466,7 @@ jobs: - name: Test installed wheel in a clean environment shell: pwsh env: - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13+signer.1.0.8 CASTLABS_RELEASE_SMOKE_REQUIRED: "1" C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev run: | @@ -490,7 +490,7 @@ jobs: release: needs: [prepare, linux, windows, test-linux-wheel, test-windows-wheel] - if: github.ref == 'refs/tags/castlabs-v0.37.13.dev2' + if: github.event_name == 'push' && github.ref == 'refs/tags/castlabs-v0.37.13+signer.1.0.8' runs-on: ubuntu-24.04 timeout-minutes: 30 environment: castlabs-vsi-release @@ -525,7 +525,7 @@ jobs: LINUX_WHEEL=$(find collected/linux -maxdepth 1 -name "*.whl") WINDOWS_WHEEL=$(find collected/windows -maxdepth 1 -name "*.whl") test "$(find collected/linux collected/windows -name "*.whl" | wc -l)" -eq 2 - BUNDLE=assets/c2pa-python-0.37.13.dev2-py3-none-wheels.tar.gz + BUNDLE=assets/c2pa-python-0.37.13+signer.1.0.8-py3-none-wheels.tar.gz python3 c2pa-python/scripts/castlabs_release.py pack \ --output "$BUNDLE" --epoch "$SOURCE_DATE_EPOCH" \ --member "$(basename "$LINUX_WHEEL")=$LINUX_WHEEL" \ @@ -542,13 +542,13 @@ jobs: python3 c2pa-python/scripts/castlabs_release.py checksum \ --artifact "$BUNDLE.evidence.json" cp "$LINUX_WHEEL" "$WINDOWS_WHEEL" assets/ - cp collected/linux/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz* assets/ - cp collected/windows/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz* assets/ + cp collected/linux/c2pa-python-0.37.13+signer.1.0.8-native-linux-x86_64.tar.gz* assets/ + cp collected/windows/c2pa-python-0.37.13+signer.1.0.8-native-windows-x86_64.tar.gz* assets/ python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.13.dev2-native-linux-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13+signer.1.0.8-native-linux-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ - --evidence assets/c2pa-python-0.37.13.dev2-native-windows-x86_64.tar.gz.evidence.json \ + --evidence assets/c2pa-python-0.37.13+signer.1.0.8-native-windows-x86_64.tar.gz.evidence.json \ --artifact-dir assets --source-sha "${{ needs.prepare.outputs.source_sha }}" python3 c2pa-python/scripts/castlabs_release.py validate-evidence \ --evidence "$BUNDLE.evidence.json" --artifact-dir assets \ @@ -562,7 +562,7 @@ jobs: - name: Require immutable release tag shell: bash run: | - TAG=castlabs-v0.37.13.dev2 + TAG=castlabs-v0.37.13+signer.1.0.8 git -C c2pa-python fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" test "$(git -C c2pa-python rev-list -n 1 "$TAG")" = \ "${{ needs.prepare.outputs.source_sha }}" @@ -572,13 +572,13 @@ jobs: with: subject-path: assets/* - - name: Reconcile no-clobber draft prerelease + - name: Reconcile no-clobber draft final release shell: bash env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - TAG=castlabs-v0.37.13.dev2 + TAG=castlabs-v0.37.13+signer.1.0.8 SOURCE_SHA="${{ needs.prepare.outputs.source_sha }}" RELEASE_NAME=$(python3 c2pa-python/scripts/castlabs_release.py release-value name) RELEASE_BODY=$(python3 c2pa-python/scripts/castlabs_release.py release-value body) @@ -604,7 +604,7 @@ jobs: } discover_release() { gh api --paginate repos/castlabs/c2pa-python/releases \ - --jq '.[] | select(.tag_name == "castlabs-v0.37.13.dev2")' > "$1" + --jq '.[] | select(.tag_name == "castlabs-v0.37.13+signer.1.0.8")' > "$1" return 0 } query_release_id() { @@ -634,10 +634,12 @@ jobs: CLEANUP_ARMED=0 fi local response="release-upload-${upload_index}.json" + local encoded_name + encoded_name=$(python3 -c 'import sys; from urllib.parse import quote; print(quote(sys.argv[1], safe=""))' "$asset_name") gh api --method POST \ -H "Content-Type: application/octet-stream" \ --input "assets/$asset_name" \ - "https://uploads.github.com/repos/castlabs/c2pa-python/releases/${RELEASE_ID}/assets?name=${asset_name}" \ + "https://uploads.github.com/repos/castlabs/c2pa-python/releases/${RELEASE_ID}/assets?name=${encoded_name}" \ > "$response" python3 c2pa-python/scripts/castlabs_release.py validate-upload-response \ --response "$response" --asset "assets/$asset_name" @@ -669,7 +671,7 @@ jobs: -f "target_commitish=$SOURCE_SHA" \ -f "name=$RELEASE_NAME" \ -f "body=$RELEASE_BODY" \ - -F draft=true -F prerelease=true \ + -F draft=true -F prerelease=false \ > release-created-response.json python3 c2pa-python/scripts/castlabs_release.py extract-release-id \ --release-json release-created-response.json \ diff --git a/.github/workflows/trusted-vsi-paired.yml b/.github/workflows/trusted-vsi-paired.yml index 7983e116..a9a347d1 100644 --- a/.github/workflows/trusted-vsi-paired.yml +++ b/.github/workflows/trusted-vsi-paired.yml @@ -35,9 +35,9 @@ jobs: C2PA_REQUIRE_SIGN_LADDER: "1" C2PA_REQUIRE_FRAGMENTED_FILES: "1" C2PA_FRAGMENT_OFFSET_REQUIRED: "1" - FUNCTIONAL_BUILD_VERSION: 0.37.13.dev2 + FUNCTIONAL_BUILD_VERSION: 0.37.13+signer.1.0.8 # The release smoke asserts the installed distribution version. - CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2 + CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13+signer.1.0.8 steps: - name: Require a full Python source SHA shell: bash @@ -52,9 +52,9 @@ jobs: with: repository: castlabs/c2pa-rs # Revision-3 trusted VSI plus offset-aware fragment verification: - # the same native commit the 0.37.13.dev2 release lock pins. This job + # the same native commit the 0.37.13+signer.1.0.8 release lock pins. This job # is qualification-only and never produces release artifacts. - ref: afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95 + ref: 6021100cc5060b06061ca15b634281cfeb97c718 path: paired-rust - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -105,7 +105,7 @@ jobs: 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) + - name: Build isolated aligned 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 diff --git a/README.md b/README.md index cfbda439..dda46091 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,32 @@ export C2PA_FFI_FEATURES=file_io,unstable_live_video make build-from-source C2PA_RS_PATH=$C2PA_RS_PATH ``` -## Castlabs VSI prerelease process +## Castlabs VSI release process + +### Signer 1.0.8 preparation only + +The candidate package identity is `0.37.13+signer.1.0.8`, with intended tag +`castlabs-v0.37.13+signer.1.0.8`. This is preparation, not a qualified or +published candidate. The signer-aligned version is a PEP 440 local version for +a final, immutable **GitHub-only** release (`prerelease=false`), never PyPI. +The next development series is future work after publication; it is not selected +or applied in this candidate. + +The candidate selects the combined #27/#28/#29 native source +`6021100cc5060b06061ca15b634281cfeb97c718`, with `Cargo.lock` SHA-256 +`ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f` consistently +in the lock, schemas, helper, workflows, tests and current docs. Native bundle +and Python/native qualification remain publication/adoption gates. Integrate +and review the Python source on `feat/trusted-vsi-functional` and record CI +evidence against its final full SHA before any tag or publication. The local +preparation branch `release/signer-1.0.8` is not an authorized artifact-release source. ### Unreleased trusted-processor API qualification The [trusted-processor API](docs/usage.md#trusted-processor-vsi-unreleased-functional-api) -is separate from complete-buffer VSI and immutable dev5. It is first released in -`0.37.13.dev2`. Its probes are true only on a native library reporting trusted -contract revision 3 and capability mask 63 (the pinned release native does); +is separate from complete-buffer VSI and immutable dev5. The current prepared +release identity is `0.37.13+signer.1.0.8`. Its probes are true only on a native library reporting trusted +contract revision 3 and capability mask 63; otherwise every probe is false and session construction/operations fail before side effects. Expert signing accepts only exact `Sig_structure` bytes and returns the 64-byte signature; the earlier `TrustedVsiSignResult` was removed. Import @@ -118,17 +136,22 @@ library (`-k "not paired"`). Separately, both it and the dedicated workflow use Linux/Windows source builds and the full focused `tests/test_trusted_vsi_api.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`. Missing reduced ABI symbols fail rather than skip. The paired Rust source is pinned to -`castlabs/c2pa-rs@afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`, the same commit as -the 0.37.13.dev2 release lock; the job logs resolved Python/Rust SHAs. The +`castlabs/c2pa-rs@6021100cc5060b06061ca15b634281cfeb97c718`, the merged #27/#28/#29 +source selected by the candidate release lock; the job logs resolved Python/Rust SHAs. The paired job is qualification only and is not release-artifact evidence. For the dedicated workflow, manually select the trusted-API workflow revision, set `trusted_vsi_only=true`, and supply its full Python `source_sha`. This path skips **all** release prepare/build/test/publish jobs. The reusable job builds -only an isolated native library and source-import metadata, never stages a -release native, uploads a wheel, or publishes anything. Ordinary release -dispatch and tag behavior, native pins, evidence, and release identity remain -unchanged. Do not package this ABI under the immutable dev5 artifact names. +an isolated native library, source-import metadata and non-publishing qualification +artifacts; it never stages a release native, uploads a wheel, or publishes anything. +Ordinary artifact builds retain the strict release-branch policy and tag-only +publication gate. Do not package this ABI under the immutable dev5 artifact names. +Paired wheel/sdist packaging copies source into a temporary directory and retains +the aligned version unchanged; installed-wheel qualification removes source and +library overrides. The existing qualification builder accepts this exact +signer-aligned source identity or a future development version. It still rejects +arbitrary final versions and historical identities, including the dev5 series. To test a locally built paired native without reinstalling the Python package: @@ -145,9 +168,9 @@ skips, or use `-k "not paired"` for baseline-only coverage. Neither is paired qualification. A fresh source checkout also needs distribution metadata for imports (`python setup.py egg_info`, without a native download or wheel build). -### Immutable Castlabs VSI prerelease process +### Immutable Castlabs VSI release process -The dedicated [`Castlabs VSI prerelease`](.github/workflows/castlabs-vsi-release.yml) workflow builds version `0.37.13.dev2` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. +The dedicated [`Castlabs VSI release`](.github/workflows/castlabs-vsi-release.yml) workflow is prepared to build version `0.37.13+signer.1.0.8` for Linux x86-64 and Windows x86-64. It does not call `scripts/download_artifacts.py`: both native libraries are compiled with Cargo `--locked` and `CARGO_BUILD_JOBS=1` from the exact [Castlabs c2pa-rs](https://github.com/castlabs/c2pa-rs) commit in [`release/castlabs-vsi-inputs.lock.json`](release/castlabs-vsi-inputs.lock.json). The lock also fixes the Rust 1.96.0 toolchain coordinated with c2pa-rs qualification, checksum-verified rustup installers, no-default-feature set, target set, and the Linux manylinux container digest. The legacy `build.yml` explicitly excludes `castlabs-v*` tag pushes and guards its jobs and PyPI publisher against manual dispatch on those tags; the dedicated workflow alone owns them. Ordinary non-Castlabs tag releases retain the legacy behavior, while manual legacy publication additionally requires `refs/heads/main` and a final `X.Y.Z` package version. The immutable `castlabs-v0.37.8.dev1` tag records failed prerelease workflow run `34030865864`. Linux compiled successfully but its combined DynamicAssertion-plus-VSI smoke used a 5-byte callback result for a 64-byte reservation and later failed with `assertion.bmffHash.mismatch`; Windows compiled successfully and failed only in platform-sensitive wheel metadata parsing. The Linux failure was an undersized callback contract violation, not an inherent incompatibility between DynamicAssertions and VSI. No dev1 draft or GitHub release was created, and the tag remains unchanged. @@ -161,17 +184,17 @@ The immutable `castlabs-v0.37.13.dev1` tag (source `78771fa`, native `3328b2b4`) Release controls: -- Configure `castlabs-v0.37.13.dev2` as a protected tag and require approval for the `castlabs-vsi-release` environment. +- Before publication, configure `castlabs-v0.37.13+signer.1.0.8` as a protected tag and require approval for the `castlabs-vsi-release` environment. The workflow's single-quoted tag filter escapes the literal `+` as `\+`; wheel filenames and dist-info directories retain `+`, and upload query parameters percent-encode it as `%2B`. - A tag run accepts only that exact tag. A manual run accepts only a full 40-character SHA equal to the tip of `feat/trusted-vsi-functional` and is build/test-only; draft release creation and provenance signing require the exact protected tag-triggered run. - Source checkout URLs, full SHAs, package/native versions, and the pinned `Cargo.lock` SHA-256 are asserted before each build. - The Linux wheel is built and repaired in the digest-pinned `manylinux_2_28_x86_64` image. Every wheel is tested without capability skips on clean Python 3.10 through 3.13; Python 3.10 also runs the broader unit and fragmented-file suites. -- Draft release creation happens only after both platform builds and all wheel tests. Every Action is pinned by full commit SHA, every bundle and evidence document has a SHA-256 sidecar, and all release files are individually covered by GitHub build provenance. The full REST create response provides the candidate ID; an immediate exact-ID GET must confirm the expected tag, draft state, empty assets, and normalized ownership marker before cleanup can be armed. Strict validation then checks prerelease state, name, normalized release identity text, and documented `target_commitish` forms. The immutable tag is independently resolved to the source SHA immediately before creation and final verification, so GitHub's ignored/defaulted `target_commitish` is never the source authority. A rerun may discover a prior draft by list only when exactly one match exists; after discovery, all queries, uploads, and downloads use the exact release or asset ID. Missing assets upload through the exact `uploads.github.com` release-ID endpoint without clobber. Immediate upload digests are checked when available; final success always requires exact API digests and downloaded byte equality. Published, mismatched, duplicate, or unexpected state fails. Cleanup uses only exact ID, tag, empty draft state, and ownership marker, and is disabled before the first upload, so response loss and all asset-bearing or reconciled drafts are preserved for manual inspection. The completed output remains a draft intentionally for operator finalization. -- [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) is a separate manual workflow protected by the `pypipublish` environment. It requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. +- Draft release creation happens only after both platform builds and all wheel tests, on the exact tag push, with `draft=true` and `prerelease=false`. Every Action is pinned by full commit SHA, every bundle and evidence document has a SHA-256 sidecar, and all release files are individually covered by GitHub build provenance. The full REST create response provides the candidate ID; an immediate exact-ID GET must confirm the expected tag, draft state, empty assets, and normalized ownership marker before cleanup can be armed. Strict validation then requires `prerelease=false`, name, normalized release identity text, and documented `target_commitish` forms. The immutable tag is independently resolved to the source SHA immediately before creation and final verification, so GitHub's ignored/defaulted `target_commitish` is never the source authority. A rerun may discover a prior draft by list only when exactly one match exists; after discovery, all queries, uploads, and downloads use the exact release or asset ID. Missing assets upload through the exact `uploads.github.com` release-ID endpoint without clobber. Immediate upload digests are checked when available; final success always requires exact API digests and downloaded byte equality. Published, mismatched, duplicate, or unexpected state fails. Cleanup uses only exact ID, tag, empty draft state, and ownership marker, and is disabled before the first upload, so response loss and all asset-bearing or reconciled drafts are preserved for manual inspection. The completed output remains a draft intentionally for operator finalization. +- [`Promote Castlabs VSI prerelease to PyPI`](.github/workflows/castlabs-vsi-pypi.yml) remains a separate **dev-only** manual workflow protected by the `pypipublish` environment, pinned to historical `castlabs-v0.37.13.dev2`. It rejects tags outside `castlabs-vX.Y.Z.devN` before checkout; the current release helper also rejects non-development versions before creating any upload set. It cannot promote this aligned release. For its historical dev target it requires the finalized GitHub prerelease and verifies every wheel, bundle, evidence document, feature report, and checksum against provenance from the exact dedicated signer workflow, tag, and source SHA. Checksums and strict evidence then validate the final-wheel metadata and embedded native digests before promotion. - PyPI does not provide a multi-file transaction. Promotion therefore queries the version JSON API first. A fresh release uploads both platform wheels together; a retry accepts an existing wheel only when PyPI's SHA-256 exactly matches the local attested wheel, then uploads all missing wheels together. A mismatched or unexpected PyPI file fails. This policy intentionally publishes no source distribution for the two-platform prerelease and safely recovers from an unavoidable prior partial wheel upload. -Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually, then remove only that draft with `gh release delete castlabs-v0.37.13.dev2 --repo castlabs/c2pa-python --yes` before rerunning the complete protected-tag workflow. Never use clobber to repair a mismatch. +Intermediate Actions artifacts expire after seven days. A draft resume always rebuilds both platforms and requires byte-identical output for every already-uploaded asset; rerunning only an expired release job cannot recover its inputs, so rerun the complete tag workflow. If reproducibility or an operator mistake leaves a mismatched prior draft, the workflow preserves it and reports failure. Inspect it manually before any operator-authorized deletion of that exact draft and rerun the complete protected-tag workflow. Never delete a published release or use clobber to repair a mismatch. -`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13.dev2`. +`tests/test_castlabs_release_smoke.py` is intentionally gated for ordinary contributors because upstream native binaries do not provide every Castlabs capability. Bare pytest skips that module unless `CASTLABS_RELEASE_SMOKE_REQUIRED=1` is set. The dedicated wheel-test jobs always set it; with the gate enabled, package version, dynamic assertions, fragmented files, offset-aware fragment verification, VSI callback signing, recovery, explicit time, and MFHD probing are hard assertions with no capability skip. Contributors with a qualified installed wheel can run the same gate explicitly with `CASTLABS_RELEASE_SMOKE_REQUIRED=1 python -m pytest -q tests/test_castlabs_release_smoke.py`. `CASTLABS_RELEASE_EXPECTED_VERSION` exists only for testing an already-built prior candidate during release-pipeline repair; the dedicated tagged workflow explicitly fixes it to `0.37.13+signer.1.0.8`. At the pinned c2pa-rs commit, VSI initialization signs through `Builder::from_shared_context`, so it inherits DynamicAssertions registered on the Context's claim signer. The common Python callback bridge enforces `reserve_size` as the exact serialized CBOR size for every signing path. The earlier `assertion.bmffHash.mismatch` came from returning only 5 bytes for a 64-byte reservation; it was not an inherent DynamicAssertion-plus-VSI incompatibility. The release smoke now returns valid exact-size CBOR, requires clean Reader validation for both standalone image and combined VSI init signing, and exercises combined media signing and recovery without an xfail. diff --git a/docs/release-notes.md b/docs/release-notes.md index f5e5d145..41dd9949 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,17 +1,47 @@ # Release notes -## Planned: signer-aligned release versions - -Decided 2026-10-16, effective from the next release built for a signer release -(signer `1.0.8` uses `0.37.13.dev2`). Such releases use a PEP 440 local version +## Version 0.37.13+signer.1.0.8 (Preparation Only) + +The updated signer `1.0.8` dependency uses the prepared package version +`0.37.13+signer.1.0.8` and intended tag `castlabs-v0.37.13+signer.1.0.8`. +This is not a qualified or published Python release. No tag or publication is +part of this preparation; paired Linux/Windows CI must qualify the exact sources. + +- Native inputs select `castlabs/c2pa-rs` + `6021100cc5060b06061ca15b634281cfeb97c718`, with `Cargo.lock` SHA-256 + `ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f`. + This combines #27 generic BMFF exclusions, ZIP 9.0.4 and WASI fixes with #28 + verified VSI init refreshes and #29's native CLI update timelines. The Python + runtime API is unchanged; the CLI is built separately by the signer from this + same native SHA. Exact-source focused native CI passed; complete native bundle + and Python/native artifact qualification remain release gates. +- Rust `1.96.0`, native `0.92.0-dev`, trusted contract revision 3, capability + mask 63, feature selection and hermetic/evidence/ownership controls are unchanged. + `c2pa-native-version.txt` remains the independent upstream prebuilt pin. +- Final GitHub release policy is `prerelease=false`, still created as a draft + for operator finalization. The literal `+` is escaped in the GitHub tag trigger, + retained in wheel/dist-info/bundle identities, and percent-encoded in asset + upload query parameters. PyPI is dev-only and rejects aligned releases. +- `feat/trusted-vsi-functional` remains the locked source branch; arbitrary branch + artifact builds are not authorized. Final Python-source review and CI + qualification are required before tagging. +- The qualification builder accepts the exact signer-aligned source identity or + a future development version, not arbitrary final versions. The next + development series is future work after publication, not a repository version + switch in this candidate. + +### Signer-aligned version policy + +Decided 2026-10-16 and applied here to the updated signer `1.0.8` dependency. +Such releases use a PEP 440 local version naming the signer version, `+signer.[.]`, for example `0.37.14+signer.1.0.9`, tagged `castlabs-v0.37.14+signer.1.0.9`. They are final, immutable GitHub releases (`prerelease=false`), not published to PyPI (local versions are rejected there). Development builds keep `.devN` versions, -and the development line moves to the next `.devN` series after each aligned -release. A signer retry under a new version gets a new `+signer` release. -Release-tooling changes (escaped tag trigger, prerelease flag, artifact names, -PyPI workflow limited to `.devN`) land with that release. +and moving the development line to the next `.devN` series is follow-up work +after each aligned release. A signer retry under a new version gets a new +`+signer` release. Historical release sections below describe their original +identities and inputs; they are not repinned for this candidate. ## Version 0.37.13.dev2 diff --git a/docs/trusted-vsi-python-contract.md b/docs/trusted-vsi-python-contract.md index 600df64f..51127cfa 100644 --- a/docs/trusted-vsi-python-contract.md +++ b/docs/trusted-vsi-python-contract.md @@ -1,19 +1,26 @@ # Trusted VSI Python Contract -Status: integrated, unreleased PR4 review changes requiring native trusted-VSI +Status: signer 1.0.8 candidate preparation requiring native trusted-VSI contract revision 3 (state version 3 and typed expert VSI payloads), following `c2pa-rs` -`docs/trusted-vsi-native-contract.md`. The Python identity is `0.37.13.dev2`, -prepared as the Castlabs VSI prerelease on native -`afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`; immutable dev5 release inputs and -artifacts are unchanged. The paired source workflow pins the same native commit +`docs/trusted-vsi-native-contract.md`. The prepared Python identity is +`0.37.13+signer.1.0.8`, with intended tag `castlabs-v0.37.13+signer.1.0.8` for a +final, immutable GitHub-only release (`prerelease=false`), not PyPI. This is +preparation only, not Python qualification or publication. Native inputs select +`6021100cc5060b06061ca15b634281cfeb97c718`; immutable historical release inputs and +artifacts are unchanged. Its `Cargo.lock` SHA-256 is +`ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f`. +The combined native #27/#28/#29 source passed focused CI; complete native bundle +and Python/native artifact qualification remain release gates. The paired +source workflow pins this same native commit (previously `3328b2b440fd07d9f857cbefa3c6d565093425de` for the unpublished 0.37.13.dev1 and `a6d4cdcc05638ee8dd0afce7fa5c850d7031a80c` before it) for revision-3 qualification. Select a reviewed local library with `C2PA_LIBRARY_NAME` for integration tests. Version `0.92.0-dev` and mask 63 do not establish contract identity; Python now requires the explicit native contract-revision probe. -Local source and installed-wheel checks passed; hosted results must be recorded -against the final source SHAs before claiming Linux/Windows qualification. +Earlier local source and installed-wheel checks do not qualify this candidate; +hosted results must be recorded against the final source SHAs before claiming +Linux/Windows qualification. Prior results and machine-local provenance are archived in [review verification](archive/trusted-vsi-review-verification.md). @@ -240,18 +247,23 @@ CBOR, BMFF or validation results. ## Qualification Identity -`scripts/build_trusted_vsi_functional.py --library --out -` first requires all probes true against that library, then builds a -NON-PUBLISHING wheel and sdist in a temporary staging copy with -`FUNCTIONAL_BUILD_VERSION` (default: the checkout's source identity -`0.37.13.dev2`; rejects `0.37.8.*`, dev5, release versions and anything older -than the source identity, including `0.37.13.dev1`, `0.37.13.dev0` and the historical -`0.37.9.dev0`). `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` identifies the native -library only. The release lock and tooling now match this source -(`castlabs-v0.37.13.dev2`); the immutable dev5 release remains historical. +The paired workflow requires all probes true against its exact native library, +then builds a NON-PUBLISHING wheel and sdist in a temporary source copy retaining +the source version `0.37.13+signer.1.0.8`. It stages only the paired native into +that copy, not the checkout or a release artifact directory. Its version variables +and the release lock/tooling match this identity. `C2PA_SOURCE_BUILD_VERSION=0.92.0-dev` +identifies the native library only; it does not attest a commit or qualification. + +`scripts/build_trusted_vsi_functional.py` accepts the checkout's exact +signer-aligned source identity or a future development version. It rejects +arbitrary final versions, dev5/`0.37.8.*`, and versions older than the checkout. +The paired workflow uses that existing builder's isolated staging and capability +probes without selecting a future development identity. The next development +series is future work after publication. Historical dev5 and dev2 +releases/tags/evidence are unchanged. `scripts/qualify_trusted_vsi_functional.py --wheel --venv ---version 0.37.13.dev2` installs the wheel into an isolated venv, strips +--version 0.37.13+signer.1.0.8` installs the wheel into an isolated venv, strips `PYTHONPATH`/`C2PA_LIBRARY_NAME`, and runs `test_trusted_vsi_api.py`, `test_fragmented_files.py`, `test_sign_ladder.py`, `test_native_ownership.py` and `test_native_ownership_opaque.py` with `C2PA_TRUSTED_VSI_ABI_REQUIRED=1`, @@ -264,15 +276,15 @@ 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 castlabs/c2pa-rs -`afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95`, the same commit as the -`castlabs-v0.37.13.dev2` release lock (trusted contract revision 3, mask 63, +`6021100cc5060b06061ca15b634281cfeb97c718`, the same commit as the +`castlabs-v0.37.13+signer.1.0.8` preparation lock (trusted contract revision 3, mask 63, offset-aware fragment exports). It sets `C2PA_FRAGMENT_OFFSET_REQUIRED=1`, so a native library without the fragment-offset probe fails rather than skips. Older pins (`6b506352`, `203dc08d`, `5c186c07`) emitted state version 2 and lacked the revision probe; they cannot qualify this gate. No tests or capability gates are weakened. -## PR4 CI Gate +## CI And Publication Hold At head `12d265db92e8dcbf80b8255278e9a7fc5945f750`, Build run `36810322522` completed its version/format/tooling jobs but skipped the paired @@ -281,12 +293,12 @@ and no labels; the source gate already accepts `COLLABORATOR` and `MEMBER`. The retained run metadata does not establish the event-time association, so this is not evidence that the current MEMBER condition is wrong. No author gate is broadened here (in particular no speculative OWNER exception or fork secrets). -Do NOT apply the maintainer `safe to test` label or run paired CI expecting it to -pass until the final native revision is integrated and pinned in the separate -authorized integration step. The unchanged older native pin and the new -revision-3 gate are intentionally incompatible. Only after integration -should a maintainer review the head, apply the existing label to trigger the -`labeled` event, and confirm required jobs actually ran. This task does not post -labels, dispatch CI, or repin. A green workflow with skipped test jobs is not -qualification, and local targeted older-library compatibility checks do not lift -the integration hold. +The candidate remains on hold until CI qualifies the exact Python/native pair +and its installed wheels. Selecting native `6021100c` does not establish that +paired qualification. Artifact builds still require `feat/trusted-vsi-functional` +(manual runs must use its full tip SHA and ref) or the exact aligned tag; the +preparation branch `release/signer-1.0.8` is not an allowed artifact-release source. +The non-publishing paired path can test a reviewed preparation SHA before that +integration. A maintainer must review the head, retain the existing author/label +gate, and confirm required jobs actually ran. This preparation does not tag or +publish. A green workflow with skipped test jobs is not qualification. diff --git a/pyproject.toml b/pyproject.toml index 827f4385..ca9441b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -version = "0.37.13.dev2" +version = "0.37.13+signer.1.0.8" requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } diff --git a/release/castlabs-release-evidence.schema.json b/release/castlabs-release-evidence.schema.json index 18d07134..419b7d41 100644 --- a/release/castlabs-release-evidence.schema.json +++ b/release/castlabs-release-evidence.schema.json @@ -13,7 +13,7 @@ "properties": { "type": {"enum": ["native-bundle", "wheel-bundle"]}, "package": {"const": "c2pa-python"}, - "version": {"const": "0.37.13.dev2"}, + "version": {"const": "0.37.13+signer.1.0.8"}, "file": {"type": "string", "minLength": 1}, "size": {"type": "integer", "minimum": 1}, "sha256": {"$ref": "#/$defs/sha256"}, @@ -49,9 +49,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95"}, + "commit": {"const": "6021100cc5060b06061ca15b634281cfeb97c718"}, "version": {"const": "0.92.0-dev"}, - "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} + "cargoLockSha256": {"const": "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f"} } } } diff --git a/release/castlabs-vsi-inputs.lock.json b/release/castlabs-vsi-inputs.lock.json index 5e8a982c..0e47f4e3 100644 --- a/release/castlabs-vsi-inputs.lock.json +++ b/release/castlabs-vsi-inputs.lock.json @@ -2,20 +2,20 @@ "schemaVersion": 1, "package": { "name": "c2pa-python", - "version": "0.37.13.dev2" + "version": "0.37.13+signer.1.0.8" }, "pythonSource": { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/trusted-vsi-functional", - "releaseTag": "castlabs-v0.37.13.dev2" + "releaseTag": "castlabs-v0.37.13+signer.1.0.8" }, "rustSource": { "repository": "castlabs/c2pa-rs", "url": "https://github.com/castlabs/c2pa-rs.git", - "commit": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95", + "commit": "6021100cc5060b06061ca15b634281cfeb97c718", "version": "0.92.0-dev", - "cargoLockSha256": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" + "cargoLockSha256": "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f" }, "rustToolchain": { "channel": "1.96.0", diff --git a/release/castlabs-vsi-inputs.schema.json b/release/castlabs-vsi-inputs.schema.json index 6e0a84bd..7095c214 100644 --- a/release/castlabs-vsi-inputs.schema.json +++ b/release/castlabs-vsi-inputs.schema.json @@ -30,7 +30,7 @@ "package": { "type": "object", "additionalProperties": false, "required": ["name", "version"], - "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13.dev2"}} + "properties": {"name": {"const": "c2pa-python"}, "version": {"const": "0.37.13+signer.1.0.8"}} }, "pythonSource": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "repository": {"const": "castlabs/c2pa-python"}, "url": {"const": "https://github.com/castlabs/c2pa-python.git"}, "releaseBranch": {"const": "feat/trusted-vsi-functional"}, - "releaseTag": {"const": "castlabs-v0.37.13.dev2"} + "releaseTag": {"const": "castlabs-v0.37.13+signer.1.0.8"} } }, "rustSource": { @@ -48,9 +48,9 @@ "properties": { "repository": {"const": "castlabs/c2pa-rs"}, "url": {"const": "https://github.com/castlabs/c2pa-rs.git"}, - "commit": {"const": "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95"}, + "commit": {"const": "6021100cc5060b06061ca15b634281cfeb97c718"}, "version": {"const": "0.92.0-dev"}, - "cargoLockSha256": {"const": "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370"} + "cargoLockSha256": {"const": "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f"} } }, "installer": { diff --git a/scripts/build_trusted_vsi_functional.py b/scripts/build_trusted_vsi_functional.py index 7d59c798..19612338 100644 --- a/scripts/build_trusted_vsi_functional.py +++ b/scripts/build_trusted_vsi_functional.py @@ -1,6 +1,6 @@ """Build source-paired functional test artifacts, never release/publish dev5. -The development version is applied only in a temporary source staging tree. +The source-aligned identity or a future development version is applied only in a temporary source staging tree. The repository's version and immutable release inputs are left untouched. """ @@ -31,10 +31,14 @@ def source_version(root: Path = ROOT) -> str: 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)): + if 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)) + # A final version is allowed only for this checkout's signer-aligned identity. + aligned_source = (version == minimum and not minimum.is_prerelease + and minimum.local is not None and minimum.local.startswith("signer.")) + if not version.is_devrelease and not aligned_source: + raise ValueError("functional version must be development or the exact signer-aligned source identity") if version < minimum: raise ValueError( f"functional version {version} must not be older than the source " diff --git a/scripts/castlabs_release.py b/scripts/castlabs_release.py index 35995525..02a4ffe3 100644 --- a/scripts/castlabs_release.py +++ b/scripts/castlabs_release.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Hermetic release helpers for the Castlabs VSI prerelease.""" +"""Hermetic release helpers for the Castlabs VSI release.""" from __future__ import annotations @@ -26,26 +26,27 @@ LOCK_PATH = ROOT / "release" / "castlabs-vsi-inputs.lock.json" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_VERSION = "0.37.13.dev2" -RUST_COMMIT = "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95" -CARGO_LOCK_SHA256 = "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" +RELEASE_VERSION = "0.37.13+signer.1.0.8" +RUST_COMMIT = "6021100cc5060b06061ca15b634281cfeb97c718" +CARGO_LOCK_SHA256 = "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f" RUST_TOOLCHAIN = "1.96.0" MANYLINUX_IMAGE = "quay.io/pypa/manylinux_2_28_x86_64" MANYLINUX_DIGEST = ( "sha256:0d9c2a66a745961947a8cecbe217ca0a7ee7a5849ba2517f20f9581d18444977" ) -RELEASE_TAG = "castlabs-v0.37.13.dev2" +RELEASE_TAG = f"castlabs-v{RELEASE_VERSION}" RELEASE_NAME = f"Castlabs c2pa-python {RELEASE_VERSION} (VSI)" RELEASE_BODY_MARKER = f"castlabs-vsi-release:{RELEASE_VERSION}" -RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable prerelease" +RELEASE_IDENTITY_TEXT = f"Castlabs c2pa-python {RELEASE_VERSION} immutable release" RELEASE_BODY = ( f"{RELEASE_BODY_MARKER}\n\n" f"{RELEASE_IDENTITY_TEXT}. Trusted-processor VSI line (native contract " - "revision 3, capability mask 63) on castlabs/c2pa-rs afd6fffe (0.92.0-dev, " + "revision 3, capability mask 63) on castlabs/c2pa-rs 6021100c (0.92.0-dev, " "Rust 1.96.0), merged with the Conformance Program 0.2 / C2PA 2.4 line: " "crJSON 2.4 output with checked export, caller-controlled validation time, " "offset-aware single-file fMP4 fragment verification and the default EKU " - "trust-policy fix. Successor to dev1 run 38786913569; no dev1 release. See " + "trust-policy fix, generic BMFF hash exclusions, ZIP 9.0.4 remediation and " + "verified VSI init refreshes. Signer-aligned 1.0.8 release, GitHub only (not PyPI). See " "the attached schema-2 evidence and SHA-256 sidecars." ) @@ -222,12 +223,12 @@ def validate_lock(lock: dict[str, Any]) -> None: if set(lock) != required or lock.get("schemaVersion") != 1: fail("release lock does not match schema version 1") if lock["package"] != {"name": "c2pa-python", "version": RELEASE_VERSION}: - fail("release lock package identity is not 0.37.13.dev2") + fail(f"release lock package identity is not {RELEASE_VERSION}") if lock["pythonSource"] != { "repository": "castlabs/c2pa-python", "url": "https://github.com/castlabs/c2pa-python.git", "releaseBranch": "feat/trusted-vsi-functional", - "releaseTag": "castlabs-v0.37.13.dev2", + "releaseTag": RELEASE_TAG, }: fail("unexpected c2pa-python release source policy") rust = lock["rustSource"] @@ -556,6 +557,7 @@ def wheel_details_bytes( native_members: list[tuple[str, bytes]] = [] metadata_members: list[bytes] = [] wheel_members: list[bytes] = [] + expected_dist_info = f"c2pa_python-{RELEASE_VERSION}.dist-info" with zipfile.ZipFile(io.BytesIO(payload)) as archive: names: set[str] = set() for info in archive.infolist(): @@ -574,8 +576,12 @@ def wheel_details_bytes( if member.parts[:2] == ("c2pa", "libs"): native_members.append((info.filename, contents)) if info.filename.endswith(".dist-info/METADATA"): + if member.parts != (expected_dist_info, "METADATA"): + fail(f"unexpected wheel dist-info directory: {info.filename}") metadata_members.append(contents) if info.filename.endswith(".dist-info/WHEEL"): + if member.parts != (expected_dist_info, "WHEEL"): + fail(f"unexpected wheel dist-info directory: {info.filename}") wheel_members.append(contents) if len(native_members) != 1: fail(f"wheel must contain exactly one native library: {name}") @@ -1163,8 +1169,8 @@ def validate_release_identity( fail("GitHub release response has the wrong release ID") if release_data.get("draft") is not True: fail("GitHub release is published; refusing to modify it") - if release_data.get("prerelease") is not True: - fail("GitHub draft is not marked as a prerelease") + if release_data.get("prerelease") is not False: + fail("GitHub draft must have prerelease=false for the signer-aligned release") if release_data.get("name") != RELEASE_NAME: fail("GitHub draft has the wrong release name") normalized_body = normalize_release_body(release_data.get("body")) @@ -1469,6 +1475,8 @@ def command_verify_draft_release(args: argparse.Namespace) -> None: def pypi_missing_wheels( wheels_dir: Path, http_status: int, response: dict[str, Any] | None ) -> tuple[dict[str, Path], list[str]]: + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+", RELEASE_VERSION): + fail("PyPI promotion accepts dev-only X.Y.Z.devN versions, not aligned releases") wheels = exact_files(wheels_dir, expected_wheel_names(), "PyPI wheel inputs") if http_status == 404: return wheels, sorted(wheels) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 654330ab..6c8c0f2f 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -11,7 +11,7 @@ # specific language governing permissions and limitations under # each license. -# Version: 0.37.13.dev2 +# Version: 0.37.13+signer.1.0.8 import ctypes import enum diff --git a/tests/test_castlabs_release_smoke.py b/tests/test_castlabs_release_smoke.py index 8dd940a6..0c10862f 100644 --- a/tests/test_castlabs_release_smoke.py +++ b/tests/test_castlabs_release_smoke.py @@ -46,7 +46,9 @@ FIXTURES = Path(__file__).parent / "fixtures" -EXPECTED_VERSION = os.environ.get("CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13.dev2") +EXPECTED_VERSION = os.environ.get( + "CASTLABS_RELEASE_EXPECTED_VERSION", "0.37.13+signer.1.0.8" +) REQUIRED_CAPABILITIES = { "dynamic assertions": has_dynamic_assertions, "fragmented files": has_fragmented_files, diff --git a/tests/test_castlabs_release_tooling.py b/tests/test_castlabs_release_tooling.py index 09f7cadf..180022ad 100644 --- a/tests/test_castlabs_release_tooling.py +++ b/tests/test_castlabs_release_tooling.py @@ -9,6 +9,7 @@ import re import shutil import subprocess +import sys import tarfile import tempfile import textwrap @@ -16,6 +17,7 @@ import zipfile from pathlib import Path from types import SimpleNamespace +from urllib.parse import parse_qs import pytest @@ -48,12 +50,14 @@ def _posix_bash() -> str: raise AssertionError(f"Git for Windows bash not found near {git}") -def test_prerelease_version_is_consistent(): +def test_signer_aligned_release_version_is_consistent(): # The release identity is consistent across the tooling, lock and source, - # so validate-sources accepts this checkout under the 0.37.13.dev2 lock. - assert release.RELEASE_VERSION == "0.37.13.dev2" - assert release.RELEASE_TAG == "castlabs-v0.37.13.dev2" - assert release.load_lock()["package"]["version"] == "0.37.13.dev2" + # while validate-sources retains the strict release-branch/tag policy. + assert release.RELEASE_VERSION == "0.37.13+signer.1.0.8" + assert release.RELEASE_TAG == "castlabs-v0.37.13+signer.1.0.8" + assert release.load_lock()["package"]["version"] == release.RELEASE_VERSION + assert "immutable release" in release.RELEASE_IDENTITY_TEXT + assert "not PyPI" in release.RELEASE_BODY source = release.project_version(ROOT) assert source == release.RELEASE_VERSION first_line = ( @@ -68,9 +72,9 @@ def test_release_lock_and_schemas_are_valid_json(): assert lock["rustSource"]["commit"] == release.RUST_COMMIT assert lock["rustSource"]["cargoLockSha256"] == release.CARGO_LOCK_SHA256 assert lock["rustToolchain"]["channel"] == "1.96.0" - assert release.RUST_COMMIT == "afd6fffe5a066e0b5b1a14f3f8c7ac189e7f0c95" + assert release.RUST_COMMIT == "6021100cc5060b06061ca15b634281cfeb97c718" assert release.CARGO_LOCK_SHA256 == ( - "f7022b3e05c2dc2080946064b386b293b29c3a60ca9daf344e1b4e3fc398c370" + "ff5cf2de2403ff491957fc8a2206c289203d2799a30425bd615f7c9e69974a8f" ) assert lock["rustSource"]["version"] == "0.92.0-dev" assert lock["pythonSource"]["releaseBranch"] == "feat/trusted-vsi-functional" @@ -127,10 +131,10 @@ def test_trusted_vsi_workflows_isolate_paired_abi_from_release(): 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 'python scripts/build_trusted_vsi_functional.py --library "$C2PA_LIBRARY_NAME" --out build/functional-qualification' 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", + "upload-artifact@", "contents: write", "id-token: write", "continue-on-error", "gh release"): assert forbidden not in paired @@ -195,7 +199,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( in release_workflow ) assert ( - "c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl" in release_workflow + "c2pa_python-0.37.13+signer.1.0.8-py3-none-manylinux_2_28_x86_64.whl" in release_workflow ) assert release_workflow.count("verify-wheel-native") == 2 assert release_workflow.index("--only-plat --plat manylinux_2_28_x86_64") < ( @@ -246,8 +250,13 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" in (release_workflow) ) - assert "if: github.ref == 'refs/tags/castlabs-v0.37.13.dev2'" in release_workflow - assert "-F draft=true -F prerelease=true" in release_workflow + escaped_tag = release.RELEASE_TAG.replace("+", r"\+") + assert f" - '{escaped_tag}'" in release_workflow + assert f"if: github.event_name == 'push' && github.ref == 'refs/tags/{release.RELEASE_TAG}'" in release_workflow + assert release_workflow.count('if [[ "${GITHUB_EVENT_NAME}" == push ]]; then EVENT=tag; fi') == 2 + assert '$event = if ($env:GITHUB_EVENT_NAME -eq "push") { "tag" } else { "manual" }' in release_workflow + assert "-F draft=true -F prerelease=false" in release_workflow + assert "prerelease=true" not in release_workflow assert "--clobber" not in release_workflow assert "repos/castlabs/c2pa-python/releases" in release_workflow assert "inspect-draft-release" in release_workflow @@ -264,7 +273,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert "target_commitish=$SOURCE_SHA" in release_workflow assert ( "https://uploads.github.com/repos/castlabs/c2pa-python/releases/" - "${RELEASE_ID}/assets?name=${asset_name}" in release_workflow + "${RELEASE_ID}/assets?name=${encoded_name}" in release_workflow ) assert "validate-upload-response" in release_workflow assert release_workflow.count("return 0") >= 5 @@ -309,6 +318,11 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_workflow.index("inspect-draft-release") ) assert "environment: pypipublish" in pypi_workflow + guard = pypi_workflow.index("Reject non-development release tags before checkout") + assert guard < pypi_workflow.index("uses: actions/checkout@") + assert r"^castlabs-v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$" in pypi_workflow + assert "v.is_devrelease and v.local is None" in pypi_workflow + assert release.RELEASE_TAG not in pypi_workflow assert ".draft == false and .prerelease == true" in pypi_workflow assert ( "python3 -m pytest -q c2pa-python/tests/test_castlabs_release_tooling.py" @@ -381,7 +395,7 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( assert smoke_gate in smoke assert smoke.index(smoke_gate) < smoke.index("from c2pa import") assert release_workflow.count('CASTLABS_RELEASE_SMOKE_REQUIRED: "1"') == 2 - assert release_workflow.count("CASTLABS_RELEASE_EXPECTED_VERSION: 0.37.13.dev2") == 2 + assert release_workflow.count(f"CASTLABS_RELEASE_EXPECTED_VERSION: {release.RELEASE_VERSION}") == 2 assert release_workflow.count("C2PA_SOURCE_BUILD_VERSION: 0.92.0-dev") == 2 # pytest is not a wheel runtime dependency: both clean installed-wheel # environments install it after the wheel, before running pytest. @@ -464,12 +478,74 @@ def test_release_workflows_are_pinned_bounded_and_do_not_drift_from_helper( release_notes = (ROOT / "docs" / "release-notes.md").read_text( encoding="utf-8" ) - assert "## Version 0.37.13.dev2" in release_notes + assert "## Version 0.37.13+signer.1.0.8 (Preparation Only)" in release_notes assert "### 0.37.13.dev1 (not released)" in release_notes assert "Dev4 and earlier accepted\n undersized callback outputs" in release_notes assert "breaking strictness\n change" in release_notes +def test_upload_query_preserves_local_version_identifiers(): + workflow = (ROOT / ".github/workflows/castlabs-vsi-release.yml").read_text( + encoding="utf-8" + ) + assignment = next( + line.strip() for line in workflow.splitlines() + if line.strip().startswith("encoded_name=$(python3") + ) + for name in release.expected_release_asset_names(): + result = subprocess.run( + [_posix_bash(), "-c", 'python3() { "$TEST_PYTHON" "$@"; }\n' + + assignment + '\nprintf "%s" "$encoded_name"'], + env=dict(os.environ, asset_name=name, TEST_PYTHON=Path(sys.executable).as_posix()), + text=True, capture_output=True, + check=True, + ).stdout + assert parse_qs(f"name={result}") == {"name": [name]} + if "+" in name: + assert "%2B" in result + assert "+" not in result + + +@pytest.mark.parametrize("event, ref", [ + ("manual", "refs/heads/release/signer-1.0.8"), + ("manual", "refs/heads/main"), + ("manual", "refs/tags/castlabs-v0.37.13+signer.1.0.8"), + ("tag", "refs/tags/castlabs-v0.37.13.dev2"), +]) +def test_aligned_release_does_not_relax_source_ref_policy(monkeypatch, tmp_path, event, ref): + monkeypatch.setattr(release, "project_version", lambda root: release.RELEASE_VERSION) + def fake_git(root, *args): + if args == ("remote", "get-url", "origin"): + return "https://github.com/castlabs/c2pa-python.git" + assert args == ("rev-parse", "HEAD") + return TEST_SOURCE_SHA + monkeypatch.setattr(release, "run_git", fake_git) + with pytest.raises(SystemExit, match="release event ref must be"): + release.command_validate_sources(SimpleNamespace( + python_root=str(tmp_path), rust_root=str(tmp_path), + source_sha=TEST_SOURCE_SHA, repository="castlabs/c2pa-python", + event=event, ref=ref, + )) + + +def test_manual_release_still_requires_locked_branch_tip(monkeypatch, tmp_path): + monkeypatch.setattr(release, "project_version", lambda root: release.RELEASE_VERSION) + def fake_git(root, *args): + if args == ("remote", "get-url", "origin"): + return "https://github.com/castlabs/c2pa-python.git" + if args == ("rev-parse", "HEAD"): + return TEST_SOURCE_SHA + assert args == ("rev-parse", "refs/remotes/origin/feat/trusted-vsi-functional") + return "b" * 40 + monkeypatch.setattr(release, "run_git", fake_git) + with pytest.raises(SystemExit, match="locked release branch tip"): + release.command_validate_sources(SimpleNamespace( + python_root=str(tmp_path), rust_root=str(tmp_path), + source_sha=TEST_SOURCE_SHA, repository="castlabs/c2pa-python", + event="manual", ref="refs/heads/feat/trusted-vsi-functional", + )) + + def test_cargo_execution_and_evidence_share_the_locked_command(monkeypatch, tmp_path): calls = [] @@ -660,7 +736,7 @@ def test_safe_extract_round_trip_and_rejects_links(): def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): with tempfile.TemporaryDirectory() as temp: root = Path(temp) - wheel = root / "c2pa_python-0.37.13.dev2-py3-none-win_amd64.whl" + wheel = root / f"c2pa_python-{release.RELEASE_VERSION}-py3-none-win_amd64.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("c2pa/libs/first.dll", b"one") archive.writestr("c2pa/libs/second.dll", b"two") @@ -671,7 +747,7 @@ def test_wheel_inspection_rejects_unsafe_and_multiple_native_members(): else: raise AssertionError("accepted wheel with multiple native libraries") - unsafe = root / "c2pa_python-0.37.13.dev2-py3-none-manylinux_2_28_x86_64.whl" + unsafe = root / f"c2pa_python-{release.RELEASE_VERSION}-py3-none-manylinux_2_28_x86_64.whl" with zipfile.ZipFile(unsafe, "w") as archive: archive.writestr("../libc2pa_c.so", b"unsafe") try: @@ -724,11 +800,12 @@ def _write_wheel( bom: bool = False, duplicate_name: bool = False, duplicate_version: bool = False, + dist_info: str | None = None, ) -> None: lock = release.load_lock() platform_tag = lock["targets"][target]["wheelPlatformTag"] native_name = lock["targets"][target]["library"] - dist_info = f"c2pa_python-{release.RELEASE_VERSION}.dist-info" + dist_info = dist_info or f"c2pa_python-{release.RELEASE_VERSION}.dist-info" metadata_lines = [ "Metadata-Version: 2.1", "Name: c2pa-python", @@ -813,6 +890,27 @@ def test_wheel_metadata_parser_handles_crlf_bom_and_rejects_duplicates(tmp_path) release.wheel_details(wheel, "x86_64-pc-windows-msvc", "Python 3.10.0") +@pytest.mark.parametrize("target, platform", [ + ("x86_64-unknown-linux-gnu", "manylinux_2_28_x86_64"), + ("x86_64-pc-windows-msvc", "win_amd64"), +]) +def test_wheel_local_version_filename_metadata_and_dist_info(tmp_path, target, platform): + wheel = tmp_path / f"c2pa_python-{release.RELEASE_VERSION}-py3-none-{platform}.whl" + assert wheel.name in release.expected_wheel_names() + _write_wheel(wheel, target, b"native") + assert release.wheel_details(wheel, target, "3.10")["file"] == wheel.name + with zipfile.ZipFile(wheel) as archive: + assert f"c2pa_python-{release.RELEASE_VERSION}.dist-info/METADATA" in archive.namelist() + for version in ("0.37.13", "0.37.13.dev2", "0.37.13_signer.1.0.8"): + _write_wheel(wheel, target, b"native", dist_info=f"c2pa_python-{version}.dist-info") + with pytest.raises(SystemExit, match="unexpected wheel dist-info directory"): + release.wheel_details(wheel, target, "3.10") + with pytest.raises(SystemExit, match="unexpected wheel filename"): + release.wheel_details_bytes( + wheel.name.replace("+", "_"), wheel.read_bytes(), target, "3.10" + ) + + def test_verified_wheel_native_is_exact_qualified_artifact(tmp_path): native = b"qualified-native-bytes" wheel = tmp_path / ( @@ -982,7 +1080,7 @@ def _release_json(paths: dict[str, Path], names: set[str], *, draft=True): "id": 77, "tag_name": release.RELEASE_TAG, "draft": draft, - "prerelease": True, + "prerelease": False, "name": release.RELEASE_NAME, "body": release.RELEASE_BODY, "target_commitish": TEST_SOURCE_SHA, @@ -1115,7 +1213,7 @@ def test_release_response_validators_cover_owned_empty_and_complete_states(tmp_p ("body", "wrong-body"), ("target_commitish", "b" * 40), ("draft", False), - ("prerelease", False), + ("prerelease", True), ): invalid = copy.deepcopy(create_response) invalid[field] = bad_value @@ -1267,7 +1365,43 @@ def _pypi_response(wheels: dict[str, Path], names: set[str]) -> dict: } -def test_pypi_retry_plan_accepts_only_matching_existing_wheels(tmp_path): +@pytest.fixture +def development_release(monkeypatch): + # Exercise the retained retry policy without allowing the aligned release + # to reach PyPI. Production inputs still require the exact committed lock. + lock = copy.deepcopy(release.load_lock()) + version = "0.37.13.dev2" + tag = f"castlabs-v{version}" + lock["package"]["version"] = version + lock["pythonSource"]["releaseTag"] = tag + monkeypatch.setattr(release, "RELEASE_VERSION", version) + monkeypatch.setattr(release, "RELEASE_TAG", tag) + monkeypatch.setattr(release, "load_lock", lambda: lock) + + +@pytest.mark.parametrize("version", [ + "0.37.13+signer.1.0.8", "0.37.13.dev2+signer.1.0.8", + "0.37.13", "0.37.13rc1", "0.37.13.dev2junk", +]) +def test_pypi_rejects_non_development_versions_before_reading_wheels( + monkeypatch, tmp_path, version +): + monkeypatch.setattr(release, "RELEASE_VERSION", version) + with pytest.raises(SystemExit, match="dev-only"): + release.pypi_missing_wheels(tmp_path / "absent", 404, None) + with pytest.raises(SystemExit, match="dev-only"): + release.command_pypi_plan(SimpleNamespace( + wheels_dir=str(tmp_path / "absent"), http_status="404", + response=str(tmp_path / "absent.json"), + destination=str(tmp_path / "publish"), output=str(tmp_path / "plan.json"), + github_output=str(tmp_path / "github-output"), + )) + assert not (tmp_path / "publish").exists() + assert not (tmp_path / "plan.json").exists() + assert not (tmp_path / "github-output").exists() + + +def test_pypi_retry_plan_accepts_only_matching_existing_wheels(tmp_path, development_release): wheels_dir = tmp_path / "wheels" wheels = _write_policy_wheels(wheels_dir) names = set(wheels) @@ -1299,7 +1433,7 @@ def test_pypi_retry_plan_accepts_only_matching_existing_wheels(tmp_path): release.pypi_missing_wheels(wheels_dir, 200, unexpected) -def test_pypi_command_stages_all_missing_wheels_together(tmp_path): +def test_pypi_command_stages_all_missing_wheels_together(tmp_path, development_release): wheels_dir = tmp_path / "wheels" wheels = _write_policy_wheels(wheels_dir) existing = {sorted(wheels)[0]} diff --git a/tests/test_trusted_vsi_build.py b/tests/test_trusted_vsi_build.py index 587a4161..32c8e8c0 100644 --- a/tests/test_trusted_vsi_build.py +++ b/tests/test_trusted_vsi_build.py @@ -15,8 +15,10 @@ SPEC.loader.exec_module(functional_build) -@pytest.mark.parametrize("version", ["0.37.13.dev2", "0.37.13.dev3", "0.38.0.dev1", "1.0.dev0+local"]) -def test_new_functional_development_version(version): +@pytest.mark.parametrize("version", [ + "0.37.13+signer.1.0.8", "0.37.14.dev0", "0.37.14.dev1", "0.38.0.dev1", "1.0.dev0+local", +]) +def test_functional_source_or_future_development_version(version): assert functional_build.functional_version(version) == version @@ -27,13 +29,16 @@ def test_new_functional_development_version(version): "0.37.9.dev0", "0.37.12.dev0", "0.37.13.dev0", "0.37.13.dev1", # Conformance-line prereleases merged into this line are older too. "0.37.12.dev4", + "0.37.13.dev2", "0.37.13.dev3", "0.37.13", "0.37.13+signer.1.0.9", + "0.37.13+signer.1.0.8.1", "0.37.14+signer.1.0.8", ]) 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): +@pytest.mark.parametrize("selected_version", ["0.37.13+signer.1.0.8", "0.37.14.dev0"]) +def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path, selected_version): import subprocess import sys import tarfile @@ -41,9 +46,9 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): before = {name: (ROOT / name).read_bytes() for name in ("pyproject.toml", "src/c2pa/c2pa.py")} stage = tmp_path / "stage" stage.mkdir() - functional_build.stage_source(stage, "0.37.13.dev3") - assert 'version = "0.37.13.dev3"' in (stage / "pyproject.toml").read_text() - assert "# Version: 0.37.13.dev3" in (stage / "src/c2pa/c2pa.py").read_text() + functional_build.stage_source(stage, selected_version) + assert f'version = "{selected_version}"' in (stage / "pyproject.toml").read_text() + assert f"# Version: {selected_version}" 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) @@ -52,12 +57,12 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): (sdist,) = (tmp_path / "dist").iterdir() name, version = parse_sdist_filename(sdist.name) assert canonicalize_name(name) == "c2pa-python" - assert version == Version("0.37.13.dev3") + assert version == Version(selected_version) root = sdist.name[:-len(".tar.gz")] with tarfile.open(sdist) as archive: names = archive.getnames() info = archive.extractfile(f"{root}/PKG-INFO").read().decode() - assert "Version: 0.37.13.dev3" in info.splitlines() + assert f"Version: {selected_version}" 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"): @@ -66,13 +71,20 @@ def test_staged_sdist_uses_new_version_without_touching_dev5_checkout(tmp_path): after = {name: (ROOT / name).read_bytes() for name in before} assert after == before # The checkout keeps its own source identity; staging never rewrites it. - assert 'version = "0.37.13.dev2"' in before["pyproject.toml"].decode() + assert 'version = "0.37.13+signer.1.0.8"' 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.dev2" - assert functional_build.functional_version(functional_build.source_version()) == "0.37.13.dev2" + assert functional_build.source_version() == "0.37.13+signer.1.0.8" + assert functional_build.functional_version(functional_build.source_version()) == "0.37.13+signer.1.0.8" + + +@pytest.mark.parametrize("version", ["0.37.13", "0.37.13+vendor.1"]) +def test_final_source_without_signer_identity_is_not_qualified(tmp_path, version): + (tmp_path / "pyproject.toml").write_text(f'[project]\nversion = "{version}"\n') + with pytest.raises(ValueError): + functional_build.functional_version(version, tmp_path) def test_probe_script_requires_every_functional_capability():