#!/usr/bin/env python3 """Preserve application argv across GNU Make's command-line parser. GNU Make removes variable assignments, consumes its first ``--``, and flattens quoted goal names in ``MAKECMDGOALS``. Linux exposes the original NUL-delimited argument vector in procfs, which lets the Makefiles keep a conventional ``make component -- application arguments`` interface without rebuilding a command string in the shell. """ from __future__ import annotations import base64 import errno import json import os from pathlib import Path import re import sys from typing import NoReturn PAYLOAD_ENV = "KAPTAN_MAKE_ARGV" ARG_COUNT_ENV = "KAPTAN_MAKE_ARG_COUNT" _MAKE_FLAG_ENV = ("MAKEFLAGS", "MFLAGS") class ArgumentRecoveryError(RuntimeError): """Raised when the original Make invocation cannot be recovered.""" def encode_arguments(arguments: list[str]) -> str: """Encode an argument vector into an ASCII-only environment value.""" document = json.dumps(arguments, ensure_ascii=True, separators=(",", ":")) return base64.urlsafe_b64encode(document.encode("ascii")).decode("ascii") def decode_arguments(encoded: str) -> list[str]: """Decode an argument vector produced by :func:`encode_arguments`.""" try: document = base64.b64decode(encoded, altchars=b"-_", validate=True) arguments = json.loads(document.decode("ascii")) except (ValueError, UnicodeError, json.JSONDecodeError) as error: raise ArgumentRecoveryError( f"invalid application argument payload in {PAYLOAD_ENV}" ) from error if not isinstance(arguments, list) or not all( isinstance(argument, str) for argument in arguments ): raise ArgumentRecoveryError( f"invalid application argument payload in {PAYLOAD_ENV}" ) return arguments def read_process_arguments(pid: int, proc_root: Path = Path("/proc")) -> list[str]: """Read one process's exact NUL-delimited argv from procfs.""" path = proc_root / str(pid) / "cmdline" try: raw = path.read_bytes() except OSError as error: raise ArgumentRecoveryError( f"cannot read {path}; exact Make argument forwarding requires Linux /proc" ) from error if not raw: raise ArgumentRecoveryError(f"cannot recover arguments from empty {path}") if not raw.endswith(b"\0"): raise ArgumentRecoveryError( f"cannot recover unterminated arguments from {path}" ) return [os.fsdecode(argument) for argument in raw[:-1].split(b"\0")] def read_parent_pid(pid: int, proc_root: Path = Path("/proc")) -> int: """Read a process's parent PID from procfs.""" path = proc_root / str(pid) / "status" try: status = path.read_text(encoding="utf-8") except OSError as error: raise ArgumentRecoveryError( f"cannot read {path}; exact Make argument forwarding requires Linux /proc" ) from error for line in status.splitlines(): if line.startswith("PPid:"): return int(line.split(":", 1)[1].strip()) raise ArgumentRecoveryError(f"cannot find PPid in {path}") def find_make_arguments(marker: str, start_pid: int | None = None) -> list[str]: """Find the nearest parent Make invocation containing *marker*.""" pid = os.getppid() if start_pid is None else start_pid visited: set[int] = set() while pid > 1 and pid not in visited: visited.add(pid) arguments = read_process_arguments(pid) executable = Path(arguments[0]).name if arguments else "" if marker in arguments and executable.endswith("make"): return arguments pid = read_parent_pid(pid) raise ArgumentRecoveryError( f"cannot find a parent Make invocation containing {marker!r}; " "exact argument forwarding requires Linux /proc" ) def _tail_after_marker(make_arguments: list[str], marker: str) -> list[str]: try: marker_index = make_arguments.index(marker) except ValueError as error: raise ArgumentRecoveryError( f"cannot find {marker!r} in the parent Make invocation" ) from error return make_arguments[marker_index + 1 :] def arguments_after_marker(make_arguments: list[str], marker: str) -> list[str]: """Return arguments following *marker*, consuming one following ``--``.""" arguments = _tail_after_marker(make_arguments, marker) if arguments[:1] == ["--"]: return arguments[1:] return arguments def select_route( make_arguments: list[str], component: str, default_target: str, local_targets: set[str], ) -> tuple[str, list[str]]: """Select a local Make target and the exact application payload. ``default_target`` applies only when the component has no arguments. An explicit delimiter or backward-compatible unknown words always select the component's ``run`` target. """ arguments = _tail_after_marker(make_arguments, component) if "--" in arguments: separator = arguments.index("--") before_separator = arguments[:separator] payload = arguments[separator + 1 :] if len(before_separator) == 1 and before_separator[0] in local_targets: return before_separator[0], payload if before_separator: raise ArgumentRecoveryError( f"unexpected words before -- for {component}: " + " ".join(repr(word) for word in before_separator) ) return "run", payload if not arguments: return default_target, [] if len(arguments) == 1 and arguments[0] in local_targets: return arguments[0], [] return "run", arguments def assignment_names(arguments: list[str]) -> set[str]: """Return environment names which GNU Make may treat as assignments.""" names: set[str] = set() for argument in arguments[1:]: for operator in (":::=", "::=", ":=", "+=", "?=", "!=", "="): if operator in argument: name, _ = argument.split(operator, 1) if name: names.add(name) break return names def scrub_make_environment( environment: dict[str, str], make_arguments: list[str] ) -> dict[str, str]: """Remove recursive-Make flags and command-line variable side effects.""" clean = environment.copy() clean.pop("MAKEOVERRIDES", None) for name in _MAKE_FLAG_ENV: value = clean.get(name, "") separator = re.search(r"(?:^|\s)--(?:\s|$)", value) flags = value[: separator.start()].strip() if separator else value.strip() if flags: clean[name] = flags else: clean.pop(name, None) for name in assignment_names(make_arguments): clean.pop(name, None) return clean def route( component: str, make_program: str, default_target: str, local_targets: set[str], ) -> NoReturn: """Recover argv and exec the component's selected recursive Make target.""" make_arguments = find_make_arguments(component) target, payload = select_route( make_arguments, component, default_target, local_targets ) environment = scrub_make_environment(dict(os.environ), make_arguments) environment[PAYLOAD_ENV] = encode_arguments(payload) environment[ARG_COUNT_ENV] = str(len(payload)) os.execvpe( make_program, [make_program, "--no-print-directory", "-C", component, target], environment, ) def execute(marker: str, command: list[str]) -> NoReturn: """Append recovered arguments to *command* and exec it without a shell.""" if not command: raise ArgumentRecoveryError("exec requires a command prefix") environment = dict(os.environ) encoded = environment.pop(PAYLOAD_ENV, None) environment.pop(ARG_COUNT_ENV, None) if encoded is None: make_arguments = find_make_arguments(marker) arguments = arguments_after_marker(make_arguments, marker) environment = scrub_make_environment(environment, make_arguments) else: arguments = decode_arguments(encoded) environment = scrub_make_environment(environment, ["make"]) os.execvpe(command[0], [*command, *arguments], environment) def usage() -> str: return ( "usage:\n" " make-argv.py route COMPONENT MAKE_PROGRAM DEFAULT_TARGET [LOCAL_TARGET ...]\n" " make-argv.py exec MARKER COMMAND [COMMAND_ARG ...]" ) def main(arguments: list[str] | None = None) -> int: arguments = sys.argv[1:] if arguments is None else arguments try: if arguments[:1] == ["route"] and len(arguments) >= 4: route(arguments[1], arguments[2], arguments[3], set(arguments[4:])) elif arguments[:1] == ["exec"] and len(arguments) >= 3: execute(arguments[1], arguments[2:]) else: raise ArgumentRecoveryError(usage()) except ArgumentRecoveryError as error: print(f"make-argv: {error}", file=sys.stderr) return 2 except OSError as error: program = error.filename or "command" detail = error.strerror or str(error) print(f"make-argv: cannot execute {program}: {detail}", file=sys.stderr) return 127 if error.errno == errno.ENOENT else 126 return 0 if __name__ == "__main__": raise SystemExit(main())