diff options
Diffstat (limited to 'scripts/update-kernel-cachyos.py')
| -rw-r--r-- | scripts/update-kernel-cachyos.py | 223 |
1 files changed, 223 insertions, 0 deletions
diff --git a/scripts/update-kernel-cachyos.py b/scripts/update-kernel-cachyos.py new file mode 100644 index 0000000..2c9bd19 --- /dev/null +++ b/scripts/update-kernel-cachyos.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Prepare a CachyOS kernel recipe from the latest AUR package release.""" + +import hashlib +import json +import os +import posixpath +import re +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parent.parent +AUR_URL = "https://aur.archlinux.org/rpc/v5/info/linux-cachyos" +MIRROR = "https://mirror.cachyos.org/repo/x86_64_v3/cachyos-v3" +RECIPE = re.compile(r"kernel-cachyos-([0-9]+(?:\.[0-9]+)*)(?:-r([1-9][0-9]*))?\.exheres-0\Z") +RELEASE = re.compile(r"([0-9]+(?:\.[0-9]+)*)-([1-9][0-9]*)\Z") +MAX_DOWNLOAD = 1_500_000_000 + + +class UpdateError(Exception): + pass + + +def release_key(value): + match = RELEASE.fullmatch(value) + if not match: + raise UpdateError(f"Invalid upstream release: {value!r}") + return tuple(map(int, match.group(1).split("."))), int(match.group(2)) + + +def recipe_key(path): + match = RECIPE.fullmatch(path.name) + if not match: + raise UpdateError(f"Unexpected kernel recipe: {path.name}") + return tuple(map(int, match.group(1).split("."))), int(match.group(2) or 0) + + +def recipe_release(path): + match = re.search(r'^MY_RELEASE="\$\{PV\}-([1-9][0-9]*)"$', path.read_text(), re.MULTILINE) + if not match: + raise UpdateError(f"Missing literal MY_RELEASE in {path.name}") + version = RECIPE.fullmatch(path.name).group(1) + return f"{version}-{match.group(1)}" + + +def latest_aur_release(): + try: + with urllib.request.urlopen(AUR_URL, timeout=20) as response: + data = json.load(response) + except (urllib.error.URLError, TimeoutError, ValueError) as error: + raise UpdateError(f"AUR request failed (rate limit or network issue): {error}") from error + if not isinstance(data, dict) or data.get("resultcount") != 1 or not isinstance(data.get("results"), list) or len(data["results"]) != 1: + raise UpdateError("AUR did not return exactly one linux-cachyos result") + result = data["results"][0] + if not isinstance(result, dict) or result.get("Name") != "linux-cachyos" or not isinstance(result.get("Version"), str): + raise UpdateError("AUR returned an unexpected package or version") + release_key(result["Version"]) + return result["Version"] + + +def download(url, destination): + digest = hashlib.sha256() + size = 0 + try: + request = urllib.request.Request(url, headers={"User-Agent": "kernel-cachyos-updater/1.0"}) + with urllib.request.urlopen(request, timeout=60) as response, destination.open("xb") as output: + while chunk := response.read(1024 * 1024): + size += len(chunk) + if size > MAX_DOWNLOAD: + raise UpdateError(f"Archive exceeds size limit: {url}") + digest.update(chunk) + output.write(chunk) + except (urllib.error.URLError, OSError, TimeoutError) as error: + raise UpdateError(f"Cannot download {url}: {error}") from error + if not size: + raise UpdateError(f"Empty archive: {url}") + return digest.hexdigest() + + +def inspect_archive(archive, name, release): + try: + with archive.open("rb") as source, subprocess.Popen( + ["zstd", "-dc"], stdin=source, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL + ) as decompressor: + try: + with tarfile.open(fileobj=decompressor.stdout, mode="r|") as tar: + paths = set() + regular = set() + metadata = None + for member in tar: + path = PurePosixPath(member.name.removeprefix("./")) + if path.is_absolute() or ".." in path.parts or str(path) == ".": + raise UpdateError(f"Unsafe archive member: {member.name!r}") + if member.issym() or member.islnk(): + target = member.linkname + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(str(path)), target)) + if target.startswith("/") or resolved == ".." or resolved.startswith("../"): + raise UpdateError(f"Unsafe archive link: {member.name!r}") + paths.add(str(path)) + if member.isfile(): + regular.add(str(path)) + if str(path) == ".PKGINFO": + if not member.isfile() or member.size > 64_000: + raise UpdateError(f"Invalid .PKGINFO in {archive.name}") + metadata = tar.extractfile(member).read().decode("utf-8") + # tar can stop at the end-of-archive marker before zstd reaches EOF. + for _ in iter(lambda: decompressor.stdout.read(1024 * 1024), b""): + pass + if decompressor.wait() != 0: + raise UpdateError(f"Cannot decompress {archive.name}") + except BaseException: + decompressor.kill() + decompressor.wait() + raise + except (tarfile.TarError, UnicodeError, OSError) as error: + raise UpdateError(f"Cannot inspect {archive.name}: {error}") from error + if metadata is None: + raise UpdateError(f"{archive.name} has no .PKGINFO") + fields = {} + for line in metadata.splitlines(): + if " = " in line: + key, value = line.split(" = ", 1) + fields.setdefault(key, []).append(value) + expected = {"pkgname": name, "pkgver": release, "arch": "x86_64_v3"} + for key, value in expected.items(): + if fields.get(key) != [value]: + raise UpdateError(f"{archive.name}: expected {key} = {value!r}, got {fields.get(key)!r}") + module = f"usr/lib/modules/{release}-cachyos" + if name == "linux-cachyos": + required = (f"{module}/vmlinuz",) + prefixes = (f"{module}/kernel/",) + else: + required = (f"{module}/build/.config", f"{module}/build/Module.symvers", f"{module}/build/System.map") + prefixes = (f"{module}/vdso/",) + if any(path not in regular for path in required) or any( + not any(path.startswith(prefix) for path in regular) for prefix in prefixes + ): + raise UpdateError(f"{archive.name}: expected kernel/module/header paths are missing") + + +def replace_once(text, pattern, replacement): + result, count = re.subn(pattern, replacement, text, flags=re.MULTILINE) + if count != 1: + raise UpdateError(f"Recipe template did not match exactly once: {pattern}") + return result + + +def render_recipe(template, release, hashes): + pkgrel = release_key(release)[1] + text = replace_once(template, r'^MY_RELEASE="\$\{PV\}-[1-9][0-9]*"$', f'MY_RELEASE="${{PV}}-{pkgrel}"') + for variable, digest in (("MY_KERNEL", hashes["linux-cachyos"]), ("MY_HEADERS", hashes["linux-cachyos-headers"])): + pattern = rf'("\${{{variable}}}"\) expected=)[0-9a-f]{{64}}( ;;)' + text = replace_once(text, pattern, lambda match: f"{match.group(1)}{digest}{match.group(2)}") + return text + + +def update(root=ROOT): + package_dir = root / "packages/sys-kernel/kernel-cachyos" + recipes = list(package_dir.glob("*.exheres-0")) + if len(recipes) != 1: + raise UpdateError(f"Expected exactly one kernel recipe, found {len(recipes)}") + latest = recipes[0] + current = recipe_release(latest) + if recipe_key(latest)[1] != release_key(current)[1] - 1: + raise UpdateError(f"Recipe revision does not match upstream pkgrel: {latest.name}") + template = latest.read_text() + if not re.search(r'^SLOT="0"$', template, re.MULTILINE): + raise UpdateError(f"Kernel recipe must use SLOT=0: {latest.name}") + upstream = latest_aur_release() + if release_key(upstream) == release_key(current): + print(f"Already current: {upstream}") + return + if release_key(upstream) < release_key(current): + raise UpdateError(f"AUR release {upstream} is older than recipe release {current}") + version = upstream.rsplit("-", 1)[0] + if version == current.rsplit("-", 1)[0] and release_key(upstream)[1] != release_key(current)[1] + 1: + raise UpdateError("Upstream pkgrel skipped a release; review missing kernel revisions") + revision = recipe_key(latest)[1] + 1 if version == current.rsplit("-", 1)[0] else 0 + suffix = f"-r{revision}" if revision else "" + target = package_dir / f"kernel-cachyos-{version}{suffix}.exheres-0" + if target.exists(): + raise UpdateError(f"Refusing to overwrite {target}") + + hashes = {} + with tempfile.TemporaryDirectory(prefix="kernel-cachyos-") as temporary: + for name in ("linux-cachyos", "linux-cachyos-headers"): + directory = Path(temporary) / name + directory.mkdir() + archive = directory / f"{name}-{upstream}-x86_64_v3.pkg.tar.zst" + hashes[name] = download(f"{MIRROR}/{archive.name}", archive) + inspect_archive(archive, name, upstream) + + content = render_recipe(template, upstream, hashes) + with tempfile.TemporaryDirectory(prefix=".kernel-recipe-", dir=package_dir) as temporary: + staged = Path(temporary) / target.name + staged.write_text(content) + result = subprocess.run(["bash", "-n", str(staged)], capture_output=True, text=True, check=False) + if result.returncode: + raise UpdateError(f"Generated recipe failed bash -n: {result.stderr.strip()}") + # Publish a complete file without replacing one another process created. + os.link(staged, target) + try: + latest.unlink() + except OSError as error: + target.unlink() + raise UpdateError(f"Could not remove old recipe: {error}") from error + print(f"Created {target.relative_to(root)} for upstream {upstream}") + print(f"Removed {latest.relative_to(root)}") + for name, digest in hashes.items(): + print(f"{name}: SHA-256 {digest} (verify against independently published hashes)") + + +if __name__ == "__main__": + try: + update() + except (UpdateError, OSError, subprocess.SubprocessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) |
