mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
Renames 314 defs and 41 module/class-level constants. Keeps 11 names underscored where the stripped form collides in scope, plus 5.5.0 shims for the three symbols external service repos call.
168 lines
7.2 KiB
Python
168 lines
7.2 KiB
Python
"""Self-check for ``assert_fragments_decrypted``: after decryption every moof must have lost
|
|
its standard senc. A survivor means the tool silently skipped that fragment (the
|
|
tfhd.sample_description_index ceiling bug), which the moov-scoped has_encrypted_sample_entry
|
|
structurally cannot see. A leftover PIFF uuid survives a good decrypt too, because content
|
|
shipping both boxes keeps it: a decrypter detaches only the atom it consumed.
|
|
The guard never reads mdat, so stray byte runs there leave it unmoved.
|
|
|
|
Run: uv run pytest tests/tracks/test_fragment_decrypted.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from unshackle.core.manifests.ism_init import PIFF_SENC_UUID
|
|
from unshackle.core.tracks.track import assert_fragments_decrypted
|
|
|
|
|
|
def box(fmt: bytes, payload: bytes = b"") -> bytes:
|
|
return struct.pack(">I", 8 + len(payload)) + fmt + payload
|
|
|
|
|
|
def senc(sample_count: int = 4) -> bytes:
|
|
return box(b"senc", b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count))
|
|
|
|
|
|
def piff_senc(sample_count: int = 4) -> bytes:
|
|
body = PIFF_SENC_UUID + b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)
|
|
return box(b"uuid", body)
|
|
|
|
|
|
def moof(*children: bytes, sdi: int = 1) -> bytes:
|
|
tfhd = box(b"tfhd", b"\x00\x00\x00\x02" + struct.pack(">II", 1, sdi)) # sample-description-index-present
|
|
return box(b"moof", box(b"mfhd", b"\x00" * 8) + box(b"traf", tfhd + b"".join(children)))
|
|
|
|
|
|
FTYP = box(b"ftyp", b"isom\x00\x00\x02\x00isomiso2")
|
|
MOOV = box(b"moov", box(b"mvex", box(b"trex", b"\x00" * 24)))
|
|
MDAT = box(b"mdat", b"\x00" * 64)
|
|
# a compressed payload can contain anything, including plausible-looking box runs
|
|
STRAY = box(b"mdat", struct.pack(">I", 64) + b"senc" + b"\xbe\xef" * 8 + PIFF_SENC_UUID)
|
|
|
|
|
|
def write(tmp_path: Path, name: str, *boxes: bytes) -> Path:
|
|
f = tmp_path / name
|
|
f.write_bytes(b"".join(boxes))
|
|
return f
|
|
|
|
|
|
def test_all_fragments_decrypted_passes(tmp_path: Path) -> None:
|
|
f = write(tmp_path, "clean.mp4", FTYP, MOOV, moof(), MDAT, moof(sdi=2), MDAT)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_surviving_senc_raises(tmp_path: Path) -> None:
|
|
# the real bug: fragment 1 (index 1) decrypted, the rest (index 2) silently skipped
|
|
boxes = [FTYP, MOOV, moof(), MDAT]
|
|
for _ in range(3):
|
|
boxes += [moof(senc(), sdi=2), MDAT]
|
|
f = write(tmp_path, "skipped.mp4", *boxes)
|
|
with pytest.raises(ValueError, match=r"3/4 fragment"):
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_surviving_cenc_senc_raises(tmp_path: Path) -> None:
|
|
f = write(tmp_path, "cenc.mp4", FTYP, MOOV, moof(senc()), MDAT)
|
|
with pytest.raises(ValueError, match=r"1/1 fragment"):
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_leftover_piff_uuid_after_senc_removed_does_not_raise(tmp_path: Path) -> None:
|
|
# live DASH shape: the traf shipped senc+saiz+saio plus a duplicate PIFF uuid. mp4decrypt
|
|
# consumed senc, detached senc/saiz/saio, and left the uuid on a fully decrypted fragment.
|
|
boxes = [FTYP, MOOV]
|
|
for _ in range(3):
|
|
boxes += [moof(piff_senc()), MDAT]
|
|
f = write(tmp_path, "piff_leftover.mp4", *boxes)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_stray_senc_bytes_in_mdat_ignored(tmp_path: Path) -> None:
|
|
f = write(tmp_path, "stray.mp4", FTYP, MOOV, moof(), STRAY, moof(sdi=2), STRAY)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_senc_text_without_plausible_size_ignored(tmp_path: Path) -> None:
|
|
# "senc" inside a free-box string has no valid box size before it
|
|
f = write(tmp_path, "text.mp4", FTYP, MOOV, moof(box(b"free", b"presence sencoded here")), MDAT)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_senc_bytes_inside_trun_payload_ignored(tmp_path: Path) -> None:
|
|
# a trun sample entry spelling "senc" after a size-looking duration; only a byte scan bites
|
|
trun = box(b"trun", b"\x00\x00\x03\x01" + struct.pack(">I", 2) + struct.pack(">I", 512) + b"senc")
|
|
f = write(tmp_path, "trun.mp4", FTYP, MOOV, moof(trun), MDAT)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_empty_senc_on_clear_fragment_ignored(tmp_path: Path) -> None:
|
|
# sample_count 0 marks a fragment with no protected samples, so the guard ignores it
|
|
f = write(tmp_path, "empty.mp4", FTYP, MOOV, moof(senc(sample_count=0)), MDAT)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def senc_override(sample_count: int) -> bytes:
|
|
# flags&1 => AlgorithmID(3) + IV_size(1) + KID(16) precede sample_count
|
|
head = b"\x00\x00\x00\x01" + b"\x00\x00\x08" + b"\x08" + b"\xaa" * 16
|
|
return box(b"senc", head + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count))
|
|
|
|
|
|
def test_override_flag_shifts_sample_count_offset(tmp_path: Path) -> None:
|
|
clear = write(tmp_path, "ovr_clear.mp4", FTYP, MOOV, moof(senc_override(0)), MDAT)
|
|
assert_fragments_decrypted(clear)
|
|
f = write(tmp_path, "ovr.mp4", FTYP, MOOV, moof(senc_override(5)), MDAT)
|
|
with pytest.raises(ValueError, match=r"1/1 fragment"):
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_senc_outside_traf_ignored(tmp_path: Path) -> None:
|
|
# a decrypted moof whose only "senc" sits in a sibling box outside any traf
|
|
f = write(tmp_path, "sibling.mp4", FTYP, MOOV, box(b"moof", box(b"mfhd", b"\x00" * 8) + senc()), MDAT)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_senc_nested_under_non_traf_ignored(tmp_path: Path) -> None:
|
|
# senc two levels down under a non-traf container: only traf children count
|
|
meta = box(b"meta", senc())
|
|
f = write(tmp_path, "nested.mp4", FTYP, MOOV, box(b"moof", box(b"mfhd", b"\x00" * 8) + meta), MDAT)
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_undersized_largesize_box_does_not_desync_walk(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
# size==1 reads a 16-byte header, so a largesize of 12 clears the 8-byte floor but
|
|
# still stalls the cursor onto payload; only a header-aware floor catches it
|
|
runt = struct.pack(">I", 1) + b"free" + struct.pack(">Q", 12)
|
|
f = write(tmp_path, "runt.mp4", FTYP, MOOV, runt, moof(senc()), MDAT, moof(senc()), MDAT)
|
|
with caplog.at_level("WARNING"):
|
|
assert_fragments_decrypted(f)
|
|
assert "malformed box size" in caplog.text
|
|
|
|
|
|
def test_largesize_and_zero_size_boxes_walked(tmp_path: Path) -> None:
|
|
frag = moof(senc())
|
|
large = struct.pack(">I", 1) + b"mdat" + struct.pack(">Q", 16 + 64) + b"\x00" * 64
|
|
trailing = struct.pack(">I", 0) + b"mdat" + b"\x00" * 64 # size 0 => runs to EOF
|
|
f = write(tmp_path, "large.mp4", FTYP, MOOV, frag, large, trailing)
|
|
with pytest.raises(ValueError, match=r"1/1 fragment"):
|
|
assert_fragments_decrypted(f)
|
|
|
|
|
|
def test_malformed_box_size_warns_and_does_not_raise(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
# failing open is deliberate, but the abort must be logged so it cannot pass as clean
|
|
overrun = struct.pack(">I", 1 << 30) + b"free" + b"\x00" * 8
|
|
f = write(tmp_path, "overrun.mp4", FTYP, MOOV, overrun, moof(senc()), MDAT)
|
|
with caplog.at_level("WARNING"):
|
|
assert_fragments_decrypted(f)
|
|
assert "malformed box size" in caplog.text
|
|
|
|
|
|
def test_missing_or_bogus_file_is_noop(tmp_path: Path) -> None:
|
|
assert_fragments_decrypted(tmp_path / "nope.mp4")
|
|
assert_fragments_decrypted(write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4"))
|
|
truncated = write(tmp_path, "cut.mp4", FTYP, MOOV, moof(senc())[:20])
|
|
assert_fragments_decrypted(truncated)
|