use std::fmt::Write as _;
use std::io::{ErrorKind, Read};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::{Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use crate::config::{HilConfig, UartBridge, RENODE_ARM64_IMAGE};
use crate::output::HilOutput;
use crate::platform::{CpuInit, ReplSource};
pub const RUNTIME_ENV: &str = "HILT_CONTAINER_RUNTIME";
const CONTAINER_WORK: &str = "/hil";
pub const RENODE_ARM64_VERSION: &str = "1.16.1";
fn renode_arm64_containerfile() -> String {
let v = RENODE_ARM64_VERSION;
format!(
r#"FROM docker.io/library/debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends libicu72 ca-certificates curl \
&& curl -fsSL -o /tmp/renode.tar.gz \
https://github.com/renode/renode/releases/download/v{v}/renode-{v}.linux-arm64-portable-dotnet.tar.gz \
&& mkdir -p /opt && tar xzf /tmp/renode.tar.gz -C /opt && rm /tmp/renode.tar.gz \
&& ln -s /opt/renode_{v}-dotnet_portable /opt/renode \
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/opt/renode/renode"]
"#
)
}
fn image_present(runtime: &str, image: &str) -> bool {
command_succeeds(runtime, &["image", "inspect", image])
}
fn ensure_renode_image(runtime: &str, image: &str) {
static ENSURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
let mut ensured = ENSURED.lock().unwrap_or_else(PoisonError::into_inner);
if ensured.iter().any(|i| i == image) {
return;
}
if image_present(runtime, image) {
} else if image == RENODE_ARM64_IMAGE {
eprintln!("hilt: building native arm64 Renode image `{image}` (first run, one-time)…");
let dir = std::env::temp_dir().join(format!("hilt-renode-build-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("failed to create Renode image build dir");
std::fs::write(dir.join("Containerfile"), renode_arm64_containerfile())
.expect("failed to write Containerfile");
let status = Command::new(runtime)
.args(["build", "--platform", "linux/arm64", "-t", image, "-f"])
.arg(dir.join("Containerfile"))
.arg(&dir)
.status()
.expect("failed to invoke container build");
let _ = std::fs::remove_dir_all(&dir);
assert!(
status.success(),
"failed to build native arm64 Renode image `{image}`"
);
} else {
eprintln!("hilt: pulling Renode image `{image}` (first run, one-time)…");
let status = Command::new(runtime)
.args(["pull", image])
.status()
.expect("failed to invoke container pull");
assert!(status.success(), "failed to pull Renode image `{image}`");
}
ensured.push(image.to_string());
}
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
.output()
.is_ok_and(|o| o.status.success())
}
fn find_container_runtime() -> Option<String> {
if let Ok(rt) = std::env::var(RUNTIME_ENV) {
return Some(rt);
}
["podman", "docker"]
.iter()
.find(|c| command_succeeds(c, &["--version"]))
.map(ToString::to_string)
}
#[must_use]
pub fn detect_container_runtime() -> String {
find_container_runtime().expect("no container runtime found -- install podman or docker")
}
#[must_use]
pub fn container_runtime_available() -> bool {
find_container_runtime().is_some()
}
#[must_use]
pub fn renode_bin() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HILT_RENODE_BIN") {
let p = PathBuf::from(p);
if p.exists() {
return Some(p);
}
}
if command_succeeds("renode", &["--version"]) {
return Some(PathBuf::from("renode"));
}
let opt = PathBuf::from("/opt/renode/renode");
opt.exists().then_some(opt)
}
#[must_use]
pub fn renode_available() -> bool {
renode_bin().is_some()
}
fn cleanup_container(runtime: &str, cidfile: &Path) {
let Ok(cid) = std::fs::read_to_string(cidfile) else {
return;
};
let cid = cid.trim();
if cid.is_empty() {
return;
}
let mut rm = Command::new(runtime);
rm.args(["rm", "-f", cid]);
if runtime.contains("podman") {
rm.args(["-t", "0"]);
}
let _ = rm.stdout(Stdio::null()).status();
}
pub fn setup_vcan(iface: &str) {
Command::new("modprobe").arg("vcan").status().ok();
Command::new("ip")
.args(["link", "del", iface])
.status()
.ok();
let status = Command::new("ip")
.args(["link", "add", "dev", iface, "type", "vcan"])
.status()
.expect("failed to create vcan interface");
assert!(
status.success(),
"failed to create {iface} -- try with sudo or in a container"
);
Command::new("ip")
.args(["link", "set", iface, "up", "mtu", "72"])
.status()
.expect("failed to bring up vcan interface");
}
#[must_use]
pub fn interface_available(iface: &str) -> bool {
command_succeeds("ip", &["link", "show", iface])
}
pub fn run_with_timeout_and_cleanup<F>(
command: Command,
wall_timeout_secs: u32,
cleanup: F,
) -> (Output, bool)
where
F: FnOnce(),
{
let deadline = Instant::now() + Duration::from_secs(wall_timeout_secs.into());
Captured::spawn(command).finish(deadline, cleanup)
}
struct Captured {
child: Child,
stdout: thread::JoinHandle<Vec<u8>>,
stderr: thread::JoinHandle<Vec<u8>>,
}
impl Captured {
fn spawn(mut command: Command) -> Self {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn container runtime: {e}"));
let stdout = drain(
child.stdout.take().expect("child stdout not captured"),
"stdout",
);
let stderr = drain(
child.stderr.take().expect("child stderr not captured"),
"stderr",
);
Self {
child,
stdout,
stderr,
}
}
fn finish(mut self, deadline: Instant, cleanup: impl FnOnce()) -> (Output, bool) {
let mut timed_out = false;
let status = loop {
match self
.child
.try_wait()
.unwrap_or_else(|e| panic!("failed to poll container runtime: {e}"))
{
Some(status) => break status,
None if Instant::now() >= deadline => {
timed_out = true;
let _ = self.child.kill();
break self.child.wait().unwrap_or_else(|e| {
panic!("failed to reap timed-out container runtime: {e}")
});
}
None => thread::sleep(Duration::from_millis(100)),
}
};
cleanup();
let join = |h: thread::JoinHandle<Vec<u8>>| {
h.join()
.unwrap_or_else(|_| panic!("failed to join output reader"))
};
(
Output {
status,
stdout: join(self.stdout),
stderr: join(self.stderr),
},
timed_out,
)
}
}
fn drain(mut pipe: impl Read + Send + 'static, name: &'static str) -> thread::JoinHandle<Vec<u8>> {
thread::spawn(move || {
let mut buf = Vec::new();
pipe.read_to_end(&mut buf)
.unwrap_or_else(|e| panic!("failed to read child {name}: {e}"));
buf
})
}
pub fn run_renode_container<F>(command: Command, timeout_secs: u32, cleanup: F) -> (Output, bool)
where
F: FnOnce(),
{
run_with_timeout_and_cleanup(
command,
crate::config::default_wall_timeout(timeout_secs),
cleanup,
)
}
#[derive(Debug, Clone, Copy)]
pub struct ElfInfo {
pub vtor: u32,
pub sp: u32,
pub pc: u32,
pub hook_addr: Option<u32>,
pub fail_addr: Option<u32>,
pub panic_addr: Option<u32>,
}
#[must_use]
pub fn extract_elf_info(firmware_elf: &Path, marker: &str, fail_marker: &str) -> ElfInfo {
let elf = std::fs::read(firmware_elf)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", firmware_elf.display()));
let (vtor, sp, pc) = elf_vector_table(&elf).unwrap_or_else(|| {
panic!(
"failed to extract vector table from {}",
firmware_elf.display()
)
});
ElfInfo {
vtor,
sp,
pc,
hook_addr: elf_symbol(&elf, marker),
fail_addr: elf_symbol(&elf, fail_marker),
panic_addr: elf_symbol_by(&elf, |n| {
n == b"rust_begin_unwind" || n.ends_with(b"17rust_begin_unwind")
}),
}
}
#[must_use]
pub fn find_symbol_address(firmware_elf: &Path, symbol: &str) -> Option<u32> {
elf_symbol(&std::fs::read(firmware_elf).ok()?, symbol)
}
fn le_u16(d: &[u8], off: usize) -> Option<u16> {
Some(u16::from_le_bytes(d.get(off..off + 2)?.try_into().ok()?))
}
fn le_u32(d: &[u8], off: usize) -> Option<u32> {
Some(u32::from_le_bytes(d.get(off..off + 4)?.try_into().ok()?))
}
fn c_str(d: &[u8], off: usize) -> Option<&[u8]> {
let s = d.get(off..)?;
Some(&s[..s.iter().position(|&b| b == 0)?])
}
struct Section {
name: usize,
kind: u32,
addr: u32,
offset: usize,
size: usize,
link: usize,
}
fn elf_sections(d: &[u8]) -> Option<Vec<Section>> {
if d.get(..6)? != b"\x7fELF\x01\x01" {
return None;
}
let shoff = le_u32(d, 0x20)? as usize;
let shentsize = usize::from(le_u16(d, 0x2E)?);
(0..usize::from(le_u16(d, 0x30)?))
.map(|i| {
let h = shoff + i * shentsize;
Some(Section {
name: le_u32(d, h)? as usize,
kind: le_u32(d, h + 4)?,
addr: le_u32(d, h + 12)?,
offset: le_u32(d, h + 16)? as usize,
size: le_u32(d, h + 20)? as usize,
link: le_u32(d, h + 24)? as usize,
})
})
.collect()
}
fn elf_vector_table(d: &[u8]) -> Option<(u32, u32, u32)> {
let sections = elf_sections(d)?;
let names = sections.get(usize::from(le_u16(d, 0x32)?))?;
let vt = sections
.iter()
.find(|s| c_str(d, names.offset + s.name) == Some(b".vector_table"))?;
let pc = le_u32(d, vt.offset + 4)? & !1;
(pc != 0).then_some((vt.addr, le_u32(d, vt.offset)?, pc))
}
fn elf_symbol(d: &[u8], symbol: &str) -> Option<u32> {
elf_symbol_by(d, |name| name == symbol.as_bytes())
}
fn elf_symbol_by(d: &[u8], matches: impl Fn(&[u8]) -> bool) -> Option<u32> {
const SHT_SYMTAB: u32 = 2;
const STT_FUNC: u8 = 2;
let sections = elf_sections(d)?;
let symtab = sections.iter().find(|s| s.kind == SHT_SYMTAB)?;
let strtab = sections.get(symtab.link)?;
(0..symtab.size / 16).find_map(|i| {
let e = symtab.offset + i * 16;
let name = c_str(d, strtab.offset + le_u32(d, e)? as usize)?;
if !matches(name) || le_u16(d, e + 14)? == 0 {
return None;
}
let value = le_u32(d, e + 4)?;
Some(if d.get(e + 12)? & 0xF == STT_FUNC {
value & !1
} else {
value
})
})
}
struct ReadyMachine {
name: String,
elf_ref: String,
repl_ref: String,
mips: Option<u32>,
can_connector: Option<&'static str>,
info: Option<ElfInfo>,
socketcan_bridge: bool,
uart_bridge: Option<UartBridge>,
uart: Option<&'static str>,
}
fn render_resc(
machines: &[ReadyMachine],
socketcan_iface: &str,
stubs_ref: Option<&str>,
timeout_secs: u32,
) -> String {
let hub = machines.len() > 1 || machines.iter().any(|m| m.socketcan_bridge);
const QUIT: &str = "; monitor.Parse('quit')";
let host_bridged = machines
.iter()
.any(|m| m.socketcan_bridge || m.uart_bridge.is_some());
let pass_quit = if machines.len() == 1 && !host_bridged {
QUIT
} else {
""
};
let mut s = String::from("using sysbus\n\n");
if hub {
s.push_str("emulation CreateCANHub \"canHub\" False\n\n");
}
for m in machines {
let _ = writeln!(s, "mach create \"{}\"", m.name);
let _ = writeln!(s, "machine LoadPlatformDescription @{}", m.repl_ref);
if let Some(stubs) = stubs_ref {
let _ = writeln!(s, "machine LoadPlatformDescription @{stubs}");
}
if let Some(mips) = m.mips {
let _ = writeln!(s, "cpu PerformanceInMips {mips}");
}
let _ = writeln!(s, "sysbus LoadELF @{}", m.elf_ref);
if let Some(uart) = m.uart {
let _ = writeln!(s, "showAnalyzer {uart}");
}
if let Some(info) = m.info {
let _ = writeln!(s, "cpu VectorTableOffset {:#010X}", info.vtor);
let _ = writeln!(s, "cpu SP {:#010X}", info.sp);
let _ = writeln!(s, "cpu PC {:#010X}", info.pc);
if let Some(addr) = info.hook_addr {
let _ = writeln!(
s,
"cpu AddHook {addr:#010X} \"self.Log(LogLevel.Warning, 'HIL OK'){pass_quit}\""
);
}
for (addr, what) in [(info.fail_addr, "fail marker"), (info.panic_addr, "panic")] {
if let Some(addr) = addr {
let _ = writeln!(
s,
"cpu AddHook {addr:#010X} \"self.Log(LogLevel.Error, 'HIL FAIL ({what})'){QUIT}\""
);
}
}
}
if hub {
if let Some(connector) = m.can_connector {
let _ = writeln!(s, "connector Connect {connector} canHub");
}
if m.socketcan_bridge {
let _ = writeln!(s, "machine CreateSocketCANBridge \"{socketcan_iface}\"");
let _ = writeln!(s, "connector Connect {socketcan_iface} canHub");
}
}
if let Some(bridge) = &m.uart_bridge {
let term = format!("{}_uart", m.name);
let _ = writeln!(
s,
"emulation CreateServerSocketTerminal {} \"{term}\" false",
bridge.host_port
);
let _ = writeln!(s, "connector Connect {} {term}", bridge.uart);
}
s.push_str("mach clear\n\n");
}
s.push_str("start\n");
let _ = writeln!(s, "emulation RunFor \"{}\"", format_run_for(timeout_secs));
s.push_str("quit\n");
s
}
pub(crate) fn format_run_for(total_secs: u32) -> String {
format!(
"{:02}:{:02}:{:02}",
total_secs / 3600,
(total_secs % 3600) / 60,
total_secs % 60
)
}
pub struct RenodeRunner {
config: HilConfig,
}
struct CleanupGuard(PathBuf);
impl Drop for CleanupGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl RenodeRunner {
#[must_use]
pub fn new(config: HilConfig) -> Self {
Self { config }
}
#[must_use]
pub fn config(&self) -> &HilConfig {
&self.config
}
#[must_use]
pub fn run(&self) -> HilOutput {
self.spawn().wait()
}
#[must_use]
pub fn spawn(&self) -> RunningHil {
let work_dir = work_dir_for(&self.config);
std::fs::create_dir_all(&work_dir).expect("failed to create HIL work dir");
let guard = CleanupGuard(work_dir.clone());
let runtime = find_container_runtime();
let use_container = runtime.is_some();
let repl_base = if use_container {
CONTAINER_WORK.to_string()
} else {
work_dir.display().to_string()
};
let bridge_ports = assign_bridge_ports(
self.config
.machines
.iter()
.map(|m| m.uart_bridge.as_ref().map(|b| b.host_port)),
use_container && !self.config.needs_host_network(),
);
let mut ready = Vec::with_capacity(self.config.machines.len());
let mut elf_paths = Vec::new();
let mut uart_ports = Vec::new();
for (m, ports) in self.config.machines.iter().zip(&bridge_ports) {
let elf = m.firmware_elf.canonicalize().unwrap_or_else(|e| {
panic!(
"firmware ELF not found at {}: {e}",
m.firmware_elf.display()
)
});
elf_paths.push(elf.clone());
let repl_ref = match m.platform.repl {
ReplSource::ImagePath(p) => p.to_string(),
ReplSource::Embedded(body) => {
let file = format!("{}.repl", m.name);
std::fs::write(work_dir.join(&file), body).expect("failed to write .repl");
format!("{repl_base}/{file}")
}
};
let info = (m.platform.init == CpuInit::VectorTable)
.then(|| extract_elf_info(&elf, &self.config.marker, &self.config.fail_marker));
ready.push(ReadyMachine {
name: m.name.clone(),
elf_ref: elf.display().to_string(),
repl_ref,
mips: m.platform.mips,
can_connector: m.platform.can_connector,
info,
socketcan_bridge: m.socketcan_bridge,
uart_bridge: m
.uart_bridge
.as_ref()
.zip(*ports)
.map(|(b, (renode, host))| {
uart_ports.push((m.name.clone(), renode, host));
UartBridge {
host_port: renode,
uart: b.uart.clone(),
}
}),
uart: m.platform.uart,
});
}
let stubs = self.config.stubs_repl.as_ref().map(|p| {
p.canonicalize()
.unwrap_or_else(|e| panic!("stubs REPL not found at {}: {e}", p.display()))
});
let resc = render_resc(
&ready,
&self.config.socketcan_iface,
stubs.as_ref().map(|p| p.display().to_string()).as_deref(),
self.config.timeout_secs,
);
std::fs::write(work_dir.join("test.resc"), &resc).expect("failed to write .resc");
let wall_timeout = self.config.resolved_wall_timeout_secs();
let (command, container) = if let Some(runtime) = runtime {
ensure_renode_image(&runtime, &self.config.image);
let cidfile = work_dir.join("renode.cid");
let relabel = if cfg!(target_os = "linux") { ",Z" } else { "" };
let mut command = Command::new(&runtime);
command.args(["run", "--rm"]);
if self.config.image == RENODE_ARM64_IMAGE {
command.args(["--platform", "linux/arm64"]);
} else if cfg!(target_arch = "aarch64") {
command.args(["-e", "DOTNET_EnableWriteXorExecute=0"]);
}
if self.config.needs_host_network() {
command.arg("--network=host");
} else {
for (_, renode, host) in &uart_ports {
let host = if *host == 0 {
String::new()
} else {
host.to_string()
};
command.arg("-p").arg(format!("127.0.0.1:{host}:{renode}"));
}
}
command
.arg("--cidfile")
.arg(&cidfile)
.arg("--volume")
.arg(format!(
"{}:{CONTAINER_WORK}:ro{relabel}",
work_dir.display()
));
for p in elf_paths.iter().chain(&stubs) {
command
.arg("--volume")
.arg(format!("{0}:{0}:ro{relabel}", p.display()));
}
command
.args(["--entrypoint", "/opt/renode/renode"])
.arg(&self.config.image)
.args(["--disable-xwt", "--plain", "-e"])
.arg(format!("include @{CONTAINER_WORK}/test.resc"));
(command, Some((runtime, cidfile)))
} else {
let renode = renode_bin().expect(
"no HIL backend: install podman/docker, or a native renode \
(HILT_RENODE_BIN, PATH, or /opt/renode/renode)",
);
let mut command = Command::new(renode);
command
.args(["--disable-xwt", "--plain"])
.arg(work_dir.join("test.resc"));
(command, None)
};
RunningHil {
deadline: Instant::now() + Duration::from_secs(wall_timeout.into()),
proc: Mutex::new(Some(Captured::spawn(command))),
container,
uart_ports,
_work_dir: guard,
}
}
}
pub struct RunningHil {
proc: Mutex<Option<Captured>>,
deadline: Instant,
container: Option<(String, PathBuf)>,
uart_ports: Vec<(String, u16, u16)>,
_work_dir: CleanupGuard,
}
impl RunningHil {
#[must_use]
pub fn wait(mut self) -> HilOutput {
self.finish(false)
}
#[must_use]
pub fn kill(mut self) -> HilOutput {
self.finish(true)
}
#[must_use]
pub fn uart_port(&self, machine: &str) -> u16 {
let &(_, renode_port, host_port) = self
.uart_ports
.iter()
.find(|(m, ..)| m == machine)
.unwrap_or_else(|| panic!("machine `{machine}` has no UART bridge"));
if host_port != 0 {
return host_port;
}
let (runtime, cidfile) = self
.container
.as_ref()
.expect("runtime-published ports only exist in container mode");
self.poll(
&format!("the runtime to publish machine `{machine}`'s UART port"),
|| {
let cid = std::fs::read_to_string(cidfile).ok()?;
let out = Command::new(runtime)
.args(["port", cid.trim(), &format!("{renode_port}/tcp")])
.output()
.ok()?;
parse_published_port(&String::from_utf8_lossy(&out.stdout))
},
)
}
#[must_use]
pub fn connect_uart(&self, machine: &str) -> TcpStream {
let port = self.uart_port(machine);
self.poll(
&format!("machine `{machine}`'s UART terminal on port {port} to accept"),
|| {
let stream = TcpStream::connect(("127.0.0.1", port)).ok()?;
terminal_accepted(&stream).then_some(stream)
},
)
}
fn poll<T>(&self, what: &str, mut f: impl FnMut() -> Option<T>) -> T {
loop {
if let Some(v) = f() {
return v;
}
let exited = self
.proc
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_mut()
.is_none_or(|p| !matches!(p.child.try_wait(), Ok(None)));
assert!(!exited, "Renode exited while waiting for {what}");
assert!(
Instant::now() < self.deadline,
"wall-clock timeout while waiting for {what}"
);
thread::sleep(Duration::from_millis(100));
}
}
fn finish(&mut self, kill: bool) -> HilOutput {
let proc = self
.proc
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.take()
.expect("HIL run already finished");
let deadline = if kill { Instant::now() } else { self.deadline };
let (output, timed_out) = proc.finish(deadline, || {
if let Some((runtime, cidfile)) = &self.container {
cleanup_container(runtime, cidfile);
}
});
HilOutput::new(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
timed_out && !kill,
)
}
}
impl Drop for RunningHil {
fn drop(&mut self) {
let running = self
.proc
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.is_some();
if running {
let _ = self.finish(true);
}
}
}
fn assign_bridge_ports(
requested: impl Iterator<Item = Option<u16>> + Clone,
runtime_publishes: bool,
) -> Vec<Option<(u16, u16)>> {
let mut fixed: Vec<u16> = requested.clone().flatten().filter(|&p| p != 0).collect();
fixed.sort_unstable();
let unique = fixed.windows(2).all(|w| w[0] != w[1]);
assert!(unique, "duplicate UART bridge host_port in config");
let mut next_private = 3456;
requested
.map(|port| {
Some(match port? {
0 if runtime_publishes => {
while fixed.contains(&next_private) {
next_private += 1;
}
next_private += 1;
(next_private - 1, 0)
}
0 => {
let port = free_local_port();
(port, port)
}
port => (port, port),
})
})
.collect()
}
fn parse_published_port(out: &str) -> Option<u16> {
out.lines()
.find_map(|l| l.trim().rsplit(':').next()?.parse().ok())
}
fn terminal_accepted(stream: &TcpStream) -> bool {
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.is_ok()
&& match stream.peek(&mut [0]) {
Ok(n) => n > 0,
Err(e) => matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut),
}
&& stream.set_read_timeout(None).is_ok()
}
fn free_local_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
.expect("failed to pick a free local port")
}
fn work_dir_for(config: &HilConfig) -> PathBuf {
static RUN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = RUN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let base = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::current_dir()
.expect("failed to read current dir")
.join("target")
});
let tag = config.machines.first().map_or("hil", |m| m.platform.name);
base.join(format!("hil-{tag}-{}-{seq}", std::process::id()))
}
#[derive(Debug, Clone)]
pub enum Artifact {
Bin(String),
Example(String),
}
#[derive(Debug, Clone)]
pub struct GuestBuild {
pub package: String,
pub artifact: Artifact,
pub target: String,
pub features: Vec<String>,
pub no_default_features: bool,
pub release: bool,
pub manifest_path: Option<PathBuf>,
}
impl GuestBuild {
#[must_use]
pub fn bin(package: impl Into<String>, name: impl Into<String>) -> Self {
Self::new(package, Artifact::Bin(name.into()))
}
#[must_use]
pub fn example(package: impl Into<String>, name: impl Into<String>) -> Self {
Self::new(package, Artifact::Example(name.into()))
}
fn new(package: impl Into<String>, artifact: Artifact) -> Self {
Self {
package: package.into(),
artifact,
target: String::new(),
features: Vec::new(),
no_default_features: false,
release: true,
manifest_path: None,
}
}
#[must_use]
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target = target.into();
self
}
#[must_use]
pub fn features<I, S>(mut self, features: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.features = features.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn no_default_features(mut self) -> Self {
self.no_default_features = true;
self
}
#[must_use]
pub fn debug(mut self) -> Self {
self.release = false;
self
}
#[must_use]
pub fn manifest_path(mut self, path: impl Into<PathBuf>) -> Self {
self.manifest_path = Some(path.into());
self
}
#[must_use]
pub fn build(&self) -> PathBuf {
let mut cmd = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()));
for var in [
"RUSTFLAGS",
"CARGO_ENCODED_RUSTFLAGS",
"CARGO_BUILD_RUSTFLAGS",
"CARGO_BUILD_TARGET",
"RUSTC_WRAPPER",
"LLVM_PROFILE_FILE",
"CARGO_LLVM_COV",
"CARGO_LLVM_COV_TARGET_DIR",
"CARGO_LLVM_COV_SHOW_ENV",
] {
cmd.env_remove(var);
}
cmd.args(["build", "-p", &self.package, "--message-format=json"]);
if let Some(manifest) = &self.manifest_path {
cmd.arg("--manifest-path").arg(manifest);
}
let target_name = match &self.artifact {
Artifact::Bin(n) => {
cmd.args(["--bin", n]);
n
}
Artifact::Example(n) => {
cmd.args(["--example", n]);
n
}
};
if self.no_default_features {
cmd.arg("--no-default-features");
}
if !self.features.is_empty() {
cmd.args(["--features", &self.features.join(",")]);
}
if self.release {
cmd.arg("--release");
}
if !self.target.is_empty() {
cmd.args(["--target", &self.target]);
}
let output = cmd.output().expect("failed to invoke cargo");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"failed to build guest artifact:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr)
);
find_executable(&stdout, target_name).unwrap_or_else(|| {
panic!("cargo build did not report an executable for artifact `{target_name}`")
})
}
}
fn find_executable(json_stdout: &str, target_name: &str) -> Option<PathBuf> {
json_stdout
.lines()
.filter(|line| line.contains("\"reason\":\"compiler-artifact\""))
.find(|line| json_str_field(line, "name").as_deref() == Some(target_name))
.and_then(|line| json_str_field(line, "executable"))
.map(PathBuf::from)
}
fn json_str_field(line: &str, key: &str) -> Option<String> {
let pat = format!("\"{key}\":\"");
let rest = &line[line.find(&pat)? + pat.len()..];
let mut out = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'/' => out.push('/'),
'n' => out.push('\n'),
't' => out.push('\t'),
other => out.push(other),
},
other => out.push(other),
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny_elf() -> Vec<u8> {
let shstrtab = b"\0.vector_table\0.symtab\0.strtab\0.shstrtab\0";
let strtab = b"\0hil_marker\0BYTE\0undef\0";
let vt = [0x2004_0000u32, 0x1000_01C1];
let sym = |name: u32, value: u32, info: u8, shndx: u16| {
let mut e = Vec::new();
e.extend(name.to_le_bytes());
e.extend(value.to_le_bytes());
e.extend(0u32.to_le_bytes());
e.extend([info, 0]);
e.extend(shndx.to_le_bytes());
e
};
let symtab = [
sym(0, 0, 0, 0),
sym(1, 0x1000_029B, 0x12, 1), sym(12, 0x2000_0003, 0x11, 1), sym(17, 0x1234, 0x10, 0), ]
.concat();
let vt_off = 52u32;
let sym_off = vt_off + 8;
let str_off = sym_off + symtab.len() as u32;
let shstr_off = str_off + strtab.len() as u32;
let sh_off = shstr_off + shstrtab.len() as u32;
let mut d = vec![0x7F, b'E', b'L', b'F', 1, 1, 1];
d.resize(16, 0);
d.extend([2, 0, 40, 0]); d.extend(1u32.to_le_bytes());
d.extend([0u32, 0, sh_off, 0].iter().flat_map(|w| w.to_le_bytes()));
d.extend([52u16, 0, 0, 40, 5, 4].iter().flat_map(|h| h.to_le_bytes()));
d.extend(vt.iter().flat_map(|w| w.to_le_bytes()));
d.extend(&symtab);
d.extend(strtab);
d.extend(shstrtab);
let sections: [[u32; 10]; 5] = [
[0; 10],
[1, 1, 2, 0x1000_0100, vt_off, 8, 0, 0, 4, 0],
[15, 2, 0, 0, sym_off, symtab.len() as u32, 3, 1, 4, 16],
[23, 3, 0, 0, str_off, strtab.len() as u32, 0, 0, 1, 0],
[31, 3, 0, 0, shstr_off, shstrtab.len() as u32, 0, 0, 1, 0],
];
d.extend(sections.iter().flatten().flat_map(|w| w.to_le_bytes()));
d
}
#[test]
fn bridge_ports_fixed_ephemeral_and_none() {
let ports = assign_bridge_ports([Some(3456), None, Some(0), Some(0)].into_iter(), true);
assert_eq!(
ports,
vec![Some((3456, 3456)), None, Some((3457, 0)), Some((3458, 0))]
);
let ports = assign_bridge_ports([Some(0), Some(4000)].into_iter(), false);
let (renode, host) = ports[0].unwrap();
assert!(renode != 0 && renode == host);
assert_eq!(ports[1], Some((4000, 4000)));
}
#[test]
#[should_panic(expected = "duplicate UART bridge host_port")]
fn bridge_ports_reject_duplicates() {
let _ = assign_bridge_ports([Some(5000), Some(0), Some(5000)].into_iter(), true);
}
#[test]
fn published_port_parsing() {
assert_eq!(parse_published_port("127.0.0.1:45067\n"), Some(45067));
assert_eq!(
parse_published_port("0.0.0.0:32768\n[::]:32768\n"),
Some(32768)
);
assert_eq!(parse_published_port("[::1]:4000"), Some(4000));
assert_eq!(parse_published_port(""), None);
assert_eq!(parse_published_port("Error: no such container"), None);
}
#[test]
fn elf_reader_vector_table_and_symbols() {
let d = tiny_elf();
assert_eq!(
elf_vector_table(&d),
Some((0x1000_0100, 0x2004_0000, 0x1000_01C0))
);
assert_eq!(elf_symbol(&d, "hil_marker"), Some(0x1000_029A));
assert_eq!(elf_symbol(&d, "BYTE"), Some(0x2000_0003));
assert_eq!(elf_symbol(&d, "undef"), None);
assert_eq!(elf_symbol(&d, "missing"), None);
assert_eq!(elf_symbol(&d[..200], "hil_marker"), None);
assert_eq!(elf_vector_table(b"not an elf"), None);
}
#[test]
fn elf_reader_file_api() {
let path = std::env::temp_dir().join(format!("hilt-tiny-{}.elf", std::process::id()));
std::fs::write(&path, tiny_elf()).unwrap();
let info = extract_elf_info(&path, "hil_marker", "hil_fail");
assert_eq!(
(info.vtor, info.sp, info.pc, info.hook_addr),
(0x1000_0100, 0x2004_0000, 0x1000_01C0, Some(0x1000_029A))
);
assert_eq!(extract_elf_info(&path, "nope", "nope").hook_addr, None);
let _ = std::fs::remove_file(&path);
assert_eq!(find_symbol_address(&path, "hil_marker"), None);
}
fn vt_info() -> ElfInfo {
ElfInfo {
vtor: 0x1000_0000,
sp: 0x2004_2000,
pc: 0x1000_0100,
hook_addr: Some(0x1000_0200),
fail_addr: Some(0x1000_0210),
panic_addr: Some(0x1000_0220),
}
}
fn vector_table_machine(name: &str) -> ReadyMachine {
ReadyMachine {
name: name.to_string(),
elf_ref: "/abs/fw.elf".to_string(),
repl_ref: format!("/hil/{name}.repl"),
mips: None,
can_connector: None,
info: Some(vt_info()),
socketcan_bridge: false,
uart_bridge: None,
uart: None,
}
}
fn board_machine(name: &str, bridge: bool) -> ReadyMachine {
ReadyMachine {
name: name.to_string(),
elf_ref: format!("/abs/{name}.elf"),
repl_ref: "platforms/boards/nucleo_h753zi.repl".to_string(),
mips: Some(125),
can_connector: Some("fdcan1"),
info: None,
socketcan_bridge: bridge,
uart_bridge: None,
uart: None,
}
}
#[test]
fn single_vector_table_resc_has_cpu_state_and_hook_no_hub() {
let resc = render_resc(&[vector_table_machine("hil")], "vcan0", None, 10);
assert!(resc.contains("mach create \"hil\""));
assert!(resc.contains("LoadPlatformDescription @/hil/hil.repl"));
assert!(resc.contains("cpu VectorTableOffset 0x10000000"));
assert!(resc.contains("cpu SP 0x20042000"));
assert!(resc.contains("cpu PC 0x10000100"));
assert!(resc.contains("cpu AddHook 0x10000200"));
assert!(resc.contains("'HIL OK'); monitor.Parse('quit')"));
assert!(resc.contains(
"cpu AddHook 0x10000210 \"self.Log(LogLevel.Error, 'HIL FAIL (fail marker)'); monitor.Parse('quit')\""
));
assert!(
resc.contains("cpu AddHook 0x10000220 \"self.Log(LogLevel.Error, 'HIL FAIL (panic)')")
);
assert!(resc.contains("RunFor \"00:00:10\""));
assert!(!resc.contains("canHub"));
}
#[test]
fn multi_board_resc_builds_hub_and_connects_fdcan() {
let machines = [
board_machine("controller", true),
board_machine("sensor", false),
];
let resc = render_resc(&machines, "vcan0", None, 15);
assert!(resc.contains("emulation CreateCANHub \"canHub\""));
assert!(resc.contains("cpu PerformanceInMips 125"));
assert!(resc.contains("connector Connect fdcan1 canHub"));
assert!(resc.contains("machine CreateSocketCANBridge \"vcan0\""));
assert!(resc.contains("connector Connect vcan0 canHub"));
assert!(resc.contains("mach create \"controller\""));
assert!(resc.contains("mach create \"sensor\""));
assert_eq!(resc.matches("CreateSocketCANBridge").count(), 1);
}
#[test]
fn single_bridged_machine_still_gets_a_hub() {
let resc = render_resc(&[board_machine("solo", true)], "vcan1", None, 20);
assert!(resc.contains("CreateCANHub"));
assert!(resc.contains("CreateSocketCANBridge \"vcan1\""));
assert!(resc.contains("RunFor \"00:00:20\""));
}
#[test]
fn uart_bridge_renders_server_terminal_and_connect() {
let mut m = vector_table_machine("hil");
m.uart_bridge = Some(UartBridge {
host_port: 3456,
uart: "sysbus.uart".to_string(),
});
let resc = render_resc(&[m], "vcan0", None, 10);
assert!(resc.contains("emulation CreateServerSocketTerminal 3456 \"hil_uart\" false"));
assert!(resc.contains("connector Connect sysbus.uart hil_uart"));
assert!(!resc.contains("canHub"));
}
#[test]
fn multi_machine_pass_does_not_quit_but_fail_does() {
let resc = render_resc(
&[vector_table_machine("a"), vector_table_machine("b")],
"vcan0",
None,
10,
);
assert!(resc.contains("'HIL OK')\""));
assert!(!resc.contains("'HIL OK'); monitor.Parse('quit')"));
assert!(resc.contains("'HIL FAIL (panic)'); monitor.Parse('quit')"));
}
#[test]
fn bridged_single_machine_pass_does_not_quit() {
let mut m = vector_table_machine("hil");
m.uart_bridge = Some(UartBridge {
host_port: 3456,
uart: "sysbus.uart".to_string(),
});
let resc = render_resc(&[m], "vcan0", None, 10);
assert!(resc.contains("'HIL OK')\""));
assert!(!resc.contains("'HIL OK'); monitor.Parse('quit')"));
assert!(resc.contains("'HIL FAIL (panic)'); monitor.Parse('quit')"));
}
#[test]
fn console_uart_gets_an_analyzer() {
let mut m = vector_table_machine("hil");
assert!(!render_resc(std::slice::from_ref(&m), "vcan0", None, 10).contains("showAnalyzer"));
m.uart = Some("sysbus.uart0");
let resc = render_resc(&[m], "vcan0", None, 10);
assert!(resc.contains("showAnalyzer sysbus.uart0"));
}
#[test]
fn stubs_overlay_is_loaded_per_machine() {
let resc = render_resc(
&[vector_table_machine("hil")],
"vcan0",
Some("/abs/stubs.repl"),
5,
);
assert!(resc.contains("LoadPlatformDescription @/abs/stubs.repl"));
}
#[test]
fn timeout_minutes_and_seconds_format() {
let resc = render_resc(&[vector_table_machine("hil")], "vcan0", None, 125);
assert!(resc.contains("RunFor \"00:02:05\""));
}
#[test]
fn timeout_carries_into_hours_past_3600s() {
assert_eq!(format_run_for(3600), "01:00:00");
assert_eq!(format_run_for(3661), "01:01:01");
assert_eq!(format_run_for(7325), "02:02:05");
}
#[test]
fn json_str_field_extracts_and_unescapes() {
let line = r#"{"executable":"/tmp/a \"weird\" path","name":"hil_controller"}"#;
assert_eq!(
json_str_field(line, "executable").as_deref(),
Some(r#"/tmp/a "weird" path"#)
);
assert_eq!(
json_str_field(line, "name").as_deref(),
Some("hil_controller")
);
assert_eq!(json_str_field(line, "missing"), None);
}
#[test]
fn find_executable_matches_target_name_and_ignores_others() {
let stdout = concat!(
r#"{"reason":"compiler-artifact","target":{"name":"other"},"executable":"/tmp/other"}"#,
"\n",
r#"{"reason":"build-script-executed","target":{"name":"hil_controller"}}"#,
"\n",
r#"{"reason":"compiler-artifact","target":{"name":"hil_controller"},"executable":"/tmp/thumbv7em-none-eabihf/release/hil_controller"}"#,
);
assert_eq!(
find_executable(stdout, "hil_controller"),
Some(PathBuf::from(
"/tmp/thumbv7em-none-eabihf/release/hil_controller"
))
);
assert_eq!(find_executable(stdout, "nonexistent"), None);
}
#[test]
fn detect_runtime_honors_env() {
let _guard = crate::test_env_lock();
let saved = std::env::var(RUNTIME_ENV).ok();
std::env::set_var(RUNTIME_ENV, "fake-runtime");
assert_eq!(detect_container_runtime(), "fake-runtime");
assert!(container_runtime_available());
match saved {
Some(v) => std::env::set_var(RUNTIME_ENV, v),
None => std::env::remove_var(RUNTIME_ENV),
}
}
#[test]
fn interface_available_loopback_yes_bogus_no() {
if Command::new("ip").arg("-V").output().is_ok() {
assert!(interface_available("lo"));
}
assert!(!interface_available("hilt_fake_iface_xyz"));
}
}