#!/usr/bin/env python3 from __future__ import annotations import argparse import os import posixpath import shutil import stat import sys import tarfile import tempfile from itertools import chain from pathlib import Path, PurePosixPath from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from scripts.dependency_lock import DEFAULT_LOCK, load_lock # noqa: E402 from scripts.setup_helpers import atomic_write, download_file # noqa: E402 def safe_extract(archive: Path, destination: Path) -> None: with tarfile.open(archive, "r:*") as bundle: for member in bundle.getmembers(): member_path = PurePosixPath(member.name) if member_path.is_absolute() or ".." in member_path.parts: raise ValueError(f"unsafe archive member path: {member.name}") if member.ischr() or member.isblk() or member.isfifo(): raise ValueError(f"unsupported archive member type: {member.name}") if member.issym() or member.islnk(): link = PurePosixPath(member.linkname) combined = member_path.parent / link if member.issym() else link normalized = PurePosixPath(posixpath.normpath(str(combined))) escapes_root = normalized.parts[:1] == ("..",) if link.is_absolute() or normalized.is_absolute() or escapes_root: raise ValueError( f"unsafe archive link: {member.name} -> {member.linkname}" ) bundle.extractall(destination) def make_tree_world_readable(root: Path) -> None: """Make a public tool archive usable by accounts other than its installer.""" for path in chain((root,), root.rglob("*")): if path.is_symlink(): continue mode = stat.S_IMODE(path.stat().st_mode) if path.is_dir(): path.chmod(mode | 0o555) elif path.is_file(): shared_mode = mode | 0o444 if mode & 0o111: shared_mode |= 0o111 path.chmod(shared_mode) def install(name: str, spec: dict[str, Any], cache: Path, update: bool) -> Path: downloads = cache / "downloads" archive = downloads / spec["archive"] download_file( spec["url"], archive, spec["sha256"], update=update, timeout=spec.get("download_timeout_seconds", 120), resume=spec.get("resume", False), ) destination = cache / "toolchains" / f"{name}-{spec['version']}" marker = destination / ".complete" if marker.is_file() and marker.read_text(encoding="utf-8").strip() == spec["sha256"]: make_tree_world_readable(destination) return destination / spec["path"] destination.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=f".{destination.name}.", dir=destination.parent) as temp: extracted = Path(temp) / "content" extracted.mkdir() safe_extract(archive, extracted) atomic_write(extracted / ".complete", spec["sha256"] + "\n") make_tree_world_readable(extracted) if destination.exists(): shutil.rmtree(destination) os.replace(extracted, destination) return destination / spec["path"] def main() -> int: parser = argparse.ArgumentParser(description="Install pinned retroSoC toolchains") parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK) parser.add_argument("--platform", default="ubuntu-22.04") parser.add_argument("--cache", type=Path, required=True) parser.add_argument("--tool", action="append", required=True) parser.add_argument("--github-path", type=Path) parser.add_argument("--update", action="store_true") args = parser.parse_args() lock = load_lock(args.lock) try: available = lock["toolchains"][args.platform] except KeyError as error: raise SystemExit(f"unsupported toolchain platform: {args.platform}") from error paths: list[Path] = [] for name in args.tool: if name not in available: raise SystemExit(f"toolchain is not locked for {args.platform}: {name}") path = install(name, available[name], args.cache.resolve(), args.update) paths.append(path) print(f"{name}: {path}") if args.github_path: args.github_path.parent.mkdir(parents=True, exist_ok=True) with args.github_path.open("a", encoding="utf-8") as stream: for path in paths: stream.write(str(path.resolve()) + "\n") return 0 if __name__ == "__main__": raise SystemExit(main())