Files
mvt/tests/test_cli_entry_points.py
Donncha Ó Cearbhaill a78894aaa5 Add a platform-neutral mvt command (#897)
* 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.
2026-08-27 14:47:15 +02:00

281 lines
8.6 KiB
Python

# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import sys
from types import SimpleNamespace
import click
import pytest
import mvt.android
import mvt.cli
import mvt.ios
from mvt.android.cli import cli as android_cli
from mvt.android.cli import main as android_main
from mvt.cli import cli as mvt_cli
from mvt.cli import main as mvt_main
from mvt.common.cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
IOS_CLI_PLUGIN_GROUP,
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
MVT_CUSTOM_COMMANDS_ENV,
MVT_IOS_CUSTOM_COMMANDS_ENV,
NEUTRAL_CLI_PLUGIN_GROUP,
)
from mvt.ios.cli import cli as ios_cli
from mvt.ios.cli import main as ios_main
from .plugin_fixtures import (
FIXTURE_COMMAND_NAME,
run_isolated_python,
write_cli_plugin_distribution,
)
MARKER_PLUGIN_TEMPLATE = """
import os
import click
# Touched when this module is imported, so a test can tell whether loading MVT
# executed the plugin.
open(os.environ["FIXTURE_PLUGIN_MARKER"], "a").close()
@click.command()
def cli():
click.echo("fixture plugin ran")
"""
PROGRAMS = {
"mvt": (mvt.cli, mvt_cli, NEUTRAL_CLI_PLUGIN_GROUP, MVT_CUSTOM_COMMANDS_ENV),
"mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP, MVT_IOS_CUSTOM_COMMANDS_ENV),
"mvt-android": (
mvt.android,
android_cli,
ANDROID_CLI_PLUGIN_GROUP,
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
),
}
CASE_SUMMARY_COMMAND = """
import click
@click.command("case-summary")
def cli():
click.echo("case summary ran")
"""
# The entry-point group of another program, for each program: no group may add
# its commands to a CLI other than its own.
OTHER_PROGRAMS_GROUP = {
"mvt": IOS_CLI_PLUGIN_GROUP,
"mvt-ios": NEUTRAL_CLI_PLUGIN_GROUP,
"mvt-android": NEUTRAL_CLI_PLUGIN_GROUP,
}
def _install_fixture_entry_point(monkeypatch, entry_point_group, command):
def entry_points(*, group):
if group != entry_point_group:
return []
return [
SimpleNamespace(
name=FIXTURE_COMMAND_NAME,
value="fixture_cli_plugin:cli",
load=lambda: command,
dist=SimpleNamespace(
metadata={"Name": "fixture-cli-plugin"}, version="1.0"
),
)
]
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points", entry_points
)
def _offline_argv(program, *arguments):
"""Build an argument list which keeps the CLI from checking for updates."""
return [
program,
"--disable-update-check",
"--disable-indicator-update-check",
*arguments,
]
@pytest.mark.parametrize("program", sorted(PROGRAMS))
def test_main_registers_installed_plugins_before_running_the_cli(
program, monkeypatch, capsys, restore_cli_commands
):
package, group, entry_point_group, _ = PROGRAMS[program]
@click.command()
def fixture_command():
click.echo("fixture plugin ran")
_install_fixture_entry_point(monkeypatch, entry_point_group, fixture_command)
monkeypatch.setattr(sys, "argv", _offline_argv(program, FIXTURE_COMMAND_NAME))
with pytest.raises(SystemExit) as exit_info:
package.main()
assert exit_info.value.code == 0
assert "fixture plugin ran" in capsys.readouterr().out
assert FIXTURE_COMMAND_NAME in group.commands
@pytest.mark.parametrize("program", sorted(PROGRAMS))
def test_main_completes_plugin_command_names(
program, monkeypatch, capsys, restore_cli_commands
):
package, _, entry_point_group, _ = PROGRAMS[program]
@click.command()
def fixture_command():
pass
_install_fixture_entry_point(monkeypatch, entry_point_group, fixture_command)
complete_variable = f"_{program.upper().replace('-', '_')}_COMPLETE"
monkeypatch.setenv(complete_variable, "bash_complete")
monkeypatch.setenv("COMP_WORDS", f"{program} fixture")
monkeypatch.setenv("COMP_CWORD", "1")
monkeypatch.setattr(sys, "argv", [program])
with pytest.raises(SystemExit):
package.main()
assert f"plain,{FIXTURE_COMMAND_NAME}" in capsys.readouterr().out
@pytest.mark.parametrize("program", sorted(PROGRAMS))
def test_main_still_loads_commands_from_a_file(
program, monkeypatch, capsys, tmp_path, restore_cli_commands
):
package, _, entry_point_group, _ = PROGRAMS[program]
command_path = tmp_path / "case_summary.py"
command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8")
_install_fixture_entry_point(
monkeypatch, entry_point_group, click.Command("unused")
)
monkeypatch.setattr(
sys,
"argv",
_offline_argv(program, "--load-command", str(command_path), "case-summary"),
)
with pytest.raises(SystemExit) as exit_info:
package.main()
assert exit_info.value.code == 0
assert "case summary ran" in capsys.readouterr().out
@pytest.mark.parametrize("program", sorted(PROGRAMS))
def test_main_loads_commands_from_the_environment_variable(
program, monkeypatch, capsys, tmp_path, restore_cli_commands
):
# Each CLI reads its own variable, so a main() reading another CLI's would
# go unnoticed without this.
package, _, _, environment_variable = PROGRAMS[program]
command_path = tmp_path / "case_summary.py"
command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8")
monkeypatch.setenv(environment_variable, str(command_path))
monkeypatch.setattr(sys, "argv", _offline_argv(program, "case-summary"))
with pytest.raises(SystemExit) as exit_info:
package.main()
assert exit_info.value.code == 0
assert "case summary ran" in capsys.readouterr().out
@pytest.mark.parametrize("program", sorted(PROGRAMS))
def test_main_ignores_the_entry_point_groups_of_the_other_programs(
program, monkeypatch, capsys, restore_cli_commands
):
package, group, _, _ = PROGRAMS[program]
_install_fixture_entry_point(
monkeypatch,
OTHER_PROGRAMS_GROUP[program],
click.Command(FIXTURE_COMMAND_NAME),
)
monkeypatch.setattr(sys, "argv", _offline_argv(program, "--help"))
with pytest.raises(SystemExit) as exit_info:
package.main()
assert exit_info.value.code == 0
assert FIXTURE_COMMAND_NAME not in group.commands
assert FIXTURE_COMMAND_NAME not in capsys.readouterr().out
def test_the_console_script_targets_are_importable():
# [project.scripts] points at these, so they must stay where they are.
assert mvt.cli.main is mvt_main
assert mvt.ios.main is ios_main
assert mvt.android.main is android_main
def test_importing_mvt_does_not_import_a_cli(tmp_path):
# The mvt package deliberately re-exports nothing of mvt.cli, so that
# importing MVT stays cheap and free of side effects.
result = run_isolated_python(
"import sys\n"
"import mvt\n"
"print('imported a cli' if 'mvt.cli' in sys.modules else 'imported mvt')\n",
home=tmp_path / "home",
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "imported mvt"
def test_importing_mvt_does_not_run_installed_plugins(tmp_path):
site_path = write_cli_plugin_distribution(
tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE
)
marker = tmp_path / "plugin-imported"
result = run_isolated_python(
"import mvt.ios.cli\nimport mvt.android.cli\nprint('imported')",
home=tmp_path / "home",
site_path=site_path,
FIXTURE_PLUGIN_MARKER=str(marker),
)
assert result.returncode == 0, result.stderr
assert "imported" in result.stdout
assert not marker.exists()
def test_registering_the_plugins_runs_the_entry_point(tmp_path):
site_path = write_cli_plugin_distribution(
tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE
)
marker = tmp_path / "plugin-imported"
result = run_isolated_python(
"import click\n"
"from mvt.common.cli_plugins import (\n"
" IOS_CLI_PLUGIN_GROUP,\n"
" BrokenPluginCommand,\n"
" register_installed_cli_commands,\n"
")\n"
"group = click.Group()\n"
"register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)\n"
f"command = group.commands[{FIXTURE_COMMAND_NAME!r}]\n"
"assert not isinstance(command, BrokenPluginCommand), command.help\n"
"print('registered')\n",
home=tmp_path / "home",
site_path=site_path,
FIXTURE_PLUGIN_MARKER=str(marker),
)
assert result.returncode == 0, result.stderr
assert "registered" in result.stdout
assert marker.exists()