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 (" 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!( "# \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 \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::>() .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 { 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 { 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 { 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); } }