From 5dd93a99f2d48976b46688ccd8508552bce8b291 Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 28 Aug 2026 13:39:16 -0400 Subject: [PATCH] feat: install FFmpeg with one command, from a pinned LGPL build Recording video needed an FFmpeg executable and the error told users to pick one of four mechanisms. `capture install-ffmpeg` now gets them one. The wheel and the sdist still carry no FFmpeg bytes, and nothing downloads unless the operator runs that command. It fetches one archive per platform from the openadapt-desktop ffmpeg-runtime-v8.1.2-r1 release, which openadapt-desktop builds from the upstream FFmpeg 8.1.2 tarball with --disable-gpl, --disable-nonfree and --disable-version3. That build is LGPL-2.1-or-later, and FFmpeg's own LICENSE.md is installed beside the binaries. Ordering, which the licensing and safety story depends on: the archive digest is compared before the archive is opened, every extracted member is written at mode 0600 and digest-checked as it is written, and only after all of them match does anything get an executable bit. A mismatch leaves nothing installed. The downloader refuses a non-HTTPS URL, an off-host redirect, an oversized body, an oversized member, and any executable member under bin/ that is not pinned. Resolution order is unchanged and the installed runtime is last, behind OPENADAPT_FFMPEG_PATH, Recorder(ffmpeg_path=...), OPENADAPT_DESKTOP_FFMPEG_PATH, Desktop's ffmpeg.json manifest, and PATH. Installing never displaces an FFmpeg the operator already chose, and the command says so when PATH wins. verify_distribution.py checked filenames, so a renamed binary passed. It now checks executable magic, container magic, and FFmpeg build strings for every member that is not text, and a member counts as text only if it is UTF-8 with no NUL byte, so documentation that names FFmpeg still passes while a renamed binary does not. That gate already runs on every pull request and on both release paths. check_ffmpeg_pin.py reads the live release assets and verifies the archive digest, every member digest, and the configure arguments recorded inside the archive, so the LGPL claim is checked against the artifact instead of asserted. The new workflow runs it weekly and on any pull request that touches the pin, plus an install-and-encode smoke test on Linux, macOS, and Windows. Co-Authored-By: Claude Opus 5 --- .github/workflows/ffmpeg-pin.yml | 116 +++++ README.md | 53 ++- docs/DESIGN.md | 15 +- openadapt_capture/cli.py | 94 +++++ openadapt_capture/ffmpeg_runtime.py | 627 ++++++++++++++++++++++++++++ openadapt_capture/video.py | 48 ++- scripts/check_ffmpeg_pin.py | 124 ++++++ scripts/verify_distribution.py | 109 ++++- tests/test_ffmpeg_provision.py | 611 +++++++++++++++++++++++++++ 9 files changed, 1753 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/ffmpeg-pin.yml create mode 100644 openadapt_capture/ffmpeg_runtime.py create mode 100644 scripts/check_ffmpeg_pin.py create mode 100644 tests/test_ffmpeg_provision.py diff --git a/.github/workflows/ffmpeg-pin.yml b/.github/workflows/ffmpeg-pin.yml new file mode 100644 index 0000000..b961a80 --- /dev/null +++ b/.github/workflows/ffmpeg-pin.yml @@ -0,0 +1,116 @@ +name: FFmpeg pin + +# openadapt-capture ships no FFmpeg bytes. `capture install-ffmpeg` fetches one +# pinned archive per platform and refuses it unless its SHA-256 matches a digest +# compiled into the package. +# +# Two things can break that quietly, and neither shows up in the unit suite: +# a release asset replaced behind the pinned URL, and a pin bumped to a build +# that is no longer the LGPL configuration. Both lanes below read the live +# artifact, so a pin edit is proven in the pull request that makes it, and a +# replaced asset is caught on the weekly schedule. + +on: + schedule: + # Thursday 05:41 UTC. + - cron: "41 5 * * 4" + workflow_dispatch: + pull_request: + paths: + - "openadapt_capture/ffmpeg_runtime.py" + - "scripts/check_ffmpeg_pin.py" + - ".github/workflows/ffmpeg-pin.yml" + +concurrency: + group: capture-ffmpeg-pin-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify-pin: + name: Verify every pinned artifact and its licence + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install exact uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.29" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Check the pinned digests and the build configuration + run: uv run python scripts/check_ffmpeg_pin.py + + install-smoke: + name: Install and probe on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install exact uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.29" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Show the plan without fetching anything + run: uv run capture install-ffmpeg --dry-run + + - name: Install the pinned runtime and run a real encode-and-decode probe + run: uv run capture install-ffmpeg + + - name: Record with the installed runtime resolved and nothing else + shell: bash + env: + # Strip every earlier mechanism so the installed runtime is what + # resolves. A hosted runner may already carry an ffmpeg on PATH, + # which outranks it by design. + OPENADAPT_FFMPEG_PATH: "" + OPENADAPT_FFPROBE_PATH: "" + OPENADAPT_DESKTOP_FFMPEG_PATH: "" + run: | + set -euo pipefail + uv run python - <<'PY' + import os + import shutil + + from openadapt_capture import ffmpeg_runtime, video + + installed = ffmpeg_runtime.find_installed_runtime() + assert installed is not None, "install-ffmpeg left no usable receipt" + + # Hide any FFmpeg the runner image already provides. + os.environ["PATH"] = os.pathsep.join( + part + for part in os.environ["PATH"].split(os.pathsep) + if part and not shutil.which("ffmpeg", path=part) + ) + video._desktop_data_dirs = lambda: [] + + provision = video.require_video_encoder() + assert provision.source == "capture install-ffmpeg", provision.source + assert provision.executable == installed[0], provision.executable + print(f"encoded and decoded with {provision.codec} into {provision.muxer}") + PY + + - name: Remove the runtime again + run: uv run capture uninstall-ffmpeg diff --git a/README.md b/README.md index dd3fa01..0be7a57 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ capture record ./my-capture --description "Describe the workflow" capture info ./my-capture ``` -`capture status`, `capture stop`, and the `status_recording` / `stop_recording` -Python contract below are on `main` and ship in 1.2.3. They are not in the +`capture status`, `capture stop`, `capture install-ffmpeg`, and the +`status_recording` / `stop_recording` Python contract below are on `main` and +ship in 1.2.3. They are not in the published 1.2.2 wheel, so install from source until 1.2.3 reaches PyPI: ```bash @@ -107,19 +108,45 @@ an authenticated session capability that lives in an owner-only runtime file On macOS it also removes extended ACL entries and verifies they are absent. The capability never reaches command arguments, logs, or the capture directory. -## You have to supply FFmpeg +## FFmpeg -Video is the default evidence format, and Capture never downloads, bundles, or -links FFmpeg or PyAV. Point it at an executable with `OPENADAPT_FFMPEG_PATH`, -`Recorder(ffmpeg_path=...)`, Desktop's `ffmpeg.json` provision manifest, or by -putting `ffmpeg` and `ffprobe` on `PATH`. Recording runs a real -encode-and-decode probe first and refuses before the input listeners start if -the executable, the codec, or the PNG verification path is missing. +Recording video needs an FFmpeg executable, and `capture install-ffmpeg` gets +you one. Video is the default evidence format, so most people need it. -A minimal managed runtime has to supply raw-video input through a pipe, the -chosen video encoder, MP4 demuxing and muxing, PNG decoding and encoding, the -`image2pipe` muxer, and the `select` video filter. Desktop provisions and -probes that exact closure. +```bash +capture install-ffmpeg +``` + +That downloads a single pinned build for your platform, checks its SHA-256 +against a digest compiled into this package, and installs it under your user +data directory. Nothing is made executable until every digest matches. Run it +with `--dry-run` to print the exact URL, digest, and destination without +fetching anything, and `capture uninstall-ffmpeg` to remove it again. Builds +are pinned for macOS on Apple silicon and Intel, Linux x86-64, and Windows +x86-64. On anything else, supply your own executable. + +The wheel and the source distribution carry no FFmpeg bytes, and Capture +downloads nothing unless you run that command. Licensing is the reason. This +package is MIT and FFmpeg is not, so shipping FFmpeg inside it would relicense +the package. The pinned build is LGPL-2.1-or-later, configured with +`--disable-gpl`, `--disable-nonfree`, and `--disable-version3`. FFmpeg's own +`LICENSE.md` covers the GPL components it leaves out: "None of these parts are +used by default." The install puts that licence text beside the binaries, and +writes a receipt naming the archive, its digest, and the matching upstream +source tarball. + +An FFmpeg you already configured keeps priority. Capture resolves, in order, +`Recorder(ffmpeg_path=...)` or `OPENADAPT_FFMPEG_PATH`, then +`OPENADAPT_DESKTOP_FFMPEG_PATH`, then Desktop's `ffmpeg.json` provision +manifest, then `PATH`, and the installed runtime last. Point +`OPENADAPT_FFMPEG_PATH` at the installed one to move it to the front. + +Recording runs a real encode-and-decode probe first, and refuses before the +input listeners start if the executable, the codec, or the PNG verification +path is missing. A minimal runtime has to supply raw-video input through a +pipe, the chosen video encoder, MP4 demuxing and muxing, PNG decoding and +encoding, the `image2pipe` muxer, and the `select` video filter. The pinned +build and Desktop's runtime both cover that closure. Frames stream from memory straight to FFmpeg. A missing integer PTS slot reuses the preceding frame, so the encode is deterministic no matter what the diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7917f80..a7f6a69 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -176,11 +176,16 @@ describe controls inside an RDP or Citrix pixel stream. ## Video and frame timing -Capture does not import, link, download, or bundle FFmpeg. It invokes an -explicitly configured, Desktop-provisioned, or user-provisioned executable -through a process boundary. Preflight verifies the required raw-video input, -selected encoder, MP4 muxing, PNG encode/decode, `image2pipe`, and `select` -filter before recording starts. +Capture does not import, link, or bundle FFmpeg, and it downloads nothing on +its own. It invokes an explicitly configured, Desktop-provisioned, +user-provisioned, or `capture install-ffmpeg` executable through a process +boundary. `capture install-ffmpeg` is the operator's opt-in: it fetches one +pinned LGPL-2.1-or-later archive, refuses it unless its SHA-256 matches a +digest compiled into the package, and verifies every extracted member before +anything is made executable. Its runtime resolves last, behind all four earlier +mechanisms. Preflight verifies the required raw-video input, selected encoder, +MP4 muxing, PNG encode/decode, `image2pipe`, and `select` filter before +recording starts. The writer emits a deterministic constant-rate stream. It reuses the preceding RGB frame for a missing integer PTS slot. A compact MP4 metadata box binds diff --git a/openadapt_capture/cli.py b/openadapt_capture/cli.py index 59d7f1a..28e4b54 100644 --- a/openadapt_capture/cli.py +++ b/openadapt_capture/cli.py @@ -1,6 +1,7 @@ """Command-line interface for openadapt-capture. Usage: + capture install-ffmpeg capture record ./my_capture capture visualize ./my_capture capture info ./my_capture @@ -431,6 +432,97 @@ def _save_transcript( print(f"[{mins}:{secs:05.2f}] {seg['text']}") +def install_ffmpeg( + dry_run: bool = False, + force: bool = False, + probe: bool = True, +) -> None: + """Install the pinned FFmpeg build that Capture records video with. + + Capture ships no FFmpeg bytes. This command is the opt-in: it downloads one + exact archive, refuses it unless its SHA-256 matches the digest compiled + into this package, and only then makes anything executable. The build is + LGPL-2.1-or-later; FFmpeg's own licence text is installed beside it, and + the receipt records the matching upstream source archive. + + An FFmpeg you already configured keeps priority. This one is used only when + OPENADAPT_FFMPEG_PATH, Recorder(ffmpeg_path=...), Desktop's ffmpeg.json, + and PATH all come up empty. + + Args: + dry_run: Print the exact artifact, digest and destination, then stop. + force: Reinstall even when this build is already present. + probe: Run a real encode-and-decode check afterwards (default: True). + """ + from openadapt_capture import ffmpeg_runtime + + try: + planned = ffmpeg_runtime.plan() + except ffmpeg_runtime.UnsupportedPlatformError as exc: + print(str(exc)) + raise SystemExit(1) from exc + + print(f"Artifact: {planned['url']}") + print(f"SHA-256: {planned['archive_sha256']}") + print(f"Licence: {planned['license']}") + print(f"Source: {planned['source_url']}") + print(f"Install: {planned['install_dir']}") + print() + + if dry_run: + print("Dry run. Nothing was downloaded.") + return + + try: + installed = ffmpeg_runtime.install(force=force) + except ffmpeg_runtime.FFmpegProvisionError as exc: + print(str(exc)) + raise SystemExit(1) from exc + + print(f"Installed {installed.build_id}") + print(f" ffmpeg {installed.ffmpeg}") + print(f" ffprobe {installed.ffprobe}") + print(f" licence {installed.license_path}") + print(f" receipt {ffmpeg_runtime.receipt_path()}") + + if not probe: + return + + from openadapt_capture.video import ( + FFmpegUnavailableError, + require_video_encoder, + resolve_ffmpeg, + ) + + try: + provision = require_video_encoder() + except FFmpegUnavailableError as exc: + print() + print(f"The installed runtime did not pass the encode-and-decode check: {exc}") + raise SystemExit(1) from exc + + print() + print(f"Verified encoder {provision.codec} into {provision.muxer}.") + resolved = resolve_ffmpeg() + if resolved.executable != installed.ffmpeg: + print( + f"Note: recording will use the FFmpeg from {resolved.source} " + f"({resolved.executable}), which keeps priority over this install. " + "Set OPENADAPT_FFMPEG_PATH to prefer the installed one." + ) + + +def uninstall_ffmpeg() -> None: + """Remove the FFmpeg runtime that `capture install-ffmpeg` installed.""" + from openadapt_capture import ffmpeg_runtime + + root = ffmpeg_runtime.runtime_root() + if ffmpeg_runtime.uninstall(): + print(f"Removed {root}") + else: + print(f"Nothing to remove at {root}") + + def share(action: str, path_or_code: str, output_dir: str = ".") -> None: """Share recordings via Magic Wormhole. @@ -465,6 +557,8 @@ def main() -> None: "info": info, "transcribe": transcribe, "share": share, + "install-ffmpeg": install_ffmpeg, + "uninstall-ffmpeg": uninstall_ffmpeg, }) diff --git a/openadapt_capture/ffmpeg_runtime.py b/openadapt_capture/ffmpeg_runtime.py new file mode 100644 index 0000000..4a848d9 --- /dev/null +++ b/openadapt_capture/ffmpeg_runtime.py @@ -0,0 +1,627 @@ +"""Opt-in provisioning of one pinned, hash-verified LGPL FFmpeg runtime. + +The wheel and the source distribution carry no FFmpeg bytes. Nothing here runs +unless an operator asks for it by running ``capture install-ffmpeg``: Capture +never downloads on its own, and a missing runtime stays an error rather than a +silent fetch. + +What the operator opts in to is one exact artifact per platform, built from the +upstream FFmpeg release tarball by ``openadapt-desktop`` and published as a +GitHub release asset. The build passes ``--disable-gpl``, ``--disable-nonfree`` +and ``--disable-version3``, so it carries FFmpeg's default license. FFmpeg's own +``LICENSE.md``, installed beside the executables, states it: most files are +under the LGPL v2.1 or later, the GPL parts are optional and "None of these +parts are used by default, you have to explicitly pass ``--enable-gpl`` to +configure to activate them." + +The archive digest is checked before the archive is opened, and every extracted +member's digest is checked before any file is made executable, so nothing +unverified is ever executable and nothing unverified is ever run. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import stat +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterator + +RECEIPT_SCHEMA = "openadapt.capture-ffmpeg-install/v1" +RECEIPT_NAME = "install.json" + +#: Runtime revision. Bump this together with every digest below, and only from +#: a published ``openadapt-desktop`` ``ffmpeg-runtime-v*`` release whose +#: ``src-tauri/ffmpeg-runtime-manifest.json`` carries the same values. +RUNTIME_VERSION = "8.1.2-r1" +FFMPEG_VERSION = "8.1.2" +RELEASE_TAG = f"ffmpeg-runtime-v{RUNTIME_VERSION}" +RELEASE_BASE_URL = ( + "https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/" f"{RELEASE_TAG}/" +) + +#: SPDX expression for the pinned build, taken from FFmpeg's own ``LICENSE.md`` +#: for a configuration that enables no GPL and no version-3 component. +LICENSE_EXPRESSION = "LGPL-2.1-or-later" + +#: The corresponding source, which is what the LGPL asks a distributor to +#: offer. It is republished unmodified beside the binaries on the same release. +SOURCE_URL = f"https://ffmpeg.org/releases/ffmpeg-{FFMPEG_VERSION}.tar.xz" +SOURCE_SHA256 = "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c" +SOURCE_SIGNATURE_URL = f"{SOURCE_URL}.asc" +# The FFmpeg developers publish this OpenPGP fingerprint for the signature +# above. A fingerprint is a public identifier, not a credential. +SOURCE_SIGNATURE_FINGERPRINT = "FCF986EA15E6E293A5644F10B4322F04D67658D8" + +_ALLOWED_DOWNLOAD_HOSTS = frozenset( + { + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", + } +) +_DOWNLOAD_TIMEOUT_SECONDS = 300.0 +_CHUNK_BYTES = 1 << 16 + + +class FFmpegProvisionError(RuntimeError): + """The pinned runtime could not be installed exactly as pinned.""" + + +class UnsupportedPlatformError(FFmpegProvisionError): + """No pinned build exists for this operating system and architecture.""" + + +@dataclass(frozen=True) +class PinnedFile: + """One archive member, pinned by digest and bounded by size.""" + + member: str + sha256: str + max_bytes: int + executable: bool = False + + +@dataclass(frozen=True) +class PinnedArtifact: + """One platform's archive, pinned by digest and bounded by size.""" + + target: str + archive_sha256: str + archive_max_bytes: int + files: tuple[PinnedFile, ...] + ffmpeg_member: str + ffprobe_member: str + license_member: str + + @property + def build_id(self) -> str: + return f"ffmpeg-{RUNTIME_VERSION}-{self.target}" + + @property + def archive_name(self) -> str: + return f"openadapt-{self.build_id}.zip" + + @property + def url(self) -> str: + return f"{RELEASE_BASE_URL}{self.archive_name}" + + +_LICENSE_FILES = ( + PinnedFile( + member="LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + sha256="246041b6ecf9bc32d718a62c57877c78b5eb397b6467e74ed7ae2626ab189c30", + max_bytes=1075093, + ), + PinnedFile( + member="LICENSES/FFmpeg-LICENSE.md", + sha256="2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af", + max_bytes=1052922, + ), +) + +PINNED_ARTIFACTS: dict[str, PinnedArtifact] = { + artifact.target: artifact + for artifact in ( + PinnedArtifact( + target="aarch64-apple-darwin", + archive_sha256=("7cd08f97a97d3032f2093f06227fea1d12d4078dbb2c75e1109a9d3d24d7a266"), + archive_max_bytes=7908219, + files=( + *_LICENSE_FILES, + PinnedFile( + member="bin/ffmpeg", + sha256=("bc0189969e8ca336e4e49b63ef84effb5d301b1cf3209fc214a20abc0679b585"), + max_bytes=5889424, + executable=True, + ), + PinnedFile( + member="bin/ffprobe", + sha256=("a0dbc88c6d1b971c044121bbee55ac761b691ca9a0b9f6e39fa63613499d12d4"), + max_bytes=5535568, + executable=True, + ), + ), + ffmpeg_member="bin/ffmpeg", + ffprobe_member="bin/ffprobe", + license_member="LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + ), + PinnedArtifact( + target="x86_64-apple-darwin", + archive_sha256=("a45ac3c766d94ff4bf77c87e1b2baccc29e1aca806b270d495f1698e6bf6776b"), + archive_max_bytes=8185775, + files=( + *_LICENSE_FILES, + PinnedFile( + member="bin/ffmpeg", + sha256=("dfb2197b1ef2b3da19ad41f4fbc337f2c50000390d75697a81e3516a32f1293a"), + max_bytes=7331584, + executable=True, + ), + PinnedFile( + member="bin/ffprobe", + sha256=("b4da8255066f2a99604ef532cc54a08c73fef9de8bdf8eecdebaddd8c0b7797c"), + max_bytes=6948320, + executable=True, + ), + ), + ffmpeg_member="bin/ffmpeg", + ffprobe_member="bin/ffprobe", + license_member="LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + ), + PinnedArtifact( + target="x86_64-unknown-linux-gnu", + archive_sha256=("05b36093f1bc9476f7116056de504224f160fdec75484fe63ec539d6744c1ba8"), + archive_max_bytes=8018223, + files=( + *_LICENSE_FILES, + PinnedFile( + member="bin/ffmpeg", + sha256=("d1bb7bea5173ee9ef20b9fa7f6a290d53ae2d19fbdc90f14f491215a48b1c2b1"), + max_bytes=7143856, + executable=True, + ), + PinnedFile( + member="bin/ffprobe", + sha256=("6fd9ffcfa0a870c82b7cd0bf7b87cb75c8d44b82a157a415af5d5c2d3bee0d76"), + max_bytes=6766896, + executable=True, + ), + ), + ffmpeg_member="bin/ffmpeg", + ffprobe_member="bin/ffprobe", + license_member="LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + ), + PinnedArtifact( + target="x86_64-pc-windows-msvc", + archive_sha256=("bd88874357d3ea6490e22e7911f31df777e4e291b3ba83f3bfef9dc9bd366080"), + archive_max_bytes=8285917, + files=( + *_LICENSE_FILES, + PinnedFile( + member="LICENSES/zlib.txt", + sha256=("e32ff4e00d9d94930537635291da39e7e612703334bf6fde8c7f1686fe8a45a2"), + max_bytes=1049578, + ), + PinnedFile( + member="bin/ffmpeg.exe", + sha256=("33e322b544f07118d4c1a8a56e9e84e5a5c06f86c9fcbf749359b2a1042eebd3"), + max_bytes=6898688, + executable=True, + ), + PinnedFile( + member="bin/ffprobe.exe", + sha256=("f65a1b8874446cce3bda7112b89c6172a8c12983eade2b040cf3672e4fa5740b"), + max_bytes=6568960, + executable=True, + ), + ), + ffmpeg_member="bin/ffmpeg.exe", + ffprobe_member="bin/ffprobe.exe", + license_member="LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + ), + ) +} + + +def _platform_name() -> str: + return os.environ.get("OPENADAPT_PLATFORM_OVERRIDE") or sys.platform + + +def current_target( + platform_name: str | None = None, + machine: str | None = None, +) -> str: + """Return the pinned build target for this machine. + + Raises: + UnsupportedPlatformError: No pinned build covers this platform. + """ + platform_name = platform_name or _platform_name() + machine = (machine or os.environ.get("OPENADAPT_MACHINE_OVERRIDE") or platform.machine()).lower() + if platform_name == "darwin": + if machine in {"arm64", "aarch64"}: + return "aarch64-apple-darwin" + if machine in {"x86_64", "amd64"}: + return "x86_64-apple-darwin" + elif platform_name == "win32": + if machine in {"amd64", "x86_64"}: + return "x86_64-pc-windows-msvc" + elif platform_name.startswith("linux"): + if machine in {"x86_64", "amd64"}: + return "x86_64-unknown-linux-gnu" + raise UnsupportedPlatformError( + f"No pinned OpenAdapt FFmpeg build exists for {platform_name}/{machine}. " + "Install FFmpeg yourself and point Capture at it with " + "OPENADAPT_FFMPEG_PATH, or pass Recorder(ffmpeg_path=...)." + ) + + +def current_artifact(target: str | None = None) -> PinnedArtifact: + """Return the pinned artifact for this machine, or for an exact target.""" + return PINNED_ARTIFACTS[target or current_target()] + + +def data_root() -> Path: + """Return Capture's own user-data root, separate from Desktop's.""" + if override := os.environ.get("OPENADAPT_CAPTURE_DATA_DIR"): + return Path(override).expanduser() + home = Path.home() + platform_name = _platform_name() + if platform_name == "darwin": + return home / "Library" / "Application Support" / "ai.openadapt.capture" + if platform_name == "win32": + local_app_data = os.environ.get("LOCALAPPDATA") + base = Path(local_app_data) if local_app_data else home / "AppData" / "Local" + return base / "ai.openadapt.capture" + xdg = os.environ.get("XDG_DATA_HOME") + return (Path(xdg) if xdg else home / ".local" / "share") / "ai.openadapt.capture" + + +def runtime_root() -> Path: + """Return the directory that holds every installed FFmpeg runtime.""" + return data_root() / "ffmpeg" + + +def receipt_path() -> Path: + """Return the path of the record of what is installed and where from.""" + return runtime_root() / RECEIPT_NAME + + +def _https_opener() -> urllib.request.OpenerDirector: + """Build an opener that refuses any non-HTTPS or off-host redirect.""" + + class StrictRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + parsed = urllib.parse.urlsplit(newurl) + if parsed.scheme != "https": + raise FFmpegProvisionError( + f"Refusing a non-HTTPS FFmpeg download redirect to {newurl}" + ) + if parsed.hostname not in _ALLOWED_DOWNLOAD_HOSTS: + raise FFmpegProvisionError( + f"Refusing an FFmpeg download redirect to an unpinned host: {parsed.hostname}" + ) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + return urllib.request.build_opener(StrictRedirect) + + +def _download_to(url: str, destination: Path, max_bytes: int) -> str: + """Stream ``url`` into ``destination`` under a byte cap, returning its digest.""" + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "https" or parsed.hostname not in _ALLOWED_DOWNLOAD_HOSTS: + raise FFmpegProvisionError(f"Refusing to download the FFmpeg runtime from {url}") + request = urllib.request.Request(url, headers={"User-Agent": "openadapt-capture"}) + digest = hashlib.sha256() + written = 0 + try: + with _https_opener().open(request, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response: + declared = response.headers.get("Content-Length") + if declared is not None and declared.isdigit() and int(declared) > max_bytes: + raise FFmpegProvisionError( + f"The FFmpeg archive declares {declared} bytes, above the pinned " + f"bound of {max_bytes}" + ) + with destination.open("wb") as sink: + while chunk := response.read(_CHUNK_BYTES): + written += len(chunk) + if written > max_bytes: + raise FFmpegProvisionError( + f"The FFmpeg archive exceeded its pinned bound of {max_bytes} bytes" + ) + digest.update(chunk) + sink.write(chunk) + sink.flush() + os.fsync(sink.fileno()) + except urllib.error.URLError as exc: + raise FFmpegProvisionError(f"Could not download the pinned FFmpeg runtime: {exc}") from exc + return digest.hexdigest() + + +def _extract_member( + archive: zipfile.ZipFile, + pinned: PinnedFile, + destination: Path, +) -> None: + """Extract one pinned member under its byte cap, without an executable bit. + + The destination is built from the pinned constant, never from a name read + out of the archive, so no archive member can direct a write outside the + staging directory. + """ + try: + info = archive.getinfo(pinned.member) + except KeyError as exc: + raise FFmpegProvisionError( + f"The FFmpeg archive has no pinned member {pinned.member!r}" + ) from exc + if info.is_dir() or info.file_size > pinned.max_bytes: + raise FFmpegProvisionError( + f"The FFmpeg archive member {pinned.member!r} is not a bounded regular file" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256() + written = 0 + with archive.open(info, "r") as source: + # 0o600 while unverified: the executable bit is set only after every + # pinned digest below has matched. + handle = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(handle, "wb") as sink: + while chunk := source.read(_CHUNK_BYTES): + written += len(chunk) + if written > pinned.max_bytes: + raise FFmpegProvisionError( + f"The FFmpeg archive member {pinned.member!r} exceeded its " + f"pinned bound of {pinned.max_bytes} bytes" + ) + digest.update(chunk) + sink.write(chunk) + sink.flush() + os.fsync(sink.fileno()) + actual = digest.hexdigest() + if actual != pinned.sha256: + raise FFmpegProvisionError( + f"The FFmpeg archive member {pinned.member!r} has digest {actual}, " + f"not the pinned {pinned.sha256}" + ) + + +def _reject_unpinned_members(archive: zipfile.ZipFile, artifact: PinnedArtifact) -> None: + """Refuse an archive that carries an executable member Capture did not pin. + + Everything the archive puts in ``bin/`` has to be one of the pinned, + digest-checked members. The archive also carries plain-text provenance and + license files, which are inert and are simply not installed. + """ + pinned = {file.member for file in artifact.files} + for name in archive.namelist(): + if name.endswith("/"): + continue + if name.startswith("bin/") and name not in pinned: + raise FFmpegProvisionError( + f"The FFmpeg archive carries an unpinned executable member: {name}" + ) + + +@dataclass(frozen=True) +class InstalledRuntime: + """One verified runtime on disk, as recorded by the install receipt.""" + + ffmpeg: str + ffprobe: str + build_id: str + runtime_version: str + target: str + url: str + archive_sha256: str + license_expression: str + license_path: str + source_url: str + source_sha256: str + installed_at: str + + def as_receipt(self) -> dict: + return { + "schema": RECEIPT_SCHEMA, + "build_id": self.build_id, + "runtime_version": self.runtime_version, + "ffmpeg_version": FFMPEG_VERSION, + "target": self.target, + "url": self.url, + "archive_sha256": self.archive_sha256, + "license": { + "expression": self.license_expression, + "path": self.license_path, + "source_url": self.source_url, + "source_sha256": self.source_sha256, + "source_signature_url": SOURCE_SIGNATURE_URL, + "source_signature_fingerprint": SOURCE_SIGNATURE_FINGERPRINT, + }, + "executables": {"ffmpeg": self.ffmpeg, "ffprobe": self.ffprobe}, + "installed_at": self.installed_at, + } + + +def read_receipt() -> dict | None: + """Return the install receipt, or ``None`` when nothing is installed.""" + path = receipt_path() + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict) or payload.get("schema") != RECEIPT_SCHEMA: + return None + return payload + + +def find_installed_runtime() -> tuple[str, str | None] | None: + """Return ``(ffmpeg, ffprobe)`` for a recorded install, or ``None``. + + This reads only Capture's own receipt and never downloads. A receipt whose + executables are gone is treated as absent, so a half-deleted install falls + through to the normal "no FFmpeg" error instead of a confusing one. + """ + payload = read_receipt() + if payload is None: + return None + executables = payload.get("executables") + if not isinstance(executables, dict): + return None + ffmpeg = executables.get("ffmpeg") + ffprobe = executables.get("ffprobe") + if not isinstance(ffmpeg, str) or not Path(ffmpeg).is_file(): + return None + root = runtime_root().resolve() + try: + Path(ffmpeg).resolve().relative_to(root) + except ValueError: + return None + if isinstance(ffprobe, str) and Path(ffprobe).is_file(): + try: + Path(ffprobe).resolve().relative_to(root) + except ValueError: + ffprobe = None + else: + ffprobe = None + return ffmpeg, ffprobe + + +def plan(target: str | None = None) -> dict: + """Describe exactly what an install would fetch, without fetching it.""" + artifact = current_artifact(target) + return { + "build_id": artifact.build_id, + "runtime_version": RUNTIME_VERSION, + "ffmpeg_version": FFMPEG_VERSION, + "target": artifact.target, + "url": artifact.url, + "archive_sha256": artifact.archive_sha256, + "license": LICENSE_EXPRESSION, + "source_url": SOURCE_URL, + "source_sha256": SOURCE_SHA256, + "install_dir": str(runtime_root() / artifact.build_id), + "receipt": str(receipt_path()), + } + + +def _staged_files(artifact: PinnedArtifact, staging: Path) -> Iterator[tuple[PinnedFile, Path]]: + for pinned in artifact.files: + yield pinned, staging / Path(*pinned.member.split("/")) + + +def install( + *, + target: str | None = None, + force: bool = False, +) -> InstalledRuntime: + """Download, verify and install the pinned FFmpeg runtime for this machine. + + The archive digest is checked before the archive is opened. Every pinned + member's digest is checked as it is written, at mode ``0600``. Only when + every digest has matched does anything become executable, and only then is + the staged directory promoted and the receipt written. + + Raises: + FFmpegProvisionError: The pinned artifact could not be obtained and + verified exactly as pinned. Nothing is installed in that case. + """ + artifact = current_artifact(target) + root = runtime_root() + final = root / artifact.build_id + if final.is_dir() and not force: + existing = find_installed_runtime() + if existing is not None: + ffmpeg, ffprobe = existing + return InstalledRuntime( + ffmpeg=ffmpeg, + ffprobe=ffprobe or "", + build_id=artifact.build_id, + runtime_version=RUNTIME_VERSION, + target=artifact.target, + url=artifact.url, + archive_sha256=artifact.archive_sha256, + license_expression=LICENSE_EXPRESSION, + license_path=str(final / Path(*artifact.license_member.split("/"))), + source_url=SOURCE_URL, + source_sha256=SOURCE_SHA256, + installed_at=(read_receipt() or {}).get("installed_at", ""), + ) + root.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix=".openadapt-ffmpeg-", dir=root) as scratch: + scratch_root = Path(scratch) + archive_path = scratch_root / artifact.archive_name + actual = _download_to(artifact.url, archive_path, artifact.archive_max_bytes) + if actual != artifact.archive_sha256: + # Nothing has been opened, extracted, marked executable, or run. + raise FFmpegProvisionError( + f"The FFmpeg archive from {artifact.url} has digest {actual}, " + f"not the pinned {artifact.archive_sha256}. Nothing was installed." + ) + + staging = scratch_root / "staging" + staging.mkdir() + with zipfile.ZipFile(archive_path) as archive: + _reject_unpinned_members(archive, artifact) + for pinned, destination in _staged_files(artifact, staging): + _extract_member(archive, pinned, destination) + + # Every pinned digest matched above. Only now does anything become + # executable. + for pinned, destination in _staged_files(artifact, staging): + if pinned.executable: + mode = destination.stat().st_mode + destination.chmod(mode | stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR) + + if final.exists(): + replaced = scratch_root / "replaced" + final.replace(replaced) + staging.replace(final) + + ffmpeg = final / Path(*artifact.ffmpeg_member.split("/")) + ffprobe = final / Path(*artifact.ffprobe_member.split("/")) + installed = InstalledRuntime( + ffmpeg=str(ffmpeg), + ffprobe=str(ffprobe), + build_id=artifact.build_id, + runtime_version=RUNTIME_VERSION, + target=artifact.target, + url=artifact.url, + archive_sha256=artifact.archive_sha256, + license_expression=LICENSE_EXPRESSION, + license_path=str(final / Path(*artifact.license_member.split("/"))), + source_url=SOURCE_URL, + source_sha256=SOURCE_SHA256, + installed_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + ) + receipt = receipt_path() + temporary = receipt.with_name(f".{receipt.name}.partial") + temporary.write_text( + json.dumps(installed.as_receipt(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(receipt) + return installed + + +def uninstall() -> bool: + """Remove every installed runtime and its receipt. Returns whether any went.""" + root = runtime_root() + if not root.exists(): + return False + shutil.rmtree(root) + return True diff --git a/openadapt_capture/video.py b/openadapt_capture/video.py index 51ab5bd..21bc9ee 100644 --- a/openadapt_capture/video.py +++ b/openadapt_capture/video.py @@ -1,9 +1,13 @@ """Video capture through a separately provisioned FFmpeg executable. -Capture itself never downloads or bundles FFmpeg. In-memory RGB frames stream -directly into the encoder process and become compact video while recording. -The finished output is verified and atomically promoted; an encoder failure is -reported and never leaves a false-success public MP4. +Capture bundles no FFmpeg and downloads none on its own. An operator who wants +one can run ``capture install-ffmpeg``, which fetches a single pinned LGPL +build and verifies its SHA-256; see ``openadapt_capture.ffmpeg_runtime``. + +In-memory RGB frames stream directly into the encoder process and become +compact video while recording. The finished output is verified and atomically +promoted; an encoder failure is reported and never leaves a false-success +public MP4. """ from __future__ import annotations @@ -29,7 +33,7 @@ from loguru import logger from PIL import Image -from openadapt_capture import utils +from openadapt_capture import ffmpeg_runtime, utils from openadapt_capture.config import config if TYPE_CHECKING: @@ -437,6 +441,11 @@ def resolve_ffmpeg( 2. ``OPENADAPT_FFMPEG_PATH`` / ``OPENADAPT_DESKTOP_FFMPEG_PATH``. 3. Desktop user-data ``ffmpeg.json`` manifest. 4. System ``PATH``. + 5. A runtime the operator installed with ``capture install-ffmpeg``. + + The managed runtime is last, so installing it never displaces an FFmpeg the + operator already chose. To prefer it over one of the four, name it in + ``OPENADAPT_FFMPEG_PATH``. ``ffprobe`` is resolved from an explicit path, the Desktop manifest, beside FFmpeg, or from ``PATH``. Encoding does not require it; exact frame lookup @@ -500,11 +509,22 @@ def resolve_ffmpeg( source="PATH", ) + managed = ffmpeg_runtime.find_installed_runtime() + if managed is not None: + managed_ffmpeg, managed_ffprobe = managed + return FFmpegProvision( + managed_ffmpeg, + ffprobe=_resolve_ffprobe(managed_ffmpeg, ffprobe_path) or managed_ffprobe, + source="capture install-ffmpeg", + ) + raise FFmpegUnavailableError( - "Video capture requires a separately provisioned FFmpeg executable. " - "Set OPENADAPT_FFMPEG_PATH, pass Recorder(ffmpeg_path=...), provision " - "Desktop's ffmpeg.json user-data manifest, or put ffmpeg on PATH. " - "OpenAdapt Capture does not download or bundle FFmpeg." + "Video capture needs an FFmpeg executable, and none is installed.\n" + " Run: capture install-ffmpeg\n" + "That downloads one pinned LGPL build, checks its SHA-256, and " + "installs it for this user only. Capture bundles no FFmpeg and never " + "downloads one on its own. If you already have an FFmpeg you want to " + "use, set OPENADAPT_FFMPEG_PATH to it instead." ) @@ -1344,10 +1364,12 @@ def move_moov_atom( def _require_ffprobe(provision: FFmpegProvision) -> str: if provision.ffprobe is None: raise FFmpegUnavailableError( - "Exact frame lookup and video metadata require a separately " - "provisioned ffprobe executable. Put ffprobe beside FFmpeg, set " - "OPENADAPT_FFPROBE_PATH, pass ffprobe_path=..., or declare it in " - "Desktop's ffmpeg.json manifest." + "Exact frame lookup and video metadata need an ffprobe executable, " + "and none is installed.\n" + " Run: capture install-ffmpeg\n" + "That installs a matching ffprobe beside FFmpeg. To use an ffprobe " + "you already have, put it beside FFmpeg, set OPENADAPT_FFPROBE_PATH, " + "or pass ffprobe_path=...." ) return provision.ffprobe diff --git a/scripts/check_ffmpeg_pin.py b/scripts/check_ffmpeg_pin.py new file mode 100644 index 0000000..eeb572b --- /dev/null +++ b/scripts/check_ffmpeg_pin.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Prove every pinned FFmpeg artifact is still exactly what the package claims. + +Three things are checked against the live release asset, not against a copy of +the pin: + +1. The archive still serves the pinned SHA-256 at the pinned URL. +2. Every member the installer would extract still has its pinned digest. +3. The build is the LGPL configuration. The archive carries the exact + ``configure`` arguments it was built with, so the licensing claim in + ``openadapt_capture/ffmpeg_runtime.py`` is verified rather than asserted. + +Run it whenever the pin changes, and on a schedule so a retagged or replaced +release asset is caught rather than silently installed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +import tempfile +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from openadapt_capture import ffmpeg_runtime as fr # noqa: E402 + +CONFIGURE_ARGS_MEMBER = "PROVENANCE/configure-args.txt" +LICENSE_STATEMENT_MEMBER = "LICENSES/FFmpeg-LICENSE.md" +REQUIRED_BUILD_FLAGS = ("--disable-gpl", "--disable-nonfree", "--disable-version3") +FORBIDDEN_BUILD_FLAGS = ("--enable-gpl", "--enable-nonfree", "--enable-version3") + + +class PinMismatch(SystemExit): + """The published artifact and the compiled pin disagree.""" + + +def _check_build_is_lgpl(archive: zipfile.ZipFile, target: str) -> None: + try: + arguments = archive.read(CONFIGURE_ARGS_MEMBER).decode("utf-8") + except KeyError: + raise PinMismatch( + f"{target}: the archive has no {CONFIGURE_ARGS_MEMBER}, so its " + "licence cannot be established from the artifact itself" + ) from None + tokens = arguments.split() + for flag in REQUIRED_BUILD_FLAGS: + if flag not in tokens: + raise PinMismatch( + f"{target}: the build is missing {flag}. Only a build with " + f"{', '.join(REQUIRED_BUILD_FLAGS)} carries the " + f"{fr.LICENSE_EXPRESSION} licence this package pins." + ) + for flag in FORBIDDEN_BUILD_FLAGS: + if flag in tokens: + raise PinMismatch( + f"{target}: the build passes {flag}, which changes FFmpeg's " + f"licence away from {fr.LICENSE_EXPRESSION}." + ) + if LICENSE_STATEMENT_MEMBER not in archive.namelist(): + raise PinMismatch(f"{target}: the archive omits {LICENSE_STATEMENT_MEMBER}") + + +def check_artifact(artifact: fr.PinnedArtifact, scratch: Path) -> None: + print(f"{artifact.target}: {artifact.url}") + download = scratch / artifact.archive_name + actual = fr._download_to(artifact.url, download, artifact.archive_max_bytes) + if actual != artifact.archive_sha256: + raise PinMismatch( + f"{artifact.target}: the published archive has digest {actual}, " + f"not the pinned {artifact.archive_sha256}" + ) + print(f" archive sha256 {actual} matches the pin") + + with zipfile.ZipFile(download) as archive: + fr._reject_unpinned_members(archive, artifact) + for pinned in artifact.files: + try: + content = archive.read(pinned.member) + except KeyError: + raise PinMismatch( + f"{artifact.target}: the archive has no pinned member {pinned.member}" + ) from None + if len(content) > pinned.max_bytes: + raise PinMismatch( + f"{artifact.target}: {pinned.member} is {len(content)} bytes, " + f"above its pinned bound of {pinned.max_bytes}" + ) + digest = hashlib.sha256(content).hexdigest() + if digest != pinned.sha256: + raise PinMismatch( + f"{artifact.target}: {pinned.member} has digest {digest}, " + f"not the pinned {pinned.sha256}" + ) + print(f" {len(artifact.files)} pinned members match their digests") + _check_build_is_lgpl(archive, artifact.target) + print(f" build configuration confirms {fr.LICENSE_EXPRESSION}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--target", + action="append", + choices=sorted(fr.PINNED_ARTIFACTS), + help="Check only these targets (default: all of them).", + ) + args = parser.parse_args() + targets = args.target or sorted(fr.PINNED_ARTIFACTS) + + print(f"openadapt-capture pins FFmpeg {fr.RUNTIME_VERSION} ({fr.LICENSE_EXPRESSION})") + print(f"corresponding source: {fr.SOURCE_URL} sha256:{fr.SOURCE_SHA256}") + print() + with tempfile.TemporaryDirectory(prefix="ffmpeg-pin-") as scratch: + for target in targets: + check_artifact(fr.PINNED_ARTIFACTS[target], Path(scratch)) + print() + print(f"verified {len(targets)} pinned artifact(s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 189f099..89ef545 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -37,6 +37,101 @@ } +# The MIT package boundary forbids shipping FFmpeg bytes, whatever the file is +# called. `openadapt_capture/ffmpeg_runtime.py` fetches a pinned LGPL build at +# the operator's request instead, so the source name is expected and only the +# bytes are forbidden. +FORBIDDEN_BINARY_NAME_TOKENS = ( + "ffmpeg", + "ffprobe", + "avcodec", + "avformat", + "avutil", + "swscale", + "swresample", + "x264", + "x265", +) +# Bytes that begin a native executable or shared library on the platforms this +# package targets. +EXECUTABLE_MAGIC = ( + b"\x7fELF", # ELF + b"\xfe\xed\xfa\xce", # Mach-O 32-bit big-endian + b"\xfe\xed\xfa\xcf", # Mach-O 64-bit big-endian + b"\xce\xfa\xed\xfe", # Mach-O 32-bit little-endian + b"\xcf\xfa\xed\xfe", # Mach-O 64-bit little-endian + b"\xca\xfe\xba\xbe", # Mach-O universal + b"MZ", # PE / COFF +) +# A nested container would hide a binary from a per-member scan. +NESTED_ARCHIVE_MAGIC = ( + b"PK\x03\x04", # zip + b"\xfd7zXZ\x00", # xz + b"\x1f\x8b", # gzip + b"BZh", # bzip2 + b"\x28\xb5\x2f\xfd", # zstd + b"!", # ar +) +# Strings that only an FFmpeg build, or a library copied out of one, carries. +FORBIDDEN_BINARY_CONTENT = ( + b"ffmpeg version", + b"ffprobe version", + b"libavcodec", + b"libavformat", + b"--enable-gpl", + b"--disable-gpl", + b"Lavc", +) + + +def _is_text(content: bytes) -> bool: + """Whether a member is text, and so cannot be a smuggled media binary. + + Source, documentation and package metadata legitimately name FFmpeg: the + package documents the FFmpeg it fetches, and README text reaches METADATA. + A native binary is not valid UTF-8 and carries NUL bytes, so this + distinguishes the two without a filename allowlist to keep in step. + """ + if b"\x00" in content: + return False + try: + content.decode("utf-8") + except UnicodeDecodeError: + return False + return True + + +def _verify_no_media_binaries(path: Path, files: dict[str, bytes]) -> None: + """Refuse an archive that carries FFmpeg bytes under any name. + + Executable and container magic are checked for every member, so a renamed + binary and a nested archive are both caught. The name and build-string + checks then apply to every member that is not text, which is what the + licensing boundary actually forbids. + """ + for name, content in files.items(): + assert not content.startswith(EXECUTABLE_MAGIC), ( + f"{path}: a native executable violates the external-process " + f"boundary: {name}" + ) + assert not content.startswith(NESTED_ARCHIVE_MAGIC), ( + f"{path}: a nested archive could hide a media binary from this " + f"gate: {name}" + ) + if _is_text(content): + continue + leaf = Path(name).name.lower() + assert not any(token in leaf for token in FORBIDDEN_BINARY_NAME_TOKENS), ( + f"{path}: bundled video binary violates the external-process " + f"boundary: {name}" + ) + for token in FORBIDDEN_BINARY_CONTENT: + assert token not in content, ( + f"{path}: FFmpeg build bytes ({token!r}) are in the release " + f"archive: {name}" + ) + + def _archive_files(path: Path) -> dict[str, bytes]: if path.suffix == ".whl": with zipfile.ZipFile(path) as archive: @@ -123,19 +218,7 @@ def verify_distribution(path: Path) -> None: f"{path}: PyAV must not be in the package dependency closure" ) - forbidden_binary_names = ( - "ffmpeg", - "ffprobe", - "avcodec", - "avformat", - "x264", - "x265", - ) - for name in files: - leaf = Path(name).name.lower() - assert not any(token in leaf for token in forbidden_binary_names), ( - f"{path}: bundled video binary violates the external-process boundary: {name}" - ) + _verify_no_media_binaries(path, files) python_sources = "\n".join( content.decode("utf-8") diff --git a/tests/test_ffmpeg_provision.py b/tests/test_ffmpeg_provision.py new file mode 100644 index 0000000..866ecb5 --- /dev/null +++ b/tests/test_ffmpeg_provision.py @@ -0,0 +1,611 @@ +"""Contract tests for the opt-in, pinned FFmpeg runtime. + +The licensing boundary these protect: the package ships no FFmpeg bytes, it +downloads nothing unless an operator asks, it installs only what its compiled +digests describe, and it never makes an unverified file executable. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import pathlib +import re +import zipfile +from pathlib import Path + +import pytest + +from openadapt_capture import ffmpeg_runtime as fr +from scripts.verify_distribution import ( + REQUIRED_OBSERVER_PATHS, + _archive_files, + verify_distribution, +) + +HEX64 = re.compile(r"^[0-9a-f]{64}$") + + +# -------------------------------------------------------------------------- +# The pin itself +# -------------------------------------------------------------------------- + + +def test_every_supported_target_is_pinned() -> None: + assert set(fr.PINNED_ARTIFACTS) == { + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + } + + +@pytest.mark.parametrize("target", sorted(fr.PINNED_ARTIFACTS)) +def test_pin_is_complete_and_well_formed(target: str) -> None: + artifact = fr.PINNED_ARTIFACTS[target] + assert artifact.target == target + assert HEX64.fullmatch(artifact.archive_sha256) + assert artifact.archive_max_bytes > 0 + assert artifact.url.startswith("https://github.com/OpenAdaptAI/openadapt-desktop/") + assert fr.RELEASE_TAG in artifact.url + + members = {file.member: file for file in artifact.files} + for member, file in members.items(): + assert HEX64.fullmatch(file.sha256), member + assert 0 < file.max_bytes <= artifact.archive_max_bytes, member + assert not member.startswith("/") and ".." not in Path(member).parts, member + + for required in (artifact.ffmpeg_member, artifact.ffprobe_member, artifact.license_member): + assert required in members, required + assert members[artifact.ffmpeg_member].executable + assert members[artifact.ffprobe_member].executable + assert not members[artifact.license_member].executable + # Only the two executables are ever made executable. + assert sum(1 for file in artifact.files if file.executable) == 2 + + +def test_pinned_build_is_not_gpl() -> None: + """The hard licensing rule turns on this: an LGPL build, never a GPL one.""" + assert fr.LICENSE_EXPRESSION == "LGPL-2.1-or-later" + assert "GPL-2" not in fr.LICENSE_EXPRESSION.replace("LGPL-2", "") + # Every artifact installs FFmpeg's own licence text beside the binaries. + for artifact in fr.PINNED_ARTIFACTS.values(): + assert artifact.license_member.startswith("LICENSES/") + assert any(file.member == "LICENSES/FFmpeg-LICENSE.md" for file in artifact.files) + + +def test_corresponding_source_is_pinned_too() -> None: + """The LGPL asks a distributor to offer the source. Record which source.""" + assert fr.SOURCE_URL.endswith(f"ffmpeg-{fr.FFMPEG_VERSION}.tar.xz") + assert HEX64.fullmatch(fr.SOURCE_SHA256) + assert fr.SOURCE_SIGNATURE_URL == f"{fr.SOURCE_URL}.asc" + + +# -------------------------------------------------------------------------- +# Platform selection +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("platform_name", "machine", "expected"), + [ + ("darwin", "arm64", "aarch64-apple-darwin"), + ("darwin", "x86_64", "x86_64-apple-darwin"), + ("linux", "x86_64", "x86_64-unknown-linux-gnu"), + ("win32", "AMD64", "x86_64-pc-windows-msvc"), + ], +) +def test_current_target(platform_name: str, machine: str, expected: str) -> None: + assert fr.current_target(platform_name, machine) == expected + + +@pytest.mark.parametrize( + ("platform_name", "machine"), + [("linux", "aarch64"), ("freebsd13", "x86_64"), ("darwin", "ppc")], +) +def test_unsupported_platform_names_the_manual_route(platform_name: str, machine: str) -> None: + with pytest.raises(fr.UnsupportedPlatformError) as excinfo: + fr.current_target(platform_name, machine) + assert "OPENADAPT_FFMPEG_PATH" in str(excinfo.value) + + +def test_data_root_is_separate_from_desktop(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OPENADAPT_CAPTURE_DATA_DIR", raising=False) + monkeypatch.setenv("OPENADAPT_PLATFORM_OVERRIDE", "darwin") + root = fr.data_root() + assert root.name == "ai.openadapt.capture" + assert "ai.openadapt.desktop" not in str(root) + + +# -------------------------------------------------------------------------- +# A synthetic pinned artifact, so the real install path can be exercised +# -------------------------------------------------------------------------- + +FAKE_TARGET = "test-target" + + +def _build_archive(members: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, content in members.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +@pytest.fixture +def synthetic(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + """Register a fake pinned target whose digests describe content we control.""" + monkeypatch.setenv("OPENADAPT_CAPTURE_DATA_DIR", str(tmp_path / "data")) + + members = { + "LICENSES/FFmpeg-LGPL-2.1-or-later.txt": b"lgpl text\n", + "bin/ffmpeg": b"not really ffmpeg\n", + "bin/ffprobe": b"not really ffprobe\n", + "PROVENANCE/BUILD.json": b"{}\n", + } + + def pinned(member: str, executable: bool = False) -> fr.PinnedFile: + return fr.PinnedFile( + member=member, + sha256=hashlib.sha256(members[member]).hexdigest(), + max_bytes=len(members[member]) + 16, + executable=executable, + ) + + archive_bytes = _build_archive(members) + artifact = fr.PinnedArtifact( + target=FAKE_TARGET, + archive_sha256=hashlib.sha256(archive_bytes).hexdigest(), + archive_max_bytes=len(archive_bytes) + 16, + files=( + pinned("LICENSES/FFmpeg-LGPL-2.1-or-later.txt"), + pinned("bin/ffmpeg", executable=True), + pinned("bin/ffprobe", executable=True), + ), + ffmpeg_member="bin/ffmpeg", + ffprobe_member="bin/ffprobe", + license_member="LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + ) + monkeypatch.setitem(fr.PINNED_ARTIFACTS, FAKE_TARGET, artifact) + + served = {"bytes": archive_bytes} + + def fake_download(url: str, destination: Path, max_bytes: int) -> str: + assert url == artifact.url + payload = served["bytes"] + assert len(payload) <= max_bytes + destination.write_bytes(payload) + return hashlib.sha256(payload).hexdigest() + + monkeypatch.setattr(fr, "_download_to", fake_download) + return artifact, members, served + + +def test_install_writes_verified_files_and_a_receipt(synthetic) -> None: + artifact, members, _ = synthetic + installed = fr.install(target=FAKE_TARGET) + + ffmpeg = Path(installed.ffmpeg) + assert ffmpeg.read_bytes() == members["bin/ffmpeg"] + assert ffmpeg.stat().st_mode & 0o100, "ffmpeg is not executable" + assert Path(installed.ffprobe).stat().st_mode & 0o100 + licence = Path(installed.license_path) + assert licence.read_bytes() == members["LICENSES/FFmpeg-LGPL-2.1-or-later.txt"] + assert not licence.stat().st_mode & 0o111, "the licence text must not be executable" + + # Only pinned members are installed. Unpinned provenance is left behind. + assert not (ffmpeg.parent.parent / "PROVENANCE").exists() + + receipt = json.loads(fr.receipt_path().read_text()) + assert receipt["schema"] == fr.RECEIPT_SCHEMA + assert receipt["url"] == artifact.url + assert receipt["archive_sha256"] == artifact.archive_sha256 + assert receipt["license"]["expression"] == fr.LICENSE_EXPRESSION + assert receipt["license"]["source_url"] == fr.SOURCE_URL + assert receipt["installed_at"] + + assert fr.find_installed_runtime() == (installed.ffmpeg, installed.ffprobe) + + +def test_every_digest_is_checked_before_anything_becomes_executable( + synthetic, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The ordering the licensing and safety story depends on.""" + events: list[tuple[str, str]] = [] + + original_extract = fr._extract_member + + def spy_extract(archive, pinned, destination): # type: ignore[no-untyped-def] + original_extract(archive, pinned, destination) + # Reaching here means this member's digest matched. + events.append(("verified", pinned.member)) + + original_chmod = pathlib.Path.chmod + + def spy_chmod(self, mode, **kwargs): # type: ignore[no-untyped-def] + if mode & 0o111: + events.append(("chmod+x", self.name)) + return original_chmod(self, mode, **kwargs) + + monkeypatch.setattr(fr, "_extract_member", spy_extract) + monkeypatch.setattr(pathlib.Path, "chmod", spy_chmod) + + fr.install(target=FAKE_TARGET) + + kinds = [kind for kind, _ in events] + assert "chmod+x" in kinds and "verified" in kinds + assert max(index for index, kind in enumerate(kinds) if kind == "verified") < min( + index for index, kind in enumerate(kinds) if kind == "chmod+x" + ), f"an executable bit was set before a digest was checked: {events}" + + +def test_a_tampered_archive_installs_nothing(synthetic) -> None: + artifact, _, served = synthetic + served["bytes"] = served["bytes"] + b"tampered" + + with pytest.raises(fr.FFmpegProvisionError) as excinfo: + fr.install(target=FAKE_TARGET) + + assert artifact.archive_sha256 in str(excinfo.value) + assert not (fr.runtime_root() / artifact.build_id).exists() + assert not fr.receipt_path().exists() + assert fr.find_installed_runtime() is None + + +def test_a_tampered_member_installs_nothing(synthetic) -> None: + """The archive digest can match while a member does not, if the pin is stale.""" + artifact, members, served = synthetic + swapped = dict(members) + swapped["bin/ffmpeg"] = b"a different executable entirely\n" + served["bytes"] = _build_archive(swapped) + # Re-pin the archive digest so only the member check can fail. + tampered = fr.PinnedArtifact( + target=FAKE_TARGET, + archive_sha256=hashlib.sha256(served["bytes"]).hexdigest(), + archive_max_bytes=len(served["bytes"]) + 64, + files=artifact.files, + ffmpeg_member=artifact.ffmpeg_member, + ffprobe_member=artifact.ffprobe_member, + license_member=artifact.license_member, + ) + fr.PINNED_ARTIFACTS[FAKE_TARGET] = tampered + + with pytest.raises(fr.FFmpegProvisionError, match="bin/ffmpeg"): + fr.install(target=FAKE_TARGET) + assert not (fr.runtime_root() / tampered.build_id).exists() + assert not fr.receipt_path().exists() + + +def test_an_unpinned_executable_member_is_refused(synthetic) -> None: + _, members, served = synthetic + smuggled = dict(members) + smuggled["bin/extra-tool"] = b"smuggled\n" + served["bytes"] = _build_archive(smuggled) + artifact = fr.PINNED_ARTIFACTS[FAKE_TARGET] + fr.PINNED_ARTIFACTS[FAKE_TARGET] = fr.PinnedArtifact( + target=FAKE_TARGET, + archive_sha256=hashlib.sha256(served["bytes"]).hexdigest(), + archive_max_bytes=len(served["bytes"]) + 64, + files=artifact.files, + ffmpeg_member=artifact.ffmpeg_member, + ffprobe_member=artifact.ffprobe_member, + license_member=artifact.license_member, + ) + with pytest.raises(fr.FFmpegProvisionError, match="unpinned executable member"): + fr.install(target=FAKE_TARGET) + + +def test_an_oversized_member_is_refused(synthetic) -> None: + artifact, members, served = synthetic + fat = dict(members) + fat["bin/ffmpeg"] = b"x" * 4096 + served["bytes"] = _build_archive(fat) + fr.PINNED_ARTIFACTS[FAKE_TARGET] = fr.PinnedArtifact( + target=FAKE_TARGET, + archive_sha256=hashlib.sha256(served["bytes"]).hexdigest(), + archive_max_bytes=len(served["bytes"]) + 64, + files=artifact.files, + ffmpeg_member=artifact.ffmpeg_member, + ffprobe_member=artifact.ffprobe_member, + license_member=artifact.license_member, + ) + with pytest.raises(fr.FFmpegProvisionError, match="bounded regular file|pinned bound"): + fr.install(target=FAKE_TARGET) + + +def test_uninstall_removes_the_runtime(synthetic) -> None: + fr.install(target=FAKE_TARGET) + assert fr.find_installed_runtime() is not None + assert fr.uninstall() is True + assert fr.find_installed_runtime() is None + assert fr.uninstall() is False + + +def test_a_receipt_pointing_outside_the_runtime_root_is_ignored( + synthetic, + tmp_path: Path, +) -> None: + fr.install(target=FAKE_TARGET) + outsider = tmp_path / "elsewhere" / "ffmpeg" + outsider.parent.mkdir(parents=True) + outsider.write_text("#!/bin/sh\n") + receipt = json.loads(fr.receipt_path().read_text()) + receipt["executables"]["ffmpeg"] = str(outsider) + fr.receipt_path().write_text(json.dumps(receipt)) + assert fr.find_installed_runtime() is None + + +def test_a_receipt_with_an_unknown_schema_is_ignored(synthetic) -> None: + fr.install(target=FAKE_TARGET) + receipt = json.loads(fr.receipt_path().read_text()) + receipt["schema"] = "something.else/v9" + fr.receipt_path().write_text(json.dumps(receipt)) + assert fr.read_receipt() is None + assert fr.find_installed_runtime() is None + + +# -------------------------------------------------------------------------- +# The download is opt-in and stays on the pinned host +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "http://github.com/OpenAdaptAI/openadapt-desktop/x.zip", + "https://example.invalid/openadapt-ffmpeg.zip", + "file:///etc/passwd", + "https://github.com.attacker.invalid/x.zip", + ], +) +def test_download_refuses_an_unpinned_url(url: str, tmp_path: Path) -> None: + with pytest.raises(fr.FFmpegProvisionError, match="Refusing to download"): + fr._download_to(url, tmp_path / "out.zip", 1024) + + +def test_nothing_downloads_without_the_command(monkeypatch: pytest.MonkeyPatch) -> None: + """Importing or resolving must never reach the network.""" + + def explode(*args, **kwargs): # type: ignore[no-untyped-def] + raise AssertionError("resolution attempted a network call") + + monkeypatch.setattr(fr, "_download_to", explode) + monkeypatch.setenv("OPENADAPT_CAPTURE_DATA_DIR", "/nonexistent-capture-data") + assert fr.find_installed_runtime() is None + assert fr.read_receipt() is None + # plan() only describes; it never fetches. + assert fr.plan("aarch64-apple-darwin")["archive_sha256"] + + +def test_plan_describes_the_exact_artifact_without_fetching() -> None: + planned = fr.plan("x86_64-unknown-linux-gnu") + artifact = fr.PINNED_ARTIFACTS["x86_64-unknown-linux-gnu"] + assert planned["url"] == artifact.url + assert planned["archive_sha256"] == artifact.archive_sha256 + assert planned["license"] == fr.LICENSE_EXPRESSION + assert planned["source_url"] == fr.SOURCE_URL + + +# -------------------------------------------------------------------------- +# Resolution precedence is unchanged; the install is only a last fallback +# -------------------------------------------------------------------------- + + +def _install_and_clear_env(monkeypatch: pytest.MonkeyPatch) -> str: + for name in ( + "OPENADAPT_FFMPEG_PATH", + "OPENADAPT_FFPROBE_PATH", + "OPENADAPT_DESKTOP_FFMPEG_PATH", + ): + monkeypatch.delenv(name, raising=False) + installed = fr.install(target=FAKE_TARGET) + return installed.ffmpeg + + +def test_managed_runtime_is_used_only_when_nothing_else_resolves( + synthetic, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from openadapt_capture import video + + managed = _install_and_clear_env(monkeypatch) + monkeypatch.setattr(video, "_desktop_data_dirs", lambda: []) + monkeypatch.setattr(video.shutil, "which", lambda name: None) + + provision = video.resolve_ffmpeg() + assert provision.executable == managed + assert provision.source == "capture install-ffmpeg" + + +def test_path_still_outranks_the_managed_runtime( + synthetic, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from openadapt_capture import video + + _install_and_clear_env(monkeypatch) + monkeypatch.setattr(video, "_desktop_data_dirs", lambda: []) + on_path = tmp_path / "path-ffmpeg" + on_path.write_text("#!/bin/sh\n") + monkeypatch.setattr( + video.shutil, + "which", + lambda name: str(on_path) if name == "ffmpeg" else None, + ) + + provision = video.resolve_ffmpeg() + assert provision.executable == str(on_path.resolve()) + assert provision.source == "PATH" + + +def test_explicit_path_still_outranks_the_managed_runtime( + synthetic, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from openadapt_capture import video + + _install_and_clear_env(monkeypatch) + explicit = tmp_path / "explicit-ffmpeg" + explicit.write_text("#!/bin/sh\n") + monkeypatch.setenv("OPENADAPT_FFMPEG_PATH", str(explicit)) + + provision = video.resolve_ffmpeg() + assert provision.executable == str(explicit.resolve()) + assert provision.source == "explicit path" + + +def test_the_missing_ffmpeg_error_names_one_command(monkeypatch: pytest.MonkeyPatch) -> None: + from openadapt_capture import video + + for name in ( + "OPENADAPT_FFMPEG_PATH", + "OPENADAPT_FFPROBE_PATH", + "OPENADAPT_DESKTOP_FFMPEG_PATH", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OPENADAPT_CAPTURE_DATA_DIR", "/nonexistent-capture-data") + monkeypatch.setattr(video, "_desktop_data_dirs", lambda: []) + monkeypatch.setattr(video.shutil, "which", lambda name: None) + + with pytest.raises(video.FFmpegUnavailableError) as excinfo: + video.resolve_ffmpeg() + message = str(excinfo.value) + assert "capture install-ffmpeg" in message + # One command, offered first. The old message listed four mechanisms. + assert message.index("capture install-ffmpeg") < message.index("OPENADAPT_FFMPEG_PATH") + + +def test_the_cli_exposes_the_command() -> None: + from openadapt_capture import cli + + assert callable(cli.install_ffmpeg) + assert callable(cli.uninstall_ffmpeg) + source = Path(cli.__file__).read_text() + assert '"install-ffmpeg": install_ffmpeg' in source + + +# -------------------------------------------------------------------------- +# The release gate: no FFmpeg bytes reach a built archive, under any name +# -------------------------------------------------------------------------- + + +def _wheel_with(tmp_path: Path, extra: dict[str, bytes]) -> Path: + wheel = tmp_path / "openadapt_capture-1.2.3-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for name in sorted(REQUIRED_OBSERVER_PATHS): + archive.writestr(name, "") + archive.writestr("openadapt_capture-1.2.3.dist-info/licenses/LICENSE", "MIT\n") + archive.writestr( + "openadapt_capture-1.2.3.dist-info/METADATA", + "Name: openadapt-capture\nProvides-Extra: linux\n" + "Requires-Dist: pygobject<3.50,>=3.46; sys_platform == 'linux' " + "and extra == 'linux'\n", + ) + for name, content in extra.items(): + archive.writestr(name, content) + return wheel + + +@pytest.mark.parametrize( + ("name", "content", "match"), + [ + # An honestly named binary. + ("openadapt_capture/bin/ffmpeg", b"\x7fELF\x02\x01", "external-process boundary"), + # An honestly named binary with its magic bytes stripped. + ("openadapt_capture/bin/ffprobe", b"\x00opaque\x00", "external-process boundary"), + # The same binary renamed, which a name-only gate would miss. + ("openadapt_capture/data/helper.dat", b"\x7fELF\x02\x01", "native executable"), + ("openadapt_capture/data/helper.dat", b"\xcf\xfa\xed\xfe\x0c", "native executable"), + ("openadapt_capture/data/helper.dat", b"MZ\x90\x00\x03", "native executable"), + # A shared library lifted out of an FFmpeg build. + ("openadapt_capture/lib/libavcodec.62.dylib", b"\xca\xfe\xba\xbe", "boundary"), + # A container hiding one from a per-member scan. + ("openadapt_capture/data/tools.zip", b"PK\x03\x04\x14\x00", "nested archive"), + ("openadapt_capture/data/tools.tar.gz", b"\x1f\x8b\x08\x00", "nested archive"), + # FFmpeg build bytes inside a file with no telling name or magic. + ( + "openadapt_capture/data/blob.dat", + b"\x00\x01ffmpeg version 8.1.2 Copyright\x00", + "FFmpeg build bytes", + ), + ( + "openadapt_capture/data/blob.dat", + b"\x00libavformat/mov.c\x00", + "FFmpeg build bytes", + ), + ], +) +def test_release_gate_refuses_ffmpeg_bytes( + tmp_path: Path, + name: str, + content: bytes, + match: str, +) -> None: + wheel = _wheel_with(tmp_path, {name: content}) + with pytest.raises(AssertionError, match=match): + verify_distribution(wheel) + + +@pytest.mark.parametrize( + ("name", "content"), + [ + # The README documents the FFmpeg build, and its text reaches METADATA. + ( + "openadapt_capture-1.2.3.dist-info/METADATA.extra", + b"configured with --disable-gpl, so it stays LGPL\n", + ), + # Documentation may name libavcodec without shipping it. + ("openadapt_capture/notes.txt", b"Capture never links libavcodec.\n"), + ], +) +def test_release_gate_allows_text_that_names_ffmpeg( + tmp_path: Path, + name: str, + content: bytes, +) -> None: + verify_distribution(_wheel_with(tmp_path, {name: content})) + + +def test_release_gate_allows_the_provisioning_source(tmp_path: Path) -> None: + """The module that fetches FFmpeg is text, and its name must stay legal.""" + source = Path(fr.__file__).read_bytes() + wheel = _wheel_with(tmp_path, {"openadapt_capture/ffmpeg_runtime.py": source}) + verify_distribution(wheel) + + +def test_the_built_wheel_and_sdist_carry_the_provisioning_source_only() -> None: + """The shipped package must contain the fetcher and no FFmpeg bytes.""" + dist = Path(__file__).resolve().parent.parent / "dist" + archives = sorted(dist.glob("openadapt_capture-*.whl")) + sorted( + dist.glob("openadapt_capture-*.tar.gz") + ) + if not archives: + pytest.skip("no built distributions; the package-contract job builds them") + for archive in archives: + verify_distribution(archive) + names = set(_archive_files(archive)) + assert any(name.endswith("openadapt_capture/ffmpeg_runtime.py") for name in names) + + +def test_the_release_gate_runs_on_every_pull_request_and_every_release() -> None: + """A gate nobody runs is not a gate.""" + workflows = Path(__file__).resolve().parent.parent / ".github/workflows" + command = "python scripts/verify_distribution.py dist/*" + tests = (workflows / "test.yml").read_text(encoding="utf-8") + release = (workflows / "release.yml").read_text(encoding="utf-8") + assert command in tests, "package-contract must verify the built archives" + assert release.count(command) >= 2, "both release paths must verify the archives" + + +def test_the_pin_is_checked_when_it_changes_and_on_a_schedule() -> None: + workflow = ( + Path(__file__).resolve().parent.parent / ".github/workflows/ffmpeg-pin.yml" + ).read_text(encoding="utf-8") + assert "openadapt_capture/ffmpeg_runtime.py" in workflow + assert "scripts/check_ffmpeg_pin.py" in workflow + assert "schedule:" in workflow + assert "capture install-ffmpeg" in workflow