Version 0.19.0 across version.py, the Void template and the synced docs; changelog entry; Gitea project URLs in pyproject.toml; per-backend ladder thresholds measured in #19; the xbps release workflow is manual-only, and packaging/void/publish-release.sh publishes a locally built, verified repository to the Gitea releases.
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 manual Gitea workflow runs build-release.sh, and no tag starts it."""
|
|
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 set(workflow[True]) == {"workflow_dispatch"}, "manual dispatch is 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"
|