Files
navop/script/package-linux-portable.py

764 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build a relocatable Linux package around a private glibc runtime."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import fnmatch
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Iterable, NoReturn
OPTIONAL_RUNTIME_LIBRARIES = (
"libnss_dns.so.2",
"libnss_files.so.2",
"libresolv.so.2",
)
REQUIRED_DLOPEN_RUNTIME_LIBRARIES = (
"libwayland-client.so.0",
"libwayland-cursor.so.0",
"libwayland-egl.so.1",
)
OPTIONAL_RUNTIME_PATTERNS = ("libnss_*.so.2",)
HOST_DRIVER_PATTERNS = (
"libcuda.so*",
"libnvidia-*.so*",
"libamdhip64.so*",
"libhsa-runtime64.so*",
"libroc*.so*",
"libigc.so*",
"libze_*.so*",
"libvulkan_*.so*",
)
USR_MERGE_PATH_ALIASES = (
("/bin", "/usr/bin"),
("/sbin", "/usr/sbin"),
("/lib", "/usr/lib"),
("/lib64", "/usr/lib64"),
)
COMMON_LIBRARY_DIRECTORIES = (
"/lib64",
"/lib",
"/usr/lib64",
"/usr/lib",
"/usr/local/lib",
)
@dataclass(frozen=True)
class TargetConfig:
machine: str
loader: str
platform_token: str
lib_token: str
library_directories: tuple[str, ...]
TARGET_CONFIGS = {
"aarch64-unknown-linux-gnu": TargetConfig(
machine="AArch64",
loader="ld-linux-aarch64.so.1",
platform_token="aarch64",
lib_token="lib",
library_directories=(
"/lib/aarch64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
*COMMON_LIBRARY_DIRECTORIES,
),
),
"x86_64-unknown-linux-gnu": TargetConfig(
machine="Advanced Micro Devices X86-64",
loader="ld-linux-x86-64.so.2",
platform_token="x86_64",
lib_token="lib64",
library_directories=(
"/lib/x86_64-linux-gnu",
"/usr/lib/x86_64-linux-gnu",
*COMMON_LIBRARY_DIRECTORIES,
),
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Bundle a Linux ELF with the runner's dynamic loader and "
"recursive shared-library closure."
)
)
parser.add_argument("--binary", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--launcher-source", required=True, type=Path)
parser.add_argument(
"--target",
default="aarch64-unknown-linux-gnu",
choices=tuple(TARGET_CONFIGS),
help="portable launcher target",
)
parser.add_argument(
"--glibc-baseline",
default="2.28",
help=(
"maximum GLIBC symbol version allowed in navop.real; the bundled "
"private runtime itself may be newer"
),
)
return parser.parse_args()
def fail(message: str) -> NoReturn:
raise SystemExit(f"Error: {message}")
def run(
command: list[str],
*,
check: bool = True,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
check=check,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def require_command(command: str) -> None:
if shutil.which(command) is None:
fail(f"required command is not installed: {command}")
def readelf(path: Path, *arguments: str) -> str:
result = run(["readelf", *arguments, str(path)], check=False)
if result.returncode != 0:
fail(f"readelf failed for {path}: {result.stderr.strip()}")
return result.stdout
def elf_machine(path: Path) -> str:
header = readelf(path, "-hW")
match = re.search(r"^\s*Machine:\s*(.+?)\s*$", header, re.MULTILINE)
if match is None:
fail(f"cannot determine ELF machine for {path}")
return match.group(1)
def elf_interpreter(path: Path) -> Path:
program_headers = readelf(path, "-lW")
matches = re.findall(
r"Requesting program interpreter:\s*([^\]]+)",
program_headers,
)
if len(matches) != 1:
fail(f"expected one PT_INTERP entry in {path}, found {len(matches)}")
return Path(matches[0])
def version_tuple(version: str) -> tuple[int, ...]:
if re.fullmatch(r"\d+(?:\.\d+)+", version) is None:
fail(f"invalid GLIBC baseline: {version}")
return tuple(int(component) for component in version.split("."))
def verify_binary_glibc_baseline(path: Path, maximum: str) -> None:
maximum_version = version_tuple(maximum)
version_info = readelf(path, "--version-info", "-W")
required_versions = {
match
for match in re.findall(r"\bGLIBC_(\d+(?:\.\d+)+)\b", version_info)
}
if not required_versions:
fail(f"readelf did not report any GLIBC versions for {path}")
highest = max(required_versions, key=version_tuple)
if version_tuple(highest) > maximum_version:
fail(
f"{path} requires GLIBC_{highest}, above the supported "
f"GLIBC_{maximum} binary baseline"
)
def dynamic_metadata(
path: Path,
target: TargetConfig,
) -> tuple[list[str], list[Path]]:
dynamic = readelf(path, "-dW")
needed = re.findall(r"\(NEEDED\).*?Shared library:\s*\[([^\]]+)\]", dynamic)
search_paths: list[Path] = []
origin = path.resolve().parent
for raw in re.findall(
r"\((?:RPATH|RUNPATH)\).*?Library (?:rpath|runpath):\s*\[([^\]]*)\]",
dynamic,
):
for item in raw.split(":"):
expanded = item
for token, value in (
("ORIGIN", str(origin)),
("LIB", target.lib_token),
("PLATFORM", target.platform_token),
):
expanded = expanded.replace(f"${{{token}}}", value).replace(
f"${token}",
value,
)
if expanded:
search_paths.append(Path(expanded))
return needed, search_paths
def ldconfig_cache() -> dict[str, list[Path]]:
result = run(["ldconfig", "-p"], check=False)
if result.returncode != 0:
fail(f"ldconfig -p failed: {result.stderr.strip()}")
cache: dict[str, list[Path]] = {}
for line in result.stdout.splitlines():
match = re.match(r"^\s*(\S+)\s+\([^)]+\)\s+=>\s+(\S+)\s*$", line)
if match is None:
continue
cache.setdefault(match.group(1), []).append(Path(match.group(2)))
return cache
def is_host_driver(soname: str) -> bool:
return any(fnmatch.fnmatch(soname, pattern) for pattern in HOST_DRIVER_PATTERNS)
def optional_runtime_sonames(cache: dict[str, list[Path]]) -> list[str]:
sonames = set(OPTIONAL_RUNTIME_LIBRARIES)
sonames.update(
soname
for soname in cache
if any(
fnmatch.fnmatch(soname, pattern)
for pattern in OPTIONAL_RUNTIME_PATTERNS
)
)
return sorted(sonames)
def resolve_library(
soname: str,
*,
consumer: Path,
consumer_search_paths: Iterable[Path],
cache: dict[str, list[Path]],
machine: str,
library_directories: Iterable[str],
) -> Path | None:
candidates: list[Path] = []
candidates.extend(path / soname for path in consumer_search_paths)
candidates.extend(cache.get(soname, []))
candidates.extend(Path(path) / soname for path in library_directories)
seen: set[Path] = set()
for candidate in candidates:
try:
resolved = candidate.resolve(strict=True)
except (FileNotFoundError, OSError):
continue
if resolved in seen or not resolved.is_file():
continue
seen.add(resolved)
try:
candidate_machine = elf_machine(resolved)
except SystemExit:
continue
if candidate_machine == machine:
return resolved
print(
f"warning: unable to resolve {soname} required by {consumer}",
file=sys.stderr,
)
return None
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def copy_runtime_file(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
if sha256(source) != sha256(destination):
fail(
"conflicting runtime libraries share the same bundled name: "
f"{source} and {destination}"
)
return
shutil.copy2(source, destination)
destination.chmod(destination.stat().st_mode | 0o444)
def package_owner_query_paths(path: Path) -> tuple[Path, ...]:
candidates: list[Path] = []
def append(candidate: Path) -> None:
if candidate not in candidates:
candidates.append(candidate)
append(path)
try:
append(path.resolve())
except OSError:
pass
for candidate in tuple(candidates):
candidate_text = str(candidate)
for legacy_prefix, merged_prefix in USR_MERGE_PATH_ALIASES:
for source_prefix, destination_prefix in (
(legacy_prefix, merged_prefix),
(merged_prefix, legacy_prefix),
):
if candidate_text == source_prefix or candidate_text.startswith(
f"{source_prefix}/"
):
append(
Path(
f"{destination_prefix}"
f"{candidate_text[len(source_prefix):]}"
)
)
return tuple(candidates)
def package_owner(path: Path) -> str | None:
for candidate in package_owner_query_paths(path):
result = run(["dpkg-query", "-S", str(candidate)], check=False)
if result.returncode != 0:
continue
for line in result.stdout.splitlines():
package, separator, _ = line.partition(": ")
if separator and package:
return package
return None
def package_version(package: str) -> str:
result = run(
["dpkg-query", "-W", "-f=${binary:Package}\t${Version}", package],
check=False,
)
if result.returncode != 0:
fail(f"cannot determine installed version for runtime package {package}")
fields = result.stdout.strip().split("\t", 1)
if len(fields) != 2 or not fields[1]:
fail(f"invalid dpkg-query version output for runtime package {package}")
return fields[1]
def copy_package_licenses(
packaged_sources: dict[str, Path],
license_directory: Path,
) -> list[dict[str, object]]:
packages: dict[str, set[str]] = {}
for bundled_name, source in packaged_sources.items():
owner = package_owner(source)
if owner is None:
fail(
"cannot publish a bundled runtime file without Debian package "
f"ownership metadata: {source}"
)
packages.setdefault(owner, set()).add(bundled_name)
license_directory.mkdir(parents=True, exist_ok=True)
package_records: list[dict[str, object]] = []
for package in sorted(packages):
package_base = package.split(":", 1)[0]
copyright_source = Path("/usr/share/doc") / package_base / "copyright"
copyright_destination = license_directory / f"{package_base}.copyright"
if copyright_source.is_file():
shutil.copy2(copyright_source, copyright_destination)
else:
fail(
"cannot publish bundled runtime package without its copyright "
f"file: {package} ({copyright_source})"
)
package_records.append(
{
"package": package,
"version": package_version(package),
"files": sorted(packages[package]),
"license": str(copyright_destination.name),
}
)
return package_records
def compile_launcher(
source: Path,
destination: Path,
expected_machine: str,
) -> str:
destination.parent.mkdir(parents=True, exist_ok=True)
run(
[
"musl-gcc",
"-static",
"-Os",
"-s",
"-Wl,--build-id=none",
str(source),
"-o",
str(destination),
]
)
destination.chmod(0o755)
if "Requesting program interpreter" in readelf(destination, "-lW"):
fail(f"portable launcher is not static: {destination}")
launcher_machine = elf_machine(destination)
if launcher_machine != expected_machine:
fail(
"portable launcher architecture mismatch: "
f"expected {expected_machine}, got {launcher_machine}"
)
return launcher_machine
def verify_private_runtime(loader: Path, library_directory: Path, binary: Path) -> None:
environment = dict(os.environ)
for name in ("LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD", "LD_PROFILE"):
environment.pop(name, None)
verify = run([str(loader), "--verify", str(binary)], check=False, env=environment)
if verify.returncode != 0:
fail(
"bundled loader rejected navop.real: "
f"{verify.stderr.strip() or verify.stdout.strip()}"
)
listed = run(
[
str(loader),
"--inhibit-cache",
"--library-path",
str(library_directory),
"--list",
str(binary),
],
check=False,
env=environment,
)
if listed.returncode != 0:
fail(
"bundled loader could not resolve navop.real dependencies: "
f"{listed.stderr.strip() or listed.stdout.strip()}"
)
print(listed.stdout.rstrip())
def manifest_files(output: Path, manifest: Path) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
for path in sorted(output.rglob("*")):
if not path.is_file() or path == manifest:
continue
relative = path.relative_to(output).as_posix()
records.append(
{
"path": relative,
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
return records
def main() -> None:
args = parse_args()
target = TARGET_CONFIGS[args.target]
for command in ("dpkg-query", "ldconfig", "musl-gcc", "readelf"):
require_command(command)
binary = args.binary.resolve()
launcher_source = args.launcher_source.resolve()
output = args.output.resolve()
repository_root = Path(__file__).resolve().parent.parent
if not binary.is_file():
fail(f"release binary does not exist: {binary}")
if not launcher_source.is_file():
fail(f"launcher source does not exist: {launcher_source}")
if output == Path("/") or output == repository_root:
fail(f"refusing to replace unsafe output directory: {output}")
machine = elf_machine(binary)
if machine != target.machine:
fail(
f"portable package for {args.target} expects {target.machine}, "
f"got {machine}"
)
interpreter = elf_interpreter(binary)
if interpreter.name != target.loader:
fail(
f"unexpected ELF interpreter for {args.target}: {interpreter}; "
f"expected {target.loader}"
)
if not interpreter.is_file():
fail(f"ELF interpreter does not exist on the build runner: {interpreter}")
verify_binary_glibc_baseline(binary, args.glibc_baseline)
if output.exists():
shutil.rmtree(output)
runtime_root = output / "usr/lib/navop"
binary_destination = runtime_root / "bin/navop.real"
library_directory = runtime_root / "lib"
launcher_destination = output / "usr/bin/navop"
documentation_directory = output / "usr/share/doc/navop"
runtime_license_directory = documentation_directory / "runtime-licenses"
binary_destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(binary, binary_destination)
binary_destination.chmod(0o755)
cache = ldconfig_cache()
queue: list[Path] = [binary]
scanned: set[Path] = set()
runtime_sources: dict[str, Path] = {
interpreter.name: interpreter.resolve(),
}
while queue:
consumer = queue.pop(0).resolve()
if consumer in scanned:
continue
scanned.add(consumer)
if elf_machine(consumer) != machine:
fail(f"runtime architecture mismatch: {consumer}")
needed, search_paths = dynamic_metadata(consumer, target)
for soname in needed:
if is_host_driver(soname):
fail(
f"{consumer} directly depends on host GPU driver {soname}; "
"portable packages may only use host drivers through runtime discovery"
)
source = resolve_library(
soname,
consumer=consumer,
consumer_search_paths=search_paths,
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
fail(f"missing required shared library {soname} for {consumer}")
previous = runtime_sources.get(soname)
if previous is not None and previous != source:
if sha256(previous) != sha256(source):
fail(
f"{soname} resolves to conflicting files: {previous} and {source}"
)
else:
runtime_sources[soname] = source
queue.append(source)
for soname in REQUIRED_DLOPEN_RUNTIME_LIBRARIES:
source = resolve_library(
soname,
consumer=binary,
consumer_search_paths=(),
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
fail(
f"missing required dlopen runtime library {soname} "
f"for portable Wayland support"
)
previous = runtime_sources.get(soname)
if previous is not None and previous != source:
if sha256(previous) != sha256(source):
fail(
f"{soname} resolves to conflicting files: {previous} and {source}"
)
else:
runtime_sources[soname] = source
queue.append(source)
for soname in optional_runtime_sonames(cache):
if soname in runtime_sources:
continue
source = resolve_library(
soname,
consumer=binary,
consumer_search_paths=(),
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
continue
runtime_sources[soname] = source
queue.append(source)
while queue:
consumer = queue.pop(0).resolve()
if consumer in scanned:
continue
scanned.add(consumer)
if elf_machine(consumer) != machine:
fail(f"runtime architecture mismatch: {consumer}")
needed, search_paths = dynamic_metadata(consumer, target)
for soname in needed:
if is_host_driver(soname):
fail(
f"{consumer} directly depends on host GPU driver {soname}; "
"portable packages may only use host drivers through runtime discovery"
)
source = resolve_library(
soname,
consumer=consumer,
consumer_search_paths=search_paths,
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
fail(f"missing required shared library {soname} for {consumer}")
previous = runtime_sources.get(soname)
if previous is not None and previous != source:
if sha256(previous) != sha256(source):
fail(
f"{soname} resolves to conflicting files: {previous} and {source}"
)
else:
runtime_sources[soname] = source
queue.append(source)
for bundled_name, source in sorted(runtime_sources.items()):
if elf_machine(source) != machine:
fail(f"runtime architecture mismatch: {source}")
copy_runtime_file(source, library_directory / bundled_name)
libc_source = runtime_sources.get("libc.so.6")
if libc_source is None:
fail("recursive dependency closure did not contain libc.so.6")
gconv_source = libc_source.parent / "gconv"
license_sources = dict(runtime_sources)
if gconv_source.is_dir():
for source in sorted(gconv_source.rglob("*")):
if source.is_file():
relative = source.relative_to(gconv_source).as_posix()
license_sources[f"gconv/{relative}"] = libc_source
shutil.copytree(
gconv_source,
library_directory / "gconv",
symlinks=False,
)
else:
print(
f"warning: glibc conversion modules were not found beside {libc_source}",
file=sys.stderr,
)
documentation_directory.mkdir(parents=True, exist_ok=True)
for license_name in ("LICENSE-APACHE", "NAVOP_LICENSE"):
source = repository_root / license_name
if not source.is_file():
fail(f"project license file is missing: {source}")
shutil.copy2(source, documentation_directory / license_name)
package_records = copy_package_licenses(
license_sources,
runtime_license_directory,
)
packages_file = runtime_root / "runtime-packages.txt"
packages_file.write_text(
"".join(
f"{record['package']}\t{record['version']}\t"
f"{','.join(record['files'])}\n"
for record in package_records
),
encoding="utf-8",
)
launcher_machine = compile_launcher(
launcher_source,
launcher_destination,
machine,
)
bundled_loader = library_directory / interpreter.name
verify_private_runtime(
bundled_loader,
library_directory,
binary_destination,
)
manifest_path = runtime_root / "runtime-manifest.json"
manifest = {
"schema_version": 1,
"product": "navop",
"target": args.target,
"elf_machine": machine,
"interpreter": str(interpreter),
"launcher_machine": launcher_machine,
"platform_token": target.platform_token,
"lib_token": target.lib_token,
"binary_glibc_baseline": args.glibc_baseline,
"entrypoint": "usr/bin/navop",
"binary": "usr/lib/navop/bin/navop.real",
"loader": f"usr/lib/navop/lib/{interpreter.name}",
"library_path": "usr/lib/navop/lib",
"gpu_policy": (
"host vendor libraries are discovered dynamically and are not bundled"
),
"nss_policy": (
"glibc NSS modules are bundled when available; host DNS, NSS, and "
"certificate configuration remains authoritative"
),
"packages": package_records,
"host_interfaces": [
"Linux kernel and procfs",
"Wayland or X11 display sockets",
"D-Bus session and system buses",
"GPU devices and host vendor drivers",
"system fonts and font configuration",
"CA certificates, DNS, and NSS configuration",
],
"files": manifest_files(output, manifest_path),
}
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"Packaged {len(runtime_sources)} runtime ELF files into {output}",
)
if __name__ == "__main__":
main()