tests(upgrade): centralize tropic-capable model/tag selection
What changed, and why it matters
This commit is a test-only refactoring change. It reorganizes how Trezor firmware upgrade tests select emulator models and handle a new hardware model (T3W1) that uses a 'tropic' chip. There is no change to the actual device firmware, wallet logic, or cryptography that end users rely on, so it does not create a security vulnerability.
No security action required. Treat as normal test-infrastructure maintenance; review for test correctness only.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff modifies tests/emulators.py and tests/upgrade_tests/init.py to centralize detection of Tropic-capable models (currently T3W1), share a single Tropic model process across upgrade-test stages, and switch test parametrization from (gen, tag, model) to (tag, model). It also adds prefer_nested lookup for emulator binaries and disallows the ambiguous TREZOR_UPGRADE_TEST=core selector. No runtime firmware, crypto, or protocol code is touched.
Changed components
tests/emulators.pytests/upgrade_tests/__init__.pyInspect captured patch +248 / −83
diff --git a/tests/emulators.py b/tests/emulators.py
index ae62b3ed..ef98be71 100644
--- a/tests/emulators.py
+++ b/tests/emulators.py
@@ -19,14 +19,21 @@ import os
import tempfile
from collections import defaultdict
from pathlib import Path
-from typing import Sequence, Tuple
+from typing import Dict, Sequence, Tuple
-from trezorlib._internal.emulator import CoreEmulator, Emulator, LegacyEmulator
-from trezorlib.models import CORE_MODELS, LEGACY_MODELS, by_internal_name
+from trezorlib._internal.emulator import (
+ CoreEmulator,
+ Emulator,
+ LegacyEmulator,
+ TropicModel,
+)
+from trezorlib.models import CORE_MODELS, LEGACY_MODELS
ROOT = Path(__file__).resolve().parent.parent
BINDIR = ROOT / "tests" / "emulators"
+_SHARED_TROPIC_MODELS: Dict[str, TropicModel] = {}
+
LOCAL_BUILD_PATHS = {
"core": ROOT / "core" / "build" / "unix" / "trezor-emu-core",
"legacy": ROOT / "legacy" / "firmware" / "trezor.elf",
@@ -37,17 +44,55 @@ CORE_SRC_DIR = ROOT / "core" / "src"
ENV = {"SDL_VIDEODRIVER": "dummy"}
TROPIC_MODEL_CONFIGFILE = ROOT / "tests" / "tropic_model" / "config.yml"
+TROPIC_CAPABLE_MODELS = {"T3W1"}
+
+
+def is_tropic_capable_model(model_internal_name: str | None) -> bool:
+ return model_internal_name in TROPIC_CAPABLE_MODELS
def gen_from_model(model_internal_name: str) -> str:
- model = by_internal_name(model_internal_name)
- if model in LEGACY_MODELS:
+ # Compare by internal_name string, not by object equality,
+ # because the models module may be patched during tests
+ legacy_names = {m.internal_name for m in LEGACY_MODELS}
+ core_names = {m.internal_name for m in CORE_MODELS}
+
+ if model_internal_name in legacy_names:
return "legacy"
- if model in CORE_MODELS:
+ if model_internal_name in core_names:
return "core"
raise ValueError(f"Unknown model: {model_internal_name}")
+def _get_shared_tropic_model(
+ profile_dir: str,
+ workdir: Path | None,
+ port: int,
+ configfile: Path,
+ logfile: Path | None,
+) -> TropicModel:
+ model = _SHARED_TROPIC_MODELS.get(profile_dir)
+ if model is None or model.process is None or model.process.poll() is not None:
+ if model is not None:
+ model.stop()
+ model = TropicModel(
+ workdir=workdir or ROOT,
+ profile_dir=Path(profile_dir),
+ port=port,
+ configfile=str(configfile),
+ logfile=logfile or (Path(profile_dir) / "trezor-tropic-model.log"),
+ )
+ model.start()
+ _SHARED_TROPIC_MODELS[profile_dir] = model
+ return model
+
+
+def stop_shared_tropic_model(profile_dir: str) -> None:
+ model = _SHARED_TROPIC_MODELS.pop(profile_dir, None)
+ if model:
+ model.stop()
+
+
def check_version(tag: str, version_tuple: Tuple[int, int, int]) -> None:
if tag is not None and tag.startswith("v") and len(tag.split(".")) == 3:
version = ".".join(str(i) for i in version_tuple)
@@ -59,37 +104,59 @@ def get_emulator_path(
gen: str,
model: str,
tag: str,
- subpath: str | None = None,
+ *,
+ prefer_nested: bool = False,
) -> Path:
- filename = f"trezor-emu-{gen}-{model}-{tag}"
- base = BINDIR / model
-
- if subpath is not None:
- return base / subpath / filename
-
- direct_path = base / filename
- if direct_path.exists():
- return direct_path
-
- matches = [p for p in base.rglob(filename) if p.is_file()]
- if matches:
- return sorted(matches)[0]
-
- return direct_path
-
+ expected_name = f"trezor-emu-{gen}-{model}-{tag}"
+ top_level_path = BINDIR / model / expected_name
+ nested_paths = sorted(
+ p for p in (BINDIR / model).glob(f"*/{expected_name}") if p.is_file()
+ )
+
+ if prefer_nested:
+ if nested_paths:
+ return nested_paths[0]
+ raise ValueError(
+ f"tropic-capable emulator executable not found: {BINDIR / model / '*/' / expected_name}"
+ )
+ else:
+ if top_level_path.exists():
+ return top_level_path
+ if nested_paths:
+ return nested_paths[0]
+
+ return top_level_path
+
+
+def get_tags(*, prefer_nested: bool = False) -> dict[str, list[str]]:
+ result = defaultdict(list)
+ for model_dir in sorted(p for p in BINDIR.iterdir() if p.is_dir()):
+ seen_tags = set()
+
+ top_level_files = sorted(
+ p for p in model_dir.glob("trezor-emu-*") if p.is_file()
+ )
+ nested_files = sorted(
+ p for p in model_dir.glob("*/trezor-emu-*") if p.is_file()
+ )
+
+ if prefer_nested:
+ files = nested_files
+ else:
+ files = [*top_level_files, *nested_files]
-def get_tags() -> dict[str, list[str]]:
- files = [p for p in BINDIR.rglob("trezor-emu-*") if p.is_file()]
+ for f in files:
+ try:
+ # example: "trezor-emu-core-T2T1-v2.0.8" or "trezor-emu-core-T2T1-v2.0.8-46ab42fw"
+ _, _, _, model, tag = f.name.split("-", maxsplit=4)
+ except ValueError:
+ continue
- result: dict[str, set[str]] = defaultdict(set)
- for f in sorted(files):
- try:
- # example: "trezor-emu-core-T2T1-v2.0.8" or "trezor-emu-core-T2T1-v2.0.8-46ab42fw"
- _, _, _, model, tag = f.name.split("-", maxsplit=4)
- result[model].add(tag)
- except ValueError:
- pass
- return {model: sorted(tags) for model, tags in result.items()}
+ if tag in seen_tags:
+ continue
+ result[model].append(tag)
+ seen_tags.add(tag)
+ return result
ALL_TAGS = get_tags()
@@ -119,26 +186,43 @@ class EmulatorWrapper:
def __init__(
self,
- gen: str,
+ gen_or_model: str | None,
tag: str | None = None,
model: str | None = None,
storage: bytes | None = None,
+ profile_dir: tempfile.TemporaryDirectory | None = None,
worker_id: int = 0,
headless: bool = True,
auto_interact: bool = True,
main_args: Sequence[str] = ("-m", "main"),
- launch_tropic_model: bool = False,
+ launch_tropic_model: bool | None = None,
+ prefer_nested: bool = False,
) -> None:
+ if gen_or_model is None:
+ raise ValueError("Either emulator gen or model must be provided")
+
+ if gen_or_model in ("core", "legacy"):
+ gen = gen_or_model
+ else:
+ model = gen_or_model
+ gen = gen_from_model(model)
+
+ if launch_tropic_model is None:
+ launch_tropic_model = is_tropic_capable_model(model)
+
if tag is not None and model is not None:
- executable = get_emulator_path(gen, model, tag)
+ executable = get_emulator_path(
+ gen, model, tag, prefer_nested=prefer_nested
+ )
else:
executable = LOCAL_BUILD_PATHS[gen]
if not executable.exists():
raise ValueError(f"emulator executable not found: {executable}")
- self.profile_dir = tempfile.TemporaryDirectory()
+ self.profile_dir = profile_dir or tempfile.TemporaryDirectory()
+ self.own_profile_dir = profile_dir is None
if executable == LOCAL_BUILD_PATHS["core"]:
workdir = CORE_SRC_DIR
else:
@@ -153,6 +237,32 @@ class EmulatorWrapper:
Path(logs_dir) / f"trezor-tropic-model-{worker_id}.log"
)
+ tropic_configfile = Path(TROPIC_MODEL_CONFIGFILE)
+ if launch_tropic_model:
+ tropic_config_output = (
+ Path(self.profile_dir.name) / "tropic_model_config_output.yml"
+ )
+ if tropic_config_output.exists():
+ tropic_configfile = tropic_config_output
+
+ use_shared_tropic_model = launch_tropic_model and not self.own_profile_dir
+ launch_tropic_model_for_emulator = launch_tropic_model
+ if use_shared_tropic_model:
+ shared_model = _get_shared_tropic_model(
+ profile_dir=self.profile_dir.name,
+ workdir=workdir,
+ port=_get_tropic_model_port(worker_id),
+ configfile=tropic_configfile,
+ logfile=(
+ tropic_model_logfile
+ if isinstance(tropic_model_logfile, Path)
+ else None
+ ),
+ )
+ launch_tropic_model_for_emulator = False
+ tropic_model_port = shared_model.port
+ else:
+ tropic_model_port = _get_tropic_model_port(worker_id)
if gen == "legacy":
self.emulator = LegacyEmulator(
executable,
@@ -168,9 +278,9 @@ class EmulatorWrapper:
self.profile_dir.name,
storage=storage,
workdir=workdir,
- launch_tropic_model=launch_tropic_model,
- tropic_model_port=_get_tropic_model_port(worker_id),
- tropic_model_configfile=str(TROPIC_MODEL_CONFIGFILE),
+ launch_tropic_model=launch_tropic_model_for_emulator,
+ tropic_model_port=tropic_model_port,
+ tropic_model_configfile=str(tropic_configfile),
tropic_model_logfile=tropic_model_logfile,
port=_get_port(worker_id),
headless=headless,
@@ -189,4 +299,5 @@ class EmulatorWrapper:
def __exit__(self, exc_type, exc_value, traceback) -> None:
self.emulator.stop()
- self.profile_dir.cleanup()
+ if self.own_profile_dir:
+ self.profile_dir.cleanup()
diff --git a/tests/upgrade_tests/__init__.py b/tests/upgrade_tests/__init__.py
index fc894626..7641278a 100644
--- a/tests/upgrade_tests/__init__.py
+++ b/tests/upgrade_tests/__init__.py
@@ -15,14 +15,40 @@
# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
import os
+import tempfile
+from contextlib import contextmanager
from typing import List, Tuple
import pytest
from _pytest.mark.structures import MarkDecorator
-from trezorlib.models import CORE_MODELS, LEGACY_MODELS, T1B1, T2T1, by_internal_name
+from trezorlib.models import T1B1, T2T1, T3W1, by_internal_name
+
+from ..emulators import (
+ LOCAL_BUILD_PATHS,
+ TROPIC_CAPABLE_MODELS,
+ get_tags,
+ stop_shared_tropic_model,
+)
+
+
+@contextmanager
+def shared_profile_dir() -> tempfile.TemporaryDirectory:
+ profile_dir = tempfile.TemporaryDirectory()
+ try:
+ yield profile_dir
+ finally:
+ stop_shared_tropic_model(profile_dir.name)
+ if os.environ.get("TREZOR_KEEP_PROFILE_DIR") != "1":
+ profile_dir.cleanup()
+
+
+ALL_TAGS = get_tags()
+_NESTED_TAGS = get_tags(prefer_nested=True)
+for model in TROPIC_CAPABLE_MODELS:
+ if model in _NESTED_TAGS:
+ ALL_TAGS[model] = _NESTED_TAGS[model]
-from ..emulators import ALL_TAGS, LOCAL_BUILD_PATHS, gen_from_model
SELECTED_GENS = [
gen.strip() for gen in os.environ.get("TREZOR_UPGRADE_TEST", "").split(",") if gen
@@ -31,12 +57,30 @@ SELECTED_GENS = [
if SELECTED_GENS:
# if any gens were selected via the environment variable, force enable all selected
LEGACY_ENABLED = "legacy" in SELECTED_GENS
- CORE_ENABLED = "core" in SELECTED_GENS
+ if "core" in SELECTED_GENS:
+ raise ValueError(
+ "TREZOR_UPGRADE_TEST=core is ambiguous. Use core-t2t1 or core-t3w1."
+ )
+ CORE_T2T1_ENABLED = "core-t2t1" in SELECTED_GENS
+ CORE_T3W1_ENABLED = "core-t3w1" in SELECTED_GENS
+ CORE_ENABLED = CORE_T2T1_ENABLED or CORE_T3W1_ENABLED
else:
# if no selection was provided, select those for which we have emulators
LEGACY_ENABLED = LOCAL_BUILD_PATHS["legacy"].exists()
CORE_ENABLED = LOCAL_BUILD_PATHS["core"].exists()
+ CORE_T2T1_ENABLED = CORE_ENABLED
+ CORE_T3W1_ENABLED = CORE_ENABLED
+
+
+def _is_model_enabled(model) -> bool:
+ if model == T1B1:
+ return LEGACY_ENABLED
+ if model == T2T1:
+ return CORE_T2T1_ENABLED
+ if model == T3W1:
+ return CORE_T3W1_ENABLED
+ return CORE_ENABLED
legacy_only = pytest.mark.skipif(
@@ -63,79 +107,89 @@ def for_all(
*args: str,
legacy_minimum_version: Tuple[int, int, int] = (1, 0, 0),
core_minimum_version: Tuple[int, int, int] = (2, 0, 0),
+ # Intentionally starts at 2.9.3 for T3W1 upgrade coverage.
+ t3w1_minimum_version: Tuple[int, int, int] = (2, 9, 3),
) -> "MarkDecorator":
"""Parametrizing decorator for test cases.
Usage example:
- >>> @for_all()
- >>> def test_runs_for_all_old_versions(gen, tag, model):
+ >>> @for_all("T1B1", "T2T1", "T3W1")
+ >>> def test_runs_for_all_models(tag, model):
>>> assert True
- Arguments can be trezor models (e.g."T1B1" and "T2T1") or aliases "core" and "legacy",
- and you can specify core_minimum_version and legacy_minimum_version as triplets.
+ Arguments should be trezor model names (e.g. "T1B1", "T2T1", "T3W1").
+ If no arguments provided, defaults to all supported models.
+ You can specify minimum versions for each model type.
- The test function should have arguments `gen` ("core" or "legacy") and `tag`
- (version tag usable in EmulatorWrapper call)
+ The test function should have arguments `tag` (version tag) and `model` (internal model name).
"""
if not ALL_TAGS:
raise ValueError(
"No files found. Use download_emulators.sh to download emulators."
)
- models = []
- gens = set()
+
+ # Map model names to TrezorModel objects
+ models_to_test = []
for item in args:
- if item == "core":
- models.extend(CORE_MODELS)
- gens.add("core")
- elif item == "legacy":
- models.extend(LEGACY_MODELS)
- gens.add("legacy")
+ model_obj = by_internal_name(item)
+ if model_obj is not None:
+ models_to_test.append(model_obj)
else:
- models.append(by_internal_name(item))
- gens.add(gen_from_model(item))
+ raise ValueError(f"Unknown model: {item}")
+ # If no args provided, default to all supported models
if not args:
- gens = ["core", "legacy"]
- models = [T1B1, T2T1]
+ models_to_test = [T1B1, T2T1, T3W1]
+
+ all_params: set[tuple[str | None, str | None]] = set()
- # If any gens were selected, use them. If none, select all.
- enabled_gens = SELECTED_GENS or list(gens)
+ models_to_test = [model for model in models_to_test if _is_model_enabled(model)]
+ if not models_to_test:
+ return pytest.mark.skip("no models are enabled")
- all_params: set[tuple[str, str | None, str | None]] = set()
- for model in models:
- if model in LEGACY_MODELS:
+ for model in models_to_test:
+ # Determine minimum version based on model
+ if model == T1B1:
minimum_version = legacy_minimum_version
- elif model in CORE_MODELS:
+ elif model == T3W1:
+ minimum_version = t3w1_minimum_version
+ elif model == T2T1:
minimum_version = core_minimum_version
else:
- raise ValueError
+ minimum_version = core_minimum_version
- gen = gen_from_model(model.internal_name)
- if gen not in enabled_gens:
- continue
try:
for tag in ALL_TAGS[model.internal_name]:
tag_version = version_from_tag(tag)
if tag_version is not None and tag_version < minimum_version:
continue
- all_params.add((gen, tag, model.internal_name))
+ all_params.add((tag, model.internal_name))
- # At the end, add (gen, None, None), which is the current master.
- # The same (gen, None, None) can be added multiple times as there are
- # more models than gens. That is why all_params is defined as a set.
- all_params.add((gen, None, None))
except KeyError:
pass
if not all_params:
return pytest.mark.skip("no versions are applicable")
- return pytest.mark.parametrize("gen, tag, model", all_params)
+ return pytest.mark.parametrize("tag, model", sorted(all_params))
def for_tags(*args: Tuple[str, List[str]]) -> "MarkDecorator":
- enabled_gens = SELECTED_GENS or ("core", "legacy")
- return pytest.mark.parametrize(
- "gen, tags", [(gen, tags) for gen, tags in args if gen in enabled_gens]
- )
+ """Parametrizing decorator for tests that need specific version tags.
+
+ Usage: @for_tags(("T1B1", ["v1.7.0", "v1.8.0"]))
+
+ Returns parameters: (tags, model)
+ """
+ params = []
+ for model_name, tags in args:
+ # Map model name to model object to get gen
+ model_obj = by_internal_name(model_name)
+ if model_obj is not None and _is_model_enabled(model_obj):
+ params.append((tags, model_name))
+
+ if not params:
+ return pytest.mark.skip("no versions are applicable")
+
+ return pytest.mark.parametrize("tags, model", params)
Why this scored 13/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.