Skip to main content

consortium_builder/pipeline/
dist.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 7: assemble the local `dist/` staging tree.
18//!
19//! `dist/` is a local staging directory a downstream bitbake recipe consumes; the
20//! pipeline never writes to a live target filesystem. Firmware ELFs land under
21//! `dist/firmware/<core>.elf` purely so a firmware-packaging recipe can install
22//! them to `/lib/firmware/<core>` for remoteproc's kernel-side loader — this is
23//! unrelated to the defmt embedding, which happens in the application binary.
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use consortium_cfg_common::config::{HmiEngine, VisibleCore};
29use thiserror::Error;
30use tokio::process::Command;
31
32/// Preprocessor defines that resolve the `dt-bindings` interrupt macros the UIO
33/// overlay emits (`GIC_SPI`, `IRQ_TYPE_*`), so `dtc` can verify the overlay
34/// without the kernel `dt-bindings` headers on disk. Values match
35/// `dt-bindings/interrupt-controller/{arm-gic,irq}.h`.
36const DT_BINDING_DEFINES: &[&str] = &[
37    "-DGIC_SPI=0",
38    "-DGIC_PPI=1",
39    "-DIRQ_TYPE_NONE=0",
40    "-DIRQ_TYPE_EDGE_RISING=1",
41    "-DIRQ_TYPE_EDGE_FALLING=2",
42    "-DIRQ_TYPE_EDGE_BOTH=3",
43    "-DIRQ_TYPE_LEVEL_HIGH=4",
44    "-DIRQ_TYPE_LEVEL_LOW=8",
45];
46
47/// The `dist/` staging tree layout.
48pub struct DistLayout {
49    /// The staging root; children are `firmware/`, `app/`, `ta/`, `www/`,
50    /// `systemd/`.
51    pub root: PathBuf,
52}
53
54impl DistLayout {
55    /// A layout rooted at `root`.
56    pub fn new(root: impl Into<PathBuf>) -> Self {
57        Self { root: root.into() }
58    }
59
60    /// The firmware subdirectory (`<root>/firmware`).
61    pub fn firmware_dir(&self) -> PathBuf {
62        self.root.join("firmware")
63    }
64
65    /// The application subdirectory (`<root>/app`).
66    pub fn app_dir(&self) -> PathBuf {
67        self.root.join("app")
68    }
69
70    /// The TA subdirectory (`<root>/ta`).
71    pub fn ta_dir(&self) -> PathBuf {
72        self.root.join("ta")
73    }
74
75    /// The Cog HMI assets subdirectory (`<root>/www`).
76    pub fn www_dir(&self) -> PathBuf {
77        self.root.join("www")
78    }
79
80    /// The PocketJS HMI subdirectory (`<root>/hmi`) holding the bundle + pak.
81    pub fn hmi_dir(&self) -> PathBuf {
82        self.root.join("hmi")
83    }
84
85    /// The Linux device-tree overlay subdirectory (`<root>/dts/linux`).
86    pub fn dts_linux_dir(&self) -> PathBuf {
87        self.root.join("dts").join("linux")
88    }
89
90    /// The kernel config-fragment subdirectory (`<root>/kernel`).
91    pub fn kernel_dir(&self) -> PathBuf {
92        self.root.join("kernel")
93    }
94
95    /// The boot-args subdirectory (`<root>/boot`).
96    pub fn boot_dir(&self) -> PathBuf {
97        self.root.join("boot")
98    }
99
100    /// The systemd unit subdirectory (`<root>/systemd`).
101    pub fn systemd_dir(&self) -> PathBuf {
102        self.root.join("systemd")
103    }
104}
105
106/// Error type for assembling the `dist/` tree.
107#[derive(Error, Debug)]
108pub enum DistAssemblyError {
109    /// A filesystem operation failed.
110    #[error("dist assembly I/O error at {path}: {source}")]
111    Io {
112        /// The path being operated on.
113        path: String,
114        /// The underlying error.
115        source: std::io::Error,
116    },
117
118    /// The C preprocessor is needed to resolve the overlay's `dt-bindings`
119    /// macros before `dtc`, but was not found on PATH.
120    #[error("`cpp` not found on PATH; needed to preprocess device-tree overlays before `dtc`")]
121    CppNotFound,
122
123    /// `dtc` (the device-tree compiler) was not found on PATH.
124    #[error("`dtc` not found on PATH; install the device-tree compiler to verify UIO overlays")]
125    DtcNotFound,
126
127    /// `dtc` (or `cpp`) rejected a generated overlay.
128    #[error("device-tree overlay {name} failed verification:\n{message}")]
129    DtsVerify {
130        /// The overlay file name.
131        name: String,
132        /// The `cpp`/`dtc` diagnostics.
133        message: String,
134    },
135}
136
137fn io(path: impl AsRef<Path>, source: std::io::Error) -> DistAssemblyError {
138    DistAssemblyError::Io {
139        path: path.as_ref().display().to_string(),
140        source,
141    }
142}
143
144/// Assembles the `dist/` tree from the pipeline's build outputs.
145///
146/// - each firmware ELF → `firmware/<core>.elf`;
147/// - each firmware ELF's unstripped original → `firmware/<core>_original.elf`
148///   (retains the `.defmt` section for host-side defmt table parsing);
149/// - the application binary → `app/<file_name>`;
150/// - the signed TA (when present) → `ta/<file_name>`;
151/// - the HMI assets (when present): a Cog dist tree → `www/` (copied
152///   recursively), or a PocketJS `<app>.js`/`<app>.pak` pair → `hmi/`;
153/// - each `(name, contents)` Linux device tree → `dts/linux/<name>`; a `.dtso`
154///   overlay is verified with `dtc` (see `verify_dts`), a merged `.dts` is
155///   not (see below);
156/// - each `(name, contents)` kernel config fragment → `kernel/<name>`;
157/// - each `(name, contents)` boot-args file → `boot/<name>`;
158/// - each `(name, contents)` systemd unit → `systemd/<name>`.
159///
160/// A `.dtso` overlay uses only the interrupt macros this module defines, so it is
161/// self-contained enough to `dtc`-verify here (before the systemd units, so a
162/// rejection fails early). A merged `.dts` `#include`s vendor `.dtsi`/`dt-bindings`
163/// headers that are not on disk offline, so it is written but not compiled here;
164/// the kernel build performs the final `dtc`.
165pub struct DistAssemblyInputs<'a> {
166    pub firmware: &'a HashMap<VisibleCore, PathBuf>,
167    /// The unstripped `<core>_original.elf` per core (keeps the `.defmt` section),
168    /// staged alongside the deployable ELF for host-side defmt table parsing.
169    pub firmware_originals: &'a HashMap<VisibleCore, PathBuf>,
170    pub application: &'a Path,
171    pub ta: Option<&'a Path>,
172    pub hmi: Option<HmiStaging<'a>>,
173    pub dts_files: &'a [(String, String)],
174    pub kconfig_fragments: &'a [(String, String)],
175    pub boot_files: &'a [(String, String)],
176    pub systemd_units: &'a [(String, String)],
177}
178
179/// How the built HMI assets are staged, selected by engine.
180#[derive(Clone, Copy)]
181pub struct HmiStaging<'a> {
182    /// Rendering engine, selecting the staging layout.
183    pub engine: HmiEngine,
184    /// PocketJS entry name (`[hmi].app`); `None` for other engines.
185    pub app: Option<&'a str>,
186    /// Build-host dist directory the frontend build produced.
187    pub dist: &'a Path,
188}
189
190pub async fn assemble(
191    layout: &DistLayout,
192    inputs: DistAssemblyInputs<'_>,
193) -> Result<(), DistAssemblyError> {
194    let DistAssemblyInputs {
195        firmware,
196        firmware_originals,
197        application,
198        ta,
199        hmi,
200        dts_files,
201        kconfig_fragments,
202        boot_files,
203        systemd_units,
204    } = inputs;
205    // Firmware ELFs, named by core for the packaging recipe.
206    let firmware_dir = layout.firmware_dir();
207    tokio::fs::create_dir_all(&firmware_dir)
208        .await
209        .map_err(|e| io(&firmware_dir, e))?;
210    for (core, elf) in firmware {
211        let dest = firmware_dir.join(format!("{core}.elf"));
212        copy_file(elf, &dest).await?;
213    }
214    // Unstripped originals (keep `.defmt`) for host-side defmt table parsing.
215    for (core, elf) in firmware_originals {
216        let dest = firmware_dir.join(format!("{core}_original.elf"));
217        copy_file(elf, &dest).await?;
218    }
219
220    // Application binary.
221    let app_dir = layout.app_dir();
222    tokio::fs::create_dir_all(&app_dir)
223        .await
224        .map_err(|e| io(&app_dir, e))?;
225    let app_dest = app_dir.join(file_name(application)?);
226    copy_file(application, &app_dest).await?;
227
228    // Signed TA, when the manifest declared one.
229    if let Some(ta) = ta {
230        let ta_dir = layout.ta_dir();
231        tokio::fs::create_dir_all(&ta_dir)
232            .await
233            .map_err(|e| io(&ta_dir, e))?;
234        let ta_dest = ta_dir.join(file_name(ta)?);
235        copy_file(ta, &ta_dest).await?;
236    }
237
238    // HMI assets. Cog serves a whole dist tree from `www/`; PocketJS installs
239    // just the built `<app>.js`/`<app>.pak` pair under `hmi/`.
240    if let Some(hmi) = hmi {
241        match hmi.engine {
242            HmiEngine::Cog => {
243                let www = layout.www_dir();
244                copy_dir_recursive(hmi.dist, &www).await?;
245            }
246            HmiEngine::Pocket => {
247                let app = hmi.app.unwrap_or_default();
248                let hmi_dir = layout.hmi_dir();
249                tokio::fs::create_dir_all(&hmi_dir)
250                    .await
251                    .map_err(|e| io(&hmi_dir, e))?;
252                for ext in ["js", "pak"] {
253                    let name = format!("{app}.{ext}");
254                    copy_file(&hmi.dist.join(&name), &hmi_dir.join(&name)).await?;
255                }
256            }
257        }
258    }
259
260    // Linux device tree, written before the systemd units. A `.dtso` overlay is
261    // `dtc`-verified here; a merged `.dts` pulls in vendor headers we don't have
262    // offline, so it is left for the kernel build to compile.
263    if !dts_files.is_empty() {
264        let dts_dir = layout.dts_linux_dir();
265        tokio::fs::create_dir_all(&dts_dir)
266            .await
267            .map_err(|e| io(&dts_dir, e))?;
268        for (name, contents) in dts_files {
269            let dest = dts_dir.join(name);
270            tokio::fs::write(&dest, contents)
271                .await
272                .map_err(|e| io(&dest, e))?;
273            if name.ends_with(".dtso") {
274                verify_dts(name, &dest).await?;
275            }
276        }
277    }
278
279    // Kernel config fragments (plain text; no verification tooling).
280    if !kconfig_fragments.is_empty() {
281        let kernel_dir = layout.kernel_dir();
282        tokio::fs::create_dir_all(&kernel_dir)
283            .await
284            .map_err(|e| io(&kernel_dir, e))?;
285        for (name, contents) in kconfig_fragments {
286            let dest = kernel_dir.join(name);
287            tokio::fs::write(&dest, contents)
288                .await
289                .map_err(|e| io(&dest, e))?;
290        }
291    }
292
293    // Boot-args files (plain text; wired into the bootloader by the integrator).
294    if !boot_files.is_empty() {
295        let boot_dir = layout.boot_dir();
296        tokio::fs::create_dir_all(&boot_dir)
297            .await
298            .map_err(|e| io(&boot_dir, e))?;
299        for (name, contents) in boot_files {
300            let dest = boot_dir.join(name);
301            tokio::fs::write(&dest, contents)
302                .await
303                .map_err(|e| io(&dest, e))?;
304        }
305    }
306
307    // systemd units.
308    if !systemd_units.is_empty() {
309        let systemd_dir = layout.systemd_dir();
310        tokio::fs::create_dir_all(&systemd_dir)
311            .await
312            .map_err(|e| io(&systemd_dir, e))?;
313        for (name, contents) in systemd_units {
314            let dest = systemd_dir.join(name);
315            tokio::fs::write(&dest, contents)
316                .await
317                .map_err(|e| io(&dest, e))?;
318        }
319    }
320
321    Ok(())
322}
323
324/// The final path component of `path` as a string.
325fn file_name(path: &Path) -> Result<String, DistAssemblyError> {
326    path.file_name()
327        .and_then(|n| n.to_str())
328        .map(|n| n.to_string())
329        .ok_or_else(|| {
330            io(
331                path,
332                std::io::Error::new(std::io::ErrorKind::InvalidInput, "no file name"),
333            )
334        })
335}
336
337/// Copies a single file, creating the parent directory if needed.
338async fn copy_file(src: &Path, dest: &Path) -> Result<(), DistAssemblyError> {
339    if let Some(parent) = dest.parent() {
340        tokio::fs::create_dir_all(parent)
341            .await
342            .map_err(|e| io(parent, e))?;
343    }
344    tokio::fs::copy(src, dest).await.map_err(|e| io(src, e))?;
345    Ok(())
346}
347
348/// Recursively copies the directory tree at `src` into `dest`.
349///
350/// Boxed because async fns cannot recurse directly. No extra dependency: the walk
351/// is a hand-rolled [`tokio::fs::read_dir`] traversal.
352pub(crate) fn copy_dir_recursive<'a>(
353    src: &'a Path,
354    dest: &'a Path,
355) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DistAssemblyError>> + Send + 'a>>
356{
357    Box::pin(async move {
358        tokio::fs::create_dir_all(dest)
359            .await
360            .map_err(|e| io(dest, e))?;
361        let mut entries = tokio::fs::read_dir(src).await.map_err(|e| io(src, e))?;
362        while let Some(entry) = entries.next_entry().await.map_err(|e| io(src, e))? {
363            let file_type = entry.file_type().await.map_err(|e| io(entry.path(), e))?;
364            let child_dest = dest.join(entry.file_name());
365            if file_type.is_dir() {
366                copy_dir_recursive(&entry.path(), &child_dest).await?;
367            } else {
368                copy_file(&entry.path(), &child_dest).await?;
369            }
370        }
371        Ok(())
372    })
373}
374
375/// Verifies a written device-tree overlay compiles.
376///
377/// The overlay carries `dt-bindings` interrupt macros (`GIC_SPI`, `IRQ_TYPE_*`),
378/// so it is first run through `cpp` with those macros defined (see
379/// [`DT_BINDING_DEFINES`]) — keeping the on-disk overlay header-free — and the
380/// preprocessed result is then compiled with `dtc`. Only a non-zero exit is an
381/// error; `dtc`'s overlay-context warnings (missing base-tree `#address-cells`,
382/// unresolved `reg` unit addresses, etc.) go to stderr with a zero exit and are
383/// expected.
384async fn verify_dts(name: &str, dts: &Path) -> Result<(), DistAssemblyError> {
385    // `cpp` resolves the macros into a sibling `<name>.pp` next to the overlay.
386    let preprocessed = dts.with_extension("dts.pp");
387    let cpp = Command::new("cpp")
388        .args(["-nostdinc", "-undef", "-x", "assembler-with-cpp"])
389        .args(DT_BINDING_DEFINES)
390        .arg(dts)
391        .arg("-o")
392        .arg(&preprocessed)
393        .output()
394        .await
395        .map_err(|e| match e.kind() {
396            std::io::ErrorKind::NotFound => DistAssemblyError::CppNotFound,
397            _ => io(dts, e),
398        })?;
399    if !cpp.status.success() {
400        return Err(DistAssemblyError::DtsVerify {
401            name: name.to_string(),
402            message: String::from_utf8_lossy(&cpp.stderr).into_owned(),
403        });
404    }
405
406    // `dtc` compiles the preprocessed overlay; the `.dtb` output is discarded to
407    // stdout (`-o -`) since only the exit status matters here.
408    let dtc = Command::new("dtc")
409        .args(["-I", "dts", "-O", "dtb", "-o", "-"])
410        .arg(&preprocessed)
411        .output()
412        .await
413        .map_err(|e| match e.kind() {
414            std::io::ErrorKind::NotFound => DistAssemblyError::DtcNotFound,
415            _ => io(&preprocessed, e),
416        });
417
418    let _ = tokio::fs::remove_file(&preprocessed).await;
419    let dtc = dtc?;
420
421    if !dtc.status.success() {
422        return Err(DistAssemblyError::DtsVerify {
423            name: name.to_string(),
424            message: String::from_utf8_lossy(&dtc.stderr).into_owned(),
425        });
426    }
427    Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn scratch(tag: &str) -> PathBuf {
435        let dir =
436            std::env::temp_dir().join(format!("consortium-dist-{tag}-{}", std::process::id()));
437        let _ = std::fs::remove_dir_all(&dir);
438        std::fs::create_dir_all(&dir).expect("create scratch");
439        dir
440    }
441
442    #[tokio::test]
443    async fn assembles_full_tree() {
444        let base = scratch("assemble");
445        let sources = base.join("src");
446        std::fs::create_dir_all(&sources).unwrap();
447
448        // Fake build outputs: ELF-magic stand-ins, an app binary, a .ta, a www tree.
449        let cm33_elf = sources.join("cm33.elf");
450        std::fs::write(&cm33_elf, [0x7f, b'E', b'L', b'F']).unwrap();
451        let cm33_original = sources.join("cm33_original.elf");
452        std::fs::write(&cm33_original, [0x7f, b'E', b'L', b'F', b'O']).unwrap();
453        let app = sources.join("melt-pot-app");
454        std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
455        let ta = sources.join("uuid.ta");
456        std::fs::write(&ta, b"signed-ta").unwrap();
457        let www = sources.join("dist");
458        std::fs::create_dir_all(www.join("assets")).unwrap();
459        std::fs::write(www.join("index.html"), b"<html>").unwrap();
460        std::fs::write(www.join("assets").join("app.js"), b"//js").unwrap();
461
462        let layout = DistLayout::new(base.join("dist"));
463        let firmware = HashMap::from([(
464            VisibleCore::Mcore("cm33".parse().unwrap()),
465            cm33_elf.clone(),
466        )]);
467        let firmware_originals = HashMap::from([(
468            VisibleCore::Mcore("cm33".parse().unwrap()),
469            cm33_original.clone(),
470        )]);
471        let units = vec![("consortium-app.service".to_string(), "[Unit]\n".to_string())];
472        let kconfig = vec![("uio-enable.cfg".to_string(), "CONFIG_UIO=y\n".to_string())];
473        let boot = vec![("boot.cmd".to_string(), "setenv bootargs\n".to_string())];
474
475        assemble(
476            &layout,
477            DistAssemblyInputs {
478                firmware: &firmware,
479                firmware_originals: &firmware_originals,
480                application: &app,
481                ta: Some(&ta),
482                hmi: Some(HmiStaging {
483                    engine: HmiEngine::Cog,
484                    app: None,
485                    dist: &www,
486                }),
487                dts_files: &[],
488                kconfig_fragments: &kconfig,
489                boot_files: &boot,
490                systemd_units: &units,
491            },
492        )
493        .await
494        .expect("assemble succeeds");
495
496        assert!(layout.firmware_dir().join("cm33.elf").exists());
497        assert!(layout.firmware_dir().join("cm33_original.elf").exists());
498        assert!(layout.app_dir().join("melt-pot-app").exists());
499        assert!(layout.ta_dir().join("uuid.ta").exists());
500        assert!(layout.www_dir().join("index.html").exists());
501        assert!(layout.www_dir().join("assets").join("app.js").exists());
502        assert_eq!(
503            std::fs::read_to_string(layout.kernel_dir().join("uio-enable.cfg")).unwrap(),
504            "CONFIG_UIO=y\n"
505        );
506        assert_eq!(
507            std::fs::read_to_string(layout.boot_dir().join("boot.cmd")).unwrap(),
508            "setenv bootargs\n"
509        );
510        assert_eq!(
511            std::fs::read_to_string(layout.systemd_dir().join("consortium-app.service")).unwrap(),
512            "[Unit]\n"
513        );
514
515        let _ = std::fs::remove_dir_all(&base);
516    }
517
518    #[tokio::test]
519    async fn skips_optional_ta_and_hmi() {
520        let base = scratch("minimal");
521        let app = base.join("app-bin");
522        std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
523
524        let layout = DistLayout::new(base.join("dist"));
525        let firmware = HashMap::new();
526        assemble(
527            &layout,
528            DistAssemblyInputs {
529                firmware: &firmware,
530                firmware_originals: &firmware,
531                application: &app,
532                ta: None,
533                hmi: None,
534                dts_files: &[],
535                kconfig_fragments: &[],
536                boot_files: &[],
537                systemd_units: &[],
538            },
539        )
540        .await
541        .expect("assemble succeeds");
542
543        assert!(layout.app_dir().join("app-bin").exists());
544        assert!(!layout.ta_dir().exists());
545        assert!(!layout.www_dir().exists());
546        assert!(!layout.dts_linux_dir().exists());
547        assert!(!layout.kernel_dir().exists());
548        assert!(!layout.boot_dir().exists());
549
550        let _ = std::fs::remove_dir_all(&base);
551    }
552
553    #[tokio::test]
554    async fn stages_pocket_bundle_and_pak_into_hmi_dir() {
555        let base = scratch("pocket");
556        let app = base.join("app-bin");
557        std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
558
559        // A PocketJS build-host dist with the two artifacts (plus a stray file
560        // that must NOT be staged — only <app>.js/<app>.pak are).
561        let src = base.join("pocket-dist");
562        std::fs::create_dir_all(&src).unwrap();
563        std::fs::write(src.join("hero-main.js"), b"//bundle").unwrap();
564        std::fs::write(src.join("hero-main.pak"), b"PAK").unwrap();
565        std::fs::write(src.join("hero.js"), b"//component-only").unwrap();
566
567        let layout = DistLayout::new(base.join("dist"));
568        let firmware = HashMap::new();
569        assemble(
570            &layout,
571            DistAssemblyInputs {
572                firmware: &firmware,
573                firmware_originals: &firmware,
574                application: &app,
575                ta: None,
576                hmi: Some(HmiStaging {
577                    engine: HmiEngine::Pocket,
578                    app: Some("hero-main"),
579                    dist: &src,
580                }),
581                dts_files: &[],
582                kconfig_fragments: &[],
583                boot_files: &[],
584                systemd_units: &[],
585            },
586        )
587        .await
588        .expect("assemble succeeds");
589
590        // Only the mounted pair lands under `hmi/`; no `www/`, no stray file.
591        assert_eq!(
592            std::fs::read_to_string(layout.hmi_dir().join("hero-main.js")).unwrap(),
593            "//bundle"
594        );
595        assert!(layout.hmi_dir().join("hero-main.pak").exists());
596        assert!(!layout.hmi_dir().join("hero.js").exists());
597        assert!(!layout.www_dir().exists());
598
599        let _ = std::fs::remove_dir_all(&base);
600    }
601
602    /// Whether both `cpp` and `dtc` resolve on PATH; the overlay-verification
603    /// tests are skipped when either is absent (they exercise the real tools).
604    fn dt_tools_available() -> bool {
605        fn on_path(program: &str) -> bool {
606            std::process::Command::new(program)
607                .arg("--version")
608                .stdout(std::process::Stdio::null())
609                .stderr(std::process::Stdio::null())
610                .status()
611                .map(|s| s.success())
612                .unwrap_or(false)
613        }
614        on_path("cpp") && on_path("dtc")
615    }
616
617    /// A minimal overlay carrying the `dt-bindings` interrupt macros, so the
618    /// `cpp` preprocessing step is actually exercised.
619    const SAMPLE_OVERLAY: &str = "/dts-v1/;\n/plugin/;\n\n\
620        / {\n\
621        \treserved-memory {\n\
622        \t\t#address-cells = <0x2>;\n\
623        \t\t#size-cells = <0x2>;\n\
624        \t\tranges;\n\
625        \t\tipc_shm: ipc-shm@20400000 {\n\
626        \t\t\treg = <0x0 0x20400000 0x0 0x8000>;\n\
627        \t\t\tno-map;\n\
628        \t\t};\n\
629        \t};\n\
630        \thsem@6240000 {\n\
631        \t\tcompatible = \"generic-uio\";\n\
632        \t\tinterrupts = <GIC_SPI 199 IRQ_TYPE_LEVEL_HIGH>;\n\
633        \t\tstatus = \"okay\";\n\
634        \t};\n\
635        };\n";
636
637    #[tokio::test]
638    async fn writes_and_verifies_overlay_before_systemd() {
639        if !dt_tools_available() {
640            eprintln!("skipping: `cpp`/`dtc` not on PATH");
641            return;
642        }
643        let base = scratch("dts-ok");
644        let app = base.join("app-bin");
645        std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
646
647        let layout = DistLayout::new(base.join("dist"));
648        let dts = vec![(
649            "consortium-uio.dtso".to_string(),
650            SAMPLE_OVERLAY.to_string(),
651        )];
652        let units = vec![("consortium-app.service".to_string(), "[Unit]\n".to_string())];
653
654        let firmware = HashMap::new();
655        assemble(
656            &layout,
657            DistAssemblyInputs {
658                firmware: &firmware,
659                firmware_originals: &firmware,
660                application: &app,
661                ta: None,
662                hmi: None,
663                dts_files: &dts,
664                kconfig_fragments: &[],
665                boot_files: &[],
666                systemd_units: &units,
667            },
668        )
669        .await
670        .expect("assemble + dtc verify succeeds");
671
672        // The overlay lands under dts/linux/, and the preprocessing temp is gone.
673        let overlay = layout.dts_linux_dir().join("consortium-uio.dtso");
674        assert!(overlay.exists());
675        assert!(!overlay.with_extension("dts.pp").exists());
676        assert!(layout.systemd_dir().join("consortium-app.service").exists());
677
678        let _ = std::fs::remove_dir_all(&base);
679    }
680
681    #[tokio::test]
682    async fn rejects_malformed_overlay() {
683        if !dt_tools_available() {
684            eprintln!("skipping: `cpp`/`dtc` not on PATH");
685            return;
686        }
687        let base = scratch("dts-bad");
688        let app = base.join("app-bin");
689        std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
690
691        let layout = DistLayout::new(base.join("dist"));
692        // Missing closing brace: `dtc` rejects it.
693        let dts = vec![("broken.dtso".to_string(), "/dts-v1/;\n/ {\n".to_string())];
694
695        let firmware = HashMap::new();
696        let err = assemble(
697            &layout,
698            DistAssemblyInputs {
699                firmware: &firmware,
700                firmware_originals: &firmware,
701                application: &app,
702                ta: None,
703                hmi: None,
704                dts_files: &dts,
705                kconfig_fragments: &[],
706                boot_files: &[],
707                systemd_units: &[],
708            },
709        )
710        .await
711        .expect_err("malformed overlay is rejected");
712        assert!(matches!(
713            err,
714            DistAssemblyError::DtsVerify { name, .. } if name == "broken.dtso"
715        ));
716
717        let _ = std::fs::remove_dir_all(&base);
718    }
719}