313 lines
10 KiB
Python
313 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for exact argument forwarding through GNU Make."""
|
|
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
SPEC = importlib.util.spec_from_file_location(
|
|
"make_argv", Path(__file__).with_name("make-argv.py")
|
|
)
|
|
assert SPEC is not None and SPEC.loader is not None
|
|
MAKE_ARGV = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(MAKE_ARGV)
|
|
|
|
|
|
class ArgumentEncodingTests(unittest.TestCase):
|
|
def test_round_trip_preserves_exact_arguments(self):
|
|
arguments = [
|
|
"--key=one",
|
|
"two words",
|
|
"",
|
|
"--key=one",
|
|
"$HOME",
|
|
"--",
|
|
"--key=two",
|
|
]
|
|
|
|
self.assertEqual(
|
|
MAKE_ARGV.decode_arguments(MAKE_ARGV.encode_arguments(arguments)),
|
|
arguments,
|
|
)
|
|
|
|
def test_rejects_invalid_payload(self):
|
|
with self.assertRaisesRegex(
|
|
MAKE_ARGV.ArgumentRecoveryError, "invalid application argument payload"
|
|
):
|
|
MAKE_ARGV.decode_arguments("not-base64!")
|
|
|
|
@mock.patch.object(
|
|
MAKE_ARGV, "execute", side_effect=FileNotFoundError(2, "missing", "tool")
|
|
)
|
|
def test_missing_application_has_a_concise_error(self, _execute):
|
|
errors = io.StringIO()
|
|
|
|
with mock.patch("sys.stderr", errors):
|
|
status = MAKE_ARGV.main(["exec", "run", "tool"])
|
|
|
|
self.assertEqual(status, 127)
|
|
self.assertEqual(errors.getvalue(), "make-argv: cannot execute tool: missing\n")
|
|
|
|
|
|
class RouteSelectionTests(unittest.TestCase):
|
|
def test_delimiter_makes_test_an_application_argument(self):
|
|
target, arguments = MAKE_ARGV.select_route(
|
|
["make", "firmware", "--", "test"],
|
|
"firmware",
|
|
"run",
|
|
{"test", "clippy"},
|
|
)
|
|
|
|
self.assertEqual(target, "run")
|
|
self.assertEqual(arguments, ["test"])
|
|
|
|
def test_no_delimiter_selects_a_known_local_target(self):
|
|
target, arguments = MAKE_ARGV.select_route(
|
|
["make", "firmware", "test"],
|
|
"firmware",
|
|
"run",
|
|
{"test", "clippy"},
|
|
)
|
|
|
|
self.assertEqual(target, "test")
|
|
self.assertEqual(arguments, [])
|
|
|
|
def test_configurable_no_argument_default(self):
|
|
target, arguments = MAKE_ARGV.select_route(
|
|
["make", "docs"], "docs", "check", {"help"}
|
|
)
|
|
|
|
self.assertEqual(target, "check")
|
|
self.assertEqual(arguments, [])
|
|
|
|
def test_explicit_docs_arguments_select_run_not_no_argument_default(self):
|
|
target, arguments = MAKE_ARGV.select_route(
|
|
["make", "docs", "--", "--version"], "docs", "check", {"help"}
|
|
)
|
|
|
|
self.assertEqual(target, "run")
|
|
self.assertEqual(arguments, ["--version"])
|
|
|
|
def test_unknown_words_without_delimiter_remain_application_arguments(self):
|
|
target, arguments = MAKE_ARGV.select_route(
|
|
["make", "firmware/tools/sitl", "move", "5", "two words"],
|
|
"firmware/tools/sitl",
|
|
"run",
|
|
{"help"},
|
|
)
|
|
|
|
self.assertEqual(target, "run")
|
|
self.assertEqual(arguments, ["move", "5", "two words"])
|
|
|
|
def test_local_flash_keeps_every_argument_after_delimiter(self):
|
|
target, arguments = MAKE_ARGV.select_route(
|
|
[
|
|
"make",
|
|
"firmware/boards/example",
|
|
"flash",
|
|
"--",
|
|
"--device=0483:df11",
|
|
"",
|
|
"--",
|
|
"$HOME",
|
|
],
|
|
"firmware/boards/example",
|
|
"run",
|
|
{"flash", "help"},
|
|
)
|
|
|
|
self.assertEqual(target, "flash")
|
|
self.assertEqual(arguments, ["--device=0483:df11", "", "--", "$HOME"])
|
|
|
|
|
|
class EnvironmentTests(unittest.TestCase):
|
|
def test_scrubs_make_flags_and_command_line_assignments(self):
|
|
environment = {
|
|
"PATH": "/bin",
|
|
"MAKEFLAGS": "-- CARGO=wrong",
|
|
"MAKEOVERRIDES": "CARGO=wrong",
|
|
"MFLAGS": "--",
|
|
"CARGO": "wrong",
|
|
"OTHER": "keep",
|
|
}
|
|
|
|
clean = MAKE_ARGV.scrub_make_environment(
|
|
environment,
|
|
["make", "firmware", "--", "CARGO=wrong", "--key=value"],
|
|
)
|
|
|
|
self.assertEqual(clean, {"PATH": "/bin", "OTHER": "keep"})
|
|
|
|
def test_preserves_make_execution_flags_without_overrides(self):
|
|
clean = MAKE_ARGV.scrub_make_environment(
|
|
{
|
|
"MAKEFLAGS": "n --no-print-directory -- --key=value",
|
|
"MFLAGS": "-n -- --key=value",
|
|
"MAKEOVERRIDES": "--key=value",
|
|
},
|
|
["make", "firmware", "--", "--key=value"],
|
|
)
|
|
|
|
self.assertEqual(
|
|
clean,
|
|
{"MAKEFLAGS": "n --no-print-directory", "MFLAGS": "-n"},
|
|
)
|
|
|
|
@mock.patch.object(MAKE_ARGV.os, "execvpe")
|
|
@mock.patch.object(MAKE_ARGV, "find_make_arguments")
|
|
def test_route_execs_recursive_make_with_encoded_payload(
|
|
self, find_make_arguments, execvpe
|
|
):
|
|
payload = ["--key=one", "two words", "", "--", "--key=one"]
|
|
find_make_arguments.return_value = [
|
|
"make",
|
|
"firmware/tools/sitl",
|
|
"--",
|
|
*payload,
|
|
]
|
|
|
|
with mock.patch.dict(
|
|
MAKE_ARGV.os.environ,
|
|
{"PATH": "/bin", "MAKEFLAGS": "n -- --key=one"},
|
|
clear=True,
|
|
):
|
|
MAKE_ARGV.route("firmware/tools/sitl", "make", "run", {"help"})
|
|
|
|
program, arguments, environment = execvpe.call_args.args
|
|
self.assertEqual(program, "make")
|
|
self.assertEqual(
|
|
arguments,
|
|
[
|
|
"make",
|
|
"--no-print-directory",
|
|
"-C",
|
|
"firmware/tools/sitl",
|
|
"run",
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
MAKE_ARGV.decode_arguments(environment[MAKE_ARGV.PAYLOAD_ENV]), payload
|
|
)
|
|
self.assertEqual(environment[MAKE_ARGV.ARG_COUNT_ENV], str(len(payload)))
|
|
self.assertEqual(environment["MAKEFLAGS"], "n")
|
|
|
|
@mock.patch.object(MAKE_ARGV.os, "execvpe")
|
|
def test_exec_uses_routed_payload_without_shell_joining(self, execvpe):
|
|
payload = ["--key=one", "two words", "", "$HOME", "--", "--key=one"]
|
|
environment = {
|
|
MAKE_ARGV.PAYLOAD_ENV: MAKE_ARGV.encode_arguments(payload),
|
|
MAKE_ARGV.ARG_COUNT_ENV: str(len(payload)),
|
|
"PATH": "/bin",
|
|
"MAKEFLAGS": "-- --key=one",
|
|
}
|
|
|
|
with mock.patch.dict(MAKE_ARGV.os.environ, environment, clear=True):
|
|
MAKE_ARGV.execute("run", ["example-app", "fixed argument"])
|
|
|
|
program, arguments, child_environment = execvpe.call_args.args
|
|
self.assertEqual(program, "example-app")
|
|
self.assertEqual(arguments, ["example-app", "fixed argument", *payload])
|
|
self.assertEqual(child_environment, {"PATH": "/bin"})
|
|
|
|
@mock.patch.object(MAKE_ARGV.os, "execvpe")
|
|
@mock.patch.object(MAKE_ARGV, "find_make_arguments")
|
|
def test_direct_local_make_exec_recovers_arguments(
|
|
self, find_make_arguments, execvpe
|
|
):
|
|
payload = ["--key=value", "two words", "", "--", "$HOME"]
|
|
find_make_arguments.return_value = ["make", "run", "--", *payload]
|
|
|
|
with mock.patch.dict(MAKE_ARGV.os.environ, {"PATH": "/bin"}, clear=True):
|
|
MAKE_ARGV.execute("run", ["example-app"])
|
|
|
|
self.assertEqual(execvpe.call_args.args[1], ["example-app", *payload])
|
|
|
|
|
|
class ProcfsTests(unittest.TestCase):
|
|
def test_procfs_preserves_a_final_empty_argument(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
process_directory = Path(directory, "123")
|
|
process_directory.mkdir()
|
|
Path(process_directory, "cmdline").write_bytes(b"make\0run\0\0")
|
|
|
|
self.assertEqual(
|
|
MAKE_ARGV.read_process_arguments(123, Path(directory)),
|
|
["make", "run", ""],
|
|
)
|
|
|
|
def test_procfs_failure_is_actionable(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
with self.assertRaisesRegex(
|
|
MAKE_ARGV.ArgumentRecoveryError, "requires Linux /proc"
|
|
):
|
|
MAKE_ARGV.read_process_arguments(123, Path(directory))
|
|
|
|
|
|
@unittest.skipUnless(Path("/proc/self/cmdline").is_file(), "requires Linux procfs")
|
|
class MakeIntegrationTests(unittest.TestCase):
|
|
def test_recursive_make_preserves_exact_application_argv(self):
|
|
make = shutil.which("make")
|
|
if make is None:
|
|
self.skipTest("GNU Make is unavailable")
|
|
|
|
helper = Path(__file__).with_name("make-argv.py").resolve()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
component = root / "component"
|
|
component.mkdir()
|
|
output = root / "arguments.json"
|
|
application = root / "record-arguments.py"
|
|
application.write_text(
|
|
"import json, pathlib, sys\n"
|
|
"pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "Makefile").write_text(
|
|
".PHONY: component\n"
|
|
"component:\n"
|
|
f"\t+@exec {sys.executable} {helper} route component make run help\n"
|
|
".DEFAULT:\n"
|
|
"\t@:\n",
|
|
encoding="utf-8",
|
|
)
|
|
(component / "Makefile").write_text(
|
|
".PHONY: run\n"
|
|
"run:\n"
|
|
f"\t@exec {sys.executable} {helper} exec run "
|
|
f"{sys.executable} {application} {output}\n"
|
|
".DEFAULT:\n"
|
|
"\t@:\n",
|
|
encoding="utf-8",
|
|
)
|
|
payload = [
|
|
"--key=one",
|
|
"two words",
|
|
"",
|
|
"--key=one",
|
|
"$HOME",
|
|
"--",
|
|
"--key=two",
|
|
]
|
|
|
|
subprocess.run(
|
|
[make, "component", "--", *payload],
|
|
cwd=root,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|