On a macOS Release build staged and split with split-debug-symbols.sh, check_install_surface reported every binary as shipping a symbol table. The only stab left after strip -S -x is the 'OPT radr://5614542' entry ld64 always writes; it carries no debug information. Unstripped binaries still trip the check (135,012 stabs in an unsplit yams-cli). Verified on macOS arm64 (ONNX + Glint build): RED with 8 false 'stripped' violations before, GREEN after. Signed-off-by: trvon <me@trevon.dev>
601 lines
24 KiB
Python
Executable file
601 lines
24 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Check the staged YAMS install surface against a checked-in policy.
|
|
|
|
The runtime install is a contract: every installed file, exported symbol and
|
|
missing hardening flag is something a package ships. This script stages a
|
|
`meson install --destdir` for a build directory (or reads an already staged
|
|
tree), then asserts it against scripts/ci/install_surface_policy.json:
|
|
|
|
* paths: every installed path matches an allowed pattern, no forbidden
|
|
pattern (headers, static archives, pkg-config, CMake files) and
|
|
every required path is present
|
|
* sizes: per-binary size budgets
|
|
* exports: per-object exported-symbol budgets and forbidden third-party
|
|
exports (sqlite3_, SSL_, curl_, boost::, spdlog::, ...)
|
|
* rpath: only $ORIGIN / @loader_path / @executable_path relative entries
|
|
* hardening: ELF PIE, BIND_NOW, GNU_RELRO, non-executable stack; Mach-O PIE
|
|
* stripped: no debug sections or symbol tables in shipped binaries
|
|
|
|
Usage:
|
|
scripts/ci/check_install_surface.py --build-dir build/release
|
|
scripts/ci/check_install_surface.py --stage /path/to/destdir --prefix /usr
|
|
|
|
With --build-dir the script stages into a temporary directory, runs the debug
|
|
split (scripts/split-debug-symbols.sh) exactly like the release workflow and
|
|
then checks. Pass --no-split to check the raw `meson install` output.
|
|
|
|
Only the Python standard library and binutils/cctools are required: readelf
|
|
and nm on Linux (llvm-readelf/llvm-nm are accepted), otool and nm on macOS.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_POLICY = REPO_ROOT / "scripts" / "ci" / "install_surface_policy.json"
|
|
SPLIT_SCRIPT = REPO_ROOT / "scripts" / "split-debug-symbols.sh"
|
|
|
|
ELF_MAGIC = b"\x7fELF"
|
|
MACHO_MAGICS = {
|
|
b"\xfe\xed\xfa\xce",
|
|
b"\xce\xfa\xed\xfe",
|
|
b"\xfe\xed\xfa\xcf",
|
|
b"\xcf\xfa\xed\xfe",
|
|
b"\xca\xfe\xba\xbe", # fat/universal
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- helpers
|
|
|
|
|
|
def glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
"""Translate a path glob with `**` support into an anchored regex."""
|
|
out = []
|
|
i = 0
|
|
while i < len(pattern):
|
|
c = pattern[i]
|
|
if pattern.startswith("**/", i):
|
|
out.append("(?:.*/)?")
|
|
i += 3
|
|
continue
|
|
if pattern.startswith("**", i):
|
|
out.append(".*")
|
|
i += 2
|
|
continue
|
|
if c == "*":
|
|
out.append("[^/]*")
|
|
elif c == "?":
|
|
out.append("[^/]")
|
|
else:
|
|
out.append(re.escape(c))
|
|
i += 1
|
|
return re.compile("^" + "".join(out) + "$")
|
|
|
|
|
|
class PatternSet:
|
|
def __init__(self, patterns: list[str], subst: dict[str, str]):
|
|
self.raw = [expand(p, subst) for p in patterns]
|
|
self.compiled = [(p, glob_to_regex(p)) for p in self.raw]
|
|
|
|
def match(self, rel: str) -> str | None:
|
|
for raw, rx in self.compiled:
|
|
if rx.match(rel):
|
|
return raw
|
|
return None
|
|
|
|
|
|
def expand(pattern: str, subst: dict[str, str]) -> str:
|
|
for key, value in subst.items():
|
|
pattern = pattern.replace("{" + key + "}", value)
|
|
return pattern
|
|
|
|
|
|
def run(cmd: list[str], check: bool = True) -> str:
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
if check and proc.returncode != 0:
|
|
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr}")
|
|
return proc.stdout
|
|
|
|
|
|
def which_any(*names: str) -> str | None:
|
|
for name in names:
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
return None
|
|
|
|
|
|
def human(n: int) -> str:
|
|
value = float(n)
|
|
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
if value < 1024 or unit == "GiB":
|
|
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
|
|
value /= 1024
|
|
return f"{n} B"
|
|
|
|
|
|
def binary_kind(path: Path) -> str | None:
|
|
try:
|
|
with path.open("rb") as fh:
|
|
magic = fh.read(4)
|
|
except OSError:
|
|
return None
|
|
if magic == ELF_MAGIC:
|
|
return "elf"
|
|
if magic in MACHO_MAGICS:
|
|
return "macho"
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------- inspection
|
|
|
|
|
|
@dataclass
|
|
class BinaryInfo:
|
|
rel: str
|
|
kind: str # elf | macho
|
|
size: int
|
|
role: str = "unknown" # executable | shared
|
|
machine: str = ""
|
|
exports: list[str] = field(default_factory=list)
|
|
pie: bool | None = None
|
|
bind_now: bool | None = None
|
|
relro: bool | None = None
|
|
nx: bool | None = None
|
|
cet: bool | None = None
|
|
fortify_imports: int = 0
|
|
rpaths: list[str] = field(default_factory=list)
|
|
debug_sections: list[str] = field(default_factory=list)
|
|
has_symtab: bool = False
|
|
|
|
|
|
class ElfInspector:
|
|
def __init__(self) -> None:
|
|
self.readelf = which_any("readelf", "llvm-readelf", "eu-readelf")
|
|
self.nm = which_any("nm", "llvm-nm")
|
|
if not self.readelf or not self.nm:
|
|
raise SystemExit("readelf and nm are required to inspect ELF files")
|
|
|
|
def inspect(self, path: Path, rel: str) -> BinaryInfo:
|
|
info = BinaryInfo(rel=rel, kind="elf", size=path.stat().st_size)
|
|
header = run([self.readelf, "-W", "-h", str(path)])
|
|
etype = re.search(r"^\s*Type:\s*(\S+)", header, re.M)
|
|
machine = re.search(r"^\s*Machine:\s*(.+)$", header, re.M)
|
|
info.machine = machine.group(1).strip() if machine else ""
|
|
|
|
phdrs = run([self.readelf, "-W", "-l", str(path)], check=False)
|
|
has_interp = re.search(r"^\s*INTERP\s", phdrs, re.M) is not None
|
|
info.relro = re.search(r"^\s*GNU_RELRO\s", phdrs, re.M) is not None
|
|
stack = re.search(r"^\s*GNU_STACK\s.*\s(R?W?E?)\s+0x[0-9a-f]+\s*$", phdrs, re.M)
|
|
info.nx = bool(stack) and "E" not in stack.group(1)
|
|
|
|
dyn = run([self.readelf, "-W", "-d", str(path)], check=False)
|
|
flags_line = " ".join(
|
|
line for line in dyn.splitlines() if "(FLAGS" in line or "(BIND_NOW)" in line
|
|
)
|
|
info.bind_now = bool(re.search(r"\bBIND_NOW\b|\bNOW\b", flags_line))
|
|
is_pie_flag = "PIE" in flags_line
|
|
for match in re.finditer(r"\((?:RUNPATH|RPATH)\).*\[(.*)\]", dyn):
|
|
info.rpaths.extend(p for p in match.group(1).split(":") if p)
|
|
|
|
etype_value = etype.group(1) if etype else ""
|
|
if has_interp or etype_value == "EXEC":
|
|
info.role = "executable"
|
|
info.pie = etype_value == "DYN" or is_pie_flag
|
|
else:
|
|
info.role = "shared"
|
|
info.pie = None
|
|
|
|
sections = run([self.readelf, "-W", "-S", str(path)], check=False)
|
|
names = re.findall(r"^\s*\[\s*\d+\]\s+(\S+)", sections, re.M)
|
|
info.debug_sections = sorted(
|
|
{n for n in names if n.startswith(".debug_") or n.startswith(".zdebug_")}
|
|
)
|
|
info.has_symtab = ".symtab" in names
|
|
|
|
notes = run([self.readelf, "-W", "-n", str(path)], check=False)
|
|
if "X86-64" in info.machine or "x86-64" in info.machine.lower():
|
|
feature = re.search(r"x86 feature:\s*(.*)$", notes, re.M)
|
|
info.cet = bool(feature) and "IBT" in feature.group(1) and "SHSTK" in feature.group(1)
|
|
|
|
exported = run([self.nm, "-D", "--defined-only", "-C", str(path)], check=False)
|
|
info.exports = parse_nm(exported)
|
|
undefined = run([self.nm, "-D", "--undefined-only", str(path)], check=False)
|
|
info.fortify_imports = len(re.findall(r"__\w+_chk\b", undefined))
|
|
return info
|
|
|
|
|
|
class MachOInspector:
|
|
def __init__(self) -> None:
|
|
self.otool = which_any("otool", "llvm-otool")
|
|
self.nm = which_any("nm", "llvm-nm")
|
|
if not self.otool or not self.nm:
|
|
raise SystemExit("otool and nm are required to inspect Mach-O files")
|
|
|
|
def inspect(self, path: Path, rel: str) -> BinaryInfo:
|
|
info = BinaryInfo(rel=rel, kind="macho", size=path.stat().st_size)
|
|
header = run([self.otool, "-hv", str(path)], check=False)
|
|
if " EXECUTE " in header:
|
|
info.role = "executable"
|
|
info.pie = " PIE" in header
|
|
else:
|
|
info.role = "shared"
|
|
load = run([self.otool, "-l", str(path)], check=False)
|
|
for match in re.finditer(r"cmd LC_RPATH\n\s*cmdsize \d+\n\s*path (\S+)", load):
|
|
info.rpaths.append(match.group(1))
|
|
info.debug_sections = sorted(set(re.findall(r"sectname (__debug_\w+)", load)))
|
|
stabs = run([self.nm, "-ap", str(path)], check=False)
|
|
# Debug stabs (N_SO/N_OSO/N_FUN...) show as type "-". ld64 always emits one
|
|
# "OPT radr://5614542" marker that `strip -S` keeps; it carries no debug info.
|
|
info.has_symtab = any(
|
|
" - " in line and "radr://5614542" not in line for line in stabs.splitlines()
|
|
)
|
|
exported = run([self.nm, "-gUjC", str(path)], check=False)
|
|
info.exports = [line.strip() for line in exported.splitlines() if line.strip()]
|
|
undefined = run([self.nm, "-guj", str(path)], check=False)
|
|
info.fortify_imports = len(re.findall(r"___\w+_chk\b", undefined))
|
|
return info
|
|
|
|
|
|
def parse_nm(text: str) -> list[str]:
|
|
names = []
|
|
for line in text.splitlines():
|
|
parts = line.split(None, 2)
|
|
if len(parts) == 3:
|
|
_, typ, name = parts
|
|
elif len(parts) == 2:
|
|
typ, name = parts
|
|
else:
|
|
continue
|
|
if typ in ("U", "w", "v"):
|
|
continue
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
# --------------------------------------------------------------------------- staging
|
|
|
|
|
|
def meson_buildoptions(build_dir: Path) -> dict[str, object]:
|
|
raw = run(["meson", "introspect", "--buildoptions", str(build_dir)])
|
|
return {opt["name"]: opt["value"] for opt in json.loads(raw)}
|
|
|
|
|
|
def stage_build(build_dir: Path, destdir: Path, tags: str | None) -> None:
|
|
cmd = ["meson", "install", "-C", str(build_dir), "--destdir", str(destdir), "--no-rebuild"]
|
|
if tags:
|
|
cmd += ["--tags", tags]
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
if proc.returncode != 0:
|
|
sys.stderr.write(proc.stdout)
|
|
raise SystemExit(f"meson install failed for {build_dir}")
|
|
|
|
|
|
def split_debug(install_root: Path, debug_dir: Path) -> None:
|
|
if not SPLIT_SCRIPT.exists():
|
|
print(f"note: {SPLIT_SCRIPT.relative_to(REPO_ROOT)} not found; skipping debug split")
|
|
return
|
|
proc = subprocess.run(
|
|
["bash", str(SPLIT_SCRIPT), str(install_root), str(debug_dir)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
if proc.returncode != 0:
|
|
sys.stderr.write(proc.stdout)
|
|
raise SystemExit("debug split failed")
|
|
|
|
|
|
# --------------------------------------------------------------------------- checks
|
|
|
|
|
|
@dataclass
|
|
class Report:
|
|
errors: list[str] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
def error(self, msg: str) -> None:
|
|
self.errors.append(msg)
|
|
|
|
def warn(self, msg: str) -> None:
|
|
self.warnings.append(msg)
|
|
|
|
|
|
def first_budget(rules: list[dict], rel: str, subst: dict[str, str], key: str, kind: str = ""):
|
|
for rule in rules:
|
|
if kind and kind not in rule.get("kinds", [kind]):
|
|
continue
|
|
if glob_to_regex(expand(rule["pattern"], subst)).match(rel):
|
|
return rule[key], rule["pattern"]
|
|
return None, None
|
|
|
|
|
|
def collect_tree(install_root: Path) -> tuple[list[str], dict[str, Path]]:
|
|
rels: list[str] = []
|
|
paths: dict[str, Path] = {}
|
|
for dirpath, dirnames, filenames in os.walk(install_root):
|
|
dirnames.sort()
|
|
base = Path(dirpath)
|
|
for name in sorted(filenames) + sorted(d for d in dirnames if (base / d).is_symlink()):
|
|
p = base / name
|
|
rel = p.relative_to(install_root).as_posix()
|
|
rels.append(rel)
|
|
paths[rel] = p
|
|
return sorted(set(rels)), paths
|
|
|
|
|
|
def check(
|
|
destdir: Path,
|
|
prefix: str,
|
|
policy: dict,
|
|
subst: dict[str, str],
|
|
report: Report,
|
|
) -> list[BinaryInfo]:
|
|
install_root = destdir / prefix.lstrip("/")
|
|
if not install_root.is_dir():
|
|
report.error(f"install root missing: {install_root}")
|
|
return []
|
|
|
|
# Anything staged outside the prefix (e.g. /etc) must be explicitly allowed.
|
|
outside_allowed = PatternSet(policy.get("allowed_outside_prefix", []), subst)
|
|
for dirpath, _, filenames in os.walk(destdir):
|
|
for name in filenames:
|
|
p = Path(dirpath) / name
|
|
try:
|
|
p.relative_to(install_root)
|
|
except ValueError:
|
|
rel_abs = "/" + p.relative_to(destdir).as_posix()
|
|
if not outside_allowed.match(rel_abs):
|
|
report.error(f"path: installed outside prefix: {rel_abs}")
|
|
|
|
rels, paths = collect_tree(install_root)
|
|
allowed = PatternSet(policy["paths"]["allowed"], subst)
|
|
forbidden = PatternSet(policy["paths"].get("forbidden", []), subst)
|
|
forbidden_hits: dict[str, list[str]] = {}
|
|
for rel in rels:
|
|
hit = forbidden.match(rel)
|
|
if hit:
|
|
forbidden_hits.setdefault(hit, []).append(rel)
|
|
elif not allowed.match(rel):
|
|
report.error(f"path: not in the allowed runtime set: {rel}")
|
|
p = paths[rel]
|
|
if p.is_symlink():
|
|
target = (p.parent / os.readlink(p)).resolve()
|
|
try:
|
|
target.relative_to(install_root.resolve())
|
|
except ValueError:
|
|
report.error(f"path: symlink escapes the install prefix: {rel} -> {os.readlink(p)}")
|
|
if not target.exists():
|
|
report.error(f"path: dangling symlink: {rel} -> {os.readlink(p)}")
|
|
|
|
for pattern, hits in sorted(forbidden_hits.items()):
|
|
size = sum(paths[h].lstat().st_size for h in hits)
|
|
sample = ", ".join(hits[:3])
|
|
report.error(
|
|
f"path: {len(hits)} forbidden path(s) ({human(size)}) match {pattern}: {sample}"
|
|
f"{', ...' if len(hits) > 3 else ''}"
|
|
)
|
|
|
|
for req in policy["paths"].get("required", []):
|
|
rx = glob_to_regex(expand(req, subst))
|
|
if not any(rx.match(r) for r in rels):
|
|
report.error(f"path: required runtime path missing: {expand(req, subst)}")
|
|
|
|
# Binary inspection
|
|
inspectors: dict[str, object] = {}
|
|
infos: list[BinaryInfo] = []
|
|
for rel in rels:
|
|
p = paths[rel]
|
|
if p.is_symlink() or not p.is_file():
|
|
continue
|
|
kind = binary_kind(p)
|
|
if kind is None:
|
|
continue
|
|
if kind not in inspectors:
|
|
inspectors[kind] = ElfInspector() if kind == "elf" else MachOInspector()
|
|
infos.append(inspectors[kind].inspect(p, rel)) # type: ignore[attr-defined]
|
|
|
|
hard = policy.get("hardening", {})
|
|
strip_policy = policy.get("stripped", {})
|
|
forbidden_exports = [re.compile(x) for x in policy.get("forbidden_exports", [])]
|
|
export_rules = policy.get("export_budgets", [])
|
|
size_rules = policy.get("size_budgets", [])
|
|
exempt = PatternSet(policy.get("third_party_exempt", []), subst)
|
|
|
|
for info in infos:
|
|
is_third_party = exempt.match(info.rel) is not None
|
|
|
|
budget, pat = first_budget(size_rules, info.rel, subst, "max_bytes", info.kind)
|
|
if budget is not None and info.size > budget:
|
|
report.error(
|
|
f"size: {info.rel} is {human(info.size)} > budget {human(budget)} ({pat})"
|
|
)
|
|
|
|
# Mach-O executables keep every non-hidden global in their export trie
|
|
# whether or not anything can bind to it; the dynamic surface that
|
|
# matters on macOS is the dylib/bundle export list.
|
|
check_exports = not (info.kind == "macho" and info.role == "executable")
|
|
budget, pat = first_budget(export_rules, info.rel, subst, "max")
|
|
if check_exports and budget is not None and len(info.exports) > budget:
|
|
report.error(
|
|
f"exports: {info.rel} exports {len(info.exports)} symbols > budget {budget} ({pat})"
|
|
)
|
|
if check_exports and not is_third_party:
|
|
leaked: dict[str, list[str]] = {}
|
|
for name in info.exports:
|
|
for rx in forbidden_exports:
|
|
if rx.search(name):
|
|
leaked.setdefault(rx.pattern, []).append(name)
|
|
break
|
|
for pattern, names in sorted(leaked.items()):
|
|
sample = ", ".join(n if len(n) <= 60 else n[:57] + "..." for n in names[:3])
|
|
report.error(
|
|
f"exports: {info.rel} leaks {len(names)} symbol(s) matching /{pattern}/ "
|
|
f"(e.g. {sample})"
|
|
)
|
|
|
|
for rp in info.rpaths:
|
|
if not (rp.startswith("$ORIGIN") or rp.startswith("${ORIGIN}") or rp.startswith("@")):
|
|
report.error(f"rpath: {info.rel} has non-relative RUNPATH/RPATH entry {rp!r}")
|
|
|
|
if strip_policy.get("no_debug_sections") and info.debug_sections:
|
|
report.error(
|
|
f"stripped: {info.rel} ships debug sections ({', '.join(info.debug_sections[:4])}"
|
|
f"{'...' if len(info.debug_sections) > 4 else ''})"
|
|
)
|
|
if strip_policy.get("no_symtab") and info.has_symtab and not is_third_party:
|
|
report.error(f"stripped: {info.rel} ships a full symbol table (.symtab / stabs)")
|
|
|
|
if info.kind == "elf" and not is_third_party:
|
|
if hard.get("pie") and info.role == "executable" and not info.pie:
|
|
report.error(f"hardening: {info.rel} is not PIE")
|
|
if hard.get("bind_now") and not info.bind_now:
|
|
report.error(f"hardening: {info.rel} lacks BIND_NOW (full RELRO)")
|
|
if hard.get("relro") and not info.relro:
|
|
report.error(f"hardening: {info.rel} lacks a GNU_RELRO segment")
|
|
if hard.get("nx") and info.nx is False:
|
|
report.error(f"hardening: {info.rel} has an executable or missing GNU_STACK")
|
|
cet_mode = hard.get("cet_x86_64", "off")
|
|
if info.cet is False and cet_mode != "off":
|
|
msg = f"hardening: {info.rel} lacks x86 IBT/SHSTK notes (-fcf-protection)"
|
|
(report.error if cet_mode == "require" else report.warn)(msg)
|
|
fortify_mode = hard.get("fortify_imports", "off")
|
|
if info.role == "executable" and info.fortify_imports == 0 and fortify_mode != "off":
|
|
msg = f"hardening: {info.rel} imports no __*_chk functions (_FORTIFY_SOURCE inert?)"
|
|
(report.error if fortify_mode == "require" else report.warn)(msg)
|
|
if info.kind == "macho" and hard.get("pie") and info.role == "executable" and not info.pie:
|
|
report.error(f"hardening: {info.rel} is not PIE (MH_PIE)")
|
|
|
|
return infos
|
|
|
|
|
|
def print_table(infos: list[BinaryInfo], rels_total: int, total_bytes: int) -> None:
|
|
print(f"\nStaged runtime: {rels_total} paths, {human(total_bytes)} total")
|
|
if not infos:
|
|
return
|
|
width = max(len(i.rel) for i in infos)
|
|
print(
|
|
f"{'binary'.ljust(width)} {'bytes':>11} {'exports':>7} pie now relro nx cet "
|
|
f"chk debug symtab rpath"
|
|
)
|
|
|
|
def yn(v):
|
|
return "-" if v is None else ("y" if v else "n")
|
|
|
|
for i in sorted(infos, key=lambda x: x.rel):
|
|
print(
|
|
f"{i.rel.ljust(width)} {i.size:>11} {len(i.exports):>7} {yn(i.pie):>3} "
|
|
f"{yn(i.bind_now):>3} {yn(i.relro):>5} {yn(i.nx):>2} {yn(i.cet):>3} "
|
|
f"{i.fortify_imports:>3} {('y' if i.debug_sections else 'n'):>5} "
|
|
f"{('y' if i.has_symtab else 'n'):>6} {':'.join(i.rpaths) or '-'}"
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
src = ap.add_mutually_exclusive_group(required=True)
|
|
src.add_argument("--build-dir", type=Path, help="Meson build dir to stage with meson install")
|
|
src.add_argument("--stage", type=Path, help="already staged DESTDIR root to check")
|
|
ap.add_argument("--prefix", help="install prefix inside the stage (default: from meson)")
|
|
ap.add_argument("--libdir", help="libdir relative to prefix (default: from meson, else lib)")
|
|
ap.add_argument("--policy", type=Path, default=DEFAULT_POLICY)
|
|
ap.add_argument("--tags", help="meson install --tags subset to stage (default: everything)")
|
|
ap.add_argument("--no-split", action="store_true", help="skip the debug split before checking")
|
|
ap.add_argument("--keep-stage", type=Path, help="stage into this directory and keep it")
|
|
ap.add_argument("--json", type=Path, help="write a machine-readable report here")
|
|
args = ap.parse_args(argv)
|
|
|
|
policy = json.loads(args.policy.read_text())
|
|
prefix = args.prefix
|
|
libdir = args.libdir
|
|
tmp: tempfile.TemporaryDirectory[str] | None = None
|
|
|
|
if args.build_dir:
|
|
opts = meson_buildoptions(args.build_dir)
|
|
prefix = prefix or str(opts["prefix"])
|
|
libdir = libdir or str(opts["libdir"])
|
|
if args.keep_stage:
|
|
if args.keep_stage.exists():
|
|
shutil.rmtree(args.keep_stage)
|
|
destdir = args.keep_stage.resolve()
|
|
else:
|
|
tmp = tempfile.TemporaryDirectory(prefix="yams-install-surface-")
|
|
destdir = Path(tmp.name) / "stage"
|
|
stage_build(args.build_dir, destdir, args.tags)
|
|
if not args.no_split:
|
|
split_debug(destdir / prefix.lstrip("/"), destdir.parent / "debug-symbols")
|
|
else:
|
|
destdir = args.stage.resolve()
|
|
if not prefix:
|
|
ap.error("--prefix is required with --stage")
|
|
libdir = libdir or "lib"
|
|
|
|
if os.path.isabs(libdir):
|
|
libdir = os.path.relpath(libdir, prefix)
|
|
subst = {"libdir": libdir.strip("/")}
|
|
report = Report()
|
|
try:
|
|
infos = check(destdir, prefix, policy, subst, report)
|
|
install_root = destdir / prefix.lstrip("/")
|
|
rels, paths = collect_tree(install_root) if install_root.is_dir() else ([], {})
|
|
total = sum(paths[r].lstat().st_size for r in rels)
|
|
print(f"Install surface check: {install_root} (policy {args.policy.name}, "
|
|
f"libdir={subst['libdir']}, host={platform.system()}/{platform.machine()})")
|
|
print_table(infos, len(rels), total)
|
|
if args.json:
|
|
args.json.write_text(
|
|
json.dumps(
|
|
{
|
|
"errors": report.errors,
|
|
"warnings": report.warnings,
|
|
"files": rels,
|
|
"total_bytes": total,
|
|
"binaries": [
|
|
{
|
|
"path": i.rel,
|
|
"bytes": i.size,
|
|
"exports": len(i.exports),
|
|
"pie": i.pie,
|
|
"bind_now": i.bind_now,
|
|
"relro": i.relro,
|
|
"nx": i.nx,
|
|
"cet": i.cet,
|
|
"fortify_imports": i.fortify_imports,
|
|
"debug_sections": bool(i.debug_sections),
|
|
"symtab": i.has_symtab,
|
|
"rpaths": i.rpaths,
|
|
}
|
|
for i in infos
|
|
],
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
finally:
|
|
if tmp is not None:
|
|
tmp.cleanup()
|
|
|
|
for w in report.warnings:
|
|
print(f"WARN {w}")
|
|
for e in report.errors:
|
|
print(f"FAIL {e}")
|
|
if report.errors:
|
|
print(f"\ninstall surface: RED ({len(report.errors)} violation(s), {len(report.warnings)} warning(s))")
|
|
return 1
|
|
print(f"\ninstall surface: GREEN ({len(report.warnings)} warning(s))")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|