Files
voculinux/tests/test_void_signed_repo.py
T
xavierk 180cd4d512
CI / security (push) Successful in 1m14s
CI / lint (push) Successful in 24s
CI / ai-review (push) Skipped
fix(spec16): close the review findings for the Void port (#34)
Model recommendation keyed to the installed build and free memory, fork-sourced Void templates, signed xbps release workflow on Gitea with the pinned repository key, niri injection guard and wtype-first order, tightened naming sweep, web copy fixes, and CI security and duplication fixes.
2026-11-06 14:13:49 +00:00

448 lines
20 KiB
Python

"""Offline policy guards for the signed Void xbps repository (ticket #29).
Spec #16 serves the Void templates from a signed personal xbps
repository: every template builds in dependency order in the pinned Void
container via podman, the repository index carries full repo data, and
packages plus metadata are signed with the RSA4096 repository key. The
pipeline fails closed — a missing, unreadable, or non-RSA4096 key aborts
before any container runs, so an unsigned repository can never publish.
Seams under test (public interfaces, not internals):
- ``packaging/void/repo.sh`` — the host entry: key handling plus the
podman delegation. Exit status and stderr on a missing key are the
fail-closed contract.
- ``packaging/void/container-repo.sh`` — the container half: build-all,
index, sign, verify, stage. Its xbps-rindex invocations are the
signing contract.
- The ``void-repo`` just recipe — how release work and CI call the
pipeline, same shape as the other packaging gates.
- ``packaging/void/README.md`` — the documented maintainer and client
flow, including the CI secret name.
- ``packaging/void/repository-key.plist`` — the committed PUBLIC half of
the repository key, whose fingerprint the READMEs and the Gitea release
workflow pin. ``tests/test_xbps_release_workflow.py`` runs the workflow.
Independent sources of truth: the xbps fingerprint definition (the MD5 of
the SSH-format ``ssh-rsa`` blob of the public key, per libxbps
``xbps_pubkey2fp``), ``packaging/void/srcpkgs/*/template``
owns the template set (a renamed or added template must not silently
drop out), ``packaging/void/tool_checksums.txt`` owns the pins,
``packaging/void/smoke.sh`` owns the dependency order the signed repo
must agree with, and ``openssl`` plus ``xbps-rindex`` own the RSA4096
signing mechanics the container half must invoke. The container run
itself is the proof; these tests are the wiring guard.
"""
import base64
import hashlib
import plistlib
import re
import struct
from pathlib import Path
from typing import Tuple
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
GITEA_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
KEY_PLIST = REPO_ROOT / "packaging" / "void" / "repository-key.plist"
ROOT_README = REPO_ROOT / "README.md"
RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md"
VOID = REPO_ROOT / "packaging" / "void"
SRC = VOID / "srcpkgs"
REPO_SH = VOID / "repo.sh"
CONTAINER_REPO = VOID / "container-repo.sh"
SMOKE_SH = VOID / "smoke.sh"
PINS = VOID / "tool_checksums.txt"
JUSTFILE = REPO_ROOT / "justfile"
README = VOID / "README.md"
#: The CI secret carrying the PEM-encoded RSA4096 repository private key.
SIGNING_SECRET = "XBPS_SIGNING_KEY"
#: The host-side path variable pointing at the key file. A file (not the
#: secret content on the command line) keeps newlines intact and the key
#: out of process listings.
KEY_FILE_VAR = "XBPS_SIGNING_KEY_FILE"
def _srcpkg_templates() -> list:
"""Every template directory under srcpkgs, in dependency build order."""
return sorted(p.name for p in SRC.iterdir() if (p / "template").is_file())
def _repo_text() -> str:
assert REPO_SH.is_file(), f"missing signed-repo pipeline: {REPO_SH}"
return REPO_SH.read_text(encoding="utf-8")
def _container_text() -> str:
assert (
CONTAINER_REPO.is_file()
), f"missing container half of the signed-repo pipeline: {CONTAINER_REPO}"
return CONTAINER_REPO.read_text(encoding="utf-8")
def _smoke_packages() -> list:
"""The ordered package list the smoke gate builds, from smoke.sh."""
match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M)
assert match, "smoke.sh must declare its ordered PKGS list"
return match.group(1).split()
def test_pipeline_builds_every_template_and_nothing_else() -> None:
"""A renamed or added template cannot silently drop out of the repo."""
text = _repo_text()
assert "smoke.sh" in text or "PKGS" in text, (
"repo.sh must take its package set from the smoke gate order, "
"not a second hand-maintained list"
)
assert set(_smoke_packages()) == set(_srcpkg_templates()), (
f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}"
)
def test_pipeline_builds_the_app_last() -> None:
"""voculinux resolves the other templates from the signed repo."""
assert (
_smoke_packages()[-1] == "voculinux"
), f"the app must build last, order is {_smoke_packages()}"
def test_pipeline_stays_podman_only_with_pinned_inputs() -> None:
"""Podman, never Docker; pins live in tool_checksums.txt, not the flow."""
for path, text in (
(REPO_SH, _repo_text()),
(CONTAINER_REPO, _container_text()),
):
assert "podman" in text or path == CONTAINER_REPO, f"{path.name} must use podman"
assert "docker" not in text.lower().replace(
"never docker", ""
), f"{path.name} must never invoke Docker"
assert "ghcr.io" not in _repo_text(), "the base image pin lives in tool_checksums.txt"
assert PINS.is_file(), f"missing pins file: {PINS}"
def test_pipeline_fails_closed_without_a_key() -> None:
"""No key file means no container, no repo, no publish — never unsigned."""
text = _repo_text()
assert KEY_FILE_VAR in text, f"repo.sh must read the key path from {KEY_FILE_VAR}"
assert (
SIGNING_SECRET in text or "SIGNING_KEY" in text
), "repo.sh must name the CI secret carrying the private key"
for marker in ("exit 1",):
assert marker in text, "the missing-key path must abort non-zero"
assert re.search(
r"[Nn]o .*key|missing .*key|key .*missing", text
), "the missing-key abort must say the key is missing"
def test_pipeline_requires_rsa4096_before_any_container_runs() -> None:
"""The contract is RSA4096: any other key aborts before podman starts."""
text = _repo_text()
assert "4096" in text, "repo.sh must pin the 4096-bit key size"
assert "openssl" in text, "repo.sh must validate the key with openssl"
key_check = text.index("openssl")
first_run = min(i for marker in ("podman pull", "podman run") if (i := text.find(marker)) >= 0)
assert key_check < first_run, "key validation must run before the first podman invocation"
def test_container_signs_packages_and_metadata_with_the_key() -> None:
"""xbps-rindex signs every binary package and the repository metadata."""
text = _container_text()
assert "xbps-rindex -a" in text, "the repo index (full repodata) is missing"
assert "-S" in text or "--sign-pkg" in text, "per-package signing (--sign-pkg) is missing"
assert (
re.search(r"(^|\s)-s(\s|$)", text) or "--sign " in text
), "repository metadata signing (-s/--sign) is missing"
assert "--privkey" in text, "signing must use the provisioned private key"
assert "--signedby" in text, "signing must carry the signer identity"
def test_container_lints_every_template_before_building() -> None:
"""One bad template fails the repo, same as the per-package harness."""
assert (
"xlint" in _container_text()
), "container-repo.sh must xlint each template before building it"
def test_container_names_the_key_fingerprint_for_clients() -> None:
"""Clients pin the key by fingerprint; the pipeline must report it."""
assert (
"ingerprint" in _container_text()
), "container-repo.sh must capture the key fingerprint for release notes"
def test_container_ships_an_importable_client_key() -> None:
"""The staged plist must parse: xbps rejects raw <> in signature-by."""
text = _container_text()
assert ".plist" in text, "container-repo.sh must stage the client .plist"
assert "&lt;" in text and "&gt;" in text, (
"the plist writer must XML-escape the signer identity "
"(xbps shows Fingerprint and refuses the import otherwise)"
)
def test_container_proves_signatures_with_a_clean_rootdir_install() -> None:
"""The pipeline verifies what it signed: a fresh rootdir install."""
text = _container_text()
assert (
"xbps-install" in text and "-r " in text
), "container-repo.sh must prove the signed repo with a rootdir install"
assert "voculinux" in text, "the proof install must be the app, resolving deps from the repo"
def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None:
"""`just void-repo` runs the pipeline script with no shared-output step."""
recipe = re.search(
r"^void-repo:([^\n]*)\n((?:[ \t].*\n|\n)+)",
JUSTFILE.read_text(encoding="utf-8"),
re.MULTILINE,
)
assert recipe, "justfile must define a void-repo recipe"
assert not recipe.group(1).strip(), "void-repo must not take a shared-output prerequisite"
assert "packaging/void/repo.sh" in recipe.group(2), "void-repo must run packaging/void/repo.sh"
def test_readme_documents_the_signed_repo_flow() -> None:
"""Maintainer key setup, CI secret, fail-closed rule, and client setup."""
text = README.read_text(encoding="utf-8")
assert "void-repo" in text or "repo.sh" in text, "README must document the signed-repo command"
assert "4096" in text, "README must document RSA4096 key generation"
assert SIGNING_SECRET in text, "README must name the CI secret"
assert (
"fail" in text.lower() and "closed" in text.lower()
), "README must state the fail-closed rule: no key, no repo"
assert ".plist" in text, "README must document the client key install"
def _release_workflow_text() -> str:
assert RELEASE_WORKFLOW.is_file(), f"missing release workflow: {RELEASE_WORKFLOW}"
return RELEASE_WORKFLOW.read_text(encoding="utf-8")
def _gitea_workflow_text() -> str:
assert GITEA_WORKFLOW.is_file(), f"missing Gitea xbps release workflow: {GITEA_WORKFLOW}"
return GITEA_WORKFLOW.read_text(encoding="utf-8")
def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None:
"""One signing path: the tag-triggered Gitea workflow runs repo.sh."""
github = _release_workflow_text()
for marker in (
"build-xbps",
"xbps-repo",
"XBPS_SIGNING_KEY",
"packaging/void/repo.sh",
):
assert marker not in github, f"release.yml must not carry the xbps job ({marker})"
gitea = _gitea_workflow_text()
assert "packaging/void/repo.sh" in gitea, "the Gitea workflow must run the signed-repo pipeline"
assert SIGNING_SECRET in gitea, "the Gitea workflow must read the XBPS_SIGNING_KEY secret"
assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman"
workflow = yaml.safe_load(gitea)
assert workflow[True] == {"push": {"tags": ["v*"]}}, "tag pushes are the only trigger"
#: Registration URL every client snippet names: the rolling ``latest``
#: release the Gitea workflow keeps current, so the URL serves the newest
#: release's downloads (the tag itself is not part of the client config).
CONCRETE_REPO_DOWNLOAD = "https://git.bongbetic.com/xavierk/voculinux/releases/download/latest"
REGISTRATION_LINE = f"repository={CONCRETE_REPO_DOWNLOAD}"
def _void_section(readme: str) -> str:
"""The root README's Void Linux section, up to the next ``###`` heading."""
start = readme.index("### Void Linux")
end = readme.find("\n### ", start + 1)
return readme[start : end if end != -1 else len(readme)]
def test_client_registration_names_the_rolling_latest_release_url() -> None:
"""No placeholder, no per-tag or GitHub URL: the registration line is the
fork's Gitea `latest` release, which the workflow republishes every tag."""
root = ROOT_README.read_text(encoding="utf-8")
void_readme = README.read_text(encoding="utf-8")
for name, text in (
("release.yml", _release_workflow_text()),
("xbps-release.yml", _gitea_workflow_text()),
("README.md", root),
("packaging/void/README.md", void_readme),
):
assert (
"<repo-url-from-release-notes>" not in text
), f"{name} still carries the self-referential repo-URL placeholder"
assert "repository=<" not in text, f"{name} still carries a placeholder repository URL"
for name, text in (
("README.md", _void_section(root)),
("packaging/void/README.md", void_readme),
):
assert REGISTRATION_LINE in text, f"{name} must carry the registration line"
assert "releases/download/${TAG}" not in text, f"{name} must not pin a per-tag URL"
assert "github.com/VocaHQ/vocalinux/releases" not in text, f"{name} points at upstream"
def test_registration_url_is_the_release_the_workflow_refreshes() -> None:
"""The URL clients register and the API the workflow publishes to must
name the same repository and the same `latest` tag."""
workflow = yaml.safe_load(_gitea_workflow_text())
api = workflow["jobs"]["xbps-release"]["env"]["GITEA_API"]
host, _, rest = api.partition("/api/v1/repos/")
assert CONCRETE_REPO_DOWNLOAD == f"{host}/{rest}/releases/download/latest"
assert "ensure_release latest" in _gitea_workflow_text()
def test_root_readme_registers_the_void_repo() -> None:
"""Root README: Void install from the signed repo, not just the track README."""
text = ROOT_README.read_text(encoding="utf-8")
assert "xbps" in text.lower(), "root README must name xbps"
for marker in ("FINGERPRINT", ".plist", "repository="):
assert marker in text, f"root README must document client registration ({marker})"
assert "xbps-install" in text, "root README must give the install command"
def test_root_readme_shows_the_fingerprint_beside_the_registration() -> None:
"""The fingerprint to pin sits in the Void section, with the registration
line and the install command, not somewhere a reader must hunt for."""
section = _void_section(ROOT_README.read_text(encoding="utf-8"))
assert _key_fingerprint() in section, "the Void section must show the fingerprint to pin"
assert REGISTRATION_LINE in section
assert "xbps-install" in section
assert "-Sy" not in section, "the first sync must not auto-accept an unknown key"
assert "third-party" in section.lower() or "third party" in section.lower()
def test_root_readme_states_void_scope_and_third_party_caveat() -> None:
"""glibc x86_64 only, and this is a third-party repo outside upstream Void."""
text = ROOT_README.read_text(encoding="utf-8").lower()
assert "x86_64" in text, "root README must scope the repo to x86_64"
assert "glibc" in text, "root README must scope the repo to glibc"
assert (
"third-party" in text or "third party" in text
), "root README must say the repo is third-party, not upstream Void"
def test_release_process_documents_void_template_bump_as_pr() -> None:
"""Template version/checksum bumps land as a pull request, never direct to main."""
text = RELEASE_PROCESS.read_text(encoding="utf-8")
assert (
"srcpkgs" in text or "xbps" in text.lower()
), "docs/RELEASE_PROCESS.md must cover the Void template bump"
assert re.search(
r"[Pp]ull request", text
), "docs/RELEASE_PROCESS.md must require the bump as a pull request"
def _der(data: bytes, pos: int) -> Tuple[int, bytes, int]:
"""Read one DER TLV at ``pos``: (tag, value, position after it)."""
tag, first = data[pos], data[pos + 1]
pos += 2
length = first
if first & 0x80:
count = first & 0x7F
length = int.from_bytes(data[pos : pos + count], "big")
pos += count
return tag, data[pos : pos + length], pos + length
def _rsa_public_numbers(pem: str) -> Tuple[int, int]:
"""(modulus, exponent) from an RSA SubjectPublicKeyInfo PEM, stdlib only."""
body = "".join(line for line in pem.splitlines() if not line.startswith("-----"))
tag, spki, _ = _der(base64.b64decode(body), 0)
assert tag == 0x30, "SubjectPublicKeyInfo must be a SEQUENCE"
_, _, after_algorithm = _der(spki, 0)
tag, bits, _ = _der(spki, after_algorithm)
assert tag == 0x03 and bits[0] == 0, "expected a BIT STRING holding the RSAPublicKey"
tag, sequence, _ = _der(bits[1:], 0)
assert tag == 0x30
tag, modulus, after_modulus = _der(sequence, 0)
assert tag == 0x02
tag, exponent, _ = _der(sequence, after_modulus)
assert tag == 0x02
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
def _mpint(value: int) -> bytes:
"""SSH ``mpint``: length-prefixed big-endian, 0x00 pad when the top bit is set."""
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return struct.pack(">I", len(raw)) + raw
def _public_pem() -> str:
assert KEY_PLIST.is_file(), f"missing committed repository public key: {KEY_PLIST}"
entry = plistlib.loads(KEY_PLIST.read_bytes())
return entry["public-key"].decode("ascii")
def _key_fingerprint() -> str:
"""The xbps fingerprint of the committed key: MD5 of the ssh-rsa blob."""
modulus, exponent = _rsa_public_numbers(_public_pem())
blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus)
# An identifier xbps defines as MD5, not a security use of the hash.
digest = hashlib.md5(blob, usedforsecurity=False).hexdigest()
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
def test_committed_repository_key_is_a_public_rsa4096_key_only() -> None:
"""Only the public half is ever committed: never private key material."""
raw = KEY_PLIST.read_text(encoding="utf-8")
pem = _public_pem()
assert "PRIVATE KEY" not in raw and "PRIVATE KEY" not in pem
assert pem.startswith("-----BEGIN PUBLIC KEY-----")
entry = plistlib.loads(KEY_PLIST.read_bytes())
assert set(entry) == {"public-key", "public-key-size", "signature-by"}
modulus, exponent = _rsa_public_numbers(pem)
assert modulus.bit_length() == 4096, "the repository key must be RSA 4096"
assert entry["public-key-size"] == 4096
assert exponent == 65537
def test_key_fingerprint_follows_the_xbps_format() -> None:
fingerprint = _key_fingerprint()
assert re.fullmatch(r"([0-9a-f]{2}:){15}[0-9a-f]{2}", fingerprint), fingerprint
def test_readmes_pin_the_fingerprint_of_the_committed_key() -> None:
"""Users compare against the README, so it must be derived, not typed."""
fingerprint = _key_fingerprint()
for name, path in (
("README.md", ROOT_README),
("packaging/void/README.md", README),
):
assert fingerprint in path.read_text(encoding="utf-8"), f"{name} must show {fingerprint}"
def test_workflow_publishes_only_under_the_pinned_fingerprint() -> None:
"""A secret holding another key fails the verify step before publishing."""
workflow = yaml.safe_load(_gitea_workflow_text())
assert workflow["jobs"]["xbps-release"]["env"]["XBPS_KEY_FINGERPRINT"] == _key_fingerprint()
def test_void_readme_documents_the_secret_and_the_public_key_only_rule() -> None:
text = README.read_text(encoding="utf-8")
assert "repository-key.plist" in text, "README must name the committed public key"
assert "never committed" in text.lower() or "never commit" in text.lower()
assert "openssl genrsa" in text and "4096" in text
assert SIGNING_SECRET in text
def test_the_private_key_is_never_committed() -> None:
"""Only the public half lives in the repository: no PEM private key block in
the Void packaging tree or the workflows that sign with it."""
private_block = re.compile(rb"-----BEGIN [A-Z ]*PRIVATE KEY-----")
roots = (
REPO_ROOT / "packaging" / "void",
REPO_ROOT / ".gitea",
REPO_ROOT / ".github",
)
scanned = 0
for root in roots:
for path in root.rglob("*"):
if path.is_file():
scanned += 1
assert not private_block.search(path.read_bytes()), f"private key in {path}"
assert scanned > 20, "the scan must actually cover the packaging and workflow trees"