add scripts for tests
This commit is contained in:
@@ -36,3 +36,14 @@ xxHash64 (XXH64): относительно простая имплементац
|
|||||||
## free
|
## free
|
||||||
|
|
||||||
free не делает очистку самой структуры, valgrind
|
free не делает очистку самой структуры, valgrind
|
||||||
|
|
||||||
|
## data
|
||||||
|
|
||||||
|
алфавит для символ библиотек:
|
||||||
|
|
||||||
|
```
|
||||||
|
.0123456789@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz
|
||||||
|
```
|
||||||
|
|
||||||
|
- предоставленные/определенные символы из библиотек обычно используют @@VERSION
|
||||||
|
- обязательные/неопределенные символы из двоичных файлов обычно используют @VERSION
|
||||||
|
|||||||
Executable
+384
@@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract ALT Linux set:version strings and optional ELF nm symbol stats.
|
||||||
|
|
||||||
|
The script uses the public rdb.altlinux.org API to read package dependency
|
||||||
|
metadata. With --download-nm it also downloads binary RPMs, extracts ELF files
|
||||||
|
with bsdtar, and runs nm on dynamic symbols.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
API_BASE = "https://rdb.altlinux.org/api"
|
||||||
|
DEFAULT_PACKAGES = [
|
||||||
|
"glibc-core",
|
||||||
|
"libcrypto3",
|
||||||
|
"libssl3",
|
||||||
|
"zlib",
|
||||||
|
"libgcc1",
|
||||||
|
"libcurl",
|
||||||
|
"libsystemd",
|
||||||
|
"libqt6-core",
|
||||||
|
"libgtk+3",
|
||||||
|
"libsqlite3",
|
||||||
|
"libxml2",
|
||||||
|
"libX11",
|
||||||
|
"libxcb",
|
||||||
|
"coreutils",
|
||||||
|
"curl",
|
||||||
|
"openssl",
|
||||||
|
"systemd",
|
||||||
|
"python3-base",
|
||||||
|
]
|
||||||
|
|
||||||
|
SET_PREFIX = "set:"
|
||||||
|
SET_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
IDENTISH_SYMBOL_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:@@?[A-Za-z0-9_.]+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SetDependency:
|
||||||
|
package: str
|
||||||
|
dep_type: str
|
||||||
|
name: str
|
||||||
|
set_string: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payload(self) -> str:
|
||||||
|
return self.set_string[len(SET_PREFIX) :]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bpp(self) -> int | None:
|
||||||
|
return set_char_to_int(self.payload[0]) if self.payload else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mshift(self) -> int | None:
|
||||||
|
return set_char_to_int(self.payload[1]) if len(self.payload) > 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NmReport:
|
||||||
|
package: str
|
||||||
|
mode: str
|
||||||
|
files_seen: int
|
||||||
|
symbols: list[str]
|
||||||
|
file_examples: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def set_char_to_int(char: str) -> int | None:
|
||||||
|
try:
|
||||||
|
return SET_ALPHABET.index(char)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def api_json(path: str, params: dict[str, object] | None = None) -> dict:
|
||||||
|
url = API_BASE + path
|
||||||
|
if params:
|
||||||
|
url += "?" + urllib.parse.urlencode(params, doseq=True)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "arsv-set-symbol-extractor/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pkghash(package: str, branch: str, arch: str) -> str:
|
||||||
|
data = api_json(
|
||||||
|
"/site/pkghash_by_binary_name",
|
||||||
|
{"branch": branch, "name": package, "arch": arch},
|
||||||
|
)
|
||||||
|
return str(data["pkghash"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_set_dependencies(package: str, branch: str, arch: str) -> tuple[str, list[SetDependency]]:
|
||||||
|
pkghash = get_pkghash(package, branch, arch)
|
||||||
|
deps = api_json(f"/dependencies/binary_package_dependencies/{pkghash}")["dependencies"]
|
||||||
|
set_deps = []
|
||||||
|
for dep in deps:
|
||||||
|
version = dep.get("version") or ""
|
||||||
|
if version.startswith(SET_PREFIX):
|
||||||
|
set_deps.append(
|
||||||
|
SetDependency(
|
||||||
|
package=package,
|
||||||
|
dep_type=dep.get("type", ""),
|
||||||
|
name=dep.get("name", ""),
|
||||||
|
set_string=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return pkghash, set_deps
|
||||||
|
|
||||||
|
|
||||||
|
def package_download_url(pkghash: str, branch: str, arch: str) -> str:
|
||||||
|
data = api_json(
|
||||||
|
f"/site/package_downloads_bin/{pkghash}",
|
||||||
|
{"branch": branch, "arch": arch},
|
||||||
|
)
|
||||||
|
downloads = data.get("downloads") or []
|
||||||
|
if not downloads or not downloads[0].get("packages"):
|
||||||
|
raise RuntimeError(f"no download URL for pkghash={pkghash}")
|
||||||
|
return downloads[0]["packages"][0]["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url: str, destination: Path) -> None:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "arsv-set-symbol-extractor/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as response, destination.open("wb") as out:
|
||||||
|
shutil.copyfileobj(response, out)
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(command: list[str], cwd: Path | None = None, check: bool = True) -> str:
|
||||||
|
proc = subprocess.run(command, cwd=cwd, text=True, capture_output=True)
|
||||||
|
if check and proc.returncode != 0:
|
||||||
|
joined = " ".join(command)
|
||||||
|
raise RuntimeError(f"{joined} failed with {proc.returncode}: {proc.stderr.strip()}")
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def rpm_members(rpm_path: Path) -> list[str]:
|
||||||
|
return [line for line in run_text(["bsdtar", "-tf", str(rpm_path)]).splitlines() if line]
|
||||||
|
|
||||||
|
|
||||||
|
def select_library_members(members: Iterable[str], max_files: int) -> list[str]:
|
||||||
|
selected = [
|
||||||
|
member
|
||||||
|
for member in members
|
||||||
|
if not member.endswith("/") and re.search(r"(^|/)lib[^/]*\.so(?:\.|$)", member)
|
||||||
|
]
|
||||||
|
return selected[:max_files]
|
||||||
|
|
||||||
|
|
||||||
|
def select_executable_members(members: Iterable[str], max_files: int) -> list[str]:
|
||||||
|
prefixes = ("./bin/", "./usr/bin/", "./sbin/", "./usr/sbin/", "./usr/lib/systemd/")
|
||||||
|
selected = [member for member in members if not member.endswith("/") and member.startswith(prefixes)]
|
||||||
|
return selected[:max_files]
|
||||||
|
|
||||||
|
|
||||||
|
def nm_symbols(path: Path, mode: str) -> list[str]:
|
||||||
|
flag = "-U" if mode == "defined" else "-u"
|
||||||
|
proc = subprocess.run(
|
||||||
|
["nm", "--dynamic", "-j", flag, str(path)],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return []
|
||||||
|
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_nm_report(
|
||||||
|
package: str,
|
||||||
|
pkghash: str,
|
||||||
|
branch: str,
|
||||||
|
arch: str,
|
||||||
|
mode: str,
|
||||||
|
max_files: int,
|
||||||
|
workdir: Path,
|
||||||
|
) -> NmReport:
|
||||||
|
rpm_url = package_download_url(pkghash, branch, arch)
|
||||||
|
rpm_path = workdir / Path(urllib.parse.urlparse(rpm_url).path).name
|
||||||
|
download_file(rpm_url, rpm_path)
|
||||||
|
members = rpm_members(rpm_path)
|
||||||
|
selected = (
|
||||||
|
select_library_members(members, max_files)
|
||||||
|
if mode == "defined"
|
||||||
|
else select_executable_members(members, max_files)
|
||||||
|
)
|
||||||
|
extract_dir = workdir / f"extract-{package.replace('/', '_').replace('+', '_')}-{mode}"
|
||||||
|
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if selected:
|
||||||
|
run_text(["bsdtar", "-xf", str(rpm_path), "-C", str(extract_dir), *selected])
|
||||||
|
|
||||||
|
symbols: list[str] = []
|
||||||
|
for member in selected:
|
||||||
|
member_path = extract_dir / member
|
||||||
|
if member_path.exists():
|
||||||
|
symbols.extend(nm_symbols(member_path, mode))
|
||||||
|
|
||||||
|
return NmReport(
|
||||||
|
package=package,
|
||||||
|
mode=mode,
|
||||||
|
files_seen=len(selected),
|
||||||
|
symbols=symbols,
|
||||||
|
file_examples=selected[:3],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def char_classes(chars: Iterable[str]) -> dict[str, object]:
|
||||||
|
char_set = sorted(set(chars))
|
||||||
|
return {
|
||||||
|
"alphabet": "".join(char_set),
|
||||||
|
"upper": sum(ch.isupper() for ch in char_set),
|
||||||
|
"lower": sum(ch.islower() for ch in char_set),
|
||||||
|
"digit": sum(ch.isdigit() for ch in char_set),
|
||||||
|
"underscore": "_" in char_set,
|
||||||
|
"at": "@" in char_set,
|
||||||
|
"dot": "." in char_set,
|
||||||
|
"dollar": "$" in char_set,
|
||||||
|
"other": "".join(ch for ch in char_set if not (ch.isalnum() or ch in "_.@$")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_set_report(set_deps: list[SetDependency], prefix_len: int) -> None:
|
||||||
|
print("# set:version dependency strings")
|
||||||
|
if not set_deps:
|
||||||
|
print("No set: dependencies found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
lengths = [len(dep.payload) for dep in set_deps]
|
||||||
|
alphabet = "".join(sorted(set("".join(dep.payload for dep in set_deps))))
|
||||||
|
print(f"count: {len(set_deps)}")
|
||||||
|
print(f"payload length min/median/max: {min(lengths)}/{statistics.median(lengths)}/{max(lengths)}")
|
||||||
|
print(f"observed encoded alphabet: {alphabet}")
|
||||||
|
print(f"bpp counts: {dict(sorted(Counter(dep.bpp for dep in set_deps).items()))}")
|
||||||
|
print(f"Mshift counts: {dict(sorted(Counter(dep.mshift for dep in set_deps).items()))}")
|
||||||
|
print()
|
||||||
|
print("package\ttype\tbpp\tMshift\tlen\tdependency\tset-prefix")
|
||||||
|
for dep in sorted(set_deps, key=lambda item: (item.package, item.dep_type, item.name)):
|
||||||
|
print(
|
||||||
|
f"{dep.package}\t{dep.dep_type}\t{dep.bpp}\t{dep.mshift}\t"
|
||||||
|
f"{len(dep.payload)}\t{dep.name}\t{dep.payload[:prefix_len]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_nm_report(reports: list[NmReport], sample_limit: int) -> None:
|
||||||
|
print("\n# nm dynamic symbol strings")
|
||||||
|
if not reports:
|
||||||
|
print("Skipped. Pass --download-nm to download RPMs and run bsdtar/nm.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for mode in ("defined", "undefined"):
|
||||||
|
mode_reports = [report for report in reports if report.mode == mode]
|
||||||
|
if not mode_reports:
|
||||||
|
continue
|
||||||
|
all_symbols = [symbol for report in mode_reports for symbol in report.symbols]
|
||||||
|
print(f"\n## {mode} symbols")
|
||||||
|
print(f"packages: {len(mode_reports)}")
|
||||||
|
print(f"symbols total/unique: {len(all_symbols)}/{len(set(all_symbols))}")
|
||||||
|
if all_symbols:
|
||||||
|
print(f"max symbol length: {max(len(symbol) for symbol in all_symbols)}")
|
||||||
|
print(f"character classes: {char_classes(''.join(all_symbols))}")
|
||||||
|
odd = sorted({symbol for symbol in all_symbols if not IDENTISH_SYMBOL_RE.match(symbol)})
|
||||||
|
mangled = sorted({symbol for symbol in all_symbols if symbol.startswith("_Z")})
|
||||||
|
print(f"non identifier-ish examples: {odd[:sample_limit]}")
|
||||||
|
print(f"C++ mangled examples: {mangled[:sample_limit]}")
|
||||||
|
print("package\tfiles\tsymbols\tfile-examples\tsymbol-examples")
|
||||||
|
for report in mode_reports:
|
||||||
|
print(
|
||||||
|
f"{report.package}\t{report.files_seen}\t{len(report.symbols)}\t"
|
||||||
|
f"{', '.join(report.file_examples)}\t{', '.join(report.symbols[:sample_limit])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Print ALT package set:version strings and optional nm symbol statistics."
|
||||||
|
)
|
||||||
|
parser.add_argument("packages", nargs="*", default=DEFAULT_PACKAGES, help="binary package names")
|
||||||
|
parser.add_argument("--branch", default="sisyphus", help="ALT repository branch/packageset")
|
||||||
|
parser.add_argument("--arch", default="x86_64", help="binary package architecture")
|
||||||
|
parser.add_argument("--download-nm", action="store_true", help="download RPMs and run nm on ELF files")
|
||||||
|
parser.add_argument("--nm-defined", action="store_true", help="with --download-nm, inspect defined symbols from libraries")
|
||||||
|
parser.add_argument("--nm-undefined", action="store_true", help="with --download-nm, inspect undefined symbols from executables")
|
||||||
|
parser.add_argument("--max-libs", type=int, default=8, help="max library files per package for defined-symbol nm")
|
||||||
|
parser.add_argument("--max-bins", type=int, default=20, help="max executable files per package for undefined-symbol nm")
|
||||||
|
parser.add_argument("--prefix-len", type=int, default=48, help="number of set payload chars to print per row")
|
||||||
|
parser.add_argument("--sample-limit", type=int, default=12, help="number of symbol examples to print")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
if args.download_nm and not args.nm_defined and not args.nm_undefined:
|
||||||
|
args.nm_defined = True
|
||||||
|
args.nm_undefined = True
|
||||||
|
|
||||||
|
if args.download_nm:
|
||||||
|
missing = [cmd for cmd in ("bsdtar", "nm") if shutil.which(cmd) is None]
|
||||||
|
if missing:
|
||||||
|
print(f"missing required command(s) for --download-nm: {', '.join(missing)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
package_hashes: dict[str, str] = {}
|
||||||
|
set_deps: list[SetDependency] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for package in args.packages:
|
||||||
|
try:
|
||||||
|
pkghash, deps = get_set_dependencies(package, args.branch, args.arch)
|
||||||
|
package_hashes[package] = pkghash
|
||||||
|
set_deps.extend(deps)
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, KeyError, RuntimeError) as exc:
|
||||||
|
errors.append(f"{package}: {exc}")
|
||||||
|
|
||||||
|
print(f"branch: {args.branch}")
|
||||||
|
print(f"arch: {args.arch}")
|
||||||
|
print(f"packages requested: {', '.join(args.packages)}")
|
||||||
|
if errors:
|
||||||
|
print("\n# lookup errors", file=sys.stderr)
|
||||||
|
for error in errors:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
print()
|
||||||
|
print_set_report(set_deps, args.prefix_len)
|
||||||
|
|
||||||
|
nm_reports: list[NmReport] = []
|
||||||
|
if args.download_nm:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="arsv-alt-rpms-") as tmp:
|
||||||
|
workdir = Path(tmp)
|
||||||
|
for package, pkghash in package_hashes.items():
|
||||||
|
if args.nm_defined:
|
||||||
|
try:
|
||||||
|
nm_reports.append(
|
||||||
|
extract_nm_report(
|
||||||
|
package,
|
||||||
|
pkghash,
|
||||||
|
args.branch,
|
||||||
|
args.arch,
|
||||||
|
"defined",
|
||||||
|
args.max_libs,
|
||||||
|
workdir,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # keep processing other packages
|
||||||
|
errors.append(f"{package} defined nm: {exc}")
|
||||||
|
if args.nm_undefined:
|
||||||
|
try:
|
||||||
|
nm_reports.append(
|
||||||
|
extract_nm_report(
|
||||||
|
package,
|
||||||
|
pkghash,
|
||||||
|
args.branch,
|
||||||
|
args.arch,
|
||||||
|
"undefined",
|
||||||
|
args.max_bins,
|
||||||
|
workdir,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # keep processing other packages
|
||||||
|
errors.append(f"{package} undefined nm: {exc}")
|
||||||
|
|
||||||
|
print_nm_report(nm_reports, args.sample_limit)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n# errors", file=sys.stderr)
|
||||||
|
for error in errors:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -355,11 +355,18 @@ def set_fini(set: Set, bpp: int) -> str | None:
|
|||||||
set.symbols.sort(key=lambda x: x[1]) # Sort by hash value
|
set.symbols.sort(key=lambda x: x[1]) # Sort by hash value
|
||||||
|
|
||||||
# warn on hash collisions
|
# warn on hash collisions
|
||||||
|
j = 0
|
||||||
for i in range(1, set.cnt):
|
for i in range(1, set.cnt):
|
||||||
if set.symbols[i][1] == set.symbols[i - 1][1]:
|
if set.symbols[i][1] == set.symbols[i - 1][1]:
|
||||||
print(
|
print(
|
||||||
f"Warning: Hash collision detected for symbols '{set.symbols[i][0]}' and '{set.symbols[i - 1][0]}'"
|
f"Warning: Hash collision detected for symbols '{set.symbols[i][0]}' and '{set.symbols[i - 1][0]}'"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
j += 1
|
||||||
|
set.symbols[j] = set.symbols[i]
|
||||||
|
|
||||||
|
set.symbols = set.symbols[: j + 1]
|
||||||
|
set.cnt = j + 1
|
||||||
|
|
||||||
hash_values = [label_hash for _, label_hash in set.symbols]
|
hash_values = [label_hash for _, label_hash in set.symbols]
|
||||||
|
|
||||||
|
|||||||
Executable
+429
@@ -0,0 +1,429 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check package compatibility using this repo's Python set implementation.
|
||||||
|
|
||||||
|
This script intentionally does *not* compare with ALT's existing set.c-produced
|
||||||
|
set strings. It uses ALT only as a source of real binary RPMs:
|
||||||
|
|
||||||
|
* Provided labels: `nm --dynamic -j -U <shared-library>`
|
||||||
|
* Required labels: `nm --dynamic -j -u <elf-file>`
|
||||||
|
|
||||||
|
Both sides are encoded with `reimplement/set.py`, then compared with that same
|
||||||
|
implementation's `rpmsetcmp()`. In other words, it checks whether the current
|
||||||
|
Python implementation is internally useful for real ALT package symbol labels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from reimplement import set as rpmset # noqa: E402
|
||||||
|
|
||||||
|
API_BASE = "https://rdb.altlinux.org/api"
|
||||||
|
DEFAULT_PROVIDERS = ["glibc-core", "zlib", "libssl3", "libcrypto3"]
|
||||||
|
DEFAULT_REQUIRERS = ["coreutils", "curl", "openssl"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PackageRPM:
|
||||||
|
name: str
|
||||||
|
pkghash: str
|
||||||
|
rpm_path: Path
|
||||||
|
extract_dir: Path
|
||||||
|
members: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LabelSet:
|
||||||
|
role: str
|
||||||
|
package: str
|
||||||
|
member: str
|
||||||
|
labels: tuple[str, ...]
|
||||||
|
set_string: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label_count(self) -> int:
|
||||||
|
return len(self.labels)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def set_len(self) -> int:
|
||||||
|
return len(self.set_string)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CompatibilityResult:
|
||||||
|
provider_package: str
|
||||||
|
provider_member: str
|
||||||
|
requirer_package: str
|
||||||
|
requirer_member: str
|
||||||
|
provider_labels: int
|
||||||
|
required_labels: int
|
||||||
|
cmp_result: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
def api_json(path: str, params: dict[str, object] | None = None) -> dict:
|
||||||
|
url = API_BASE + path
|
||||||
|
if params:
|
||||||
|
url += "?" + urllib.parse.urlencode(params, doseq=True)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "arsv-alt-set-compat/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pkghash(package: str, branch: str, arch: str) -> str:
|
||||||
|
data = api_json(
|
||||||
|
"/site/pkghash_by_binary_name",
|
||||||
|
{"branch": branch, "name": package, "arch": arch},
|
||||||
|
)
|
||||||
|
return str(data["pkghash"])
|
||||||
|
|
||||||
|
|
||||||
|
def package_download_url(pkghash: str, branch: str, arch: str) -> str:
|
||||||
|
data = api_json(
|
||||||
|
f"/site/package_downloads_bin/{pkghash}",
|
||||||
|
{"branch": branch, "arch": arch},
|
||||||
|
)
|
||||||
|
downloads = data.get("downloads") or []
|
||||||
|
if not downloads or not downloads[0].get("packages"):
|
||||||
|
raise RuntimeError(f"no download URL for pkghash={pkghash}")
|
||||||
|
return downloads[0]["packages"][0]["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(command: list[str], cwd: Path | None = None, check: bool = True) -> str:
|
||||||
|
proc = subprocess.run(command, cwd=cwd, text=True, capture_output=True)
|
||||||
|
if check and proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"{' '.join(command)} failed with {proc.returncode}: {proc.stderr.strip()}")
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url: str, destination: Path) -> None:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "arsv-alt-set-compat/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as response, destination.open("wb") as out:
|
||||||
|
shutil.copyfileobj(response, out)
|
||||||
|
|
||||||
|
|
||||||
|
def rpm_members(rpm_path: Path) -> list[str]:
|
||||||
|
return [line for line in run_text(["bsdtar", "-tf", str(rpm_path)]).splitlines() if line]
|
||||||
|
|
||||||
|
|
||||||
|
def is_shared_library_member(member: str) -> bool:
|
||||||
|
name = Path(member).name
|
||||||
|
return not member.endswith("/") and re.search(r"(?:^|/)lib[^/]*\.so(?:\.|$)", member) is not None and ".debug" not in name
|
||||||
|
|
||||||
|
|
||||||
|
def select_provider_members(members: Iterable[str]) -> list[str]:
|
||||||
|
"""Files whose defined dynamic symbols are Provided labels."""
|
||||||
|
return [member for member in members if is_shared_library_member(member)]
|
||||||
|
|
||||||
|
|
||||||
|
def select_requirer_members(members: Iterable[str]) -> list[str]:
|
||||||
|
"""Files whose undefined dynamic symbols are Required labels."""
|
||||||
|
executable_prefixes = ("./bin/", "./usr/bin/", "./sbin/", "./usr/sbin/", "./usr/lib/systemd/")
|
||||||
|
selected = []
|
||||||
|
for member in members:
|
||||||
|
if member.endswith("/"):
|
||||||
|
continue
|
||||||
|
if is_shared_library_member(member) or member.startswith(executable_prefixes):
|
||||||
|
selected.append(member)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def extract_members(rpm_path: Path, extract_dir: Path, members: Iterable[str]) -> None:
|
||||||
|
unique_members = sorted(set(members))
|
||||||
|
if unique_members:
|
||||||
|
run_text(["bsdtar", "-xf", str(rpm_path), "-C", str(extract_dir), *unique_members])
|
||||||
|
|
||||||
|
|
||||||
|
def nm_symbols(path: Path, mode: str) -> list[str]:
|
||||||
|
command = ["nm", "--dynamic", "-j", "-U", str(path)] if mode == "provided" else ["nm", "--dynamic", "-u", str(path)]
|
||||||
|
proc = subprocess.run(command, text=True, capture_output=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return []
|
||||||
|
if mode == "required":
|
||||||
|
return parse_required_nm_output(proc.stdout)
|
||||||
|
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_required_nm_output(output: str) -> list[str]:
|
||||||
|
"""Parse `nm --dynamic -u` output, ignoring weak undefined references.
|
||||||
|
|
||||||
|
Plain `nm -j -u` discards the symbol type, but real ALT RPMs contain weak
|
||||||
|
undefined hooks like `__gmon_start__` and `_ITM_*`. Those are optional ELF
|
||||||
|
references, not hard Required labels, so keep only strong `U` entries.
|
||||||
|
"""
|
||||||
|
symbols = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
symbol_type, symbol = parts[-2], parts[-1]
|
||||||
|
if symbol_type == "U":
|
||||||
|
symbols.append(symbol)
|
||||||
|
return symbols
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_required_symbol(symbol: str) -> str:
|
||||||
|
"""Normalize `foo@VER` from undefined nm output to provider-like `foo@@VER`.
|
||||||
|
|
||||||
|
`nm -u` prints required versioned symbols with a single `@`, while defined
|
||||||
|
default-version symbols commonly use `@@`. This normalization is for this
|
||||||
|
script's compatibility model only; it is not a set.c compatibility shim.
|
||||||
|
"""
|
||||||
|
if "@@" in symbol or "@" not in symbol:
|
||||||
|
return symbol
|
||||||
|
name, version = symbol.split("@", 1)
|
||||||
|
if not name or not version:
|
||||||
|
return symbol
|
||||||
|
return f"{name}@@{version}"
|
||||||
|
|
||||||
|
|
||||||
|
def labels_to_set_string(labels: Iterable[str], bpp: int) -> str | None:
|
||||||
|
item_set = rpmset.set_new()
|
||||||
|
for label in labels:
|
||||||
|
rpmset.set_add(item_set, label)
|
||||||
|
# reimplement/set.py prints collision warnings to stdout; keep this script's
|
||||||
|
# stdout machine-readable and route those warnings to stderr instead.
|
||||||
|
with contextlib.redirect_stdout(sys.stderr):
|
||||||
|
return rpmset.set_fini(item_set, bpp)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_label_set(role: str, member: str, labels: Iterable[str], bpp: int, package: str = "") -> LabelSet:
|
||||||
|
unique_labels = tuple(sorted(set(label for label in labels if label)))
|
||||||
|
set_string = labels_to_set_string(unique_labels, bpp)
|
||||||
|
if set_string is None:
|
||||||
|
raise ValueError(f"no labels for {role} {package}:{member}")
|
||||||
|
return LabelSet(role=role, package=package, member=member, labels=unique_labels, set_string=set_string)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_status(cmp_result: int) -> str:
|
||||||
|
# provider is first argument; compatible means provider is equal or superset.
|
||||||
|
if cmp_result in (0, 1):
|
||||||
|
return "compatible"
|
||||||
|
return "incompatible"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_label_sets(provider: LabelSet, requirer: LabelSet) -> CompatibilityResult:
|
||||||
|
cmp_result = rpmset.rpmsetcmp(provider.set_string, requirer.set_string)
|
||||||
|
return CompatibilityResult(
|
||||||
|
provider_package=provider.package,
|
||||||
|
provider_member=provider.member,
|
||||||
|
requirer_package=requirer.package,
|
||||||
|
requirer_member=requirer.member,
|
||||||
|
provider_labels=provider.label_count,
|
||||||
|
required_labels=requirer.label_count,
|
||||||
|
cmp_result=cmp_result,
|
||||||
|
status=compare_status(cmp_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dependency_results(provider_sets: list[LabelSet], requirer_sets: list[LabelSet], bpp: int) -> list[CompatibilityResult]:
|
||||||
|
"""Compare each library only with the symbols actually required from it.
|
||||||
|
|
||||||
|
A package executable/library has one undefined-symbol list containing symbols
|
||||||
|
required from all of its DT_NEEDED libraries. Comparing that whole list with
|
||||||
|
one provider library gives false ``-2`` results: e.g. ``/usr/bin/curl`` needs
|
||||||
|
symbols from libc, libssl, libcrypto, zlib, etc., and no single library is
|
||||||
|
supposed to provide all of them.
|
||||||
|
|
||||||
|
For this local set.py check, keep the symbol-level ground truth around and
|
||||||
|
split every requirer's labels by provider library: provider labels ∩ required
|
||||||
|
labels. Each non-empty subset is then encoded as the requirement for exactly
|
||||||
|
that provider and compared with ``rpmsetcmp(provider, required_subset)``.
|
||||||
|
"""
|
||||||
|
results: list[CompatibilityResult] = []
|
||||||
|
for requirer in requirer_sets:
|
||||||
|
required_labels = set(requirer.labels)
|
||||||
|
for provider in provider_sets:
|
||||||
|
required_from_provider = sorted(required_labels.intersection(provider.labels))
|
||||||
|
if not required_from_provider:
|
||||||
|
continue
|
||||||
|
split_requirer = generate_label_set(
|
||||||
|
"required",
|
||||||
|
requirer.member,
|
||||||
|
required_from_provider,
|
||||||
|
bpp,
|
||||||
|
package=requirer.package,
|
||||||
|
)
|
||||||
|
results.append(compare_label_sets(provider, split_requirer))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_package_rpm(package: str, branch: str, arch: str, workdir: Path) -> PackageRPM:
|
||||||
|
pkghash = get_pkghash(package, branch, arch)
|
||||||
|
rpm_url = package_download_url(pkghash, branch, arch)
|
||||||
|
rpm_path = workdir / Path(urllib.parse.urlparse(rpm_url).path).name
|
||||||
|
if not rpm_path.exists():
|
||||||
|
download_file(rpm_url, rpm_path)
|
||||||
|
members = rpm_members(rpm_path)
|
||||||
|
extract_dir = workdir / f"extract-{package.replace('/', '_').replace('+', '_')}"
|
||||||
|
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return PackageRPM(package, pkghash, rpm_path, extract_dir, members)
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider_sets(package_rpm: PackageRPM, bpp: int, max_files: int) -> list[LabelSet]:
|
||||||
|
members = select_provider_members(package_rpm.members)[:max_files]
|
||||||
|
extract_members(package_rpm.rpm_path, package_rpm.extract_dir, members)
|
||||||
|
sets = []
|
||||||
|
for member in members:
|
||||||
|
labels = nm_symbols(package_rpm.extract_dir / member, "provided")
|
||||||
|
if labels:
|
||||||
|
sets.append(generate_label_set("provided", member, labels, bpp, package_rpm.name))
|
||||||
|
return sets
|
||||||
|
|
||||||
|
|
||||||
|
def build_requirer_sets(
|
||||||
|
package_rpm: PackageRPM,
|
||||||
|
bpp: int,
|
||||||
|
max_files: int,
|
||||||
|
normalize_versions: bool,
|
||||||
|
) -> list[LabelSet]:
|
||||||
|
members = select_requirer_members(package_rpm.members)[:max_files]
|
||||||
|
extract_members(package_rpm.rpm_path, package_rpm.extract_dir, members)
|
||||||
|
sets = []
|
||||||
|
for member in members:
|
||||||
|
labels = nm_symbols(package_rpm.extract_dir / member, "required")
|
||||||
|
if normalize_versions:
|
||||||
|
labels = [normalize_required_symbol(label) for label in labels]
|
||||||
|
if labels:
|
||||||
|
sets.append(generate_label_set("required", member, labels, bpp, package_rpm.name))
|
||||||
|
return sets
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_label_sets(role: str, package_names: list[str], label_sets: list[LabelSet], bpp: int) -> LabelSet:
|
||||||
|
labels = [label for label_set in label_sets for label in label_set.labels]
|
||||||
|
return generate_label_set(role, "+".join(package_names), labels, bpp, package="aggregate")
|
||||||
|
|
||||||
|
|
||||||
|
def print_label_sets(title: str, label_sets: list[LabelSet]) -> None:
|
||||||
|
print(f"\n# {title}")
|
||||||
|
print("role\tpackage\tmember\tlabels\tset_len\tset_prefix")
|
||||||
|
for label_set in label_sets:
|
||||||
|
print(
|
||||||
|
f"{label_set.role}\t{label_set.package}\t{label_set.member}\t"
|
||||||
|
f"{label_set.label_count}\t{label_set.set_len}\t{label_set.set_string[:48]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_results(results: list[CompatibilityResult]) -> None:
|
||||||
|
print("\n# compatibility")
|
||||||
|
print("status\tcmp\tprovider_pkg\tprovider_member\tprovider_labels\trequirer_pkg\trequirer_member\trequired_labels")
|
||||||
|
for result in results:
|
||||||
|
print(
|
||||||
|
f"{result.status}\t{result.cmp_result}\t{result.provider_package}\t{result.provider_member}\t"
|
||||||
|
f"{result.provider_labels}\t{result.requirer_package}\t{result.requirer_member}\t{result.required_labels}"
|
||||||
|
)
|
||||||
|
summary = {status: sum(1 for result in results if result.status == status) for status in sorted({r.status for r in results})}
|
||||||
|
print(f"summary: {summary}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_package_list(values: list[str] | None) -> list[str]:
|
||||||
|
packages = []
|
||||||
|
for value in values or []:
|
||||||
|
packages.extend(part for part in value.split(",") if part)
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate Provided/Required set strings from ALT RPM labels with reimplement/set.py and compare them."
|
||||||
|
)
|
||||||
|
parser.add_argument("packages", nargs="*", help="packages used as both providers and requirers if explicit lists are omitted")
|
||||||
|
parser.add_argument("--provider", action="append", help="provider package; can be repeated or comma-separated")
|
||||||
|
parser.add_argument("--requirer", action="append", help="requirer package; can be repeated or comma-separated")
|
||||||
|
parser.add_argument("--branch", default="sisyphus", help="ALT repository branch/packageset")
|
||||||
|
parser.add_argument("--arch", default="x86_64", help="binary package architecture")
|
||||||
|
parser.add_argument("--bpp", type=int, default=32, help="bits per hash used by local set.py")
|
||||||
|
parser.add_argument("--max-provider-files", type=int, default=64, help="max provider ELF files per package")
|
||||||
|
parser.add_argument("--max-requirer-files", type=int, default=64, help="max requirer ELF files per package")
|
||||||
|
parser.add_argument("--all-pairs", action="store_true", help="compare every provider file set with every requirer file set")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-normalize-required-version",
|
||||||
|
action="store_true",
|
||||||
|
help="keep nm -u single-@ required symbols unchanged instead of converting foo@VER to foo@@VER",
|
||||||
|
)
|
||||||
|
parser.add_argument("--keep-workdir", action="store_true", help="keep downloaded RPMs/extracted files")
|
||||||
|
parser.add_argument("--workdir", type=Path, help="directory for downloads/extraction")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
missing = [cmd for cmd in ("bsdtar", "nm") if shutil.which(cmd) is None]
|
||||||
|
if missing:
|
||||||
|
print(f"missing required command(s): {', '.join(missing)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
positional = args.packages or []
|
||||||
|
providers = parse_package_list(args.provider) or positional or DEFAULT_PROVIDERS
|
||||||
|
requirers = parse_package_list(args.requirer) or positional or DEFAULT_REQUIRERS
|
||||||
|
|
||||||
|
cleanup = False
|
||||||
|
if args.workdir:
|
||||||
|
workdir = args.workdir
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
workdir = Path(tempfile.mkdtemp(prefix="arsv-alt-set-compat-"))
|
||||||
|
cleanup = not args.keep_workdir
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider_sets: list[LabelSet] = []
|
||||||
|
requirer_sets: list[LabelSet] = []
|
||||||
|
for package in providers:
|
||||||
|
provider_sets.extend(
|
||||||
|
build_provider_sets(fetch_package_rpm(package, args.branch, args.arch, workdir), args.bpp, args.max_provider_files)
|
||||||
|
)
|
||||||
|
for package in requirers:
|
||||||
|
requirer_sets.extend(
|
||||||
|
build_requirer_sets(
|
||||||
|
fetch_package_rpm(package, args.branch, args.arch, workdir),
|
||||||
|
args.bpp,
|
||||||
|
args.max_requirer_files,
|
||||||
|
normalize_versions=not args.no_normalize_required_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"branch: {args.branch}")
|
||||||
|
print(f"arch: {args.arch}")
|
||||||
|
print(f"bpp: {args.bpp}")
|
||||||
|
print(f"providers: {', '.join(providers)}")
|
||||||
|
print(f"requirers: {', '.join(requirers)}")
|
||||||
|
print(f"required symbol version normalization: {not args.no_normalize_required_version}")
|
||||||
|
print_label_sets("generated Provided sets", provider_sets)
|
||||||
|
print_label_sets("generated Required sets", requirer_sets)
|
||||||
|
|
||||||
|
if not provider_sets or not requirer_sets:
|
||||||
|
print("\nNo comparable sets generated.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.all_pairs:
|
||||||
|
results = [compare_label_sets(provider, requirer) for provider in provider_sets for requirer in requirer_sets]
|
||||||
|
else:
|
||||||
|
results = build_dependency_results(provider_sets, requirer_sets, args.bpp)
|
||||||
|
print_results(results)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
print(f"workdir: {workdir}")
|
||||||
|
|
||||||
|
return 0 if all(result.status == "compatible" for result in results) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check reimplement/set.py against ELF libraries installed on this Arch Linux system.
|
||||||
|
|
||||||
|
This is the local-system analogue of check_alt_set_impl.py. It treats shared
|
||||||
|
libraries as providers (defined dynamic symbols) and executables/shared objects
|
||||||
|
as requirers (undefined dynamic symbols). Required labels are split by provider
|
||||||
|
library before comparing, so each library is checked only against symbols that
|
||||||
|
it actually exports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(SCRIPT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
|
|
||||||
|
import check_alt_set_impl as compat # noqa: E402
|
||||||
|
|
||||||
|
DEFAULT_PROVIDER_DIRS = [Path("/usr/lib")]
|
||||||
|
DEFAULT_REQUIRER_DIRS = [Path("/usr/bin"), Path("/usr/lib")]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_path_list(values: list[str] | None, defaults: list[Path]) -> list[Path]:
|
||||||
|
paths: list[Path] = []
|
||||||
|
for value in values or []:
|
||||||
|
paths.extend(Path(part) for part in value.split(",") if part)
|
||||||
|
return paths or defaults
|
||||||
|
|
||||||
|
|
||||||
|
def is_shared_library_path(path: Path) -> bool:
|
||||||
|
name = path.name
|
||||||
|
return ".debug" not in name and name.startswith("lib") and ".so" in name
|
||||||
|
|
||||||
|
|
||||||
|
def iter_files(paths: list[Path], recursive: bool) -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for root in paths:
|
||||||
|
if root.is_file():
|
||||||
|
candidates = [root]
|
||||||
|
elif recursive:
|
||||||
|
candidates = (path for path in root.rglob("*") if path.is_file())
|
||||||
|
else:
|
||||||
|
candidates = (path for path in root.iterdir() if path.is_file()) if root.is_dir() else []
|
||||||
|
for path in candidates:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if resolved in seen:
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
files.append(path)
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
|
||||||
|
def executable_or_library(path: Path) -> bool:
|
||||||
|
return is_shared_library_path(path) or os.access(path, os.X_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def build_local_provider_sets(paths: list[Path], bpp: int, recursive: bool, max_files: int) -> list[compat.LabelSet]:
|
||||||
|
provider_files = [path for path in iter_files(paths, recursive) if is_shared_library_path(path)][:max_files]
|
||||||
|
sets: list[compat.LabelSet] = []
|
||||||
|
for path in provider_files:
|
||||||
|
labels = compat.nm_symbols(path, "provided")
|
||||||
|
if labels:
|
||||||
|
sets.append(compat.generate_label_set("provided", str(path), labels, bpp, package="arch-local"))
|
||||||
|
return sets
|
||||||
|
|
||||||
|
|
||||||
|
def build_local_requirer_sets(
|
||||||
|
paths: list[Path],
|
||||||
|
bpp: int,
|
||||||
|
recursive: bool,
|
||||||
|
max_files: int,
|
||||||
|
normalize_versions: bool,
|
||||||
|
) -> list[compat.LabelSet]:
|
||||||
|
requirer_files = [path for path in iter_files(paths, recursive) if executable_or_library(path)][:max_files]
|
||||||
|
sets: list[compat.LabelSet] = []
|
||||||
|
for path in requirer_files:
|
||||||
|
labels = compat.nm_symbols(path, "required")
|
||||||
|
if normalize_versions:
|
||||||
|
labels = [compat.normalize_required_symbol(label) for label in labels]
|
||||||
|
if labels:
|
||||||
|
sets.append(compat.generate_label_set("required", str(path), labels, bpp, package="arch-local"))
|
||||||
|
return sets
|
||||||
|
|
||||||
|
|
||||||
|
def print_unmatched_required_labels(provider_sets: list[compat.LabelSet], requirer_sets: list[compat.LabelSet], limit: int) -> None:
|
||||||
|
provided = {label for provider in provider_sets for label in provider.labels}
|
||||||
|
rows: list[tuple[str, int, list[str]]] = []
|
||||||
|
for requirer in requirer_sets:
|
||||||
|
missing = sorted(set(requirer.labels).difference(provided))
|
||||||
|
if missing:
|
||||||
|
rows.append((requirer.member, len(missing), missing[:limit]))
|
||||||
|
|
||||||
|
print("\n# required labels not exported by scanned provider libraries")
|
||||||
|
print("requirer\tmissing_labels\texamples")
|
||||||
|
for member, count, examples in rows[:limit]:
|
||||||
|
print(f"{member}\t{count}\t{', '.join(examples)}")
|
||||||
|
if len(rows) > limit:
|
||||||
|
print(f"... {len(rows) - limit} more requirer files with unmatched labels")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate Provided/Required set strings from local Arch Linux ELF files and compare them with reimplement/set.py."
|
||||||
|
)
|
||||||
|
parser.add_argument("--provider-dir", action="append", help="directory/file containing provider libraries; repeat or comma-separate")
|
||||||
|
parser.add_argument("--requirer-dir", action="append", help="directory/file containing requirer ELF files; repeat or comma-separate")
|
||||||
|
parser.add_argument("--bpp", type=int, default=32, help="bits per hash used by local set.py")
|
||||||
|
parser.add_argument("--max-provider-files", type=int, default=256, help="max provider libraries to inspect")
|
||||||
|
parser.add_argument("--max-requirer-files", type=int, default=256, help="max requirer files to inspect")
|
||||||
|
parser.add_argument("--recursive", action="store_true", help="scan directories recursively")
|
||||||
|
parser.add_argument("--all-pairs", action="store_true", help="compare every provider set with every full requirer set")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-normalize-required-version",
|
||||||
|
action="store_true",
|
||||||
|
help="keep nm -u single-@ required symbols unchanged instead of converting foo@VER to foo@@VER",
|
||||||
|
)
|
||||||
|
parser.add_argument("--unmatched-limit", type=int, default=20, help="max unmatched-label rows/examples to print")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
if shutil.which("nm") is None:
|
||||||
|
print("missing required command: nm", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
provider_paths = parse_path_list(args.provider_dir, DEFAULT_PROVIDER_DIRS)
|
||||||
|
requirer_paths = parse_path_list(args.requirer_dir, DEFAULT_REQUIRER_DIRS)
|
||||||
|
|
||||||
|
provider_sets = build_local_provider_sets(provider_paths, args.bpp, args.recursive, args.max_provider_files)
|
||||||
|
requirer_sets = build_local_requirer_sets(
|
||||||
|
requirer_paths,
|
||||||
|
args.bpp,
|
||||||
|
args.recursive,
|
||||||
|
args.max_requirer_files,
|
||||||
|
normalize_versions=not args.no_normalize_required_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("system: arch-local")
|
||||||
|
print(f"bpp: {args.bpp}")
|
||||||
|
print(f"provider paths: {', '.join(str(path) for path in provider_paths)}")
|
||||||
|
print(f"requirer paths: {', '.join(str(path) for path in requirer_paths)}")
|
||||||
|
print(f"required symbol version normalization: {not args.no_normalize_required_version}")
|
||||||
|
compat.print_label_sets("generated Provided sets", provider_sets)
|
||||||
|
compat.print_label_sets("generated Required sets", requirer_sets)
|
||||||
|
|
||||||
|
if not provider_sets or not requirer_sets:
|
||||||
|
print("\nNo comparable sets generated.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.all_pairs:
|
||||||
|
results = [compat.compare_label_sets(provider, requirer) for provider in provider_sets for requirer in requirer_sets]
|
||||||
|
else:
|
||||||
|
results = compat.build_dependency_results(provider_sets, requirer_sets, args.bpp)
|
||||||
|
compat.print_results(results)
|
||||||
|
print_unmatched_required_labels(provider_sets, requirer_sets, args.unmatched_limit)
|
||||||
|
|
||||||
|
return 0 if results and all(result.status == "compatible" for result in results) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare one binary's required set string with a few provider libraries.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/compare_binary_with_lib_sets.py [--bpp N] BINARY LIB.so [LIB.so ...]
|
||||||
|
|
||||||
|
For every library, this script compares:
|
||||||
|
set(defined dynamic symbols from LIB) vs
|
||||||
|
set(required dynamic symbols from BINARY that LIB provides)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from reimplement import set as rpmset # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run_nm(command: list[str]) -> str:
|
||||||
|
proc = subprocess.run(command, text=True, capture_output=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"{' '.join(command)} failed with {proc.returncode}: {proc.stderr.strip()}")
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_required_symbol(symbol: str) -> str:
|
||||||
|
"""Normalize nm's required `foo@VER` form to provider-like `foo@@VER`."""
|
||||||
|
if "@@" in symbol or "@" not in symbol:
|
||||||
|
return symbol
|
||||||
|
name, version = symbol.split("@", 1)
|
||||||
|
if not name or not version:
|
||||||
|
return symbol
|
||||||
|
return f"{name}@@{version}"
|
||||||
|
|
||||||
|
|
||||||
|
def required_symbols(path: Path, normalize_versions: bool) -> set[str]:
|
||||||
|
"""Return strong undefined dynamic symbols required by one ELF file."""
|
||||||
|
output = run_nm(["nm", "--dynamic", "-u", str(path)])
|
||||||
|
symbols: set[str] = set()
|
||||||
|
for line in output.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2 or parts[-2] != "U":
|
||||||
|
continue
|
||||||
|
symbol = parts[-1]
|
||||||
|
symbols.add(normalize_required_symbol(symbol) if normalize_versions else symbol)
|
||||||
|
return symbols
|
||||||
|
|
||||||
|
|
||||||
|
def provided_symbols(path: Path) -> set[str]:
|
||||||
|
"""Return defined dynamic symbols provided by one ELF shared library."""
|
||||||
|
output = run_nm(["nm", "--dynamic", "-j", "-U", str(path)])
|
||||||
|
return {line.strip() for line in output.splitlines() if line.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def labels_to_set_string(labels: Iterable[str], bpp: int) -> str | None:
|
||||||
|
item_set = rpmset.set_new()
|
||||||
|
for label in sorted(set(labels)):
|
||||||
|
rpmset.set_add(item_set, label)
|
||||||
|
# set.py may print hash-collision warnings; keep stdout tabular.
|
||||||
|
with contextlib.redirect_stdout(sys.stderr):
|
||||||
|
return rpmset.set_fini(item_set, bpp)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_library(binary_required: set[str], library: Path, bpp: int) -> tuple[str, str, int, int, str, str]:
|
||||||
|
provided = provided_symbols(library)
|
||||||
|
required_from_library = binary_required.intersection(provided)
|
||||||
|
provider_set = labels_to_set_string(provided, bpp)
|
||||||
|
required_set = labels_to_set_string(required_from_library, bpp)
|
||||||
|
|
||||||
|
if not provided:
|
||||||
|
return "no-provided-symbols", "", 0, 0, "-", "-"
|
||||||
|
if not required_from_library:
|
||||||
|
return "not-required", "", len(provided), 0, provider_set or "-", "-"
|
||||||
|
|
||||||
|
assert provider_set is not None
|
||||||
|
assert required_set is not None
|
||||||
|
cmp_result = rpmset.rpmsetcmp(provider_set, required_set)
|
||||||
|
status = "compatible" if cmp_result in (0, 1) else "incompatible"
|
||||||
|
return status, str(cmp_result), len(provided), len(required_from_library), provider_set, required_set
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Compare one binary against a few libraries using local set.py set strings.")
|
||||||
|
parser.add_argument("binary", type=Path, help="ELF executable/shared object with required dynamic symbols")
|
||||||
|
parser.add_argument("libraries", nargs="+", type=Path, help="provider shared libraries to compare against")
|
||||||
|
parser.add_argument("--bpp", type=int, default=32, help="bits per hash used by local set.py")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-normalize-required-version",
|
||||||
|
action="store_true",
|
||||||
|
help="keep nm -u single-@ required symbols unchanged instead of converting foo@VER to foo@@VER",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
if shutil.which("nm") is None:
|
||||||
|
print("missing required command: nm", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
required = required_symbols(args.binary, normalize_versions=not args.no_normalize_required_version)
|
||||||
|
if not required:
|
||||||
|
print(f"no strong dynamic required symbols found in {args.binary}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"binary\t{args.binary}")
|
||||||
|
print(f"bpp\t{args.bpp}")
|
||||||
|
print(f"required_symbols\t{len(required)}")
|
||||||
|
print("status\tcmp\tlib\tprovided\trequired_from_lib\tprovider_set\trequired_set")
|
||||||
|
|
||||||
|
failed = False
|
||||||
|
for library in args.libraries:
|
||||||
|
try:
|
||||||
|
status, cmp_result, provided_count, required_count, provider_set, required_set = compare_library(
|
||||||
|
required, library, args.bpp
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"error\t\t{library}\t0\t0\t-\t-", flush=True)
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
failed = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{status}\t{cmp_result}\t{library}\t{provided_count}\t{required_count}\t{provider_set}\t{required_set}"
|
||||||
|
)
|
||||||
|
failed = failed or status in {"incompatible", "no-provided-symbols"}
|
||||||
|
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check_alt_set_impl.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_script():
|
||||||
|
spec = importlib.util.spec_from_file_location("check_alt_set_impl", SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec.loader is not None
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_elf_members_separates_providers_and_requirers():
|
||||||
|
check = load_script()
|
||||||
|
members = [
|
||||||
|
"./usr/lib64/libfoo.so.1",
|
||||||
|
"./usr/lib64/libfoo.so.1.2.3",
|
||||||
|
"./usr/lib64/libfoo.a",
|
||||||
|
"./usr/bin/tool",
|
||||||
|
"./usr/share/doc/readme",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert check.select_provider_members(members) == [
|
||||||
|
"./usr/lib64/libfoo.so.1",
|
||||||
|
"./usr/lib64/libfoo.so.1.2.3",
|
||||||
|
]
|
||||||
|
assert check.select_requirer_members(members) == [
|
||||||
|
"./usr/lib64/libfoo.so.1",
|
||||||
|
"./usr/lib64/libfoo.so.1.2.3",
|
||||||
|
"./usr/bin/tool",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_label_set_and_compare_uses_only_local_set_py():
|
||||||
|
check = load_script()
|
||||||
|
provided = check.generate_label_set("provider", "libfoo.so.1", ["foo", "bar", "baz"], bpp=16)
|
||||||
|
required = check.generate_label_set("requirer", "tool", ["foo", "bar"], bpp=16)
|
||||||
|
missing = check.generate_label_set("requirer", "badtool", ["foo", "quux"], bpp=16)
|
||||||
|
|
||||||
|
ok = check.compare_label_sets(provided, required)
|
||||||
|
bad = check.compare_label_sets(provided, missing)
|
||||||
|
|
||||||
|
assert ok.status == "compatible"
|
||||||
|
assert ok.cmp_result == 1
|
||||||
|
assert bad.status == "incompatible"
|
||||||
|
assert bad.cmp_result == -2
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_required_symbol_version_can_match_provided_symbol_version():
|
||||||
|
check = load_script()
|
||||||
|
|
||||||
|
assert check.normalize_required_symbol("foo@LIB_1") == "foo@@LIB_1"
|
||||||
|
assert check.normalize_required_symbol("foo@@LIB_1") == "foo@@LIB_1"
|
||||||
|
assert check.normalize_required_symbol("foo") == "foo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_required_nm_output_filters_weak_undefined_symbols():
|
||||||
|
check = load_script()
|
||||||
|
nm_output = """
|
||||||
|
w __gmon_start__
|
||||||
|
w _ITM_deregisterTMCloneTable
|
||||||
|
U close@GLIBC_2.2.5
|
||||||
|
U memcpy@GLIBC_2.14
|
||||||
|
W optional_hook
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert check.parse_required_nm_output(nm_output) == [
|
||||||
|
"close@GLIBC_2.2.5",
|
||||||
|
"memcpy@GLIBC_2.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_bpp_is_large_enough_for_real_alt_symbol_sets():
|
||||||
|
check = load_script()
|
||||||
|
|
||||||
|
assert check.parse_args([]).bpp == 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_dependency_results_splits_required_labels_by_provider_library():
|
||||||
|
check = load_script()
|
||||||
|
libc = check.generate_label_set(
|
||||||
|
"provided", "libc.so.6", ["close@@GLIBC_2.2.5", "read@@GLIBC_2.2.5"], bpp=16, package="glibc-core"
|
||||||
|
)
|
||||||
|
libz = check.generate_label_set(
|
||||||
|
"provided", "libz.so.1", ["inflate", "deflate"], bpp=16, package="zlib"
|
||||||
|
)
|
||||||
|
tool = check.generate_label_set(
|
||||||
|
"required", "tool", ["close@@GLIBC_2.2.5", "inflate"], bpp=16, package="consumer"
|
||||||
|
)
|
||||||
|
|
||||||
|
results = check.build_dependency_results([libc, libz], [tool], bpp=16)
|
||||||
|
|
||||||
|
assert [(result.provider_member, result.required_labels, result.status) for result in results] == [
|
||||||
|
("libc.so.6", 1, "compatible"),
|
||||||
|
("libz.so.1", 1, "compatible"),
|
||||||
|
]
|
||||||
@@ -52,11 +52,13 @@ class SetStringTest(unittest.TestCase):
|
|||||||
encoded = rpmset.set_fini(item_set, bpp=8)
|
encoded = rpmset.set_fini(item_set, bpp=8)
|
||||||
self.assertIsNotNone(encoded)
|
self.assertIsNotNone(encoded)
|
||||||
self.assertEqual(item_set.cnt, 2)
|
self.assertEqual(item_set.cnt, 2)
|
||||||
self.assertEqual(item_set.labels, sorted(item_set.labels, key=lambda item: item[1]))
|
self.assertEqual(
|
||||||
|
item_set.symbols, sorted(item_set.symbols, key=lambda item: item[1])
|
||||||
|
)
|
||||||
|
|
||||||
rpmset.set_free(item_set)
|
rpmset.set_free(item_set)
|
||||||
self.assertEqual(item_set.cnt, 0)
|
self.assertEqual(item_set.cnt, 0)
|
||||||
self.assertEqual(item_set.labels, [])
|
self.assertEqual(item_set.symbols, [])
|
||||||
|
|
||||||
def test_hash_is_stable_64_bit_ascii_integer(self):
|
def test_hash_is_stable_64_bit_ascii_integer(self):
|
||||||
self.assertEqual(rpmset.hash("ascii_symbol"), 10827468943333989194)
|
self.assertEqual(rpmset.hash("ascii_symbol"), 10827468943333989194)
|
||||||
@@ -69,7 +71,9 @@ class SetStringTest(unittest.TestCase):
|
|||||||
|
|
||||||
class DownsampleSetTest(unittest.TestCase):
|
class DownsampleSetTest(unittest.TestCase):
|
||||||
def test_masks_high_half_and_keeps_sorted_unique_values(self):
|
def test_masks_high_half_and_keeps_sorted_unique_values(self):
|
||||||
self.assertEqual(rpmset.downsample_set([1, 3, 6, 8, 10, 14], 3), [0, 1, 2, 3, 6])
|
self.assertEqual(
|
||||||
|
rpmset.downsample_set([1, 3, 6, 8, 10, 14], 3), [0, 1, 2, 3, 6]
|
||||||
|
)
|
||||||
|
|
||||||
def test_removes_duplicates_created_by_masking(self):
|
def test_removes_duplicates_created_by_masking(self):
|
||||||
self.assertEqual(rpmset.downsample_set([1, 6, 14], 3), [1, 6])
|
self.assertEqual(rpmset.downsample_set([1, 6, 14], 3), [1, 6])
|
||||||
|
|||||||
Reference in New Issue
Block a user