From e3c7cc374a50443fb2186006682690c8d26f07b7 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 9 Jul 2026 13:52:34 -0700 Subject: [PATCH] feat(client): manifest-based resolution with legacy fallback + cross-platform encode tests Implements the client-side half of issue #20 while guaranteeing existing installs are never disturbed. - add static_ffmpeg/manifest.py: platform-tuple detection (os/arch/libc, so glibc vs musl Linux builds are distinguished), zackees/manifest.json Catalog resolution, and sha256-verified downloads. Best-effort and disabled by default (opt in via STATIC_FFMPEG_MANIFEST_URL) until ffmpeg-bins2 + CDN are live. - run.py resolves via the manifest first and falls back to the frozen legacy ffmpeg_bins URLs on any failure; downloads are sha256-verified when the manifest supplies a hash. Current/old behavior is unchanged. - tests/test_encode.py: real end-to-end encode (lavfi -> H.264/AAC mp4) + probe, riding every existing per-platform test workflow. - tests/test_manifest.py: offline resolver unit tests + an 8-target contract test against manifest.example.json. - document the migration and backward-compat guarantee in README. Co-Authored-By: Claude Opus 4.8 --- README.md | 25 ++++ manifest.example.json | 97 +++++++++++++++ static_ffmpeg/manifest.py | 244 ++++++++++++++++++++++++++++++++++++++ static_ffmpeg/run.py | 59 ++++++++- tests/test_encode.py | 61 ++++++++++ tests/test_manifest.py | 144 ++++++++++++++++++++++ 6 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 manifest.example.json create mode 100644 static_ffmpeg/manifest.py create mode 100644 tests/test_encode.py create mode 100644 tests/test_manifest.py diff --git a/README.md b/README.md index e34329b..18e2af6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,32 @@ subprocess.check_output([ffmpeg, "-version"]) subprocess.check_output([ffprobe, "-version"]) ``` +## Binary resolution & backward compatibility +By default `static-ffmpeg` downloads the same per-platform zips it always has, +from the frozen [`ffmpeg_bins`](https://github.com/zackees/ffmpeg_bins) repo, so +**existing installs keep resolving to the exact same URLs** — those are never +moved. + +New installs can additionally resolve binaries through a +[`manifest.json`](https://github.com/zackees/manifest.json) catalog, which lets +the download source, versions, and integrity hashes change without a client +release. The client detects a platform tuple (`os` / `arch` / `libc`, so glibc +and musl Linux builds are distinguished), resolves it against the catalog, and +verifies the artifact's `sha256` before use. If the manifest is disabled, +unreachable, or does not describe the current platform, it falls back to the +legacy URL — resolution is never worse than before. + +Manifest resolution is opt-in until the `ffmpeg-bins2` catalog + CDN are live. +Point it at a catalog with: + +```bash +export STATIC_FFMPEG_MANIFEST_URL="https:///ffmpeg/manifest.json" +``` + +See [`manifest.example.json`](manifest.example.json) for the catalog format and +[issue #20](https://github.com/zackees/static_ffmpeg/issues/20) for the full +migration plan. ## Testing diff --git a/manifest.example.json b/manifest.example.json new file mode 100644 index 0000000..ae371a2 --- /dev/null +++ b/manifest.example.json @@ -0,0 +1,97 @@ +{ + "kind": "Catalog", + "schema_version": 1, + "tool": "ffmpeg", + "_comment": "Example ffmpeg catalog in the zackees/manifest.json Catalog format. This documents the exact contract static_ffmpeg/manifest.py resolves against. urls[] point at a CDN-fronted www site (no bandwidth limits); sha256 is verified by the client after download. Replace , versions, sizes and hashes with real published values in ffmpeg-bins2.", + "channels": { + "latest-stable": "8.0.0" + }, + "releases": [ + { + "version": "8.0.0", + "published_at": "2026-01-01T00:00:00Z", + "platforms": [ + { + "platform": { "os": "windows", "arch": "x86_64" }, + "asset": { + "filename": "win_x64.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/win_x64.zip"] + } + }, + { + "platform": { "os": "windows", "arch": "arm64" }, + "asset": { + "filename": "win_arm64.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/win_arm64.zip"] + } + }, + { + "platform": { "os": "macos", "arch": "x86_64" }, + "asset": { + "filename": "darwin_x64.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/darwin_x64.zip"] + } + }, + { + "platform": { "os": "macos", "arch": "arm64" }, + "asset": { + "filename": "darwin_arm64.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/darwin_arm64.zip"] + } + }, + { + "platform": { "os": "linux", "arch": "x86_64", "libc": "glibc" }, + "asset": { + "filename": "linux_x64_glibc217.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/linux_x64_glibc217.zip"] + } + }, + { + "platform": { "os": "linux", "arch": "arm64", "libc": "glibc" }, + "asset": { + "filename": "linux_arm64_glibc217.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/linux_arm64_glibc217.zip"] + } + }, + { + "platform": { "os": "linux", "arch": "x86_64", "libc": "musl" }, + "asset": { + "filename": "linux_x64_musl.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/linux_x64_musl.zip"] + } + }, + { + "platform": { "os": "linux", "arch": "arm64", "libc": "musl" }, + "asset": { + "filename": "linux_arm64_musl.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "media_type": "application/zip", + "urls": ["https:///ffmpeg/8.0.0/linux_arm64_musl.zip"] + } + } + ] + } + ] +} diff --git a/static_ffmpeg/manifest.py b/static_ffmpeg/manifest.py new file mode 100644 index 0000000..547ec8a --- /dev/null +++ b/static_ffmpeg/manifest.py @@ -0,0 +1,244 @@ +""" +Manifest-based artifact resolution for static_ffmpeg. + +This implements the client half of the migration described in +https://github.com/zackees/static_ffmpeg/issues/20 : instead of hard-coding one +download URL per platform, the client detects a *platform tuple* +(``os`` / ``arch`` / ``libc``) and resolves it against a ``manifest.json`` +catalog (see https://github.com/zackees/manifest.json for the format). + +Design constraints (from the issue): + +* **Backward compatibility is absolute.** This module is *best effort*. If the + manifest is disabled, unreachable, malformed, or does not describe the current + platform, the caller falls back to the legacy hard-coded URL. Existing + installs -- which never call this module at all -- are unaffected because the + legacy ``ffmpeg_bins`` URLs are never moved or mutated. +* **Integrity is verified.** Every asset carries a ``sha256`` which the caller + checks after download. +* **Linux distinguishes glibc from musl**, so we can ship both a glibc-2.17 + baseline build and a musl build for the same architecture. + +The default manifest URL is intentionally empty until ``ffmpeg-bins2`` and its +CDN front-end are live; set ``STATIC_FFMPEG_MANIFEST_URL`` to opt in early, or +flip ``DEFAULT_MANIFEST_URL`` once the infrastructure exists. +""" + +import glob +import hashlib +import os +import platform +import subprocess +import sys +from typing import Dict, List, NamedTuple, Optional + +import requests # type: ignore + +# Empty by default: current/legacy behaviour is preserved byte-for-byte until +# ffmpeg-bins2 + CDN are live. Override via the environment for early adopters +# and tests, or set this constant once the catalog is published. +DEFAULT_MANIFEST_URL = "" + +# The channel a fresh install pulls from. Existing installs pin their resolved +# version in installed.crumb and are never silently upgraded. +DEFAULT_CHANNEL = "latest-stable" + +TOOL_NAME = "ffmpeg" + + +class Asset(NamedTuple): + """A single downloadable artifact resolved from the manifest.""" + + url: str + sha256: Optional[str] + filename: Optional[str] = None + size_bytes: Optional[int] = None + version: Optional[str] = None + + +def manifest_url() -> str: + """Return the manifest URL, or "" if manifest resolution is disabled.""" + return os.environ.get("STATIC_FFMPEG_MANIFEST_URL", DEFAULT_MANIFEST_URL) + + +def _detect_arch() -> str: + """Normalise ``platform.machine()`` into a manifest arch token.""" + machine = platform.machine().lower() + if machine in ("arm64", "aarch64"): + return "arm64" + if machine in ("x86_64", "amd64", "x64"): + return "x86_64" + return machine + + +def _detect_linux_libc() -> str: + """ + Best-effort detection of glibc vs musl on Linux. + + ``platform.libc_ver()`` reports glibc reliably but returns empty for musl + (musl has no gnu_get_libc_version symbol), so we corroborate with ``ldd`` + and, as a last resort, the presence of the musl dynamic loader. + """ + try: + libc_name, _ = platform.libc_ver() + if libc_name and "glibc" in libc_name.lower(): + return "glibc" + except OSError: + pass + + try: + proc = subprocess.run( + ["ldd", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=5, + check=False, + ) + text = proc.stdout.decode("utf-8", "replace").lower() + if "musl" in text: + return "musl" + if "glibc" in text or "gnu libc" in text or "gnu c library" in text: + return "glibc" + except (OSError, subprocess.SubprocessError): + pass + + if glob.glob("/lib/ld-musl-*") or glob.glob("/usr/lib/ld-musl-*"): + return "musl" + + # Default assumption for Linux is glibc. + return "glibc" + + +def get_platform_tuple() -> Dict[str, str]: + """ + Detect the current platform as a manifest tuple. + + Returns a dict with ``os`` and ``arch`` always present, and ``libc`` present + on Linux (``glibc`` or ``musl``). + """ + arch = _detect_arch() + if sys.platform == "win32": + return {"os": "windows", "arch": arch} + if sys.platform == "darwin": + return {"os": "macos", "arch": arch} + if sys.platform == "linux": + return {"os": "linux", "arch": arch, "libc": _detect_linux_libc()} + return {"os": sys.platform, "arch": arch} + + +def _tuple_matches(query: Dict[str, str], candidate: Dict[str, object]) -> bool: + """ + Manifest matching rule: two tuples match iff every field *present in the + candidate* is equal to the query. Fields absent from the candidate act as + wildcards, so a manifest may omit ``libc`` to match any Linux libc. + """ + for key, value in candidate.items(): + if key not in query: + return False + if str(query[key]) != str(value): + return False + return True + + +def _resolve_asset_from_catalog( + catalog: dict, query: Dict[str, str], channel: str +) -> Optional[Asset]: + """Resolve ``query`` against an already-parsed Catalog document.""" + if catalog.get("tool") not in (None, TOOL_NAME): + return None + + channels = catalog.get("channels", {}) + releases = catalog.get("releases", []) + if not releases: + return None + + target_version = channels.get(channel, channel) + + release = None + for candidate in releases: + if candidate.get("version") == target_version: + release = candidate + break + if release is None: + # Fall back to the newest release (releases are newest-first). + release = releases[0] + + for entry in release.get("platforms", []): + candidate_tuple = entry.get("platform", {}) + if not _tuple_matches(query, candidate_tuple): + continue + asset = entry.get("asset", {}) + urls = asset.get("urls") or [] + if not urls: + continue + return Asset( + url=urls[0], + sha256=asset.get("sha256"), + filename=asset.get("filename"), + size_bytes=asset.get("size_bytes"), + version=release.get("version"), + ) + return None + + +def resolve_asset( + query: Optional[Dict[str, str]] = None, + channel: str = DEFAULT_CHANNEL, + url: Optional[str] = None, + timeout: int = 30, +) -> Optional[Asset]: + """ + Resolve the current (or given) platform to an :class:`Asset` via the + manifest. Returns ``None`` -- never raises -- when resolution is disabled or + fails for any reason, so the caller can fall back to legacy behaviour. + """ + src = url if url is not None else manifest_url() + if not src: + return None + if query is None: + query = get_platform_tuple() + + try: + resp = requests.get(src, timeout=timeout) + resp.raise_for_status() + catalog = resp.json() + except (requests.RequestException, ValueError): + return None + + try: + return _resolve_asset_from_catalog(catalog, query, channel) + except (AttributeError, TypeError, KeyError): + return None + + +def sha256_of_file(path: str, chunk_size: int = 1024 * 1024) -> str: + """Return the lowercase hex sha256 digest of a file.""" + digest = hashlib.sha256() + with open(path, "rb") as file_d: + for chunk in iter(lambda: file_d.read(chunk_size), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_sha256(path: str, expected: Optional[str]) -> None: + """ + Raise ``ValueError`` if ``path`` does not match ``expected`` sha256. + + A ``None``/empty ``expected`` is treated as "no integrity data available" + and passes silently, matching legacy downloads that carry no checksum. + """ + if not expected: + return + actual = sha256_of_file(path) + if actual.lower() != expected.lower(): + raise ValueError( + f"sha256 mismatch for {path}: expected {expected}, got {actual}" + ) + + +def platform_keys_for(query: Dict[str, str]) -> List[str]: + """Human-readable description of a platform tuple, for logging/errors.""" + parts = [query.get("os", "?"), query.get("arch", "?")] + if "libc" in query: + parts.append(query["libc"]) + return parts diff --git a/static_ffmpeg/run.py b/static_ffmpeg/run.py index ad1e85c..755fc1a 100644 --- a/static_ffmpeg/run.py +++ b/static_ffmpeg/run.py @@ -9,13 +9,15 @@ import sys import zipfile from datetime import datetime -from typing import Tuple +from typing import Optional, Tuple import requests # type: ignore from filelock import FileLock, Timeout from progress.bar import Bar # type: ignore from progress.spinner import Spinner # type: ignore +from static_ffmpeg import manifest + TIMEOUT = 10 * 60 # Wait upto 10 minutes to validate install # otherwise break the lock and install anyway. @@ -61,19 +63,53 @@ def check_system() -> None: def get_platform_http_zip() -> str: - """Return the download link for the current platform""" + """Return the legacy download link for the current platform. + + Kept for backward compatibility: this is the exact URL that older installs + resolve to, and it must never move. New code should prefer + :func:`resolve_platform_download`, which layers manifest resolution on top + of this with a guaranteed fallback here. + """ check_system() return PLATFORM_ZIP_FILES[get_platform_key()] +def resolve_platform_download() -> Tuple[str, Optional[str]]: + """Resolve the download URL (and optional sha256) for this platform. + + Tries the manifest catalog first (best effort, opt-in until ffmpeg-bins2 and + its CDN are live). On any failure -- disabled, unreachable, malformed, or a + platform the manifest does not describe -- it falls back to the legacy + hard-coded URL so behaviour is never worse than today and old installs are + never disturbed. Returns ``(url, expected_sha256_or_None)``. + """ + try: + asset = manifest.resolve_asset() + except Exception as err: # pylint: disable=broad-except + # Manifest resolution is strictly best-effort; never let it break a + # download that legacy behaviour could satisfy. + print(f"{__file__}: manifest resolution failed ({err}); using legacy URL") + asset = None + if asset is not None: + return asset.url, asset.sha256 + return get_platform_http_zip(), None + + def get_platform_dir() -> str: """Either get the executable or raise an error""" check_system() return os.path.join(SELF_DIR, "bin", get_platform_key()) -def download_file(url: str, local_path: str) -> str: - """Downloads a file to the give path.""" +def download_file( + url: str, local_path: str, expected_sha256: Optional[str] = None +) -> str: + """Downloads a file to the give path. + + If ``expected_sha256`` is provided, the downloaded file is verified against + it and a ``ValueError`` is raised on mismatch (the corrupt file is removed). + Legacy downloads pass ``None`` and are not checksum-verified. + """ # NOTE the stream=True parameter below print(f"Downloading {url} -> {local_path}") chunk_size = (1024 * 1024) // 4 @@ -92,6 +128,15 @@ def download_file(url: str, local_path: str) -> str: file_d.write(chunk) spinner.next(len(chunk)) sys.stdout.write(f"\nDownload of {url} -> {local_path} completed.\n") + if expected_sha256: + try: + manifest.verify_sha256(local_path, expected_sha256) + except ValueError: + try: + os.remove(local_path) + except OSError: + pass + raise return local_path @@ -118,6 +163,8 @@ def _get_or_fetch_platform_executables_else_raise_no_lock( fix_permissions=True, download_dir=None ) -> Tuple[str, str]: """Either get the executable or raise an error, internal api""" + # pylint: disable=too-many-locals + exe_dir = download_dir if download_dir else get_platform_dir() installed_crumb = os.path.join(exe_dir, "installed.crumb") if not os.path.exists(installed_crumb): @@ -126,9 +173,9 @@ def _get_or_fetch_platform_executables_else_raise_no_lock( # the install one level up from that same directory. install_dir = os.path.dirname(exe_dir) os.makedirs(exe_dir, exist_ok=True) - url = get_platform_http_zip() + url, expected_sha256 = resolve_platform_download() local_zip = exe_dir + ".zip" - download_file(url, local_zip) + download_file(url, local_zip, expected_sha256=expected_sha256) print(f"Extracting {local_zip} -> {install_dir}") with zipfile.ZipFile(local_zip, mode="r") as zipf: zipf.extractall(install_dir) diff --git a/tests/test_encode.py b/tests/test_encode.py new file mode 100644 index 0000000..06f4fa4 --- /dev/null +++ b/tests/test_encode.py @@ -0,0 +1,61 @@ +"""End-to-end encode test. + +Guarantees that, on every platform CI runs on, the resolved ffmpeg binary can +actually encode a small video and that ffprobe can read it back. This is the +"small test encoding file" check from issue #20 -- it exercises the real codec +path, not just ``-version``. +""" + +import json +import os +import subprocess +import tempfile +import unittest + +from static_ffmpeg import run + + +class EncodeTester(unittest.TestCase): + def setUp(self) -> None: + self.ffmpeg, self.ffprobe = ( + run.get_or_fetch_platform_executables_else_raise() + ) + + def test_encode_and_probe_small_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, "out.mp4") + # Synthesize 1 second of 320x240 test video + a sine tone and encode + # to H.264/AAC in an mp4 container. Fully self-contained (lavfi + # sources), so no input asset is required. + cmd = [ + self.ffmpeg, + "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=15:duration=1", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-shortest", + out, + ] + subprocess.check_call(cmd) + + self.assertTrue(os.path.exists(out), "encoder produced no output file") + self.assertGreater(os.path.getsize(out), 0, "encoded file is empty") + + # Probe it back and assert we have a real video stream. + probe = subprocess.check_output( + [ + self.ffprobe, + "-v", "error", + "-show_streams", + "-of", "json", + out, + ] + ) + info = json.loads(probe) + codecs = {s.get("codec_type") for s in info.get("streams", [])} + self.assertIn("video", codecs, "no video stream in encoded output") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..32f2378 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,144 @@ +"""Offline unit tests for the manifest resolver (no network access).""" + +import hashlib +import json +import os +import tempfile +import unittest + +from static_ffmpeg import manifest + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_EXAMPLE_MANIFEST = os.path.join(_REPO_ROOT, "manifest.example.json") + +_ALL_TARGETS = [ + {"os": "windows", "arch": "x86_64"}, + {"os": "windows", "arch": "arm64"}, + {"os": "macos", "arch": "x86_64"}, + {"os": "macos", "arch": "arm64"}, + {"os": "linux", "arch": "x86_64", "libc": "glibc"}, + {"os": "linux", "arch": "arm64", "libc": "glibc"}, + {"os": "linux", "arch": "x86_64", "libc": "musl"}, + {"os": "linux", "arch": "arm64", "libc": "musl"}, +] + + +def _catalog(): + """A minimal but representative Catalog document for ffmpeg.""" + return { + "kind": "Catalog", + "schema_version": 1, + "tool": "ffmpeg", + "channels": {"latest-stable": "8.0.1"}, + "releases": [ + { + "version": "8.0.1", + "platforms": [ + { + "platform": {"os": "windows", "arch": "x86_64"}, + "asset": { + "filename": "win_x64.zip", + "sha256": "aa" * 32, + "size_bytes": 123, + "urls": ["https://cdn.example/ffmpeg/8.0.1/win_x64.zip"], + }, + }, + { + "platform": {"os": "linux", "arch": "x86_64", "libc": "musl"}, + "asset": { + "filename": "linux_x64_musl.zip", + "sha256": "bb" * 32, + "urls": [ + "https://cdn.example/ffmpeg/8.0.1/linux_x64_musl.zip" + ], + }, + }, + { + "platform": {"os": "linux", "arch": "x86_64", "libc": "glibc"}, + "asset": { + "filename": "linux_x64_glibc217.zip", + "sha256": "cc" * 32, + "urls": [ + "https://cdn.example/ffmpeg/8.0.1/linux_x64_glibc217.zip" + ], + }, + }, + ], + } + ], + } + + +class ManifestTester(unittest.TestCase): + def test_platform_tuple_has_os_and_arch(self) -> None: + tup = manifest.get_platform_tuple() + self.assertIn("os", tup) + self.assertIn("arch", tup) + # Linux must carry libc so glibc/musl builds can be distinguished. + if tup["os"] == "linux": + self.assertIn(tup["libc"], ("glibc", "musl")) + + def test_resolves_musl_vs_glibc(self) -> None: + cat = _catalog() + musl = manifest._resolve_asset_from_catalog( + cat, {"os": "linux", "arch": "x86_64", "libc": "musl"}, "latest-stable" + ) + glibc = manifest._resolve_asset_from_catalog( + cat, {"os": "linux", "arch": "x86_64", "libc": "glibc"}, "latest-stable" + ) + assert musl is not None and glibc is not None + self.assertIn("musl", musl.url) + self.assertIn("glibc", glibc.url) + self.assertEqual(musl.version, "8.0.1") + + def test_resolves_windows(self) -> None: + asset = manifest._resolve_asset_from_catalog( + _catalog(), {"os": "windows", "arch": "x86_64"}, "latest-stable" + ) + assert asset is not None + self.assertTrue(asset.url.endswith("win_x64.zip")) + self.assertEqual(asset.sha256, "aa" * 32) + + def test_unknown_platform_returns_none(self) -> None: + asset = manifest._resolve_asset_from_catalog( + _catalog(), {"os": "plan9", "arch": "sparc"}, "latest-stable" + ) + self.assertIsNone(asset) + + def test_disabled_manifest_returns_none(self) -> None: + # An empty manifest URL means resolution is disabled: it must be a + # no-op that returns None so the caller falls back to the legacy URL. + self.assertIsNone(manifest.resolve_asset(url="")) + self.assertEqual(manifest.DEFAULT_MANIFEST_URL, "") + + def test_example_manifest_resolves_all_eight_targets(self) -> None: + # The shipped example manifest documents the client contract; every one + # of the 8 issue-#20 targets must resolve to a distinct asset URL. + with open(_EXAMPLE_MANIFEST, "r", encoding="utf-8") as file_d: + catalog = json.load(file_d) + urls = set() + for target in _ALL_TARGETS: + asset = manifest._resolve_asset_from_catalog( + catalog, target, "latest-stable" + ) + self.assertIsNotNone(asset, f"no asset for {target}") + assert asset is not None + self.assertTrue(asset.url.startswith("https://")) + urls.add(asset.url) + self.assertEqual(len(urls), len(_ALL_TARGETS), "targets must be distinct") + + def test_verify_sha256_ok_and_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "blob.bin") + data = b"static-ffmpeg-test-payload" + with open(path, "wb") as file_d: + file_d.write(data) + good = hashlib.sha256(data).hexdigest() + manifest.verify_sha256(path, good) # should not raise + manifest.verify_sha256(path, None) # no checksum -> passes + with self.assertRaises(ValueError): + manifest.verify_sha256(path, "00" * 32) + + +if __name__ == "__main__": + unittest.main()