rigless 0.0.5

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model, and unit of virtual time in Rust; a fork of QEMU is built in and executes the instructions
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Builds the emulator into the crate. It is not optional: what this produces links a fork of QEMU,
//! which is why the package is GPL-2.0-or-later.
//!
//! The fork is the submodule at `qemu/`. Its own build links its whole-archive parts as a list of
//! objects, because the emulator registers its types from constructors and a plain archive would
//! drop them, then a group of three archives, then the system libraries. This script runs that
//! build, bundles the same objects into one archive of its own, links it whole, and leaves out two
//! objects: the fork's `main`, which the crate replaces, and the null core, which stands in for the
//! Rust one only in the fork's standalone binary.
//!
//! A published crate has no submodule: 169 MB of emulator source is far past what a package may
//! carry, so `emulator.pin` names the fork commit and the digest of a tarball of it, and this
//! script fetches that instead. A checkout builds what is on disk and never reaches the network.

#![expect(
    clippy::disallowed_methods,
    reason = "a build script is told what to build by cargo through its environment"
)]

use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use sha2::{Digest, Sha256};

/// The fork's executable for the one core family this build carries. A second family is a second
/// target on the configure line, its own device list in the fork, and its objects in the same bundle.
const EXECUTABLE: &str = "qemu-system-arm";

/// What the fork's own build script configures, less the download and the plugins.
const CONFIGURE: [&str; 8] = [
    "--target-list=arm-softmmu",
    "--without-default-features",
    "--without-default-devices",
    "--with-devices-arm=rigless",
    "--enable-tcg",
    "--enable-fdt=internal",
    "--disable-download",
    "--disable-plugins",
];

/// The two objects of the fork's executable that this crate provides itself.
const NOT_BUNDLED: [&str; 2] = ["system_main.c.o", "hw_misc_rigless-null-core.c.o"];

/// The link arguments of the fork's executable that are the linker driver's business, not ours.
const NOT_PASSED: [&str; 5] = [
    "-Wl,--as-needed",
    "-Wl,--no-undefined",
    "-pie",
    "-Wl,-z,relro",
    "-Wl,-z,now",
];

/// The entry points the proxy device calls, which live in this crate's library and must be linked
/// wherever its rlib sits on the line.
const ENTRY_POINTS: [&str; 4] = [
    "rigless_core_new",
    "rigless_core_access",
    "rigless_core_stop",
    "rigless_core_free",
];

/// What the fork's tree must carry for this to be a fork and not an empty directory. The two
/// subprojects are submodules of the fork, so a tarball that forgot them fails here rather than
/// several minutes into a build.
const MUST_CARRY: [&str; 3] = [
    "meson.build",
    "subprojects/dtc/meson.build",
    "subprojects/keycodemapdb/README",
];

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=emulator.pin");
    println!("cargo:rerun-if-env-changed=DOCS_RS");
    println!("cargo:rerun-if-env-changed=RIGLESS_EMULATOR_TARBALL");
    println!("cargo::rustc-check-cfg=cfg(emulator_absent)");

    // docs.rs blocks the network and gives rustdoc fifteen minutes, which is neither long enough to
    // build the emulator nor able to fetch it; Miri interprets Rust and cannot execute linked C at
    // all. Neither can have one, and neither is a reason to have no crate.
    if env::var_os("DOCS_RS").is_some() || env::var_os("CARGO_CFG_MIRI").is_some() {
        println!("cargo::rustc-cfg=emulator_absent");
        return;
    }

    let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("cargo sets it"));
    let out = PathBuf::from(env::var("OUT_DIR").expect("cargo sets it"));
    let fork = source(&manifest, &out);

    // Only a checkout is worth watching. A fetched tree is named by the commit it came from and
    // cannot change under us, so watching it would be a rebuild trigger that can never fire.
    if fork.starts_with(&manifest) {
        for watched in [
            "hw/misc",
            "hw/arm",
            "include/hw/misc",
            "accel/tcg",
            "target/arm/tcg",
            "configs/devices/arm-softmmu",
        ] {
            println!("cargo:rerun-if-changed={}", fork.join(watched).display());
        }
    }

    let build = out.join("qemu");
    if !build.join("build.ninja").is_file() {
        fs::create_dir_all(&build).expect("the build directory");
        run(
            Command::new(fork.join("configure"))
                .current_dir(&build)
                .args(CONFIGURE),
            "configuring the emulator; it needs a C compiler, python3 with its venv and wheel packages, meson, ninja, pkg-config and the glib-2.0 headers",
        );
    }
    // Cargo decides how parallel a build may be, and a build script that ignores it takes every
    // core on the machine no matter what `-j` said.
    let mut ninja = Command::new("ninja");
    ninja.current_dir(&build).arg(EXECUTABLE);
    if let Ok(jobs) = env::var("NUM_JOBS") {
        ninja.arg("-j").arg(jobs);
    }
    run(&mut ninja, "building the emulator");
    let (objects, arguments) = link_line(&build);
    let bundle = out.join("libqemu_bundle.a");
    fs::remove_file(&bundle).ok();
    run(
        Command::new("ar").arg("qc").arg(&bundle).args(&objects),
        "bundling the emulator's objects",
    );
    run(
        Command::new("ar").arg("s").arg(&bundle),
        "indexing the bundle",
    );
    println!("cargo:rustc-link-search=native={}", out.display());
    println!("cargo:rustc-link-lib=static:+whole-archive=qemu_bundle");
    for symbol in ENTRY_POINTS {
        println!("cargo:rustc-link-arg=-Wl,-u,{symbol}");
    }
    for argument in arguments {
        link(&argument);
    }
}

/// Emits one of the fork's link arguments in the form cargo carries to whoever links last.
///
/// `rustc-link-arg` reaches this package's own binaries and tests and stops there; `rustc-link-lib`
/// and `rustc-link-search` are carried to anything that depends on this crate. The fork's link line
/// is archives and system libraries, so all of it can be said the second way — and it has to be, or
/// the crate builds here and fails to link in the first project that takes it as a dependency, on
/// every symbol glib and the fork's own utilities provide.
fn link(argument: &str) {
    let path = Path::new(argument);
    if let Some(stem) = library(path, "a") {
        if let Some(directory) = path.parent() {
            println!("cargo:rustc-link-search=native={}", directory.display());
        }
        // `-bundle` keeps the archive out of the rlib and hands it to whoever links last. Bundled,
        // which is the default, its members are copied into the rlib and then linked a second time
        // from the archive itself, and every object the bundle already carries collides.
        println!("cargo:rustc-link-lib=static:-bundle={stem}");
    } else if let Some(stem) = library(path, "so") {
        if let Some(directory) = path.parent() {
            println!("cargo:rustc-link-search=native={}", directory.display());
        }
        println!("cargo:rustc-link-lib=dylib={stem}");
    } else if let Some(name) = argument.strip_prefix("-l") {
        println!("cargo:rustc-link-lib=dylib={name}");
    } else if argument == "-pthread" {
        println!("cargo:rustc-link-lib=dylib=pthread");
    } else if argument == "-Wl,--start-group" || argument == "-Wl,--end-group" {
        // Cargo has no way to say "group", and it does not need one: what the group is for is
        // archives that reference each other, and rustc already repeats native libraries on the
        // link line rather than passing each once.
    } else {
        println!("cargo:rustc-link-arg={argument}");
    }
}

/// The library name inside `libNAME.EXTENSION`, for a path that is one.
fn library<'a>(path: &'a Path, extension: &str) -> Option<&'a str> {
    (path.extension()? == extension)
        .then(|| path.file_stem()?.to_str()?.strip_prefix("lib"))
        .flatten()
}

/// The fork's tree to build from: the submodule where there is one, and the pinned tarball where
/// there is not, which is every build of a published crate.
fn source(manifest: &Path, out: &Path) -> PathBuf {
    let submodule = manifest.join("qemu");
    if submodule.join("meson.build").is_file() {
        carries(
            &submodule,
            "the emulator fork and its subprojects are the submodules under `qemu/`; fetch them with `git submodule update --init --depth 1 qemu` then `git -C qemu submodule update --init --depth 1 subprojects/dtc subprojects/keycodemapdb`",
        );
        return submodule;
    }

    let pin = Pin::read(manifest);
    let tree = out.join(format!("qemu-{}", pin.commit));
    if tree.join("meson.build").is_file() {
        return tree;
    }

    let tarball = out.join(format!("qemu-{}.tar.gz", pin.commit));
    // A local copy is the offline and air-gapped path, and the one a build behind a proxy uses. A
    // build script has no flags and cargo speaks to it through the environment, which is the same
    // exception this file's `expect` already names.
    match env::var_os("RIGLESS_EMULATOR_TARBALL") {
        Some(local) => {
            fs::copy(&local, &tarball).unwrap_or_else(|error| {
                panic!(
                    "reading the emulator tarball at {}: {error}",
                    Path::new(&local).display()
                )
            });
        }
        None => run(
            Command::new("curl")
                .args(["--fail", "--location", "--silent", "--show-error"])
                .arg("--output")
                .arg(&tarball)
                .arg(&pin.url),
            "fetching the emulator; it needs curl and a network, or a local copy named by RIGLESS_EMULATOR_TARBALL",
        ),
    }

    // Without this, whatever answered that URL becomes code in every binary built from this crate.
    let got = digest(&tarball);
    assert_eq!(
        got, pin.sha256,
        "the emulator tarball at {} hashes to {got}, and `emulator.pin` expects {}; refusing to build something nobody pinned",
        pin.url, pin.sha256
    );

    fs::create_dir_all(&tree).expect("the emulator directory");
    run(
        Command::new("tar")
            .arg("--extract")
            .arg("--gzip")
            .arg("--strip-components=1")
            .arg("--file")
            .arg(&tarball)
            .arg("--directory")
            .arg(&tree),
        "unpacking the emulator",
    );
    carries(
        &tree,
        "the emulator tarball is missing what a build needs; it is packed from a checkout with the dtc and keycodemapdb subprojects initialised",
    );
    tree
}

/// What `emulator.pin` says: which fork commit this tree pairs with, and where a tarball of it is
/// with the digest it must have.
struct Pin {
    commit: String,
    url: String,
    sha256: String,
}

impl Pin {
    fn read(manifest: &Path) -> Self {
        let path = manifest.join("emulator.pin");
        let text = fs::read_to_string(&path)
            .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()));
        let field = |name: &str| -> String {
            text.lines()
                .map(str::trim)
                .filter(|line| !line.starts_with('#'))
                .find_map(|line| {
                    let (key, value) = line.split_once('=')?;
                    (key.trim() == name).then(|| value.trim().to_owned())
                })
                .unwrap_or_else(|| panic!("{} names no {name}", path.display()))
        };
        Self {
            commit: field("commit"),
            url: field("url"),
            sha256: field("sha256"),
        }
    }
}

/// The SHA-256 of a file, read in chunks: the tarball is tens of megabytes and there is no reason
/// for all of it to be resident at once.
fn digest(path: &Path) -> String {
    use std::io::Read;

    let mut file =
        fs::File::open(path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
    let mut hasher = Sha256::new();
    let mut buffer = vec![0u8; 64 * 1024];
    loop {
        let read = file
            .read(&mut buffer)
            .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()));
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }
    format!("{:x}", hasher.finalize())
}

/// Fails with `missing` unless the tree holds every file a build reads before it reads anything else.
fn carries(tree: &Path, missing: &str) {
    for wanted in MUST_CARRY {
        assert!(tree.join(wanted).is_file(), "{missing} (no {wanted})");
    }
}

fn run(command: &mut Command, doing: &str) {
    let status = command
        .status()
        .unwrap_or_else(|error| panic!("{doing}: {error}"));
    assert!(status.success(), "{doing}: {status}");
}

/// The objects and the arguments of the fork's own final link, read off its `build.ninja`: the
/// objects it links whole less the two this crate provides, made absolute, and its link arguments
/// less the ones that are the driver's.
fn link_line(build: &Path) -> (Vec<PathBuf>, Vec<String>) {
    let ninja = fs::read_to_string(build.join("build.ninja")).expect("the fork's build.ninja");
    let mut lines = ninja.lines();
    let mut build_line = None;
    for line in lines.by_ref() {
        if line.starts_with(&format!("build {EXECUTABLE}:")) {
            build_line = Some(line.to_owned());
            break;
        }
    }
    let build_line = build_line.expect("build.ninja links the executable");
    let inputs = build_line
        .split_once(':')
        .expect("a build line")
        .1
        .split(" | ")
        .next()
        .expect("the inputs");
    let objects: Vec<PathBuf> = inputs
        .split_whitespace()
        .skip(1)
        .filter(|token| {
            Path::new(token)
                .extension()
                .is_some_and(|extension| extension == "o")
        })
        .filter(|token| !NOT_BUNDLED.iter().any(|left_out| token.ends_with(left_out)))
        .map(|token| build.join(token))
        .collect();
    assert!(objects.len() > 100, "the fork's link lists its objects");
    let link_args = lines
        .find_map(|line| line.trim_start().strip_prefix("LINK_ARGS = "))
        .expect("the link arguments follow the build line");
    let mut arguments = Vec::new();
    let mut tokens = link_args.split_whitespace();
    while let Some(token) = tokens.next() {
        if NOT_PASSED.contains(&token) {
            continue;
        }
        if token == "-Xlinker" {
            tokens.next();
            continue;
        }
        let relative = build.join(token);
        if !token.starts_with('-') && relative.is_file() {
            arguments.push(relative.display().to_string());
        } else {
            arguments.push(token.to_owned());
        }
    }
    (objects, arguments)
}