mirror of
https://github.com/mvt-project/mvt.git
synced 2026-09-11 20:29:00 +02:00
* Defer indicator loading until first use * Load indicators once at the start of a run The lazy property loads indicators wherever they are first read, which in Command.run() is inside the module loop, after init(). For check-androidqf that means the whole acquisition is walked before a missing --iocs file is reported, and the STIX parsing lines land between the module list and the first module. Read the indicators once after the module list is settled, so that a bad --module name still loads nothing, and hand the same object to every module. check-iocs reads them once too, so that a wrong --iocs path is reported even when no stored result matches a module. * Drop two test assertions the change does not need Retrying after a failed indicator load is incidental to the property rather than a requirement, so nothing should pin it. The exact wording of the check-backup rejection belongs to that command's own tests; exit code 1 already shows the path was rejected before indicators were loaded. * Create the output folder and command.log when a run starts Command.__init__ created the --output folder and attached the command.log handler, so `--list-modules -o out` or a rejected target path left an empty folder behind for a run that never happened. Do both at the top of run(), before the module list is resolved so that its warnings still reach the log. The nested commands run by check-androidqf re-attach the handler at the start of their runs, as they did at construction. Lines the CLI logs between construction and run(), such as the target path being checked, no longer reach command.log; info.json records the target path. This is the remaining part of #888. * Cache the indicators with functools.cached_property A property with a setter and a backing attribute does by hand what cached_property does: compute on first read, store the result on the instance, and accept assignment, which is how nested commands receive their parent's indicators. A failed load is still not cached. * Announce the target from the command so that it reaches command.log The CLI logged which backup, filesystem or acquisition it was about to check just before run(), which is now before command.log exists, so the line only reached the console. Each command logs it from init() instead, which runs once the log is attached, and run() logs the module list after init() so that the target still comes first. Nested commands without a target path log nothing, as before. * Log the Android backup path from the typed local mypy cannot determine the type of target_path in this command, which the surrounding lines already silence, so read the announced path from the local that carries the type instead of adding another ignore. --------- Co-authored-by: bitmeta69 <206962233+bitmeta69@users.noreply.github.com> Co-authored-by: besendorf <janik@besendorf.org> Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
881 lines
29 KiB
Python
881 lines
29 KiB
Python
# Mobile Verification Toolkit (MVT)
|
|
# Copyright (c) 2021-2023 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 json
|
|
import logging
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from mvt.common.command import Command
|
|
from mvt.common.indicators import Indicators
|
|
from mvt.common.module import MVTModule
|
|
|
|
|
|
class RecordingModule(MVTModule):
|
|
run_order: list[str] = []
|
|
|
|
def run(self):
|
|
self.run_order.append(self.__class__.__name__)
|
|
|
|
def check_indicators(self):
|
|
pass
|
|
|
|
|
|
class FirstModule(RecordingModule):
|
|
def run(self):
|
|
super().run()
|
|
self.results = ["first"]
|
|
|
|
|
|
class SecondModule(RecordingModule):
|
|
dependencies = (FirstModule,)
|
|
|
|
def run(self):
|
|
super().run()
|
|
self.results = self.get_dependency_results(FirstModule) + ["second"]
|
|
|
|
|
|
class ThirdModule(RecordingModule):
|
|
dependencies = (SecondModule,)
|
|
|
|
|
|
class IndependentModule(RecordingModule):
|
|
pass
|
|
|
|
|
|
class URLRecordingModule(RecordingModule):
|
|
def collect_url_results(self):
|
|
self.add_url_result(
|
|
"https://example.org/message",
|
|
"2026-07-29 12:00:00.000000",
|
|
"test-chat",
|
|
)
|
|
|
|
|
|
class CustomIOSBackupModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
|
|
|
|
class CustomIOSFSModule(RecordingModule):
|
|
supported_commands = (("ios", "check-fs"),)
|
|
|
|
|
|
class UnscopedCustomModule(RecordingModule):
|
|
pass
|
|
|
|
|
|
class CustomDependsOnBuiltin(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
dependencies = (FirstModule,)
|
|
|
|
|
|
class ReplacementModule(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
|
|
def run(self):
|
|
super().run()
|
|
self.results = ["replacement"]
|
|
|
|
|
|
class OtherReplacementModule(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
|
|
|
|
class UnrelatedReplacementModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = IndependentModule
|
|
|
|
|
|
class ReplacementOfReplacementModule(ReplacementModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = ReplacementModule
|
|
|
|
def run(self):
|
|
super().run()
|
|
self.results = ["replacement of replacement"]
|
|
|
|
|
|
class ReplacesOwnDependencyModule(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
dependencies = (FirstModule,)
|
|
|
|
|
|
class DisabledReplacementModule(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
enabled = False
|
|
|
|
|
|
class MutualReplacementOne(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
|
|
|
|
class MutualReplacementTwo(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = MutualReplacementOne
|
|
|
|
|
|
MutualReplacementOne.replaces = MutualReplacementTwo
|
|
|
|
|
|
class CycleOneModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
|
|
|
|
class CycleTwoModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = CycleOneModule
|
|
|
|
|
|
class CycleThreeModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = CycleTwoModule
|
|
|
|
|
|
CycleOneModule.replaces = CycleThreeModule
|
|
|
|
|
|
class UnavailableDependencyModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
|
|
|
|
class ReplacementMissingDependency(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
dependencies = (UnavailableDependencyModule,)
|
|
|
|
|
|
class SharedSlugModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
slug = "shared_slug"
|
|
|
|
|
|
class OtherSharedSlugModule(RecordingModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
slug = "shared_slug"
|
|
|
|
|
|
class ReplacementKeepingTheSlug(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
slug = "first_module"
|
|
|
|
|
|
class SameNameReplacementModule(FirstModule):
|
|
supported_commands = (("ios", "check-backup"),)
|
|
replaces = FirstModule
|
|
|
|
def run(self):
|
|
super().run()
|
|
self.results = ["replacement"]
|
|
|
|
|
|
# Modules replacing a built-in one usually keep its class name, which is the
|
|
# name `--module` matches on.
|
|
SameNameReplacementModule.__name__ = "FirstModule"
|
|
|
|
|
|
def logged_substitutions(caplog) -> list[str]:
|
|
return [
|
|
record.getMessage()
|
|
for record in caplog.records
|
|
if record.levelno == logging.INFO and "replaces module" in record.getMessage()
|
|
]
|
|
|
|
|
|
class RecordingCommand(Command):
|
|
def init(self):
|
|
self.initialized = True
|
|
|
|
def module_init(self, module):
|
|
pass
|
|
|
|
def finish(self):
|
|
pass
|
|
|
|
|
|
class TestCommand:
|
|
def test_listing_modules_does_not_load_indicators(self):
|
|
with patch("mvt.common.command.Indicators.load_indicators_files") as load:
|
|
cmd = RecordingCommand()
|
|
cmd.list_modules()
|
|
load.assert_not_called()
|
|
|
|
def test_output_folder_is_created_by_a_run_not_by_listing(self, tmp_path):
|
|
output_path = tmp_path / "out"
|
|
with patch("mvt.common.command.Indicators.load_indicators_files"):
|
|
cmd = RecordingCommand(results_path=str(output_path))
|
|
cmd.list_modules()
|
|
assert not output_path.exists()
|
|
cmd.run()
|
|
assert (output_path / "command.log").is_file()
|
|
|
|
def test_indicators_load_once_and_are_shared(self, indicator_file, monkeypatch):
|
|
from mvt.common.config import settings
|
|
|
|
monkeypatch.setattr(settings, "STIX2", "")
|
|
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
|
|
original = Indicators.load_indicators_files
|
|
with patch.object(
|
|
Indicators, "load_indicators_files", autospec=True, side_effect=original
|
|
) as load:
|
|
cmd = RecordingCommand(ioc_files=[indicator_file])
|
|
load.assert_not_called()
|
|
indicators = cmd.iocs
|
|
assert indicators.total_ioc_count == 9
|
|
assert len(indicators.ioc_collections) == 1
|
|
assert cmd.iocs is indicators
|
|
child = RecordingCommand(iocs=indicators)
|
|
assert child.iocs is indicators
|
|
load.assert_called_once_with(indicators, [indicator_file])
|
|
|
|
@pytest.mark.parametrize("assign", [False, True])
|
|
def test_supplied_empty_indicators_are_not_loaded(self, assign):
|
|
indicators = Indicators(logging.getLogger(__name__))
|
|
with patch.object(Indicators, "load_indicators_files") as load:
|
|
cmd = RecordingCommand(iocs=None if assign else indicators)
|
|
if assign:
|
|
cmd.iocs = indicators
|
|
assert cmd.iocs is indicators
|
|
assert cmd.iocs.total_ioc_count == 0
|
|
load.assert_not_called()
|
|
|
|
@pytest.mark.parametrize("list_modules", [False, True])
|
|
def test_backup_cli_does_not_load_indicators_before_analysis(
|
|
self, tmp_path, list_modules
|
|
):
|
|
from mvt.ios.cli import check_backup
|
|
|
|
args = [str(tmp_path)]
|
|
if list_modules:
|
|
args.insert(0, "--list-modules")
|
|
with patch.object(Indicators, "load_indicators_files") as load:
|
|
result = CliRunner().invoke(check_backup, args)
|
|
assert result.exit_code == (0 if list_modules else 1)
|
|
load.assert_not_called()
|
|
|
|
def test_run_checks_synthetic_indicators(self, indicator_file, monkeypatch):
|
|
from mvt.common.config import settings
|
|
|
|
monkeypatch.setattr(settings, "STIX2", "")
|
|
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
|
|
|
|
class MatchingModule(RecordingModule):
|
|
def run(self):
|
|
self.results = ["https://example.org/test"]
|
|
|
|
def check_indicators(self):
|
|
self.detected = [
|
|
url for url in self.results if self.indicators.check_domain(url)
|
|
]
|
|
|
|
cmd = RecordingCommand(ioc_files=[indicator_file])
|
|
cmd.modules = [MatchingModule]
|
|
cmd.run()
|
|
assert cmd.executed[0].detected == ["https://example.org/test"]
|
|
assert cmd.executed[0].indicators is cmd.iocs
|
|
|
|
def setup_method(self):
|
|
RecordingModule.run_order = []
|
|
|
|
def test_store_alerts_handles_bytes(self, tmp_path):
|
|
cmd = Command(results_path=str(tmp_path))
|
|
cmd.alertstore.medium(
|
|
"bytes event",
|
|
"",
|
|
{"payload": b"\xa8\xa9"},
|
|
)
|
|
|
|
cmd._store_alerts()
|
|
|
|
alerts = json.loads((tmp_path / "alerts.json").read_text())
|
|
assert alerts[0]["event"]["payload"] == "\\xa8\\xa9"
|
|
|
|
def test_stores_collected_urls(self, tmp_path):
|
|
cmd = RecordingCommand(results_path=str(tmp_path))
|
|
cmd.modules = [URLRecordingModule]
|
|
|
|
cmd.run()
|
|
|
|
assert json.loads((tmp_path / "urls.json").read_text()) == [
|
|
{
|
|
"url": "https://example.org/message",
|
|
"expanded_url": None,
|
|
"timestamp": "2026-07-29 12:00:00.000000",
|
|
"source": "test-chat",
|
|
}
|
|
]
|
|
|
|
def test_modules_run_in_stable_topological_order(self):
|
|
cmd = RecordingCommand()
|
|
cmd.modules = [ThirdModule, IndependentModule, SecondModule, FirstModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"IndependentModule",
|
|
"FirstModule",
|
|
"SecondModule",
|
|
"ThirdModule",
|
|
]
|
|
second = next(module for module in cmd.executed if isinstance(module, SecondModule))
|
|
assert second.results == ["first", "second"]
|
|
|
|
def test_selected_module_runs_transitive_dependencies(self):
|
|
cmd = RecordingCommand(module_name="ThirdModule")
|
|
cmd.modules = [ThirdModule, SecondModule, FirstModule, IndependentModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"FirstModule",
|
|
"SecondModule",
|
|
"ThirdModule",
|
|
]
|
|
|
|
def test_circular_dependency_warns_and_stops(self, caplog):
|
|
class CircularOne(RecordingModule):
|
|
pass
|
|
|
|
class CircularTwo(RecordingModule):
|
|
dependencies = (CircularOne,)
|
|
|
|
CircularOne.dependencies = (CircularTwo,)
|
|
|
|
cmd = RecordingCommand()
|
|
cmd.modules = [CircularOne, CircularTwo]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == []
|
|
assert not hasattr(cmd, "initialized")
|
|
assert "Circular module dependency detected" in caplog.text
|
|
|
|
def test_unavailable_dependency_only_skips_the_dependent_module(self, caplog):
|
|
class UnavailableModule(RecordingModule):
|
|
pass
|
|
|
|
class DependentModule(RecordingModule):
|
|
dependencies = (UnavailableModule,)
|
|
|
|
cmd = RecordingCommand()
|
|
cmd.modules = [DependentModule, IndependentModule, FirstModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["IndependentModule", "FirstModule"]
|
|
assert cmd.initialized
|
|
assert "Module DependentModule will be SKIPPED" in caplog.text
|
|
assert "depends on module UnavailableModule" in caplog.text
|
|
|
|
def test_modules_depending_on_a_skipped_module_are_skipped_too(self, caplog):
|
|
class UnavailableModule(RecordingModule):
|
|
pass
|
|
|
|
class SkippedModule(RecordingModule):
|
|
dependencies = (UnavailableModule,)
|
|
|
|
class DependsOnSkippedModule(RecordingModule):
|
|
dependencies = (SkippedModule,)
|
|
|
|
class DependsOnTheChain(RecordingModule):
|
|
dependencies = (DependsOnSkippedModule,)
|
|
|
|
cmd = RecordingCommand()
|
|
cmd.modules = [
|
|
DependsOnTheChain,
|
|
DependsOnSkippedModule,
|
|
SkippedModule,
|
|
IndependentModule,
|
|
]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["IndependentModule"]
|
|
skip_warnings = [
|
|
record.getMessage()
|
|
for record in caplog.records
|
|
if "will be SKIPPED" in record.getMessage()
|
|
]
|
|
assert len(skip_warnings) == 3
|
|
assert [warning.split()[1] for warning in skip_warnings] == [
|
|
"SkippedModule",
|
|
"DependsOnSkippedModule",
|
|
"DependsOnTheChain",
|
|
]
|
|
# Every warning names the root cause: the module missing a dependency
|
|
# and the dependency it is missing.
|
|
assert all("UnavailableModule" in warning for warning in skip_warnings)
|
|
assert all("module SkippedModule" in warning for warning in skip_warnings[1:])
|
|
|
|
def test_explicitly_selected_module_with_missing_dependency_runs_nothing(
|
|
self, caplog
|
|
):
|
|
class UnavailableModule(RecordingModule):
|
|
pass
|
|
|
|
class DependentModule(RecordingModule):
|
|
dependencies = (UnavailableModule,)
|
|
|
|
cmd = RecordingCommand(module_name="DependentModule")
|
|
cmd.modules = [DependentModule, IndependentModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == []
|
|
assert "Module DependentModule will be SKIPPED" in caplog.text
|
|
assert "No modules will be run" in caplog.text
|
|
# Nothing else was selected, so the warning must not promise that the
|
|
# analysis continues right before saying that it does not.
|
|
assert "The rest of the analysis will still run" not in caplog.text
|
|
|
|
def test_unaffected_dependency_chains_keep_their_order(self, caplog):
|
|
class UnavailableModule(RecordingModule):
|
|
pass
|
|
|
|
class SkippedModule(RecordingModule):
|
|
dependencies = (UnavailableModule,)
|
|
|
|
cmd = RecordingCommand()
|
|
cmd.modules = [ThirdModule, SkippedModule, SecondModule, FirstModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"FirstModule",
|
|
"SecondModule",
|
|
"ThirdModule",
|
|
]
|
|
|
|
def test_custom_modules_are_filtered_before_ordering(self):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [
|
|
CustomIOSBackupModule,
|
|
CustomIOSFSModule,
|
|
UnscopedCustomModule,
|
|
]
|
|
|
|
assert [module.__name__ for module in cmd._ordered_modules()] == [
|
|
"FirstModule",
|
|
"CustomIOSBackupModule",
|
|
]
|
|
|
|
def test_selected_custom_module_runs(self):
|
|
cmd = RecordingCommand(module_name="CustomIOSBackupModule")
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.custom_modules = [CustomIOSBackupModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["CustomIOSBackupModule"]
|
|
|
|
def test_selected_unsupported_custom_module_does_not_run(self):
|
|
cmd = RecordingCommand(module_name="CustomIOSFSModule")
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.custom_modules = [CustomIOSFSModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == []
|
|
|
|
def test_custom_module_dependencies_use_topological_order(self):
|
|
cmd = RecordingCommand(module_name="CustomDependsOnBuiltin")
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [SecondModule, FirstModule]
|
|
cmd.custom_modules = [CustomDependsOnBuiltin]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
|
|
|
|
def test_custom_module_replaces_builtin(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule, IndependentModule]
|
|
cmd.custom_modules = [ReplacementModule]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
|
|
assert cmd.module_replacements == {FirstModule: ReplacementModule}
|
|
assert (
|
|
"Module ReplacementModule from" in caplog.text
|
|
and "replaces module FirstModule" in caplog.text
|
|
)
|
|
|
|
def test_modules_without_replaces_all_run(self):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [CustomIOSBackupModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["FirstModule", "CustomIOSBackupModule"]
|
|
assert cmd.module_replacements == {}
|
|
|
|
def test_unavailable_replaced_module_is_ignored(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [IndependentModule]
|
|
cmd.custom_modules = [ReplacementModule]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"]
|
|
assert cmd.module_replacements == {}
|
|
assert "replaces module FirstModule" not in caplog.text
|
|
|
|
def test_dependencies_are_resolved_to_the_replacement(self):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [SecondModule, FirstModule]
|
|
cmd.custom_modules = [ReplacementModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["ReplacementModule", "SecondModule"]
|
|
second = next(
|
|
module for module in cmd.executed if isinstance(module, SecondModule)
|
|
)
|
|
assert isinstance(second.dependency_modules[FirstModule], ReplacementModule)
|
|
assert second.results == ["replacement", "second"]
|
|
|
|
def test_replacement_with_unavailable_dependency_skips_its_dependents(
|
|
self, caplog
|
|
):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule, SecondModule, IndependentModule]
|
|
cmd.custom_modules = [ReplacementMissingDependency]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
# The replacement cannot run, and the module it replaced was dropped
|
|
# from the run by the replacement, so neither of them produces
|
|
# results and the module depending on the replaced one is skipped.
|
|
assert RecordingModule.run_order == ["IndependentModule"]
|
|
assert (
|
|
"Module ReplacementMissingDependency will be SKIPPED: it depends "
|
|
"on module UnavailableDependencyModule, which is not available in "
|
|
"this command." in caplog.text
|
|
)
|
|
# The skipped dependent is told which module it actually depends on,
|
|
# and the module class its author declared.
|
|
assert (
|
|
"Module SecondModule will be SKIPPED: it depends on module "
|
|
"ReplacementMissingDependency (replacing module FirstModule), "
|
|
"itself skipped for depending on unavailable module "
|
|
"UnavailableDependencyModule." in caplog.text
|
|
)
|
|
|
|
def test_selected_replacement_with_unavailable_dependency_runs_nothing(
|
|
self, caplog, tmp_path
|
|
):
|
|
cmd = RecordingCommand(module_name="FirstModule", results_path=str(tmp_path))
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule, IndependentModule]
|
|
cmd.custom_modules = [ReplacementMissingDependency]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == []
|
|
assert not hasattr(cmd, "initialized")
|
|
assert (
|
|
"Module FirstModule was replaced by module "
|
|
"ReplacementMissingDependency, which is run in its place."
|
|
in caplog.text
|
|
)
|
|
assert "Module ReplacementMissingDependency will be SKIPPED" in caplog.text
|
|
assert "No modules will be run" in caplog.text
|
|
# Nothing else was selected, so the warnings must not promise that the
|
|
# analysis continues right before saying that it does not.
|
|
assert "The rest of the analysis will still run" not in caplog.text
|
|
# No module ran, so no results were stored next to the command log.
|
|
assert [path.name for path in tmp_path.iterdir()] == ["command.log"]
|
|
|
|
def test_chained_replacements_are_resolved(self):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [SecondModule, FirstModule]
|
|
cmd.custom_modules = [ReplacementModule, ReplacementOfReplacementModule]
|
|
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"ReplacementOfReplacementModule",
|
|
"SecondModule",
|
|
]
|
|
second = next(
|
|
module for module in cmd.executed if isinstance(module, SecondModule)
|
|
)
|
|
assert second.results == ["replacement of replacement", "second"]
|
|
|
|
def test_replacement_which_is_not_a_subclass_warns(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [IndependentModule]
|
|
cmd.custom_modules = [UnrelatedReplacementModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["UnrelatedReplacementModule"]
|
|
assert "is not a subclass of it" in caplog.text
|
|
|
|
def test_multiple_modules_replacing_the_same_module_warn(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [ReplacementModule, OtherReplacementModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"ReplacementModule",
|
|
"OtherReplacementModule",
|
|
]
|
|
assert cmd.module_replacements == {FirstModule: ReplacementModule}
|
|
assert (
|
|
"Modules ReplacementModule and OtherReplacementModule both replace "
|
|
"module FirstModule. Both of them will run, FirstModule will not, "
|
|
"and modules depending on FirstModule will use the results of "
|
|
"ReplacementModule." in caplog.text
|
|
)
|
|
assert "overwrite each other's results file" in caplog.text
|
|
|
|
def test_module_replacing_its_own_dependency_runs(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [ReplacesOwnDependencyModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["ReplacesOwnDependencyModule"]
|
|
assert (
|
|
"Module ReplacesOwnDependencyModule depends on module FirstModule, "
|
|
"which it also replaces" in caplog.text
|
|
)
|
|
|
|
def test_literal_self_dependency_is_still_circular(self, caplog):
|
|
class SelfDependentModule(RecordingModule):
|
|
pass
|
|
|
|
SelfDependentModule.dependencies = (SelfDependentModule,)
|
|
|
|
cmd = RecordingCommand()
|
|
cmd.modules = [SelfDependentModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == []
|
|
assert not hasattr(cmd, "initialized")
|
|
assert "Circular module dependency detected" in caplog.text
|
|
|
|
def test_disabled_replacement_keeps_the_replaced_module(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [DisabledReplacementModule]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["FirstModule"]
|
|
assert cmd.module_replacements == {}
|
|
assert logged_substitutions(caplog) == []
|
|
|
|
def test_modules_replacing_each_other_all_run(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.custom_modules = [MutualReplacementOne, MutualReplacementTwo]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"MutualReplacementOne",
|
|
"MutualReplacementTwo",
|
|
]
|
|
assert cmd.module_replacements == {}
|
|
assert (
|
|
"Modules MutualReplacementOne, MutualReplacementTwo replace each "
|
|
"other in a cycle" in caplog.text
|
|
)
|
|
assert logged_substitutions(caplog) == []
|
|
|
|
def test_replacement_cycle_of_three_modules_all_run(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.custom_modules = [CycleOneModule, CycleTwoModule, CycleThreeModule]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"CycleOneModule",
|
|
"CycleTwoModule",
|
|
"CycleThreeModule",
|
|
]
|
|
assert cmd.module_replacements == {}
|
|
assert (
|
|
"Modules CycleOneModule, CycleThreeModule, CycleTwoModule replace "
|
|
"each other in a cycle" in caplog.text
|
|
)
|
|
assert logged_substitutions(caplog) == []
|
|
|
|
def test_selected_replaced_module_name_runs_the_replacement(self, caplog):
|
|
cmd = RecordingCommand(module_name="FirstModule")
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [ReplacementModule]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == ["ReplacementModule"]
|
|
assert (
|
|
"Module FirstModule was replaced by module ReplacementModule"
|
|
in caplog.text
|
|
)
|
|
|
|
def test_unknown_selected_module_warns_and_stops(self, caplog):
|
|
cmd = RecordingCommand(module_name="NoSuchModule")
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == []
|
|
assert not hasattr(cmd, "initialized")
|
|
assert (
|
|
"No module named NoSuchModule is available for the check-backup "
|
|
"command" in caplog.text
|
|
)
|
|
|
|
def test_selected_module_name_matches_the_replacement(self):
|
|
cmd = RecordingCommand(module_name="FirstModule")
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [SameNameReplacementModule]
|
|
|
|
cmd.run()
|
|
|
|
assert len(cmd.executed) == 1
|
|
assert isinstance(cmd.executed[0], SameNameReplacementModule)
|
|
assert cmd.executed[0].results == ["replacement"]
|
|
|
|
def test_list_modules_reflects_replacements(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule, IndependentModule]
|
|
cmd.custom_modules = [ReplacementModule]
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
cmd.list_modules()
|
|
|
|
listed = [
|
|
record.getMessage()
|
|
for record in caplog.records
|
|
if "Modules from" in record.getMessage()
|
|
]
|
|
assert any("ReplacementModule" in message for message in listed)
|
|
assert not any("FirstModule" in message for message in listed)
|
|
|
|
def test_modules_sharing_a_slug_are_reported(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.custom_modules = [SharedSlugModule, OtherSharedSlugModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert RecordingModule.run_order == [
|
|
"SharedSlugModule",
|
|
"OtherSharedSlugModule",
|
|
]
|
|
collisions = [
|
|
record.getMessage()
|
|
for record in caplog.records
|
|
if "both use the slug" in record.getMessage()
|
|
]
|
|
assert len(collisions) == 1
|
|
assert "Modules SharedSlugModule from" in collisions[0]
|
|
assert "and OtherSharedSlugModule from" in collisions[0]
|
|
assert "both use the slug shared_slug" in collisions[0]
|
|
assert "overwrites the results of the other in shared_slug.json" in (
|
|
collisions[0]
|
|
)
|
|
|
|
def test_replacement_keeping_the_replaced_slug_is_not_reported(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule]
|
|
cmd.custom_modules = [ReplacementKeepingTheSlug]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
# The replaced module is no longer part of the run, so taking over its
|
|
# slug is what the replacement is for, not a collision.
|
|
assert RecordingModule.run_order == ["ReplacementKeepingTheSlug"]
|
|
assert ReplacementKeepingTheSlug.get_slug() == FirstModule.get_slug()
|
|
assert "both use the slug" not in caplog.text
|
|
|
|
def test_modules_with_distinct_slugs_are_not_reported(self, caplog):
|
|
cmd = RecordingCommand()
|
|
cmd.platform = "ios"
|
|
cmd.name = "check-backup"
|
|
cmd.modules = [FirstModule, IndependentModule]
|
|
cmd.custom_modules = [CustomIOSBackupModule]
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
cmd.run()
|
|
|
|
assert "both use the slug" not in caplog.text
|