Skip to main content

consortium_builder/compile/
application.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//! Linux application compilation.
18//!
19//! Builds the A-core executable with `cargo`, usually cross-compiled, and injects the
20//! `CONSORTIUM_*` environment variables the application and its generated module read at
21//! compile time.
22//!
23//! The interesting part is the sysroot. Cross-compiling against a Yocto/OpenSTLinux SDK
24//! needs a target sysroot, a cross linker, and a `PATH` that does not leak host tools
25//! into the build. Resolution order is `[profile] sysroot` first, then the
26//! `SDKTARGETSYSROOT` / `OECORE_TARGET_SYSROOT` environment variables — so a manifest can
27//! pin a sysroot without the developer having sourced an SDK environment script.
28//!
29//! On an arm64 Linux host, `aarch64-unknown-linux-gnu` is a *native* build and
30//! `sysroot = "/"` resolves to the image root, which is why the melt-pot manifests can
31//! be built inside an Apple `container` VM with no SDK at all.
32
33use std::path::{Path, PathBuf};
34use thiserror::Error;
35use tokio::process::Command;
36
37use super::Compiler;
38use crate::compile::env::{CstiWorkspace, HmiConfig};
39use crate::compile::utils::{
40    clean_path, elf_path, inject_csti, inject_hmi, resolve_cargo, resolve_rustc, run_command,
41    verify_elf,
42};
43use crate::progress::BuildReporter;
44
45/// Error type for application compilation.
46#[derive(Error, Debug)]
47pub enum ApplicationCompileError {
48    #[error("Failed to resolve sysroot: {0}")]
49    SysrootError(String),
50
51    #[error("Failed to resolve rustup tool: {0}")]
52    RustupError(String),
53
54    #[error("Cargo failed: {0}")]
55    BuildFailed(String),
56
57    #[error("ELF not found at expected path: {0:?}")]
58    ElfNotFound(PathBuf),
59
60    #[error("{0}")]
61    UtilityError(String),
62}
63
64/// Result of cross-compiler detection, including whether an explicit compiler was found.
65struct CrossCompiler {
66    /// Absolute path to the C cross-compiler, or `None` when falling back to the generic linker.
67    cc: Option<String>,
68    /// Absolute path to the C++ cross-compiler, or `None` when falling back to the generic linker.
69    cxx: Option<String>,
70}
71
72impl CrossCompiler {
73    /// Detect the cross-compiler in priority order:
74    /// 1. `CC` / `CXX` env vars (explicit, platform-agnostic)
75    /// 2. `None` → generic Rust linker
76    fn detect() -> Self {
77        // 1. Explicit CC/CXX: honour whatever the user or SDK set
78        if let Ok(cc) = std::env::var("CC") {
79            let cxx = std::env::var("CXX").unwrap_or_else(|_| cc.clone());
80            return CrossCompiler {
81                cc: Some(cc),
82                cxx: Some(cxx),
83            };
84        }
85
86        // 2. Nothing found
87        CrossCompiler {
88            cc: None,
89            cxx: None,
90        }
91    }
92}
93
94struct SdkEnv {
95    sysroot: String,
96    cross: CrossCompiler,
97    /// Tune flags from `OECORE_TUNE_CCARGS`
98    arch_flags: String,
99    /// Native sysroot used to strip SDK entries from PATH
100    native_sysroot: String,
101}
102
103impl SdkEnv {
104    /// Resolve the SDK environment.
105    ///
106    /// `sysroot_override` is the `[profile] sysroot` from the manifest (already
107    /// resolved to an absolute path by the caller). When present it takes
108    /// priority over the `SDKTARGETSYSROOT` / `OECORE_TARGET_SYSROOT` environment
109    /// variables, so a build does not require a sourced SDK environment.
110    fn resolve(sysroot_override: Option<&Path>) -> Result<Self, ApplicationCompileError> {
111        let sysroot = match sysroot_override {
112            Some(path) => path.to_string_lossy().into_owned(),
113            None => std::env::var("SDKTARGETSYSROOT")
114                .or_else(|_| std::env::var("OECORE_TARGET_SYSROOT"))
115                .map_err(|_| {
116                    ApplicationCompileError::SysrootError(
117                        "Could not determine target sysroot. Set the [profile] sysroot in \
118                     Consortium.toml, export SDKTARGETSYSROOT, or source a Yocto \
119                     environment-setup script."
120                            .to_string(),
121                    )
122                })?,
123        };
124
125        if !PathBuf::from(&sysroot).exists() {
126            return Err(ApplicationCompileError::SysrootError(format!(
127                "Target sysroot does not exist: {sysroot}"
128            )));
129        }
130
131        // OECORE_NATIVE_SYSROOT is optional; only needed for PATH sanitization
132        let native_sysroot = std::env::var("OECORE_NATIVE_SYSROOT").unwrap_or_default();
133
134        let arch_flags = std::env::var("OECORE_TUNE_CCARGS")
135            .unwrap_or_else(|_| "-mcpu=cortex-a35+crc -mbranch-protection=standard".to_string());
136
137        Ok(SdkEnv {
138            sysroot,
139            cross: CrossCompiler::detect(),
140            arch_flags,
141            native_sysroot,
142        })
143    }
144
145    fn rustflags(&self) -> String {
146        let arch_link_args: String = self
147            .arch_flags
148            .split_whitespace()
149            .map(|f| format!(" -C link-arg={f}"))
150            .collect();
151
152        match &self.cross.cc {
153            Some(cc) => format!(
154                "-C linker={cc} -C link-arg=--sysroot={sysroot}{arch_link_args}",
155                sysroot = self.sysroot,
156            ),
157            None => format!(
158                "-C link-arg=--sysroot={sysroot}{arch_link_args}",
159                sysroot = self.sysroot,
160            ),
161        }
162    }
163}
164
165/// Build parameters for a Linux application (typically targeting a Yocto A-core).
166///
167/// # Environment variables (in priority order)
168///
169/// Cross-compiler detection:
170///   `CC` / `CXX`: explicit cross-compiler (any Yocto flavor)
171///   Fallback: generic Rust linker
172///
173/// Sysroot:
174///   `SDKTARGETSYSROOT` > `OECORE_TARGET_SYSROOT`
175///
176/// Rustup overrides:
177///   `CONSORTIUM_RUSTC`, `CONSORTIUM_CARGO`
178///
179/// HMI (when `hmi` is `Some`):
180///   `CONSORTIUM_HMI_DIST`: absolute path to the compiled frontend dist directory
181///   `CONSORTIUM_HMI_DEBUG_URL`: dev-server URL (only injected when present)
182#[derive(Clone)]
183pub struct ApplicationConfiguration {
184    /// Path to the Cargo project root.
185    pub path: PathBuf,
186    /// Rust target triple (e.g. `"aarch64-unknown-linux-gnu"`).
187    pub target: String,
188    /// Build in release mode.
189    pub release: bool,
190    /// Pass `--offline` to cargo: resolve and build from the local registry cache
191    /// only. Populate that cache first with `csti fetch`.
192    pub offline: bool,
193    /// Optional HMI config. `dist` must be an absolute path (resolved by the
194    /// caller from the manifest directory before constructing this struct).
195    pub hmi: Option<HmiConfig>,
196    /// Target sysroot from `[profile] sysroot`, resolved to an absolute path.
197    /// Takes priority over the `SDKTARGETSYSROOT` / `OECORE_TARGET_SYSROOT`
198    /// environment variables when present.
199    pub sysroot: Option<PathBuf>,
200    /// Build-host directories injected as `CSTI_*` env vars, and the workspace
201    /// layout locating `target/`. `None` injects nothing and uses the crate's
202    /// own `target/`.
203    pub workspace: Option<CstiWorkspace>,
204}
205
206/// Compiler for Linux applications targeting Yocto A-cores.
207pub struct ApplicationCompiler;
208
209impl ApplicationCompiler {
210    pub async fn build_with_reporter(
211        config: ApplicationConfiguration,
212        reporter: Option<&dyn BuildReporter>,
213    ) -> Result<PathBuf, ApplicationCompileError> {
214        Self::cargo(&config, "build", reporter).await?;
215
216        let elf = Self::output_path(&config).await?;
217
218        if !elf.exists() {
219            return Err(ApplicationCompileError::ElfNotFound(elf));
220        }
221
222        verify_elf(&elf)
223            .await
224            .map_err(ApplicationCompileError::UtilityError)?;
225
226        Ok(elf)
227    }
228
229    /// Type-checks the application with `cargo check`, under the same sysroot,
230    /// linker, flags, and injected environment as
231    /// [`build_with_reporter`](Self::build_with_reporter). Produces no ELF.
232    pub async fn check_with_reporter(
233        config: &ApplicationConfiguration,
234        reporter: Option<&dyn BuildReporter>,
235    ) -> Result<(), ApplicationCompileError> {
236        Self::cargo(config, "check", reporter).await
237    }
238
239    /// Runs `cargo <subcommand>` for the application crate in the resolved SDK
240    /// environment.
241    async fn cargo(
242        config: &ApplicationConfiguration,
243        subcommand: &str,
244        reporter: Option<&dyn BuildReporter>,
245    ) -> Result<(), ApplicationCompileError> {
246        let sdk = SdkEnv::resolve(config.sysroot.as_deref())?;
247
248        let rustc = resolve_rustc()
249            .await
250            .map_err(ApplicationCompileError::RustupError)?;
251        let cargo = resolve_cargo()
252            .await
253            .map_err(ApplicationCompileError::RustupError)?;
254
255        let rustflags = sdk.rustflags();
256        let sanitized_path = clean_path(&sdk.native_sysroot);
257
258        let mut cmd = Command::new(&cargo);
259        cmd.arg(subcommand)
260            .arg("--target")
261            .arg(&config.target)
262            .current_dir(&config.path)
263            .env("PATH", &sanitized_path)
264            .env("RUSTC", &rustc)
265            .env("RUSTFLAGS", &rustflags)
266            .env("SYSROOT", &sdk.sysroot)
267            .env("PKG_CONFIG_SYSROOT_DIR", &sdk.sysroot)
268            .env(
269                "PKG_CONFIG_PATH",
270                format!(
271                    "{s}/usr/lib/pkgconfig:{s}/usr/share/pkgconfig",
272                    s = sdk.sysroot,
273                ),
274            );
275
276        if let Some(ref cc) = sdk.cross.cc {
277            cmd.arg("--config")
278                .arg(format!("{}.linker={cc}", config.target));
279            cmd.env("CC", format!("{cc} --sysroot={}", sdk.sysroot));
280        }
281
282        if let Some(ref cxx) = sdk.cross.cxx {
283            cmd.env("CXX", format!("{cxx} --sysroot={}", sdk.sysroot));
284        }
285
286        inject_hmi(&mut cmd, config.hmi.as_ref());
287
288        inject_csti(&mut cmd, config.workspace.as_ref());
289
290        if config.release {
291            cmd.arg("--release");
292        }
293
294        if config.offline {
295            cmd.arg("--offline");
296        }
297
298        let status = run_command(&mut cmd, reporter)
299            .await
300            .map_err(|e| ApplicationCompileError::BuildFailed(e.to_string()))?;
301
302        if !status.success() {
303            return Err(ApplicationCompileError::BuildFailed(format!(
304                "cargo {subcommand} failed with status: {status}"
305            )));
306        }
307        Ok(())
308    }
309}
310
311impl Compiler for ApplicationCompiler {
312    type Configuration = ApplicationConfiguration;
313    type Error = ApplicationCompileError;
314    type Output = PathBuf;
315
316    async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
317        Self::build_with_reporter(config, None).await
318    }
319
320    async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
321        elf_path(
322            &config.path,
323            config.workspace.as_ref(),
324            &config.target,
325            config.release,
326        )
327        .await
328        .map_err(ApplicationCompileError::UtilityError)
329    }
330}
331
332/// Backward-compatible builder; delegates to [`ApplicationCompiler`].
333///
334/// Cross-compiler detection and environment handling is identical to
335/// [`ApplicationCompiler`]. See [`ApplicationConfiguration`] for env var docs.
336/// Always builds online (`offline: false`); use [`ApplicationCompiler`] directly
337/// to build from the local registry cache.
338pub struct ApplicationBuilder {
339    /// Path to the Cargo project root.
340    pub path: PathBuf,
341    pub release: bool,
342    pub target: String,
343    pub hmi: Option<HmiConfig>,
344    pub sysroot: Option<PathBuf>,
345}
346
347impl ApplicationBuilder {
348    /// Builds the Linux application, returning the path to the output ELF.
349    pub async fn build(&self) -> Result<PathBuf, String> {
350        ApplicationCompiler::build(ApplicationConfiguration {
351            path: self.path.clone(),
352            target: self.target.clone(),
353            release: self.release,
354            offline: false,
355            hmi: self.hmi.clone(),
356            sysroot: self.sysroot.clone(),
357            workspace: None,
358        })
359        .await
360        .map_err(|e| e.to_string())
361    }
362
363    /// Returns the expected output ELF path without building.
364    pub async fn elf_path(&self) -> Result<PathBuf, String> {
365        ApplicationCompiler::output_path(&ApplicationConfiguration {
366            path: self.path.clone(),
367            target: self.target.clone(),
368            release: self.release,
369            offline: false,
370            hmi: self.hmi.clone(),
371            sysroot: self.sysroot.clone(),
372            workspace: None,
373        })
374        .await
375        .map_err(|e| e.to_string())
376    }
377}