mirror of
https://github.com/mvt-project/mvt.git
synced 2026-09-03 08:30:51 +02:00
* Add a platform-neutral mvt command Several MVT commands have nothing to do with the acquisition of one platform, yet they were reachable only through mvt-ios and mvt-android. Asking which version is installed or downloading the public indicators meant picking one of the two platform commands arbitrarily, and each of those tasks had to be written, documented and maintained twice. Add a third console script, mvt, hosting the commands which belong to no platform: version and download-iocs for now, with completion following in a later commit. Commands installed in the new mvt.cli_plugins entry-point group are registered on mvt, and on mvt only, so that a command package chooses the CLI each of its commands is added to: mvt.ios.cli_plugins for mvt-ios, mvt.android.cli_plugins for mvt-android and mvt.cli_plugins for mvt. A command wanted on both platform CLIs is registered in both platform groups; no group adds a command to every CLI. The MVT_CUSTOM_COMMANDS variable loads command files and folders into mvt the way the platform variables already do for mvt-ios and mvt-android. Run on its own, mvt prints the banner and its help instead of a usage error. The help text reminds that the forensic analysis of an acquisition runs through mvt-ios and mvt-android, so that the command which knows nothing about acquisitions says where they are analysed. version and download-iocs stay on mvt-ios and mvt-android for now, so that no documented invocation stops working. They are to be dropped from the platform CLIs in a later release, once mvt has been available long enough for the change to be announced. Unlike mvt-ios and mvt-android, which point at their subpackages, the console script points at mvt.cli:main and the mvt package re-exports nothing of it. Importing mvt has to stay cheap and free of side effects: it is the package plugins import from, and pulling in Click, the CLI and everything the commands import merely because something imported mvt would work against that. While here, give the version command of both platform CLIs the context settings every other command already has, so that "mvt-ios version -h" prints its help instead of failing on an unknown option. * Reduce indent for MVT CLI header * Move shell completion to the mvt command and generate one script for every MVT command Setting up shell completion had nothing to do with the acquisition of one platform, yet it was a command of mvt-ios and mvt-android, each generating the script of the program it ran under only. Completion now leaves the platform CLIs for mvt, which emits one script covering mvt, mvt-ios and mvt-android, installed as a single file loaded when the shell starts. Click names the completion function of each program after the program, so the three scripts concatenate without colliding, and fish takes the file in conf.d rather than one named after a single command. With one script there is nothing left for the platform commands to generate, so their completion command goes, and with it the banner suppression which was keyed on the command name: mvt-ios and mvt-android now print the banner for every command they have. The long help line says which commands the completion covers, so a short_help keeps "mvt --help" from truncating it. * Point the README and the command docs at plugin packages Loading a command by path is for local development; a package is how a command is distributed. The README's summary of what extends MVT now names plugin packages and nothing else, and the section on loading a command file says what it is for: keeping a command being written loadable without reinstalling its package after every change.
566 lines
17 KiB
Python
566 lines
17 KiB
Python
from types import SimpleNamespace
|
|
|
|
import click
|
|
from click.testing import CliRunner
|
|
|
|
from mvt.cli import cli as mvt_cli
|
|
from mvt.common.cli_plugins import (
|
|
ANDROID_CLI_PLUGIN_GROUP,
|
|
IOS_CLI_PLUGIN_GROUP,
|
|
MVT_CUSTOM_COMMANDS_ENV,
|
|
NEUTRAL_CLI_PLUGIN_GROUP,
|
|
BrokenPluginCommand,
|
|
load_cli_commands_option,
|
|
register_cli_commands_from_path,
|
|
register_cli_plugins,
|
|
register_installed_cli_commands,
|
|
)
|
|
|
|
|
|
# Keep the banner of the mvt group callback from checking for updates online.
|
|
OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"]
|
|
|
|
COMMAND_TEMPLATE = """
|
|
import click
|
|
|
|
|
|
@click.command({name!r})
|
|
@click.pass_context
|
|
def cli(ctx):
|
|
click.echo({message!r})
|
|
if ctx.obj:
|
|
click.echo(ctx.obj.get("marker", ""))
|
|
"""
|
|
|
|
|
|
def _write_command(path, name, message="command ran"):
|
|
path.write_text(
|
|
COMMAND_TEMPLATE.format(name=name, message=message),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def _make_group():
|
|
@click.group()
|
|
@load_cli_commands_option
|
|
@click.pass_context
|
|
def group(ctx):
|
|
ctx.ensure_object(dict)
|
|
ctx.obj["marker"] = "parent context"
|
|
|
|
return group
|
|
|
|
|
|
def _entry_point(name, value, command=None, exception=None, distribution="plugin"):
|
|
def load():
|
|
if exception is not None:
|
|
raise exception
|
|
return command
|
|
|
|
dist = SimpleNamespace(metadata={"Name": distribution}, version="1.0")
|
|
return SimpleNamespace(name=name, value=value, load=load, dist=dist)
|
|
|
|
|
|
def test_load_command_option_registers_command_before_resolution(tmp_path):
|
|
command_path = _write_command(tmp_path / "hello.py", "hello")
|
|
group = _make_group()
|
|
|
|
result = CliRunner().invoke(
|
|
group,
|
|
["--load-command", str(command_path), "hello"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "command ran" in result.output
|
|
assert "parent context" in result.output
|
|
|
|
|
|
def test_load_command_option_supports_folders_and_repeated_paths(tmp_path):
|
|
folder = tmp_path / "commands"
|
|
folder.mkdir()
|
|
_write_command(folder / "b.py", "second")
|
|
_write_command(folder / "a.py", "first")
|
|
_write_command(folder / ".hidden.py", "hidden")
|
|
_write_command(folder / "__init__.py", "init")
|
|
other = _write_command(tmp_path / "third.py", "third")
|
|
group = _make_group()
|
|
|
|
result = CliRunner().invoke(
|
|
group,
|
|
[
|
|
"--load-command",
|
|
str(folder),
|
|
"--load-command",
|
|
str(other),
|
|
"--load-command",
|
|
str(other),
|
|
"--help",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "first" in result.output
|
|
assert "second" in result.output
|
|
assert "third" in result.output
|
|
assert "hidden" not in result.output
|
|
assert "init" not in result.output
|
|
|
|
|
|
def test_loaded_command_participates_in_shell_completion(tmp_path):
|
|
command_path = _write_command(tmp_path / "hello.py", "hello")
|
|
group = _make_group()
|
|
words = f"group --load-command {command_path} he"
|
|
|
|
result = CliRunner().invoke(
|
|
group,
|
|
[],
|
|
env={
|
|
"_GROUP_COMPLETE": "bash_complete",
|
|
"COMP_WORDS": words,
|
|
"COMP_CWORD": "3",
|
|
},
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "plain,hello" in result.output
|
|
|
|
|
|
def test_explicit_command_import_and_contract_failures_are_usage_errors(tmp_path):
|
|
broken_path = tmp_path / "broken.py"
|
|
broken_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
|
|
missing_cli_path = tmp_path / "missing_cli.py"
|
|
missing_cli_path.write_text("value = 1", encoding="utf-8")
|
|
|
|
broken_result = CliRunner().invoke(
|
|
_make_group(),
|
|
["--load-command", str(broken_path), "broken"],
|
|
)
|
|
missing_cli_result = CliRunner().invoke(
|
|
_make_group(),
|
|
["--load-command", str(missing_cli_path), "missing-cli"],
|
|
)
|
|
|
|
assert broken_result.exit_code == 2
|
|
assert "broken import" in broken_result.output
|
|
assert missing_cli_result.exit_code == 2
|
|
assert "must export a Click command or group named 'cli'" in (
|
|
missing_cli_result.output
|
|
)
|
|
|
|
|
|
def test_environment_command_failure_gets_broken_placeholder(tmp_path):
|
|
command_path = tmp_path / "broken_command.py"
|
|
command_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
|
|
group = click.Group()
|
|
|
|
registered = register_cli_commands_from_path(group, command_path)
|
|
|
|
assert registered == ["broken-command"]
|
|
assert isinstance(group.commands["broken-command"], BrokenPluginCommand)
|
|
result = CliRunner().invoke(group, ["broken-command"])
|
|
assert result.exit_code == 1
|
|
assert "broken import" in result.output
|
|
assert str(command_path) in result.output
|
|
|
|
|
|
def test_environment_command_system_exit_gets_broken_placeholder(tmp_path):
|
|
command_path = tmp_path / "exiting_command.py"
|
|
command_path.write_text("raise SystemExit(7)", encoding="utf-8")
|
|
group = click.Group()
|
|
|
|
registered = register_cli_commands_from_path(group, command_path)
|
|
|
|
assert registered == ["exiting-command"]
|
|
assert isinstance(group.commands["exiting-command"], BrokenPluginCommand)
|
|
result = CliRunner().invoke(group, ["exiting-command"])
|
|
assert result.exit_code == 1
|
|
assert "Unable to import custom command" in result.output
|
|
assert result.output.rstrip().endswith(": 7")
|
|
|
|
|
|
def test_installed_entry_point_name_is_the_command_name(monkeypatch):
|
|
@click.command("internal-name")
|
|
def command():
|
|
click.echo("installed command ran")
|
|
|
|
entry_point = _entry_point(
|
|
"external-name",
|
|
"example_plugin:cli",
|
|
command=command,
|
|
)
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
lambda **kwargs: [entry_point],
|
|
)
|
|
group = click.Group()
|
|
|
|
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
|
|
|
assert registered == ["external-name"]
|
|
assert "internal-name" not in group.commands
|
|
result = CliRunner().invoke(group, ["external-name"])
|
|
assert result.exit_code == 0
|
|
assert result.output == "installed command ran\n"
|
|
|
|
|
|
def test_broken_installed_plugin_does_not_break_cli(monkeypatch):
|
|
broken = _entry_point(
|
|
"broken",
|
|
"broken_plugin:cli",
|
|
exception=RuntimeError("missing dependency"),
|
|
distribution="broken-plugin",
|
|
)
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
lambda **kwargs: [broken],
|
|
)
|
|
group = click.Group()
|
|
|
|
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
|
|
|
help_result = CliRunner().invoke(group, ["--help"])
|
|
assert help_result.exit_code == 0
|
|
assert "Warning: external command could not be loaded." in help_result.output
|
|
|
|
result = CliRunner().invoke(group, ["broken"])
|
|
assert result.exit_code == 1
|
|
assert "broken-plugin 1.0 (broken_plugin:cli)" in result.output
|
|
assert "RuntimeError: missing dependency" in result.output
|
|
|
|
|
|
def test_installed_plugin_system_exit_does_not_break_cli(monkeypatch):
|
|
exiting = _entry_point(
|
|
"exiting",
|
|
"exiting_plugin:cli",
|
|
exception=SystemExit(7),
|
|
distribution="exiting-plugin",
|
|
)
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
lambda **kwargs: [exiting],
|
|
)
|
|
group = click.Group()
|
|
|
|
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
|
|
|
help_result = CliRunner().invoke(group, ["--help"])
|
|
assert help_result.exit_code == 0
|
|
result = CliRunner().invoke(group, ["exiting"])
|
|
assert result.exit_code == 1
|
|
assert "SystemExit: 7" in result.output
|
|
|
|
|
|
def test_non_click_entry_point_gets_broken_placeholder(monkeypatch):
|
|
invalid = _entry_point("invalid", "plugin:value", command=object())
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
lambda **kwargs: [invalid],
|
|
)
|
|
group = click.Group()
|
|
|
|
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
|
|
|
assert isinstance(group.commands["invalid"], BrokenPluginCommand)
|
|
result = CliRunner().invoke(group, ["invalid"])
|
|
assert result.exit_code == 1
|
|
assert "must resolve to a Click command or group" in result.output
|
|
|
|
|
|
def test_entry_point_discovery_failure_does_not_break_group(monkeypatch, caplog):
|
|
def fail_discovery(**kwargs):
|
|
raise RuntimeError("invalid package metadata")
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
fail_discovery,
|
|
)
|
|
group = click.Group()
|
|
|
|
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
|
|
|
assert registered == []
|
|
assert not group.commands
|
|
assert "Unable to discover external commands" in caplog.text
|
|
assert "invalid package metadata" in caplog.text
|
|
|
|
|
|
def test_builtin_and_first_external_command_win_collisions(monkeypatch, caplog):
|
|
@click.command("version")
|
|
def core_version():
|
|
pass
|
|
|
|
@click.command()
|
|
def first():
|
|
pass
|
|
|
|
@click.command()
|
|
def second():
|
|
pass
|
|
|
|
entry_points = [
|
|
_entry_point("duplicate", "z_plugin:cli", command=second, distribution="z"),
|
|
_entry_point("version", "plugin:version", command=first),
|
|
_entry_point("duplicate", "a_plugin:cli", command=first, distribution="a"),
|
|
]
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
lambda **kwargs: entry_points,
|
|
)
|
|
group = click.Group(commands={"version": core_version})
|
|
|
|
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
|
|
|
|
assert registered == ["duplicate"]
|
|
assert group.commands["version"] is core_version
|
|
assert group.commands["duplicate"] is first
|
|
assert "the command name is already registered" in caplog.text
|
|
|
|
|
|
def test_explicit_command_cannot_replace_existing_command(tmp_path):
|
|
command_path = _write_command(tmp_path / "version.py", "version")
|
|
group = _make_group()
|
|
|
|
@group.command("version")
|
|
def core_version():
|
|
pass
|
|
|
|
result = CliRunner().invoke(
|
|
group,
|
|
["--load-command", str(command_path), "version"],
|
|
)
|
|
|
|
assert result.exit_code == 2
|
|
assert "the command name is already registered" in result.output
|
|
|
|
|
|
def test_platform_entry_point_groups_and_environment_paths_are_separate(
|
|
tmp_path, monkeypatch
|
|
):
|
|
ios_path = _write_command(tmp_path / "ios.py", "ios-file")
|
|
android_path = _write_command(tmp_path / "android.py", "android-file")
|
|
|
|
@click.command()
|
|
def ios_package():
|
|
pass
|
|
|
|
@click.command()
|
|
def android_package():
|
|
pass
|
|
|
|
def entry_points(*, group):
|
|
if group == IOS_CLI_PLUGIN_GROUP:
|
|
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
|
|
if group == ANDROID_CLI_PLUGIN_GROUP:
|
|
return [
|
|
_entry_point("android-package", "android_plugin:cli", android_package)
|
|
]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
entry_points,
|
|
)
|
|
monkeypatch.setenv("TEST_IOS_COMMANDS", str(ios_path))
|
|
monkeypatch.setenv("TEST_ANDROID_COMMANDS", str(android_path))
|
|
ios_group = click.Group()
|
|
android_group = click.Group()
|
|
|
|
register_cli_plugins(
|
|
ios_group,
|
|
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
|
environment_variable="TEST_IOS_COMMANDS",
|
|
)
|
|
register_cli_plugins(
|
|
android_group,
|
|
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
|
environment_variable="TEST_ANDROID_COMMANDS",
|
|
)
|
|
|
|
assert set(ios_group.commands) == {"ios-file", "ios-package"}
|
|
assert set(android_group.commands) == {"android-file", "android-package"}
|
|
|
|
|
|
def test_neutral_entry_point_group_is_not_registered_on_the_platform_clis(
|
|
monkeypatch,
|
|
):
|
|
@click.command()
|
|
def neutral_package():
|
|
pass
|
|
|
|
def entry_points(*, group):
|
|
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
|
return [
|
|
_entry_point("neutral-package", "neutral_plugin:cli", neutral_package)
|
|
]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
entry_points,
|
|
)
|
|
ios_group = click.Group()
|
|
android_group = click.Group()
|
|
|
|
register_cli_plugins(
|
|
ios_group,
|
|
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
|
environment_variable="TEST_IOS_COMMANDS",
|
|
)
|
|
register_cli_plugins(
|
|
android_group,
|
|
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
|
|
environment_variable="TEST_ANDROID_COMMANDS",
|
|
)
|
|
|
|
assert not ios_group.commands
|
|
assert not android_group.commands
|
|
|
|
|
|
def test_environment_command_wins_collision_with_installed_command(
|
|
tmp_path, monkeypatch, caplog
|
|
):
|
|
command_path = _write_command(
|
|
tmp_path / "duplicate.py",
|
|
"duplicate",
|
|
message="environment command ran",
|
|
)
|
|
|
|
@click.command()
|
|
def installed_command():
|
|
pass
|
|
|
|
def entry_points(*, group):
|
|
if group == IOS_CLI_PLUGIN_GROUP:
|
|
return [
|
|
_entry_point(
|
|
"duplicate",
|
|
"ios_plugin:cli",
|
|
installed_command,
|
|
distribution="ios-plugin",
|
|
)
|
|
]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
entry_points,
|
|
)
|
|
monkeypatch.setenv("TEST_IOS_COMMANDS", str(command_path))
|
|
group = click.Group()
|
|
|
|
register_cli_plugins(
|
|
group,
|
|
entry_point_group=IOS_CLI_PLUGIN_GROUP,
|
|
environment_variable="TEST_IOS_COMMANDS",
|
|
)
|
|
|
|
assert group.commands["duplicate"] is not installed_command
|
|
result = CliRunner().invoke(group, ["duplicate"])
|
|
assert result.exit_code == 0
|
|
assert "environment command ran" in result.output
|
|
assert "the command name is already registered" in caplog.text
|
|
assert "ios-plugin 1.0 (ios_plugin:cli)" in caplog.text
|
|
|
|
|
|
def test_the_mvt_cli_gets_the_neutral_commands_and_no_platform_command(
|
|
monkeypatch, restore_cli_commands
|
|
):
|
|
@click.command()
|
|
def shared_package():
|
|
click.echo("shared command ran")
|
|
|
|
@click.command()
|
|
def ios_package():
|
|
pass
|
|
|
|
def entry_points(*, group):
|
|
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
|
return [_entry_point("shared-package", "shared_plugin:cli", shared_package)]
|
|
if group == IOS_CLI_PLUGIN_GROUP:
|
|
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
entry_points,
|
|
)
|
|
|
|
register_cli_plugins(
|
|
mvt_cli,
|
|
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
|
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
|
)
|
|
|
|
assert "ios-package" not in mvt_cli.commands
|
|
result = CliRunner().invoke(mvt_cli, [*OFFLINE, "shared-package"])
|
|
assert result.exit_code == 0
|
|
assert "shared command ran" in result.output
|
|
|
|
|
|
def test_builtin_mvt_command_wins_collision_with_neutral_command(
|
|
monkeypatch, caplog, restore_cli_commands
|
|
):
|
|
@click.command()
|
|
def neutral_version():
|
|
pass
|
|
|
|
def entry_points(*, group):
|
|
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
|
return [
|
|
_entry_point(
|
|
"version",
|
|
"neutral_plugin:cli",
|
|
neutral_version,
|
|
distribution="neutral-plugin",
|
|
)
|
|
]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
entry_points,
|
|
)
|
|
builtin_version = mvt_cli.commands["version"]
|
|
|
|
register_cli_plugins(
|
|
mvt_cli,
|
|
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
|
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
|
)
|
|
|
|
assert mvt_cli.commands["version"] is builtin_version
|
|
assert "the command name is already registered" in caplog.text
|
|
assert "neutral-plugin 1.0 (neutral_plugin:cli)" in caplog.text
|
|
|
|
|
|
def test_broken_neutral_plugin_does_not_break_the_mvt_cli(
|
|
monkeypatch, restore_cli_commands
|
|
):
|
|
def entry_points(*, group):
|
|
if group == NEUTRAL_CLI_PLUGIN_GROUP:
|
|
return [
|
|
_entry_point(
|
|
"broken",
|
|
"broken_plugin:cli",
|
|
exception=RuntimeError("missing dependency"),
|
|
distribution="broken-plugin",
|
|
)
|
|
]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"mvt.common.cli_plugins.importlib.metadata.entry_points",
|
|
entry_points,
|
|
)
|
|
|
|
register_cli_plugins(
|
|
mvt_cli,
|
|
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
|
|
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
|
|
)
|
|
|
|
assert isinstance(mvt_cli.commands["broken"], BrokenPluginCommand)
|
|
result = CliRunner().invoke(mvt_cli, [*OFFLINE, "version"])
|
|
assert result.exit_code == 0
|