What changed, and why it matters
This commit is a developer tooling change for the Trezor firmware build system. It introduces 'build presets'—named configuration bundles stored in TOML files—so developers can select common build settings with a single command-line flag instead of typing many options. It does not change how the device works, how funds are protected, or any cryptographic code. There is no indication this is a security fix or vulnerability patch.
No security action required. Treat as a normal build-system feature commit. If reviewing for supply-chain risk, verify that user-presets.toml remains .gitignored and that CI does not unintentionally load untrusted local preset files.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit adds a presets subsystem to the xtask build helper in core/embed/xtask. New files include presets.toml (shared defaults and named presets such as ‘test’ and ‘dev’), presets.rs (parser/merger), and .gitignore for user-presets.toml. BuildOptions resolution now loads shared and optional user presets, applies defaults, then overlays the selected preset and explicit CLI options. The feature_resolver.rs logic is refactored so that dbg_console and frozen features are driven by resolved options rather than hard-coded emulator/project branches, but the effective feature combinations remain equivalent for normal builds. Unit tests cover preset merging, precedence, and error handling.
Changed components
core/embed/xtask build toolingfeature flag resolution in core/embed/xtask/src/feature_resolver.rsBuildOptions/ResolvedBuildArgs construction in core/embed/xtask/src/options.rsInspect captured patch +397 / −41
diff --git a/core/embed/xtask/.gitignore b/core/embed/xtask/.gitignore
new file mode 100644
index 00000000..0e1b5c27
--- /dev/null
+++ b/core/embed/xtask/.gitignore
@@ -0,0 +1 @@
+user-presets.toml
diff --git a/core/embed/xtask/presets.toml b/core/embed/xtask/presets.toml
new file mode 100644
index 00000000..be1d88ec
--- /dev/null
+++ b/core/embed/xtask/presets.toml
@@ -0,0 +1,56 @@
+# Build presets shared by xtask and tf-tools.
+#
+# Each [[<presets-name>]] table is a preset fragment. Fragments whose optional
+# `when` filter matches are applied in file order; later matching fragments
+# override earlier values. Explicit CLI options override all preset values.
+# Local entries in user-presets.toml are applied after this file and may add or
+# override presets. The local file is ignored by Git.
+#
+# `model` and `project` accept arrays. Selector fields are combined with AND;
+# values within a selector array are combined with OR.
+
+# Build defaults (applied to all builds as a base layer)
+
+[[defaults]]
+when = { emulator = true }
+dbg-console = "swo" # required by prodtest test
+source-lines = true # required by test coverage
+
+[[defaults]]
+when = { emulator = false }
+frozen = true
+pyopt = true
+
+[[test]]
+debug = true
+debug-link = true
+disable-animation = true
+frozen = true
+pyopt = false
+
+[[test]]
+when = { emulator = false }
+storage-insecure-testing-mode = true
+disable-optiga = true
+disable-tropic = true
+
+
+[[dev]]
+when = { emulator = true }
+asan = true
+debug = true
+frozen = false
+pyopt = false
+source-lines = true
+
+[[dev]]
+when = { emulator = false, project = ["firmware", "prodtest"] }
+dbg-console = "swo"
+debug = true
+pyopt = false
+
+# Example of a model- and project-specific fragment:
+#
+# [[profile-id]]
+# when = { model = ["t3t1", "t3t2", "t3w1"], project = ["firmware", "kernel"] }
+# debug-link = true
diff --git a/core/embed/xtask/src/args.rs b/core/embed/xtask/src/args.rs
index 0e5e91dd..c5b6f6e4 100644
--- a/core/embed/xtask/src/args.rs
+++ b/core/embed/xtask/src/args.rs
@@ -5,7 +5,8 @@ use serde::Deserialize;
pub use crate::model::Model;
use crate::options::BuildOptions;
-#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
pub enum Project {
Bootloader,
Boardloader,
@@ -104,6 +105,7 @@ impl Project {
}
#[derive(ValueEnum, Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
pub enum ConsoleType {
Vcp,
Swo,
@@ -157,6 +159,10 @@ pub struct BuildArgs {
#[arg(long, short = 'e')]
pub emulator: bool,
+ /// Build preset
+ #[arg(long, short = 'p')]
+ pub preset: Option<String>,
+
#[command(flatten)]
pub options: BuildOptions,
}
diff --git a/core/embed/xtask/src/cargo.rs b/core/embed/xtask/src/cargo.rs
index 705fb29e..869d9b8d 100644
--- a/core/embed/xtask/src/cargo.rs
+++ b/core/embed/xtask/src/cargo.rs
@@ -8,7 +8,7 @@ use crate::options::ResolvedBuildArgs;
use crate::{artifacts, feature_resolver, helpers, memusage, postbuild, prebuild};
pub fn build(args: BuildArgs) -> Result<()> {
- let resolved_args = ResolvedBuildArgs::from_build_args(&args);
+ let resolved_args = ResolvedBuildArgs::from_build_args(&args)?;
build_impl(resolved_args.clone(), false)?;
@@ -25,12 +25,12 @@ pub fn build(args: BuildArgs) -> Result<()> {
}
pub fn clippy(args: BuildArgs) -> Result<()> {
- let resolved_args = ResolvedBuildArgs::from_build_args(&args);
+ let resolved_args = ResolvedBuildArgs::from_build_args(&args)?;
run_cargo_subcommand("clippy", &resolved_args)
}
pub fn check(args: BuildArgs) -> Result<()> {
- let resolved_args = ResolvedBuildArgs::from_build_args(&args);
+ let resolved_args = ResolvedBuildArgs::from_build_args(&args)?;
run_cargo_subcommand("check", &resolved_args)
}
diff --git a/core/embed/xtask/src/feature_resolver.rs b/core/embed/xtask/src/feature_resolver.rs
index bd67f3c2..a74ed6a2 100644
--- a/core/embed/xtask/src/feature_resolver.rs
+++ b/core/embed/xtask/src/feature_resolver.rs
@@ -18,6 +18,10 @@ pub fn resolve_features(args: &ResolvedBuildArgs) -> Result<ResolvedBuildFeature
if args.emulator {
features.push("emulator".into());
+
+ if args.asan {
+ features.push("asan".into());
+ }
}
if args.production {
@@ -32,23 +36,15 @@ pub fn resolve_features(args: &ResolvedBuildArgs) -> Result<ResolvedBuildFeature
features.push("force_bootloader_upgrade".into());
}
- if args.emulator {
- features.push("dbg_console".into());
-
- if args.asan {
- features.push("asan".into());
- }
- } else {
- match (args.project, args.dbg_console) {
- (Project::Firmware, Some(_)) => features.push("dbg_console".into()),
- (Project::Secmon, Some(ConsoleType::Vcp)) => (),
- (Project::Boardloader, Some(ConsoleType::Vcp)) => (),
- (Project::Prodtest, Some(ConsoleType::Vcp)) => (),
- (_, Some(ConsoleType::Vcp)) => features.push("dbg_console_vcp".into()),
- (_, Some(ConsoleType::Swo)) => features.push("dbg_console_swo".into()),
- (_, Some(ConsoleType::SystemView)) => features.push("dbg_console_system_view".into()),
- (_, None) => (),
- }
+ match (args.project, args.dbg_console) {
+ (Project::Firmware, Some(_)) => features.push("dbg_console".into()),
+ (Project::Secmon, Some(ConsoleType::Vcp)) => (),
+ (Project::Boardloader, Some(ConsoleType::Vcp)) => (),
+ (Project::Prodtest, Some(ConsoleType::Vcp)) => (),
+ (_, Some(ConsoleType::Vcp)) => features.push("dbg_console_vcp".into()),
+ (_, Some(ConsoleType::Swo)) => features.push("dbg_console_swo".into()),
+ (_, Some(ConsoleType::SystemView)) => features.push("dbg_console_system_view".into()),
+ (_, None) => (),
}
if args.project == Project::Firmware {
@@ -81,6 +77,10 @@ pub fn resolve_features(args: &ResolvedBuildArgs) -> Result<ResolvedBuildFeature
if args.n4w1 {
features.push("n4w1".into());
}
+
+ if args.frozen {
+ features.push("frozen".into());
+ }
}
if matches!(args.project, Project::Firmware | Project::Kernel) {
@@ -145,10 +145,6 @@ pub fn resolve_features(args: &ResolvedBuildArgs) -> Result<ResolvedBuildFeature
}
}
- if args.project == Project::Firmware && (args.frozen || !args.emulator) {
- features.push("frozen".into());
- }
-
// Board and model-intrinsic features from TOML config. The emulator emulates
// the same board it would build for on real hardware (`default_board`, or an
// explicit `--board`); only the configuration header differs.
diff --git a/core/embed/xtask/src/lib.rs b/core/embed/xtask/src/lib.rs
index 81177814..61d94067 100644
--- a/core/embed/xtask/src/lib.rs
+++ b/core/embed/xtask/src/lib.rs
@@ -11,4 +11,5 @@ pub mod model;
pub mod options;
pub mod postbuild;
pub mod prebuild;
+pub mod presets;
pub mod upload;
diff --git a/core/embed/xtask/src/model.rs b/core/embed/xtask/src/model.rs
index 2646be33..5bcce6bd 100644
--- a/core/embed/xtask/src/model.rs
+++ b/core/embed/xtask/src/model.rs
@@ -1,10 +1,12 @@
use anyhow::{Context, Result};
use clap::ValueEnum;
+use serde::Deserialize;
use crate::config::ModelConfig;
use crate::helpers;
-#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
pub enum Model {
#[value(name = "d001")]
D001,
diff --git a/core/embed/xtask/src/options.rs b/core/embed/xtask/src/options.rs
index 488bf18a..510ff466 100644
--- a/core/embed/xtask/src/options.rs
+++ b/core/embed/xtask/src/options.rs
@@ -1,10 +1,13 @@
+use anyhow::Result;
use clap::Args;
use serde::Deserialize;
use crate::args::{BuildArgs, ConsoleType, Model, Project};
+use crate::presets;
#[derive(Args, Deserialize, Debug, Clone, Default)]
#[serde(deny_unknown_fields)]
+#[serde(rename_all = "kebab-case")]
pub struct BuildOptions {
/// Enable debug build
#[arg(long, short = 'd', num_args = 0..=1, default_missing_value = "true")]
@@ -119,15 +122,6 @@ pub struct BuildOptions {
}
impl BuildOptions {
- pub fn defaults_for(_project: Project, _model: Model, emulator: bool) -> Self {
- Self {
- debug: Some(emulator),
- pyopt: Some(true),
- source_lines: Some(emulator),
- ..BuildOptions::default()
- }
- }
-
pub fn overlay(self, opt: Self) -> Self {
Self {
debug: opt.debug.or(self.debug),
@@ -209,12 +203,11 @@ pub struct ResolvedBuildArgs {
}
impl ResolvedBuildArgs {
- pub fn from_build_args(args: &BuildArgs) -> Self {
- let o = BuildOptions::defaults_for(args.project, args.model, args.emulator)
- .overlay(args.options.clone())
- .postfix();
+ pub fn from_build_args(args: &BuildArgs) -> Result<Self> {
+ let preset_options = presets::resolve(args)?;
+ let o = preset_options.overlay(args.options.clone()).postfix();
- Self {
+ Ok(Self {
project: args.project,
model: args.model,
emulator: args.emulator,
@@ -245,7 +238,7 @@ impl ResolvedBuildArgs {
emit_memory_analysis: o.emit_memory_analysis.unwrap_or_default(),
timings: o.timings.unwrap_or_default(),
verbose: o.verbose.unwrap_or_default(),
- }
+ })
}
/// Determines the Cargo profile to use
diff --git a/core/embed/xtask/src/presets.rs b/core/embed/xtask/src/presets.rs
new file mode 100644
index 00000000..56031060
--- /dev/null
+++ b/core/embed/xtask/src/presets.rs
@@ -0,0 +1,301 @@
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result, bail};
+use serde::Deserialize;
+
+use crate::args::{BuildArgs, Model, Project};
+use crate::helpers;
+use crate::options::BuildOptions;
+
+#[derive(Debug, Clone, Deserialize, Default)]
+#[serde(deny_unknown_fields)]
+pub struct PresetFilter {
+ pub model: Option<Vec<Model>>,
+ pub project: Option<Vec<Project>>,
+ pub emulator: Option<bool>,
+}
+
+impl PresetFilter {
+ fn matches(&self, args: &BuildArgs) -> bool {
+ self.model
+ .as_ref()
+ .is_none_or(|models| models.contains(&args.model))
+ && self
+ .project
+ .as_ref()
+ .is_none_or(|projects| projects.contains(&args.project))
+ && self
+ .emulator
+ .is_none_or(|emulator| emulator == args.emulator)
+ }
+}
+
+#[derive(Deserialize, Debug, Clone, Default)]
+#[serde(deny_unknown_fields)]
+pub struct Preset {
+ #[serde(rename = "when", default)]
+ pub filter: PresetFilter,
+
+ #[serde(flatten)]
+ pub options: BuildOptions,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(transparent)]
+pub struct PresetsFile {
+ pub presets: BTreeMap<String, Vec<Preset>>,
+}
+
+impl PresetsFile {
+ fn load(path: &Path) -> Result<Self> {
+ let content = fs::read_to_string(&path)
+ .with_context(|| format!("Failed to read build presets: {}", path.display()))?;
+ toml::from_str(&content)
+ .with_context(|| format!("Failed to parse build presets: {}", path.display()))
+ }
+
+ fn load_optional(path: &Path) -> Result<Self> {
+ match Self::load(path) {
+ Ok(presets) => Ok(presets),
+ Err(error)
+ if error
+ .downcast_ref::<std::io::Error>()
+ .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
+ {
+ Ok(Self::default())
+ }
+ Err(error) => Err(error),
+ }
+ }
+
+ fn resolve(&self, name: &str, args: &BuildArgs) -> Option<BuildOptions> {
+ let presets = self.presets.get(name)?;
+
+ let matching: Vec<&Preset> = presets
+ .iter()
+ .filter(|preset| preset.filter.matches(args))
+ .collect();
+
+ if matching.is_empty() {
+ return None;
+ }
+
+ Some(
+ matching
+ .into_iter()
+ .fold(BuildOptions::default(), |options, preset| {
+ options.overlay(preset.options.clone())
+ }),
+ )
+ }
+}
+
+pub fn resolve(args: &BuildArgs) -> Result<BuildOptions> {
+ let presets_dir = helpers::workspace_dir()?.join("xtask");
+ let shared = PresetsFile::load(&presets_dir.join("presets.toml"))?;
+ let user = PresetsFile::load_optional(&presets_dir.join("user-presets.toml"))?;
+
+ resolve_sources(&shared, &user, args)
+}
+
+fn resolve_sources(
+ shared: &PresetsFile,
+ user: &PresetsFile,
+ args: &BuildArgs,
+) -> Result<BuildOptions> {
+ let defaults = shared
+ .resolve("defaults", args)
+ .unwrap_or_default()
+ .overlay(user.resolve("defaults", args).unwrap_or_default());
+
+ let Some(name) = args.preset.as_deref() else {
+ return Ok(defaults);
+ };
+
+ let shared_options = shared.resolve(name, args);
+ let user_options = user.resolve(name, args);
+
+ match (shared_options, user_options) {
+ (None, None) if !shared.presets.contains_key(name) && !user.presets.contains_key(name) => {
+ bail!("Unknown build preset '{name}'")
+ }
+ (None, None) => bail!("Build preset '{name}' has no entries matching this build"),
+ (Some(options), None) | (None, Some(options)) => Ok(defaults.overlay(options)),
+ (Some(shared), Some(user)) => Ok(defaults.overlay(shared.overlay(user))),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args() -> BuildArgs {
+ BuildArgs {
+ project: Project::Firmware,
+ model: Model::T3W1,
+ emulator: true,
+ preset: Some("test".to_string()),
+ options: BuildOptions::default(),
+ }
+ }
+
+ #[test]
+ fn resolves_matching_presets_in_file_order() {
+ let presets: PresetsFile = toml::from_str(
+ r#"
+ [[test]]
+ when = { model = ["t3w1"], project = ["firmware"], emulator = true }
+ pyopt = false
+ frozen = true
+
+ [[test]]
+ when = { emulator = true }
+ pyopt = true
+ debug = true
+ debug-link = false
+ "#,
+ )
+ .unwrap();
+
+ let options = presets.resolve("test", &args()).unwrap();
+ assert_eq!(options.pyopt, Some(true));
+ assert_eq!(options.debug, Some(true));
+ assert_eq!(options.debug_link, Some(false));
+ assert_eq!(options.frozen, Some(true));
+ }
+
+ #[test]
+ fn rejects_unknown_preset() {
+ let presets = PresetsFile::default();
+ assert!(resolve_sources(&presets, &presets, &args()).is_err());
+ }
+
+ #[test]
+ fn explicit_options_override_preset_options() {
+ let presets: PresetsFile = toml::from_str(
+ r#"
+ [[test]]
+ pyopt = false
+ "#,
+ )
+ .unwrap();
+ let cli_options = BuildOptions {
+ pyopt: Some(true),
+ ..BuildOptions::default()
+ };
+
+ let options = presets
+ .resolve("test", &args())
+ .unwrap()
+ .overlay(cli_options);
+ assert_eq!(options.pyopt, Some(true));
+ }
+
+ #[test]
+ fn user_presets_override_shared_presets() {
+ let shared: PresetsFile = toml::from_str(
+ r#"
+ [[test]]
+ pyopt = false
+ "#,
+ )
+ .unwrap();
+ let user: PresetsFile = toml::from_str(
+ r#"
+ [[test]]
+ pyopt = true
+ timings = true
+ "#,
+ )
+ .unwrap();
+
+ let options = resolve_sources(&shared, &user, &args()).unwrap();
+ assert_eq!(options.pyopt, Some(true));
+ assert_eq!(options.timings, Some(true));
+ }
+
+ #[test]
+ fn user_presets_can_define_local_only_presets() {
+ let user: PresetsFile = toml::from_str(
+ r#"
+ [[local]]
+ benchmark = true
+ "#,
+ )
+ .unwrap();
+
+ let mut build_args = args();
+ build_args.preset = Some("local".to_string());
+
+ let options = resolve_sources(&PresetsFile::default(), &user, &build_args).unwrap();
+ assert_eq!(options.benchmark, Some(true));
+ }
+
+ #[test]
+ fn missing_optional_presets_file_is_empty() {
+ let directory = tempfile::tempdir().unwrap();
+ let presets = PresetsFile::load_optional(&directory.path().join("missing.toml")).unwrap();
+ assert!(presets.presets.is_empty());
+ }
+
+ #[test]
+ fn applies_defaults_without_an_explicit_preset() {
+ let presets: PresetsFile = toml::from_str(
+ r#"
+ [[defaults]]
+ when = { emulator = true }
+ debug = true
+ pyopt = false
+ "#,
+ )
+ .unwrap();
+ let mut build_args = args();
+ build_args.preset = None;
+
+ let options = resolve_sources(&presets, &PresetsFile::default(), &build_args).unwrap();
+ assert_eq!(options.debug, Some(true));
+ assert_eq!(options.pyopt, Some(false));
+ }
+
+ #[test]
+ fn named_presets_override_defaults() {
+ let presets: PresetsFile = toml::from_str(
+ r#"
+ [[defaults]]
+ pyopt = true
+
+ [[test]]
+ pyopt = false
+ "#,
+ )
+ .unwrap();
+
+ let options = resolve_sources(&presets, &PresetsFile::default(), &args()).unwrap();
+ assert_eq!(options.pyopt, Some(false));
+ }
+
+ #[test]
+ fn user_defaults_override_shared_defaults() {
+ let shared: PresetsFile = toml::from_str(
+ r#"
+ [[defaults]]
+ pyopt = true
+ "#,
+ )
+ .unwrap();
+ let user: PresetsFile = toml::from_str(
+ r#"
+ [[defaults]]
+ pyopt = false
+ "#,
+ )
+ .unwrap();
+ let mut build_args = args();
+ build_args.preset = None;
+
+ let options = resolve_sources(&shared, &user, &build_args).unwrap();
+ assert_eq!(options.pyopt, Some(false));
+ }
+}
Why this scored 15/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.