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 f5bdc9f..a503cb4 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