Every release is built from its tag alone by packaging/void/build-release.sh, shared by the tag workflow and local dry runs. Signs in a network-less container with the key mounted read-only and publishes to the tag release and the rolling latest release.
308 lines
14 KiB
Python
308 lines
14 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.
|
|
The release path itself (build, sign, install smoke) is guarded in
|
|
``tests/test_void_release_build.py``; this module guards what must stay true
|
|
around it: the committed public key and its pinned fingerprint, the README and
|
|
root README registration text users copy, the release workflow's publish target,
|
|
the template bump documented as a pull request, and the rule that no private key
|
|
is ever committed.
|
|
|
|
Seams under test (public interfaces, not internals):
|
|
|
|
- ``packaging/void/repository-key.plist``: the committed PUBLIC half of the
|
|
repository key. Its fingerprint is what the READMEs pin.
|
|
- ``README.md`` and ``packaging/void/README.md``: the client registration text.
|
|
- ``.gitea/workflows/xbps-release.yml``: the tag trigger, the Gitea API it
|
|
publishes to, and the release path it calls.
|
|
- ``docs/RELEASE_PROCESS.md``: the template bump is a pull request.
|
|
|
|
Independent sources of truth: the xbps fingerprint definition (the MD5 of the
|
|
ssh-rsa blob of the public key, per libxbps ``xbps_pubkey2fp``), computed here
|
|
with the standard library only, and ``packaging/void/smoke.sh`` owns the package
|
|
order the signed repository must agree with.
|
|
"""
|
|
|
|
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"
|
|
BUILD_RELEASE = VOID / "build-release.sh"
|
|
CONTAINER_RELEASE = VOID / "container-release.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 _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_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 "build-release.sh" in text, "README must document the release command"
|
|
assert "Runner requirements" in text, "README must document the runner requirements"
|
|
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 build-release.sh."""
|
|
github = _release_workflow_text()
|
|
for marker in (
|
|
"build-xbps",
|
|
"xbps-repo",
|
|
"XBPS_SIGNING_KEY",
|
|
"packaging/void/build-release.sh",
|
|
):
|
|
assert marker not in github, f"release.yml must not carry the xbps job ({marker})"
|
|
gitea = _gitea_workflow_text()
|
|
assert (
|
|
"packaging/void/build-release.sh" in gitea
|
|
), "the Gitea workflow must run the release path"
|
|
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_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"
|