Files
framebridge/tools/test_build_apt_repository.py
T

151 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Integration checks with disposable DEBs and a disposable signing identity."""
import argparse
import contextlib
import datetime as dt
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
spec = importlib.util.spec_from_file_location("apt_builder", Path(__file__).with_name("build_apt_repository.py"))
builder = importlib.util.module_from_spec(spec)
spec.loader.exec_module(builder)
class RepositoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="framebridge-apt-tests-")
cls.base = Path(cls.temp.name)
cls.key_home = cls.base / "test-key"
cls.key_home.mkdir(mode=0o700)
subprocess.run(["gpg", "--no-options", "--homedir", str(cls.key_home), "--batch",
"--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key",
"FrameBridge disposable test <test@invalid>", "ed25519", "sign", "0"],
check=True, capture_output=True)
result = subprocess.run(["gpg", "--no-options", "--homedir", str(cls.key_home),
"--with-colons", "--list-keys"], check=True, capture_output=True).stdout.decode()
cls.original_fingerprint = builder.FINGERPRINT
builder.FINGERPRINT = next(x.split(":")[9] for x in result.splitlines() if x.startswith("fpr:"))
cls.public_key = cls.base / "test.asc"
cls.public_key.write_bytes(subprocess.run(
["gpg", "--no-options", "--homedir", str(cls.key_home), "--armor", "--export", builder.FINGERPRINT],
check=True, capture_output=True).stdout)
cls.debs = {}
for distro, (_, _, _, python) in builder.PROFILES.items():
tree = cls.base / distro
(tree / "DEBIAN").mkdir(parents=True)
(tree / "DEBIAN").chmod(0o755)
(tree / "DEBIAN/control").write_text(
f"Package: {builder.PACKAGE}\nVersion: 0.3.3\nArchitecture: amd64\n"
f"Maintainer: FrameBridge <test@invalid>\nDepends: python3 (>= {python})\n"
f"Description: Disposable {distro} test package\n")
(tree / "distribution.txt").write_text(distro)
deb = cls.base / (distro + ".deb")
subprocess.run(["dpkg-deb", "--build", str(tree), str(deb)], check=True, capture_output=True)
cls.debs[distro] = deb
@classmethod
def tearDownClass(cls):
subprocess.run(["gpgconf", "--homedir", str(cls.key_home), "--kill", "gpg-agent"], capture_output=True)
builder.FINGERPRINT = cls.original_fingerprint
cls.temp.cleanup()
def setUp(self):
self.output = Path(tempfile.mkdtemp(dir=self.base, prefix="output-")) / "repository"
def prepare(self, **changes):
args = argparse.Namespace(output=self.output, version="0.3.3", public_key=self.public_key,
epoch=1793199600, **self.debs)
for key, value in changes.items():
setattr(args, key, value)
with contextlib.redirect_stdout(io.StringIO()):
builder.prepare(args)
raw = (self.output / "APT-INPUT-MANIFEST.json").read_bytes()
return hashlib.sha256(raw).hexdigest(), json.loads(raw)
def sign_test_releases(self):
for source, signed, clearsign in builder.signature_paths(self.output):
subprocess.run(["gpg", "--no-options", "--homedir", str(self.key_home), "--batch",
"--pinentry-mode", "loopback", "--passphrase", "", "--local-user", builder.FINGERPRINT,
"--digest-algo", "SHA256", "--output", str(signed),
"--clearsign" if clearsign else "--detach-sign", str(source)],
check=True, capture_output=True)
def test_actual_apt_update_accepts_three_signed_suites(self):
expected, manifest = self.prepare()
self.sign_test_releases()
self.assertTrue(builder.verify_signatures(self.output, manifest)["verified"])
for suite, *_ in builder.PROFILES.values():
isolated = self.output.parent / ("apt-" + suite)
isolated.mkdir()
(isolated / "state/lists/partial").mkdir(parents=True)
(isolated / "cache/archives/partial").mkdir(parents=True)
source = isolated / "sources.list"
source.write_text(f"deb [arch=amd64 signed-by={self.public_key}] file:{self.output} {suite} main\n")
result = subprocess.run(["apt-get", "-o", f"Dir::State={isolated}/state",
"-o", f"Dir::Cache={isolated}/cache", "-o", f"Dir::Etc::sourcelist={source}",
"-o", "Dir::Etc::sourceparts=-", "-o", "Acquire::Languages=none",
"-o", "Debug::NoLocking=1", "update"], capture_output=True, text=True, timeout=35)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Reading package lists", result.stdout)
self.assertEqual(builder.check_inputs(self.output, expected)["version"], "0.3.3")
def test_altered_package_is_rejected_before_signing(self):
expected, manifest = self.prepare()
p = self.output / manifest["artifacts"][1]["filename"]
p.write_bytes(p.read_bytes() + b"changed")
with self.assertRaisesRegex(ValueError, "Input changed"):
builder.check_inputs(self.output, expected)
def test_altered_index_is_rejected(self):
expected, _ = self.prepare()
(self.output / "dists/noble/main/binary-amd64/Packages").write_text("altered")
with self.assertRaisesRegex(ValueError, "Input changed"):
builder.check_inputs(self.output, expected)
def test_wrong_version_does_not_create_repository(self):
with self.assertRaisesRegex(ValueError, "wrong version"):
self.prepare(version="0.3.4")
self.assertFalse(self.output.exists())
def test_existing_output_never_overwritten(self):
self.output.mkdir()
sentinel = self.output / "sentinel"
sentinel.write_text("published")
with self.assertRaisesRegex(ValueError, "never overwrite"):
self.prepare()
self.assertEqual(sentinel.read_text(), "published")
def test_manifest_traversal_is_rejected(self):
_, manifest = self.prepare()
manifest["files"]["../outside"] = {"bytes": 0, "sha256": hashlib.sha256(b"").hexdigest()}
raw = json.dumps(manifest).encode()
(self.output / "APT-INPUT-MANIFEST.json").write_bytes(raw)
with self.assertRaisesRegex(ValueError, "Unsafe manifest path"):
builder.check_inputs(self.output, hashlib.sha256(raw).hexdigest())
def test_public_key_bundle_with_extra_primary_key_is_rejected(self):
bundle = self.output.parent / "extra-primary-key.asc"
bundle.write_bytes(self.public_key.read_bytes() + self.public_key.read_bytes())
with self.assertRaisesRegex(ValueError, "exactly one official primary"):
self.prepare(public_key=bundle)
self.assertFalse(self.output.exists())
def test_changed_signed_release_fails_verification(self):
_, manifest = self.prepare()
self.sign_test_releases()
path = self.output / "dists/trixie/InRelease"
path.write_bytes(path.read_bytes().replace(b"Components: main", b"Components: evil"))
with self.assertRaises(subprocess.CalledProcessError):
builder.verify_signatures(self.output, manifest)
if __name__ == "__main__":
unittest.main(verbosity=2)