From 10cf9554626367148735de8a930c2cd54acd5cae Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Thu, 24 Sep 2026 23:36:22 +0200 Subject: [PATCH 1/7] feat: add Builder.sign_ladder for single-file ABR ladders Wraps `c2pa_builder_sign_ladder` (castlabs/c2pa-rs#9): sign an ABR ladder of single-file fragmented MP4s, one file per rendition, into one manifest with one Merkle tree per rendition. Sources and dests are parallel lists, positionally matched; lengths are checked and an empty ladder refused before the FFI runs; the builder is marked consumed and the manifest bytes released on every path. There is no read-back step: the writer returns the manifest it embedded, so the glob read-back defect fixed in castlabs/c2pa-rs#6 cannot recur here. Requires a native library built from #9. Against an older libc2pa_c the registration table fails at import, so this ships with the native bump. Co-Authored-By: Claude Fable 5.1 --- src/c2pa/c2pa.py | 108 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 4fa8522a..37b6fbdb 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -70,6 +70,7 @@ 'c2pa_builder_sign', 'c2pa_builder_sign_context', 'c2pa_builder_sign_fragmented', + 'c2pa_builder_sign_ladder', 'c2pa_builder_from_context', 'c2pa_builder_with_definition', 'c2pa_builder_with_archive', @@ -789,6 +790,16 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], ctypes.c_int64 ) +_setup_function( + _lib.c2pa_builder_sign_ladder, + [ctypes.POINTER(C2paBuilder), + ctypes.POINTER(C2paSigner), + ctypes.POINTER(ctypes.c_char_p), # sources + ctypes.POINTER(ctypes.c_char_p), # dests + ctypes.c_size_t, # count + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 +) class C2paError(Exception): @@ -3948,6 +3959,103 @@ def sign_fragmented( return manifest_bytes + def sign_ladder( + self, + signer: Signer, + sources: list[Union[str, Path]], + dests: list[Union[str, Path]], + ) -> bytes: + """Sign an ABR ladder of single-file fragmented MP4s into ONE manifest. + + Wraps ``c2pa::Builder::sign_ladder_files`` via the + ``c2pa_builder_sign_ladder`` FFI. Every rendition of the ladder is + covered by a single claim: the BMFF hash assertion carries one Merkle + tree per rendition, and the identical manifest is embedded into each + output file, so the set validates together and a watermark that + resolves to the session resolves to one manifest rather than one per + rendition. + + Each source must be a *single-file* fragmented MP4 -- its own ``moov`` + and ``moof`` boxes. A multiplexed asset (several tracks in one file) is + rejected; demux it into one file per rendition first. For the + init-segment-plus-fragments layout use + :py:meth:`sign_fragmented` instead. + + Args: + signer: The signer to use. Either kind works -- one built from + ``C2paSignerInfo`` or one with an explicit callback. + sources: One path per rendition. + dests: Output paths, positionally matched to ``sources``. Must be + distinct and must not alias a source. + + Returns: + The manifest bytes embedded in every rendition. + + Raises: + C2paError: If signing fails, or if the inputs are not a valid + ladder (empty, mismatched lengths, a rendition that is not + single-file fragmented, or overlapping paths). + """ + self._ensure_valid_state() + if not hasattr(signer, "_handle") or not signer._handle: + raise C2paError("Invalid or closed signer") + if len(sources) != len(dests): + raise C2paError( + f"sources and dests must have the same length; " + f"got {len(sources)} and {len(dests)}" + ) + if not sources: + raise C2paError("a ladder needs at least one rendition") + + count = len(sources) + source_bytes = [os.fspath(p).encode("utf-8") for p in sources] + dest_bytes = [os.fspath(p).encode("utf-8") for p in dests] + # Keep the Python bytes objects alive for the duration of the call -- + # the arrays below hold borrowed pointers into them. + source_array = (ctypes.c_char_p * count)(*source_bytes) + dest_array = (ctypes.c_char_p * count)(*dest_bytes) + + 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), + ) + # The Rust FFI consumes the builder pointer on sign; mark + # ours consumed too so subsequent methods raise clearly. + self._mark_consumed() + except Exception as e: + self._mark_consumed() + raise C2paError(f"Error during ladder signing: {e}") + + _check_ffi_operation_result( + result, + "Error during ladder signing", + check=lambda r: r < 0, + ) + + manifest_bytes = b"" + if manifest_bytes_ptr and result > 0: + try: + temp_buffer = (ctypes.c_ubyte * result)() + ctypes.memmove(temp_buffer, manifest_bytes_ptr, result) + manifest_bytes = bytes(temp_buffer) + except Exception: + manifest_bytes = b"" + finally: + try: + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + except Exception: + logger.error( + "Failed to release native manifest bytes memory" + ) + + return manifest_bytes + @overload def sign_file( self, From 298919cb852e402df427d4cdbfbfbb6352ed85b5 Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Fri, 25 Sep 2026 00:28:28 +0200 Subject: [PATCH 2/7] fix: make the ladder symbol optional and stop consuming the builder `c2pa_builder_sign_ladder` was listed in `_REQUIRED_FUNCTIONS`, so every importer failed at import against the pinned native library, which does not carry it yet. The symbol is now optional: `_HAS_SIGN_LADDER` records whether the loaded library has it, the ctypes signature is installed only then, and `Builder.sign_ladder` raises a C2paError naming the missing capability. The stock 0.31.0+stardustproof.5 library imports again and the unit suite passes against it. `sign_ladder` also called `_mark_consumed()` on success and on failure, although the FFI borrows the builder and never frees it, so the native object leaked. The handle is kept and released by `close()` or collection; verified that the builder closes cleanly after a successful and a failed ladder signing on a library that has the symbol. Co-Authored-By: Claude Fable 5.1 --- src/c2pa/c2pa.py | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index 37b6fbdb..b7d231a2 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -70,7 +70,6 @@ 'c2pa_builder_sign', 'c2pa_builder_sign_context', 'c2pa_builder_sign_fragmented', - 'c2pa_builder_sign_ladder', 'c2pa_builder_from_context', 'c2pa_builder_with_definition', 'c2pa_builder_with_archive', @@ -790,16 +789,21 @@ def _setup_function(func, argtypes, restype=None): ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], ctypes.c_int64 ) -_setup_function( - _lib.c2pa_builder_sign_ladder, - [ctypes.POINTER(C2paBuilder), - ctypes.POINTER(C2paSigner), - ctypes.POINTER(ctypes.c_char_p), # sources - ctypes.POINTER(ctypes.c_char_p), # dests - ctypes.c_size_t, # count - ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], - ctypes.c_int64 -) +# Optional: present only in native builds carrying castlabs/c2pa-rs#9. Not +# listed in _REQUIRED_FUNCTIONS, so older libraries still import; sign_ladder +# reports the missing capability itself. +_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), # sources + ctypes.POINTER(ctypes.c_char_p), # dests + ctypes.c_size_t, # count + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64 + ) class C2paError(Exception): @@ -3997,6 +4001,12 @@ def sign_ladder( single-file fragmented, or overlapping paths). """ self._ensure_valid_state() + if not _HAS_SIGN_LADDER: + raise C2paError( + "this native library cannot sign a ladder: " + "c2pa_builder_sign_ladder is absent. It needs a build " + "carrying castlabs/c2pa-rs#9." + ) if not hasattr(signer, "_handle") or not signer._handle: raise C2paError("Invalid or closed signer") if len(sources) != len(dests): @@ -4025,12 +4035,11 @@ def sign_ladder( count, ctypes.byref(manifest_bytes_ptr), ) - # The Rust FFI consumes the builder pointer on sign; mark - # ours consumed too so subsequent methods raise clearly. - self._mark_consumed() except Exception as e: - self._mark_consumed() raise C2paError(f"Error during ladder signing: {e}") + # The FFI borrows the builder and does not free it, so the handle is + # kept: it is released by close() or on collection, on success and on + # failure alike. Discarding it here would leak the native object. _check_ffi_operation_result( result, From f520eededc1e479fe54b83acd45ec06e7c5be795 Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Fri, 25 Sep 2026 10:08:21 +0200 Subject: [PATCH 3/7] fix: validate ladder paths before the FFI, propagate copy failures, add tests Review findings on Builder.sign_ladder. A path with an embedded NUL was accepted by `c_char_p` and silently truncated at the NUL, so `output.mp4\0ignored` wrote `output.mp4`. Every source and destination is now encoded by `_ladder_path_bytes`, which accepts str, bytes and PathLike, refuses a NUL, and turns an unencodable or non-path value into a C2paError, all before the native call. A failure while copying the returned manifest was swallowed and reported as successful empty bytes. The copy now uses `ctypes.string_at` and any failure propagates; the native buffer is released exactly once either way; a positive length with no buffer is an error; a zero length is b"". The missing-symbol case raises `C2paError.NotSupported`. tests/test_builder_sign_ladder.py covers the optional symbol at import and call time, every preflight refusal (no native call made), path order and encoding through a CFUNCTYPE stand-in for the native function, negative results, copy failure with exactly-once freeing, and the builder surviving success and failure. Its last test signs two real renditions and validates each output; it skips when the loaded library lacks the symbol unless C2PA_REQUIRE_SIGN_LADDER=1, in which case the absence is a failure -- the switch a release lane that is supposed to carry the symbol turns on. The single-file fragmented fixture is the c2pa-rs one, with its generation command. Co-Authored-By: Claude Fable 5.1 --- src/c2pa/c2pa.py | 69 ++-- .../fixtures/single-file-fragmented/README.md | 16 + .../single_file_fragments.mp4 | Bin 0 -> 3812 bytes tests/test_builder_sign_ladder.py | 294 ++++++++++++++++++ 4 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 tests/fixtures/single-file-fragmented/README.md create mode 100644 tests/fixtures/single-file-fragmented/single_file_fragments.mp4 create mode 100644 tests/test_builder_sign_ladder.py diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py index b7d231a2..2c10206c 100644 --- a/src/c2pa/c2pa.py +++ b/src/c2pa/c2pa.py @@ -3193,6 +3193,28 @@ def _wrapped(ctx, c_label, c_reserve, c_json, out_ptr, out_max): _parse_operation_result_for_error(None) +def _ladder_path_bytes(path, name: str, index: int) -> bytes: + """Encode one ladder path for the C API. + + ``c_char_p`` hands C a NUL-terminated string, so a path with an embedded + NUL would silently become its prefix -- a different file read or written + instead of an error. Refuse it here, and turn an unencodable path into the + same error type rather than a bare UnicodeEncodeError. + """ + try: + raw = os.fspath(path) + encoded = raw if isinstance(raw, bytes) else raw.encode("utf-8") + except TypeError as e: + raise C2paError(f"{name}[{index}] is not a path: {path!r}") from e + except UnicodeEncodeError as e: + raise C2paError( + f"{name}[{index}] cannot be encoded as UTF-8: {path!r}" + ) from e + if b"\0" in encoded: + raise C2paError(f"{name}[{index}] contains a NUL byte: {path!r}") + return encoded + + class Builder(ManagedResource): """High-level wrapper for C2PA Builder operations.""" @@ -3996,13 +4018,16 @@ def sign_ladder( The manifest bytes embedded in every rendition. Raises: + C2paError.NotSupported: If the loaded native library has no + ``c2pa_builder_sign_ladder`` (see ``_HAS_SIGN_LADDER``). C2paError: If signing fails, or if the inputs are not a valid - ladder (empty, mismatched lengths, a rendition that is not - single-file fragmented, or overlapping paths). + ladder (empty, mismatched lengths, a path that cannot be + handed to C, a rendition that is not single-file fragmented, + or overlapping paths). """ self._ensure_valid_state() if not _HAS_SIGN_LADDER: - raise C2paError( + raise C2paError.NotSupported( "this native library cannot sign a ladder: " "c2pa_builder_sign_ladder is absent. It needs a build " "carrying castlabs/c2pa-rs#9." @@ -4018,8 +4043,12 @@ def sign_ladder( raise C2paError("a ladder needs at least one rendition") count = len(sources) - source_bytes = [os.fspath(p).encode("utf-8") for p in sources] - dest_bytes = [os.fspath(p).encode("utf-8") for p in dests] + source_bytes = [ + _ladder_path_bytes(p, "sources", i) for i, p in enumerate(sources) + ] + dest_bytes = [ + _ladder_path_bytes(p, "dests", i) for i, p in enumerate(dests) + ] # Keep the Python bytes objects alive for the duration of the call -- # the arrays below hold borrowed pointers into them. source_array = (ctypes.c_char_p * count)(*source_bytes) @@ -4047,23 +4076,23 @@ def sign_ladder( check=lambda r: r < 0, ) - manifest_bytes = b"" - if manifest_bytes_ptr and result > 0: + if result == 0: + return b"" + if not manifest_bytes_ptr: + raise C2paError( + f"native ladder signing reported {result} manifest bytes but " + "returned no buffer" + ) + # A failed copy is a failed call: the manifest was not delivered, so + # it must not come back as successful empty bytes. The native buffer + # is released exactly once either way. + try: + return ctypes.string_at(manifest_bytes_ptr, result) + finally: try: - temp_buffer = (ctypes.c_ubyte * result)() - ctypes.memmove(temp_buffer, manifest_bytes_ptr, result) - manifest_bytes = bytes(temp_buffer) + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) except Exception: - manifest_bytes = b"" - finally: - try: - _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) - except Exception: - logger.error( - "Failed to release native manifest bytes memory" - ) - - return manifest_bytes + logger.error("Failed to release native manifest bytes memory") @overload def sign_file( diff --git a/tests/fixtures/single-file-fragmented/README.md b/tests/fixtures/single-file-fragmented/README.md new file mode 100644 index 00000000..b0c08a06 --- /dev/null +++ b/tests/fixtures/single-file-fragmented/README.md @@ -0,0 +1,16 @@ +# Single-file fragmented fixture + +`single_file_fragments.mp4` is the c2pa-rs fixture of the same name +(`sdk/tests/fixtures/single_file_fragments.mp4`): one file holding the +initialization prefix (`ftyp`/`moov`) and three H.264 `moof`/`mdat` +fragments, generated from the synthetic `testsrc2` source with FFmpeg 6.1.1: + +```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 +``` + +`Builder.sign_ladder` requires this shape for every rendition. Tests use the +committed bytes and do not require FFmpeg. diff --git a/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 b/tests/fixtures/single-file-fragmented/single_file_fragments.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1a4813c4c60473c619665e130c75f4f63b071b01 GIT binary patch literal 3812 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 B3upiU literal 0 HcmV?d00001 diff --git a/tests/test_builder_sign_ladder.py b/tests/test_builder_sign_ladder.py new file mode 100644 index 00000000..bb00181a --- /dev/null +++ b/tests/test_builder_sign_ladder.py @@ -0,0 +1,294 @@ +"""Tests for ``Builder.sign_ladder`` (wraps ``c2pa_builder_sign_ladder``). + +The native symbol is optional -- it exists only in libraries carrying +castlabs/c2pa-rs#9 -- so most of this file drives the Python side against a +fake native function and checks the contract the binding promises: + +* a library without the symbol imports, and ``sign_ladder`` fails with + ``C2paError.NotSupported`` at call time; +* argument problems are refused *before* any native call; +* paths reach C in order, UTF-8 encoded, and never with an embedded NUL; +* a negative native result is an error, a copy failure is an error (not + empty bytes), and the native buffer is released exactly once; +* the builder is borrowed, not consumed: it survives success and failure. + +The last test is the real thing. It signs two renditions through the native +library and validates each output. It skips when the loaded library lacks the +symbol -- unless ``C2PA_REQUIRE_SIGN_LADDER=1`` is set, in which case the +absence is a failure: that is how a release lane that is *supposed* to carry +the symbol proves it does. +""" + +from __future__ import annotations + +import ctypes +import os +import shutil +from pathlib import Path +from unittest import mock + +import pytest + +import c2pa.c2pa as binding +from c2pa import Builder, C2paError, C2paSignerInfo, Reader, Signer + +FIXTURES = Path(__file__).parent / "fixtures" +RENDITION = FIXTURES / "single-file-fragmented" / "single_file_fragments.mp4" + +_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)), +) + + +def _manifest_definition() -> dict: + return { + "claim_generator_info": [{"name": "python_test_sign_ladder", "version": "0.0.1"}], + "format": "video/mp4", + "title": "python test sign_ladder", + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCreation"}]}, + } + ], + } + + +@pytest.fixture +def signer(): + info = C2paSignerInfo( + alg=b"es256", + sign_cert=(FIXTURES / "es256_certs.pem").read_bytes(), + private_key=(FIXTURES / "es256_private.key").read_bytes(), + ta_url=b"http://timestamp.digicert.com", + ) + s = Signer.from_info(info) + yield s + s.close() + + +@pytest.fixture +def builder(): + b = Builder(_manifest_definition()) + yield b + b.close() + + +class _FakeNative: + """A stand-in for ``c2pa_builder_sign_ladder`` that records what C would + have seen and returns what the test dictates.""" + + def __init__(self, result: int, manifest: bytes = b"", set_out: bool = True): + self.result = result + self.set_out = set_out + self.calls: list[dict] = [] + self._buffer = ctypes.create_string_buffer(manifest, max(len(manifest), 1)) + self.callback = _NATIVE_SIGNATURE(self._call) + + def _call(self, _builder, _signer, sources, dests, count, out): + self.calls.append( + { + "sources": [sources[i] for i in range(count)], + "dests": [dests[i] for i in range(count)], + "count": count, + } + ) + if self.result > 0 and self.set_out: + out[0] = ctypes.cast(self._buffer, ctypes.POINTER(ctypes.c_ubyte)) + return self.result + + +@pytest.fixture +def fake_native(monkeypatch): + """Install ``fake`` as the native function and count buffer releases.""" + + def install(result: int, manifest: bytes = b"") -> tuple[_FakeNative, mock.Mock]: + fake = _FakeNative(result, manifest) + free = mock.Mock(name="c2pa_manifest_bytes_free") + monkeypatch.setattr(binding, "_HAS_SIGN_LADDER", True) + monkeypatch.setattr(binding._lib, "c2pa_builder_sign_ladder", fake.callback, raising=False) + monkeypatch.setattr(binding._lib, "c2pa_manifest_bytes_free", free) + return fake, free + + return install + + +# --- missing symbol ----------------------------------------------------------- + + +def test_the_symbol_is_optional_at_import(): + assert "c2pa_builder_sign_ladder" not in binding._REQUIRED_FUNCTIONS + assert isinstance(binding._HAS_SIGN_LADDER, bool) + + +def test_a_library_without_the_symbol_fails_at_call_time_not_import(monkeypatch, builder, signer): + monkeypatch.setattr(binding, "_HAS_SIGN_LADDER", False) + native = mock.Mock(name="c2pa_builder_sign_ladder") + monkeypatch.setattr(binding._lib, "c2pa_builder_sign_ladder", native, raising=False) + with pytest.raises(C2paError.NotSupported, match="c2pa_builder_sign_ladder is absent"): + builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["b.mp4"]) + native.assert_not_called() + # The builder was not touched, let alone consumed. + assert builder._handle + + +# --- preflight --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "sources, dests, message", + [ + (["a.mp4"], ["x.mp4", "y.mp4"], "same length"), + ([], [], "at least one rendition"), + (["a\0.mp4"], ["x.mp4"], r"sources\[0\] contains a NUL byte"), + ([b"a\0.mp4"], ["x.mp4"], r"sources\[0\] contains a NUL byte"), + (["a.mp4"], ["x.mp4\0ignored"], r"dests\[0\] contains a NUL byte"), + (["a\udcff.mp4"], ["x.mp4"], r"sources\[0\] cannot be encoded as UTF-8"), + ([7], ["x.mp4"], r"sources\[0\] is not a path"), + ], +) +def test_argument_problems_are_refused_before_any_native_call( + fake_native, builder, signer, sources, dests, message +): + fake, free = fake_native(result=-1) + with pytest.raises(C2paError, match=message): + builder.sign_ladder(signer=signer, sources=sources, dests=dests) + assert fake.calls == [] + free.assert_not_called() + assert builder._handle + + +def test_a_closed_signer_is_refused_before_any_native_call(fake_native, builder, signer): + fake, _free = fake_native(result=-1) + signer.close() + with pytest.raises(C2paError, match="Invalid or closed signer"): + builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) + assert fake.calls == [] + + +def test_a_closed_builder_is_refused_before_any_native_call(fake_native, signer): + fake, _free = fake_native(result=-1) + b = Builder(_manifest_definition()) + b.close() + with pytest.raises(C2paError): + b.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) + assert fake.calls == [] + + +# --- marshalling ------------------------------------------------------------- + + +def test_paths_reach_c_in_order_as_utf8(fake_native, builder, signer, tmp_path): + fake, free = fake_native(result=5, manifest=b"jumbf") + sources = [tmp_path / "1080p.mp4", "720p/vidéo.mp4", os.fspath(tmp_path / "360p.mp4")] + dests = ["out/1080p.mp4", Path("out/720p.mp4"), b"out/360p.mp4"] # bytes are paths too + manifest = builder.sign_ladder(signer=signer, sources=sources, dests=dests) + assert manifest == b"jumbf" + (call,) = fake.calls + assert call["count"] == 3 + assert call["sources"] == [os.fspath(p).encode("utf-8") for p in sources] + assert call["dests"] == [b"out/1080p.mp4", b"out/720p.mp4", b"out/360p.mp4"] + free.assert_called_once() + + +# --- native results ----------------------------------------------------------- + + +def test_a_negative_native_result_is_an_error(fake_native, builder, signer): + fake, free = fake_native(result=-1) + with pytest.raises(C2paError): + builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) + assert len(fake.calls) == 1 + free.assert_not_called() + + +def test_a_positive_result_without_a_buffer_is_an_error(fake_native, builder, signer): + fake = _FakeNative(result=9, set_out=False) # claims nine bytes, sets no out-pointer + free = mock.Mock() + with mock.patch.object(binding, "_HAS_SIGN_LADDER", True), mock.patch.object( + binding._lib, "c2pa_builder_sign_ladder", fake.callback, create=True + ), mock.patch.object(binding._lib, "c2pa_manifest_bytes_free", free): + with pytest.raises(C2paError, match="returned no buffer"): + builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) + free.assert_not_called() + + +def test_a_copy_failure_is_an_error_and_the_buffer_is_freed_once(fake_native, builder, signer): + fake, free = fake_native(result=5, manifest=b"jumbf") + with mock.patch.object(ctypes, "string_at", side_effect=MemoryError("no room")): + with pytest.raises(MemoryError): + builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) + assert len(fake.calls) == 1 + free.assert_called_once() + assert builder._handle + + +def test_a_zero_result_is_empty_bytes_with_nothing_to_free(fake_native, builder, signer): + _fake, free = fake_native(result=0) + assert builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) == b"" + free.assert_not_called() + + +# --- ownership ---------------------------------------------------------------- + + +def test_the_builder_survives_success_and_failure(fake_native, builder, signer): + fake, _free = fake_native(result=5, manifest=b"jumbf") + assert builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) == b"jumbf" + assert builder._handle, "the builder was consumed on success" + fake.result = -1 + with pytest.raises(C2paError): + builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) + assert builder._handle, "the builder was consumed on failure" + fake.result = 5 + assert builder.sign_ladder(signer=signer, sources=["a.mp4"], dests=["x.mp4"]) == b"jumbf" + builder.close() + assert builder._handle is None + builder.close() # idempotent: no double free + + +# --- the real thing ----------------------------------------------------------- + + +def test_a_real_ladder_signs_and_validates(signer, tmp_path): + if not binding._HAS_SIGN_LADDER: + if os.environ.get("C2PA_REQUIRE_SIGN_LADDER"): + pytest.fail( + "C2PA_REQUIRE_SIGN_LADDER is set but the loaded native library " + "has no c2pa_builder_sign_ladder" + ) + pytest.skip("native library lacks c2pa_builder_sign_ladder") + + sources = [] + for name in ("1080p", "720p"): + source = tmp_path / f"{name}.mp4" + shutil.copy2(RENDITION, source) + sources.append(source) + before = [s.read_bytes() for s in sources] + dests = [tmp_path / "out" / f"{s.stem}.mp4" for s in sources] + dests[0].parent.mkdir() + + builder = Builder(_manifest_definition()) + try: + manifest = builder.sign_ladder(signer=signer, sources=sources, dests=dests) + assert manifest and b"c2pa" in manifest + assert builder._handle, "the builder was consumed" + finally: + builder.close() + + assert [s.read_bytes() for s in sources] == before, "a source was modified" + for dest in dests: + with open(dest, "rb") as stream: + reader = Reader("video/mp4", stream) + try: + report = reader.json() + assert '"active_manifest"' in report + assert "assertion.bmffHash.mismatch" not in report + finally: + reader.close() From 641750f1dff5a9e3e8b9a8e246e4b934b30e4487 Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Fri, 25 Sep 2026 16:33:53 +0200 Subject: [PATCH 4/7] docs: describe the optional ladder binding in the stable-release docs The README's stable fMP4 section and the AGENTS.md change table now record `Builder.sign_ladder`: what it signs, that the native symbol is optional (`_HAS_SIGN_LADDER`, `C2paError.NotSupported` when absent, the .5 library does not carry it), that the builder is borrowed, that outputs must not exist and are removed on failure, and how `C2PA_REQUIRE_SIGN_LADDER=1` turns the real roundtrip into a hard requirement for a lane that should have the symbol. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 1 + README.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6f2ca84a..91389415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ The native library (`libc2pa_c.so` / `.dylib` / `.dll`) is built from the `c2pa- | `Signer.add_dynamic_assertion()` | ~line 3045 | Python API: register a callback, invokes C FFI `c2pa_signer_add_dynamic_assertion` | | `has_signer` fix | ~line 3700 | `_sign_common` tries context-based signing when `self._context is not None` | | `Builder.sign_fragmented()` | ~line 3782 | Python API for fragmented BMFF signing; invokes C FFI `c2pa_builder_sign_fragmented` | +| `Builder.sign_ladder()` | ~line 3990 | Python API for signing an ABR ladder of single-file fragmented MP4s into one manifest; invokes C FFI `c2pa_builder_sign_ladder` (castlabs/c2pa-rs#9). The symbol is OPTIONAL: not in `_REQUIRED_FUNCTIONS`, presence recorded in `_HAS_SIGN_LADDER`, `C2paError.NotSupported` when absent. Borrows the builder (no `_mark_consumed`). Paths are validated (NUL, encoding) before the FFI; manifest copy failures propagate. Tests: `tests/test_builder_sign_ladder.py` (`C2PA_REQUIRE_SIGN_LADDER=1` makes the real roundtrip mandatory). | | `Reader.from_fragmented_files()` | ~line 2270 | Python classmethod for fragmented BMFF read; invokes C FFI `c2pa_reader_from_fragmented_files`. Read-side counterpart to `Builder.sign_fragmented` — takes the init segment path plus an explicit list of fragment paths and returns a `Reader` whose `json()` / `detailed_json()` describe the signed segmented asset. | ### Rust — C FFI (`c2pa-rs/c2pa_c_ffi/src/c_api.rs`) diff --git a/README.md b/README.md index 20582971..80f3ee38 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,28 @@ OpenSSL crypto without importing the live-video VSI runtime. Release context: in the stable schema-2 release lock. See [the stable release contract and runbook](release/STABLE-FMP4.md) for the exact approval gate, artifacts, mandatory real-native tests and publication safeguards. + +### ABR ladder signing (`Builder.sign_ladder`) + +`Builder.sign_ladder(signer, sources, dests) -> bytes` signs a ladder of +*single-file* fragmented MP4s -- one file per rendition -- into **one** +manifest: one Merkle tree per rendition in the shared `c2pa.hash.bmff.v3` +assertion, the identical manifest embedded in every output. It wraps the +`c2pa_builder_sign_ladder` C API from castlabs/c2pa-rs#9. + +That native symbol is **optional**: the binding imports against any stable +library, records whether the symbol is present in `c2pa.c2pa._HAS_SIGN_LADDER`, +and `sign_ladder` raises `C2paError.NotSupported` when it is absent. The +`0.31.0+stardustproof.5` library does not carry it; a release built from a +native source that includes castlabs/c2pa-rs#9 does. The builder is borrowed +by the call and stays usable afterwards; release it with `close()` as usual. + +Outputs must not exist yet -- the native writer creates each one and never +overwrites -- and every output it created is removed again if the call fails. +Sources that already carry a C2PA manifest are refused. + +`tests/test_builder_sign_ladder.py` covers the binding against a stand-in for +the native function on any library, and signs a real two-rendition ladder when +the loaded library has the symbol. Set `C2PA_REQUIRE_SIGN_LADDER=1` in a lane +whose library is supposed to carry it, so its absence fails instead of +skipping. From b13e8a3cfb75c032bae4954beef30193313c8017 Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Mon, 28 Sep 2026 15:25:08 +0200 Subject: [PATCH 5/7] docs: qualify ladder cleanup; assert the full roundtrip in the native test Non-blocking review follow-ups. The README distinguished no cases: native cleanup after a failed call is best effort, and a Python-side failure while copying the returned manifest happens after native signing has succeeded, so signed outputs are then in place. The native roundtrip test now asserts a non-null active manifest, a Valid/Trusted state with only the untrusted test certificate reported, a BMFF hash match and validated signature per output, exactly one BMFF assertion carrying two Merkle maps, and identical manifests across the two renditions. Co-Authored-By: Claude Fable 5.1 --- README.md | 7 +++++-- tests/test_builder_sign_ladder.py | 23 ++++++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 80f3ee38..d005bd27 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,11 @@ native source that includes castlabs/c2pa-rs#9 does. The builder is borrowed by the call and stays usable afterwards; release it with `close()` as usual. Outputs must not exist yet -- the native writer creates each one and never -overwrites -- and every output it created is removed again if the call fails. -Sources that already carry a C2PA manifest are refused. +overwrites. If the native call fails it removes the outputs it created, best +effort; and a Python-side failure while copying the returned manifest happens +*after* native signing has succeeded, so the signed outputs are then in place. +Do not infer from an exception that no output exists: inspect or discard the +destinations. Sources that already carry a C2PA manifest are refused. `tests/test_builder_sign_ladder.py` covers the binding against a stand-in for the native function on any library, and signs a real two-rendition ladder when diff --git a/tests/test_builder_sign_ladder.py b/tests/test_builder_sign_ladder.py index bb00181a..b2a73921 100644 --- a/tests/test_builder_sign_ladder.py +++ b/tests/test_builder_sign_ladder.py @@ -22,6 +22,7 @@ from __future__ import annotations import ctypes +import json import os import shutil from pathlib import Path @@ -283,12 +284,28 @@ def test_a_real_ladder_signs_and_validates(signer, tmp_path): builder.close() assert [s.read_bytes() for s in sources] == before, "a source was modified" + manifests = [] for dest in dests: with open(dest, "rb") as stream: reader = Reader("video/mp4", stream) try: - report = reader.json() - assert '"active_manifest"' in report - assert "assertion.bmffHash.mismatch" not in report + report = json.loads(reader.json()) finally: reader.close() + active = report["active_manifest"] + assert active, f"{dest.name}: no active manifest: {report}" + # The test certificate is untrusted, which is informational; the + # manifest itself must validate, and its binding must match. + assert report["validation_state"] in ("Valid", "Trusted"), report["validation_state"] + results = report["validation_results"]["activeManifest"] + codes = {entry["code"] for entry in results.get("success", [])} + assert "assertion.bmffHash.match" in codes, f"{dest.name}: {codes}" + assert "claimSignature.validated" in codes, f"{dest.name}: {codes}" + failures = {entry["code"] for entry in results.get("failure", [])} + assert failures <= {"signingCredential.untrusted"}, f"{dest.name}: {failures}" + manifest = report["manifests"][active] + bmff = [a for a in manifest["assertions"] if a["label"].startswith("c2pa.hash.bmff")] + assert len(bmff) == 1, [a["label"] for a in manifest["assertions"]] + assert len(bmff[0]["data"]["merkle"]) == 2, "one Merkle map per rendition" + manifests.append((active, json.dumps(report["manifests"], sort_keys=True))) + assert manifests[0] == manifests[1], "the renditions do not carry the identical manifest" From fc26ec2ee43a40a25eea8b0d97372689c89e9961 Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Wed, 30 Sep 2026 10:26:36 +0200 Subject: [PATCH 6/7] Compare the embedded manifest bytes directly; decode the Merkle count from CBOR Follow-ups from the review of b13e8a3c. The real-ladder test now checks the strong property from the bytes: the manifest embedded in each output, read straight from the C2PA uuid box after its merkle_offset, must equal the bytes sign_ladder returned, and the one bmff hash assertion must carry one Merkle map per rendition -- decoded from the manifest's own CBOR, so the check no longer depends on how a given Reader version renders hash assertions as JSON. Reader is kept only for the validation result, and the JSON-presentation assertions on the hash assertion are gone. cbor2 is imported inside the one helper that needs it: the seventeen stand-in tests must keep running in a lane without it, and they do -- verified with the module hidden. Against a library built from the #7+#9 stable tree with C2PA_REQUIRE_SIGN_LADDER=1: 18 passed. Against the stock library: 17 passed, the native test skipped as designed. Co-Authored-By: Claude Fable 5.1 --- tests/test_builder_sign_ladder.py | 81 +++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/tests/test_builder_sign_ladder.py b/tests/test_builder_sign_ladder.py index b2a73921..f4ae30f7 100644 --- a/tests/test_builder_sign_ladder.py +++ b/tests/test_builder_sign_ladder.py @@ -35,6 +35,72 @@ FIXTURES = Path(__file__).parent / "fixtures" RENDITION = FIXTURES / "single-file-fragmented" / "single_file_fragments.mp4" +C2PA_UUID = bytes.fromhex("d8fec3d61b0e483c92975828877ec481") + + +def _boxes(data: bytes): + """Yield ``(kind, payload)`` for each BMFF box in ``data``, in order.""" + offset = 0 + while offset < len(data): + assert len(data) - offset >= 8, "truncated box header" + size = int.from_bytes(data[offset : offset + 4], "big") + kind = data[offset + 4 : offset + 8] + header = 8 + if size == 1: + size = int.from_bytes(data[offset + 8 : offset + 16], "big") + header = 16 + elif size == 0: + size = len(data) - offset + assert header <= size <= len(data) - offset, "invalid box bounds" + yield kind, data[offset + header : offset + size] + offset += size + + +def _embedded_manifest(data: bytes) -> bytes: + """The JUMBF bytes c2pa embedded in a BMFF file, read straight from the + boxes rather than through any Reader presentation: the top-level ``uuid`` + box carrying the C2PA UUID, purpose ``manifest``. Its payload is the + 16-byte UUID, a 4-byte version, the NUL-terminated purpose, then -- for + ``manifest`` only -- an 8-byte big-endian ``merkle_offset`` to the first + ``merkle`` box, and only then the manifest itself.""" + found = [] + for kind, payload in _boxes(data): + if kind == b"uuid" and payload[:16] == C2PA_UUID: + purpose, separator, content = payload[20:].partition(b"\0") + assert separator, "malformed C2PA uuid box" + if purpose == b"manifest": + merkle_offset = int.from_bytes(content[:8], "big") + assert 0 < merkle_offset < len(data), f"implausible merkle_offset {merkle_offset}" + found.append(content[8:]) + assert len(found) == 1, f"expected exactly one embedded manifest, found {len(found)}" + return found[0] + + +def _merkle_maps(manifest: bytes) -> list: + """The ``merkle`` array of the one ``c2pa.hash.bmff*`` assertion, decoded + from the manifest's own CBOR so the check does not depend on how a given + Reader version renders hash assertions as JSON. Only the real-library test + needs ``cbor2``, so it is imported here rather than at module level: the + stand-in tests must keep running in a lane without it.""" + import cbor2 # noqa: PLC0415 -- see the docstring + + maps = [] + + def walk(data: bytes) -> None: + for kind, payload in _boxes(data): + if kind != b"jumb": + continue + children = list(_boxes(payload)) + labels = [c[17:].split(b"\0", 1)[0] for k, c in children if k == b"jumd" and c[16] & 1] + if labels and labels[0].startswith(b"c2pa.hash.bmff"): + cbors = [c for k, c in children if k == b"cbor"] + assert len(cbors) == 1, "hash assertion without exactly one cbor box" + maps.append(cbor2.loads(cbors[0])["merkle"]) + walk(payload) + + walk(manifest) + assert len(maps) == 1, f"expected one bmff hash assertion, found {len(maps)}" + return maps[0] _NATIVE_SIGNATURE = ctypes.CFUNCTYPE( ctypes.c_int64, @@ -284,6 +350,17 @@ def test_a_real_ladder_signs_and_validates(signer, tmp_path): builder.close() assert [s.read_bytes() for s in sources] == before, "a source was modified" + + # The strong check first, straight from the bytes: what came back is what + # was embedded, byte for byte, in every rendition -- and the assertion in + # it carries one Merkle map per rendition. Neither depends on how Reader + # happens to render the manifest as JSON. + for dest in dests: + assert _embedded_manifest(dest.read_bytes()) == manifest, ( + f"{dest.name}: the embedded manifest differs from the returned bytes" + ) + assert len(_merkle_maps(manifest)) == 2, "one Merkle map per rendition" + manifests = [] for dest in dests: with open(dest, "rb") as stream: @@ -303,9 +380,5 @@ def test_a_real_ladder_signs_and_validates(signer, tmp_path): assert "claimSignature.validated" in codes, f"{dest.name}: {codes}" failures = {entry["code"] for entry in results.get("failure", [])} assert failures <= {"signingCredential.untrusted"}, f"{dest.name}: {failures}" - manifest = report["manifests"][active] - bmff = [a for a in manifest["assertions"] if a["label"].startswith("c2pa.hash.bmff")] - assert len(bmff) == 1, [a["label"] for a in manifest["assertions"]] - assert len(bmff[0]["data"]["merkle"]) == 2, "one Merkle map per rendition" manifests.append((active, json.dumps(report["manifests"], sort_keys=True))) assert manifests[0] == manifests[1], "the renditions do not carry the identical manifest" From 1fa22786f38b74d4205d959d4dcb86121b77f88a Mon Sep 17 00:00:00 2001 From: bibinbaby444 Date: Wed, 30 Sep 2026 11:43:46 +0200 Subject: [PATCH 7/7] Test the Label-Present toggle bit, and describe the cleanup honestly An adversarial pass over fc26ec2 found two things. The jumd label check tested toggle bit 0x01, which is Requestable; Label Present is 0x02. c2pa-rs's reader requires both (togs & 0x03 == 0x03) and its writer always emits 3, so the test passed for the right files by accident. It now tests 0x02, with a comment saying why. The README's "inspect or discard the destinations" was the indiscriminate wording the maintainer rejected on castlabs/c2pa-rs#9, and this binding's docs must give the same guidance as the native ones: a destination that already existed is refused before anything is written and never touched; on a native failure the writer removes what is then at the output paths it reserved, by path and best effort; discard only the leftovers at the destinations you passed, never a pre-existing file such as a source or a link to one. The two new assert messages that ran past the file's line length are wrapped. Against a library built from the #7+#9 stable tree with C2PA_REQUIRE_SIGN_LADDER=1: 18 passed. Stock library with cbor2 hidden: 17 passed, 1 skipped. Co-Authored-By: Claude Fable 5.1 --- README.md | 15 ++++++++++----- tests/test_builder_sign_ladder.py | 18 +++++++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d005bd27..431af953 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,16 @@ native source that includes castlabs/c2pa-rs#9 does. The builder is borrowed by the call and stays usable afterwards; release it with `close()` as usual. Outputs must not exist yet -- the native writer creates each one and never -overwrites. If the native call fails it removes the outputs it created, best -effort; and a Python-side failure while copying the returned manifest happens -*after* native signing has succeeded, so the signed outputs are then in place. -Do not infer from an exception that no output exists: inspect or discard the -destinations. Sources that already carry a C2PA manifest are refused. +overwrites, and a destination that already existed is refused before anything +is written and is never touched. If the native call fails it removes what is +then at the output paths it reserved -- by path and best effort, so under the +stable-path assumption that is exactly its own outputs, and a failed removal is +not reported. A Python-side failure while copying the returned manifest +happens *after* native signing has succeeded, so the signed outputs are then in +place. Do not infer from an exception that no output exists; discard only the +leftovers at the destinations you passed, never a pre-existing file such as a +source or a link to one. Sources that already carry a C2PA manifest are +refused. `tests/test_builder_sign_ladder.py` covers the binding against a stand-in for the native function on any library, and signs a real two-rendition ladder when diff --git a/tests/test_builder_sign_ladder.py b/tests/test_builder_sign_ladder.py index f4ae30f7..e7925e1d 100644 --- a/tests/test_builder_sign_ladder.py +++ b/tests/test_builder_sign_ladder.py @@ -70,9 +70,13 @@ def _embedded_manifest(data: bytes) -> bytes: assert separator, "malformed C2PA uuid box" if purpose == b"manifest": merkle_offset = int.from_bytes(content[:8], "big") - assert 0 < merkle_offset < len(data), f"implausible merkle_offset {merkle_offset}" + assert 0 < merkle_offset < len(data), ( + f"implausible merkle_offset {merkle_offset}" + ) found.append(content[8:]) - assert len(found) == 1, f"expected exactly one embedded manifest, found {len(found)}" + assert len(found) == 1, ( + f"expected exactly one embedded manifest, found {len(found)}" + ) return found[0] @@ -91,7 +95,14 @@ def walk(data: bytes) -> None: if kind != b"jumb": continue children = list(_boxes(payload)) - labels = [c[17:].split(b"\0", 1)[0] for k, c in children if k == b"jumd" and c[16] & 1] + labels = [] + for child_kind, child in children: + # jumd: 16-byte type, one toggles byte, then the label when + # toggle bit 0x02 (Label Present) is set. Bit 0x01 is + # Requestable, which c2pa also sets, so testing it would pass + # by accident. + if child_kind == b"jumd" and child[16] & 0x02: + labels.append(child[17:].split(b"\0", 1)[0]) if labels and labels[0].startswith(b"c2pa.hash.bmff"): cbors = [c for k, c in children if k == b"cbor"] assert len(cbors) == 1, "hash assertion without exactly one cbor box" @@ -102,6 +113,7 @@ def walk(data: bytes) -> None: assert len(maps) == 1, f"expected one bmff hash assertion, found {len(maps)}" return maps[0] + _NATIVE_SIGNATURE = ctypes.CFUNCTYPE( ctypes.c_int64, ctypes.POINTER(binding.C2paBuilder),