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.
This commit is contained in:
Donncha Ó Cearbhaill
2026-08-26 12:43:12 +02:00
parent f8456059db
commit c95e6659c6
14 changed files with 529 additions and 50 deletions
+195 -1
View File
@@ -3,9 +3,12 @@ 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,
@@ -14,6 +17,9 @@ from mvt.common.cli_plugins import (
)
# 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
@@ -345,7 +351,11 @@ def test_platform_entry_point_groups_and_environment_paths_are_separate(
def entry_points(*, group):
if group == IOS_CLI_PLUGIN_GROUP:
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
return [_entry_point("android-package", "android_plugin:cli", android_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",
@@ -369,3 +379,187 @@ def test_platform_entry_point_groups_and_environment_paths_are_separate(
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
+35
View File
@@ -8,6 +8,11 @@ import os
import pytest
from mvt.common.cli_plugins import (
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
MVT_CUSTOM_COMMANDS_ENV,
MVT_IOS_CUSTOM_COMMANDS_ENV,
)
from mvt.common.indicators import Indicators
from .artifacts.generate_stix import generate_test_stix_file
@@ -58,3 +63,33 @@ def indicators_factory(indicator_file):
return ind
return f
@pytest.fixture()
def restore_cli_commands(monkeypatch):
"""Keep the external commands a test registers out of the next test.
Each CLI group is a module-level object shared by every test, so a test
registering plugin or environment commands on one has to put it back. The
groups are imported here rather than at the top of the file, so that
collecting the tests does not import three CLIs for the sake of one
fixture.
"""
from mvt.android.cli import cli as android_cli
from mvt.cli import cli as neutral_cli
from mvt.ios.cli import cli as ios_cli
groups = (neutral_cli, ios_cli, android_cli)
for variable in (
MVT_CUSTOM_COMMANDS_ENV,
MVT_IOS_CUSTOM_COMMANDS_ENV,
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
):
monkeypatch.delenv(variable, raising=False)
originals = [dict(group.commands) for group in groups]
yield
for group, commands in zip(groups, originals):
group.commands.clear()
group.commands.update(commands)
if hasattr(group, "_mvt_external_command_sources"):
delattr(group, "_mvt_external_command_sources")
+49
View File
@@ -0,0 +1,49 @@
# 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/
from click.testing import CliRunner
from mvt.cli import cli
from mvt.common.updates import IndicatorsUpdates
from mvt.common.version import MVT_VERSION
# Keep the banner of the group callback from checking for updates online.
OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"]
class TestMvtCommand:
def test_running_mvt_alone_shows_the_logo_and_the_commands(self):
result = CliRunner().invoke(cli, OFFLINE)
assert result.exit_code == 0
logo_at = result.output.index("Mobile Verification Toolkit")
usage_at = result.output.index("Usage:")
assert logo_at < usage_at
assert "mvt-ios" in result.output and "mvt-android" in result.output
def test_help_reminds_where_the_analysis_runs(self):
result = CliRunner().invoke(cli, ["--help"])
assert result.exit_code == 0
assert "mvt-ios" in result.output
assert "mvt-android" in result.output
assert "check-*" in result.output
def test_version_prints_the_installed_version(self):
result = CliRunner().invoke(cli, [*OFFLINE, "version"])
assert result.exit_code == 0
assert f"Version: {MVT_VERSION}" in result.output
def test_download_iocs_updates_the_indicators(self, monkeypatch):
updates = []
monkeypatch.setattr(
IndicatorsUpdates, "update", lambda self: updates.append(self)
)
result = CliRunner().invoke(cli, [*OFFLINE, "download-iocs"])
assert result.exit_code == 0
assert len(updates) == 1
+93 -28
View File
@@ -10,10 +10,20 @@ 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.common.cli_plugins import ANDROID_CLI_PLUGIN_GROUP, IOS_CLI_PLUGIN_GROUP
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
@@ -39,23 +49,32 @@ def cli():
"""
PROGRAMS = {
"mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP),
"mvt-android": (mvt.android, android_cli, ANDROID_CLI_PLUGIN_GROUP),
"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
@pytest.fixture
def restore_cli_commands():
"""Undo the plugin registration main() performs on the shared CLI groups."""
originals = {
program: dict(group.commands) for program, (_, group, _) in PROGRAMS.items()
}
yield
for program, (_, group, _) in PROGRAMS.items():
group.commands.clear()
group.commands.update(originals[program])
if hasattr(group, "_mvt_external_command_sources"):
delattr(group, "_mvt_external_command_sources")
@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):
@@ -92,7 +111,7 @@ def _offline_argv(program, *arguments):
def test_main_registers_installed_plugins_before_running_the_cli(
program, monkeypatch, capsys, restore_cli_commands
):
package, group, entry_point_group = PROGRAMS[program]
package, group, entry_point_group, _ = PROGRAMS[program]
@click.command()
def fixture_command():
@@ -113,7 +132,7 @@ def test_main_registers_installed_plugins_before_running_the_cli(
def test_main_completes_plugin_command_names(
program, monkeypatch, capsys, restore_cli_commands
):
package, _, entry_point_group = PROGRAMS[program]
package, _, entry_point_group, _ = PROGRAMS[program]
@click.command()
def fixture_command():
@@ -136,17 +155,9 @@ def test_main_completes_plugin_command_names(
def test_main_still_loads_commands_from_a_file(
program, monkeypatch, capsys, tmp_path, restore_cli_commands
):
package, _, entry_point_group = PROGRAMS[program]
package, _, entry_point_group, _ = PROGRAMS[program]
command_path = tmp_path / "case_summary.py"
command_path.write_text(
"import click\n"
"\n"
"\n"
'@click.command("case-summary")\n'
"def cli():\n"
' click.echo("case summary ran")\n',
encoding="utf-8",
)
command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8")
_install_fixture_entry_point(
monkeypatch, entry_point_group, click.Command("unused")
)
@@ -163,12 +174,66 @@ def test_main_still_loads_commands_from_a_file(
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 on the packages.
# [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