crates/nornir-xtask-support/src/lockcheck.rs
//! # The lock check: every `Cargo.lock` of a constellation of heads, against each other
//!
//! On 2026-10-23 mixed sibling locks cost eight release attempts: holger on ssh-key 0.6
//! against siblings on 0.7, korp-installer 0.2.6 and 0.2.7 in one lock, and three locks
//! (holger-estate-front, mock-upcloud-kvm, monetize-*) stale against the edda and
//! Skidbladnir mains. Each was found by a release, never before. This module asks the
//! question for every lock at once:
//!
//! * **stale**: `cargo metadata --locked` in the lock's own directory (so its
//! `.cargo/config.toml`, and with it the sibling patches, is read as cargo reads it)
//! fails, so the lock no longer matches the manifests and the sibling heads beside it;
//! * **twice**: a crate appears at two versions where that is refused: a crate named
//! in the single-version list (korp-installer by default, [`DEFAULT_SINGLE`]), or a
//! crate a sibling checkout provides (a path package) that the lock ALSO carries from
//! the registry at another version.
//!
//! A repo whose sibling patches are opt-in (holger's `.cargo/estate-siblings.toml`) has
//! them switched on first, the way its `cargo xtask` launcher does.
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::Result;
/// Crates that must never appear at two versions in one lock.
pub const DEFAULT_SINGLE: &[&str] = &["korp-installer"];
/// One `[[package]]` of a lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockPackage {
pub name: String,
pub version: String,
/// `None` for a path package (a workspace member or a sibling checkout).
pub source: Option<String>,
}
impl Finding {
/// Does this finding make the check red?
pub fn is_red(&self) -> bool {
!matches!(self, Finding::Drift { .. })
}
}
/// What the check found in one lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Finding {
/// `cargo metadata --locked` refused the lock; the detail is cargo's error, the fix
/// the command that makes it `--locked` clean again (tried, then undone).
Stale { lock: PathBuf, detail: String, fix: String },
/// `name` appears at more than one version where that is refused (the
/// single-version list).
Twice { lock: PathBuf, name: String, versions: Vec<String> },
/// A sibling's crate appears beside a registry copy of it at another version. Not
/// refused (two copies link), so a note, never red: it is how two `TestResultRow`
/// types came to live in one graph.
Drift { lock: PathBuf, name: String, versions: Vec<String> },
/// The lock could not be read.
Unreadable { lock: PathBuf, detail: String },
/// The repo's declared toolchain image (`[build.toolchain] image_sha256`) is not
/// the reference pin (edda's, the repo of the Build Thing): its Build Things look
/// for an image no other box holds (holger on `3d5115ba…`, only on oden, 2026-10-23).
PinDiffers { repo: PathBuf, image: String, pin: String, reference: String },
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Finding::Stale { lock, detail, fix } if fix.is_empty() => write!(f, "STALE {}: {detail}", lock.display()),
Finding::Stale { lock, detail, fix } => write!(f, "STALE {}: {detail} | fix: {fix}", lock.display()),
Finding::Twice { lock, name, versions } => {
write!(f, "TWICE {}: {name} at {}", lock.display(), versions.join(" and "))
}
Finding::Drift { lock, name, versions } => {
write!(f, "note {}: {name} at {} (a sibling beside its registry copy)", lock.display(), versions.join(" and "))
}
Finding::Unreadable { lock, detail } => write!(f, "UNREADABLE {}: {detail}", lock.display()),
Finding::PinDiffers { repo, image, pin, reference } => write!(
f,
"PIN {}: {image}@sha256:{} is not the reference pin sha256:{}",
repo.display(),
&pin[..pin.len().min(12)],
&reference[..reference.len().min(12)]
),
}
}
}
/// The packages of a `Cargo.lock` text. Not a TOML parser: a lock is machine-written
/// and its `[[package]]` tables carry `name`, `version` and `source` as plain lines.
pub fn parse_lock(text: &str) -> Vec<LockPackage> {
let mut out = Vec::new();
let mut cur: Option<LockPackage> = None;
for line in text.lines() {
let l = line.trim();
if l.starts_with('[') {
if let Some(p) = cur.take() {
out.push(p);
}
if l == "[[package]]" {
cur = Some(LockPackage { name: String::new(), version: String::new(), source: None });
}
continue;
}
let Some(p) = cur.as_mut() else { continue };
let Some((k, v)) = l.split_once('=') else { continue };
let v = v.trim().trim_matches('"').to_string();
match k.trim() {
"name" => p.name = v,
"version" => p.version = v,
"source" => p.source = Some(v),
_ => {}
}
}
if let Some(p) = cur.take() {
out.push(p);
}
out.retain(|p| !p.name.is_empty());
out
}
/// The crates of `pkgs` that appear at two versions where that is refused (by name) or
/// noted (a path package beside a registry copy): `(name, versions, refused)`.
pub fn twice(pkgs: &[LockPackage], single: &[&str]) -> Vec<(String, Vec<String>, bool)> {
let mut by: BTreeMap<&str, Vec<&LockPackage>> = BTreeMap::new();
for p in pkgs {
by.entry(p.name.as_str()).or_default().push(p);
}
let mut out = Vec::new();
for (name, ps) in by {
let mut versions: Vec<String> = ps.iter().map(|p| p.version.clone()).collect();
versions.sort();
versions.dedup();
if versions.len() < 2 {
continue;
}
let path_and_registry = ps.iter().any(|p| p.source.is_none()) && ps.iter().any(|p| p.source.is_some());
if single.contains(&name) || path_and_registry {
out.push((name.to_string(), versions, single.contains(&name)));
}
}
out
}
/// Every `Cargo.lock` of `repo` that sits beside a `Cargo.toml`, skipping build output,
/// hidden directories, vendored trees and test fixtures (a `fixtures` directory: gunnar's
/// `xtask/tests/fixtures/*` are stale on purpose, the material its tests judge).
pub fn locks_of(repo: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth > 6 {
return;
}
let lock = dir.join("Cargo.lock");
if lock.is_file() && dir.join("Cargo.toml").is_file() {
out.push(lock);
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut subs: Vec<PathBuf> = rd
.flatten()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.map(|e| e.path())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
!(n.starts_with('.') || n == "target" || n.starts_with("target-") || n == "node_modules" || n == "vendor" || n == "fixtures")
})
.collect();
subs.sort();
for s in subs {
walk(&s, depth + 1, out);
}
}
let mut out = Vec::new();
walk(repo, 0, &mut out);
out
}
/// Switch on a repo's opt-in sibling patches (`.cargo/estate-siblings.toml` copied to
/// `.cargo/siblings.toml`) when every sibling it names is checked out beside it.
/// Returns whether the patches are on.
pub fn siblings_on(repo: &Path) -> Result<bool> {
let template = repo.join(".cargo/estate-siblings.toml");
let Ok(text) = std::fs::read_to_string(&template) else { return Ok(false) };
let parent = repo.parent().unwrap_or(repo);
for part in text.split("path = \"").skip(1) {
let Some(path) = part.split('"').next() else { continue };
let Some(rest) = path.strip_prefix("../") else { continue };
let top = rest.split('/').next().unwrap_or(rest);
if !parent.join(top).is_dir() {
return Ok(false);
}
}
let copy = repo.join(".cargo/siblings.toml");
if std::fs::read_to_string(©).ok().as_deref() != Some(text.as_str()) {
std::fs::write(©, &text).map_err(|e| format!("write {}: {e}", copy.display()))?;
}
Ok(true)
}
fn cargo() -> std::ffi::OsString {
std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}
/// Is `lock` in the git index of `repo`? Outside a git checkout every lock counts.
pub fn tracked(repo: &Path, lock: &Path) -> bool {
let Ok(rel) = lock.strip_prefix(repo) else { return true };
let rel = rel.to_string_lossy().replace('\\', "/");
match nornir_git::gitio::open_for_status(repo) {
Ok(r) => match r.index_or_empty() {
Ok(index) => index.entry_by_path(rel.as_str().into()).is_some(),
Err(_) => true,
},
Err(_) => true,
}
}
/// cargo's error out of its stderr: from the first `error:` line, warnings dropped.
pub fn cargo_error(stderr: &str) -> String {
let lines: Vec<&str> = stderr.lines().map(str::trim_end).filter(|l| !l.trim().is_empty()).collect();
let start = lines.iter().position(|l| l.starts_with("error")).unwrap_or(0);
lines[start..]
.iter()
.filter(|l| !l.starts_with("warning:") && !l.trim_start().starts_with("Updating") && !l.trim_start().starts_with("help:"))
.take(5)
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(" | ")
}
/// The packages whose version moved between two locks, as `name@old` specs (a name
/// with two versions in the old lock is named once per version).
pub fn moved(old: &[LockPackage], new: &[LockPackage]) -> Vec<String> {
let mut out = Vec::new();
for o in old {
let same = new.iter().any(|n| n.name == o.name && n.version == o.version);
let still = new.iter().any(|n| n.name == o.name);
if !same && still {
let spec = format!("{}@{}", o.name, o.version);
if !out.contains(&spec) {
out.push(spec);
}
}
}
out
}
fn cargo_in(dir: &Path, args: &[&str]) -> bool {
Command::new(cargo())
.current_dir(dir)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// The exact command that makes the stale lock in `dir` `cargo metadata --locked`
/// clean: `cargo update -p <name@old>...` for the packages a fresh resolution moves,
/// else `cargo update --workspace`. Each candidate is TRIED on the lock and the lock is
/// put back as it was, whatever happened; the first one that leaves it clean is named.
pub fn fix_line(dir: &Path, committed: &str) -> String {
let lock = dir.join("Cargo.lock");
let restore = || {
let _ = std::fs::write(&lock, committed);
};
// What a fresh, minimal resolution moves.
let fresh = if cargo_in(dir, &["metadata", "--format-version", "1"]) {
std::fs::read_to_string(&lock).unwrap_or_default()
} else {
String::new()
};
restore();
let moved = moved(&parse_lock(committed), &parse_lock(&fresh));
let mut candidates: Vec<Vec<String>> = Vec::new();
if !moved.is_empty() {
let mut c = vec!["update".to_string()];
for m in &moved {
c.push("-p".into());
c.push(m.clone());
}
candidates.push(c);
}
candidates.push(vec!["update".into(), "--workspace".into()]);
let mut found = String::new();
for c in candidates {
let args: Vec<&str> = c.iter().map(String::as_str).collect();
let ok = cargo_in(dir, &args) && cargo_in(dir, &["metadata", "--locked", "--format-version", "1"]);
restore();
if ok {
found = format!("cd {} && cargo {}", dir.display(), c.join(" "));
break;
}
}
if found.is_empty() {
found = format!("cd {} && cargo metadata --format-version 1 >/dev/null (no cargo update line alone makes it clean)", dir.display());
}
found
}
// ── lockfollow: the mechanical fix ───────────────────────────────────────────
/// One package version that differs between two locks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub name: String,
/// The version in the old lock (`None`: added).
pub old: Option<String>,
/// The version in the new lock (`None`: removed).
pub new: Option<String>,
/// A path package (a workspace member or a sibling checkout), not a registry crate.
pub path: bool,
}
impl std::fmt::Display for Change {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let kind = if self.path { "sibling" } else { "REGISTRY" };
match (&self.old, &self.new) {
(Some(o), Some(n)) => write!(f, "{kind} {} {o} -> {n}", self.name),
(Some(o), None) => write!(f, "{kind} {} {o} removed", self.name),
(None, Some(n)) => write!(f, "{kind} {} {n} added", self.name),
(None, None) => write!(f, "{kind} {}", self.name),
}
}
}
/// Every package version that differs between `old` and `new`. A name whose one
/// version moved is ONE change (old -> new); otherwise each version that left is a
/// removal and each that came an addition.
pub fn lock_diff(old: &[LockPackage], new: &[LockPackage]) -> Vec<Change> {
let mut names: Vec<&str> = old.iter().chain(new.iter()).map(|p| p.name.as_str()).collect();
names.sort();
names.dedup();
let mut out = Vec::new();
for name in names {
let o: Vec<&LockPackage> = old.iter().filter(|p| p.name == name).collect();
let n: Vec<&LockPackage> = new.iter().filter(|p| p.name == name).collect();
let gone: Vec<&&LockPackage> = o.iter().filter(|p| !n.iter().any(|q| q.version == p.version && q.source == p.source)).collect();
let came: Vec<&&LockPackage> = n.iter().filter(|p| !o.iter().any(|q| q.version == p.version && q.source == p.source)).collect();
// A sibling follow: a path package moved, or a registry copy went away in
// favour of the path copy of the same name (the lock unified onto the sibling).
let path_stays = n.iter().any(|p| p.source.is_none());
if gone.len() == 1 && came.len() == 1 {
out.push(Change {
name: name.into(),
old: Some(gone[0].version.clone()),
new: Some(came[0].version.clone()),
path: came[0].source.is_none(),
});
continue;
}
for g in gone {
out.push(Change { name: name.into(), old: Some(g.version.clone()), new: None, path: g.source.is_none() || path_stays });
}
for c in came {
out.push(Change { name: name.into(), old: None, new: Some(c.version.clone()), path: c.source.is_none() });
}
}
out
}
/// The committed text of `lock` (HEAD), read in process through nornir-git's gix.
pub fn committed_text(repo: &Path, lock: &Path) -> Option<String> {
let rel = lock.strip_prefix(repo).ok()?.to_string_lossy().replace('\\', "/");
let r = nornir_git::gitio::open_for_status(repo).ok()?;
let spec = format!("HEAD:{rel}");
let id = r.rev_parse_single(spec.as_str()).ok()?;
let obj = id.object().ok()?;
String::from_utf8(obj.data.clone()).ok()
}
/// What `lockfollow` did, or would do, to one lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Follow {
/// `cargo metadata --locked` is already clean.
Clean,
/// Followed (or, with `apply = false`, would be): the `cargo update` line run and
/// every change it made, each a sibling path crate.
Followed { line: String, changes: Vec<Change> },
/// Refused: following it would change more than sibling path crates (or cargo
/// could not resolve it at all). The lock is left as committed.
Refused { why: String, changes: Vec<Change> },
}
/// **lockfollow for one lock**: make the lock in `dir` follow its sibling checkouts,
/// mechanically and safely. Starting from the committed text, `cargo update -p
/// <name>@<old>` runs ONLY for the sibling (path) crates whose version moved, never a
/// blanket update and never for a registry crate; the result must change nothing but
/// sibling path crates and be `cargo metadata --locked` clean, or the lock is put back
/// and the follow is refused with what would have changed. `apply = false` plans only.
pub fn follow_lock(dir: &Path, committed: &str, apply: bool) -> Follow {
let lock = dir.join("Cargo.lock");
let restore = || {
let _ = std::fs::write(&lock, committed);
};
restore();
if cargo_in(dir, &["metadata", "--locked", "--format-version", "1"]) {
return Follow::Clean;
}
let old = parse_lock(committed);
// What a fresh, minimal resolution would change. A lock that carries a registry
// copy beside a sibling's path copy (the note of the check) may not resolve at all
// once the sibling moved: the registry copy is moved onto the sibling first.
let drift: Vec<String> = twice(&old, &[])
.iter()
.flat_map(|(name, _, _)| {
old.iter()
.filter(|p| p.name == *name && p.source.is_some())
.map(|p| format!("{}@{}", p.name, p.version))
.collect::<Vec<_>>()
})
.collect();
let mut resolved = cargo_in(dir, &["metadata", "--format-version", "1"]);
if !resolved && !drift.is_empty() {
restore();
let mut a: Vec<String> = vec!["update".into()];
for d in &drift {
a.push("-p".into());
a.push(d.clone());
}
let argv: Vec<&str> = a.iter().map(String::as_str).collect();
resolved = cargo_in(dir, &argv);
}
if !resolved {
restore();
return Follow::Refused { why: "cargo cannot resolve this lock's workspace at all".into(), changes: Vec::new() };
}
let fresh = std::fs::read_to_string(&lock).unwrap_or_default();
restore();
let wanted = lock_diff(&old, &parse_lock(&fresh));
if let Some(bad) = wanted.iter().find(|c| !c.path) {
return Follow::Refused {
why: format!("following it would change a registry crate ({bad}); that is not a sibling follow"),
changes: wanted,
};
}
let mut specs: Vec<String> = wanted.iter().filter_map(|c| c.old.as_ref().map(|o| format!("{}@{o}", c.name))).collect();
specs.dedup();
let mut args: Vec<String> = vec!["update".into()];
for s in &specs {
args.push("-p".into());
args.push(s.clone());
}
let mut line = if specs.is_empty() {
// Only the lock's non-package tables moved (a sibling patch now unused):
// cargo's own minimal rewrite is the follow.
"cargo metadata --format-version 1".to_string()
} else {
format!("cargo {}", args.join(" "))
};
let argv: Vec<&str> = if specs.is_empty() {
vec!["metadata", "--format-version", "1"]
} else {
args.iter().map(String::as_str).collect()
};
if !cargo_in(dir, &argv) {
// `cargo update -p <name>@<old>` cannot name a sibling's old version when the
// workspace has its own crate of that name (nornir's nornir-build-thing 0.1.0
// beside edda's 0.2.5 -> 0.2.6): cargo no longer loads the old entry. The fresh
// resolution moved sibling path crates only (checked above), so it is the follow;
// the registry and --locked checks below still hold it.
let _ = std::fs::write(&lock, &fresh);
line = "cargo metadata --format-version 1".to_string();
}
let result = std::fs::read_to_string(&lock).unwrap_or_default();
let made = lock_diff(&old, &parse_lock(&result));
if let Some(bad) = made.iter().find(|c| !c.path) {
restore();
return Follow::Refused { why: format!("`{line}` also changed a registry crate ({bad})"), changes: made };
}
if !cargo_in(dir, &["metadata", "--locked", "--format-version", "1"]) {
restore();
return Follow::Refused { why: format!("`{line}` did not leave the lock --locked clean"), changes: made };
}
if !apply {
restore();
}
Follow::Followed { line: format!("cd {} && {line}", dir.display()), changes: made }
}
/// `cargo xtask ci lockfollow [--check]` over every tracked lock of `repo` (sibling
/// patches on). Returns false when a stale lock was refused.
pub fn follow_repo(repo: &Path, apply: bool, w: &mut dyn Write) -> Result<bool> {
let on = siblings_on(repo)?;
let locks: Vec<PathBuf> = locks_of(repo).into_iter().filter(|l| tracked(repo, l)).collect();
let _ = writeln!(w, "lockfollow: {} ({} locks{}{})", repo.display(), locks.len(), if on { ", sibling patches on" } else { "" }, if apply { "" } else { ", --check: nothing written" });
let mut ok = true;
for lock in locks {
let dir = lock.parent().unwrap_or(repo).to_path_buf();
// The committed text, never the live file: a `cargo xtask` run has already
// rewritten the live lock, and following from it would call that "clean".
let Some(committed) = committed_text(repo, &lock) else {
let _ = writeln!(w, " UNREADABLE {}: HEAD's lock could not be read (a linked worktree whose gitdir is not visible here?)", lock.display());
ok = false;
continue;
};
match follow_lock(&dir, &committed, apply) {
Follow::Clean => {
let _ = writeln!(w, " clean {}", lock.display());
}
Follow::Followed { line, changes } => {
let _ = writeln!(w, " {} {} ({line})", if apply { "FOLLOWED " } else { "would follow" }, lock.display());
for c in changes {
let _ = writeln!(w, " {c}");
}
}
Follow::Refused { why, changes } => {
ok = false;
let _ = writeln!(w, " REFUSED {}: {why}", lock.display());
for c in changes {
let _ = writeln!(w, " {c}");
}
}
}
}
Ok(ok)
}
/// Check one lock./// Check one lock.
pub fn check_lock(lock: &Path, single: &[&str]) -> Vec<Finding> {
let dir = lock.parent().unwrap_or(Path::new("."));
let mut out = Vec::new();
let text = match std::fs::read_to_string(lock) {
Ok(t) => t,
Err(e) => return vec![Finding::Unreadable { lock: lock.into(), detail: e.to_string() }],
};
let run = Command::new(cargo())
.current_dir(dir)
.args(["metadata", "--locked", "--format-version", "1", "--manifest-path", "Cargo.toml"])
.stdout(std::process::Stdio::null())
.output();
match run {
Ok(o) if o.status.success() => {}
Ok(o) => {
let fix = match follow_lock(dir, &text, false) {
Follow::Followed { line, .. } => format!("cargo xtask ci lockfollow (runs: {line})"),
Follow::Refused { why, .. } => format!("{} (lockfollow refuses: {why})", fix_line(dir, &text)),
Follow::Clean => String::new(),
};
out.push(Finding::Stale { lock: lock.into(), detail: cargo_error(&String::from_utf8_lossy(&o.stderr)), fix });
}
Err(e) => out.push(Finding::Unreadable { lock: lock.into(), detail: format!("cargo metadata: {e}") }),
}
for (name, versions, refused) in twice(&parse_lock(&text), single) {
out.push(if refused {
Finding::Twice { lock: lock.into(), name, versions }
} else {
Finding::Drift { lock: lock.into(), name, versions }
});
}
out
}
/// Check every lock of `repo` (sibling patches on), writing one line per lock to `w`.
pub fn check_repo(repo: &Path, single: &[&str], w: &mut dyn Write) -> Result<Vec<Finding>> {
let on = siblings_on(repo)?;
let mut all = Vec::new();
// Only TRACKED locks: an ignored one (holger's xtask/boot, Skidbladnir's root) is
// the checkout's own business, not a lock anyone ships.
let locks: Vec<PathBuf> = locks_of(repo).into_iter().filter(|l| tracked(repo, l)).collect();
// `cargo xtask` itself is a `cargo run`, which rewrites a stale lock before any
// step runs. So a lock that differs from the committed one IS the finding: the
// committed lock did not match its manifests and siblings.
// A checkout whose git state cannot be read (a linked worktree whose gitdir is not
// visible here, a bare tree) is said so and is red: "clean" must never mean
// "could not look".
let changed = match nornir_git::gitio::worktree_changed_paths(repo) {
Ok(c) => c,
Err(e) => {
let _ = writeln!(w, " UNREADABLE {}: git status could not be read ({e}); no lock here is judged", repo.display());
return Ok(vec![Finding::Unreadable { lock: repo.join("Cargo.lock"), detail: format!("git status could not be read: {e}") }]);
}
};
for lock in &locks {
let rel = lock.strip_prefix(repo).map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
if changed.contains(&rel) {
let dir = lock.parent().unwrap_or(repo).display().to_string();
all.push(Finding::Stale {
lock: lock.clone(),
detail: "the lock differs from the committed one (a cargo run in this checkout rewrote it, so the committed lock is stale)".into(),
fix: format!("cd {dir} && cargo metadata --format-version 1 >/dev/null && git add Cargo.lock (commit the lock cargo resolves)"),
});
}
}
let _ = writeln!(w, "lockcheck: {} ({} locks{})", repo.display(), locks.len(), if on { ", sibling patches on" } else { "" });
for x in &all {
let _ = writeln!(w, " {x}");
}
for lock in locks {
let f = check_lock(&lock, single);
if f.is_empty() && !all.iter().any(|x| matches!(x, Finding::Stale { lock: l, .. } if *l == lock)) {
let _ = writeln!(w, " ok {}", lock.display());
}
for x in &f {
let _ = writeln!(w, " {x}");
}
all.extend(f);
}
Ok(all)
}
/// The repo whose toolchain pin is the reference: the Build Thing's own.
pub const PIN_REFERENCE_REPO: &str = "edda";
/// A repo's declared toolchain image and pin: `image` and `image_sha256` of the
/// `[build.toolchain]` table in `.nornir/build-variants.toml`, if it declares one.
pub fn toolchain_pin(repo: &Path) -> Option<(String, String)> {
let text = std::fs::read_to_string(repo.join(".nornir/build-variants.toml")).ok()?;
let mut inside = false;
let (mut image, mut pin) = (None, None);
for line in text.lines() {
let l = line.trim();
if l.starts_with('[') {
inside = l == "[build.toolchain]";
continue;
}
if !inside {
continue;
}
let Some((k, v)) = l.split_once('=') else { continue };
let v = v.split('#').next().unwrap_or("").trim().trim_matches('"').to_string();
match k.trim() {
"image" => image = Some(v),
"image_sha256" => pin = Some(v),
_ => {}
}
}
Some((image?, pin?))
}
/// Every repo under `dir` whose pin for the reference repo's image differs from the
/// reference repo's pin. Nothing when the reference repo is absent or pins nothing.
pub fn pin_findings(repos: &[PathBuf], dir: &Path) -> Vec<Finding> {
let Some((image, reference)) = toolchain_pin(&dir.join(PIN_REFERENCE_REPO)) else { return Vec::new() };
repos
.iter()
.filter_map(|r| {
let (i, p) = toolchain_pin(r)?;
(i == image && p != reference).then(|| Finding::PinDiffers {
repo: r.clone(),
image: i,
pin: p,
reference: reference.clone(),
})
})
.collect()
}
/// The checkouts under `dir` (every subdirectory with a `.git`), or the named ones.
pub fn repos_under(dir: &Path, named: &[String]) -> Vec<PathBuf> {
if !named.is_empty() {
return named.iter().map(|n| dir.join(n)).collect();
}
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.join(".git").exists()).collect())
.unwrap_or_default();
v.sort();
v
}
/// The whole constellation under `dir`: every lock of every checkout. Green when no
/// returned finding [is red](Finding::is_red).
pub fn check_constellation(dir: &Path, named: &[String], single: &[&str], w: &mut dyn Write) -> Result<Vec<Finding>> {
let mut all = Vec::new();
let repos = repos_under(dir, named);
for repo in &repos {
all.extend(check_repo(repo, single, w)?);
}
let pins = pin_findings(&repos, dir);
for p in &pins {
let _ = writeln!(w, " {p}");
}
all.extend(pins);
let red = all.iter().filter(|f| f.is_red()).count();
let _ = writeln!(w, "lockcheck: {red} red finding(s), {} note(s)", all.len() - red);
Ok(all)
}
/// `--single <crate>` (repeatable) on top of [`DEFAULT_SINGLE`].
pub fn single_from_args(args: &[String]) -> Vec<String> {
let mut v: Vec<String> = DEFAULT_SINGLE.iter().map(|s| s.to_string()).collect();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--single" {
if let Some(n) = it.next() {
v.push(n.clone());
}
}
}
v
}
/// A [`crate::ci::Step`] that lock-checks the repo at `root` itself.
pub fn step(root: &Path) -> crate::ci::Step {
let root = root.to_path_buf();
crate::ci::Step::in_process("lockcheck", move |w| {
let single: Vec<&str> = DEFAULT_SINGLE.to_vec();
let red: Vec<String> = check_repo(&root, &single, w)?.iter().filter(|f| f.is_red()).map(|f| f.to_string()).collect();
if red.is_empty() {
Ok(None)
} else {
Err(red.join("; ").into())
}
})
}
#[cfg(test)]
mod tests {
use super::*;
const LOCK: &str = r#"# This file is automatically @generated by Cargo.
version = 4
[[package]]
name = "holger"
version = "0.9.0"
[[package]]
name = "korp-installer"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab"
[[package]]
name = "korp-installer"
version = "0.2.7"
dependencies = [
"x",
]
[[package]]
name = "nordisk-pairing"
version = "0.5.26"
[[package]]
name = "nordisk-pairing"
version = "0.5.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "ssh-key"
version = "0.7.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
[metadata]
"#;
#[test]
fn a_lock_is_read_package_by_package() {
let p = parse_lock(LOCK);
assert_eq!(p.len(), 7);
assert_eq!(p[0], LockPackage { name: "holger".into(), version: "0.9.0".into(), source: None });
assert!(p[1].source.as_deref().unwrap().starts_with("registry+"));
}
#[test]
fn korp_installer_twice_and_a_sibling_beside_its_registry_copy_are_refused() {
let t = twice(&parse_lock(LOCK), DEFAULT_SINGLE);
let names: Vec<&str> = t.iter().map(|(n, _, _)| n.as_str()).collect();
// ssh-key at two REGISTRY versions is cargo's business (two majors may coexist);
// korp-installer is refused by name; nordisk-pairing because a sibling provides it.
assert_eq!(names, vec!["korp-installer", "nordisk-pairing"]);
assert_eq!(t[0].1, vec!["0.2.6".to_string(), "0.2.7".to_string()]);
assert!(t[0].2, "korp-installer twice is refused");
assert!(!t[1].2, "a sibling beside its registry copy is a note");
let named = twice(&parse_lock(LOCK), &["ssh-key"]);
assert!(named.iter().any(|(n, _, r)| n == "ssh-key" && *r), "a named crate is refused at two versions");
}
#[test]
fn locks_are_found_beside_manifests_and_build_output_and_fixtures_are_skipped() {
let d = std::env::temp_dir().join(format!("nornir-lockcheck-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for sub in ["", "front", "target/debug/x", ".hidden", "xtask/tests/fixtures/cve_vulnerable"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
std::fs::write(d.join(sub).join("Cargo.toml"), "").unwrap();
std::fs::write(d.join(sub).join("Cargo.lock"), "").unwrap();
}
std::fs::create_dir_all(d.join("nomanifest")).unwrap();
std::fs::write(d.join("nomanifest/Cargo.lock"), "").unwrap();
let got: Vec<PathBuf> = locks_of(&d).into_iter().map(|p| p.strip_prefix(&d).unwrap().to_path_buf()).collect();
assert_eq!(got, vec![PathBuf::from("Cargo.lock"), PathBuf::from("front/Cargo.lock")]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_sibling_patches_go_on_only_when_every_sibling_is_beside_the_repo() {
let d = std::env::temp_dir().join(format!("nornir-siblings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let repo = d.join("holger");
std::fs::create_dir_all(repo.join(".cargo")).unwrap();
let tpl = "[patch.crates-io]\njera = { path = \"../edda/crates/jera\" }\n";
std::fs::write(repo.join(".cargo/estate-siblings.toml"), tpl).unwrap();
assert!(!siblings_on(&repo).unwrap(), "edda is not beside it");
assert!(!repo.join(".cargo/siblings.toml").exists());
std::fs::create_dir_all(d.join("edda")).unwrap();
assert!(siblings_on(&repo).unwrap());
assert_eq!(std::fs::read_to_string(repo.join(".cargo/siblings.toml")).unwrap(), tpl);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn cargos_error_is_kept_and_its_warnings_dropped() {
let e = "warning: patch `x v1 (/s/x)` was not used in the crate graph\nhelp: Check that\n Updating crates.io index\nerror: cannot update the lock file /s/Cargo.lock because --locked was passed\n";
assert_eq!(cargo_error(e), "error: cannot update the lock file /s/Cargo.lock because --locked was passed");
}
#[test]
fn a_lock_rewritten_after_its_commit_is_stale() {
let d = std::env::temp_dir().join(format!("nornir-lockcheck-git-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
std::fs::write(d.join("lib.rs"), "").unwrap();
std::fs::write(d.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
nornir_git::gitio::init(&d).unwrap();
nornir_git::gitio::commit_all(&d, "lock").unwrap();
let mut sink = Vec::new();
let clean = check_repo(&d, DEFAULT_SINGLE, &mut sink).unwrap();
assert!(!clean.iter().any(|f| f.is_red()), "{clean:?}");
std::fs::write(d.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n\n").unwrap();
std::fs::create_dir_all(d.join("boot")).unwrap();
std::fs::write(d.join("boot/Cargo.toml"), "").unwrap();
std::fs::write(d.join("boot/Cargo.lock"), "garbage").unwrap();
assert!(!tracked(&d, &d.join("boot/Cargo.lock")) && tracked(&d, &d.join("Cargo.lock")));
let after = check_repo(&d, DEFAULT_SINGLE, &mut sink).unwrap();
assert!(!after.iter().any(|f| format!("{f}").contains("boot")), "an untracked lock is not checked: {after:?}");
let committed = committed_text(&d, &d.join("Cargo.lock")).expect("HEAD's lock is read");
assert!(committed.ends_with("version = \"0.1.0\"\n"), "the committed text, not the rewritten one: {committed:?}");
assert!(after.iter().any(|f| matches!(f, Finding::Stale { detail, .. } if detail.contains("committed"))), "{after:?}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_repo_pinned_off_the_reference_toolchain_is_red_and_another_image_is_not_compared() {
let d = std::env::temp_dir().join(format!("nornir-pins-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let pin = |repo: &str, image: &str, sha: &str| {
std::fs::create_dir_all(d.join(repo).join(".nornir")).unwrap();
std::fs::write(
d.join(repo).join(".nornir/build-variants.toml"),
format!("[[build.variant]]\nname = \"x\"\nimage = \"not-this\"\n\n[build.toolchain]\nimage = \"{image}\"\nimage_sha256 = \"{sha}\" # pinned\nrust_version = \"1.97.1\"\n"),
)
.unwrap();
};
pin("edda", "nordisk-cargo-toolchain", "86d575e8aa");
pin("holger", "nordisk-cargo-toolchain", "3d5115baaa");
pin("dwarves", "nordisk-cargo-toolchain", "86d575e8aa");
pin("facett", "nordisk-pip-toolchain", "1111");
assert_eq!(toolchain_pin(&d.join("holger")), Some(("nordisk-cargo-toolchain".into(), "3d5115baaa".into())));
let repos: Vec<PathBuf> = ["dwarves", "edda", "facett", "holger"].iter().map(|r| d.join(r)).collect();
let f = pin_findings(&repos, &d);
assert_eq!(f.len(), 1, "{f:?}");
assert!(f[0].is_red() && f[0].to_string().contains("holger") && f[0].to_string().contains("3d5115baaa"), "{}", f[0]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_packages_a_fresh_resolution_moves_are_named_by_their_old_version() {
let old = parse_lock(LOCK);
let mut new = old.clone();
new.retain(|p| !(p.name == "korp-installer" && p.version == "0.2.6"));
for p in new.iter_mut().filter(|p| p.name == "ssh-key") {
p.version = "0.7.0".into();
}
new.push(LockPackage { name: "brand-new".into(), version: "1.0.0".into(), source: None });
let m = moved(&old, &new);
assert_eq!(m, vec!["korp-installer@0.2.6".to_string(), "ssh-key@0.6.7".to_string(), "ssh-key@0.7.0-rc.11".to_string()]);
}
#[test]
fn a_stale_lock_is_reported_with_the_command_that_fixes_it_and_is_left_as_it_was() {
let d = std::env::temp_dir().join(format!("nornir-lockfix-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("dep")).unwrap();
std::fs::write(d.join("dep/Cargo.toml"), "[package]\nname = \"dep\"\nversion = \"0.2.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
std::fs::write(d.join("dep/lib.rs"), "").unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n[dependencies]\ndep = { path = \"dep\" }\n[workspace]\n").unwrap();
std::fs::write(d.join("lib.rs"), "").unwrap();
// The lock still names dep 0.1.0: the sibling moved on, the lock did not.
let stale = "version = 4\n\n[[package]]\nname = \"dep\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"top\"\nversion = \"0.1.0\"\ndependencies = [\n \"dep\",\n]\n";
std::fs::write(d.join("Cargo.lock"), stale).unwrap();
let f = check_lock(&d.join("Cargo.lock"), DEFAULT_SINGLE);
let fix = f.iter().find_map(|x| match x {
Finding::Stale { fix, .. } => Some(fix.clone()),
_ => None,
});
let fix = fix.expect("the stale lock is found");
assert!(fix.starts_with("cargo xtask ci lockfollow") && fix.contains("cargo update -p dep@0.1.0"), "{fix}");
assert_eq!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), stale, "the lock is left as it was");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_lock_diff_names_each_move_and_whether_it_is_a_sibling() {
let old = parse_lock(LOCK);
let mut new = old.clone();
for p in new.iter_mut() {
if p.name == "holger" {
p.version = "0.9.1".into();
}
}
new.retain(|p| !(p.name == "korp-installer" && p.version == "0.2.6"));
let d = lock_diff(&old, &new);
assert_eq!(d.len(), 2, "{d:?}");
assert_eq!(d[0].to_string(), "sibling holger 0.9.0 -> 0.9.1");
assert_eq!(d[1].to_string(), "sibling korp-installer 0.2.6 removed", "the registry copy left in favour of the sibling's 0.2.7");
let mut gone = old.clone();
gone.retain(|p| p.name != "ssh-key" || p.version != "0.6.7");
let d = lock_diff(&old, &gone);
assert_eq!(d[0].to_string(), "REGISTRY ssh-key 0.6.7 removed", "no sibling stays: a registry change");
}
fn sibling_fixture(tag: &str, dep_version: &str) -> (PathBuf, String) {
let d = std::env::temp_dir().join(format!("nornir-lockfollow-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("dep")).unwrap();
std::fs::write(d.join("dep/Cargo.toml"), format!("[package]\nname = \"dep\"\nversion = \"{dep_version}\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n")).unwrap();
std::fs::write(d.join("dep/lib.rs"), "").unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n[dependencies]\ndep = { path = \"dep\" }\n[workspace]\n").unwrap();
std::fs::write(d.join("lib.rs"), "").unwrap();
let stale = "version = 4\n\n[[package]]\nname = \"dep\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"top\"\nversion = \"0.1.0\"\ndependencies = [\n \"dep\",\n]\n".to_string();
std::fs::write(d.join("Cargo.lock"), &stale).unwrap();
(d, stale)
}
#[test]
fn lockfollow_moves_only_the_sibling_and_check_writes_nothing() {
let (d, stale) = sibling_fixture("ok", "0.2.0");
match follow_lock(&d, &stale, false) {
Follow::Followed { line, changes } => {
assert!(line.ends_with("cargo update -p dep@0.1.0"), "{line}");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].to_string(), "sibling dep 0.1.0 -> 0.2.0");
}
other => panic!("{other:?}"),
}
assert_eq!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), stale, "--check writes nothing");
assert!(matches!(follow_lock(&d, &stale, true), Follow::Followed { .. }));
assert!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap().contains("version = \"0.2.0\""));
assert_eq!(follow_lock(&d, &std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), true), Follow::Clean);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn lockfollow_refuses_a_follow_that_would_change_a_registry_crate() {
let (d, _) = sibling_fixture("reg", "0.2.0");
// The committed lock names a registry crate nothing depends on any more: a
// fresh resolution would remove it, which is not a sibling follow.
let stale = "version = 4\n\n[[package]]\nname = \"dep\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"itoa\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"00\"\n\n[[package]]\nname = \"top\"\nversion = \"0.1.0\"\ndependencies = [\n \"dep\",\n]\n";
std::fs::write(d.join("Cargo.lock"), stale).unwrap();
match follow_lock(&d, stale, true) {
Follow::Refused { why, changes } => {
assert!(why.contains("registry crate") && why.contains("itoa"), "{why}");
assert!(changes.iter().any(|c| c.name == "itoa" && !c.path));
}
other => panic!("{other:?}"),
}
assert_eq!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), stale, "a refused follow leaves the lock as committed");
let _ = std::fs::remove_dir_all(&d);
}
/// nornir's shape (2026-10-24): the workspace has its OWN crate named like the sibling
/// (nornir's nornir-build-thing 0.1.0 beside edda's 0.2.5), and the sibling moves
/// (0.2.5 -> 0.2.6). `cargo update -p nornir-build-thing@0.2.5` cannot name the old
/// version any more, but the fresh resolution moves the sibling and nothing else: that
/// IS the follow, not a refusal.
#[test]
fn a_sibling_named_like_a_local_crate_is_followed() {
let d = std::env::temp_dir().join(format!("nornir-lockfollow-samename-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let pkg = |dir: &str, name: &str, version: &str, deps: &str| {
std::fs::create_dir_all(d.join(dir)).unwrap();
std::fs::write(d.join(dir).join("lib.rs"), "").unwrap();
std::fs::write(
d.join(dir).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n[dependencies]\n{deps}"),
)
.unwrap();
};
pkg("sib", "nbt", "0.2.5", "");
pkg("ws/local", "nbt", "0.1.0", "up = { package = \"nbt\", path = \"../../sib\" }\n");
pkg("ws", "top", "0.1.0", "nbt = { path = \"local\" }\n");
let mut m = std::fs::read_to_string(d.join("ws/Cargo.toml")).unwrap();
m.push_str("[workspace]\nmembers = [\"local\"]\n");
std::fs::write(d.join("ws/Cargo.toml"), m).unwrap();
let ws = d.join("ws");
assert!(cargo_in(&ws, &["metadata", "--format-version", "1"]), "the fixture resolves");
let committed = std::fs::read_to_string(ws.join("Cargo.lock")).unwrap();
assert!(committed.contains("0.2.5") && committed.contains("0.1.0"), "{committed}");
pkg("sib", "nbt", "0.2.6", "");
match follow_lock(&ws, &committed, true) {
Follow::Followed { changes, .. } => assert!(changes.iter().all(|c| c.path), "{changes:?}"),
other => panic!("the sibling move is a follow: {other:?}"),
}
let after = std::fs::read_to_string(ws.join("Cargo.lock")).unwrap();
assert!(after.contains("0.2.6") && after.contains("0.1.0") && !after.contains("0.2.5"), "{after}");
assert!(cargo_in(&ws, &["metadata", "--locked", "--format-version", "1"]), "the followed lock is --locked clean");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_committed_text_is_read_in_a_linked_worktree_too() {
let d = std::env::temp_dir().join(format!("nornir-lock-wt-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let main = d.join("main");
std::fs::create_dir_all(&main).unwrap();
std::fs::write(main.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
std::fs::write(main.join("lib.rs"), "").unwrap();
std::fs::write(main.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
nornir_git::gitio::init(&main).unwrap();
nornir_git::gitio::commit_all(&main, "lock").unwrap();
// The git CLI only lays the fixture (a linked worktree), as a developer does.
let wt = d.join("wt");
let ok = std::process::Command::new("git").args(["-C", main.to_str().unwrap(), "worktree", "add", "-q", wt.to_str().unwrap(), "HEAD"]).status().map(|s| s.success()).unwrap_or(false);
if !ok {
eprintln!("no git CLI here: the worktree fixture cannot be laid");
return;
}
std::fs::write(wt.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.2.0\"\n").unwrap();
let got = committed_text(&wt, &wt.join("Cargo.lock")).expect("HEAD's lock is read through the worktree");
assert!(got.contains("0.1.0") && !got.contains("0.2.0"), "the committed text, not the live file: {got:?}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_checkout_whose_git_cannot_be_read_is_said_unreadable_never_clean() {
let d = std::env::temp_dir().join(format!("nornir-lock-nogit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
std::fs::write(d.join("lib.rs"), "").unwrap();
std::fs::write(d.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
// A worktree's gitfile pointing at a gitdir that is not here.
std::fs::write(d.join(".git"), "gitdir: /nonexistent/repo/.git/worktrees/x\n").unwrap();
let mut sink = Vec::new();
let f = check_repo(&d, DEFAULT_SINGLE, &mut sink).unwrap();
assert!(f.iter().any(|x| matches!(x, Finding::Unreadable { .. })) && f.iter().all(|x| x.is_red()), "{f:?}");
assert!(!follow_repo(&d, false, &mut sink).unwrap(), "lockfollow is not ok either");
let said = String::from_utf8_lossy(&sink);
assert!(said.contains("UNREADABLE") && !said.contains("clean"), "{said}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn single_names_add_to_the_default() {
let a: Vec<String> = ["--single", "ssh-key", "x"].iter().map(|s| s.to_string()).collect();
assert_eq!(single_from_args(&a), vec!["korp-installer".to_string(), "ssh-key".to_string()]);
}
}