From 6dd25756b6401ab0e70b7e3f3bc0be14fa8d461b Mon Sep 17 00:00:00 2001 From: Ben Zeng Date: Fri, 23 Jan 2026 14:19:44 +0200 Subject: [PATCH 1/4] auto-patchelf: support structured logging This introduces a --structured-logs parameter, which will then output log lines in JSON, distinguishing certain event types, instead of plain text written to stdout. If set, it makes it easier to interpret the output, and reason about which files were patched (or not), and why. --- .../au/auto-patchelf/source/auto-patchelf.py | 120 ++++++++++++++---- 1 file changed, 95 insertions(+), 25 deletions(-) diff --git a/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py b/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py index 360756375f48..358e0a1fad2a 100644 --- a/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py +++ b/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py @@ -9,10 +9,10 @@ import json from fnmatch import fnmatch from collections import defaultdict from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, asdict from itertools import chain from pathlib import Path, PurePath -from typing import DefaultDict, Generator, Iterator, Optional +from typing import DefaultDict, Generator, Iterator, Optional, Protocol from elftools.common.exceptions import ELFError # type: ignore from elftools.elf.dynamic import DynamicSection # type: ignore @@ -233,40 +233,93 @@ def find_first_matching_rpath_with_origin(binary: Path, lib_dir: Path, rpaths: l return Path(rpath) return None +class Event(Protocol): + """Protocol for loggable events that occur during the auto-patchelf process.""" + def to_human_readable_str(self) -> str: ... + + +@dataclass +class SkipFile: + file: Path # The file being skipped + reason: str # Why the file is being skipped + + def to_human_readable_str(self) -> str: + return f"skipping {self.file} because {self.reason}" + + +@dataclass +class SetInterpreter: + file: Path # The file being patched + interpreter_path: Path # The interpreter being set + + def to_human_readable_str(self) -> str: + return f"setting interpreter of {self.file}" + @dataclass class Dependency: - file: Path # The file that contains the dependency - name: Path # The name of the dependency - found: bool = False # Whether it was found somewhere + file: Path # The file that contains the dependency + name: Path # The name of the dependency + found: bool = False # Whether it was found somewhere + location: Optional[Path] = None # Where the dependency was found (if found) + + def to_human_readable_str(self) -> str: + if self.found: + return f" {self.name} -> found: {self.location}" + else: + return f" {self.name} -> not found!" -def auto_patchelf_file(path: Path, runtime_deps: list[Path], append_rpaths: list[Path] = [], keep_libc: bool = False, preserve_origin: bool = False, extra_args: list[str] = []) -> list[Dependency]: +@dataclass +class SetRpath: + file: Path # The file being patched + rpath: str # The RPATH being set + + def to_human_readable_str(self) -> str: + return f"setting RPATH to: {self.rpath}" + + +class Logger: + """Outputs events in either structured (JSON) or human-readable format.""" + + def __init__(self, structured: bool = False): + self.structured = structured + + def log(self, event: Event) -> None: + """Output an event immediately. In structured mode, output JSON. In human-readable mode, output human-readable text.""" + if self.structured: + event_type = type(event).__name__ + event_data = asdict(event) + print(json.dumps({event_type: event_data}, default=str)) + else: + print(event.to_human_readable_str()) + + + +def auto_patchelf_file(logger: Logger, path: Path, runtime_deps: list[Path], append_rpaths: list[Path] = [], keep_libc: bool = False, preserve_origin: bool = False, extra_args: list[str] = []) -> list[Dependency]: try: with open_elf(path) as elf: if is_static_executable(elf): # No point patching these - print(f"skipping {path} because it is statically linked") + logger.log(SkipFile(file=path, reason="it is statically linked")) return [] if elf.num_segments() == 0: # no segment (e.g. object file) - print(f"skipping {path} because it contains no segment") + logger.log(SkipFile(file=path, reason="it contains no segment")) return [] file_arch = get_arch(elf) if interpreter_arch != file_arch: # Our target architecture is different than this file's # architecture, so skip it. - print(f"skipping {path} because its architecture ({file_arch})" - f" differs from target ({interpreter_arch})") + logger.log(SkipFile(file=path, reason=f"its architecture ({file_arch}) differs from target ({interpreter_arch})")) return [] file_osabi = get_osabi(elf) if not osabi_are_compatible(interpreter_osabi, file_osabi): - print(f"skipping {path} because its OS ABI ({file_osabi}) is" - f" not compatible with target ({interpreter_osabi})") + logger.log(SkipFile(file=path, reason=f"its OS ABI ({file_osabi}) is not compatible with target ({interpreter_osabi})")) return [] file_is_dynamic_executable = is_dynamic_executable(elf) @@ -283,13 +336,14 @@ def auto_patchelf_file(path: Path, runtime_deps: list[Path], append_rpaths: list rpath = [] if file_is_dynamic_executable: - print("setting interpreter of", path) + logger.log(SetInterpreter(file=path, interpreter_path=interpreter_path)) subprocess.run( ["patchelf", "--set-interpreter", interpreter_path.as_posix(), path.as_posix()] + extra_args, check=True) rpath += runtime_deps - print("searching for dependencies of", path) + if not logger.structured: + print(f"searching for dependencies of {path}") dependencies = [] # Be sure to get the output of all missing dependencies instead of # failing at the first one, because it's more useful when working @@ -335,8 +389,9 @@ def auto_patchelf_file(path: Path, runtime_deps: list[Path], append_rpaths: list elif found_dependency := find_dependency(candidate.name, file_arch, file_osabi): origin_rpath_entry = find_first_matching_rpath_with_origin(path, found_dependency, existing_rpaths) if preserve_origin else None rpath.append(origin_rpath_entry or found_dependency) - dependencies.append(Dependency(path, candidate, found=True)) - print(f" {candidate} -> found: {found_dependency}") + dep = Dependency(file=path, name=candidate, found=True, location=found_dependency) + dependencies.append(dep) + logger.log(dep) was_found = True break elif is_libc and keep_libc: @@ -345,8 +400,9 @@ def auto_patchelf_file(path: Path, runtime_deps: list[Path], append_rpaths: list if not was_found: dep_name = dep[0] if len(dep) == 1 else f"any({', '.join(map(str, dep))})" - dependencies.append(Dependency(path, dep_name, found=False)) - print(f" {dep_name} -> not found!") + dep = Dependency(file=path, name=dep_name, found=False, location=None) + dependencies.append(dep) + logger.log(dep) rpath.extend(append_rpaths) @@ -360,7 +416,7 @@ def auto_patchelf_file(path: Path, runtime_deps: list[Path], append_rpaths: list rpath_str = ":".join(dict.fromkeys(map(Path.as_posix, rpath))) if rpath: - print("setting RPATH to:", rpath_str) + logger.log(SetRpath(file=path, rpath=rpath_str)) subprocess.run( ["patchelf", "--set-rpath", rpath_str, path.as_posix()] + extra_args, check=True) @@ -369,6 +425,7 @@ def auto_patchelf_file(path: Path, runtime_deps: list[Path], append_rpaths: list def auto_patchelf( + logger: Logger, paths_to_patch: list[Path], lib_dirs: list[Path], runtime_deps: list[Path], @@ -393,20 +450,23 @@ def auto_patchelf( dependencies = [] for path in chain.from_iterable(glob(p, '*', recursive) for p in paths_to_patch): if not path.is_symlink() and path.is_file(): - dependencies += auto_patchelf_file(path, runtime_deps, append_rpaths, keep_libc, preserve_origin, extra_args) + dependencies += auto_patchelf_file(logger, path, runtime_deps, append_rpaths, keep_libc, preserve_origin, extra_args) missing = [dep for dep in dependencies if not dep.found] # Print a summary of the missing dependencies at the end - print(f"auto-patchelf: {len(missing)} dependencies could not be satisfied") + if not logger.structured: + print(f"auto-patchelf: {len(missing)} dependencies could not be satisfied") failure = False for dep in missing: for pattern in ignore_missing: if fnmatch(dep.name.name, pattern): - print(f"warn: auto-patchelf ignoring missing {dep.name} wanted by {dep.file}") + if not logger.structured: + print(f"warn: auto-patchelf ignoring missing {dep.name} wanted by {dep.file}") break else: - print(f"error: auto-patchelf could not satisfy dependency {dep.name} wanted by {dep.file}") + if not logger.structured: + print(f"error: auto-patchelf could not satisfy dependency {dep.name} wanted by {dep.file}") failure = True if failure: @@ -485,6 +545,11 @@ def main() -> None: action="store_false", help="Do not add the existing rpaths of the patched files to the list of directories to search for dependencies.", ) + parser.add_argument( + "--structured-logs", + action="store_true", + help="Output events as JSON Lines to stdout instead of human-readable diagnostics.", + ) parser.add_argument( "--extra-args", # Undocumented Python argparse feature: consume all remaining arguments @@ -496,11 +561,16 @@ def main() -> None: help="Extra arguments to pass to patchelf. This argument should always come last.", ) - print("automatically fixing dependencies for ELF files") args = parser.parse_args() - pprint.pprint(vars(args)) + logger = Logger(structured=args.structured_logs) + if not logger.structured: + print("automatically fixing dependencies for ELF files") + + if not logger.structured: + pprint.pprint(vars(args)) auto_patchelf( + logger, args.paths, args.libs, args.runtime_dependencies, From 518d82c5be13eaefe7f74272d7946026a0a9abf7 Mon Sep 17 00:00:00 2001 From: Florian Klink Date: Sun, 25 Jan 2026 21:37:36 +0200 Subject: [PATCH 2/4] tests.auto-patchelf-structured-log: init This adds a test for the --structured-log feature of auto-patchelf. When auto-patchelf'ing ToneLib-Jam and only making freetype available, we expect: - one "SetInterpreter" line - 3 "Dependency" lines (of which one found) - one SetRpath line (containing freetype) --- .../auto-patchelf-structured-log/default.nix | 53 +++++++++++++++++++ pkgs/test/default.nix | 2 + 2 files changed, 55 insertions(+) create mode 100644 pkgs/test/auto-patchelf-structured-log/default.nix diff --git a/pkgs/test/auto-patchelf-structured-log/default.nix b/pkgs/test/auto-patchelf-structured-log/default.nix new file mode 100644 index 000000000000..b75e5cb58ca1 --- /dev/null +++ b/pkgs/test/auto-patchelf-structured-log/default.nix @@ -0,0 +1,53 @@ +{ + lib, + stdenv, + fetchurl, + + auto-patchelf, + dpkg, + freetype, + jq, +}: + +stdenv.mkDerivation { + name = "auto-patchelf-structured-log-test"; + + src = fetchurl { + url = "https://tonelib.net/download/221222/ToneLib-Jam-amd64.deb"; + sha256 = "sha256-c6At2lRPngQPpE7O+VY/Hsfw+QfIb3COIuHfbqqIEuM="; + }; + + unpackCmd = '' + dpkg -x $curSrc source + ''; + + nativeBuildInputs = [ + dpkg + auto-patchelf + jq + ]; + + installPhase = '' + auto-patchelf \ + --paths ./usr/bin/ToneLib-Jam \ + --libs ${lib.getLib freetype}/lib \ + --structured-logs > log.jsonl || true + + # Expect 1 SetInterpreter line + jq -e -s '[.[] | select(has("SetInterpreter"))] | length == 1' log.jsonl + + # We expect 3 Dependency lines + jq -e -s '[.[] | select(has("Dependency"))] | length == 3' log.jsonl + + # Expect the last line to set the rpath as expected + jq -e -s 'last == { + "SetRpath": { + "file": "usr/bin/ToneLib-Jam", + "rpath": "${lib.getLib freetype}/lib" + } + }' log.jsonl + + cp log.jsonl $out + ''; + +} diff --git a/pkgs/test/default.nix b/pkgs/test/default.nix index 3823ce88bc80..efcd4963136d 100644 --- a/pkgs/test/default.nix +++ b/pkgs/test/default.nix @@ -227,6 +227,8 @@ in buildFHSEnv = recurseIntoAttrs (callPackages ./buildFHSEnv { }); + auto-patchelf-structured-log = callPackage ./auto-patchelf-structured-log { }; + auto-patchelf-hook = callPackage ./auto-patchelf-hook { }; auto-patchelf-hook-preserve-origin = callPackage ./auto-patchelf-hook-preserve-origin { }; From 45a5479dc18bf27c1662158d9a7f5d2c32355328 Mon Sep 17 00:00:00 2001 From: Florian Klink Date: Tue, 27 Jan 2026 12:48:19 +0200 Subject: [PATCH 3/4] auto-patchelf: introduce Logger.debug function This is used when printing a simple string only in the case of non-structured logging is desired. --- .../au/auto-patchelf/source/auto-patchelf.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py b/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py index 358e0a1fad2a..87081048343b 100644 --- a/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py +++ b/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py @@ -285,6 +285,11 @@ class Logger: def __init__(self, structured: bool = False): self.structured = structured + def debug(self, message: str): + """Output a debug log message (text), only if not in structured mode.""" + if not self.structured: + print(message) + def log(self, event: Event) -> None: """Output an event immediately. In structured mode, output JSON. In human-readable mode, output human-readable text.""" if self.structured: @@ -342,8 +347,7 @@ def auto_patchelf_file(logger: Logger, path: Path, runtime_deps: list[Path], app check=True) rpath += runtime_deps - if not logger.structured: - print(f"searching for dependencies of {path}") + logger.debug(f"searching for dependencies of {path}") dependencies = [] # Be sure to get the output of all missing dependencies instead of # failing at the first one, because it's more useful when working @@ -455,18 +459,15 @@ def auto_patchelf( missing = [dep for dep in dependencies if not dep.found] # Print a summary of the missing dependencies at the end - if not logger.structured: - print(f"auto-patchelf: {len(missing)} dependencies could not be satisfied") + logger.debug(f"auto-patchelf: {len(missing)} dependencies could not be satisfied") failure = False for dep in missing: for pattern in ignore_missing: if fnmatch(dep.name.name, pattern): - if not logger.structured: - print(f"warn: auto-patchelf ignoring missing {dep.name} wanted by {dep.file}") + logger.debug(f"warn: auto-patchelf ignoring missing {dep.name} wanted by {dep.file}") break else: - if not logger.structured: - print(f"error: auto-patchelf could not satisfy dependency {dep.name} wanted by {dep.file}") + logger.debug(f"error: auto-patchelf could not satisfy dependency {dep.name} wanted by {dep.file}") failure = True if failure: @@ -563,11 +564,8 @@ def main() -> None: args = parser.parse_args() logger = Logger(structured=args.structured_logs) - if not logger.structured: - print("automatically fixing dependencies for ELF files") - - if not logger.structured: - pprint.pprint(vars(args)) + logger.debug("automatically fixing dependencies for ELF files") + logger.debug(pprint.pformat(vars(args))) auto_patchelf( logger, From c45f0081875eaa814e774ccc86f417d0a394a3d2 Mon Sep 17 00:00:00 2001 From: Florian Klink Date: Tue, 27 Jan 2026 13:20:46 +0200 Subject: [PATCH 4/4] auto-patchelf: add IgnoredDependency event This logs when there was a missing dependency that was matched by an ignore pattern. The test case is extended to also check for having the IgnoredDependency events in the structured log output. --- .../by-name/au/auto-patchelf/source/auto-patchelf.py | 11 ++++++++++- pkgs/test/auto-patchelf-structured-log/default.nix | 12 ++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py b/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py index 87081048343b..33813168f560 100644 --- a/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py +++ b/pkgs/by-name/au/auto-patchelf/source/auto-patchelf.py @@ -256,6 +256,15 @@ class SetInterpreter: return f"setting interpreter of {self.file}" +@dataclass +class IgnoredDependency: + file: Path # The file that contains the ignored dependency + name: Path # The name of the dependency + pattern: str # The pattern that caused this missing dep to be ignored + + def to_human_readable_str(self) -> str: + return f"warn: auto-patchelf ignoring missing {self.name} wanted by {self.file}" + @dataclass class Dependency: file: Path # The file that contains the dependency @@ -464,7 +473,7 @@ def auto_patchelf( for dep in missing: for pattern in ignore_missing: if fnmatch(dep.name.name, pattern): - logger.debug(f"warn: auto-patchelf ignoring missing {dep.name} wanted by {dep.file}") + logger.log(IgnoredDependency(file=dep.file, name=dep.name, pattern=pattern)) break else: logger.debug(f"error: auto-patchelf could not satisfy dependency {dep.name} wanted by {dep.file}") diff --git a/pkgs/test/auto-patchelf-structured-log/default.nix b/pkgs/test/auto-patchelf-structured-log/default.nix index b75e5cb58ca1..b8e73f040964 100644 --- a/pkgs/test/auto-patchelf-structured-log/default.nix +++ b/pkgs/test/auto-patchelf-structured-log/default.nix @@ -31,7 +31,8 @@ stdenv.mkDerivation { auto-patchelf \ --paths ./usr/bin/ToneLib-Jam \ --libs ${lib.getLib freetype}/lib \ - --structured-logs > log.jsonl || true + --ignore-missing "libasound.so.*" "libGL.so.*" \ + --structured-logs | tee log.jsonl # Expect 1 SetInterpreter line jq -e -s '[.[] | select(has("SetInterpreter"))] | length == 1' log.jsonl @@ -39,13 +40,16 @@ stdenv.mkDerivation { # We expect 3 Dependency lines jq -e -s '[.[] | select(has("Dependency"))] | length == 3' log.jsonl - # Expect the last line to set the rpath as expected - jq -e -s 'last == { + # We expect 2 IgnoredDependency lines + jq -e -s '[.[] | select(has("IgnoredDependency"))] | length == 2' log.jsonl + + # Expect there to be a SetRpath event using the expected rpath + jq -e -s 'any(.[]; . == { "SetRpath": { "file": "usr/bin/ToneLib-Jam", "rpath": "${lib.getLib freetype}/lib" } - }' log.jsonl + })' log.jsonl cp log.jsonl $out '';