Skip to main content

consortium_builder/pipeline/
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//! Stage 5: compile the Linux application.
18//!
19//! The AP's generated init module embeds each debug ring's firmware ELF via
20//! `include_bytes!`, so this stage substitutes Stage 1's resolved ELF paths into
21//! the debug IR before generating that module. Each path is emitted relative to
22//! the `CSTI_*` directory the application build is given (see
23//! [`embed_locations`](super::elf_map::embed_locations)), so the generated
24//! module carries no build-host path.
25
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28
29use consortium_cfg::Session;
30use consortium_cfg_common::config::{AcoreName, Party, VisibleCore};
31
32use super::{CargoOptions, PipelineError, elf_map};
33use crate::compile::application::ApplicationConfiguration;
34use crate::compile::env::{CstiWorkspace, HmiConfig};
35
36/// A resolved application build plus its generated AP init module.
37pub struct ApplicationBuildPlan {
38    /// `cargo build` parameters for the Linux application.
39    pub config: ApplicationConfiguration,
40    /// The generated AP init module, rendered to Rust source, to be written into
41    /// the application crate before building. Its `DbgRingSpec`s already embed the
42    /// resolved firmware ELF paths.
43    pub generated: String,
44}
45
46/// Plans the Linux application build.
47///
48/// `hmi_dist` is `Some` when Stage 3 built the frontend; it only gates whether an
49/// [`HmiConfig`] is injected — the *on-target* serve path is derived from
50/// `deploy_root`, not the build-host dist. `resolved_dbg_elfs` maps each debug
51/// core to the absolute firmware ELF the AP embeds.
52pub fn plan_application_build(
53    session: &Session,
54    workspace: &CstiWorkspace,
55    hmi_dist: Option<&Path>,
56    resolved_dbg_elfs: &HashMap<VisibleCore, PathBuf>,
57    cargo: CargoOptions,
58    ty_crate: &syn::Path,
59) -> Result<ApplicationBuildPlan, PipelineError> {
60    let root = session
61        .config
62        .endpoints
63        .get(&Party::AcoreNonSecure)
64        .ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreNonSecure.to_string()))?;
65    let manifest_dir = &workspace.manifest_dir;
66    let path = manifest_dir.join(root);
67
68    let target = acore_linux_triple(&session.database.ap)?;
69
70    // The HMI carrier points the deployed app at its on-target install path
71    // under `deploy_root` (Cog: `www/`; PocketJS: `hmi/<app>.{js,pak}`), which a
72    // packaging recipe populates from the matching `dist/` subtree.
73    let hmi = match (&session.config.hmi, hmi_dist) {
74        (Some(hmi), Some(_)) => Some(HmiConfig::from_hmi(
75            hmi,
76            &session.config.profile.deploy_root,
77        )),
78        _ => None,
79    };
80
81    // A relative `[profile] sysroot` is anchored at the manifest directory; an
82    // absolute one is used verbatim.
83    let sysroot = session
84        .config
85        .profile
86        .sysroot
87        .as_ref()
88        .map(|s| manifest_dir.join(s));
89
90    // Substitute the resolved firmware ELF paths into the debug IR, then generate
91    // the AP init module that `include_bytes!`es them.
92    let mut ir = session.lower()?;
93    let embedded = elf_map::embed_locations(resolved_dbg_elfs, workspace);
94    ir.dbg = std::mem::take(&mut ir.dbg).with_resolved_elf_paths(&embedded);
95    consortium_log::trace!("rendering AP init module for target {}", target);
96    let generated =
97        consortium_cfg::render(ir.initialize(VisibleCore::Ap, ty_crate)).map_err(|e| {
98            consortium_log::trace!("AP init codegen render failed: {}", e);
99            PipelineError::Codegen(e.to_string())
100        })?;
101
102    Ok(ApplicationBuildPlan {
103        config: ApplicationConfiguration {
104            path,
105            target,
106            release: cargo.release,
107            offline: cargo.offline,
108            hmi,
109            sysroot,
110            workspace: Some(workspace.clone()),
111        },
112        generated,
113    })
114}
115
116/// The Linux Rust target triple for the AP core.
117///
118/// Derived from the AP core's bitness rather than hardcoded, so a 32-bit A-core
119/// (e.g. Cortex-A7) targets armv7 instead of aarch64. This is the Linux/`gnu`
120/// ABI, distinct from [`AcoreName::as_rust_triple`]'s bare-metal `-none` triples.
121pub(crate) fn acore_linux_triple(ap: &str) -> Result<String, PipelineError> {
122    let name: AcoreName = ap
123        .parse()
124        .map_err(|_| PipelineError::UnknownCore(ap.to_string()))?;
125    Ok(if name.is_64_bit() {
126        "aarch64-unknown-linux-gnu".to_string()
127    } else {
128        "armv7-unknown-linux-gnueabihf".to_string()
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::pipeline::resolve_workspace;
136
137    const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
138
139    fn cm33() -> VisibleCore {
140        VisibleCore::Mcore("cm33".parse().unwrap())
141    }
142
143    #[test]
144    fn acore_triple_tracks_bitness() {
145        assert_eq!(
146            acore_linux_triple("ca35").unwrap(),
147            "aarch64-unknown-linux-gnu"
148        );
149        assert_eq!(
150            acore_linux_triple("ca7").unwrap(),
151            "armv7-unknown-linux-gnueabihf"
152        );
153        assert!(acore_linux_triple("bogus").is_err());
154    }
155
156    #[test]
157    fn plans_application_with_hmi_and_embedded_dbg() {
158        let session = Session::from_toml(STM32).expect("manifest loads");
159        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
160        let resolved = HashMap::from([(cm33(), PathBuf::from("/build/cm33.elf"))]);
161
162        let plan = plan_application_build(
163            &session,
164            &resolve_workspace(&session, Path::new("/proj")).unwrap(),
165            Some(Path::new("/build/website/dist")),
166            &resolved,
167            CargoOptions {
168                release: true,
169                offline: true,
170            },
171            &ty_crate,
172        )
173        .expect("plan succeeds");
174
175        assert_eq!(plan.config.path, Path::new("/proj/app"));
176        assert_eq!(plan.config.target, "aarch64-unknown-linux-gnu");
177        assert!(plan.config.release);
178        // The shared cargo flags reach the application plan too.
179        assert!(plan.config.offline);
180
181        // The HMI serve path resolves under the deploy root, not the build dist.
182        let hmi = plan.config.hmi.expect("hmi injected");
183        assert_eq!(
184            hmi,
185            HmiConfig::Cog {
186                dist: "/opt/consortium/www".to_string(),
187                debug_url: Some("http://127.0.0.1:5173/".to_string()),
188            }
189        );
190
191        // The generated AP module embeds the resolved firmware ELF.
192        assert!(
193            plan.generated
194                .contains("include_bytes!(\"/build/cm33.elf\")")
195        );
196        // The AP is the debug host: it reads rings, it never initializes a logger.
197        assert!(!plan.generated.contains("ConsortiumLogger"));
198
199        // The stm32 example does not declare a sysroot, so none is planned.
200        assert!(plan.config.sysroot.is_none());
201    }
202
203    #[test]
204    fn embeds_debug_elf_relative_to_mcu_path() {
205        // The usual shape: the ELF sits under the nested firmware crate's
206        // `target/`, so the module names it through `CSTI_MCU_PATH` and the build
207        // is given that variable.
208        let session = Session::from_toml(STM32).expect("manifest loads");
209        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
210        let workspace = resolve_workspace(&session, Path::new("/proj")).unwrap();
211        let elf = "/proj/firmware/target/thumbv8m.main-none-eabihf/debug/mcu_original.elf";
212
213        let plan = plan_application_build(
214            &session,
215            &workspace,
216            None,
217            &HashMap::from([(cm33(), PathBuf::from(elf))]),
218            CargoOptions::default(),
219            &ty_crate,
220        )
221        .expect("plan succeeds");
222
223        assert!(!plan.generated.contains("/proj"), "{}", plan.generated);
224        assert!(
225            plan.generated.contains("::core::env!(\"CSTI_MCU_PATH\")"),
226            "{}",
227            plan.generated
228        );
229        assert!(
230            plan.generated
231                .contains("\"/target/thumbv8m.main-none-eabihf/debug/mcu_original.elf\"")
232        );
233        assert_eq!(plan.config.workspace, Some(workspace));
234    }
235
236    #[test]
237    fn relative_profile_sysroot_anchors_at_manifest_dir() {
238        let toml = STM32.replace(
239            "deploy_root = \"/opt/consortium\"",
240            "deploy_root = \"/opt/consortium\"\nsysroot     = \"sdk/sysroot\"",
241        );
242        let session = Session::from_toml(&toml).expect("manifest loads");
243        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
244
245        let plan = plan_application_build(
246            &session,
247            &resolve_workspace(&session, Path::new("/proj")).unwrap(),
248            None,
249            &HashMap::from([(cm33(), PathBuf::from("/build/cm33.elf"))]),
250            CargoOptions::default(),
251            &ty_crate,
252        )
253        .expect("plan succeeds");
254
255        assert_eq!(
256            plan.config.sysroot.as_deref(),
257            Some(Path::new("/proj/sdk/sysroot"))
258        );
259    }
260
261    #[test]
262    fn absolute_profile_sysroot_used_verbatim() {
263        let toml = STM32.replace(
264            "deploy_root = \"/opt/consortium\"",
265            "deploy_root = \"/opt/consortium\"\nsysroot     = \"/sdk/target/sysroot\"",
266        );
267        let session = Session::from_toml(&toml).expect("manifest loads");
268        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
269
270        let plan = plan_application_build(
271            &session,
272            &resolve_workspace(&session, Path::new("/proj")).unwrap(),
273            None,
274            &HashMap::from([(cm33(), PathBuf::from("/build/cm33.elf"))]),
275            CargoOptions::default(),
276            &ty_crate,
277        )
278        .expect("plan succeeds");
279
280        assert_eq!(
281            plan.config.sysroot.as_deref(),
282            Some(Path::new("/sdk/target/sysroot"))
283        );
284    }
285
286    #[test]
287    fn omits_hmi_when_frontend_not_built() {
288        let session = Session::from_toml(STM32).expect("manifest loads");
289        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
290        let plan = plan_application_build(
291            &session,
292            &resolve_workspace(&session, Path::new("/proj")).unwrap(),
293            None,
294            &HashMap::from([(cm33(), PathBuf::from("/build/cm33.elf"))]),
295            CargoOptions::default(),
296            &ty_crate,
297        )
298        .expect("plan succeeds");
299        assert!(plan.config.hmi.is_none());
300    }
301}