use anyhow::{bail, Context, Result};
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::BuildConfig;
/// Official Arch Linux ARM generic aarch64 rootfs tarball.
const ALARM_TARBALL_URL: &str =
"http://os.archlinuxarm.org/os/ArchLinuxARM-aarch64-latest.tar.gz";
const ALARM_TARBALL: &str = "ArchLinuxARM-aarch64-latest.tar.gz";
/// Marker written only after a complete, successful extraction.
const BOOTSTRAP_MARKER: &str = ".alarm-rootfs-complete";
/// Packages the non-systemd boot path needs at runtime.
///
/// `openssh` for remote login, `wpa_supplicant` + `iw` for the BCM4356 WiFi,
/// `dhcpcd` for leases. The ALARM tarball ships none of them.
const RUNTIME_PACKAGES: &[&str] = &["openssh", "wpa_supplicant", "iw", "dhcpcd"];
/// PID 1 script, installed at `/etc/alarm-init.sh`.
///
/// Runs after `/sbin/init` (see `SBIN_INIT`) has mounted the pseudo-filesystems
/// and built the USB serial console gadget. It starts the console shell and
/// brings up WiFi and SSH.
const ALARM_INIT_SH: &str = include_str!("../rootfs/alarm-init.sh");
/// PID 1 itself, installed at `/sbin/init`.
///
/// The mainline kernel boots with `rdinit=/nonexistent`, so it mounts the root
/// filesystem itself through `prepare_namespace()` and then runs `/sbin/init`.
/// The switchroot L4T initramfs is bypassed deliberately: its userspace `mount`
/// hangs mounting this same filesystem, while the kernel mounts it without
/// trouble. This script mounts the pseudo-filesystems, builds the USB serial
/// console gadget on tegra-xudc, and hands over to `ALARM_INIT_SH`.
///
/// Both scripts live in `rootfs/` as real files rather than escaped Rust string
/// literals, so they can be shell-checked and read normally.
const SBIN_INIT: &str = include_str!("../rootfs/sbin-init");
/// Create (or update) the ALARM root filesystem.
pub fn create(
config: &BuildConfig,
build_dir: &Path,
desktop: bool,
extra_packages: Option<&str>,
) -> Result<()> {
let rootfs_dir = build_dir.join("rootfs");
// The rootfs contains root-owned files (device nodes, setuid binaries,
// system dirs) after extraction, so every step needs root.
crate::utils::require_root("create the ALARM root filesystem")?;
bootstrap(&rootfs_dir, build_dir)?;
let mut packages: Vec<&str> = RUNTIME_PACKAGES.to_vec();
if desktop {
packages.extend_from_slice(&[
"xorg-server",
"xorg-xinit",
"xorg-xrandr",
"mesa",
"xfce4",
"xfce4-terminal",
"lightdm",
"lightdm-gtk-greeter",
"noto-fonts",
"pulseaudio",
]);
}
if let Some(extra) = extra_packages {
for pkg in extra.split(',') {
let pkg = pkg.trim();
if !pkg.is_empty() {
packages.push(pkg);
}
}
}
if !packages.is_empty() {
install_packages(&rootfs_dir, &packages)?;
}
configure_system(&rootfs_dir, config)?;
install_kernel_files(&rootfs_dir, build_dir)?;
install_switchroot_modules(build_dir, &rootfs_dir)?;
setup_services(&rootfs_dir)?;
println!("Root filesystem ready at: {}", rootfs_dir.display());
Ok(())
}
/// Extract the ALARM tarball into `rootfs_dir` if it has not been bootstrapped.
///
/// A marker file records completion. Without it, any existing directory is
/// discarded first: an interrupted extraction (or the old placeholder rootfs)
/// can leave a `lib/` directory where the tarball needs a `lib -> usr/lib`
/// symlink, and `tar` cannot replace a non-empty directory with a symlink.
fn bootstrap(rootfs_dir: &Path, build_dir: &Path) -> Result<()> {
let marker = build_dir.join(BOOTSTRAP_MARKER);
if marker.exists() && rootfs_complete(rootfs_dir) {
println!("Rootfs already bootstrapped at {}", rootfs_dir.display());
return Ok(());
}
if rootfs_dir.exists() {
println!("Removing incomplete rootfs at {}...", rootfs_dir.display());
fs::remove_dir_all(rootfs_dir)
.with_context(|| format!("failed to clear {}", rootfs_dir.display()))?;
}
let tarball = build_dir.join(ALARM_TARBALL);
if !tarball.exists() {
download(ALARM_TARBALL_URL, &tarball)
.context("failed to download the ALARM rootfs tarball")?;
}
println!("Extracting ALARM rootfs (this takes a minute)...");
fs::create_dir_all(rootfs_dir)?;
let extraction = extract(&tarball, rootfs_dir);
// Archive tools sometimes exit non-zero for benign metadata warnings
// (unknown xattrs, hard links within a partial set). Trust the result.
if rootfs_complete(rootfs_dir) {
if extraction.is_err() {
println!("Warning: extractor reported errors but the rootfs looks complete; continuing.");
}
} else {
extraction?;
bail!("rootfs extraction did not produce a usable system; re-run to retry");
}
fs::write(&marker, "ok\n").context("failed to write bootstrap marker")?;
Ok(())
}
/// Heuristic that a rootfs was fully extracted.
fn rootfs_complete(rootfs_dir: &Path) -> bool {
rootfs_dir.join("etc/pacman.conf").exists()
&& rootfs_dir.join("usr/bin/pacman").exists()
&& rootfs_dir.join("usr/bin/bash").exists()
&& rootfs_dir.join("usr/lib").is_dir()
}
/// Extract an archive, preferring `bsdtar` so libarchive capabilities survive.
fn extract(tarball: &Path, dest: &Path) -> Result<()> {
if which::which("bsdtar").is_ok() {
run(
"bsdtar",
&["-xpf", &tarball.to_string_lossy(), "-C", &dest.to_string_lossy()],
)
.context("failed to extract the rootfs with bsdtar")
} else {
run(
"tar",
&["-xpf", &tarball.to_string_lossy(), "-C", &dest.to_string_lossy()],
)
.context("failed to extract the rootfs with tar")
}
}
/// Download `url` to `dest` using curl or wget.
fn download(url: &str, dest: &Path) -> Result<()> {
if which::which("curl").is_ok() {
let mut cmd = Command::new("curl");
cmd.args(["-fL", "--retry", "3", "-o"]).arg(dest).arg(url);
check(cmd, "curl")?;
} else if which::which("wget").is_ok() {
let mut cmd = Command::new("wget");
cmd.arg("-O").arg(dest).arg(url);
check(cmd, "wget")?;
} else {
bail!("neither curl nor wget is installed; cannot download {}", url);
}
Ok(())
}
/// Install extra packages into the rootfs. The rootfs is a foreign (aarch64)
/// architecture, so this needs an aarch64-capable chroot to run package hooks.
fn install_packages(rootfs_dir: &Path, packages: &[&str]) -> Result<()> {
let tool = ["arch-chroot", "systemd-nspawn"]
.iter()
.find(|t| which::which(t).is_ok())
.copied();
let Some(tool) = tool else {
println!(
"⚠️ Skipping extra packages: no arch-chroot/systemd-nspawn available.\n \
Install qemu-user-static with binfmt registered, then re-run to add: {}",
packages.join(", ")
);
return Ok(());
};
println!("Installing packages: {}", packages.join(" "));
let mut cmd = Command::new(tool);
if tool == "systemd-nspawn" {
cmd.arg("-D").arg(rootfs_dir);
} else {
cmd.arg(rootfs_dir);
}
cmd.args(["pacman", "-S", "--needed", "--noconfirm"]);
cmd.args(packages);
check(cmd, tool)?;
Ok(())
}
/// Configure hostname, locale, fstab and mountpoints.
fn configure_system(rootfs_dir: &Path, config: &BuildConfig) -> Result<()> {
write(rootfs_dir.join("etc/hostname"), "alarm-switch\n")?;
write(
rootfs_dir.join("etc/hosts"),
"127.0.0.1 localhost\n127.0.1.1 alarm-switch.localdomain alarm-switch\n::1 localhost\n",
)?;
write(rootfs_dir.join("etc/locale.gen"), "en_US.UTF-8 UTF-8\n")?;
write(rootfs_dir.join("etc/locale.conf"), "LANG=en_US.UTF-8\n")?;
write(rootfs_dir.join("etc/vconsole.conf"), "KEYMAP=us\n")?;
// The switchroot L4T initramfs refuses to switch_root unless this file
// exists ("<dev> not a switchroot installation..."). It only checks for
// the file, so a minimal marker is enough to reuse their initramfs.
write(
rootfs_dir.join("etc/switchroot_version.conf"),
"switchroot_version=1.0\ndistro=alarm\n",
)?;
// The L4T initramfs finds the root by filesystem label; fstab must match.
let fstab = format!(
"# <file system> <dir> <type> <options> <dump> <pass>\n\
LABEL={label} / ext4 defaults,noatime 0 1\n\
LABEL=BOOT /boot vfat defaults,noatime 0 2\n\
tmpfs /tmp tmpfs defaults,nosuid,nodev,noatime 0 0\n",
label = config.image.label
);
write(rootfs_dir.join("etc/fstab"), &fstab)?;
fs::create_dir_all(rootfs_dir.join("boot"))?;
// Persist the journal so boot failures can be read from the card on a PC.
// journald needs a machine-id for persistent storage; the ALARM tarball
// ships an empty one, so generate it here.
fs::create_dir_all(rootfs_dir.join("var/log/journal"))?;
write(
rootfs_dir.join("etc/systemd/journald.conf.d/10-persistent.conf"),
"[Journal]\nStorage=persistent\nSystemMaxUse=64M\n",
)?;
let machine_id = random_machine_id();
write(rootfs_dir.join("etc/machine-id"), &format!("{machine_id}\n"))?;
// --- Non-systemd boot path -------------------------------------------
//
// /sbin/init is our own PID 1, not the image's symlink to systemd. The
// kernel runs it directly (rdinit=/nonexistent); it mounts the
// pseudo-filesystems, builds the USB serial console gadget, and hands over
// to /etc/alarm-init.sh.
use std::os::unix::fs::PermissionsExt;
let sbin_init = rootfs_dir.join("sbin/init");
// ALARM ships /sbin/init as a symlink to ../lib/systemd/systemd.
if fs::symlink_metadata(&sbin_init)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
fs::remove_file(&sbin_init).context("failed to remove the /sbin/init symlink")?;
}
write(sbin_init.clone(), SBIN_INIT)?;
fs::set_permissions(&sbin_init, fs::Permissions::from_mode(0o755))?;
let init_script = rootfs_dir.join("etc/alarm-init.sh");
write(init_script.clone(), ALARM_INIT_SH)?;
fs::set_permissions(&init_script, fs::Permissions::from_mode(0o755))?;
// WiFi credentials. Only an example is written: the real PSK is not kept in
// the repository. The init script skips WiFi if the file is absent, so an
// unconfigured card still boots (serial console only).
write(
rootfs_dir.join("etc/wpa_supplicant/wpa_supplicant.conf.example"),
"# Copy to wpa_supplicant.conf to enable WiFi at boot.\n\
# Generate the psk with: wpa_passphrase <SSID> <passphrase>\n\
network={\n\
\tssid=\"YOUR_SSID\"\n\
\t#psk=\"YOUR_PASSPHRASE\"\n\
\tpsk=0000000000000000000000000000000000000000000000000000000000000000\n\
}\n",
)?;
// ALARM ships /etc/resolv.conf as a symlink into systemd-resolved
// (/run/systemd/resolve/resolv.conf), which never runs here. That leaves
// DNS dangling, so `pacman -Sy` fails with "Could not resolve host".
let resolv = rootfs_dir.join("etc/resolv.conf");
let _ = fs::remove_file(&resolv);
write(resolv, "nameserver 1.1.1.1\nnameserver 8.8.8.8\n")?;
// Allow root login over SSH: the init script starts sshd at boot, so this
// is the only way in besides the serial console. The drop-in is included
// first by sshd_config, so it wins over the commented-out defaults.
//
// The root password itself is deliberately NOT set here. The ALARM tarball
// ships a locked root account; set a password from the serial console with
// `passwd` (see the README), otherwise SSH login stays impossible.
write(
rootfs_dir.join("etc/ssh/sshd_config.d/10-alarm.conf"),
"# Written by alarm-build: allow root + password login (LAN-only device).\n\
PermitRootLogin yes\n\
PasswordAuthentication yes\n",
)?;
// systemd 258+ drops privileges for `pacman` downloads using Landlock,
// which the 4.9 L4T kernel does not implement. Downloads then fail with
// "Landlock unavailable"; comment the setting out.
let pacman_conf = rootfs_dir.join("etc/pacman.conf");
if let Ok(body) = fs::read_to_string(&pacman_conf) {
let patched = body
.lines()
.map(|line| {
if line.trim_start().starts_with("DownloadUser") {
format!("#{line}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
write(pacman_conf, &format!("{patched}\n"))?;
}
Ok(())
}
/// 32 hex chars from /dev/urandom (fallback: timestamp).
fn random_machine_id() -> String {
let mut buf = [0u8; 16];
if let Ok(mut f) = fs::File::open("/dev/urandom") {
if f.read_exact(&mut buf).is_ok() {
return buf.iter().map(|b| format!("{b:02x}")).collect();
}
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{nanos:032x}")
}
/// Copy the compiled kernel modules into the rootfs (harmless if unused).
fn install_kernel_files(rootfs_dir: &Path, build_dir: &Path) -> Result<()> {
let modules = build_dir.join("kernel/out/lib/modules");
if !modules.exists() {
println!("No kernel modules built; skipping module install");
return Ok(());
}
let dest = rootfs_dir.join("lib/modules");
fs::create_dir_all(&dest)?;
run(
"rsync",
&["-aHAX", "--delete", "--numeric-ids", &src_arg(&modules), &src_arg(&dest)],
)?;
println!("Installed kernel modules");
Ok(())
}
/// Import the kernel modules and firmware that match the switchroot L4T
/// kernel from its own rootfs image.
///
/// `switchroot/install/l4t.NN` are consecutive parts of a single ext4
/// filesystem (volume `SWR-JAM`). Because the booted kernel is switchroot's
/// 4.9.140, the Arch rootfs needs *its* modules/firmware for WiFi, BT, audio,
/// etc. Best-effort: failures are reported but do not abort the build.
fn install_switchroot_modules(build_dir: &Path, rootfs_dir: &Path) -> Result<()> {
let project_root = std::env::current_dir().context("failed to resolve project root")?;
let install = project_root.join("switchroot/install");
let parts = collect_image_parts(&install);
if parts.is_empty() {
println!("No switchroot install parts (switchroot/install/l4t.*); skipping modules/firmware import");
return Ok(());
}
let image = build_dir.join("switchroot/rootfs.img");
let result = (|| -> Result<()> {
assemble_parts(&parts, &image)?;
import_from_image(&image, rootfs_dir)
})();
if let Err(err) = &result {
println!("Warning: switchroot modules/firmware import failed: {err:#}");
}
Ok(())
}
/// Collect `l4t.00`, `l4t.01`, … in order, stopping at the first gap.
fn collect_image_parts(install: &Path) -> Vec<PathBuf> {
let mut parts = Vec::new();
for i in 0..64 {
let part = install.join(format!("l4t.{i:02}"));
if part.exists() {
parts.push(part);
} else if i > 0 {
break;
}
}
parts
}
/// Concatenate the parts into one sparse image, reusing an existing one.
fn assemble_parts(parts: &[PathBuf], image: &Path) -> Result<()> {
let total: u64 = parts
.iter()
.map(|p| fs::metadata(p).map(|m| m.len()).unwrap_or(0))
.sum();
if fs::metadata(image).map(|m| m.len()).unwrap_or(0) == total && image.exists() {
return Ok(());
}
println!("Assembling switchroot rootfs image ({} MiB)...", total / (1024 * 1024));
let parent = image.parent().context("image path has no parent")?;
fs::create_dir_all(parent)?;
let file = fs::File::create(image)?;
file.set_len(total)?;
drop(file);
let mut offset = 0u64;
for part in parts {
let mut src = fs::File::open(part)
.with_context(|| format!("failed to open {}", part.display()))?;
let mut dst = fs::OpenOptions::new().write(true).open(image)?;
dst.seek(SeekFrom::Start(offset))?;
std::io::copy(&mut src, &mut dst)
.with_context(|| format!("failed to copy {}", part.display()))?;
offset += fs::metadata(part)?.len();
}
Ok(())
}
/// Loop-mount the assembled image read-only and copy modules + firmware out.
fn import_from_image(image: &Path, rootfs_dir: &Path) -> Result<()> {
let loop_dev = capture(
"losetup",
&["--find", "--read-only", "--show", &image.to_string_lossy()],
)?
.trim()
.to_string();
let mnt = temp_dir("switchroot")?;
let result = (|| -> Result<()> {
run("mount", &["-o", "ro", &loop_dev, &mnt.to_string_lossy()])?;
for name in ["lib/modules", "lib/firmware"] {
let src = mnt.join(name);
if !src.exists() {
continue;
}
let dest = rootfs_dir.join(name);
fs::create_dir_all(&dest)?;
run("rsync", &["-aHAX", "--numeric-ids", &src_arg(&src), &src_arg(&dest)])?;
println!("Imported /{name} from the switchroot image");
}
run("umount", &[&mnt.to_string_lossy()])?;
Ok(())
})();
let _ = Command::new("losetup").args(["-d", &loop_dev]).status();
let _ = fs::remove_dir_all(&mnt);
result
}
/// Enable the SSH and NetworkManager services, and a boot-log dumper.
///
/// Note: systemd never starts on this kernel (see `ALARM_INIT_SH`), so these
/// units are inert. They are kept so the rootfs is a valid Arch installation
/// and because a future L4S/backported kernel would make them work as-is. The
/// services that actually run today live in `/etc/alarm-init.sh`.
fn setup_services(rootfs_dir: &Path) -> Result<()> {
let wants = rootfs_dir.join("etc/systemd/system/multi-user.target.wants");
fs::create_dir_all(&wants)?;
for unit in ["sshd.service", "NetworkManager.service"] {
let target = format!("/usr/lib/systemd/system/{unit}");
let link = wants.join(unit);
if rootfs_dir.join(&target[1..]).exists() && !link.exists() {
let _ = symlink(&target, &link);
}
}
install_bootlog_service(rootfs_dir)?;
Ok(())
}
/// Install a unit that writes the boot journal to the FAT boot partition, so a
/// failed boot can be inspected from any PC without root or journalctl.
fn install_bootlog_service(rootfs_dir: &Path) -> Result<()> {
let script = rootfs_dir.join("usr/local/sbin/alarm-bootlog.sh");
write(
script.clone(),
"#!/bin/sh\n\
mkdir -p /boot\n\
mount | grep -q ' /boot ' || { mount -L BOOT /boot 2>/dev/null || mount /dev/mmcblk0p1 /boot 2>/dev/null; }\n\
{\n\
\techo \"=== ALARM boot log: $(date) ===\"\n\
\techo \"cmdline: $(cat /proc/cmdline)\"\n\
\techo; echo '=== journalctl -b ==='\n\
\tjournalctl -b --no-pager 2>/dev/null || true\n\
\techo; echo '=== dmesg ==='\n\
\tdmesg 2>/dev/null || true\n\
} > /boot/ALARM_BOOT_LOG.txt 2>&1\n",
)?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script, fs::Permissions::from_mode(0o755))?;
let unit = "[Unit]\n\
Description=Dump ALARM boot log to the FAT boot partition\n\
After=systemd-journald.service local-fs.target\n\
\n\
[Service]\n\
Type=oneshot\n\
RemainAfterExit=yes\n\
ExecStart=/usr/local/sbin/alarm-bootlog.sh\n\
\n\
[Install]\n\
WantedBy=multi-user.target rescue.target emergency.target\n";
write(
rootfs_dir.join("etc/systemd/system/alarm-bootlog.service"),
unit,
)?;
for target in ["multi-user.target", "rescue.target", "emergency.target"] {
let wants = rootfs_dir.join(format!("etc/systemd/system/{target}.wants"));
fs::create_dir_all(&wants)?;
let link = wants.join("alarm-bootlog.service");
if !link.exists() {
let _ = symlink("/etc/systemd/system/alarm-bootlog.service", &link);
}
}
Ok(())
}
/// Create a bootable SD-card image: FAT32 boot partition + ext4 root labelled
/// `config.image.label`.
pub fn create_image(
config: &BuildConfig,
build_dir: &Path,
output_dir: &Path,
size_gb: u32,
) -> Result<()> {
crate::utils::require_root("create a disk image")?;
let rootfs_dir = build_dir.join("rootfs");
if !rootfs_dir.join("etc/pacman.conf").exists() {
bail!("Rootfs not found at {}. Run 'rootfs' first.", rootfs_dir.display());
}
if size_gb == 0 {
bail!("image size must be at least 1 GB");
}
fs::create_dir_all(output_dir)?;
let image = output_dir.join("alarm-tegra-x1.img");
let boot_mib = config.image.boot_size_mib.max(64);
let size_arg = format!("{size_gb}G");
let boot_end = format!("{}MiB", 1 + boot_mib);
let img = image.to_string_lossy().into_owned();
println!("Creating {} GiB image at {}...", size_gb, image.display());
// 1. Sparse file + GPT + two partitions.
run("truncate", &["-s", &size_arg, &img])?;
run("parted", &["-s", &img, "mklabel", "gpt"])?;
run("parted", &["-s", &img, "mkpart", "primary", "fat32", "1MiB", &boot_end])?;
run("parted", &["-s", &img, "set", "1", "esp", "on"])?;
run("parted", &["-s", &img, "mkpart", "primary", "ext4", &boot_end, "100%"])?;
// 2. Attach as a loop device with partition scanning.
let loop_dev = capture("losetup", &["--find", "--partscan", "--show", &img])?
.trim()
.to_string();
// Some kernels/udev setups do not create the partition nodes from
// --partscan alone; give them a nudge and wait briefly.
let _ = Command::new("partprobe").arg(&loop_dev).status();
let _ = Command::new("udevadm").args(["settle"]).status();
for part in ["p1", "p2"] {
wait_for_partition(&format!("{loop_dev}{part}"))?;
}
let result = populate_image(config, &loop_dev, &rootfs_dir, output_dir);
// 3. Always detach the loop device, even on failure.
let _ = Command::new("losetup").args(["-d", &loop_dev]).status();
result?;
println!("Image created: {}", image.display());
println!("Flash with: sudo alarm-build flash /dev/sdX");
Ok(())
}
/// Format and populate an attached loop device.
fn populate_image(
config: &BuildConfig,
loop_dev: &str,
rootfs_dir: &Path,
output_dir: &Path,
) -> Result<()> {
let boot_part = format!("{loop_dev}p1");
let root_part = format!("{loop_dev}p2");
run("mkfs.vfat", &["-F", "32", "-n", "BOOT", &boot_part])?;
// Cache-width features introduced by modern mke2fs cannot be mounted by the
// (older) switchroot L4T kernel: orphan_file (Linux 5.15+) and
// metadata_csum_seed (4.13+) produce
// "failed to mount rootfs / filesystem has errors" at boot. Disable them.
run(
"mke2fs",
&[
"-t",
"ext4",
"-L",
&config.image.label,
"-F",
"-O",
"^orphan_file,^metadata_csum_seed",
&root_part,
],
)?;
let mnt_root = temp_dir("alarm-root")?;
let mnt_boot = temp_dir("alarm-boot")?;
let result = (|| -> Result<()> {
run("mount", &[&root_part, &mnt_root.to_string_lossy()])?;
run(
"rsync",
&["-aHAX", "--numeric-ids", &src_arg(rootfs_dir), &src_arg(&mnt_root)],
)?;
run("mount", &[&boot_part, &mnt_boot.to_string_lossy()])?;
// hekate reads bootloader/ and switchroot/ from the FAT partition root.
let boot_root = mnt_boot.clone();
copy_boot_tree(output_dir, &boot_root)?;
// Mirror them into /boot of the root filesystem too.
let root_boot = mnt_root.join("boot");
fs::create_dir_all(&root_boot)?;
copy_boot_tree(output_dir, &root_boot)?;
run("umount", &[&mnt_boot.to_string_lossy()])?;
run("umount", &[&mnt_root.to_string_lossy()])?;
Ok(())
})();
let _ = fs::remove_dir_all(&mnt_root);
let _ = fs::remove_dir_all(&mnt_boot);
result
}
/// Copy the hekate boot tree into `dest`.
fn copy_boot_tree(output_dir: &Path, dest: &Path) -> Result<()> {
for entry in ["bootloader", "switchroot"] {
let src = output_dir.join(entry);
if src.exists() {
copy_dir_recursive(&src, &dest.join(entry))?;
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)
.with_context(|| format!("failed to copy {}", src_path.display()))?;
}
}
Ok(())
}
fn temp_dir(prefix: &str) -> Result<PathBuf> {
let base = std::env::temp_dir().join(format!("{}-{}", prefix, std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base)?;
Ok(base)
}
/// Wait up to ~5s for a loop partition node to appear.
fn wait_for_partition(path: &str) -> Result<()> {
for _ in 0..50 {
if Path::new(path).exists() {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
bail!("partition device {} never appeared after losetup", path)
}
fn write(path: PathBuf, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
}
/// Turn a directory path into the `rsync` style `dir/` argument.
fn src_arg(dir: &Path) -> String {
let mut s = dir.to_string_lossy().into_owned();
if !s.ends_with('/') {
s.push('/');
}
s
}
/// Run a command and fail on a non-zero exit status.
fn run(program: &str, args: &[&str]) -> Result<()> {
let mut cmd = Command::new(program);
cmd.args(args);
check(cmd, program)
}
/// Run a prepared command and fail on a non-zero exit status.
fn check(mut cmd: Command, program: &str) -> Result<()> {
let status = cmd
.status()
.with_context(|| format!("failed to execute {}", program))?;
if !status.success() {
bail!("{} failed (exit status {})", program, status);
}
Ok(())
}
/// Run a command and capture its stdout.
fn capture(program: &str, args: &[&str]) -> Result<String> {
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("failed to execute {}", program))?;
if !output.status.success() {
bail!("{} failed", program);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn write_creates_parent_directories() {
let dir = std::env::temp_dir().join(format!("alarm-write-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
// This mirrors the journald drop-in path that previously failed.
let path = dir.join("etc/systemd/journald.conf.d/10-persistent.conf");
write(path.clone(), "[Journal]\n").unwrap();
assert!(path.exists(), "write() must create parent directories");
let _ = fs::remove_dir_all(&dir);
}
/// Everything the non-systemd boot path relies on must be produced by the
/// rootfs build, otherwise a fresh card boots to nothing.
#[test]
fn configure_system_writes_non_systemd_boot_files() {
let dir = std::env::temp_dir().join(format!("alarm-config-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let config = BuildConfig::default_for_odnx02_a2();
configure_system(&dir, &config).unwrap();
// The initramfs execs this as PID 1; it must exist and be executable.
let init = dir.join("etc/alarm-init.sh");
let body = fs::read_to_string(&init).unwrap();
assert!(body.starts_with("#!/bin/sh"), "init script: {body}");
assert!(body.contains("exec /bin/sh"), "init script: {body}");
assert!(
body.contains("/etc/wpa_supplicant/wpa_supplicant.conf"),
"init script does not look for a WiFi config: {body}"
);
assert_eq!(
fs::metadata(&init).unwrap().permissions().mode() & 0o777,
0o755,
"init script is not executable"
);
// DNS must point at real nameservers, not the dead systemd-resolved path.
assert_eq!(
fs::read_to_string(dir.join("etc/resolv.conf")).unwrap(),
"nameserver 1.1.1.1\nnameserver 8.8.8.8\n"
);
// Root + password login is how the device is reached over the network.
let sshd = fs::read_to_string(dir.join("etc/ssh/sshd_config.d/10-alarm.conf")).unwrap();
assert!(sshd.contains("PermitRootLogin yes"), "sshd drop-in: {sshd}");
assert!(
sshd.contains("PasswordAuthentication yes"),
"sshd drop-in: {sshd}"
);
// The WiFi secret itself is never written by the build.
assert!(
!dir.join("etc/wpa_supplicant/wpa_supplicant.conf").exists(),
"build must not invent WiFi credentials"
);
let _ = fs::remove_dir_all(&dir);
}
}