151 lines
7.5 KiB
Python
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)
|