use std::collections::HashMap;
use std::path::{Path, PathBuf};
use consortium_cfg_common::config::{HmiEngine, VisibleCore};
use thiserror::Error;
use tokio::process::Command;
const DT_BINDING_DEFINES: &[&str] = &[
"-DGIC_SPI=0",
"-DGIC_PPI=1",
"-DIRQ_TYPE_NONE=0",
"-DIRQ_TYPE_EDGE_RISING=1",
"-DIRQ_TYPE_EDGE_FALLING=2",
"-DIRQ_TYPE_EDGE_BOTH=3",
"-DIRQ_TYPE_LEVEL_HIGH=4",
"-DIRQ_TYPE_LEVEL_LOW=8",
];
pub struct DistLayout {
pub root: PathBuf,
}
impl DistLayout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn firmware_dir(&self) -> PathBuf {
self.root.join("firmware")
}
pub fn app_dir(&self) -> PathBuf {
self.root.join("app")
}
pub fn ta_dir(&self) -> PathBuf {
self.root.join("ta")
}
pub fn www_dir(&self) -> PathBuf {
self.root.join("www")
}
pub fn hmi_dir(&self) -> PathBuf {
self.root.join("hmi")
}
pub fn dts_linux_dir(&self) -> PathBuf {
self.root.join("dts").join("linux")
}
pub fn kernel_dir(&self) -> PathBuf {
self.root.join("kernel")
}
pub fn boot_dir(&self) -> PathBuf {
self.root.join("boot")
}
pub fn systemd_dir(&self) -> PathBuf {
self.root.join("systemd")
}
}
#[derive(Error, Debug)]
pub enum DistAssemblyError {
#[error("dist assembly I/O error at {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("`cpp` not found on PATH; needed to preprocess device-tree overlays before `dtc`")]
CppNotFound,
#[error("`dtc` not found on PATH; install the device-tree compiler to verify UIO overlays")]
DtcNotFound,
#[error("device-tree overlay {name} failed verification:\n{message}")]
DtsVerify {
name: String,
message: String,
},
}
fn io(path: impl AsRef<Path>, source: std::io::Error) -> DistAssemblyError {
DistAssemblyError::Io {
path: path.as_ref().display().to_string(),
source,
}
}
pub struct DistAssemblyInputs<'a> {
pub firmware: &'a HashMap<VisibleCore, PathBuf>,
pub firmware_originals: &'a HashMap<VisibleCore, PathBuf>,
pub application: &'a Path,
pub ta: Option<&'a Path>,
pub hmi: Option<HmiStaging<'a>>,
pub dts_files: &'a [(String, String)],
pub kconfig_fragments: &'a [(String, String)],
pub boot_files: &'a [(String, String)],
pub systemd_units: &'a [(String, String)],
}
#[derive(Clone, Copy)]
pub struct HmiStaging<'a> {
pub engine: HmiEngine,
pub app: Option<&'a str>,
pub dist: &'a Path,
}
pub async fn assemble(
layout: &DistLayout,
inputs: DistAssemblyInputs<'_>,
) -> Result<(), DistAssemblyError> {
let DistAssemblyInputs {
firmware,
firmware_originals,
application,
ta,
hmi,
dts_files,
kconfig_fragments,
boot_files,
systemd_units,
} = inputs;
let firmware_dir = layout.firmware_dir();
tokio::fs::create_dir_all(&firmware_dir)
.await
.map_err(|e| io(&firmware_dir, e))?;
for (core, elf) in firmware {
let dest = firmware_dir.join(format!("{core}.elf"));
copy_file(elf, &dest).await?;
}
for (core, elf) in firmware_originals {
let dest = firmware_dir.join(format!("{core}_original.elf"));
copy_file(elf, &dest).await?;
}
let app_dir = layout.app_dir();
tokio::fs::create_dir_all(&app_dir)
.await
.map_err(|e| io(&app_dir, e))?;
let app_dest = app_dir.join(file_name(application)?);
copy_file(application, &app_dest).await?;
if let Some(ta) = ta {
let ta_dir = layout.ta_dir();
tokio::fs::create_dir_all(&ta_dir)
.await
.map_err(|e| io(&ta_dir, e))?;
let ta_dest = ta_dir.join(file_name(ta)?);
copy_file(ta, &ta_dest).await?;
}
if let Some(hmi) = hmi {
match hmi.engine {
HmiEngine::Cog => {
let www = layout.www_dir();
copy_dir_recursive(hmi.dist, &www).await?;
}
HmiEngine::Pocket => {
let app = hmi.app.unwrap_or_default();
let hmi_dir = layout.hmi_dir();
tokio::fs::create_dir_all(&hmi_dir)
.await
.map_err(|e| io(&hmi_dir, e))?;
for ext in ["js", "pak"] {
let name = format!("{app}.{ext}");
copy_file(&hmi.dist.join(&name), &hmi_dir.join(&name)).await?;
}
}
}
}
if !dts_files.is_empty() {
let dts_dir = layout.dts_linux_dir();
tokio::fs::create_dir_all(&dts_dir)
.await
.map_err(|e| io(&dts_dir, e))?;
for (name, contents) in dts_files {
let dest = dts_dir.join(name);
tokio::fs::write(&dest, contents)
.await
.map_err(|e| io(&dest, e))?;
if name.ends_with(".dtso") {
verify_dts(name, &dest).await?;
}
}
}
if !kconfig_fragments.is_empty() {
let kernel_dir = layout.kernel_dir();
tokio::fs::create_dir_all(&kernel_dir)
.await
.map_err(|e| io(&kernel_dir, e))?;
for (name, contents) in kconfig_fragments {
let dest = kernel_dir.join(name);
tokio::fs::write(&dest, contents)
.await
.map_err(|e| io(&dest, e))?;
}
}
if !boot_files.is_empty() {
let boot_dir = layout.boot_dir();
tokio::fs::create_dir_all(&boot_dir)
.await
.map_err(|e| io(&boot_dir, e))?;
for (name, contents) in boot_files {
let dest = boot_dir.join(name);
tokio::fs::write(&dest, contents)
.await
.map_err(|e| io(&dest, e))?;
}
}
if !systemd_units.is_empty() {
let systemd_dir = layout.systemd_dir();
tokio::fs::create_dir_all(&systemd_dir)
.await
.map_err(|e| io(&systemd_dir, e))?;
for (name, contents) in systemd_units {
let dest = systemd_dir.join(name);
tokio::fs::write(&dest, contents)
.await
.map_err(|e| io(&dest, e))?;
}
}
Ok(())
}
fn file_name(path: &Path) -> Result<String, DistAssemblyError> {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_string())
.ok_or_else(|| {
io(
path,
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no file name"),
)
})
}
async fn copy_file(src: &Path, dest: &Path) -> Result<(), DistAssemblyError> {
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| io(parent, e))?;
}
tokio::fs::copy(src, dest).await.map_err(|e| io(src, e))?;
Ok(())
}
pub(crate) fn copy_dir_recursive<'a>(
src: &'a Path,
dest: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DistAssemblyError>> + Send + 'a>>
{
Box::pin(async move {
tokio::fs::create_dir_all(dest)
.await
.map_err(|e| io(dest, e))?;
let mut entries = tokio::fs::read_dir(src).await.map_err(|e| io(src, e))?;
while let Some(entry) = entries.next_entry().await.map_err(|e| io(src, e))? {
let file_type = entry.file_type().await.map_err(|e| io(entry.path(), e))?;
let child_dest = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &child_dest).await?;
} else {
copy_file(&entry.path(), &child_dest).await?;
}
}
Ok(())
})
}
async fn verify_dts(name: &str, dts: &Path) -> Result<(), DistAssemblyError> {
let preprocessed = dts.with_extension("dts.pp");
let cpp = Command::new("cpp")
.args(["-nostdinc", "-undef", "-x", "assembler-with-cpp"])
.args(DT_BINDING_DEFINES)
.arg(dts)
.arg("-o")
.arg(&preprocessed)
.output()
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => DistAssemblyError::CppNotFound,
_ => io(dts, e),
})?;
if !cpp.status.success() {
return Err(DistAssemblyError::DtsVerify {
name: name.to_string(),
message: String::from_utf8_lossy(&cpp.stderr).into_owned(),
});
}
let dtc = Command::new("dtc")
.args(["-I", "dts", "-O", "dtb", "-o", "-"])
.arg(&preprocessed)
.output()
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => DistAssemblyError::DtcNotFound,
_ => io(&preprocessed, e),
});
let _ = tokio::fs::remove_file(&preprocessed).await;
let dtc = dtc?;
if !dtc.status.success() {
return Err(DistAssemblyError::DtsVerify {
name: name.to_string(),
message: String::from_utf8_lossy(&dtc.stderr).into_owned(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("consortium-dist-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch");
dir
}
#[tokio::test]
async fn assembles_full_tree() {
let base = scratch("assemble");
let sources = base.join("src");
std::fs::create_dir_all(&sources).unwrap();
let cm33_elf = sources.join("cm33.elf");
std::fs::write(&cm33_elf, [0x7f, b'E', b'L', b'F']).unwrap();
let cm33_original = sources.join("cm33_original.elf");
std::fs::write(&cm33_original, [0x7f, b'E', b'L', b'F', b'O']).unwrap();
let app = sources.join("melt-pot-app");
std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
let ta = sources.join("uuid.ta");
std::fs::write(&ta, b"signed-ta").unwrap();
let www = sources.join("dist");
std::fs::create_dir_all(www.join("assets")).unwrap();
std::fs::write(www.join("index.html"), b"<html>").unwrap();
std::fs::write(www.join("assets").join("app.js"), b"//js").unwrap();
let layout = DistLayout::new(base.join("dist"));
let firmware = HashMap::from([(
VisibleCore::Mcore("cm33".parse().unwrap()),
cm33_elf.clone(),
)]);
let firmware_originals = HashMap::from([(
VisibleCore::Mcore("cm33".parse().unwrap()),
cm33_original.clone(),
)]);
let units = vec![("consortium-app.service".to_string(), "[Unit]\n".to_string())];
let kconfig = vec![("consortium.cfg".to_string(), "CONFIG_UIO=y\n".to_string())];
let boot = vec![("boot.cmd".to_string(), "setenv bootargs\n".to_string())];
assemble(
&layout,
DistAssemblyInputs {
firmware: &firmware,
firmware_originals: &firmware_originals,
application: &app,
ta: Some(&ta),
hmi: Some(HmiStaging {
engine: HmiEngine::Cog,
app: None,
dist: &www,
}),
dts_files: &[],
kconfig_fragments: &kconfig,
boot_files: &boot,
systemd_units: &units,
},
)
.await
.expect("assemble succeeds");
assert!(layout.firmware_dir().join("cm33.elf").exists());
assert!(layout.firmware_dir().join("cm33_original.elf").exists());
assert!(layout.app_dir().join("melt-pot-app").exists());
assert!(layout.ta_dir().join("uuid.ta").exists());
assert!(layout.www_dir().join("index.html").exists());
assert!(layout.www_dir().join("assets").join("app.js").exists());
assert_eq!(
std::fs::read_to_string(layout.kernel_dir().join("consortium.cfg")).unwrap(),
"CONFIG_UIO=y\n"
);
assert_eq!(
std::fs::read_to_string(layout.boot_dir().join("boot.cmd")).unwrap(),
"setenv bootargs\n"
);
assert_eq!(
std::fs::read_to_string(layout.systemd_dir().join("consortium-app.service")).unwrap(),
"[Unit]\n"
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn skips_optional_ta_and_hmi() {
let base = scratch("minimal");
let app = base.join("app-bin");
std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
let layout = DistLayout::new(base.join("dist"));
let firmware = HashMap::new();
assemble(
&layout,
DistAssemblyInputs {
firmware: &firmware,
firmware_originals: &firmware,
application: &app,
ta: None,
hmi: None,
dts_files: &[],
kconfig_fragments: &[],
boot_files: &[],
systemd_units: &[],
},
)
.await
.expect("assemble succeeds");
assert!(layout.app_dir().join("app-bin").exists());
assert!(!layout.ta_dir().exists());
assert!(!layout.www_dir().exists());
assert!(!layout.dts_linux_dir().exists());
assert!(!layout.kernel_dir().exists());
assert!(!layout.boot_dir().exists());
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn stages_pocket_bundle_and_pak_into_hmi_dir() {
let base = scratch("pocket");
let app = base.join("app-bin");
std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
let src = base.join("pocket-dist");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("hero-main.js"), b"//bundle").unwrap();
std::fs::write(src.join("hero-main.pak"), b"PAK").unwrap();
std::fs::write(src.join("hero.js"), b"//component-only").unwrap();
let layout = DistLayout::new(base.join("dist"));
let firmware = HashMap::new();
assemble(
&layout,
DistAssemblyInputs {
firmware: &firmware,
firmware_originals: &firmware,
application: &app,
ta: None,
hmi: Some(HmiStaging {
engine: HmiEngine::Pocket,
app: Some("hero-main"),
dist: &src,
}),
dts_files: &[],
kconfig_fragments: &[],
boot_files: &[],
systemd_units: &[],
},
)
.await
.expect("assemble succeeds");
assert_eq!(
std::fs::read_to_string(layout.hmi_dir().join("hero-main.js")).unwrap(),
"//bundle"
);
assert!(layout.hmi_dir().join("hero-main.pak").exists());
assert!(!layout.hmi_dir().join("hero.js").exists());
assert!(!layout.www_dir().exists());
let _ = std::fs::remove_dir_all(&base);
}
fn dt_tools_available() -> bool {
fn on_path(program: &str) -> bool {
std::process::Command::new(program)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
on_path("cpp") && on_path("dtc")
}
const SAMPLE_OVERLAY: &str = "/dts-v1/;\n/plugin/;\n\n\
/ {\n\
\treserved-memory {\n\
\t\t#address-cells = <0x2>;\n\
\t\t#size-cells = <0x2>;\n\
\t\tranges;\n\
\t\tipc_shm: ipc-shm@20400000 {\n\
\t\t\treg = <0x0 0x20400000 0x0 0x8000>;\n\
\t\t\tno-map;\n\
\t\t};\n\
\t};\n\
\thsem@6240000 {\n\
\t\tcompatible = \"generic-uio\";\n\
\t\tinterrupts = <GIC_SPI 199 IRQ_TYPE_LEVEL_HIGH>;\n\
\t\tstatus = \"okay\";\n\
\t};\n\
};\n";
#[tokio::test]
async fn writes_and_verifies_overlay_before_systemd() {
if !dt_tools_available() {
eprintln!("skipping: `cpp`/`dtc` not on PATH");
return;
}
let base = scratch("dts-ok");
let app = base.join("app-bin");
std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
let layout = DistLayout::new(base.join("dist"));
let dts = vec![(
"consortium-uio.dtso".to_string(),
SAMPLE_OVERLAY.to_string(),
)];
let units = vec![("consortium-app.service".to_string(), "[Unit]\n".to_string())];
let firmware = HashMap::new();
assemble(
&layout,
DistAssemblyInputs {
firmware: &firmware,
firmware_originals: &firmware,
application: &app,
ta: None,
hmi: None,
dts_files: &dts,
kconfig_fragments: &[],
boot_files: &[],
systemd_units: &units,
},
)
.await
.expect("assemble + dtc verify succeeds");
let overlay = layout.dts_linux_dir().join("consortium-uio.dtso");
assert!(overlay.exists());
assert!(!overlay.with_extension("dts.pp").exists());
assert!(layout.systemd_dir().join("consortium-app.service").exists());
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn rejects_malformed_overlay() {
if !dt_tools_available() {
eprintln!("skipping: `cpp`/`dtc` not on PATH");
return;
}
let base = scratch("dts-bad");
let app = base.join("app-bin");
std::fs::write(&app, [0x7f, b'E', b'L', b'F']).unwrap();
let layout = DistLayout::new(base.join("dist"));
let dts = vec![("broken.dtso".to_string(), "/dts-v1/;\n/ {\n".to_string())];
let firmware = HashMap::new();
let err = assemble(
&layout,
DistAssemblyInputs {
firmware: &firmware,
firmware_originals: &firmware,
application: &app,
ta: None,
hmi: None,
dts_files: &dts,
kconfig_fragments: &[],
boot_files: &[],
systemd_units: &[],
},
)
.await
.expect_err("malformed overlay is rejected");
assert!(matches!(
err,
DistAssemblyError::DtsVerify { name, .. } if name == "broken.dtso"
));
let _ = std::fs::remove_dir_all(&base);
}
}