1use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use consortium_cfg_common::config::{HmiEngine, VisibleCore};
29use thiserror::Error;
30use tokio::process::Command;
31
32const 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
47pub struct DistLayout {
49 pub root: PathBuf,
52}
53
54impl DistLayout {
55 pub fn new(root: impl Into<PathBuf>) -> Self {
57 Self { root: root.into() }
58 }
59
60 pub fn firmware_dir(&self) -> PathBuf {
62 self.root.join("firmware")
63 }
64
65 pub fn app_dir(&self) -> PathBuf {
67 self.root.join("app")
68 }
69
70 pub fn ta_dir(&self) -> PathBuf {
72 self.root.join("ta")
73 }
74
75 pub fn www_dir(&self) -> PathBuf {
77 self.root.join("www")
78 }
79
80 pub fn hmi_dir(&self) -> PathBuf {
82 self.root.join("hmi")
83 }
84
85 pub fn dts_linux_dir(&self) -> PathBuf {
87 self.root.join("dts").join("linux")
88 }
89
90 pub fn kernel_dir(&self) -> PathBuf {
92 self.root.join("kernel")
93 }
94
95 pub fn boot_dir(&self) -> PathBuf {
97 self.root.join("boot")
98 }
99
100 pub fn systemd_dir(&self) -> PathBuf {
102 self.root.join("systemd")
103 }
104}
105
106#[derive(Error, Debug)]
108pub enum DistAssemblyError {
109 #[error("dist assembly I/O error at {path}: {source}")]
111 Io {
112 path: String,
114 source: std::io::Error,
116 },
117
118 #[error("`cpp` not found on PATH; needed to preprocess device-tree overlays before `dtc`")]
121 CppNotFound,
122
123 #[error("`dtc` not found on PATH; install the device-tree compiler to verify UIO overlays")]
125 DtcNotFound,
126
127 #[error("device-tree overlay {name} failed verification:\n{message}")]
129 DtsVerify {
130 name: String,
132 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
144pub struct DistAssemblyInputs<'a> {
166 pub firmware: &'a HashMap<VisibleCore, PathBuf>,
167 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#[derive(Clone, Copy)]
181pub struct HmiStaging<'a> {
182 pub engine: HmiEngine,
184 pub app: Option<&'a str>,
186 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 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 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 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 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 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 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 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 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 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
324fn 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
337async 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
348pub(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
375async fn verify_dts(name: &str, dts: &Path) -> Result<(), DistAssemblyError> {
385 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 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 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![("consortium.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("consortium.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 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 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 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 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 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 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}