Files
govoplan/tests/test_full_registry_catalog.py
T

467 lines
24 KiB
Python

from __future__ import annotations
import base64
from contextlib import ExitStack
import csv
from copy import deepcopy
import hashlib
import io
import json
import os
from pathlib import Path
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from urllib.parse import quote
import zipfile
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools/release"))
from govoplan_release import full_catalog # noqa: E402
from govoplan_release.artifact_identity import selected_artifact_identity_issues # noqa: E402
from govoplan_release.catalog import canonical_hash # noqa: E402
from govoplan_release.model import RepositorySpec # noqa: E402
from govoplan_release.registry_reference import registry_entry_source # noqa: E402
from govoplan_release.selective_catalog import ( # noqa: E402
load_authenticated_catalog_base, public_key_base64, signature,
)
from govoplan_release.source_provenance import ( # noqa: E402
SourceTagProvenanceIssue, catalog_source_selection,
registered_source_origin_issues,
)
from govoplan_release.version_alignment import candidate_catalog_version_issues # noqa: E402
class FullRegistryCatalogTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.web = self.root / "addideas-govoplan-website"
self.wheels = self.root / "wheels"
self.npm = self.root / "npm"
self.wheels.mkdir(mode=0o700)
self.npm.mkdir(mode=0o700)
self.key = Ed25519PrivateKey.generate()
self.keypath = self.root / "key.pem"
self.keypath.write_bytes(self.key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
self.keypath.chmod(0o600)
self.keyring = {
"keyring_version": "1", "keys": [{
"key_id": "known-key", "public_key": public_key_base64(self.key),
"status": "active",
}],
}
self.base = {
"catalog_version": "1", "channel": "stable", "sequence": 1,
"core_release": {"version": "1.0.0"}, "modules": [],
"release": {},
}
self.write_base()
self.package_set = {
"schema_version": "1", "release_version": "1.2.3", "profile": "full",
"registries": {
"python": "https://git.add-ideas.de/api/packages/GovOPlaN/pypi/simple",
"npm": "https://git.add-ideas.de/api/packages/GovOPlaN/npm/",
},
"python": [self.package("govoplan-core"), self.package("govoplan-demo")],
"webui": [self.package("govoplan-core", webui=True)],
}
self.seal(self.package_set, "package_set_sha256")
self.lock = {
"schema_version": "1", "release_version": "1.2.3", "profile": "full",
"registries": self.package_set["registries"],
"package_set_sha256": self.package_set["package_set_sha256"],
"python": [], "webui": [],
}
for row in self.package_set["python"]:
path = self.wheel(row["name"])
url = full_catalog._tool("resolve-package-artifacts")["_python_artifact_url"](
self.package_set["registries"]["python"], package=row, filename=path.name,
)
self.lock["python"].append(self.artifact(row, path, url))
npm_package = self.package_set["webui"][0]
npm_path = self.npm / "govoplan-core-webui-1.2.3.tgz"
self.tarball(npm_path, "@govoplan/core-webui", "1.2.3")
url = self.package_set["registries"]["npm"] + quote(npm_package["name"], safe="") + "/-/1.2.3/core-webui-1.2.3.tgz"
row = self.artifact(npm_package, npm_path, url)
row["integrity"] = "sha512-" + base64.b64encode(hashlib.sha512(npm_path.read_bytes()).digest()).decode()
self.lock["webui"].append(row)
self.seal(self.lock, "lock_sha256")
self.set_path = self.root / "package-set.json"
self.lock_path = self.root / "package-lock.json"
self.write_inputs()
self.output = self.root / "candidate"
@staticmethod
def package(repo: str, *, webui: bool = False) -> dict:
row = {
"name": "@govoplan/core-webui" if webui else repo, "version": "1.2.3",
"repository": repo, "tag": "v1.2.3",
"commit": ("a" if repo == "govoplan-core" else "b") * 40,
}
if not webui:
row["extras"] = ["server"] if repo == "govoplan-core" else []
return row
@staticmethod
def artifact(package: dict, path: Path, url: str) -> dict:
encoded = path.read_bytes()
return {**package, "filename": path.name, "url": url,
"sha256": hashlib.sha256(encoded).hexdigest(), "size": len(encoded)}
@staticmethod
def seal(payload: dict, field: str) -> None:
payload.pop(field, None)
payload[field] = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def write_inputs(self) -> None:
self.set_path.write_text(json.dumps(self.package_set))
self.lock_path.write_text(json.dumps(self.lock))
def write_base(self) -> None:
self.base.pop("signatures", None)
self.base["signatures"] = [signature(self.base, key_id="known-key", private_key=self.key)]
folder = self.web / "public/catalogs/v1"
(folder / "channels").mkdir(parents=True, exist_ok=True)
(folder / "channels/stable.json").write_text(json.dumps(self.base))
(folder / "keyring.json").write_text(json.dumps(self.keyring))
def wheel(self, package: str) -> Path:
stem = package.replace("-", "_")
info = f"{stem}-1.2.3.dist-info"
files = {
f"{stem}/__init__.py": b"VALUE = 1\n",
f"{info}/METADATA": f"Metadata-Version: 2.1\nName: {package}\nVersion: 1.2.3\n".encode(),
f"{info}/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
}
record = io.StringIO()
writer = csv.writer(record, lineterminator="\n")
for name, value in files.items():
writer.writerow((name, "", len(value)))
writer.writerow((f"{info}/RECORD", "", ""))
files[f"{info}/RECORD"] = record.getvalue().encode()
path = self.wheels / f"{stem}-1.2.3-py3-none-any.whl"
with zipfile.ZipFile(path, "w") as archive:
for name, value in files.items():
archive.writestr(name, value)
return path
@staticmethod
def tarball(path: Path, name: str, version: str, *, duplicate: bool = False) -> None:
encoded = json.dumps({"name": name, "version": version}).encode()
with tarfile.open(path, "w:gz") as archive:
for _ in range(2 if duplicate else 1):
member = tarfile.TarInfo("package/package.json")
member.size = len(encoded)
archive.addfile(member, io.BytesIO(encoded))
def build(self, *, provenance_errors=(), origin_errors=()) -> dict:
registry_generator = full_catalog._tool("generate-release-catalog")
tools = {name: dict(full_catalog._tool(name)) for name in (
"generate-release-catalog", "generate-release-package-set", "resolve-package-artifacts",
)}
tools["generate-release-package-set"]["generate_package_set"] = lambda **kwargs: self.package_set
self.provenance = {
row["repository"]: {"commit_sha": row["commit"], "tag_object_sha": str(index + 1) * 40}
for index, row in enumerate(self.package_set["python"])
}
entry = {
"module_id": "demo", "name": "Demo", "version": "1.2.3",
"python_package": "govoplan-demo",
"python_ref": "govoplan-demo @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-demo.git@v1.2.3",
}
with ExitStack() as stack:
stack.enter_context(patch.object(full_catalog, "_tool", side_effect=lambda name: tools[name]))
stack.enter_context(patch.dict(registry_generator["_catalog_payload"].__globals__, {
"synthesize_repository_catalog_entries": lambda **kwargs: (dict(entry),),
}))
stack.enter_context(patch.object(full_catalog, "enforce_selected_version_alignment"))
stack.enter_context(patch.object(full_catalog, "registered_source_origin_issues", return_value=origin_errors))
self.provenance_check = stack.enter_context(patch.object(full_catalog, "source_tag_provenance_issues", return_value=provenance_errors))
stack.enter_context(patch.object(full_catalog, "selected_source_provenance", return_value=self.provenance))
return full_catalog.build_full_registry_candidate(
package_set_path=self.set_path, package_lock_path=self.lock_path,
wheelhouse=self.wheels, webui_packages=self.npm, output_dir=self.output,
selected_repositories=("govoplan-core",),
signing_keys=(f"known-key={self.keypath}",), workspace_root=self.root,
)
def candidate(self) -> dict:
return json.loads((self.output / "channels/stable.json").read_text())
def test_full_candidate_uses_registry_bytes_and_preserves_unchanged_tag_provenance(self) -> None:
result = self.build()
candidate = self.candidate()
self.assertEqual("ready", result["status"])
self.assertEqual(2, result["package_count"])
self.assertEqual(1, result["selected_count"])
self.assertEqual(self.keyring, json.loads((self.output / "keyring.json").read_text()))
self.assertEqual(canonical_hash(self.keyring), candidate["release"]["keyring_sha256"])
self.assertEqual(2, len(candidate["release"]["artifacts"]))
self.assertIn("/pypi/files/", candidate["core_release"]["python_ref"])
self.assertEqual((), candidate_catalog_version_issues(candidate))
self.assertEqual((), selected_artifact_identity_issues(candidate))
sources = catalog_source_selection(candidate)
self.assertEqual((), sources.issues)
self.assertEqual({"govoplan-core": "1.2.3"}, sources.selected_versions)
self.assertEqual({"govoplan-core": "1.2.3", "govoplan-demo": "1.2.3"}, sources.all_versions)
self.assertEqual("b" * 40, sources.selected_commits["govoplan-demo"])
self.assertEqual("2" * 40, sources.selected_tag_objects["govoplan-demo"])
self.assertEqual(2, self.provenance_check.call_count)
for call in self.provenance_check.call_args_list:
self.assertEqual({"govoplan-core"}, call.kwargs["require_head_repos"])
for path in [self.output, *self.output.rglob("*")]:
self.assertEqual(0o700 if path.is_dir() else 0o600, path.stat().st_mode & 0o777)
def test_legacy_base_is_authenticated_but_remains_rejected_by_selective(self) -> None:
self.build()
with self.assertRaisesRegex(ValueError, "does not pin"):
load_authenticated_catalog_base(
base_catalog=None, base_keyring=None, web_root=self.web,
channel="stable", public_base_url="https://unused.example",
signer_public_keys={"known-key": public_key_base64(self.key)},
)
def test_injected_key_or_mismatched_pinned_keyring_is_rejected(self) -> None:
for mutation in ("extra-key", "bad-hash"):
with self.subTest(mutation=mutation):
if mutation == "extra-key":
self.keyring["keys"].append({"key_id": "injected", "status": "active", "public_key": public_key_base64(Ed25519PrivateKey.generate())})
else:
self.keyring["keys"] = self.keyring["keys"][:1]
self.base["release"]["keyring_sha256"] = "f" * 64
self.write_base()
with self.assertRaises(ValueError):
self.build()
self.assertFalse(self.output.exists())
def test_tampered_base_signature_is_rejected(self) -> None:
path = self.web / "public/catalogs/v1/channels/stable.json"
payload = json.loads(path.read_text())
payload["sequence"] = 999
path.write_text(json.dumps(payload))
with self.assertRaisesRegex(ValueError, "signature verification"):
self.build()
def test_wrong_registry_bytes_and_reused_candidate_fail_closed(self) -> None:
self.build()
original = (self.output / "channels/stable.json").read_bytes()
with self.assertRaisesRegex(ValueError, "must not already exist"):
self.build()
self.assertEqual(original, (self.output / "channels/stable.json").read_bytes())
self.output = self.root / "candidate-2"
wheel = self.wheels / self.lock["python"][0]["filename"]
with wheel.open("ab") as stream:
stream.write(b"tampered")
with self.assertRaisesRegex(ValueError, "bytes differ"):
self.build()
self.assertFalse(self.output.exists())
def test_webui_identity_and_url_changes_are_rejected(self) -> None:
row = self.lock["webui"][0]
original = row["url"]
for url in (
original + "?alternate=true", original.replace("/-/1.2.3/", "/-/9.9.9/"),
original.replace("/npm/", "/npm/../other/"),
original.replace("/npm/", "/npm/%2e%2e/other/"),
original.replace("/npm/", "/npm/%252e%252e/other/"),
):
with self.subTest(url=url):
row["url"] = url
self.seal(self.lock, "lock_sha256")
self.write_inputs()
with self.assertRaisesRegex(ValueError, "URL differs"):
self.build()
def test_duplicate_missing_and_symlinked_artifacts_are_rejected(self) -> None:
row = self.lock["webui"][0]
self.lock["webui"].append(dict(row))
with self.assertRaisesRegex(ValueError, "duplicate/missing"):
full_catalog.verify_registry_artifacts(package_set=self.package_set, lock=self.lock, wheelhouse=self.wheels, webui_packages=self.npm)
self.lock["webui"].pop()
path = self.npm / row["filename"]
moved = self.root / "moved.tgz"
path.rename(moved)
path.symlink_to(moved)
with self.assertRaises(OSError):
full_catalog.verify_registry_artifacts(package_set=self.package_set, lock=self.lock, wheelhouse=self.wheels, webui_packages=self.npm)
def test_archive_metadata_is_bounded_and_not_ambiguous(self) -> None:
path = self.npm / "duplicate.tgz"
self.tarball(path, "@govoplan/core-webui", "1.2.3", duplicate=True)
with self.assertRaisesRegex(ValueError, "duplicate"):
full_catalog._inspect_npm_metadata(path, name="@govoplan/core-webui", version="1.2.3")
def test_origin_or_tag_provenance_failure_prevents_output(self) -> None:
issue = SourceTagProvenanceIssue("govoplan-core", "v1.2.3", "wrong immutable identity")
for kwargs in ({"origin_errors": (issue,)}, {"provenance_errors": (issue,)}):
with self.subTest(kwargs=kwargs), self.assertRaisesRegex(ValueError, "gate failed"):
self.build(**kwargs)
self.assertFalse(self.output.exists())
def test_registered_source_origin_requires_exact_fetch_and_push_targets(self) -> None:
repo = self.root / "govoplan-core"
repo.mkdir()
spec = RepositorySpec("govoplan-core", "system", "kernel", "git@example.test:trusted/core.git", "govoplan-core")
with patch("govoplan_release.source_provenance.load_repository_specs", return_value=(spec,)):
for targets in ((spec.remote, spec.remote), ("git@evil.test:core.git", spec.remote), (spec.remote, "git@evil.test:core.git")):
with self.subTest(targets=targets), patch("govoplan_release.source_provenance.git_text", side_effect=targets):
issues = registered_source_origin_issues(repo_versions={"govoplan-core": "1.2.3"}, workspace=self.root, remote="origin")
self.assertEqual(targets != (spec.remote, spec.remote), bool(issues))
def test_registry_metadata_cannot_cross_wire_webui_or_archive_identity(self) -> None:
self.build()
candidate = self.candidate()
entry = candidate["core_release"]
entry["webui_package"] = "@govoplan/files-webui"
with self.assertRaisesRegex(ValueError, "another source repository"):
registry_entry_source(entry)
candidate = self.candidate()
candidate["release"]["artifacts"][0]["archive_sha256"] = "f" * 64
self.assertIn("matching inspected wheel", " ".join(selected_artifact_identity_issues(candidate)))
def test_registry_version_and_selected_source_identity_must_agree(self) -> None:
self.build()
for field in ("commit_sha", "tag_object_sha"):
candidate = self.candidate()
candidate["release"]["selected_units"][0][field] = "f" * 40
self.assertIn("differs", " ".join(issue.message for issue in catalog_source_selection(candidate).issues))
candidate = self.candidate()
candidate["modules"][0]["artifact_integrity"]["python"]["git_ref"] = "v9.9.9"
self.assertTrue(candidate_catalog_version_issues(candidate))
def test_repeated_module_projections_must_bind_identical_python_and_webui_bytes(self) -> None:
self.build()
for kind in ("python", "webui"):
for reversed_order in (False, True):
with self.subTest(kind=kind, reversed_order=reversed_order):
candidate = self.candidate()
entry = deepcopy(candidate["core_release"])
entry["module_id"] = "another-core-projection"
artifact = entry["artifact_integrity"][kind]
artifact["sha256"] = "f" * 64
if kind == "python":
entry["python_ref"] = artifact["ref"] = entry["python_ref"].split("#sha256=", 1)[0] + "#sha256=" + "f" * 64
if reversed_order:
original = candidate["core_release"]
candidate["core_release"] = entry
entry = original
candidate["modules"].append(entry)
self.assertIn(f"conflicting {kind}", " ".join(selected_artifact_identity_issues(candidate)))
self.assertTrue(candidate_catalog_version_issues(candidate))
candidate = self.candidate()
repeated = deepcopy(candidate["modules"][0])
repeated["module_id"] = "second-demo-projection"
candidate["modules"].append(repeated)
self.assertEqual((), selected_artifact_identity_issues(candidate))
self.assertEqual((), candidate_catalog_version_issues(candidate))
def test_metadata_inspection_uses_the_opened_archive_not_a_replaced_path(self) -> None:
path = self.npm / "original.tgz"
replacement = self.npm / "replacement.tgz"
saved = self.npm / "saved.tgz"
self.tarball(path, "@govoplan/incorrect-webui", "1.2.3")
self.tarball(replacement, "@govoplan/core-webui", "1.2.3")
real_open = tarfile.open
def replace_path(*args, **kwargs):
self.assertIn("fileobj", kwargs)
path.rename(saved)
replacement.rename(path)
return real_open(*args, **kwargs)
with patch("govoplan_release.full_catalog.tarfile.open", side_effect=replace_path):
with self.assertRaisesRegex(ValueError, "metadata differs"):
full_catalog._inspect_npm_metadata(path, name="@govoplan/core-webui", version="1.2.3")
def test_registry_url_provenance_rejects_package_version_and_traversal_mismatch(self) -> None:
self.build()
for old, new in (
("/govoplan-core/1.2.3/", "/govoplan-files/1.2.3/"),
("/govoplan-core/1.2.3/", "/govoplan-core/9.9.9/"),
("/pypi/files/", "/pypi/files/%2e%2e/"),
("/pypi/files/", "/pypi/files/%252e%252e/"),
):
with self.subTest(new=new):
entry = self.candidate()["core_release"]
artifact = entry["artifact_integrity"]["python"]
artifact["url"] = artifact["url"].replace(old, new)
artifact["ref"] = entry["python_ref"] = entry["python_ref"].replace(old, new)
with self.assertRaises(ValueError):
registry_entry_source(entry)
def test_package_set_must_match_fixed_meta_pins_not_just_its_own_hash(self) -> None:
payload = deepcopy(self.package_set)
payload["python"][1]["version"] = "9.9.9"
self.seal(payload, "package_set_sha256")
self.set_path.write_text(json.dumps(payload))
with self.assertRaisesRegex(ValueError, "exact Meta full pins"):
self.build()
self.assertFalse(self.output.exists())
def test_package_set_git_reads_ignore_caller_redirection(self) -> None:
tool = full_catalog._tool("generate-release-package-set")
with patch.dict(os.environ, {"GIT_DIR": "/outside", "GIT_CONFIG_GLOBAL": "/outside/config", "PATH": "/outside/bin"}):
with patch("subprocess.check_output", return_value="a" * 40 + "\n") as execute:
self.assertEqual("a" * 40, tool["_git"](self.root, "rev-parse", "HEAD"))
self.assertEqual("/usr/bin/git", execute.call_args.args[0][0])
self.assertNotIn("GIT_DIR", execute.call_args.kwargs["env"])
self.assertEqual(os.devnull, execute.call_args.kwargs["env"]["GIT_CONFIG_GLOBAL"])
self.assertEqual("/usr/bin:/bin", execute.call_args.kwargs["env"]["PATH"])
def test_unchanged_real_annotated_ancestor_is_valid_but_selecting_it_requires_head(self) -> None:
from tests.test_release_source_provenance import git, git_text, make_repo
from govoplan_release.source_provenance import source_tag_provenance_issues
workspace = self.root / "source-workspace"
workspace.mkdir()
repo, _remote = make_repo(workspace, self.root, "govoplan-access", "1.2.3")
git(repo, "tag", "-a", "v1.2.3", "-m", "reviewed immutable package")
git(repo, "push", "origin", "refs/tags/v1.2.3")
tagged_commit = git_text(repo, "rev-parse", "HEAD")
(repo / "workflow-only.txt").write_text("post-tag workflow repair\n")
git(repo, "add", "workflow-only.txt")
git(repo, "commit", "-m", "repair workflow without replacing package")
git(repo, "push", "origin", "main")
common = {
"repo_versions": {"govoplan-access": "1.2.3"}, "workspace": workspace,
"expected_commits": {"govoplan-access": tagged_commit},
"expected_tag_objects": {"govoplan-access": git_text(repo, "rev-parse", "refs/tags/v1.2.3")},
}
self.assertEqual((), source_tag_provenance_issues(**common))
issues = source_tag_provenance_issues(**common, require_head_repos=("govoplan-access",))
self.assertIn("not selected HEAD", " ".join(issue.message for issue in issues))
def test_tagged_manifest_synthesis_ignores_local_git_replacement_objects(self) -> None:
from tests.test_release_source_provenance import git, git_text, make_repo
from govoplan_release.catalog_entry_synthesis import materialized_source_tree
workspace = self.root / "materialization-workspace"
workspace.mkdir()
repo, _remote = make_repo(workspace, self.root, "govoplan-access", "1.2.3")
original = (repo / "pyproject.toml").read_text()
tagged_commit = git_text(repo, "rev-parse", "HEAD")
git(repo, "tag", "-a", "v1.2.3", "-m", "reviewed immutable package")
(repo / "pyproject.toml").write_text(original.replace("1.2.3", "9.9.9"))
git(repo, "add", "pyproject.toml")
git(repo, "commit", "-m", "unreviewed replacement tree")
git(repo, "replace", tagged_commit, git_text(repo, "rev-parse", "HEAD"))
with patch.dict(os.environ, {"GIT_DIR": str(self.root / "outside"), "PATH": "/outside/bin"}):
with materialized_source_tree(repo, source_ref="v1.2.3") as source:
self.assertEqual(original, (source / "pyproject.toml").read_text())
if __name__ == "__main__":
unittest.main()