from __future__ import annotations import importlib.util import os import subprocess from pathlib import Path from types import SimpleNamespace import pytest MODULE = ( Path(__file__).resolve().parents[2] / "ops/gitea-deploy-broker/registered_build_context.py" ) def load(): spec = importlib.util.spec_from_file_location( "registered_build_context_test", MODULE ) assert spec and spec.loader module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def git(path, *args): return ( subprocess.run( ["git", "-C", str(path), *args], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) .stdout.decode() .strip() ) @pytest.fixture def inputs(tmp_path): checkout = tmp_path / "working" checkout.mkdir() git(checkout, "init", "-q") git(checkout, "config", "user.email", "test@example.invalid") git(checkout, "config", "user.name", "Test") (checkout / "server").mkdir() (checkout / "server/Dockerfile").write_text("FROM scratch\nCOPY . /app\n") (checkout / "server/main.py").write_text("print('protected')\n") (checkout / "run.sh").write_text("#!/bin/sh\nexit 0\n") (checkout / "run.sh").chmod(0o755) git(checkout, "add", ".") git(checkout, "update-index", "--chmod=+x", "run.sh") git(checkout, "commit", "-qm", "protected source") revision = git(checkout, "rev-parse", "HEAD") bare = tmp_path / "repo.git" subprocess.run( ["git", "clone", "--bare", "--quiet", str(checkout), str(bare)], check=True ) live = tmp_path / "live" live.mkdir() (live / "server").mkdir() (live / "server/stale.py").write_text("untracked") (live / ".env").write_text("PRIVATE_VALUE=do-not-copy") project = {"slug": "quiz-monitor", "source_dir": str(live), "compose_dir": ""} model = { "services": { "quiz-server": { "build": {"context": str(live), "dockerfile": "server/Dockerfile"} } } } return bare, revision, project, model, tmp_path / "state", live, checkout def prepare(module, inputs): return module.prepare_build_contexts(*inputs[:5]) def test_exact_archive_omits_live_untracked_files_and_preserves_live_tree(inputs): result = prepare(load(), inputs) tree = Path(result["quiz-server"]) assert tree.is_absolute() assert (tree / "server/main.py").read_text() == "print('protected')\n" assert not (tree / "server/stale.py").exists() assert not (tree / ".env").exists() assert (inputs[5] / "server/stale.py").read_text() == "untracked" assert (inputs[5] / ".env").read_text() == "PRIVATE_VALUE=do-not-copy" assert (tree.parent / "manifest.json").is_file() assert not (tree / "manifest.json").exists() assert prepare(load(), inputs) == result if os.name != "nt": assert tree.parent.stat().st_mode & 0o777 == 0o700 assert (tree / "run.sh").stat().st_mode & 0o777 == 0o755 @pytest.mark.parametrize("change", ["content", "extra", "missing", "mode", "manifest"]) def test_cached_context_drift_fails_without_replacing_source(inputs, change): module = load() tree = Path(prepare(module, inputs)["quiz-server"]) if change == "content": (tree / "server/main.py").write_text("tampered") elif change == "extra": (tree / "untracked.py").write_text("tampered") elif change == "missing": (tree / "server/main.py").unlink() elif change == "mode": if os.name == "nt": pytest.skip("POSIX permissions are verified on Linux") (tree / "run.sh").chmod(0o644) else: (tree.parent / "manifest.json").write_text("{}") with pytest.raises(module.RegisteredBuildContextError): prepare(module, inputs) assert (inputs[5] / "server/stale.py").is_file() @pytest.mark.parametrize( "build", [ {"context": "../outside", "dockerfile": "Dockerfile"}, {"context": "https://example.invalid/source.git"}, {"context": ".", "dockerfile": "../outside/Dockerfile"}, {"context": ".", "dockerfile": "not-tracked"}, {"context": ".", "additional_contexts": {"other": "/elsewhere"}}, {"context": ".", "dockerfile_inline": "FROM scratch"}, ], ) def test_external_or_untracked_build_inputs_are_rejected(inputs, build): module = load() inputs[3]["services"]["quiz-server"]["build"] = build with pytest.raises(module.RegisteredBuildContextError): prepare(module, inputs) def test_subdirectory_context_is_remapped_with_exact_revision(inputs): inputs[3]["services"]["quiz-server"]["build"] = { "context": "server", "dockerfile": "Dockerfile", } result = prepare(load(), inputs) assert Path(result["quiz-server"]).name == "server" assert (Path(result["quiz-server"]) / "main.py").is_file() def test_revision_is_not_a_branch_and_old_archive_survives_new_commit(inputs): module = load() result = prepare(module, inputs) inputs[6].joinpath("server/main.py").write_text("changed main") git(inputs[6], "commit", "-qam", "advance main") git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") assert prepare(module, inputs) == result assert ( Path(result["quiz-server"]).joinpath("server/main.py").read_text() == "print('protected')\n" ) with pytest.raises(module.RegisteredBuildContextError): module.prepare_build_contexts(inputs[0], "main", *inputs[2:5]) @pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary") def test_symlinked_live_context_or_archived_file_is_rejected(inputs): module = load() (inputs[5] / "link").symlink_to(inputs[5] / "server", target_is_directory=True) inputs[3]["services"]["quiz-server"]["build"] = { "context": "link", "dockerfile": "Dockerfile", } with pytest.raises(module.RegisteredBuildContextError): prepare(module, inputs) inputs[6].joinpath("tracked-link").symlink_to("server/main.py") git(inputs[6], "add", "tracked-link") git(inputs[6], "commit", "-qm", "unsupported tracked symlink") revision = git(inputs[6], "rev-parse", "HEAD") git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") inputs[3]["services"]["quiz-server"]["build"] = { "context": ".", "dockerfile": "server/Dockerfile", } with pytest.raises(module.RegisteredBuildContextError): module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) @pytest.mark.parametrize( "field", ["secrets", "ssh", "additional_contexts", "dockerfile_inline"] ) def test_build_cannot_request_host_credentials(inputs, field): module = load() inputs[3]["services"]["quiz-server"]["build"][field] = ["default"] with pytest.raises(module.RegisteredBuildContextError): prepare(module, inputs) def test_tracked_runtime_env_is_rejected_before_cache_creation(inputs): module = load() inputs[6].joinpath(".env").write_text("PRIVATE_CONFIG=value") git(inputs[6], "add", ".env") git(inputs[6], "commit", "-qm", "unsupported runtime env") revision = git(inputs[6], "rev-parse", "HEAD") git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") with pytest.raises(module.RegisteredBuildContextError): module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) assert not inputs[4].exists() def test_git_export_attributes_cannot_change_exact_archive_bytes(inputs): module = load() inputs[6].joinpath(".gitattributes").write_text("server/main.py export-ignore\n") git(inputs[6], "add", ".gitattributes") git(inputs[6], "commit", "-qm", "unsupported export omission") revision = git(inputs[6], "rev-parse", "HEAD") git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") with pytest.raises(module.RegisteredBuildContextError): module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) assert not inputs[4].joinpath("registered-builds/quiz-monitor", revision).exists() @pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary") def test_cached_directory_link_is_rejected_without_touching_link_target(inputs): module = load() tree = Path(prepare(module, inputs)["quiz-server"]) tree.rename(tree.parent / "original") tree.symlink_to(inputs[5], target_is_directory=True) with pytest.raises(module.RegisteredBuildContextError): prepare(module, inputs) assert (inputs[5] / ".env").is_file() def test_tree_size_bound_prevents_archive_execution(inputs, monkeypatch): module = load() monkeypatch.setattr(module, "MAX_BYTES", 1) with pytest.raises(module.RegisteredBuildContextError, match="bound_exceeded"): prepare(module, inputs) assert not inputs[4].exists() @pytest.mark.skipif( os.name == "nt" or os.getuid() != 0, reason="observed Linux read-only Gitea mount ownership", ) def test_gitea_owned_readonly_repository_uses_exact_safe_directory(inputs, monkeypatch): module = load() os.chown(inputs[0], 1000, 1000) commands = [] original = module.subprocess.Popen def spy(command, **kwargs): commands.append(command) return original(command, **kwargs) monkeypatch.setattr(module.subprocess, "Popen", spy) result = prepare(module, inputs) assert Path(result["quiz-server"]).joinpath("server/main.py").is_file() assert commands assert all( command[:4] == ["git", "--no-replace-objects", "-c", f"safe.directory={inputs[0]}"] for command in commands ) @pytest.mark.skipif( os.name == "nt" or os.getuid() != 0, reason="Linux repository UID boundary" ) @pytest.mark.parametrize("owner,mode", [(1001, 0o755), (1000, 0o775), (0, 0o777)]) def test_unexpected_or_writable_repository_is_rejected(inputs, owner, mode): module = load() os.chown(inputs[0], owner, owner) inputs[0].chmod(mode) with pytest.raises(module.RegisteredBuildContextError): prepare(module, inputs) assert not inputs[4].exists() def test_windows_path_identity_uses_creation_time_not_inconsistent_ctime(monkeypatch): module = load() monkeypatch.setattr(module, "os", SimpleNamespace(name="nt")) descriptor = SimpleNamespace( st_dev=1, st_ino=2, st_size=3, st_mtime_ns=40, st_ctime_ns=40, st_birthtime_ns=30, ) named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 30}) assert module._path_identity(descriptor) == module._path_identity(named) named.st_ino = 9 assert module._path_identity(descriptor) != module._path_identity(named) def test_posix_path_identity_preserves_change_time(monkeypatch): module = load() monkeypatch.setattr(module, "os", SimpleNamespace(name="posix")) descriptor = SimpleNamespace( st_dev=1, st_ino=2, st_size=3, st_mtime_ns=40, st_ctime_ns=40, ) named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 41}) assert module._path_identity(descriptor) != module._path_identity(named) def test_descriptor_metadata_changed_during_read_is_rejected(tmp_path, monkeypatch): module = load() path = tmp_path / "tracked" raw = b"tracked bytes" path.write_bytes(raw) path.chmod(0o644) original = module.os.fstat calls = 0 def changed(descriptor): nonlocal calls calls += 1 info = original(descriptor) if calls == 1: return info fields = { name: getattr(info, name) for name in dir(info) if name.startswith("st_") } fields["st_ctime_ns"] += 1 return SimpleNamespace(**fields) monkeypatch.setattr(module.os, "fstat", changed) with pytest.raises(module.RegisteredBuildContextError, match="file_changed"): module._file( path, {"size": len(raw), "oid": module._blob_digest(raw), "mode": 0o644} ) def test_git_replacement_refs_cannot_substitute_other_commit_contents(inputs): checkout, bare, protected = inputs[6], inputs[0], inputs[1] checkout.joinpath("server/main.py").write_text("replacement commit bytes\n") git(checkout, "commit", "-qam", "replacement commit") other = git(checkout, "rev-parse", "HEAD") git(bare, "fetch", "--quiet", str(checkout), "HEAD") git(bare, "replace", protected, other) assert ( git(bare, "show", f"{protected}:server/main.py") == "replacement commit bytes" ) tree = Path(prepare(load(), inputs)["quiz-server"]) assert tree.joinpath("server/main.py").read_text() == "print('protected')\n" @pytest.mark.parametrize( "name", ["C:/outside.txt", "D:outside.txt", "server/file:stream"] ) def test_git_manifest_rejects_windows_drive_and_stream_paths_before_extraction( tmp_path, monkeypatch, name ): module = load() revision, tree, blob = "a" * 40, "b" * 40, "c" * 40 responses = iter( [ f"{revision}\n".encode(), f"{tree}\n".encode(), f"100644 blob {blob} 1\t{name}\0".encode(), ] ) monkeypatch.setattr(module, "_git_output", lambda *_args: next(responses)) with pytest.raises(module.RegisteredBuildContextError, match="path_invalid"): module._manifest(tmp_path, revision) assert not list(tmp_path.iterdir())