alarm-tegra-x1 / src/rootfs.rs

raw file 30317 bytes
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);
    }
}