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, DbgConfig, HmiConfig, SharedMemConfig};
39use crate::compile::utils::{
40    clean_path, elf_path, inject_csti, inject_dbg, inject_hmi, inject_shared_mem, resolve_cargo,
41    resolve_rustc, run_command, 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    /// IPC shared-memory layout, injected as `CONSORTIUM_IPC_*` env vars.
197    pub ipc: Option<SharedMemConfig>,
198    /// Debug ring layout, injected as `CONSORTIUM_DBG_*` env vars.
199    pub dbg: Option<DbgConfig>,
200    /// Target sysroot from `[profile] sysroot`, resolved to an absolute path.
201    /// Takes priority over the `SDKTARGETSYSROOT` / `OECORE_TARGET_SYSROOT`
202    /// environment variables when present.
203    pub sysroot: Option<PathBuf>,
204    /// Build-host directories injected as `CSTI_*` env vars, and the workspace
205    /// layout locating `target/`. `None` injects nothing and uses the crate's
206    /// own `target/`.
207    pub workspace: Option<CstiWorkspace>,
208}
209
210/// Compiler for Linux applications targeting Yocto A-cores.
211pub struct ApplicationCompiler;
212
213impl ApplicationCompiler {
214    pub async fn build_with_reporter(
215        config: ApplicationConfiguration,
216        reporter: Option<&dyn BuildReporter>,
217    ) -> Result<PathBuf, ApplicationCompileError> {
218        Self::cargo(&config, "build", reporter).await?;
219
220        let elf = Self::output_path(&config).await?;
221
222        if !elf.exists() {
223            return Err(ApplicationCompileError::ElfNotFound(elf));
224        }
225
226        verify_elf(&elf)
227            .await
228            .map_err(ApplicationCompileError::UtilityError)?;
229
230        Ok(elf)
231    }
232
233    /// Type-checks the application with `cargo check`, under the same sysroot,
234    /// linker, flags, and injected environment as
235    /// [`build_with_reporter`](Self::build_with_reporter). Produces no ELF.
236    pub async fn check_with_reporter(
237        config: &ApplicationConfiguration,
238        reporter: Option<&dyn BuildReporter>,
239    ) -> Result<(), ApplicationCompileError> {
240        Self::cargo(config, "check", reporter).await
241    }
242
243    /// Runs `cargo <subcommand>` for the application crate in the resolved SDK
244    /// environment.
245    async fn cargo(
246        config: &ApplicationConfiguration,
247        subcommand: &str,
248        reporter: Option<&dyn BuildReporter>,
249    ) -> Result<(), ApplicationCompileError> {
250        let sdk = SdkEnv::resolve(config.sysroot.as_deref())?;
251
252        let rustc = resolve_rustc()
253            .await
254            .map_err(ApplicationCompileError::RustupError)?;
255        let cargo = resolve_cargo()
256            .await
257            .map_err(ApplicationCompileError::RustupError)?;
258
259        let rustflags = sdk.rustflags();
260        let sanitized_path = clean_path(&sdk.native_sysroot);
261
262        let mut cmd = Command::new(&cargo);
263        cmd.arg(subcommand)
264            .arg("--target")
265            .arg(&config.target)
266            .current_dir(&config.path)
267            .env("PATH", &sanitized_path)
268            .env("RUSTC", &rustc)
269            .env("RUSTFLAGS", &rustflags)
270            .env("SYSROOT", &sdk.sysroot)
271            .env("PKG_CONFIG_SYSROOT_DIR", &sdk.sysroot)
272            .env(
273                "PKG_CONFIG_PATH",
274                format!(
275                    "{s}/usr/lib/pkgconfig:{s}/usr/share/pkgconfig",
276                    s = sdk.sysroot,
277                ),
278            );
279
280        if let Some(ref cc) = sdk.cross.cc {
281            cmd.arg("--config")
282                .arg(format!("{}.linker={cc}", config.target));
283            cmd.env("CC", format!("{cc} --sysroot={}", sdk.sysroot));
284        }
285
286        if let Some(ref cxx) = sdk.cross.cxx {
287            cmd.env("CXX", format!("{cxx} --sysroot={}", sdk.sysroot));
288        }
289
290        inject_hmi(&mut cmd, config.hmi.as_ref());
291
292        inject_shared_mem(&mut cmd, "IPC", config.ipc.as_ref());
293        inject_dbg(&mut cmd, config.dbg.as_ref());
294        inject_csti(&mut cmd, config.workspace.as_ref());
295
296        if config.release {
297            cmd.arg("--release");
298        }
299
300        if config.offline {
301            cmd.arg("--offline");
302        }
303
304        let status = run_command(&mut cmd, reporter)
305            .await
306            .map_err(|e| ApplicationCompileError::BuildFailed(e.to_string()))?;
307
308        if !status.success() {
309            return Err(ApplicationCompileError::BuildFailed(format!(
310                "cargo {subcommand} failed with status: {status}"
311            )));
312        }
313        Ok(())
314    }
315}
316
317impl Compiler for ApplicationCompiler {
318    type Configuration = ApplicationConfiguration;
319    type Error = ApplicationCompileError;
320    type Output = PathBuf;
321
322    async fn build(config: Self::Configuration) -> Result<Self::Output, Self::Error> {
323        Self::build_with_reporter(config, None).await
324    }
325
326    async fn output_path(config: &Self::Configuration) -> Result<PathBuf, Self::Error> {
327        elf_path(
328            &config.path,
329            config.workspace.as_ref(),
330            &config.target,
331            config.release,
332        )
333        .await
334        .map_err(ApplicationCompileError::UtilityError)
335    }
336}
337
338/// Backward-compatible builder; delegates to [`ApplicationCompiler`].
339///
340/// Cross-compiler detection and environment handling is identical to
341/// [`ApplicationCompiler`]. See [`ApplicationConfiguration`] for env var docs.
342/// Always builds online (`offline: false`); use [`ApplicationCompiler`] directly
343/// to build from the local registry cache.
344pub struct ApplicationBuilder {
345    /// Path to the Cargo project root.
346    pub path: PathBuf,
347    pub release: bool,
348    pub target: String,
349    pub hmi: Option<HmiConfig>,
350    pub ipc: Option<SharedMemConfig>,
351    pub dbg: Option<DbgConfig>,
352    pub sysroot: Option<PathBuf>,
353}
354
355impl ApplicationBuilder {
356    /// Builds the Linux application, returning the path to the output ELF.
357    pub async fn build(&self) -> Result<PathBuf, String> {
358        ApplicationCompiler::build(ApplicationConfiguration {
359            path: self.path.clone(),
360            target: self.target.clone(),
361            release: self.release,
362            offline: false,
363            hmi: self.hmi.clone(),
364            ipc: self.ipc,
365            dbg: self.dbg,
366            sysroot: self.sysroot.clone(),
367            workspace: None,
368        })
369        .await
370        .map_err(|e| e.to_string())
371    }
372
373    /// Returns the expected output ELF path without building.
374    pub async fn elf_path(&self) -> Result<PathBuf, String> {
375        ApplicationCompiler::output_path(&ApplicationConfiguration {
376            path: self.path.clone(),
377            target: self.target.clone(),
378            release: self.release,
379            offline: false,
380            hmi: self.hmi.clone(),
381            ipc: self.ipc,
382            dbg: self.dbg,
383            sysroot: self.sysroot.clone(),
384            workspace: None,
385        })
386        .await
387        .map_err(|e| e.to_string())
388    }
389}