rigless 0.0.6

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, and a fork of QEMU built in to execute the instructions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
// 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-only.
//!
//! 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 with the submodule fetched builds it at `qemu/` and never
//! reaches the network. A checkout whose submodule you haven't fetched takes the fetch path too.

#![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};

#[path = "src/runtime/version/provenance.rs"]
mod provenance;

/// 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)");

    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"));
    write_version(&manifest, &out);

    // 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 shared = emulator_dir(&manifest, &out);
    fs::create_dir_all(&shared).expect("the emulator directory");
    let _building = Building::take(&shared);
    let fork = source(&manifest, &shared);

    // 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 = shared.join("build");
    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 = shared.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={}", shared.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);
    }
}

/// What this build calls itself, for `src/runtime/version.rs` to include: the crate's version, and
/// the source it came from when that isn't a release.
fn write_version(manifest: &Path, out: &Path) {
    let version = env::var("CARGO_PKG_VERSION").expect("cargo sets it");
    let source = source_of(manifest, &version);
    let written = provenance::describe(&version, &source);
    fs::write(out.join("version.rs"), format!("{written:?}")).expect("the version file");
}

/// Where this build's source came from. A package carries cargo's own record of it. A checkout asks
/// git, and watches everything that can change the answer: the commit, the tags, the index, and every
/// file that builds, so an edit is never reported as the commit it was made on.
fn source_of(manifest: &Path, version: &str) -> provenance::Provenance {
    let record = manifest.join(".cargo_vcs_info.json");
    if let Ok(json) = fs::read_to_string(&record) {
        println!("cargo:rerun-if-changed={}", record.display());
        return provenance::from_vcs_info(&json);
    }
    let git = |args: &[&str]| {
        Command::new("git")
            .arg("-C")
            .arg(manifest)
            .args(args)
            .output()
            .ok()
            .filter(|output| output.status.success())
            .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
    };
    let Some(commit) = git(&["rev-parse", "HEAD"]) else {
        return provenance::Provenance::Unknown;
    };
    let mut watched: Vec<String> = ["HEAD", "index", "packed-refs", "refs/tags"]
        .iter()
        .map(|&path| path.to_owned())
        .collect();
    watched.extend(git(&["symbolic-ref", "-q", "HEAD"]));
    for path in watched {
        let Some(found) = git(&["rev-parse", "--git-path", &path]) else {
            continue;
        };
        // A path that doesn't exist would rerun this script on every build, so only what's there.
        let found = manifest.join(found);
        if found.exists() {
            println!("cargo:rerun-if-changed={}", found.display());
        }
    }
    for builds in ["src", "Cargo.toml", "Cargo.lock"] {
        println!("cargo:rerun-if-changed={}", manifest.join(builds).display());
    }
    let changed = git(&[
        "status",
        "--porcelain",
        "--untracked-files=no",
        "--",
        "src",
        "build.rs",
        "Cargo.toml",
        "Cargo.lock",
        "emulator.pin",
        "qemu",
    ]);
    let dirty = changed.is_none_or(|changes| !changes.is_empty());
    let tags = git(&["tag", "--points-at", "HEAD"]).unwrap_or_default();
    provenance::from_checkout(version, &commit, &tags, dirty)
}

/// Where the emulator is built: once per target directory, shared by every feature set, profile and
/// test target. A build script's own output directory is one per feature set, so without this every
/// `cargo check` of a different feature set built the fork again, and a pipeline that checks four
/// feature sets and runs coverage built it six times. The directory is named by what it builds
/// from: the checkout, which ninja keeps up to date in place, or the pinned commit, which never
/// changes.
fn emulator_dir(manifest: &Path, out: &Path) -> PathBuf {
    let key = if manifest.join("qemu").join("meson.build").is_file() {
        "checkout".to_owned()
    } else {
        Pin::read(manifest).commit
    };
    // OUT_DIR is <target>/<profile>/build/<package>-<hash>/out.
    let target = out
        .ancestors()
        .nth(4)
        .expect("cargo puts a build script's output four levels under the target directory");
    target.join(format!("emulator-{key}"))
}

/// The shared directory is one build, so two build scripts reaching it at once wait for each other.
/// Cargo runs this script once per invocation, so the case is two invocations side by side. A
/// directory is the lock because creating one is atomic and needs nothing but the standard library.
struct Building(PathBuf);

impl Building {
    fn take(shared: &Path) -> Self {
        let lock = shared.join(".building");
        loop {
            match fs::create_dir(&lock) {
                Ok(()) => return Self(lock),
                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
                    // ceiling: a lock older than an hour is a build that died holding it; upgrade
                    // when a build legitimately takes longer.
                    let stale = fs::metadata(&lock)
                        .and_then(|meta| meta.modified())
                        .is_ok_and(|since| since.elapsed().unwrap_or_default().as_secs() > 3_600);
                    if stale {
                        fs::remove_dir(&lock).ok();
                        continue;
                    }
                    std::thread::sleep(std::time::Duration::from_secs(1));
                }
                Err(error) => panic!(
                    "taking the emulator's build lock at {}: {error}",
                    lock.display()
                ),
            }
        }
    }
}

impl Drop for Building {
    fn drop(&mut self) {
        fs::remove_dir(&self.0).ok();
    }
}

/// 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. The tarball and its tree go under the
/// shared emulator directory.
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)
}