#!/usr/bin/env python3 """Archive rootless staging with package ownership and portable Linux capabilities.""" import json import os import pathlib import shutil import stat import struct import subprocess import sys import tarfile from rootfs_lib import digest, package_db, rooted root, work, *caches = (pathlib.Path(arg).resolve() for arg in sys.argv[1:]) db = package_db(root) inputs = work / "packages" inputs.mkdir() owners = {} records = [] scripts = [] for name, props in sorted(db.items()): filename = f'{props["pkgver"]}.{props["architecture"]}.xbps' candidates = [cache / filename for cache in caches if (cache / filename).is_file()] if not candidates: sys.exit(f"ERROR: selected package input is missing: {filename}") source = candidates[0] checksum = digest(source) expected = props.get("filename-sha256") if expected and expected != checksum: sys.exit(f"ERROR: package input changed after installation: {filename}") shutil.copyfile(source, inputs / filename) if source.with_suffix(".xbps.sig2").is_file(): shutil.copyfile(source.with_suffix(".xbps.sig2"), inputs / (filename + ".sig2")) records.append({"name": name, "version": props["pkgver"], "architecture": props["architecture"], "sha256": checksum, "filename": filename}) # Python 3.14 understands XBPS's zstd archives without third-party modules. with tarfile.open(source, "r:*") as archive: for member in archive: path = member.name.removeprefix("./") if path in ("INSTALL", "REMOVE"): scripts.append(f"### {filename}: {path}\n" + archive.extractfile(member).read().decode()) if path in ("INSTALL", "REMOVE", "props.plist", "files.plist") or not path: continue if path.startswith("/") or ".." in pathlib.PurePosixPath(path).parts: sys.exit(f"ERROR: unsafe package path: {path}") # Resolve parent aliases (/bin -> /usr/bin), not a symlink leaf. parent = rooted(root, str(pathlib.PurePosixPath(path).parent)) canonical = str((parent / pathlib.PurePosixPath(path).name).relative_to(root)) value = (member.uid, member.gid) if canonical in owners and owners[canonical] != value: sys.exit(f"ERROR: conflicting package ownership: {canonical}") owners[canonical] = value # util-linux's INSTALL assigns tty ownership after extraction. The single-user # namespace cannot represent every target group; restore this tested policy. for name in ("usr/bin/wall", "usr/bin/write"): if (root / name).exists(): owners[name] = (0, 5) # libutempter's INSTALL assigns utmp after extraction. A single-UID build # namespace cannot apply that group; exporting SGID root would be incorrect. if (root / "usr/lib/utempter/utempter").exists(): groups = {line.split(":")[0]: int(line.split(":")[2]) for line in (root / "etc/group").read_text().splitlines() if line and not line.startswith("#")} owners["usr/lib/utempter/utempter"] = (0, groups["utmp"]) # XBPS restricts its setuid chroot helper to the dedicated xbuilder group. owners["usr/bin/xbps-uchroot"] = (0, 101) epoch = int(subprocess.check_output(["git", "-C", "vendor/void-packages", "show", "-s", "--format=%ct", "HEAD"])) metadata = {} def normalize(info): info.uid, info.gid = owners.get(info.name, (0, 0)) info.uname = info.gname = "" info.mtime = epoch info.mode = stat.S_IMODE(info.mode) init_fifo = info.isfifo() and info.name.startswith("etc/s6-linux-init/current/run-image/") if not (info.isfile() or info.isdir() or info.issym() or info.islnk() or init_fifo): raise ValueError(f"Unexpected special file in image: {info.name}") path = root / info.name if not info.issym() and "security.capability" in os.listxattr(path): capability = os.getxattr(path, "security.capability") revision = struct.unpack_from("> 24 == 3: capability = struct.pack("> 24 != 2: raise ValueError(f"Unsupported capability encoding: {info.name}") info.pax_headers["SCHILY.xattr.security.capability"] = capability.decode("utf-8", "surrogateescape") metadata[info.name] = {"uid": info.uid, "gid": info.gid, "mode": oct(info.mode), "capability": info.pax_headers.get("SCHILY.xattr.security.capability", "").encode("utf-8", "surrogateescape").hex()} return info output = work / "rootfs-aarch64.tar" with tarfile.open(output, "w", format=tarfile.PAX_FORMAT) as archive: # EROFS tar import otherwise synthesizes / using the host UID and 0777. # Record the filesystem root explicitly, not only its children. root_info = tarfile.TarInfo('.') root_info.type = tarfile.DIRTYPE root_info.mode = 0o755 archive.addfile(normalize(root_info)) for path in sorted(root.iterdir()): archive.add(path, arcname=path.name, filter=normalize) (work / "packages.json").write_text(json.dumps(records, indent=2) + "\n") (work / "archive-metadata.json").write_text(json.dumps(metadata, indent=2) + "\n") (work / "package-scripts.txt").write_text("\n".join(scripts)) (work / "packages.sha256").write_text("".join(f'{item["sha256"]} packages/{item["filename"]}\n' for item in records)) (work / "rootfs.sha256").write_text(f"{digest(output)} rootfs-aarch64.tar\n") print(f"PASS: archive with restored ownership and capabilities ({output.stat().st_size} bytes)")