some structure refactor
This commit is contained in:
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:]))
|
||||
Executable
+401
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark rpmsetcmp from a selected set.c on hardcoded ELF pairs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
import compare_realization as common
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUN_CMP_SOURCE = REPO_ROOT / "scripts" / "run_cmp.cpp"
|
||||
DEFAULT_RPM_ROOT = REPO_ROOT / "rpm-build"
|
||||
DEFAULT_RESULT_ROOT = REPO_ROOT / "res_cmp"
|
||||
|
||||
LIBC_CANDIDATES = (
|
||||
"/usr/lib/libc.so.6",
|
||||
"/lib64/libc.so.6",
|
||||
"/usr/lib64/libc.so.6",
|
||||
"/lib/libc.so.6",
|
||||
"/lib/x86_64-linux-gnu/libc.so.6",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComparisonCase:
|
||||
name: str
|
||||
binary_candidates: tuple[str, ...]
|
||||
library_candidates: tuple[str, ...]
|
||||
|
||||
|
||||
# The benchmark corpus is intentionally fixed here rather than supplied via CLI.
|
||||
HARDCODED_CASES = (
|
||||
ComparisonCase("true-libc", ("/usr/bin/true", "/bin/true"), LIBC_CANDIDATES),
|
||||
ComparisonCase("ls-libc", ("/usr/bin/ls", "/bin/ls"), LIBC_CANDIDATES),
|
||||
ComparisonCase("bash-libc", ("/usr/bin/bash", "/bin/bash"), LIBC_CANDIDATES),
|
||||
ComparisonCase(
|
||||
"python-libc",
|
||||
("/usr/bin/python3", "/usr/local/bin/python3"),
|
||||
LIBC_CANDIDATES,
|
||||
),
|
||||
)
|
||||
|
||||
SUMMARY_FIELDS = (
|
||||
"case",
|
||||
"binary",
|
||||
"library",
|
||||
"runs",
|
||||
"cmp_result",
|
||||
"outputs_consistent",
|
||||
"median_rpmsetcmp_ns",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedCase:
|
||||
name: str
|
||||
binary: Path
|
||||
library: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedCase:
|
||||
case: ResolvedCase
|
||||
provider_set: str
|
||||
required_set: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComparisonSummary:
|
||||
prepared: PreparedCase
|
||||
runs: int
|
||||
cmp_result: int
|
||||
outputs_consistent: bool
|
||||
median_ns: int | float
|
||||
|
||||
def as_row(self) -> dict[str, str]:
|
||||
return {
|
||||
"case": self.prepared.case.name,
|
||||
"binary": str(self.prepared.case.binary),
|
||||
"library": str(self.prepared.case.library),
|
||||
"runs": str(self.runs),
|
||||
"cmp_result": str(self.cmp_result),
|
||||
"outputs_consistent": "yes" if self.outputs_consistent else "no",
|
||||
"median_rpmsetcmp_ns": common.format_number(self.median_ns),
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Compile a selected set.c and benchmark only rpmsetcmp on the "
|
||||
"hardcoded binary/library corpus."
|
||||
)
|
||||
)
|
||||
parser.add_argument("set_c", type=Path, help="path to the set.c implementation")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--runs",
|
||||
type=common.positive_int,
|
||||
default=10,
|
||||
help="rpmsetcmp process runs per hardcoded case (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rpm-root",
|
||||
type=Path,
|
||||
default=DEFAULT_RPM_ROOT,
|
||||
help="ALT rpm source root",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--res-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_RESULT_ROOT,
|
||||
help="result root (default: repository res_cmp/)",
|
||||
)
|
||||
parser.add_argument("--name", help="implementation directory name under res_cmp/")
|
||||
parser.add_argument(
|
||||
"--bpp",
|
||||
type=common.bpp_value,
|
||||
help="force one bpp value while preparing provider/required sets",
|
||||
)
|
||||
parser.add_argument("--cc", default="cc", help="C compiler (default: cc)")
|
||||
parser.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def first_existing(candidates: Sequence[str]) -> Path | None:
|
||||
for candidate in candidates:
|
||||
path = Path(candidate)
|
||||
if path.is_file():
|
||||
return path.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_cases(cases: Sequence[ComparisonCase]) -> list[ResolvedCase]:
|
||||
resolved: list[ResolvedCase] = []
|
||||
for case in cases:
|
||||
binary = first_existing(case.binary_candidates)
|
||||
library = first_existing(case.library_candidates)
|
||||
if binary is None or library is None:
|
||||
missing = "binary" if binary is None else "library"
|
||||
print(f"warning: skipping {case.name}: no hardcoded {missing} path exists", file=sys.stderr)
|
||||
continue
|
||||
resolved.append(ResolvedCase(common.safe_name(case.name), binary, library))
|
||||
if not resolved:
|
||||
raise RuntimeError("none of the hardcoded binary/library cases is available")
|
||||
return resolved
|
||||
|
||||
|
||||
def compile_cmp_runner(build_dir: Path, rpm_root: Path, cxx: str) -> Path:
|
||||
runner = build_dir / "run_cmp"
|
||||
command = [
|
||||
cxx,
|
||||
"-O2",
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
str(RUN_CMP_SOURCE),
|
||||
str(build_dir / "set.o"),
|
||||
str(build_dir / "rpmmalloc.o"),
|
||||
"-o",
|
||||
str(runner),
|
||||
]
|
||||
log: list[str] = ["\n[rpmsetcmp runner]", "$ " + shlex.join(command)]
|
||||
completed = subprocess.run(command, cwd=rpm_root, text=True, capture_output=True)
|
||||
if completed.stdout:
|
||||
log.append(completed.stdout.rstrip())
|
||||
if completed.stderr:
|
||||
log.append(completed.stderr.rstrip())
|
||||
log.append(f"[exit {completed.returncode}]")
|
||||
build_log = build_dir / "build.log"
|
||||
with build_log.open("a", encoding="utf-8") as stream:
|
||||
stream.write("\n".join(log) + "\n")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"run_cmp compilation failed; see {build_log}")
|
||||
return runner
|
||||
|
||||
|
||||
def run_set_once(
|
||||
runner: Path,
|
||||
target: Path,
|
||||
sets_dir: Path,
|
||||
slug: str,
|
||||
bpp: int | None,
|
||||
) -> common.ParsedRun:
|
||||
command = [str(runner)]
|
||||
if bpp is not None:
|
||||
command.extend(("--bpp", str(bpp)))
|
||||
command.append(str(target))
|
||||
completed = subprocess.run(command, text=True, capture_output=True)
|
||||
stdout_path = sets_dir / f"{slug}.tsv"
|
||||
stderr_path = sets_dir / f"{slug}.stderr"
|
||||
stdout_path.write_text(completed.stdout, encoding="utf-8")
|
||||
stderr_path.write_text(completed.stderr, encoding="utf-8")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"run_set failed for {target}; see {stderr_path}")
|
||||
return common.parse_run_set_output(completed.stdout)
|
||||
|
||||
|
||||
def same_file(left: str, right: Path) -> bool:
|
||||
try:
|
||||
return os.path.samefile(left, right)
|
||||
except OSError:
|
||||
return Path(left).resolve() == right.resolve()
|
||||
|
||||
|
||||
def prepare_cases(
|
||||
cases: Sequence[ResolvedCase],
|
||||
run_set: Path,
|
||||
sets_dir: Path,
|
||||
bpp: int | None,
|
||||
) -> list[PreparedCase]:
|
||||
sets_dir.mkdir(parents=True)
|
||||
cache: dict[Path, common.ParsedRun] = {}
|
||||
used_slugs: set[str] = set()
|
||||
|
||||
def inspect(target: Path) -> common.ParsedRun:
|
||||
parsed = cache.get(target)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
slug = common.target_slug(target, used_slugs)
|
||||
parsed = run_set_once(run_set, target, sets_dir, slug, bpp)
|
||||
cache[target] = parsed
|
||||
return parsed
|
||||
|
||||
prepared: list[PreparedCase] = []
|
||||
for case in cases:
|
||||
library_run = inspect(case.library)
|
||||
provider_rows = [row for row in library_run.rows if row.get("role") == "provided"]
|
||||
if len(provider_rows) != 1:
|
||||
raise RuntimeError(
|
||||
f"expected one provider set for {case.library}, got {len(provider_rows)}"
|
||||
)
|
||||
|
||||
binary_run = inspect(case.binary)
|
||||
required_rows = [
|
||||
row
|
||||
for row in binary_run.rows
|
||||
if row.get("role") == "required"
|
||||
and row.get("object")
|
||||
and same_file(row["object"], case.library)
|
||||
]
|
||||
if len(required_rows) != 1:
|
||||
raise RuntimeError(
|
||||
f"expected one {case.library.name} requirement from {case.binary}, "
|
||||
f"got {len(required_rows)}"
|
||||
)
|
||||
|
||||
provider_row = provider_rows[0]
|
||||
required_row = required_rows[0]
|
||||
if provider_row["bpp"] != required_row["bpp"]:
|
||||
raise RuntimeError(
|
||||
f"bpp mismatch for {case.name}: provider={provider_row['bpp']} "
|
||||
f"required={required_row['bpp']}"
|
||||
)
|
||||
prepared.append(
|
||||
PreparedCase(
|
||||
case=case,
|
||||
provider_set=provider_row["set"],
|
||||
required_set=required_row["set"],
|
||||
)
|
||||
)
|
||||
return prepared
|
||||
|
||||
|
||||
def parse_cmp_output(output: str) -> tuple[int, int]:
|
||||
values: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
key, value = line.split("\t", 1)
|
||||
values[key] = value
|
||||
try:
|
||||
result = int(values["cmp_result"])
|
||||
elapsed = int(values["rpmsetcmp_ns"])
|
||||
except (KeyError, ValueError) as exception:
|
||||
raise RuntimeError("invalid run_cmp output") from exception
|
||||
if elapsed < 0:
|
||||
raise RuntimeError("run_cmp returned a negative duration")
|
||||
return result, elapsed
|
||||
|
||||
|
||||
def benchmark_case(
|
||||
runner: Path,
|
||||
prepared: PreparedCase,
|
||||
runs: int,
|
||||
output_dir: Path,
|
||||
) -> ComparisonSummary:
|
||||
output_dir.mkdir(parents=True)
|
||||
results: list[int] = []
|
||||
timings: list[int] = []
|
||||
for run_number in range(1, runs + 1):
|
||||
completed = subprocess.run(
|
||||
[str(runner), prepared.provider_set, prepared.required_set],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
stem = f"run-{run_number:03d}"
|
||||
stdout_path = output_dir / f"{stem}.tsv"
|
||||
stderr_path = output_dir / f"{stem}.stderr"
|
||||
stdout_path.write_text(completed.stdout, encoding="utf-8")
|
||||
stderr_path.write_text(completed.stderr, encoding="utf-8")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"run_cmp failed for {prepared.case.name} on run {run_number}; "
|
||||
f"see {stderr_path}"
|
||||
)
|
||||
result, elapsed = parse_cmp_output(completed.stdout)
|
||||
results.append(result)
|
||||
timings.append(elapsed)
|
||||
|
||||
return ComparisonSummary(
|
||||
prepared=prepared,
|
||||
runs=runs,
|
||||
cmp_result=results[0],
|
||||
outputs_consistent=len(set(results)) == 1,
|
||||
median_ns=statistics.median(timings),
|
||||
)
|
||||
|
||||
|
||||
def write_summary(path: Path, summaries: Sequence[ComparisonSummary]) -> None:
|
||||
with path.open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=list(SUMMARY_FIELDS), delimiter="\t")
|
||||
writer.writeheader()
|
||||
for summary in summaries:
|
||||
writer.writerow(summary.as_row())
|
||||
|
||||
|
||||
def print_summary(summaries: Sequence[ComparisonSummary]) -> None:
|
||||
print("\t".join(SUMMARY_FIELDS))
|
||||
for summary in summaries:
|
||||
row = summary.as_row()
|
||||
print("\t".join(row[field] for field in SUMMARY_FIELDS))
|
||||
|
||||
|
||||
def write_metadata(
|
||||
path: Path,
|
||||
set_source: Path,
|
||||
rpm_root: Path,
|
||||
runs: int,
|
||||
cases: Sequence[ResolvedCase],
|
||||
) -> None:
|
||||
lines = [
|
||||
f"set_c\t{set_source}",
|
||||
f"rpm_root\t{rpm_root}",
|
||||
f"runs\t{runs}",
|
||||
]
|
||||
for case in cases:
|
||||
lines.append(f"case\t{case.name}\t{case.binary}\t{case.library}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main(argv: Sequence[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
common.require_commands((args.cc, args.cxx, "eu-readelf", "eu-elfclassify", "nm"))
|
||||
set_source = common.resolve_file(args.set_c, "set.c")
|
||||
rpm_root = common.resolve_directory(args.rpm_root, "rpm root")
|
||||
common.resolve_file(rpm_root / "system.h", "system.h")
|
||||
cases = resolve_cases(HARDCODED_CASES)
|
||||
name = common.safe_name(args.name) if args.name else common.default_name(set_source)
|
||||
result_dir = args.res_dir.expanduser().resolve() / name
|
||||
if result_dir.exists():
|
||||
shutil.rmtree(result_dir)
|
||||
build_dir = result_dir / "build"
|
||||
sets_dir = result_dir / "sets"
|
||||
runs_dir = result_dir / "runs"
|
||||
build_dir.mkdir(parents=True)
|
||||
runs_dir.mkdir()
|
||||
|
||||
run_set = common.compile_runner(set_source, rpm_root, build_dir, args.cc, args.cxx)
|
||||
run_cmp = compile_cmp_runner(build_dir, rpm_root, args.cxx)
|
||||
prepared = prepare_cases(cases, run_set, sets_dir, args.bpp)
|
||||
summaries = [
|
||||
benchmark_case(run_cmp, item, args.runs, runs_dir / item.case.name)
|
||||
for item in prepared
|
||||
]
|
||||
|
||||
write_summary(result_dir / "summary.tsv", summaries)
|
||||
write_metadata(result_dir / "metadata.tsv", set_source, rpm_root, args.runs, cases)
|
||||
print_summary(summaries)
|
||||
print(f"\nresults\t{result_dir}")
|
||||
return 0
|
||||
except (OSError, RuntimeError, ValueError) as exception:
|
||||
print(f"compare_cmp_realization: {exception}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+518
@@ -0,0 +1,518 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build one set.c implementation and benchmark it through run_set.cpp.
|
||||
|
||||
The benchmark stores every raw run_set.cpp stdout/stderr stream and a TSV
|
||||
summary with median set.c API timings under res/<implementation>/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import shlex
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUN_SET_SOURCE = REPO_ROOT / "scripts" / "run_set.cpp"
|
||||
DEFAULT_RPM_ROOT = REPO_ROOT / "rpm-build"
|
||||
|
||||
TIMING_FIELDS = (
|
||||
"set_new_ns",
|
||||
"set_add_total_ns",
|
||||
"set_fini_ns",
|
||||
"set_free_ns",
|
||||
"set_api_total_ns",
|
||||
)
|
||||
SUMMARY_FIELDS = (
|
||||
"target",
|
||||
"kind",
|
||||
"runs",
|
||||
"sets_per_run",
|
||||
"outputs_consistent",
|
||||
*(f"median_{field}" for field in TIMING_FIELDS),
|
||||
)
|
||||
|
||||
# Each tuple describes one logical target. The first existing path is used.
|
||||
DEFAULT_TARGET_GROUPS = (
|
||||
("/usr/bin/true", "/bin/true"),
|
||||
("/usr/bin/ls", "/bin/ls"),
|
||||
("/usr/bin/bash", "/bin/bash"),
|
||||
("/usr/bin/python3", "/usr/local/bin/python3"),
|
||||
("/usr/lib/libc.so.6", "/lib64/libc.so.6", "/lib/x86_64-linux-gnu/libc.so.6"),
|
||||
("/usr/lib/libm.so.6", "/lib64/libm.so.6", "/lib/x86_64-linux-gnu/libm.so.6"),
|
||||
("/usr/lib/libz.so.1", "/usr/lib64/libz.so.1", "/lib/x86_64-linux-gnu/libz.so.1"),
|
||||
(
|
||||
"/usr/lib/libstdc++.so.6",
|
||||
"/usr/lib64/libstdc++.so.6",
|
||||
"/lib/x86_64-linux-gnu/libstdc++.so.6",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedRun:
|
||||
kind: str
|
||||
rows: tuple[dict[str, str], ...]
|
||||
totals: dict[str, int]
|
||||
output_signature: tuple[tuple[str, ...], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TargetSummary:
|
||||
target: Path
|
||||
kind: str
|
||||
runs: int
|
||||
sets_per_run: int
|
||||
outputs_consistent: bool
|
||||
medians: dict[str, int | float]
|
||||
|
||||
def as_row(self) -> dict[str, str]:
|
||||
row = {
|
||||
"target": str(self.target),
|
||||
"kind": self.kind,
|
||||
"runs": str(self.runs),
|
||||
"sets_per_run": str(self.sets_per_run),
|
||||
"outputs_consistent": "yes" if self.outputs_consistent else "no",
|
||||
}
|
||||
row.update(
|
||||
{
|
||||
f"median_{field}": format_number(value)
|
||||
for field, value in self.medians.items()
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def positive_int(value: str) -> int:
|
||||
number = int(value)
|
||||
if number < 1:
|
||||
raise argparse.ArgumentTypeError("must be greater than zero")
|
||||
return number
|
||||
|
||||
|
||||
def bpp_value(value: str) -> int:
|
||||
number = int(value)
|
||||
if not 10 <= number <= 32:
|
||||
raise argparse.ArgumentTypeError("must be in [10, 32]")
|
||||
return number
|
||||
|
||||
|
||||
def safe_name(value: str) -> str:
|
||||
name = "".join(
|
||||
character if character.isalnum() or character in "._-" else "-"
|
||||
for character in value
|
||||
)
|
||||
name = name.strip(".-")
|
||||
if not name or name in {".", ".."}:
|
||||
raise ValueError(f"invalid result name: {value!r}")
|
||||
return name
|
||||
|
||||
|
||||
def default_name(set_source: Path) -> str:
|
||||
return safe_name(f"{set_source.parent.name}-{set_source.stem}")
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Compile a selected set.c, run scripts/run_set.cpp repeatedly on ELF targets, "
|
||||
"and store raw outputs plus median set.c timings."
|
||||
)
|
||||
)
|
||||
parser.add_argument("set_c", type=Path, help="path to the set.c implementation")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--runs",
|
||||
type=positive_int,
|
||||
default=10,
|
||||
help="runs per ELF target (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rpm-root", type=Path, default=DEFAULT_RPM_ROOT, help="ALT rpm source root"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--res-dir",
|
||||
type=Path,
|
||||
default=REPO_ROOT / "res",
|
||||
help="result root (default: repository res/)",
|
||||
)
|
||||
parser.add_argument("--name", help="implementation directory name under res/")
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="ELF target; repeat to override the built-in binary/library set",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bpp", type=bpp_value, help="force one bpp value for every run_set invocation"
|
||||
)
|
||||
parser.add_argument("--cc", default="cc", help="C compiler (default: cc)")
|
||||
parser.add_argument("--cxx", default="g++", help="C++ compiler (default: g++)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def resolve_file(path: Path, description: str) -> Path:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.is_file():
|
||||
raise RuntimeError(f"{description} is not a file: {path}")
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_directory(path: Path, description: str) -> Path:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.is_dir():
|
||||
raise RuntimeError(f"{description} is not a directory: {path}")
|
||||
return resolved
|
||||
|
||||
|
||||
def select_default_targets() -> list[Path]:
|
||||
selected: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for candidates in DEFAULT_TARGET_GROUPS:
|
||||
for candidate in candidates:
|
||||
path = Path(candidate)
|
||||
if not path.is_file():
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
if resolved not in seen:
|
||||
selected.append(resolved)
|
||||
seen.add(resolved)
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def resolve_targets(explicit: Iterable[Path]) -> list[Path]:
|
||||
supplied = list(explicit)
|
||||
targets = (
|
||||
[resolve_file(path, "target") for path in supplied]
|
||||
if supplied
|
||||
else select_default_targets()
|
||||
)
|
||||
unique: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for target in targets:
|
||||
if target not in seen:
|
||||
unique.append(target)
|
||||
seen.add(target)
|
||||
if not unique:
|
||||
raise RuntimeError("no ELF targets were found; pass at least one --target")
|
||||
return unique
|
||||
|
||||
|
||||
def require_commands(commands: Iterable[str]) -> None:
|
||||
missing = [command for command in commands if shutil.which(command) is None]
|
||||
if missing:
|
||||
raise RuntimeError("missing required commands: " + ", ".join(missing))
|
||||
|
||||
|
||||
def create_header_shim(rpm_root: Path, include_dir: Path) -> None:
|
||||
include_dir.mkdir(parents=True, exist_ok=True)
|
||||
by_name: dict[str, Path] = {}
|
||||
for header in sorted(rpm_root.glob("*/*.h")):
|
||||
existing = by_name.get(header.name)
|
||||
if existing is not None and existing.resolve() != header.resolve():
|
||||
raise RuntimeError(
|
||||
f"duplicate rpm header basename {header.name}: {existing} and {header}"
|
||||
)
|
||||
by_name[header.name] = header
|
||||
if not by_name:
|
||||
raise RuntimeError(f"no */*.h headers found below {rpm_root}")
|
||||
for name, source in by_name.items():
|
||||
(include_dir / name).symlink_to(source.resolve())
|
||||
|
||||
|
||||
def run_build_command(
|
||||
command: Sequence[str], cwd: Path, log: list[str]
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
log.append("$ " + shlex.join(str(part) for part in command))
|
||||
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True)
|
||||
if completed.stdout:
|
||||
log.append(completed.stdout.rstrip())
|
||||
if completed.stderr:
|
||||
log.append(completed.stderr.rstrip())
|
||||
log.append(f"[exit {completed.returncode}]")
|
||||
return completed
|
||||
|
||||
|
||||
def compile_runner(
|
||||
set_source: Path,
|
||||
rpm_root: Path,
|
||||
build_dir: Path,
|
||||
cc: str,
|
||||
cxx: str,
|
||||
) -> Path:
|
||||
include_dir = build_dir / "include" / "rpm"
|
||||
create_header_shim(rpm_root, include_dir)
|
||||
set_object = build_dir / "set.o"
|
||||
malloc_object = build_dir / "rpmmalloc.o"
|
||||
runner = build_dir / "run_set"
|
||||
rpmmalloc_source = resolve_file(rpm_root / "rpmio" / "rpmmalloc.c", "rpmmalloc.c")
|
||||
log: list[str] = [f"set_c={set_source}", f"rpm_root={rpm_root}"]
|
||||
|
||||
common_flags = [
|
||||
"-O3",
|
||||
"-std=gnu11",
|
||||
"-include",
|
||||
"stddef.h",
|
||||
f"-I{rpm_root}",
|
||||
f"-I{include_dir}",
|
||||
]
|
||||
compatibility_flags = [
|
||||
"-D_GNU_SOURCE=1",
|
||||
"-DSTDC_HEADERS=1",
|
||||
"-DHAVE_STRING_H=1",
|
||||
"-DHAVE_SETENV=1",
|
||||
"-DHAVE_STPCPY=1",
|
||||
"-DHAVE_STPNCPY=1",
|
||||
"-DHAVE_S_IFSOCK=1",
|
||||
"-DHAVE_S_ISLNK=1",
|
||||
"-DHAVE_S_ISSOCK=1",
|
||||
]
|
||||
|
||||
def attempt(extra_flags: Sequence[str], label: str) -> bool:
|
||||
log.append(f"\n[{label}]")
|
||||
set_command = [
|
||||
cc,
|
||||
*common_flags,
|
||||
*extra_flags,
|
||||
"-c",
|
||||
str(set_source),
|
||||
"-o",
|
||||
str(set_object),
|
||||
]
|
||||
malloc_command = [
|
||||
cc,
|
||||
*common_flags,
|
||||
*extra_flags,
|
||||
"-include",
|
||||
"stdarg.h",
|
||||
"-c",
|
||||
str(rpmmalloc_source),
|
||||
"-o",
|
||||
str(malloc_object),
|
||||
]
|
||||
link_command = [
|
||||
cxx,
|
||||
"-O3",
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
str(RUN_SET_SOURCE),
|
||||
str(set_object),
|
||||
str(malloc_object),
|
||||
"-o",
|
||||
str(runner),
|
||||
]
|
||||
for command in (set_command, malloc_command, link_command):
|
||||
if run_build_command(command, rpm_root, log).returncode != 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
succeeded = attempt((), "minimal ALT-style build")
|
||||
if not succeeded:
|
||||
set_object.unlink(missing_ok=True)
|
||||
malloc_object.unlink(missing_ok=True)
|
||||
runner.unlink(missing_ok=True)
|
||||
succeeded = attempt(compatibility_flags, "modern libc compatibility retry")
|
||||
|
||||
build_log = build_dir / "build.log"
|
||||
build_log.write_text("\n".join(log) + "\n", encoding="utf-8")
|
||||
if not succeeded:
|
||||
raise RuntimeError(f"compilation failed; see {build_log}")
|
||||
return runner
|
||||
|
||||
|
||||
def parse_run_set_output(output: str) -> ParsedRun:
|
||||
lines = output.splitlines()
|
||||
try:
|
||||
table_start = next(
|
||||
index for index, line in enumerate(lines) if line.startswith("role\t")
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise RuntimeError("run_set output has no TSV result table") from exception
|
||||
|
||||
metadata: dict[str, str] = {}
|
||||
for line in lines[:table_start]:
|
||||
if "\t" not in line:
|
||||
continue
|
||||
key, value = line.split("\t", 1)
|
||||
metadata[key] = value
|
||||
rows = tuple(
|
||||
csv.DictReader(io.StringIO("\n".join(lines[table_start:])), delimiter="\t")
|
||||
)
|
||||
if not rows:
|
||||
raise RuntimeError("run_set output contains no result rows")
|
||||
|
||||
totals = {field: 0 for field in TIMING_FIELDS}
|
||||
for row in rows:
|
||||
for field in TIMING_FIELDS:
|
||||
try:
|
||||
totals[field] += int(row[field])
|
||||
except (KeyError, ValueError) as exception:
|
||||
raise RuntimeError(f"invalid {field} in run_set output") from exception
|
||||
signature_fields = ("role", "object", "labels", "bpp", "set")
|
||||
signature = tuple(tuple(row[field] for field in signature_fields) for row in rows)
|
||||
return ParsedRun(
|
||||
kind=metadata.get("kind", "unknown"),
|
||||
rows=rows,
|
||||
totals=totals,
|
||||
output_signature=signature,
|
||||
)
|
||||
|
||||
|
||||
def target_slug(target: Path, used: set[str]) -> str:
|
||||
base = safe_name(target.name)
|
||||
candidate = base
|
||||
if candidate in used:
|
||||
digest = hashlib.sha256(str(target).encode()).hexdigest()[:8]
|
||||
candidate = f"{base}-{digest}"
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def benchmark_target(
|
||||
runner: Path,
|
||||
target: Path,
|
||||
runs: int,
|
||||
output_dir: Path,
|
||||
bpp: int | None,
|
||||
) -> TargetSummary:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
parsed_runs: list[ParsedRun] = []
|
||||
for run_number in range(1, runs + 1):
|
||||
command = [str(runner)]
|
||||
if bpp is not None:
|
||||
command.extend(("--bpp", str(bpp)))
|
||||
command.append(str(target))
|
||||
completed = subprocess.run(command, text=True, capture_output=True)
|
||||
stem = f"run-{run_number:03d}"
|
||||
(output_dir / f"{stem}.tsv").write_text(completed.stdout, encoding="utf-8")
|
||||
(output_dir / f"{stem}.stderr").write_text(completed.stderr, encoding="utf-8")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"run_set failed for {target} on run {run_number}; "
|
||||
f"see {output_dir / f'{stem}.stderr'}"
|
||||
)
|
||||
parsed_runs.append(parse_run_set_output(completed.stdout))
|
||||
|
||||
signatures = {parsed.output_signature for parsed in parsed_runs}
|
||||
kinds = {parsed.kind for parsed in parsed_runs}
|
||||
row_counts = {len(parsed.rows) for parsed in parsed_runs}
|
||||
medians = {
|
||||
field: statistics.median(parsed.totals[field] for parsed in parsed_runs)
|
||||
for field in TIMING_FIELDS
|
||||
}
|
||||
return TargetSummary(
|
||||
target=target,
|
||||
kind=parsed_runs[0].kind if len(kinds) == 1 else "inconsistent",
|
||||
runs=runs,
|
||||
sets_per_run=len(parsed_runs[0].rows) if len(row_counts) == 1 else -1,
|
||||
outputs_consistent=len(signatures) == 1
|
||||
and len(kinds) == 1
|
||||
and len(row_counts) == 1,
|
||||
medians=medians,
|
||||
)
|
||||
|
||||
|
||||
def format_number(value: int | float) -> str:
|
||||
number = float(value)
|
||||
return str(int(number)) if number.is_integer() else f"{number:.1f}"
|
||||
|
||||
|
||||
def write_summary(path: Path, summaries: Iterable[TargetSummary]) -> None:
|
||||
with path.open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=SUMMARY_FIELDS, delimiter="\t")
|
||||
writer.writeheader()
|
||||
for summary in summaries:
|
||||
writer.writerow(summary.as_row())
|
||||
|
||||
|
||||
def print_summary(summaries: Iterable[TargetSummary]) -> None:
|
||||
fields = (
|
||||
"target",
|
||||
"kind",
|
||||
"runs",
|
||||
"sets_per_run",
|
||||
"outputs_consistent",
|
||||
"median_set_api_total_ns",
|
||||
)
|
||||
print("\t".join(fields))
|
||||
for summary in summaries:
|
||||
row = summary.as_row()
|
||||
print("\t".join(row[field] for field in fields))
|
||||
|
||||
|
||||
def write_metadata(
|
||||
path: Path,
|
||||
set_source: Path,
|
||||
rpm_root: Path,
|
||||
runner: Path,
|
||||
runs: int,
|
||||
targets: Iterable[Path],
|
||||
) -> None:
|
||||
lines = [
|
||||
f"set_c\t{set_source}",
|
||||
f"rpm_root\t{rpm_root}",
|
||||
f"runner\t{runner}",
|
||||
f"runs\t{runs}",
|
||||
]
|
||||
lines.extend(f"target\t{target}" for target in targets)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main(argv: Sequence[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
require_commands((args.cc, args.cxx, "eu-readelf", "eu-elfclassify", "nm"))
|
||||
set_source = resolve_file(args.set_c, "set.c")
|
||||
rpm_root = resolve_directory(args.rpm_root, "rpm root")
|
||||
resolve_file(rpm_root / "system.h", "system.h")
|
||||
targets = resolve_targets(args.target)
|
||||
name = safe_name(args.name) if args.name else default_name(set_source)
|
||||
result_root = args.res_dir.expanduser().resolve()
|
||||
result_dir = result_root / name
|
||||
if result_dir.exists():
|
||||
shutil.rmtree(result_dir)
|
||||
build_dir = result_dir / "build"
|
||||
runs_dir = result_dir / "runs"
|
||||
build_dir.mkdir(parents=True)
|
||||
runs_dir.mkdir()
|
||||
|
||||
runner = compile_runner(set_source, rpm_root, build_dir, args.cc, args.cxx)
|
||||
used_slugs: set[str] = set()
|
||||
summaries: list[TargetSummary] = []
|
||||
for target in targets:
|
||||
slug = target_slug(target, used_slugs)
|
||||
summaries.append(
|
||||
benchmark_target(runner, target, args.runs, runs_dir / slug, args.bpp)
|
||||
)
|
||||
|
||||
write_summary(result_dir / "summary.tsv", summaries)
|
||||
write_metadata(
|
||||
result_dir / "metadata.tsv",
|
||||
set_source,
|
||||
rpm_root,
|
||||
runner,
|
||||
args.runs,
|
||||
targets,
|
||||
)
|
||||
print_summary(summaries)
|
||||
print(f"\nresults\t{result_dir}")
|
||||
return 0
|
||||
except (OSError, RuntimeError, ValueError) as exception:
|
||||
print(f"compare_realization: {exception}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,48 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
extern "C" {
|
||||
#include "../rpm-build/lib/set.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Nanoseconds = std::chrono::nanoseconds;
|
||||
|
||||
void usage(const char* program)
|
||||
{
|
||||
std::cerr << "Usage: " << program << " PROVIDER_SET REQUIRED_SET\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc != 3) {
|
||||
usage(argv[0]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const std::string provider_set = argv[1];
|
||||
const std::string required_set = argv[2];
|
||||
|
||||
const auto start = Clock::now();
|
||||
const int result = rpmsetcmp(provider_set.c_str(), required_set.c_str());
|
||||
const auto finish = Clock::now();
|
||||
const std::int64_t elapsed =
|
||||
std::chrono::duration_cast<Nanoseconds>(finish - start).count();
|
||||
|
||||
std::cout << "provider_set\t" << provider_set << '\n';
|
||||
std::cout << "required_set\t" << required_set << '\n';
|
||||
std::cout << "cmp_result\t" << result << '\n';
|
||||
std::cout << "rpmsetcmp_ns\t" << elapsed << '\n';
|
||||
|
||||
if (result == -3 || result == -4) {
|
||||
std::cerr << "run_cmp: rpmsetcmp rejected an input set (result=" << result << ")\n";
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern "C" {
|
||||
#include "../rpm-build/lib/set.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Nanoseconds = std::chrono::nanoseconds;
|
||||
using LabelSet = std::set<std::string>;
|
||||
|
||||
struct CommandResult {
|
||||
int exit_code;
|
||||
std::string output;
|
||||
};
|
||||
|
||||
struct Timings {
|
||||
std::int64_t set_new_ns = 0;
|
||||
std::int64_t set_add_total_ns = 0;
|
||||
std::int64_t set_fini_ns = 0;
|
||||
std::int64_t set_free_ns = 0;
|
||||
|
||||
std::int64_t total_ns() const
|
||||
{
|
||||
return set_new_ns + set_add_total_ns + set_fini_ns + set_free_ns;
|
||||
}
|
||||
};
|
||||
|
||||
struct SetResult {
|
||||
std::string value;
|
||||
Timings timings;
|
||||
};
|
||||
|
||||
struct Options {
|
||||
std::filesystem::path input;
|
||||
std::optional<int> bpp;
|
||||
};
|
||||
|
||||
std::int64_t elapsed_ns(Clock::time_point start, Clock::time_point finish)
|
||||
{
|
||||
return std::chrono::duration_cast<Nanoseconds>(finish - start).count();
|
||||
}
|
||||
|
||||
std::string shortened(const std::string& value, std::size_t limit = 1200)
|
||||
{
|
||||
if (value.size() <= limit) {
|
||||
return value;
|
||||
}
|
||||
return value.substr(0, limit) + "\n... output truncated ...";
|
||||
}
|
||||
|
||||
CommandResult run_command(const std::vector<std::string>& command,
|
||||
const std::map<std::string, std::string>& environment = {})
|
||||
{
|
||||
if (command.empty()) {
|
||||
throw std::runtime_error("empty command");
|
||||
}
|
||||
|
||||
int output_pipe[2];
|
||||
if (pipe(output_pipe) != 0) {
|
||||
throw std::runtime_error("pipe failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
|
||||
const pid_t child = fork();
|
||||
if (child < 0) {
|
||||
const int saved_errno = errno;
|
||||
close(output_pipe[0]);
|
||||
close(output_pipe[1]);
|
||||
throw std::runtime_error("fork failed: " + std::string(std::strerror(saved_errno)));
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
close(output_pipe[0]);
|
||||
if (dup2(output_pipe[1], STDOUT_FILENO) < 0 || dup2(output_pipe[1], STDERR_FILENO) < 0) {
|
||||
_exit(126);
|
||||
}
|
||||
close(output_pipe[1]);
|
||||
|
||||
setenv("LC_ALL", "C", 1);
|
||||
for (const auto& [name, value] : environment) {
|
||||
setenv(name.c_str(), value.c_str(), 1);
|
||||
}
|
||||
|
||||
std::vector<char*> argv;
|
||||
argv.reserve(command.size() + 1);
|
||||
for (const std::string& argument : command) {
|
||||
argv.push_back(const_cast<char*>(argument.c_str()));
|
||||
}
|
||||
argv.push_back(nullptr);
|
||||
execvp(argv[0], argv.data());
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
close(output_pipe[1]);
|
||||
std::string output;
|
||||
char buffer[16384];
|
||||
while (true) {
|
||||
const ssize_t count = read(output_pipe[0], buffer, sizeof(buffer));
|
||||
if (count > 0) {
|
||||
output.append(buffer, static_cast<std::size_t>(count));
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (count < 0) {
|
||||
const int saved_errno = errno;
|
||||
close(output_pipe[0]);
|
||||
waitpid(child, nullptr, 0);
|
||||
throw std::runtime_error("read from child failed: " + std::string(std::strerror(saved_errno)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
close(output_pipe[0]);
|
||||
|
||||
int status = 0;
|
||||
while (waitpid(child, &status, 0) < 0) {
|
||||
if (errno != EINTR) {
|
||||
throw std::runtime_error("waitpid failed: " + std::string(std::strerror(errno)));
|
||||
}
|
||||
}
|
||||
|
||||
int exit_code = 128;
|
||||
if (WIFEXITED(status)) {
|
||||
exit_code = WEXITSTATUS(status);
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
exit_code += WTERMSIG(status);
|
||||
}
|
||||
return {exit_code, std::move(output)};
|
||||
}
|
||||
|
||||
CommandResult checked_command(const std::vector<std::string>& command,
|
||||
const std::map<std::string, std::string>& environment = {})
|
||||
{
|
||||
CommandResult result = run_command(command, environment);
|
||||
if (result.exit_code != 0) {
|
||||
std::ostringstream message;
|
||||
message << command.front() << " exited with " << result.exit_code;
|
||||
if (!result.output.empty()) {
|
||||
message << ":\n" << shortened(result.output);
|
||||
}
|
||||
throw std::runtime_error(message.str());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string strip_symbol_version(std::string symbol)
|
||||
{
|
||||
const std::size_t at = symbol.find('@');
|
||||
if (at != std::string::npos) {
|
||||
symbol.resize(at);
|
||||
}
|
||||
return symbol;
|
||||
}
|
||||
|
||||
bool allowed_symbol_type(const std::string& type)
|
||||
{
|
||||
static const std::unordered_set<std::string> allowed = {
|
||||
"NOTYPE", "OBJECT", "FUNC", "COMMON", "TLS", "IFUNC", "GNU_IFUNC",
|
||||
};
|
||||
return allowed.count(type) != 0;
|
||||
}
|
||||
|
||||
bool allowed_symbol_binding(const std::string& binding)
|
||||
{
|
||||
static const std::unordered_set<std::string> allowed = {
|
||||
"GLOBAL", "WEAK", "UNIQUE", "GNU_UNIQUE",
|
||||
};
|
||||
return allowed.count(binding) != 0;
|
||||
}
|
||||
|
||||
bool allowed_symbol_visibility(const std::string& visibility)
|
||||
{
|
||||
return visibility == "DEFAULT" || visibility == "PROTECTED";
|
||||
}
|
||||
|
||||
bool special_symbol(const std::string& symbol)
|
||||
{
|
||||
static const std::unordered_set<std::string> ignored = {
|
||||
"__bss_start", "_edata", "_end", "_fini", "_init",
|
||||
};
|
||||
return ignored.count(symbol) != 0;
|
||||
}
|
||||
|
||||
LabelSet provided_labels(const std::filesystem::path& library)
|
||||
{
|
||||
const CommandResult result = checked_command(
|
||||
{"eu-readelf", "--wide", "--dyn-syms", library.string()});
|
||||
LabelSet labels;
|
||||
std::istringstream output(result.output);
|
||||
std::string line;
|
||||
while (std::getline(output, line)) {
|
||||
std::istringstream fields(line);
|
||||
std::string number;
|
||||
std::string value;
|
||||
std::string size;
|
||||
std::string type;
|
||||
std::string binding;
|
||||
std::string visibility;
|
||||
std::string section;
|
||||
std::string symbol;
|
||||
if (!(fields >> number >> value >> size >> type >> binding >> visibility >> section >> symbol)) {
|
||||
continue;
|
||||
}
|
||||
if (number.empty() || number.back() != ':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::uint64_t symbol_value = 0;
|
||||
try {
|
||||
symbol_value = std::stoull(value, nullptr, 16);
|
||||
} catch (const std::exception&) {
|
||||
continue;
|
||||
}
|
||||
if (symbol_value == 0 && type != "TLS") {
|
||||
continue;
|
||||
}
|
||||
if (!allowed_symbol_type(type) || !allowed_symbol_binding(binding) ||
|
||||
!allowed_symbol_visibility(visibility)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
symbol = strip_symbol_version(std::move(symbol));
|
||||
if (symbol.empty() || special_symbol(symbol) ||
|
||||
symbol.find_first_of("()@") != std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
labels.insert(std::move(symbol));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
LabelSet weak_undefined_labels(const std::filesystem::path& executable)
|
||||
{
|
||||
const CommandResult result = checked_command({"nm", "--dynamic", executable.string()});
|
||||
LabelSet weak;
|
||||
std::istringstream output(result.output);
|
||||
std::string line;
|
||||
while (std::getline(output, line)) {
|
||||
std::istringstream fields(line);
|
||||
std::vector<std::string> parts;
|
||||
std::string part;
|
||||
while (fields >> part) {
|
||||
parts.push_back(part);
|
||||
}
|
||||
if (parts.size() != 2 || parts[0].size() != 1 ||
|
||||
std::string("wWvV").find(parts[0][0]) == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
weak.insert(strip_symbol_version(parts[1]));
|
||||
}
|
||||
return weak;
|
||||
}
|
||||
|
||||
std::string program_interpreter(const std::filesystem::path& executable)
|
||||
{
|
||||
const CommandResult result = checked_command(
|
||||
{"eu-readelf", "--program-headers", executable.string()});
|
||||
const std::string marker = "[Requesting program interpreter: ";
|
||||
const std::size_t start = result.output.find(marker);
|
||||
if (start == std::string::npos) {
|
||||
throw std::runtime_error("ELF executable has no PT_INTERP entry: " + executable.string());
|
||||
}
|
||||
const std::size_t value_start = start + marker.size();
|
||||
const std::size_t end = result.output.find(']', value_start);
|
||||
if (end == std::string::npos || end == value_start) {
|
||||
throw std::runtime_error("cannot parse PT_INTERP for " + executable.string());
|
||||
}
|
||||
return result.output.substr(value_start, end - value_start);
|
||||
}
|
||||
|
||||
bool same_path(const std::string& lhs, const std::filesystem::path& rhs)
|
||||
{
|
||||
std::error_code error;
|
||||
if (std::filesystem::equivalent(lhs, rhs, error)) {
|
||||
return true;
|
||||
}
|
||||
return std::filesystem::path(lhs).lexically_normal() == rhs.lexically_normal();
|
||||
}
|
||||
|
||||
std::map<std::string, LabelSet> required_labels_by_provider(
|
||||
const std::filesystem::path& executable)
|
||||
{
|
||||
const std::string interpreter = program_interpreter(executable);
|
||||
const std::map<std::string, std::string> environment = {
|
||||
{"LD_BIND_NOW", "1"},
|
||||
{"LD_DEBUG", "bindings"},
|
||||
{"LD_TRACE_LOADED_OBJECTS", "1"},
|
||||
{"LD_WARN", "1"},
|
||||
};
|
||||
const CommandResult result = checked_command(
|
||||
{interpreter, executable.string()}, environment);
|
||||
const LabelSet weak = weak_undefined_labels(executable);
|
||||
std::map<std::string, LabelSet> labels_by_provider;
|
||||
|
||||
std::istringstream output(result.output);
|
||||
std::string line;
|
||||
while (std::getline(output, line)) {
|
||||
const std::string binding_marker = "binding file ";
|
||||
const std::size_t binding = line.find(binding_marker);
|
||||
if (binding == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
const std::size_t source_start = binding + binding_marker.size();
|
||||
const std::size_t source_end = line.find(" [", source_start);
|
||||
const std::size_t to = source_end == std::string::npos
|
||||
? std::string::npos
|
||||
: line.find(" to ", source_end);
|
||||
const std::size_t provider_start = to == std::string::npos
|
||||
? std::string::npos
|
||||
: to + std::string(" to ").size();
|
||||
const std::size_t provider_end = provider_start == std::string::npos
|
||||
? std::string::npos
|
||||
: line.find(" [", provider_start);
|
||||
const std::size_t symbol_marker = provider_end == std::string::npos
|
||||
? std::string::npos
|
||||
: line.find(" symbol ", provider_end);
|
||||
if (source_end == std::string::npos || to == std::string::npos ||
|
||||
provider_start == std::string::npos || provider_end == std::string::npos ||
|
||||
symbol_marker == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string source = line.substr(source_start, source_end - source_start);
|
||||
const std::string provider = line.substr(provider_start, provider_end - provider_start);
|
||||
if (!same_path(source, executable) || source == provider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::size_t symbol_start = symbol_marker + std::string(" symbol ").size();
|
||||
if (symbol_start < line.size() && (line[symbol_start] == '`' || line[symbol_start] == '\'')) {
|
||||
++symbol_start;
|
||||
}
|
||||
std::size_t symbol_end = line.find('\'', symbol_start);
|
||||
if (symbol_end == std::string::npos) {
|
||||
symbol_end = line.find_first_of(" \t[", symbol_start);
|
||||
}
|
||||
if (symbol_end == std::string::npos) {
|
||||
symbol_end = line.size();
|
||||
}
|
||||
std::string symbol = strip_symbol_version(
|
||||
line.substr(symbol_start, symbol_end - symbol_start));
|
||||
if (symbol.empty() || weak.count(symbol) != 0) {
|
||||
continue;
|
||||
}
|
||||
labels_by_provider[provider].insert(std::move(symbol));
|
||||
}
|
||||
|
||||
for (auto it = labels_by_provider.begin(); it != labels_by_provider.end();) {
|
||||
if (it->second.empty()) {
|
||||
it = labels_by_provider.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
return labels_by_provider;
|
||||
}
|
||||
|
||||
int suggested_bpp(std::size_t provider_label_count)
|
||||
{
|
||||
if (provider_label_count < 1) {
|
||||
provider_label_count = 1;
|
||||
}
|
||||
std::size_t value = provider_label_count - 1;
|
||||
int bits = 0;
|
||||
while (value != 0) {
|
||||
++bits;
|
||||
value >>= 1;
|
||||
}
|
||||
return std::min(32, bits + 10);
|
||||
}
|
||||
|
||||
SetResult build_set(const LabelSet& labels, int bpp)
|
||||
{
|
||||
if (labels.empty()) {
|
||||
throw std::runtime_error("cannot build a set from zero labels");
|
||||
}
|
||||
|
||||
Timings timings;
|
||||
auto start = Clock::now();
|
||||
struct set* value = set_new();
|
||||
auto finish = Clock::now();
|
||||
timings.set_new_ns = elapsed_ns(start, finish);
|
||||
if (value == nullptr) {
|
||||
throw std::runtime_error("set_new returned NULL");
|
||||
}
|
||||
|
||||
start = Clock::now();
|
||||
for (const std::string& label : labels) {
|
||||
set_add(value, label.c_str());
|
||||
}
|
||||
finish = Clock::now();
|
||||
timings.set_add_total_ns = elapsed_ns(start, finish);
|
||||
|
||||
start = Clock::now();
|
||||
const char* payload = set_fini(value, bpp);
|
||||
finish = Clock::now();
|
||||
timings.set_fini_ns = elapsed_ns(start, finish);
|
||||
if (payload == nullptr) {
|
||||
set_free(value);
|
||||
throw std::runtime_error("set_fini returned NULL");
|
||||
}
|
||||
std::string encoded = "set:" + std::string(payload);
|
||||
std::free(const_cast<char*>(payload));
|
||||
|
||||
start = Clock::now();
|
||||
value = set_free(value);
|
||||
finish = Clock::now();
|
||||
timings.set_free_ns = elapsed_ns(start, finish);
|
||||
(void)value;
|
||||
return {std::move(encoded), timings};
|
||||
}
|
||||
|
||||
enum class ElfKind {
|
||||
executable,
|
||||
shared_library,
|
||||
};
|
||||
|
||||
ElfKind classify_elf(const std::filesystem::path& input)
|
||||
{
|
||||
const CommandResult executable = run_command(
|
||||
{"eu-elfclassify", "--executable", input.string()});
|
||||
if (executable.exit_code == 0) {
|
||||
return ElfKind::executable;
|
||||
}
|
||||
if (executable.exit_code == 127) {
|
||||
throw std::runtime_error("eu-elfclassify is required but was not found");
|
||||
}
|
||||
|
||||
const CommandResult shared = run_command(
|
||||
{"eu-elfclassify", "--shared", input.string()});
|
||||
if (shared.exit_code == 0) {
|
||||
return ElfKind::shared_library;
|
||||
}
|
||||
throw std::runtime_error("input is not a supported dynamic ELF executable or shared library: " +
|
||||
input.string());
|
||||
}
|
||||
|
||||
int parse_bpp(const std::string& value)
|
||||
{
|
||||
std::size_t parsed = 0;
|
||||
int bpp = 0;
|
||||
try {
|
||||
bpp = std::stoi(value, &parsed);
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error("invalid bpp: " + value);
|
||||
}
|
||||
if (parsed != value.size() || bpp < 10 || bpp > 32) {
|
||||
throw std::runtime_error("bpp must be an integer in [10, 32]");
|
||||
}
|
||||
return bpp;
|
||||
}
|
||||
|
||||
void usage(const char* program)
|
||||
{
|
||||
std::cerr << "Usage: " << program << " [--bpp 10..32] ELF_PATH\n";
|
||||
}
|
||||
|
||||
Options parse_options(int argc, char** argv)
|
||||
{
|
||||
Options options;
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string argument = argv[index];
|
||||
if (argument == "--help" || argument == "-h") {
|
||||
usage(argv[0]);
|
||||
std::exit(0);
|
||||
}
|
||||
if (argument == "--bpp") {
|
||||
if (++index >= argc) {
|
||||
throw std::runtime_error("--bpp requires a value");
|
||||
}
|
||||
options.bpp = parse_bpp(argv[index]);
|
||||
continue;
|
||||
}
|
||||
if (!argument.empty() && argument.front() == '-') {
|
||||
throw std::runtime_error("unknown option: " + argument);
|
||||
}
|
||||
if (!options.input.empty()) {
|
||||
throw std::runtime_error("exactly one ELF path is required");
|
||||
}
|
||||
options.input = argument;
|
||||
}
|
||||
if (options.input.empty()) {
|
||||
throw std::runtime_error("ELF path is required");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
void print_header(const std::filesystem::path& input, ElfKind kind,
|
||||
const std::optional<int>& bpp)
|
||||
{
|
||||
std::cout << "input\t" << input.string() << '\n';
|
||||
std::cout << "kind\t"
|
||||
<< (kind == ElfKind::executable ? "executable" : "shared-library") << '\n';
|
||||
std::cout << "bpp_mode\t" << (bpp.has_value() ? "override" : "auto") << '\n';
|
||||
std::cout << "role\tobject\tlabels\tbpp\tset\tset_new_ns\tset_add_total_ns\t"
|
||||
"set_fini_ns\tset_free_ns\tset_api_total_ns\n";
|
||||
}
|
||||
|
||||
void print_row(const std::string& role, const std::string& object,
|
||||
std::size_t label_count, int bpp, const SetResult& result)
|
||||
{
|
||||
const Timings& timings = result.timings;
|
||||
std::cout << role << '\t' << object << '\t' << label_count << '\t' << bpp << '\t'
|
||||
<< result.value << '\t' << timings.set_new_ns << '\t'
|
||||
<< timings.set_add_total_ns << '\t' << timings.set_fini_ns << '\t'
|
||||
<< timings.set_free_ns << '\t' << timings.total_ns() << '\n';
|
||||
}
|
||||
|
||||
int run(const Options& options)
|
||||
{
|
||||
std::error_code error;
|
||||
const std::filesystem::path input = std::filesystem::canonical(options.input, error);
|
||||
if (error || !std::filesystem::is_regular_file(input)) {
|
||||
throw std::runtime_error("input is not a readable regular file: " + options.input.string());
|
||||
}
|
||||
if (input.string().find_first_of("\t\r\n") != std::string::npos) {
|
||||
throw std::runtime_error("input path contains characters unsupported by TSV output");
|
||||
}
|
||||
|
||||
const ElfKind kind = classify_elf(input);
|
||||
print_header(input, kind, options.bpp);
|
||||
|
||||
if (kind == ElfKind::shared_library) {
|
||||
const LabelSet labels = provided_labels(input);
|
||||
if (labels.empty()) {
|
||||
throw std::runtime_error("no provided labels found in " + input.string());
|
||||
}
|
||||
const int bpp = options.bpp.value_or(suggested_bpp(labels.size()));
|
||||
print_row("provided", input.string(), labels.size(), bpp, build_set(labels, bpp));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const std::map<std::string, LabelSet> requirements = required_labels_by_provider(input);
|
||||
if (requirements.empty()) {
|
||||
throw std::runtime_error("no external dynamic symbol bindings found in " + input.string());
|
||||
}
|
||||
for (const auto& [provider, labels] : requirements) {
|
||||
std::size_t provider_count = 0;
|
||||
try {
|
||||
provider_count = provided_labels(provider).size();
|
||||
} catch (const std::exception& exception) {
|
||||
std::cerr << "warning: cannot inspect provider " << provider << ": "
|
||||
<< exception.what() << '\n';
|
||||
}
|
||||
const int bpp = options.bpp.value_or(
|
||||
suggested_bpp(provider_count == 0 ? labels.size() : provider_count));
|
||||
print_row("required", provider, labels.size(), bpp, build_set(labels, bpp));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
try {
|
||||
return run(parse_options(argc, argv));
|
||||
} catch (const std::exception& exception) {
|
||||
std::cerr << "run_set: " << exception.what() << '\n';
|
||||
usage(argv[0]);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user