276 lines
9.2 KiB
Python
276 lines
9.2 KiB
Python
#!/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())
|