diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 674102f..10b0e38 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `166e10b7084c977816a75560d015da8f350c534c`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `50b3c135619d2d32304d515aa76903c6cb062eab`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index d98de3d..009a748 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "166e10b7084c977816a75560d015da8f350c534c", + "sourceRevision": "50b3c135619d2d32304d515aa76903c6cb062eab", "files": [ { "path": ".dockerignore", @@ -1659,8 +1659,8 @@ }, { "path": "backend/tests/test_runner_fleet_specialize.py", - "sha256": "e37f114322da2182d8448439025abe2ad772564332cff5955e93cc43d28cf45b", - "bytes": 188449 + "sha256": "da0b0099ab3d8b5e651db0d69aa6470e67be911ec6f2814216c05889ad5f8065", + "bytes": 195475 }, { "path": "backend/tests/test_runner_fleet_v2_execution_surface.py", diff --git a/backend/tests/test_runner_fleet_specialize.py b/backend/tests/test_runner_fleet_specialize.py index 8e08c05..549c044 100644 --- a/backend/tests/test_runner_fleet_specialize.py +++ b/backend/tests/test_runner_fleet_specialize.py @@ -7,6 +7,7 @@ import importlib.util import io import json import os +import stat import subprocess import sys import tarfile @@ -145,6 +146,162 @@ def _fixture_rollback_maintenance(): yield lambda: None +@pytest.fixture +def task_archive_source(specialize, tmp_path, monkeypatch): + state = tmp_path / "fleet-state" + state.mkdir(mode=0o700) + state.chmod(0o700) + workdir = state / ".specialization-images-reviewed" + workdir.mkdir(mode=0o700) + workdir.chmod(0o700) + archive = workdir / "task-images.tar" + payload = b"verified archive bytes\x00" * 1024 + archive.write_bytes(payload) + archive.chmod(0o600) + manifest = _valid_task_image_manifest(specialize) + manifest.update(dockerTarBytes=len(payload), dockerTarSha256=hashlib.sha256(payload).hexdigest()) + monkeypatch.setattr(specialize, "SPECIALIZED_STATE_ROOT", state) + return archive, manifest, payload + + +@pytest.mark.parametrize("outcome", ["success", "nonzero", "timeout", "verify_failed", "source_changed"]) +def test_image_load_streams_exact_fd_over_unix_without_temporary_container_file( + specialize, task_archive_source, monkeypatch, outcome, +): + archive, manifest, payload = task_archive_source + calls = [] + checks = [] + + # File ownership is separately qualified under real POSIX metadata below. + @contextmanager + def opened(path, value): + assert path == archive and value is manifest + with archive.open("rb") as stream: + def current(): + checks.append(True) + if outcome == "source_changed" and len(checks) > 1: + raise specialize.RunnerFleetSpecializeError("runner_fleet_specialization_image_archive_source_untrusted") + yield stream, current + current() + + def run(args, **kwargs): + calls.append(args) + assert args == ["docker", "exec", "-i", "-e", + f"DOCKER_HOST={specialize._DIND_DOCKER_HOST}", + specialize.DIND_NAME, "docker", "image", "load"] + assert kwargs["stdin"].tell() == 0 + assert kwargs["stdin"].read() == payload + assert "input" not in kwargs and "shell" not in kwargs + assert kwargs["stdout"] == kwargs["stderr"] == subprocess.DEVNULL + assert kwargs["timeout"] == 900 and kwargs["check"] is False + if outcome == "timeout": + raise subprocess.TimeoutExpired(args, 900, stderr="secret must stay redacted") + return SimpleNamespace(returncode=1 if outcome == "nonzero" else 0) + + verified = [] + monkeypatch.setattr(specialize, "_open_verified_task_archive", opened) + monkeypatch.setattr(specialize.subprocess, "run", run) + monkeypatch.setattr(specialize, "_run", lambda *_a, **_k: pytest.fail("no cp, temp or rm command allowed")) + monkeypatch.setattr(specialize, "_verify_manifest_images_in_dind", + lambda images: verified.append(images) or outcome != "verify_failed") + if outcome == "success": + specialize._load_images_into_dind(archive, manifest) + assert verified == [manifest["images"]] + else: + with pytest.raises(specialize.RunnerFleetSpecializeError) as error: + specialize._load_images_into_dind(archive, manifest) + assert "secret" not in str(error.value) + assert bool(verified) is (outcome == "verify_failed") + assert len(calls) == 1 + + +@pytest.mark.parametrize("directory", [False, True]) +@pytest.mark.parametrize("fault", [None, "uid", "gid", "type", "mode", "special"]) +def test_task_archive_identity_requires_exact_private_root_metadata(specialize, directory, fault): + values = dict(st_mode=(stat.S_IFDIR | 0o700) if directory else (stat.S_IFREG | 0o600), + st_uid=0, st_gid=0, st_nlink=2 if directory else 1, + st_dev=1, st_ino=2, st_size=123, st_mtime_ns=4, st_ctime_ns=5) + if fault in {"uid", "gid"}: + values[f"st_{fault}"] = 1000 + elif fault == "type": + values["st_mode"] = stat.S_IFLNK | 0o700 + elif fault == "mode": + values["st_mode"] |= 0o010 + elif fault == "special": + values["st_mode"] |= stat.S_ISUID + if fault is None: + assert specialize._task_archive_identity(SimpleNamespace(**values), directory=directory) + else: + with pytest.raises(specialize.RunnerFleetSpecializeError, match="source_untrusted"): + specialize._task_archive_identity(SimpleNamespace(**values), directory=directory) + + +def test_task_archive_identity_rejects_multiple_links(specialize): + info = SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=0, st_gid=0, st_nlink=2) + with pytest.raises(specialize.RunnerFleetSpecializeError, match="source_untrusted"): + specialize._task_archive_identity(info) + + +@pytest.mark.skipif(os.name != "posix" or os.geteuid() != 0, reason="requires POSIX root ownership") +@pytest.mark.parametrize("fault", [None, "missing", "symlink", "directory", "hardlink", "file_mode", + "parent_mode", "owner", "parent_owner", "size", "digest", "foreign_path", "replace", "changed_fd"]) +def test_source_image_archive_is_verified_and_same_fd_pinned_before_any_load( + specialize, task_archive_source, monkeypatch, fault, +): + archive, manifest, payload = task_archive_source + if fault == "missing": + archive.unlink() + elif fault == "symlink": + original = archive.with_name("owned-original") + archive.rename(original) + archive.symlink_to(original) + elif fault == "directory": + archive.unlink() + archive.mkdir(mode=0o600) + elif fault == "hardlink": + os.link(archive, archive.with_name("extra-link")) + elif fault == "file_mode": + archive.chmod(0o640) + elif fault == "parent_mode": + archive.parent.chmod(0o710) + elif fault == "owner": + os.chown(archive, 1000, 0) + elif fault == "parent_owner": + os.chown(archive.parent, 1000, 0) + elif fault == "size": + manifest["dockerTarBytes"] += 1 + elif fault == "digest": + manifest["dockerTarSha256"] = "e" * 64 + elif fault == "foreign_path": + archive = archive.with_name("foreign.tar") + loaded = [] + + def run(args, **kwargs): + loaded.append(args) + assert kwargs["stdin"].read() == payload + if fault == "replace": + replacement = archive.with_name("replacement") + replacement.write_bytes(payload) + replacement.chmod(0o600) + replacement.replace(archive) + elif fault == "changed_fd": + with archive.open("r+b") as writer: + writer.write(b"X") + return SimpleNamespace(returncode=0) + + verified = [] + monkeypatch.setattr(specialize.subprocess, "run", run) + monkeypatch.setattr(specialize, "_verify_manifest_images_in_dind", lambda images: verified.append(images) or True) + if fault is None: + specialize._load_images_into_dind(archive, manifest) + assert len(loaded) == 1 and verified == [manifest["images"]] + else: + with pytest.raises(specialize.RunnerFleetSpecializeError): + specialize._load_images_into_dind(archive, manifest) + assert len(loaded) == (1 if fault in {"replace", "changed_fd"} else 0) + assert verified == [] + + def _valid_task_image_manifest(specialize, revision="a" * 40, tree="b" * 40): source = "https://gitea.example.com/Jens/Unraid-AppOps-Gateway" return {