Files
huntedbytheirs 1c668c3c1e kernel, patch, doctor, config: fill in the rest of Now and Next
kernel quest reads bugzilla's REST API and picks one bug through a seeded
xorshift, so a pick is reproducible in a test and varied in a person's hands.
kernel test builds under spawn_blocking and boots under qemu with the serial
console streamed line by line, and it reports what qemu did instead of
flattening a failed boot into a success.

patch create writes a whole series when --range is given. git format-patch
does the naming and the numbering because that is already its job, the cover
letter comes from --cover-letter, and a series.toml beside the patches records
the range with both ends as hashes, the revision, the files and the blurb.
patch submit takes a directory or several files and sends them in one
git send-email invocation, and patch update re-rolls a directory through
--reroll-count, deleting the superseded revision only once the new one is
complete.

doctor runs its checks in the order a first run hits them, prints one line and
a fix hint for each, and names the first hard failure in the summary. A missing
qemu or a missing mail route warns and keeps exit 0, because --dry-run is where
the loop actually stops on a machine without an SMTP route.

~/.config/spectral/config.toml holds the tree path, the patch directory,
always-CC addresses and an identity, with the environment winning over the file
and the file over the built-in default. spectral init verifies or clones a tree
and writes it once: a second run with the same tree says so and writes nothing,
and a run that would change an existing answer is refused until --force.

tests/ grew a fixture for each of those. The harness controls PATH and git's
configuration, and its PATH can hold a git that answers send-email either way,
so a host without that support still passes rather than deciding the result.

Two review lanes ran over this work and this commit carries their fixes. A
re-roll regenerated base..HEAD, so a commit that landed after create would have
silently joined the set and been mailed: the range is recorded now and a
re-roll regenerates exactly it. A patch file that was already gone made update
fail forever while the sidecar described a revision that was not on disk: a
missing file is the state the re-roll wanted. An empty blurb aborted after the
files were written: it is checked before anything is generated. A tree that is
not a git repository was reported as a missing revision. init cloned before it
refused. The bugzilla request had no timeout, a signal-ended child printed the
bare word "signal", CommandSpawn dropped the io reason, and the printed
send-email line quoted only arguments with spaces, so --in-reply-to <id@host>
pasted as a redirection.

Checked with cargo fmt --all -- --check, cargo clippy --all-targets --locked --
-D warnings, cargo test --locked (96 passed, 4 ignored) and cargo doc --no-deps.
The four ignored tests want the host's own checkpatch, get_maintainer and
bugzilla, and all four pass when asked for.
2026-09-16 21:52:05 -04:00

343 lines
12 KiB
Rust

//! Fixture helpers shared by the CLI tests.
//!
//! A test never touches the machine it runs on. `HOME`, `SPECTRAL_KERNEL`, and
//! git's global configuration all point into a temp directory that deletes
//! itself when the test ends, so `~/.spectral` is never written and the
//! developer's git config never leaks in.
//!
//! The kernel tree, when a test wants one, is a real git repository holding the
//! two scripts spectral shells out to, a MAINTAINERS entry, and one commit.
#![allow(dead_code)] // each test binary uses a different part of this set
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
/// The checkpatch stand-in.
///
/// The real one is a kernel script whose output moves with the kernel version,
/// which is not something a test can assert on. This records how it was called,
/// then says what the test asked for through the environment:
/// `FIXTURE_SUMMARY` is printed as checkpatch's summary line, `FIXTURE_EXIT` is
/// the exit status, and `FIXTURE_RECORD` names the file the call is noted in.
const CHECKPATCH: &str = r#"#!/bin/sh
: "${FIXTURE_RECORD:=.checkpatch-call}"
{
echo "argv: $*"
echo "cwd: $(pwd)"
cat
} >> "$FIXTURE_RECORD"
[ -n "$FIXTURE_SUMMARY" ] && printf '%s\n' "$FIXTURE_SUMMARY"
exit "${FIXTURE_EXIT:-0}"
"#;
/// The get_maintainer stand-in, for the same reason.
///
/// One line per role the real script emits, including a git signer whose
/// address is already on the `To:` line, so the dedup is exercised.
const GET_MAINTAINER: &str = r#"#!/bin/sh
echo "argv: $*" >> .getmaintainer-call
echo "Fixture Maintainer <[email protected]> (maintainer:FIXTURE DRIVER)"
echo "[email protected] (open list:FIXTURE DRIVER)"
echo "Fixture Reviewer <[email protected]> (reviewer:FIXTURE REVIEWERS)"
echo "[email protected] (open list:FIXTURE REVIEWERS)"
echo "\"GitAuthor: Fixture\" <[email protected]> (authored,added_lines)"
"#;
/// The file `return a;` is committed as, and the edit most tests start from.
pub const COMMITTED: &str = "static int foo(int a)\n{\n\treturn a;\n}\n";
pub const EDITED: &str = "static int foo(int a)\n{\n\treturn a + 1;\n}\n";
/// A temp directory that removes itself when the test ends.
#[derive(Debug)]
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(label: &str) -> Self {
static NEXT: AtomicU32 = AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"spectral-test-{}-{label}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&path).expect("create the temp directory");
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn join(&self, rel: &str) -> PathBuf {
self.path.join(rel)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// A throwaway home directory, and a kernel tree when the test wants one.
pub struct Fixture {
home: TempDir,
root: TempDir,
}
impl Fixture {
/// A home with nothing in it but a git identity.
pub fn bare() -> Self {
let fixture = Self {
home: TempDir::new("home"),
root: TempDir::new("root"),
};
std::fs::write(
fixture.home.join("gitconfig"),
"[user]\n\tname = Fixture\n\temail = [email protected]\n",
)
.expect("write the fixture git config");
fixture
}
/// A git repository shaped like a kernel tree.
pub fn kernel_tree() -> Self {
let fixture = Self::bare();
// Pinned: `create` defaults to `--base master`, the kernel's branch,
// and the fixture should not inherit whatever a machine's git defaults
// to for a new repository.
fixture.git(&["init", "--quiet", "--initial-branch=master"]);
fixture.git(&["config", "user.name", "Fixture"]);
fixture.git(&["config", "user.email", "[email protected]"]);
fixture.write(
"MAINTAINERS",
"FIXTURE DRIVER\nM:\tFixture Maintainer <[email protected]>\nL:\t[email protected]\nS:\tMaintained\nF:\tdrivers/foo/*\n\nFIXTURE REVIEWERS\nR:\tFixture Reviewer <[email protected]>\nL:\t[email protected]\nS:\tOdd Fixes\nF:\tdrivers/foo/*\n",
);
fixture.write("drivers/foo/bar.c", COMMITTED);
fixture.write_script("scripts/checkpatch.pl", CHECKPATCH);
fixture.write_script("scripts/get_maintainer.pl", GET_MAINTAINER);
fixture.commit_all("fixture: add bar");
fixture
}
/// The directory the tests treat as `$HOME`.
pub fn home(&self) -> &Path {
self.home.path()
}
/// The kernel tree, which is also the working directory the binary runs in.
pub fn tree(&self) -> &Path {
self.root.path()
}
pub fn join(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
/// Write a file into the tree, making its directory first.
pub fn write(&self, rel: &str, contents: &str) {
let path = self.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create the file's directory");
}
std::fs::write(&path, contents).expect("write the file");
}
/// Write an executable script into the tree.
pub fn write_script(&self, rel: &str, body: &str) {
self.write(rel, body);
let path = self.join(rel);
let mut permissions = std::fs::metadata(&path)
.expect("stat the script")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions).expect("make the script executable");
}
/// Write an executable script into a directory of its own.
fn write_script_in(&self, dir: &Path, name: &str, body: &str) {
let path = dir.join(name);
std::fs::write(&path, body).expect("write the script");
let mut permissions = std::fs::metadata(&path)
.expect("stat the script")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions).expect("make the script executable");
}
pub fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.join(rel)).expect("read the file")
}
/// Run git in the tree and return its trimmed stdout.
pub fn git(&self, args: &[&str]) -> String {
let output = self.git_command(args).output().expect("run git");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_owned()
}
/// Run git expecting a failure, and hand back its stderr.
///
/// Used to pin the things spectral works around, such as send-email
/// refusing a bare diff.
pub fn git_must_fail(&self, args: &[&str]) -> String {
let output = self.git_command(args).output().expect("run git");
assert!(
!output.status.success(),
"git {} was supposed to fail",
args.join(" ")
);
String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned()
}
pub fn commit_all(&self, message: &str) {
self.git(&["add", "-A"]);
self.git(&["commit", "--quiet", "-m", message]);
}
/// Do the work on a branch of its own, the way the loop expects: `create`
/// diffs the branch against `master`, so committing straight to `master`
/// leaves it nothing to send.
pub fn work_on_a_branch(&self) {
self.git(&["checkout", "--quiet", "-b", "work"]);
}
/// Run the built binary with the fixture's environment.
pub fn cli(&self, args: &[&str]) -> Output {
self.cli_env(args, &[])
}
/// Run the built binary without `$SPECTRAL_KERNEL`, so the config file and
/// the built-in default are the only things left to resolve a tree from.
///
/// `XDG_CONFIG_HOME` points into the fixture, which is where the test's
/// config file lives.
pub fn cli_without_the_tree_variable(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_spectral"));
command
.args(args)
.current_dir(self.root.path())
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", self.home.path())
.env("XDG_CONFIG_HOME", self.home.join("config"))
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
.env("GIT_CONFIG_SYSTEM", "/dev/null");
for (key, value) in env {
command.env(key, value);
}
command.output().expect("run spectral")
}
/// Write a config file into the fixture's own config directory.
pub fn write_config(&self, contents: &str) -> PathBuf {
let path = self.home.join("config/spectral/config.toml");
std::fs::create_dir_all(path.parent().expect("the config directory"))
.expect("create the config directory");
std::fs::write(&path, contents).expect("write the config file");
path
}
/// Run the built binary with extra environment on top of the fixture's.
pub fn cli_env(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_spectral"));
command
.args(args)
.current_dir(self.root.path())
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", self.home.path())
.env("SPECTRAL_KERNEL", self.root.path())
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
.env("GIT_CONFIG_SYSTEM", "/dev/null");
for (key, value) in env {
command.env(key, value);
}
command.output().expect("run spectral")
}
fn git_command(&self, args: &[&str]) -> Command {
let mut command = Command::new("git");
command
.arg("-C")
.arg(self.root.path())
.args(args)
.env("HOME", self.home.path())
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
.env("GIT_CONFIG_SYSTEM", "/dev/null");
command
}
/// A `PATH` holding symlinks to exactly these programs, found on the
/// machine's own path.
///
/// This is how a test takes a tool away: uninstalling qemu to prove the
/// missing-qemu path would be absurd, and pointing `PATH` somewhere narrow
/// asserts the same thing without touching the host.
pub fn restricted_path(&self, programs: &[&str]) -> PathBuf {
let bin = self.home.join("bin");
std::fs::create_dir_all(&bin).expect("create the restricted bin directory");
for program in programs {
let found = self.program(program);
std::os::unix::fs::symlink(found, bin.join(program)).expect("link the program");
}
bin
}
/// A `PATH` whose `git` is the machine's, except that `send-email` answers
/// with `answer` instead of running.
///
/// The harness controls `PATH` and git's configuration but not git's own
/// exec-path, so whether the host has git's send-email support installed is
/// otherwise inherited from the machine. This makes the answer the test's,
/// in both directions.
pub fn path_with_send_email(&self, answer: u32) -> PathBuf {
let bin = self.home.join("gitbin");
std::fs::create_dir_all(&bin).expect("create the git bin directory");
let real = self.program("git");
let wrapper = format!(
"#!/bin/sh\nif [ \"$1\" = send-email ]; then exit {answer}; fi\nexec {} \"$@\"\n",
real.display()
);
self.write_script_in(&bin, "git", &wrapper);
bin
}
/// The machine's own copy of a program, found on its `PATH`.
fn program(&self, name: &str) -> PathBuf {
std::env::var("PATH")
.unwrap_or_default()
.split(':')
.map(|dir| Path::new(dir).join(name))
.find(|candidate| candidate.is_file())
.unwrap_or_else(|| panic!("{name} is not on this machine's PATH"))
}
}