Skip to main content

consortium_builder/pipeline/
fetch.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 pre-build download stage: everything [`run`](super::run) would reach the
18//! network for, done up front.
19//!
20//! A Consortium build spans several independent Cargo projects — one firmware
21//! crate per remote coprocessor, the TA, the Linux application — plus a frontend
22//! project with its own package manager. Each of those pulls from a different
23//! place, which is what makes an air-gapped or reproducible build awkward: there
24//! is no single `cargo fetch` that covers the system.
25//!
26//! This stage resolves the same project set the build plan does and fetches each
27//! one, so a later `build --offline` finds every input in the local caches:
28//!
29//! 1. `rustup target add` for each distinct triple (a cross build needs the
30//!    target's prebuilt `std`, which `cargo fetch` does not provide);
31//! 2. `cargo fetch --target <triple>` per Cargo project; and
32//! 3. the frontend's package install (`vp`/`npm`/`bun install`).
33//!
34//! Deliberately *not* resolved here: the OP-TEE dev kit, a Yocto sysroot, kernel
35//! sources for `dtc`, and BitBake's own downloads. Those are host provisioning
36//! rather than project dependencies, and each has its own vendor-specific
37//! acquisition path.
38//!
39//! Unlike [`plan`](super::plan) this needs no code generation and no OP-TEE
40//! toolchain: fetching reads Cargo manifests only, so it works on a machine that
41//! could not yet complete a build.
42
43use std::path::{Path, PathBuf};
44
45use consortium_cfg::Session;
46use consortium_cfg_common::config::Party;
47use tokio::process::Command;
48
49use super::application::acore_linux_triple;
50use super::firmware::remote_coprocessors;
51use super::{PipelineError, core_rust_triple, visible_core_of};
52use crate::compile::optee::OPTEE_TARGET;
53use crate::compile::utils::run_command;
54use crate::compile::web::HmiBuildCommand;
55use crate::progress::BuildReporter;
56
57/// One Cargo project to fetch dependencies for.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct CargoFetch {
60    /// What this project builds, for progress output (a core name, `ta`, `app`).
61    pub label: String,
62    /// The Cargo project root.
63    pub path: PathBuf,
64    /// The Rust target triple the fetch is scoped to.
65    pub target: String,
66}
67
68/// The HMI frontend's dependency install.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct HmiFetch {
71    /// The frontend project root (the manifest's `[hmi].source`, absolute).
72    pub project_dir: PathBuf,
73    /// The package manager command that installs its dependencies.
74    pub command: HmiBuildCommand,
75}
76
77/// Every download a build of this manifest needs, resolved without running any.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct FetchPlan {
80    /// One entry per Cargo project, in build order.
81    pub cargo: Vec<CargoFetch>,
82    /// The frontend install, when the manifest declares `[hmi]` and it was not
83    /// skipped by the caller.
84    pub hmi: Option<HmiFetch>,
85}
86
87impl FetchPlan {
88    /// The distinct target triples the build compiles for, in first-use order.
89    ///
90    /// Deduplicated because several projects can share a triple — the TA and the
91    /// Linux application both target `aarch64-unknown-linux-gnu` — and installing
92    /// one target's `std` twice is pure latency.
93    pub fn rust_targets(&self) -> Vec<String> {
94        let mut targets: Vec<String> = Vec::new();
95        for entry in &self.cargo {
96            if !targets.contains(&entry.target) {
97                targets.push(entry.target.clone());
98            }
99        }
100        targets
101    }
102}
103
104/// All parameters for a fetch run.
105pub struct FetchConfig {
106    /// Path to the `Consortium.toml` manifest.
107    pub manifest_path: PathBuf,
108    /// The frontend install, or `None` to skip it. Derive with
109    /// [`derive_hmi_fetch`].
110    pub hmi: Option<HmiFetch>,
111    /// Install each target's Rust standard library with `rustup target add`.
112    /// A host that manages its own toolchain (no rustup, a vendored `std`) can
113    /// turn this off; a missing `rustup` is a skipped step, not a failure.
114    pub rust_targets: bool,
115}
116
117/// Resolves every Cargo project a build of `session` would compile.
118///
119/// Mirrors the build plan's project set — one firmware crate per remote
120/// coprocessor with an endpoint, the TA when `[tee]` is declared, and the Linux
121/// application — but resolves only paths and triples.
122pub fn plan_fetch(
123    session: &Session,
124    manifest_dir: &Path,
125    hmi: Option<HmiFetch>,
126) -> Result<FetchPlan, PipelineError> {
127    let mut cargo = Vec::new();
128
129    // Firmware, one crate per remote coprocessor that declares an endpoint.
130    for cp in remote_coprocessors(&session.database) {
131        let core = visible_core_of(cp)?;
132        let Some((_, root)) = session
133            .config
134            .endpoints
135            .iter()
136            .find(|(party, _)| party.core() == core)
137        else {
138            continue;
139        };
140        cargo.push(CargoFetch {
141            label: core.to_string(),
142            path: manifest_dir.join(root),
143            target: core_rust_triple(core),
144        });
145    }
146
147    // The TA. Unlike `tee::plan_ta_build` this needs no `TeeToolchain`: the dev
148    // kit is a build and signing input, not a dependency-resolution one, so a
149    // host can populate the cache before it has an OP-TEE SDK.
150    if session.config.tee.is_some() {
151        let root = session
152            .config
153            .endpoints
154            .get(&Party::AcoreSecure)
155            .ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreSecure.to_string()))?;
156        cargo.push(CargoFetch {
157            label: "ta".to_string(),
158            path: manifest_dir.join(root),
159            target: OPTEE_TARGET.to_string(),
160        });
161    }
162
163    // The Linux application.
164    let root = session
165        .config
166        .endpoints
167        .get(&Party::AcoreNonSecure)
168        .ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreNonSecure.to_string()))?;
169    cargo.push(CargoFetch {
170        label: "app".to_string(),
171        path: manifest_dir.join(root),
172        target: acore_linux_triple(&session.database.ap)?,
173    });
174
175    Ok(FetchPlan { cargo, hmi })
176}
177
178/// Derives the HMI dependency install from the manifest's `[hmi]` section.
179///
180/// Returns `None` when the manifest declares no frontend. The installer is
181/// resolved per engine (`vp`/`npm install` for Cog, `bun install` for Pocket) and
182/// is overridable with `CONSORTIUM_HMI_FETCH_CMD`.
183pub fn derive_hmi_fetch(
184    session: &Session,
185    manifest_dir: &Path,
186) -> Result<Option<HmiFetch>, PipelineError> {
187    use consortium_cfg_common::config::HmiEngine;
188
189    let Some(hmi) = &session.config.hmi else {
190        return Ok(None);
191    };
192    hmi.validate().map_err(PipelineError::Codegen)?;
193
194    let command = match hmi.engine {
195        HmiEngine::Cog => crate::compile::utils::resolve_hmi_fetch_command(),
196        HmiEngine::Pocket => crate::compile::utils::resolve_pocket_fetch_command(),
197    }
198    .map_err(PipelineError::Codegen)?;
199
200    Ok(Some(HmiFetch {
201        project_dir: manifest_dir.join(&hmi.source),
202        command,
203    }))
204}
205
206/// Loads the manifest, plans the fetch, and runs it.
207pub async fn run(config: FetchConfig) -> Result<FetchPlan, PipelineError> {
208    run_inner(config, None).await
209}
210
211/// Runs the fetch while reporting step progress and subprocess output.
212pub async fn run_with_reporter(
213    config: FetchConfig,
214    reporter: &dyn BuildReporter,
215) -> Result<FetchPlan, PipelineError> {
216    run_inner(config, Some(reporter)).await
217}
218
219async fn run_inner(
220    config: FetchConfig,
221    reporter: Option<&dyn BuildReporter>,
222) -> Result<FetchPlan, PipelineError> {
223    let session = super::load_session(&config.manifest_path).await?;
224    let manifest_dir = config
225        .manifest_path
226        .parent()
227        .map(Path::to_path_buf)
228        .unwrap_or_else(|| PathBuf::from("."));
229
230    let plan = plan_fetch(&session, &manifest_dir, config.hmi)?;
231    let targets = plan.rust_targets();
232    let install_targets = config.rust_targets && !targets.is_empty();
233
234    let total = usize::from(install_targets) + plan.cargo.len() + usize::from(plan.hmi.is_some());
235    let mut steps = super::StepRunner::new(reporter, total);
236
237    if install_targets {
238        let detail = targets.join(" ");
239        steps
240            .run("Install Rust targets", Some(&detail), async {
241                install_rust_targets(&targets, reporter).await
242            })
243            .await?;
244    }
245
246    for entry in &plan.cargo {
247        let detail = format!("{} ({})", entry.label, entry.target);
248        steps
249            .run("Fetch dependencies", Some(&detail), async {
250                cargo_fetch(entry, reporter).await
251            })
252            .await?;
253    }
254
255    if let Some(hmi) = &plan.hmi {
256        let detail = hmi.project_dir.display().to_string();
257        steps
258            .run("Install HMI dependencies", Some(&detail), async {
259                hmi_install(hmi, reporter).await
260            })
261            .await?;
262    }
263
264    Ok(plan)
265}
266
267/// Downloads one Cargo project's dependencies into the local registry cache.
268///
269/// Scoped with `--target` so a bare-metal firmware crate does not drag in a host
270/// target's dependency set. No feature selection is needed: `cargo fetch` works
271/// from the lock file, which carries optional dependencies whether or not their
272/// feature is active.
273async fn cargo_fetch(
274    entry: &CargoFetch,
275    reporter: Option<&dyn BuildReporter>,
276) -> Result<(), PipelineError> {
277    let cargo = crate::compile::utils::resolve_cargo()
278        .await
279        .map_err(|message| PipelineError::Fetch {
280            label: entry.label.clone(),
281            target: entry.target.clone(),
282            message,
283        })?;
284
285    let mut cmd = Command::new(&cargo);
286    cmd.arg("fetch")
287        .arg("--target")
288        .arg(&entry.target)
289        .current_dir(&entry.path);
290
291    let status = run_command(&mut cmd, reporter)
292        .await
293        .map_err(|e| PipelineError::Fetch {
294            label: entry.label.clone(),
295            target: entry.target.clone(),
296            message: e.to_string(),
297        })?;
298
299    if !status.success() {
300        return Err(PipelineError::Fetch {
301            label: entry.label.clone(),
302            target: entry.target.clone(),
303            message: format!("`cargo fetch` exited with status: {status}"),
304        });
305    }
306    Ok(())
307}
308
309/// Installs the prebuilt standard library for each triple with rustup.
310///
311/// A host without rustup manages its own toolchain (a Yocto SDK, a distro
312/// rustc), so a missing binary is reported and skipped rather than failing the
313/// run — the subsequent `cargo fetch` steps are still worth doing. A rustup that
314/// *is* present and fails is a real error.
315async fn install_rust_targets(
316    targets: &[String],
317    reporter: Option<&dyn BuildReporter>,
318) -> Result<(), PipelineError> {
319    let mut cmd = Command::new("rustup");
320    cmd.args(["target", "add", "--toolchain", "stable"])
321        .args(targets);
322
323    let status = match run_command(&mut cmd, reporter).await {
324        Ok(status) => status,
325        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
326            eprintln!(
327                "note: rustup not found; skipping `rustup target add {}`. Ensure the \
328                 standard library for these targets is installed by other means.",
329                targets.join(" ")
330            );
331            return Ok(());
332        }
333        Err(e) => {
334            return Err(PipelineError::RustTarget {
335                targets: targets.join(" "),
336                message: e.to_string(),
337            });
338        }
339    };
340
341    if !status.success() {
342        return Err(PipelineError::RustTarget {
343            targets: targets.join(" "),
344            message: format!("`rustup target add` exited with status: {status}"),
345        });
346    }
347    Ok(())
348}
349
350/// Runs the frontend's package install in the frontend project directory.
351async fn hmi_install(
352    hmi: &HmiFetch,
353    reporter: Option<&dyn BuildReporter>,
354) -> Result<(), PipelineError> {
355    let dir = hmi.project_dir.display().to_string();
356    let (program, args) = hmi.command.program_args();
357    if program.is_empty() {
358        return Err(PipelineError::HmiFetch {
359            dir,
360            message: "HMI install command is empty".to_string(),
361        });
362    }
363
364    match tokio::fs::metadata(&hmi.project_dir).await {
365        Ok(meta) if meta.is_dir() => {}
366        _ => {
367            return Err(PipelineError::HmiFetch {
368                dir,
369                message: "project directory not found".to_string(),
370            });
371        }
372    }
373
374    let mut cmd = Command::new(program);
375    cmd.args(&args).current_dir(&hmi.project_dir);
376
377    let status = run_command(&mut cmd, reporter)
378        .await
379        .map_err(|e| PipelineError::HmiFetch {
380            dir: dir.clone(),
381            message: format!("failed to launch `{program}`: {e}"),
382        })?;
383
384    if !status.success() {
385        return Err(PipelineError::HmiFetch {
386            dir,
387            message: format!("`{program}` exited with status: {status}"),
388        });
389    }
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
398
399    fn session() -> Session {
400        Session::from_toml(STM32).expect("example manifest loads")
401    }
402
403    #[test]
404    fn plans_one_fetch_per_cargo_project() {
405        let plan = plan_fetch(&session(), Path::new("/proj"), None).expect("plan succeeds");
406
407        // The stm32 example declares cm33 firmware, a TA, and the application.
408        // cm0p is a manager core, so it is not a firmware target and not fetched.
409        let entries: Vec<(&str, &str)> = plan
410            .cargo
411            .iter()
412            .map(|e| (e.label.as_str(), e.target.as_str()))
413            .collect();
414        assert_eq!(
415            entries,
416            vec![
417                ("cm33", "thumbv8m.main-none-eabihf"),
418                ("ta", "aarch64-unknown-linux-gnu"),
419                ("app", "aarch64-unknown-linux-gnu"),
420            ]
421        );
422
423        // Paths anchor at the manifest directory, matching the build plan.
424        assert_eq!(plan.cargo[0].path, Path::new("/proj/./firmware"));
425        assert_eq!(plan.cargo[1].path, Path::new("/proj/./ta"));
426        assert_eq!(plan.cargo[2].path, Path::new("/proj/./app"));
427
428        assert!(plan.hmi.is_none());
429    }
430
431    #[test]
432    fn rust_targets_are_deduplicated_in_first_use_order() {
433        // The TA and the application share aarch64-unknown-linux-gnu; installing
434        // that std twice would be wasted work.
435        let plan = plan_fetch(&session(), Path::new("/proj"), None).expect("plan succeeds");
436        assert_eq!(
437            plan.rust_targets(),
438            vec!["thumbv8m.main-none-eabihf", "aarch64-unknown-linux-gnu"]
439        );
440    }
441
442    #[test]
443    fn plan_carries_the_hmi_install_through() {
444        let hmi = HmiFetch {
445            project_dir: PathBuf::from("/proj/website"),
446            command: HmiBuildCommand::Custom(vec!["pnpm".into(), "install".into()]),
447        };
448        let plan =
449            plan_fetch(&session(), Path::new("/proj"), Some(hmi.clone())).expect("plan succeeds");
450        assert_eq!(plan.hmi, Some(hmi));
451    }
452
453    #[test]
454    fn plan_omits_the_ta_without_a_tee_section() {
455        // Without `[tee]` there is no TA crate to fetch, and the secure-A-core
456        // endpoint is not required.
457        let manifest = "\
458            [profile]\n\
459            chip = \"stm32mp257fak3\"\n\
460            shared = \"./shared\"\n\
461            deploy_root = \"/opt/consortium\"\n\
462            \n\
463            [endpoints]\n\
464            linux = \"./app\"\n";
465        let session = Session::from_toml(manifest).expect("manifest loads");
466        let plan = plan_fetch(&session, Path::new("/proj"), None).expect("plan succeeds");
467        let labels: Vec<&str> = plan.cargo.iter().map(|e| e.label.as_str()).collect();
468        assert_eq!(labels, vec!["app"]);
469    }
470
471    #[test]
472    fn plan_errors_without_an_application_endpoint() {
473        let manifest = "\
474            [profile]\n\
475            chip = \"stm32mp257fak3\"\n\
476            shared = \"./shared\"\n\
477            deploy_root = \"/opt/consortium\"\n";
478        let session = Session::from_toml(manifest).expect("manifest loads");
479        let err = plan_fetch(&session, Path::new("/proj"), None)
480            .expect_err("a build always needs the Linux application");
481        assert!(matches!(err, PipelineError::MissingEndpoint(_)));
482    }
483
484    #[tokio::test]
485    async fn hmi_install_runs_the_command_in_the_project_dir() {
486        let dir = std::env::var("SCRATCH_DIR")
487            .ok()
488            .map(PathBuf::from)
489            .unwrap_or_else(std::env::temp_dir)
490            .join(format!("consortium-fetch-hmi-{}", std::process::id()));
491        let _ = std::fs::remove_dir_all(&dir);
492        std::fs::create_dir_all(&dir).expect("create temp dir");
493
494        let hmi = HmiFetch {
495            project_dir: dir.clone(),
496            command: HmiBuildCommand::Custom(vec![
497                "sh".into(),
498                "-c".into(),
499                "printf x > installed".into(),
500            ]),
501        };
502        hmi_install(&hmi, None).await.expect("install succeeds");
503        assert!(dir.join("installed").exists());
504
505        // A failing package manager surfaces as a fetch error, not a silent skip.
506        let failing = HmiFetch {
507            project_dir: dir.clone(),
508            command: HmiBuildCommand::Custom(vec!["false".into()]),
509        };
510        let err = hmi_install(&failing, None)
511            .await
512            .expect_err("non-zero exit is an error");
513        assert!(matches!(err, PipelineError::HmiFetch { .. }));
514
515        let _ = std::fs::remove_dir_all(&dir);
516    }
517
518    #[tokio::test]
519    async fn hmi_install_errors_when_project_dir_missing() {
520        let hmi = HmiFetch {
521            project_dir: PathBuf::from("/nonexistent/consortium-website"),
522            command: HmiBuildCommand::Custom(vec!["true".into()]),
523        };
524        let err = hmi_install(&hmi, None)
525            .await
526            .expect_err("missing project dir is an error");
527        assert!(matches!(err, PipelineError::HmiFetch { .. }));
528    }
529}