Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,36 @@ 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 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
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.
146 changes: 146 additions & 0 deletions src/c2pa/c2pa.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,21 @@ def _setup_function(func, argtypes, restype=None):
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):
Expand Down Expand Up @@ -3178,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."""

Expand Down Expand Up @@ -3948,6 +3985,115 @@ 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.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 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.NotSupported(
"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):
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 = [
_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)
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),
)
except Exception as e:
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,
"Error during ladder signing",
check=lambda r: r < 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:
_lib.c2pa_manifest_bytes_free(manifest_bytes_ptr)
except Exception:
logger.error("Failed to release native manifest bytes memory")

@overload
def sign_file(
self,
Expand Down
16 changes: 16 additions & 0 deletions tests/fixtures/single-file-fragmented/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file not shown.
Loading
Loading