#!/usr/bin/env python3 from __future__ import annotations import argparse import hashlib import json import re from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DEFAULT_LOCK = ROOT / "dependencies/dependencies.lock.json" DEFAULT_FLAKE_LOCK = ROOT / "flake.lock" NIX_SRI_HASH = re.compile(r"sha256-[A-Za-z0-9+/]{43}=") OCI_DIGEST = re.compile(r"sha256:[0-9a-f]{64}") class LockError(ValueError): pass def load_lock(path: Path = DEFAULT_LOCK) -> dict[str, Any]: path = path.resolve() try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise LockError(f"cannot read dependency lock {path}: {error}") from error validate_lock(data) return data def validate_lock(data: dict[str, Any]) -> None: if data.get("schema_version") != 1: raise LockError("dependency lock schema_version must be 1") for section in ("sources", "archives", "container_images", "nix_inputs", "toolchains"): if not isinstance(data.get(section), dict) or not data[section]: raise LockError(f"dependency lock section is missing or empty: {section}") for name, source in data["sources"].items(): _require_fields("source", name, source, ("url", "revision", "destination", "license")) if not re.fullmatch(r"[0-9a-f]{40}", source["revision"]): raise LockError(f"source {name} revision must be a full 40-character commit") _validate_url(f"source {name}", source["url"]) _validate_relative_path(f"source {name} destination", source["destination"]) _validate_source_submodules(name, source) for name, archive in data["archives"].items(): _validate_archive("archive", name, archive) for name, image in data["container_images"].items(): _require_fields("container image", name, image, ("image", "digest", "url", "license")) if not re.fullmatch(r"[a-z0-9]+(?:[._/-][a-z0-9]+)*", image["image"]): raise LockError(f"container image {name} has an invalid image name") if OCI_DIGEST.fullmatch(image["digest"]) is None: raise LockError(f"container image {name} digest must be a SHA-256 OCI digest") _validate_url(f"container image {name}", image["url"]) for name, nix_input in data["nix_inputs"].items(): _require_fields("nix input", name, nix_input, ("url", "revision", "nar_hash", "license")) if not re.fullmatch(r"[0-9a-f]{40}", nix_input["revision"]): raise LockError(f"nix input {name} revision must be a full 40-character commit") _validate_url(f"nix input {name}", nix_input["url"]) if NIX_SRI_HASH.fullmatch(nix_input["nar_hash"]) is None: raise LockError(f"nix input {name} nar_hash must be a SHA-256 SRI hash") for platform, tools in data["toolchains"].items(): if not isinstance(tools, dict) or not tools: raise LockError(f"toolchain platform is empty: {platform}") for name, tool in tools.items(): _require_fields("toolchain", name, tool, ("version", "url", "sha256", "archive", "path")) _validate_sha(f"toolchain {platform}/{name}", tool["sha256"]) _validate_url(f"toolchain {platform}/{name}", tool["url"]) _validate_relative_path(f"toolchain {platform}/{name} archive", tool["archive"]) _validate_relative_path(f"toolchain {platform}/{name} path", tool["path"]) timeout = tool.get("download_timeout_seconds", 120) if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0: raise LockError( f"toolchain {platform}/{name} download_timeout_seconds must be positive" ) if not isinstance(tool.get("resume", False), bool): raise LockError(f"toolchain {platform}/{name} resume must be a boolean") def _validate_archive(kind: str, name: str, value: dict[str, Any]) -> None: _require_fields(kind, name, value, ("url", "sha256", "destination", "license")) _validate_sha(f"{kind} {name}", value["sha256"]) _validate_url(f"{kind} {name}", value["url"]) _validate_relative_path(f"{kind} {name} destination", value["destination"]) def _validate_source_submodules(name: str, value: dict[str, Any]) -> None: submodules = value.get("submodules") if submodules is None: return if value.get("recursive") is not True: raise LockError(f"source {name} submodules require recursive=true") if not isinstance(submodules, list) or not submodules: raise LockError(f"source {name} submodules must be a non-empty list") for submodule in submodules: if not isinstance(submodule, str): raise LockError(f"source {name} submodules must contain paths") _validate_relative_path(f"source {name} submodule", submodule) def _require_fields(kind: str, name: str, value: Any, fields: tuple[str, ...]) -> None: if not isinstance(value, dict): raise LockError(f"{kind} {name} must be an object") missing = [field for field in fields if not value.get(field)] if missing: raise LockError(f"{kind} {name} missing fields: {', '.join(missing)}") def _validate_sha(label: str, value: str) -> None: if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): raise LockError(f"{label} sha256 must be 64 lowercase hexadecimal characters") def _validate_url(label: str, value: str) -> None: if not value.startswith("https://"): raise LockError(f"{label} URL must use HTTPS") def _validate_relative_path(label: str, value: str) -> None: path = Path(value) if path.is_absolute() or ".." in path.parts: raise LockError(f"{label} must be a repository-relative path") def validate_flake_lock(data: dict[str, Any], path: Path = DEFAULT_FLAKE_LOCK) -> None: try: flake = json.loads(path.read_text(encoding="utf-8")) nodes = flake["nodes"] root = nodes[flake["root"]] except (KeyError, OSError, json.JSONDecodeError) as error: raise LockError(f"cannot read flake lock {path}: {error}") from error if not isinstance(nodes, dict) or not isinstance(root, dict): raise LockError("flake lock nodes and root must be objects") for name, expected in data["nix_inputs"].items(): try: node = nodes[root["inputs"][name]] locked = node["locked"] owner = locked["owner"] repository = locked["repo"] except (KeyError, TypeError) as error: raise LockError(f"flake lock is missing nix input {name}") from error actual_url = f"https://github.com/{owner}/{repository}" if actual_url != expected["url"]: raise LockError(f"flake lock URL mismatch for nix input {name}") if locked.get("rev") != expected["revision"]: raise LockError(f"flake lock revision mismatch for nix input {name}") if locked.get("narHash") != expected["nar_hash"]: raise LockError(f"flake lock narHash mismatch for nix input {name}") def lock_digest(path: Path = DEFAULT_LOCK) -> str: return hashlib.sha256(path.resolve().read_bytes()).hexdigest() def source(name: str, path: Path = DEFAULT_LOCK) -> dict[str, Any]: data = load_lock(path) try: return data["sources"][name] except KeyError as error: raise LockError(f"unknown source dependency: {name}") from error def archive(name: str, path: Path = DEFAULT_LOCK) -> dict[str, Any]: data = load_lock(path) try: return data["archives"][name] except KeyError as error: raise LockError(f"unknown archive dependency: {name}") from error def main() -> int: parser = argparse.ArgumentParser(description="Validate the retroSoC dependency lock") parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK) parser.add_argument("--flake-lock", type=Path, default=DEFAULT_FLAKE_LOCK) parser.add_argument("--digest", action="store_true") args = parser.parse_args() data = load_lock(args.lock) validate_flake_lock(data, args.flake_lock) if args.digest: print(lock_digest(args.lock)) else: print( f"dependency lock valid: {len(data['sources'])} sources, " f"{len(data['archives'])} archives, {len(data['container_images'])} container images, " f"{len(data['nix_inputs'])} Nix inputs" ) return 0 if __name__ == "__main__": raise SystemExit(main())