Skip to main content

consortium_builder/pipeline/
check.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! The fast feedback loop: validate the manifest and type-check every Rust
18//! endpoint, the way `cargo check` does for one crate.
19//!
20//! A check runs the same front half as [`run`](super::run) — load and validate the
21//! manifest, lower it, render and write each endpoint's `consortium.gen.rs` (and
22//! each firmware's `memory.x`) — then runs `cargo check` instead of `cargo build`
23//! in each Cargo project, with the same target, flags, and injected
24//! `CONSORTIUM_*` environment. Nothing is linked, post-processed, signed, or
25//! staged: no objcopy, no HMI frontend build, no `dist/`, no `dtc`, no BitBake.
26//!
27//! Two differences from a build are deliberate:
28//!
29//! - **The AP's embedded debug ELFs.** The AP init module `include_bytes!`es each
30//!   `[dbg.<core>]` firmware's `*_original.elf`, and `include_bytes!` is resolved
31//!   during `cargo check` too — but checking the firmware produces no ELF. When a
32//!   previous build left the real image on disk it is embedded as usual; otherwise
33//!   an empty stand-in is written next to where it would be (see
34//!   [`placeholder_elf_path`]) so the application still type-checks. The generated
35//!   module then points at that stand-in until the next `csti build` rewrites it.
36//! - **Host provisioning is optional.** The device tree is rendered (not written or
37//!   compiled) only when a `--linux` checkout is supplied, and the TA is checked
38//!   only when an OP-TEE dev kit is available: a TA build script needs
39//!   `TA_DEV_KIT_DIR`, but a laptop iterating on firmware usually has none. The
40//!   plan records the skip so the caller can say so.
41
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44
45use consortium_cfg::Session;
46use consortium_cfg_common::config::VisibleCore;
47
48use super::application::{ApplicationBuildPlan, plan_application_build};
49use super::firmware::{FirmwareBuildPlan, firmware_elf_path, plan_firmware_builds};
50use super::tee::{TeeToolchain, plan_ta_build};
51use super::{
52    CargoOptions, PipelineError, StepRunner, dts, elf_map, load_session, resolve_ty_crate,
53    write_generated_memory_x, write_generated_module,
54};
55use crate::compile::application::ApplicationCompiler;
56use crate::compile::firmware::{FirmwareCompiler, original_elf_path};
57use crate::compile::optee::{OpteeCompiler, OpteeConfiguration};
58use crate::progress::BuildReporter;
59
60/// All parameters for a check run.
61pub struct CheckConfig {
62    /// Path to the `Consortium.toml` manifest.
63    pub manifest_path: PathBuf,
64    /// Linux kernel checkout to render the device tree against, or `None` to skip
65    /// device-tree validation.
66    pub linux: Option<PathBuf>,
67    /// Check the release profile.
68    pub release: bool,
69    /// Pass `--offline` to every cargo invocation.
70    pub offline: bool,
71    /// OP-TEE toolchain, or `None` to skip the TA check.
72    pub tee_toolchain: Option<TeeToolchain>,
73    /// Treat a missing remoteproc DTS node as an error (see
74    /// [`PipelineConfig::strict_remoteproc`](super::PipelineConfig::strict_remoteproc)).
75    pub strict_remoteproc: bool,
76}
77
78impl CheckConfig {
79    /// The cargo flags every Rust check of this run shares.
80    pub fn cargo(&self) -> CargoOptions {
81        CargoOptions {
82            release: self.release,
83            offline: self.offline,
84        }
85    }
86}
87
88/// Why the TA is not part of a check.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum TaSkip {
91    /// The manifest declares `[tee]`, but no OP-TEE toolchain was supplied.
92    NoToolchain,
93}
94
95/// The fully resolved plan for a check run.
96pub struct CheckPlan {
97    /// One plan per remote coprocessor firmware, checked in order.
98    pub firmware: Vec<FirmwareBuildPlan>,
99    /// The TA to check, when the manifest declares `[tee]` and a toolchain exists.
100    pub ta: Option<OpteeConfiguration>,
101    /// Set when the manifest declares `[tee]` but the TA will not be checked.
102    pub ta_skipped: Option<TaSkip>,
103    /// The application check parameters and its generated AP init module.
104    pub application: ApplicationBuildPlan,
105    /// Debug ELFs with no image on disk yet: empty stand-ins written before the
106    /// application is checked, so its `include_bytes!` resolves.
107    pub placeholder_elfs: Vec<PathBuf>,
108    /// File names of the device trees rendered for validation. Empty when no
109    /// `--linux` checkout was supplied or the manifest needs no device tree.
110    pub dts_files: Vec<String>,
111}
112
113/// The empty stand-in written for a debug firmware ELF that has not been built.
114///
115/// Lives beside the raw cargo output (`.../<profile>/<name>_check.elf`), so it
116/// shares the firmware's `target/` lifetime and never shadows the real
117/// `*_original.elf` a later build produces.
118pub fn placeholder_elf_path(raw: &Path) -> PathBuf {
119    let stem = raw
120        .file_name()
121        .and_then(|n| n.to_str())
122        .unwrap_or("firmware");
123    raw.with_file_name(format!("{stem}_check.elf"))
124}
125
126/// Resolves everything a check needs without running cargo.
127///
128/// Fails exactly where [`plan`](super::plan) would for the same manifest, except
129/// that the device tree is rendered only when [`CheckConfig::linux`] is set and a
130/// missing OP-TEE toolchain skips the TA instead of failing.
131pub async fn plan_check(
132    session: &Session,
133    config: &CheckConfig,
134) -> Result<CheckPlan, PipelineError> {
135    let manifest_dir = config
136        .manifest_path
137        .parent()
138        .map(Path::to_path_buf)
139        .unwrap_or_else(|| PathBuf::from("."));
140    let ty_crate = resolve_ty_crate(session, &manifest_dir).await?;
141    let cargo = config.cargo();
142    let workspace = super::resolve_workspace(session, &manifest_dir)?;
143
144    let firmware = plan_firmware_builds(session, &workspace, cargo, &ty_crate)?;
145
146    // The AP embeds each debug core's original ELF: the real one when a previous
147    // build left it on disk, an empty stand-in otherwise.
148    let mut dbg_sources: HashMap<VisibleCore, PathBuf> = HashMap::new();
149    for plan in &firmware {
150        let raw = firmware_elf_path(&plan.config).await?;
151        let original = original_elf_path(&raw);
152        let source = if original.exists() {
153            original
154        } else {
155            placeholder_elf_path(&raw)
156        };
157        dbg_sources.insert(plan.core, source);
158    }
159    let ir = session.lower()?;
160    let dbg_elfs = elf_map::resolve_dbg_elf_paths(&dbg_sources, ir.dbg.rings().keys().copied())?;
161    let placeholder_elfs = dbg_elfs.values().filter(|p| !p.exists()).cloned().collect();
162
163    let (ta, ta_skipped) = match (&session.config.tee, &config.tee_toolchain) {
164        (Some(_), None) => (None, Some(TaSkip::NoToolchain)),
165        _ => (
166            plan_ta_build(
167                session,
168                &workspace,
169                config.tee_toolchain.as_ref(),
170                cargo,
171                false,
172            )?,
173            None,
174        ),
175    };
176
177    // Inject the same HMI environment a build would. The on-target serve path is
178    // derived from `deploy_root`, so the frontend need not have been built.
179    let hmi_dist = session
180        .config
181        .hmi
182        .as_ref()
183        .map(|hmi| manifest_dir.join(&hmi.build));
184    let application = plan_application_build(
185        session,
186        &workspace,
187        hmi_dist.as_deref(),
188        &dbg_elfs,
189        cargo,
190        &ty_crate,
191    )?;
192
193    let mut dts_files = Vec::new();
194    if let Some(linux) = &config.linux {
195        let profile = &session.config.profile;
196        let target_cores: Vec<String> = firmware.iter().map(|p| p.core.to_string()).collect();
197        if let Some(rendered) = dts::render_linux_dts(
198            &profile.chip,
199            profile.devkit.as_deref(),
200            session.config.dts.base.as_deref(),
201            session.config.dts.mode,
202            &ir,
203            &target_cores,
204            linux,
205            config.strict_remoteproc,
206        )? {
207            for warning in &rendered.warnings {
208                eprintln!("warning: {warning}");
209            }
210            dts_files.push(rendered.file_name);
211        }
212    }
213
214    Ok(CheckPlan {
215        firmware,
216        ta,
217        ta_skipped,
218        application,
219        placeholder_elfs,
220        dts_files,
221    })
222}
223
224/// Plans and runs a check, returning the plan that was checked.
225pub async fn run(config: CheckConfig) -> Result<CheckPlan, PipelineError> {
226    run_inner(config, None).await
227}
228
229/// Runs a check while reporting step progress and subprocess output.
230pub async fn run_with_reporter(
231    config: CheckConfig,
232    reporter: &dyn BuildReporter,
233) -> Result<CheckPlan, PipelineError> {
234    run_inner(config, Some(reporter)).await
235}
236
237async fn run_inner(
238    config: CheckConfig,
239    reporter: Option<&dyn BuildReporter>,
240) -> Result<CheckPlan, PipelineError> {
241    consortium_log::trace!("check run: manifest {}", config.manifest_path.display());
242    let session = load_session(&config.manifest_path).await?;
243    let plan = plan_check(&session, &config).await?;
244
245    let total = plan.firmware.len() + usize::from(plan.ta.is_some()) + 1;
246    let mut steps = StepRunner::new(reporter, total);
247
248    for fw in &plan.firmware {
249        let detail = format!("{} ({})", fw.core, fw.config.target);
250        steps
251            .run("Check firmware", Some(&detail), async {
252                write_generated_module(&fw.config.path, &fw.generated).await?;
253                write_generated_memory_x(&fw.config).await?;
254                FirmwareCompiler::check_with_reporter(&fw.config, reporter).await?;
255                Ok(())
256            })
257            .await?;
258    }
259
260    if let Some(ta) = &plan.ta {
261        steps
262            .run("Check TEE", Some("trusted application"), async {
263                OpteeCompiler::check_with_reporter(ta, reporter).await?;
264                Ok(())
265            })
266            .await?;
267    }
268
269    let app_detail = plan.application.config.target.clone();
270    steps
271        .run("Check application", Some(&app_detail), async {
272            for path in &plan.placeholder_elfs {
273                write_placeholder_elf(path).await?;
274            }
275            write_generated_module(&plan.application.config.path, &plan.application.generated)
276                .await?;
277            ApplicationCompiler::check_with_reporter(&plan.application.config, reporter).await?;
278            Ok(())
279        })
280        .await?;
281
282    Ok(plan)
283}
284
285/// Writes an empty stand-in ELF, creating its directory if the firmware has never
286/// been built for this profile.
287async fn write_placeholder_elf(path: &Path) -> Result<(), PipelineError> {
288    let io_error = |source| PipelineError::GeneratedWrite {
289        path: path.display().to_string(),
290        source,
291    };
292    if let Some(parent) = path.parent() {
293        tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
294    }
295    tokio::fs::write(path, []).await.map_err(io_error)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
303
304    /// A project tree with per-endpoint `Cargo.toml`s and no sources, enough for
305    /// [`plan_check`] to resolve crate names and artifact paths.
306    fn fabricate_project(tag: &str) -> PathBuf {
307        let root =
308            std::env::temp_dir().join(format!("consortium-check-{tag}-{}", std::process::id()));
309        let _ = std::fs::remove_dir_all(&root);
310        std::fs::create_dir_all(&root).unwrap();
311        std::fs::write(root.join("Consortium.toml"), STM32).unwrap();
312        for (dir, pkg) in [
313            ("app", "melt-pot-app"),
314            ("ta", "melt-pot-ta"),
315            ("firmware", "melt-pot-mcu"),
316            ("shared", "melt-pot-shared"),
317        ] {
318            let crate_dir = root.join(dir);
319            std::fs::create_dir_all(&crate_dir).unwrap();
320            std::fs::write(
321                crate_dir.join("Cargo.toml"),
322                format!("[package]\nname = \"{pkg}\"\nversion = \"0.0.0\"\n"),
323            )
324            .unwrap();
325        }
326        root
327    }
328
329    fn config(root: &Path, tee_toolchain: Option<TeeToolchain>) -> CheckConfig {
330        CheckConfig {
331            manifest_path: root.join("Consortium.toml"),
332            linux: None,
333            release: false,
334            offline: true,
335            tee_toolchain,
336            strict_remoteproc: true,
337        }
338    }
339
340    fn raw_mcu_elf(root: &Path) -> PathBuf {
341        root.join("./firmware/target/thumbv8m.main-none-eabihf/debug/melt-pot-mcu")
342    }
343
344    #[tokio::test]
345    async fn unbuilt_debug_firmware_gets_a_placeholder() {
346        let root = fabricate_project("placeholder");
347        let session = Session::from_toml(STM32).unwrap();
348
349        let plan = plan_check(&session, &config(&root, None))
350            .await
351            .expect("check plans without --linux or a TEE toolchain");
352
353        assert_eq!(plan.firmware.len(), 1);
354        assert!(plan.firmware[0].config.offline);
355        // No DTS without a Linux checkout.
356        assert!(plan.dts_files.is_empty());
357        // `[tee]` is declared but no toolchain was given: skipped, not an error.
358        assert!(plan.ta.is_none());
359        assert_eq!(plan.ta_skipped, Some(TaSkip::NoToolchain));
360
361        // cm33 declares `[dbg.cm33]` and has never been built, so the AP embeds
362        // an empty stand-in beside the raw cargo output, named relative to the
363        // firmware crate through `CSTI_MCU_PATH`.
364        let placeholder = std::path::absolute(placeholder_elf_path(&raw_mcu_elf(&root))).unwrap();
365        assert_eq!(plan.placeholder_elfs, vec![placeholder.clone()]);
366        let generated = &plan.application.generated;
367        assert!(
368            generated.contains("::core::env!(\"CSTI_MCU_PATH\")"),
369            "{generated}"
370        );
371        assert!(
372            generated
373                .contains("\"/target/thumbv8m.main-none-eabihf/debug/melt-pot-mcu_check.elf\"")
374        );
375        assert!(!generated.contains(&root.display().to_string()));
376
377        let _ = std::fs::remove_dir_all(&root);
378    }
379
380    #[tokio::test]
381    async fn built_debug_firmware_is_embedded_as_is() {
382        let root = fabricate_project("built");
383        let original = original_elf_path(&raw_mcu_elf(&root));
384        std::fs::create_dir_all(original.parent().unwrap()).unwrap();
385        std::fs::write(&original, b"\x7fELF").unwrap();
386        let session = Session::from_toml(STM32).unwrap();
387        let toolchain = TeeToolchain {
388            ta_dev_kit_dir: PathBuf::from("/opt/optee/export-ta_arm64"),
389            optee_signer: PathBuf::from("/opt/optee/scripts/sign_encrypt.py"),
390            linker: None,
391        };
392
393        let plan = plan_check(&session, &config(&root, Some(toolchain)))
394            .await
395            .expect("check plans");
396
397        assert!(plan.placeholder_elfs.is_empty());
398        assert!(
399            plan.application
400                .generated
401                .contains("\"/target/thumbv8m.main-none-eabihf/debug/melt-pot-mcu_original.elf\"")
402        );
403        // With a toolchain the TA is checked, and a check never signs.
404        let ta = plan.ta.expect("TA planned");
405        assert!(!ta.sign);
406        assert!(plan.ta_skipped.is_none());
407
408        let _ = std::fs::remove_dir_all(&root);
409    }
410}