mod api;
mod index;
mod json;
mod lock;
mod manifest;
mod prefixes;
mod privileged;
#[cfg(feature = "tui")]
mod progress;
mod report;
mod shadow;
mod stage;
mod text;
#[cfg(feature = "tui")]
mod tui;
mod validate;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use lock::{Mode, StateLock};
use manifest::{Entry, Manifest};
use report::{Checked, Report, Status};
use semver::Version;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use validate::{InstallSpec, validate_name};
const EXIT_UPDATES: u8 = 0;
const EXIT_ERROR: u8 = 1;
const EXIT_NO_UPDATES: u8 = 2;
#[derive(Parser)]
#[command(
name = "cargo-lbin",
version,
about = "Install crates.io binaries into <prefix>/bin (default /usr/local/bin)",
long_about = "Builds crates as the invoking user in a stage directory, then installs \
the resulting binaries into <prefix>/bin, escalating via sudo only for file \
placement. State lives in <prefix>/share/cargo-lbin/manifest.json. Sources are \
crates.io exclusively."
)]
struct Cli {
#[arg(
long,
global = true,
env = "CARGO_LBIN_PREFIX",
help = "Installation prefix; binaries land in <prefix>/bin",
default_value = "/usr/local"
)]
prefix: PathBuf,
#[arg(long, global = true)]
user: bool,
#[command(subcommand)]
cmd: Cmd,
}
fn user_prefix() -> Result<PathBuf> {
#[allow(deprecated)] std::env::home_dir()
.context("--user needs a home directory, and none could be determined")
.map(|home| home.join(".local"))
}
#[derive(Subcommand)]
enum Cmd {
Install {
#[arg(required = true, value_name = "NAME[@VERSION]")]
crates: Vec<String>,
#[arg(long)]
locked: bool,
},
Remove {
#[arg(required = true)]
crates: Vec<String>,
},
Verify {
#[arg(long)]
json: bool,
},
Clean {
#[arg(long)]
dry_run: bool,
#[arg(long)]
stages: bool,
#[arg(long, value_name = "DAYS")]
logs_older_than: Option<u64>,
},
Pin {
#[arg(required = true)]
crates: Vec<String>,
},
Unpin {
#[arg(required = true)]
crates: Vec<String>,
},
Pinned {
#[arg(long)]
check: bool,
#[arg(long)]
json: bool,
},
List {
#[arg(long)]
json: bool,
},
#[cfg(feature = "tui")]
Tui,
Info {
#[arg(required = true)]
crates: Vec<String>,
#[arg(long)]
versions: bool,
},
Search {
#[arg(required = true)]
query: Vec<String>,
#[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(1..=100))]
limit: u8,
},
Checkupdate {
#[arg(long)]
json: bool,
},
Downgrade {
#[arg(value_name = "NAME")]
name: String,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Man {
dir: PathBuf,
},
Migrate {
#[arg(required_unless_present = "all", conflicts_with = "all")]
crates: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long, value_name = "PREFIX")]
to: PathBuf,
#[arg(long, short)]
yes: bool,
},
Update {
#[arg(required_unless_present = "all", conflicts_with = "all")]
crates: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long, short)]
yes: bool,
},
}
fn main() -> ExitCode {
let args = std::env::args_os()
.enumerate()
.filter_map(|(i, a)| (!(i == 1 && a == *"lbin")).then_some(a));
let matches = <Cli as clap::CommandFactory>::command().get_matches_from(args);
let mut cli = match <Cli as clap::FromArgMatches>::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
if cli.user {
if matches.value_source("prefix") == Some(clap::parser::ValueSource::CommandLine) {
eprintln!("error: the argument '--user' cannot be used with an explicit '--prefix'");
return ExitCode::from(EXIT_ERROR);
}
cli.prefix = match user_prefix() {
Ok(prefix) => prefix,
Err(e) => {
eprintln!("error: {e:#}");
return ExitCode::from(EXIT_ERROR);
}
};
}
if unsafe { libc::geteuid() } == 0
&& std::env::var_os("CARGO_LBIN_ALLOW_ROOT").is_none_or(|v| v != "1")
{
eprintln!("error: cargo-lbin must not be run as root");
eprintln!("run it as your normal user; sudo is requested only when required for placement");
eprintln!("(set CARGO_LBIN_ALLOW_ROOT=1 only in environments where root is the only user)");
return ExitCode::from(EXIT_ERROR);
}
if let Some(note) = bin_dir_prefix_note(&cli.prefix) {
eprintln!("warning: {note}");
}
if let Cmd::Migrate { ref to, .. } = cli.cmd
&& let Some(note) = bin_dir_prefix_note(to)
{
eprintln!("warning: {note}");
}
let result = match cli.cmd {
Cmd::Install { ref crates, locked } => cmd_install(&cli.prefix, crates, locked),
Cmd::Remove { ref crates } => cmd_remove(&cli.prefix, crates),
Cmd::Verify { json } => cmd_verify(&cli.prefix, json),
Cmd::Pin { ref crates } => cmd_set_pinned(&cli.prefix, crates, true),
Cmd::Unpin { ref crates } => cmd_set_pinned(&cli.prefix, crates, false),
Cmd::Pinned { check, json } => return cmd_pinned(&cli.prefix, check, json),
Cmd::List { json } => cmd_list(&cli.prefix, json),
#[cfg(feature = "tui")]
Cmd::Tui => tui::run(&cli.prefix),
Cmd::Info {
ref crates,
versions,
} => cmd_info(&cli.prefix, crates, versions),
Cmd::Search { ref query, limit } => cmd_search(&cli.prefix, query, limit),
Cmd::Checkupdate { json } => return cmd_checkupdate(&cli.prefix, json),
Cmd::Clean {
dry_run,
stages,
logs_older_than,
} => cmd_clean(dry_run, stages, logs_older_than),
Cmd::Man { ref dir } => cmd_man(dir),
Cmd::Completions { shell } => {
cmd_completions(shell);
Ok(())
}
Cmd::Downgrade { ref name } => cmd_downgrade(&cli.prefix, name),
Cmd::Update {
ref crates,
all,
yes,
} => cmd_update(&cli.prefix, crates, all, yes),
Cmd::Migrate {
ref crates,
all,
ref to,
yes,
} => cmd_migrate(&cli.prefix, to, crates, all, yes),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(EXIT_ERROR)
}
}
}
fn cache_dir() -> Result<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
return Ok(PathBuf::from(xdg).join("cargo-lbin"));
}
let home = std::env::var_os("HOME").context("neither XDG_CACHE_HOME nor HOME is set")?;
Ok(PathBuf::from(home).join(".cache/cargo-lbin"))
}
fn obsolete_bins(old: &[String], new: &[String]) -> Vec<String> {
old.iter().filter(|b| !new.contains(b)).cloned().collect()
}
fn newly_introduced_bins(old: &[String], new: &[String]) -> Vec<String> {
obsolete_bins(new, old)
}
struct RollbackSet {
new_names: Vec<String>,
placed: Vec<PathBuf>,
}
impl RollbackSet {
fn snapshot(manifest: &Manifest, name: &str, new_bins: &[String]) -> Self {
let old_bins = manifest
.crates
.get(name)
.map(|e| e.bins.clone())
.unwrap_or_default();
Self {
new_names: newly_introduced_bins(&old_bins, new_bins),
placed: Vec::new(),
}
}
fn note_placed(&mut self, bin: &str, dest: PathBuf) {
if self.new_names.iter().any(|n| n == bin) {
self.placed.push(dest);
}
}
}
fn rollback_new_bins(policy: privileged::Policy, placed: &[PathBuf]) {
if placed.is_empty() {
return;
}
eprintln!("rolling back newly installed binaries");
for path in placed {
if privileged::remove_files(policy, &[path.as_path()]).is_err() {
eprintln!(
"warning: could not remove {}; remove it manually before retrying",
path.display()
);
}
}
}
fn check_collisions(
manifest: &Manifest,
name: &str,
bins: &[String],
bin_dir: &Path,
) -> Result<()> {
for bin in bins {
let owned_by_self = manifest
.crates
.get(name)
.is_some_and(|e| e.bins.contains(bin));
if owned_by_self {
continue;
}
if let Some((other, _)) = manifest
.crates
.iter()
.find(|(n, e)| n.as_str() != name && e.bins.contains(bin))
{
bail!("binary `{bin}` is already provided by crate `{other}`");
}
let dest = bin_dir.join(bin);
if dest.symlink_metadata().is_ok() {
bail!(
"{} already exists and is not managed by cargo-lbin \
(if it is a leftover from an interrupted run, remove it and retry)",
dest.display()
);
}
}
Ok(())
}
#[cfg(feature = "tui")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum LineKind {
Cargo,
Notice,
Warning,
}
#[cfg(feature = "tui")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[repr(u8)]
pub(crate) enum BuildPhase {
Building = 0,
Placement = 1,
Cancelling = 2,
}
#[cfg(feature = "tui")]
pub(crate) struct BuildControl {
phase: std::sync::atomic::AtomicU8,
pgid: std::sync::atomic::AtomicI32,
}
#[cfg(feature = "tui")]
#[derive(Debug)]
pub(crate) enum CancelOutcome {
Accepted,
Killed,
AlreadyStopping,
TooLate,
}
#[cfg(feature = "tui")]
impl BuildControl {
const ORD: std::sync::atomic::Ordering = std::sync::atomic::Ordering::SeqCst;
pub fn new() -> Self {
Self {
phase: std::sync::atomic::AtomicU8::new(BuildPhase::Building as u8),
pgid: std::sync::atomic::AtomicI32::new(0),
}
}
fn phase(&self) -> BuildPhase {
match self.phase.load(Self::ORD) {
0 => BuildPhase::Building,
1 => BuildPhase::Placement,
_ => BuildPhase::Cancelling,
}
}
pub fn cancelled(&self) -> bool {
self.phase() == BuildPhase::Cancelling
}
pub fn spawned(&self, pgid: i32) {
self.pgid.store(pgid, Self::ORD);
if self.cancelled() {
Self::signal(pgid, libc::SIGTERM);
}
}
pub fn reaped(&self) {
self.pgid.store(0, Self::ORD);
}
pub fn begin_placement(&self) -> Result<()> {
self.phase
.compare_exchange(
BuildPhase::Building as u8,
BuildPhase::Placement as u8,
Self::ORD,
Self::ORD,
)
.map(|_| ())
.map_err(|_| anyhow::Error::new(BuildCancelled))
}
pub fn request_cancel(&self) -> CancelOutcome {
match self.phase.compare_exchange(
BuildPhase::Building as u8,
BuildPhase::Cancelling as u8,
Self::ORD,
Self::ORD,
) {
Ok(_) => {
let pgid = self.pgid.load(Self::ORD);
if pgid != 0 {
Self::signal(pgid, libc::SIGTERM);
}
CancelOutcome::Accepted
}
Err(current) if current == BuildPhase::Cancelling as u8 => {
let pgid = self.pgid.load(Self::ORD);
if pgid == 0 {
CancelOutcome::AlreadyStopping
} else {
Self::signal(pgid, libc::SIGKILL);
CancelOutcome::Killed
}
}
Err(_) => CancelOutcome::TooLate,
}
}
fn signal(pgid: i32, sig: i32) {
unsafe {
libc::kill(-pgid, sig);
}
}
}
#[cfg(feature = "tui")]
#[derive(Debug)]
pub(crate) struct BuildCancelled;
#[cfg(feature = "tui")]
impl std::fmt::Display for BuildCancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("build cancelled")
}
}
#[cfg(feature = "tui")]
impl std::error::Error for BuildCancelled {}
pub(crate) enum Frontend<'a> {
Terminal,
Checkpointed {
checkpoint: &'a mut dyn FnMut() -> Result<()>,
},
#[cfg(feature = "tui")]
Captured {
on_line: &'a mut dyn FnMut(LineKind, &str),
before_placement: &'a mut dyn FnMut(&Path) -> Result<()>,
control: &'a BuildControl,
checkpoint: Option<&'a mut dyn FnMut() -> Result<()>>,
},
#[cfg(not(feature = "tui"))]
#[allow(dead_code)]
Never(std::marker::PhantomData<&'a ()>),
}
impl Frontend<'_> {
#[cfg_attr(not(feature = "tui"), allow(unused_variables))]
fn build(
&mut self,
name: &str,
version: Option<&Version>,
locked: bool,
stage_dir: &Path,
cache: &Path,
) -> Result<stage::Built> {
match self {
Frontend::Terminal | Frontend::Checkpointed { .. } => {
stage::build(name, version, locked, stage_dir)
}
#[cfg(feature = "tui")]
Frontend::Captured {
on_line, control, ..
} => {
if control.cancelled() {
return Err(anyhow::Error::new(BuildCancelled));
}
stage::build_captured(
name,
version,
locked,
stage_dir,
&cache.join("logs"),
&mut |l| on_line(LineKind::Cargo, l),
control,
)
}
#[cfg(not(feature = "tui"))]
Frontend::Never(_) => unreachable!(),
}
}
fn note(&mut self, s: &str) {
match self {
Frontend::Terminal | Frontend::Checkpointed { .. } => println!("{s}"),
#[cfg(feature = "tui")]
Frontend::Captured { on_line, .. } => on_line(LineKind::Notice, s),
#[cfg(not(feature = "tui"))]
Frontend::Never(_) => unreachable!(),
}
}
fn warning(&mut self, s: &str) {
match self {
Frontend::Terminal | Frontend::Checkpointed { .. } => eprintln!("{s}"),
#[cfg(feature = "tui")]
Frontend::Captured { on_line, .. } => on_line(LineKind::Warning, s),
#[cfg(not(feature = "tui"))]
Frontend::Never(_) => unreachable!(),
}
}
fn placement_begins(&mut self) -> Result<()> {
match self {
Frontend::Terminal => Ok(()),
Frontend::Checkpointed { checkpoint } => checkpoint(),
#[cfg(feature = "tui")]
Frontend::Captured {
control,
checkpoint,
..
} => {
control.begin_placement()?;
if let Some(checkpoint) = checkpoint {
checkpoint()?;
}
Ok(())
}
#[cfg(not(feature = "tui"))]
Frontend::Never(_) => unreachable!(),
}
}
fn before_placement(&mut self, prefix: &Path) -> Result<()> {
match self {
Frontend::Terminal | Frontend::Checkpointed { .. } => {
privileged::preauthorize(prefix, true, privileged::AuthPurpose::Placement)
}
#[cfg(feature = "tui")]
Frontend::Captured {
before_placement, ..
} => before_placement(prefix),
#[cfg(not(feature = "tui"))]
Frontend::Never(_) => unreachable!(),
}
}
}
fn authorize_placement(escalates: bool, prefix: &Path, frontend: &mut Frontend<'_>) -> Result<()> {
if escalates {
frontend.before_placement(prefix)?;
}
frontend.placement_begins()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ShadowReport {
OnCommit,
Deferred,
}
#[allow(clippy::too_many_arguments)]
fn install_and_commit(
prefix: &Path,
cache: &Path,
manifest: &mut Manifest,
name: &str,
version: Option<&Version>,
locked: bool,
pin: PinPolicy,
shadow: ShadowReport,
frontend: &mut Frontend<'_>,
) -> Result<Version> {
validate_name(name)?;
let policy = match frontend {
Frontend::Terminal | Frontend::Checkpointed { .. } => {
privileged::Policy::for_prefix(prefix)
}
#[cfg(feature = "tui")]
Frontend::Captured { .. } => privileged::Policy::for_prefix(prefix).screen_owned(),
#[cfg(not(feature = "tui"))]
Frontend::Never(_) => unreachable!(),
};
install_needs_privilege(policy, prefix)?;
let run_dir = cache
.join(stage::RUN_NAMESPACE)
.join(stage::new_run_dir_name().context("naming the build stage")?);
let lease = stage::Lease::acquire(&run_dir)?;
let stage_dir = run_dir.join(name);
let built = frontend.build(name, version, locked, &stage_dir, cache);
#[cfg(feature = "tui")]
let built = match built {
Err(e) => {
if e.downcast_ref::<BuildCancelled>().is_some() {
stage::release_and_remove_run(lease, &run_dir);
}
return Err(e);
}
Ok(built) => built,
};
#[cfg(not(feature = "tui"))]
let built = built?;
check_collisions(manifest, name, &built.bins, &prefix.join("bin"))?;
let new_bins: Vec<String> = built
.bins
.iter()
.filter(|b| {
!manifest
.crates
.get(name)
.is_some_and(|e| e.bins.contains(b))
})
.cloned()
.collect();
if shadow == ShadowReport::OnCommit {
for w in shadow_warnings(prefix, &new_bins) {
frontend.warning(&w);
}
}
let mut rollback = RollbackSet::snapshot(manifest, name, &built.bins);
let pinned = match pin {
PinPolicy::Infer => {
version.is_some() || manifest.crates.get(name).is_some_and(|e| e.pinned)
}
PinPolicy::Exactly(pinned) => pinned,
};
let checkpoints = (|| -> Result<()> {
let escalates = install_needs_privilege(policy, prefix)?;
authorize_placement(escalates, prefix, frontend)
})();
let checkpoints = checkpoints.context(
"the build finished, but placement did not begin: no files were placed and the \
manifest is unchanged",
);
#[cfg(feature = "tui")]
if let Err(e) = checkpoints {
if e.downcast_ref::<BuildCancelled>().is_some() {
stage::release_and_remove_run(lease, &run_dir);
}
return Err(e);
}
#[cfg(not(feature = "tui"))]
checkpoints?;
let installed = built.version.clone();
if let Err(err) = place_and_commit(
prefix,
policy,
manifest,
name,
built,
locked,
pinned,
&mut rollback,
frontend,
) {
rollback_new_bins(policy, &rollback.placed);
return Err(err);
}
stage::release_and_remove_run(lease, &run_dir);
Ok(installed)
}
#[allow(clippy::too_many_arguments)]
fn place_and_commit(
prefix: &Path,
policy: privileged::Policy,
manifest: &mut Manifest,
name: &str,
built: stage::Built,
locked: bool,
pinned: bool,
rollback: &mut RollbackSet,
frontend: &mut Frontend<'_>,
) -> Result<()> {
let bin_dir = prefix.join("bin");
let verified: Vec<privileged::VerifiedSource> = built
.bin_paths
.iter()
.map(|p| privileged::VerifiedSource::open(p))
.collect::<Result<_>>()?;
for (src, bin) in verified.iter().zip(&built.bins) {
let dest = bin_dir.join(bin);
privileged::install_verified(policy, src, &dest, "755")?;
rollback.note_placed(bin, dest);
}
drop(verified);
let installed: Vec<PathBuf> = built.bins.iter().map(|b| bin_dir.join(b)).collect();
let installed_refs: Vec<&Path> = installed.iter().map(PathBuf::as_path).collect();
privileged::restorecon(policy, &installed_refs);
if let Some(old) = manifest.crates.get(name) {
let obsolete = obsolete_bins(&old.bins, &built.bins);
if !obsolete.is_empty() {
let paths: Vec<PathBuf> = obsolete.iter().map(|b| bin_dir.join(b)).collect();
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
privileged::remove_files(policy, &refs)?;
frontend.note(&format!(
"removed obsolete binaries: {}",
obsolete.join(", ")
));
}
}
let bins_list = built.bins.join(", ");
commit_entry(
manifest,
prefix,
policy,
name,
Entry {
version: built.version.to_string(),
bins: built.bins,
locked,
pinned,
},
)?;
let pin_note = if pinned {
format!(" [pinned; `cargo lbin unpin {name}` to allow updates]")
} else {
String::new()
};
frontend.note(&format!(
"installed {name} {} -> {} ({bins_list}){pin_note}",
built.version,
bin_dir.display(),
));
Ok(())
}
fn duplicate_install_warnings<'a>(
prefix: &Path,
manifest: &Manifest,
names: impl Iterator<Item = &'a str>,
) -> Vec<String> {
duplicate_install_warnings_from(&prefixes::also_installed(prefix), prefix, manifest, names)
}
fn duplicate_install_warnings_from<'a>(
also: &std::collections::BTreeMap<String, Vec<prefixes::AlsoIn>>,
prefix: &Path,
manifest: &Manifest,
names: impl Iterator<Item = &'a str>,
) -> Vec<String> {
duplicate_install_warning_lines(
&cross_prefix_duplicates_from(also, prefix, manifest, names),
prefix,
)
}
struct CrossPrefixDuplicate {
name: String,
other_prefix: PathBuf,
other_version: String,
migrate_hint: Option<String>,
}
fn cross_prefix_duplicates_from<'a>(
also: &std::collections::BTreeMap<String, Vec<prefixes::AlsoIn>>,
prefix: &Path,
manifest: &Manifest,
names: impl Iterator<Item = &'a str>,
) -> Vec<CrossPrefixDuplicate> {
let mut duplicates = Vec::new();
for name in names {
if manifest.crates.contains_key(name) {
continue;
}
let Some(entries) = also.get(name) else {
continue;
};
for other in entries {
let migrate_hint = match (
pasteable_path_arg("--prefix", &other.prefix),
pasteable_path_arg("--to", prefix),
) {
(Some(source), Some(dest)) => {
Some(format!("cargo lbin migrate {name} {source} {dest}"))
}
_ => None,
};
duplicates.push(CrossPrefixDuplicate {
name: name.to_owned(),
other_prefix: other.prefix.clone(),
other_version: other.version.clone(),
migrate_hint,
});
}
}
duplicates
}
fn duplicate_install_warning_lines(
duplicates: &[CrossPrefixDuplicate],
prefix: &Path,
) -> Vec<String> {
let mut lines = Vec::new();
for dup in duplicates {
lines.push(text::sanitize(&format!(
"warning: `{}` is already managed under {} @{}",
dup.name,
dup.other_prefix.display(),
dup.other_version
)));
lines.push(text::sanitize(&format!(
"this will install another copy under {}",
prefix.display()
)));
match &dup.migrate_hint {
Some(hint) => {
lines.push(text::sanitize(&format!(
"use `{hint}` if you intended to move it"
)));
}
None => lines.push(text::sanitize(
"use `cargo lbin migrate` with explicit --prefix/--to \
if you intended to move it",
)),
}
}
lines
}
fn shadow_warnings(prefix: &Path, bins: &[String]) -> Vec<String> {
shadow_notes(prefix, bins)
.into_iter()
.map(|n| format!("warning: {n}"))
.collect()
}
fn shadow_findings(prefix: &Path, bins: &[String]) -> Vec<Finding> {
if bins.is_empty() {
return Vec::new();
}
let Some(path_var) = std::env::var_os("PATH") else {
return Vec::new();
};
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
let prefix_bin = prefix.join("bin");
shadow::find_shadows(&path_var, &prefix_bin, bins, &cwd, shadow::is_executable)
.iter()
.map(|s| {
let owner = shadow::owner_of(&s.existing);
Finding {
bin: Some(s.bin.clone()),
path: Some(s.existing.clone()),
..Finding::plain(
"path-shadow",
shadow::describe(s, &prefix_bin, owner.as_deref()),
)
}
})
.collect()
}
fn shadow_notes(prefix: &Path, bins: &[String]) -> Vec<String> {
if bins.is_empty() {
return Vec::new();
}
let Some(path_var) = std::env::var_os("PATH") else {
return Vec::new();
};
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
let prefix_bin = prefix.join("bin");
shadow::find_shadows(&path_var, &prefix_bin, bins, &cwd, shadow::is_executable)
.iter()
.map(|s| {
let owner = shadow::owner_of(&s.existing);
shadow::describe(s, &prefix_bin, owner.as_deref())
})
.collect()
}
fn install_needs_privilege(policy: privileged::Policy, prefix: &Path) -> Result<bool> {
Ok(policy.probe_destination(&prefix.join("bin"))?
|| policy.probe_destination(&prefix.join("share/cargo-lbin"))?)
}
#[derive(Debug, serde::Serialize)]
pub(crate) struct Finding {
pub(crate) kind: &'static str,
pub(crate) message: String,
#[serde(rename = "crate")]
pub(crate) krate: Option<String>,
pub(crate) bin: Option<String>,
pub(crate) path: Option<PathBuf>,
pub(crate) hint: Option<String>,
}
impl Finding {
fn plain(kind: &'static str, message: String) -> Self {
Self {
kind,
message,
krate: None,
bin: None,
path: None,
hint: None,
}
}
fn for_crate(kind: &'static str, krate: &str, message: String) -> Self {
Self {
krate: Some(krate.to_owned()),
..Self::plain(kind, message)
}
}
fn for_bin(kind: &'static str, krate: &str, bin: &str, message: String) -> Self {
Self {
bin: Some(bin.to_owned()),
..Self::for_crate(kind, krate, message)
}
}
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
pub(crate) struct VerifyReport {
pub(crate) crates: Option<usize>,
pub(crate) errors: Vec<Finding>,
pub(crate) warnings: Vec<Finding>,
}
pub(crate) fn verify_prefix(
prefix: &Path,
lock_notice: &mut dyn FnMut(&str),
) -> Result<VerifyReport> {
let (crates, errors, names, all_bins) = {
let _lock = StateLock::acquire_shared_existing(prefix, lock_notice)?;
let manifest = match Manifest::load_unvalidated(prefix) {
Ok(manifest) => manifest,
Err(e) => {
let kind = if e.downcast_ref::<std::io::Error>().is_some() {
"manifest-unreadable"
} else {
"manifest-unparseable"
};
let message = if kind == "manifest-unreadable" {
format!(
"the manifest cannot be inspected: {e:#} — no further \
manifest-dependent checks can be performed"
)
} else {
format!(
"the manifest cannot be parsed: {e:#} — repair {} by \
hand, or restore it from a backup",
Manifest::path(prefix).display()
)
};
let finding = Finding {
path: Some(Manifest::path(prefix)),
..Finding::plain(kind, message)
};
return Ok(VerifyReport {
crates: None,
errors: vec![sanitize_finding(finding)],
warnings: Vec::new(),
});
}
};
let (errors, checkable_bins) = verify_entries(prefix, &manifest);
let names: Vec<String> = manifest.crates.keys().cloned().collect();
(Some(manifest.crates.len()), errors, names, checkable_bins)
};
let mut warnings = Vec::new();
let also = prefixes::also_installed(prefix);
for name in &names {
for a in also.get(name).map_or(&[][..], Vec::as_slice) {
warnings.push(Finding {
path: Some(a.prefix.clone()),
..Finding::for_crate(
"also-installed",
name,
format!(
"`{name}` is also installed under {} @{} — legal; `cargo lbin remove` \
the unwanted side if both were not meant",
a.prefix.display(),
a.version
),
)
});
}
}
warnings.extend(shadow_findings(prefix, &all_bins));
if let Ok(cache) = cache_dir()
&& let Ok(stale) = scan_stale_stages(&cache)
&& !stale.is_empty()
{
warnings.push(Finding {
path: Some(cache.clone()),
..Finding::plain(
"stale-stages",
format!(
"{} stage director{} under {} with no live owner — possible \
leftover build debris; inspect, then `cargo lbin clean --stages` \
when safe (for pre-lease stages the owner test is a PID \
heuristic: a PID can be reused, and an orphaned build may \
still hold the directory)",
stale.len(),
if stale.len() == 1 { "y" } else { "ies" },
cache.display()
),
)
});
}
Ok(VerifyReport {
crates,
errors: errors.into_iter().map(sanitize_finding).collect(),
warnings: warnings.into_iter().map(sanitize_finding).collect(),
})
}
fn sanitize_finding(f: Finding) -> Finding {
Finding {
message: text::sanitize(&f.message),
..f
}
}
fn shell_quote(s: &str) -> String {
let boring = !s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '/' | '.' | '_' | '-' | '+' | ':' | ',' | '=' | '@' | '%')
});
if boring {
return s.to_owned();
}
format!("'{}'", s.replace('\'', "'\\''"))
}
fn pasteable_path_arg(flag: &str, path: &Path) -> Option<String> {
let s = path.to_str()?;
if s.chars().any(char::is_control) {
return None;
}
Some(format!("{flag}={}", shell_quote(s)))
}
fn pasteable_prefix(prefix: &Path) -> Option<String> {
pasteable_path_arg("--prefix", prefix)
}
fn reinstall_hint(prefix: &Path, name: &str, entry: &Entry) -> Option<String> {
let prefix_arg = pasteable_prefix(prefix)?;
let mut hint = format!("cargo lbin install {name}");
if entry.pinned {
hint.push('@');
hint.push_str(&entry.version);
}
if entry.locked {
hint.push_str(" --locked");
}
hint.push(' ');
hint.push_str(&prefix_arg);
Some(hint)
}
fn disk_finding(
prefix: &Path,
bin_dir: &Path,
loadable: bool,
name: &str,
entry: &Entry,
bin: &str,
) -> Option<Finding> {
use std::os::unix::fs::PermissionsExt;
let hint = if loadable {
reinstall_hint(prefix, name, entry)
} else {
None
};
let remedy = if loadable {
match hint.clone() {
Some(hint) => format!(" — reinstall: {hint}"),
None => " — reinstall it (this prefix's name cannot be \
spelled as a safe shell command, so none is \
offered)"
.to_owned(),
}
} else {
" — reinstall once the manifest findings above are repaired".to_owned()
};
let path = bin_dir.join(bin);
match fs::symlink_metadata(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(Finding {
path: Some(path.clone()),
hint: hint.clone(),
..Finding::for_bin(
"binary-missing",
name,
bin,
format!(
"`{name}`: managed binary {} is missing{remedy}",
path.display()
),
)
}),
Err(e) => Some(Finding {
path: Some(path.clone()),
..Finding::for_bin(
"binary-uninspectable",
name,
bin,
format!(
"`{name}`: managed binary {} cannot be inspected: {e} — the \
manifest's claim could not be checked",
path.display()
),
)
}),
Ok(md) if md.file_type().is_symlink() => Some(Finding {
path: Some(path.clone()),
hint: hint.clone(),
..Finding::for_bin(
"binary-is-a-symlink",
name,
bin,
format!(
"`{name}`: managed binary {} is a symlink, not the regular \
file lbin placed — remove it, then{}",
path.display(),
remedy.trim_start_matches(" —")
),
)
}),
Ok(md) if !md.is_file() => Some(Finding {
path: Some(path.clone()),
hint: hint.clone(),
..Finding::for_bin(
"binary-not-a-regular-file",
name,
bin,
format!(
"`{name}`: managed binary {} is not a regular file — remove \
whatever took its place, then{}",
path.display(),
remedy.trim_start_matches(" —")
),
)
}),
Ok(md) if md.permissions().mode() & 0o111 == 0 => Some(Finding {
path: Some(path.clone()),
hint: hint.clone(),
..Finding::for_bin(
"binary-not-executable",
name,
bin,
format!(
"`{name}`: managed binary {} is not executable{remedy}",
path.display()
),
)
}),
Ok(_) => None,
}
}
fn verify_entries(prefix: &Path, manifest: &Manifest) -> (Vec<Finding>, Vec<String>) {
let mut errors = Vec::new();
let mut checkable: Vec<String> = Vec::new();
let mut claims: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
let bin_dir = prefix.join("bin");
let by_hand = format!(
"lbin's own commands refuse a manifest in this state; repair {} by hand, \
or restore it from a backup",
Manifest::path(prefix).display()
);
for (name, entry) in &manifest.crates {
if validate_name(name).is_err() {
errors.push(Finding::for_crate(
"invalid-crate-name",
name,
format!("`{name}` is not a valid crate name — {by_hand}"),
));
}
if Version::parse(&entry.version).is_err() {
errors.push(Finding::for_crate(
"unparseable-version",
name,
format!(
"`{name}`: manifest version `{}` is unparseable — {by_hand}",
entry.version
),
));
}
if entry.bins.is_empty() {
errors.push(Finding::for_crate(
"no-binaries",
name,
format!("`{name}`: declares no binaries — a state lbin never writes; {by_hand}"),
));
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for bin in &entry.bins {
if validate::validate_bin_name(bin).is_err() {
errors.push(Finding::for_bin(
"invalid-bin-name",
name,
bin,
format!("`{name}`: bin entry `{bin}` is not one plain filename — {by_hand}"),
));
continue;
}
if !seen.insert(bin.as_str()) {
errors.push(Finding::for_bin(
"duplicate-bin-in-entry",
name,
bin,
format!("`{name}`: binary `{bin}` is listed twice — {by_hand}"),
));
continue;
}
claims.entry(bin).or_default().push(name);
checkable.push(bin.clone());
}
}
for (bin, names) in &claims {
if names.len() > 1 {
let owners = names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(" and ");
errors.push(Finding {
bin: Some((*bin).clone()),
..Finding::plain(
"duplicate-bin-claim",
format!(
"binary `{bin}` is claimed by {owners} — a state lbin never \
writes; {by_hand}"
),
)
});
}
}
let loadable = errors.is_empty();
for (name, entry) in &manifest.crates {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for bin in &entry.bins {
if validate::validate_bin_name(bin).is_err() || !seen.insert(bin.as_str()) {
continue;
}
if let Some(finding) = disk_finding(prefix, &bin_dir, loadable, name, entry, bin) {
errors.push(finding);
}
}
}
(errors, checkable)
}
fn scan_stale_stages(cache: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut stale = Vec::new();
for (namespace, leased) in [("stage", false), (stage::RUN_NAMESPACE, true)] {
scan_stage_dir(&cache.join(namespace), leased, &mut stale)?;
}
stale.sort();
Ok(stale)
}
fn scan_stage_dir(dir: &Path, leased: bool, stale: &mut Vec<PathBuf>) -> std::io::Result<()> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
for entry in entries {
let entry = entry?;
let run = entry.file_name().to_str().and_then(stage::parse_run_dir);
let stale_entry = match (leased, run) {
(false, Some(stage::StageRun::LegacyPid(pid))) => {
!Path::new(&format!("/proc/{pid}")).exists()
}
(true, Some(stage::StageRun::LeasedRun { .. })) => {
stage::probe_lease(&entry.path()) == stage::LeaseState::Released
}
_ => true,
};
if stale_entry {
stale.push(entry.path());
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoveOutcome {
Removed,
Deferred,
AlreadyGone,
Failed,
}
fn remove_stale_stage(dir: &Path, dry_run: bool) -> RemoveOutcome {
let verb = if dry_run { "would remove" } else { "removing" };
let leased = dir
.parent()
.and_then(Path::file_name)
.is_some_and(|n| n == stage::RUN_NAMESPACE)
&& dir
.file_name()
.and_then(|n| n.to_str())
.and_then(stage::parse_run_dir)
.is_some_and(|r| matches!(r, stage::StageRun::LeasedRun { .. }));
if dry_run {
println!("{verb} ownerless stage {}", dir.display());
return RemoveOutcome::Removed;
}
let held = if leased {
match stage::take_lease_for_removal(dir) {
Ok(Some(held)) => Some(held),
Ok(None) => {
println!("deferring {}: its lease is held right now", dir.display());
return RemoveOutcome::Deferred;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!("skipping {}: already removed", dir.display());
return RemoveOutcome::AlreadyGone;
}
Err(e) => {
eprintln!("error: taking the lease of {}: {e}", dir.display());
return RemoveOutcome::Failed;
}
}
} else {
None
};
println!("{verb} ownerless stage {}", dir.display());
let removal = if held.is_some() {
stage::remove_leased_run(dir)
} else {
fs::remove_dir_all(dir)
};
if let Err(e) = removal {
eprintln!("error: removing {}: {e}", dir.display());
return RemoveOutcome::Failed;
}
drop(held);
RemoveOutcome::Removed
}
fn cmd_clean(dry_run: bool, stages: bool, logs_older_than_days: Option<u64>) -> Result<()> {
clean_cache(&cache_dir()?, dry_run, stages, logs_older_than_days)
}
fn clean_cache(
cache: &Path,
dry_run: bool,
stages: bool,
logs_older_than_days: Option<u64>,
) -> Result<()> {
if !stages && logs_older_than_days.is_none() {
bail!("nothing requested: name --stages and/or --logs-older-than DAYS");
}
let stale = if stages {
scan_stale_stages(cache)
.with_context(|| format!("reading the stage namespaces under {}", cache.display()))?
} else {
Vec::new()
};
let cutoff = match logs_older_than_days {
Some(days) => {
let seconds = days
.checked_mul(86_400)
.context("--logs-older-than is too large")?;
Some(
std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(seconds))
.context("--logs-older-than is too large")?,
)
}
None => None,
};
let mut old_logs = Vec::new();
if let Some(cutoff) = cutoff {
let logs_dir = cache.join("logs");
let entries = match fs::read_dir(&logs_dir) {
Ok(entries) => Some(entries),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(e).with_context(|| format!("reading {}", logs_dir.display()));
}
};
if let Some(entries) = entries {
for entry in entries {
let entry = entry.with_context(|| format!("reading {}", logs_dir.display()))?;
let path = entry.path();
if path.extension().is_none_or(|e| e != "log") {
continue;
}
let modified = entry
.metadata()
.and_then(|m| m.modified())
.with_context(|| format!("inspecting {}", path.display()))?;
if modified < cutoff {
old_logs.push(path);
}
}
}
}
old_logs.sort();
if stale.is_empty() && old_logs.is_empty() {
println!("nothing to clean");
return Ok(());
}
let verb = if dry_run { "would remove" } else { "removing" };
let mut failures = 0usize;
let (mut removed, mut deferred) = (0usize, 0usize);
for dir in &stale {
match remove_stale_stage(dir, dry_run) {
RemoveOutcome::Removed => removed += 1,
RemoveOutcome::Deferred => deferred += 1,
RemoveOutcome::AlreadyGone => {}
RemoveOutcome::Failed => failures += 1,
}
}
let days = logs_older_than_days.unwrap_or_default();
for log in &old_logs {
println!("{verb} log older than {days} day(s): {}", log.display());
if !dry_run && let Err(e) = fs::remove_file(log) {
eprintln!("error: removing {}: {e}", log.display());
failures += 1;
}
}
if failures > 0 {
bail!("{failures} removal(s) failed");
}
let done = if dry_run { "would remove" } else { "removed" };
let mut parts = Vec::new();
if stages {
parts.push(format!("{removed} ownerless stage(s)"));
if deferred > 0 {
parts.push(format!("{deferred} deferred (lease held)"));
}
}
if logs_older_than_days.is_some() {
parts.push(format!("{} old log(s)", old_logs.len()));
}
println!("{done} {}", parts.join(", "));
Ok(())
}
fn cmd_verify(prefix: &Path, json: bool) -> Result<()> {
let report = verify_prefix(prefix, &mut |m: &str| eprintln!("{m}"))?;
if json {
crate::json::print_verify(prefix, &report)?;
} else {
for e in &report.errors {
eprintln!("error: {e}");
}
for w in &report.warnings {
eprintln!("warning: {w}");
}
}
if report.errors.is_empty() {
let crates = report.crates.unwrap_or(0);
if !json {
if report.warnings.is_empty() {
println!("ok: {crates} managed crate(s)");
} else {
println!(
"ok: {crates} managed crate(s), {} warning(s)",
report.warnings.len()
);
}
}
return Ok(());
}
match report.crates {
Some(crates) => bail!(
"{crates} managed crate(s): {} verification error(s), {} warning(s)",
report.errors.len(),
report.warnings.len()
),
None => bail!(
"managed crate count unavailable: {} verification error(s), {} warning(s)",
report.errors.len(),
report.warnings.len()
),
}
}
#[cfg(feature = "tui")]
fn state_needs_privilege(policy: privileged::Policy, prefix: &Path) -> Result<bool> {
Ok(policy.probe_destination(&prefix.join("share/cargo-lbin"))?
|| (matches!(policy.sudo, privileged::Sudo::Allowed)
&& StateLock::preparation_needs_privilege(prefix)))
}
fn late_escalation_certain(source: &Result<bool>, dest: &Result<bool>) -> bool {
matches!((source, dest), (Ok(true), Ok(false)))
}
fn late_escalation_note(name: &str, source: &Path) -> String {
text::sanitize(&format!(
"retiring `{name}` from {} needs sudo after the build; \
a password may be requested then",
source.display()
))
}
fn bin_dir_prefix_note(prefix: &Path) -> Option<String> {
if prefix.file_name()? != "bin" {
return None;
}
let parent = prefix.parent()?;
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
Some(text::sanitize(&format!(
"prefix {} ends in `bin`, so binaries go to {} — a prefix is the parent of `bin`; \
did you mean {}?",
prefix.display(),
prefix.join("bin").display(),
parent.display()
)))
}
fn placement_needs_privilege(policy: privileged::Policy, prefix: &Path) -> Result<bool> {
Ok(install_needs_privilege(policy, prefix)?
|| (matches!(policy.sudo, privileged::Sudo::Allowed)
&& StateLock::preparation_needs_privilege(prefix)))
}
fn commit_entry(
manifest: &mut Manifest,
prefix: &Path,
policy: privileged::Policy,
name: &str,
entry: Entry,
) -> Result<()> {
let previous = manifest.crates.insert(name.to_owned(), entry);
if let Err(err) = manifest.store_with_policy(prefix, policy) {
if let Some(old) = previous {
manifest.crates.insert(name.to_owned(), old);
} else {
manifest.crates.remove(name);
}
return Err(err);
}
Ok(())
}
#[cfg(feature = "tui")]
pub(crate) enum TuiSetPinned {
Set {
version: String,
},
Already,
PrefixBusy,
}
#[cfg(feature = "tui")]
pub(crate) fn tui_set_pinned(prefix: &Path, name: &str, pinned: bool) -> Result<TuiSetPinned> {
let policy = privileged::Policy::for_prefix(prefix).screen_owned();
let Some(_lock) = StateLock::try_acquire_with(prefix, &Mode::Exclusive, policy, &mut |_| {})?
else {
return Ok(TuiSetPinned::PrefixBusy);
};
let mut manifest = Manifest::load(prefix)?;
let Some(entry) = manifest.crates.get_mut(name) else {
bail!("`{name}` is not in the manifest (changed since the list was read?)");
};
if entry.pinned == pinned {
return Ok(TuiSetPinned::Already);
}
entry.pinned = pinned;
let version = entry.version.clone();
manifest.store_with_policy(prefix, policy)?;
Ok(TuiSetPinned::Set { version })
}
#[cfg(feature = "tui")]
pub(crate) enum TuiRemove {
Removed(Vec<String>),
PrefixBusy,
}
#[cfg(feature = "tui")]
pub(crate) fn tui_remove_one(prefix: &Path, name: &str) -> Result<TuiRemove> {
let policy = privileged::Policy::for_prefix(prefix).screen_owned();
let Some(_lock) = StateLock::try_acquire_with(prefix, &Mode::Exclusive, policy, &mut |_| {})?
else {
return Ok(TuiRemove::PrefixBusy);
};
let mut manifest = Manifest::load(prefix)?;
let Some(entry) = manifest.crates.remove(name) else {
bail!("`{name}` is not in the manifest (changed since the list was read?)");
};
let bin_dir = prefix.join("bin");
let paths: Vec<PathBuf> = entry.bins.iter().map(|b| bin_dir.join(b)).collect();
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
privileged::remove_files(policy, &refs)?;
manifest.store_with_policy(prefix, policy)?;
Ok(TuiRemove::Removed(entry.bins))
}
#[cfg(feature = "tui")]
pub(crate) fn tui_migrate_one(
source: &Path,
dest: &Path,
name: &str,
snap: &MigrationSnapshot,
on_line: &mut dyn FnMut(LineKind, &str),
before_placement: &mut dyn FnMut(&Path) -> Result<()>,
control: &BuildControl,
) -> Result<MigrateOutcome> {
let cache = cache_dir()?;
migrate_one(
source,
dest,
&cache,
name,
snap,
&mut MigrateFrontend::Captured {
on_line,
before_placement,
control,
},
)
}
#[cfg(feature = "tui")]
pub(crate) fn tui_downgrade_one(
prefix: &Path,
name: &str,
expected: &str,
version: &Version,
on_line: &mut dyn FnMut(LineKind, &str),
before_placement: &mut dyn FnMut(&Path) -> Result<()>,
control: &BuildControl,
) -> Result<()> {
let cache = cache_dir()?;
let _lock = StateLock::acquire_with(
prefix,
&Mode::Exclusive,
privileged::Policy::for_prefix(prefix).screen_owned(),
&mut |s| on_line(LineKind::Notice, s),
)?;
let mut manifest = Manifest::load(prefix)?;
let fresh = manifest.crates.get(name).with_context(|| {
format!("`{name}` was removed while a version was being chosen; press D again")
})?;
if fresh.version != expected {
bail!(
"`{name}` changed from {expected} to {} while a version was being chosen; \
press D again",
fresh.version
);
}
let locked = fresh.locked;
let mut frontend = Frontend::Captured {
on_line,
before_placement,
control,
checkpoint: None,
};
install_and_commit(
prefix,
&cache,
&mut manifest,
name,
Some(version),
locked,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut frontend,
)?;
Ok(())
}
#[cfg(feature = "tui")]
pub(crate) fn tui_install_one(
prefix: &Path,
spec: &InstallSpec,
locked: bool,
on_line: &mut dyn FnMut(LineKind, &str),
before_placement: &mut dyn FnMut(&Path) -> Result<()>,
control: &BuildControl,
) -> Result<()> {
let cache = cache_dir()?;
let _lock = StateLock::acquire_with(
prefix,
&Mode::Exclusive,
privileged::Policy::for_prefix(prefix).screen_owned(),
&mut |s| on_line(LineKind::Notice, s),
)?;
let mut manifest = Manifest::load(prefix)?;
if spec.version.is_none() {
refuse_pinned(&manifest, std::slice::from_ref(&spec.name))?;
}
let mut frontend = Frontend::Captured {
on_line,
before_placement,
control,
checkpoint: None,
};
for w in duplicate_install_warnings(prefix, &manifest, std::iter::once(spec.name.as_str())) {
frontend.warning(&w);
}
install_and_commit(
prefix,
&cache,
&mut manifest,
&spec.name,
spec.version.as_ref(),
locked,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut frontend,
)?;
Ok(())
}
fn cmd_install(prefix: &Path, crates: &[String], locked: bool) -> Result<()> {
let specs = InstallSpec::parse_all(crates)?;
let cache = cache_dir()?;
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let unversioned: Vec<String> = specs
.iter()
.filter(|s| s.version.is_none())
.map(|s| s.name.clone())
.collect();
refuse_pinned(&manifest, &unversioned)?;
let mut frontend = Frontend::Terminal;
for w in duplicate_install_warnings(prefix, &manifest, specs.iter().map(|s| s.name.as_str())) {
frontend.warning(&w);
}
for spec in &specs {
install_and_commit(
prefix,
&cache,
&mut manifest,
&spec.name,
spec.version.as_ref(),
locked,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut frontend,
)?;
}
Ok(())
}
fn refuse_pinned(manifest: &Manifest, crates: &[String]) -> Result<()> {
let pinned: Vec<&str> = crates
.iter()
.filter(|n| manifest.crates.get(n.as_str()).is_some_and(|e| e.pinned))
.map(String::as_str)
.collect();
if !pinned.is_empty() {
let names = pinned.join(" ");
bail!(
"pinned: {} (run `cargo lbin unpin {names}` first)",
pinned.join(", ")
);
}
Ok(())
}
fn cmd_set_pinned(prefix: &Path, crates: &[String], pinned: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let targets = select_targets(&manifest, crates)?;
let verb = if pinned { "pinned" } else { "unpinned" };
let mut changed: Vec<String> = Vec::new();
for name in &targets {
if let Some(entry) = manifest.crates.get_mut(name) {
if entry.pinned == pinned {
println!("{name} is already {verb}");
} else {
entry.pinned = pinned;
changed.push(format!("{verb} {name} at {}", entry.version));
}
}
}
if !changed.is_empty() {
manifest.store(prefix)?;
for line in changed {
println!("{line}");
}
}
Ok(())
}
fn cmd_pinned(prefix: &Path, check: bool, json: bool) -> ExitCode {
let outcome = (|| {
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
let report = if check {
Some(Report::new(
prefix,
check_versions(manifest.crates.iter().filter(|(_, e)| e.pinned), || false)?
.expect("a `|| false` token never cancels"),
)?)
} else {
match cache_dir().and_then(|cache| Report::load(&cache, prefix)) {
Ok(report) => report,
Err(e) => {
eprintln!("warning: {e:#}");
None
}
}
};
let identity = report::identity(prefix)?;
Ok::<_, anyhow::Error>((manifest, report, identity))
})();
let (manifest, report, identity) = match outcome {
Ok(parts) => parts,
Err(e) => {
eprintln!("error: {e:#}");
return ExitCode::from(EXIT_ERROR);
}
};
let any_outdated = manifest
.crates
.iter()
.filter(|(_, entry)| entry.pinned)
.any(|(name, entry)| {
Version::parse(&entry.version)
.ok()
.and_then(|current| report.as_ref()?.status_for(name, ¤t))
.is_some_and(|status| matches!(status, Status::Outdated(_)))
});
if json {
let also = prefixes::also_installed(prefix);
let output = json::PinnedOutput::build(identity, &manifest, report.as_ref(), &also);
if let Err(e) = json::print(&output) {
eprintln!("error: {e:#}");
return ExitCode::from(EXIT_ERROR);
}
} else {
let mut any_pinned = false;
for (name, entry) in manifest.crates.iter().filter(|(_, e)| e.pinned) {
any_pinned = true;
let locked = if entry.locked { " [locked]" } else { "" };
let status = Version::parse(&entry.version)
.ok()
.and_then(|current| report.as_ref()?.status_for(name, ¤t))
.map(|status| match status {
Status::Outdated(latest) => format!(" -> {latest}"),
Status::UpToDate => " (up to date)".to_owned(),
})
.unwrap_or_default();
println!("{name} {}{locked}{status}", entry.version);
}
if !any_pinned {
println!("no pinned crates under {}", prefix.display());
}
if !check {
if let Some(r) = &report {
eprintln!("update check: {}", report::describe_age(r.age()));
} else {
eprintln!(
"no update check recorded; run `cargo lbin checkupdate` or use `--check`"
);
}
}
}
if any_outdated {
ExitCode::from(EXIT_UPDATES)
} else {
ExitCode::from(EXIT_NO_UPDATES)
}
}
fn cmd_remove(prefix: &Path, crates: &[String]) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let policy = privileged::Policy::for_prefix(prefix);
let mut manifest = Manifest::load(prefix)?;
let bin_dir = prefix.join("bin");
let mut removed_any = false;
for name in crates {
let Some(entry) = manifest.crates.remove(name) else {
eprintln!("warning: `{name}` is not in the manifest, skipping");
continue;
};
let paths: Vec<PathBuf> = entry.bins.iter().map(|b| bin_dir.join(b)).collect();
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
privileged::remove_files(policy, &refs)?;
manifest.store(prefix)?;
println!("removed {name} ({})", entry.bins.join(", "));
removed_any = true;
}
if !removed_any {
bail!("nothing to remove");
}
Ok(())
}
fn cmd_list(prefix: &Path, json: bool) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
let manifest = Manifest::load(prefix)?;
let also = prefixes::also_installed(prefix);
let report = match cache_dir().and_then(|cache| Report::load(&cache, prefix)) {
Ok(report) => report,
Err(e) => {
eprintln!("warning: {e:#}");
None
}
};
if json {
let output =
json::ListOutput::build(report::identity(prefix)?, &manifest, report.as_ref(), &also);
return json::print(&output);
}
if manifest.crates.is_empty() {
println!("no crates installed under {}", prefix.display());
return Ok(());
}
for (name, entry) in &manifest.crates {
let locked = if entry.locked { " [locked]" } else { "" };
let pinned = if entry.pinned { " [pinned]" } else { "" };
let status = Version::parse(&entry.version)
.ok()
.and_then(|current| report.as_ref()?.status_for(name, ¤t))
.map(|status| match status {
Status::Outdated(latest) => format!(" -> {latest}"),
Status::UpToDate => " (up to date)".to_owned(),
})
.unwrap_or_default();
let also = prefixes::describe_for(&also, name);
println!(
"{name} {}{locked}{pinned}{also} ({}){status}",
entry.version,
entry.bins.join(", ")
);
}
if let Some(r) = report {
eprintln!("update check: {}", report::describe_age(r.age()));
} else {
eprintln!("no update check recorded; run `cargo lbin checkupdate`");
}
Ok(())
}
fn select_targets(manifest: &Manifest, crates: &[String]) -> Result<BTreeSet<String>> {
let unknown: Vec<&str> = crates
.iter()
.filter(|n| !manifest.crates.contains_key(n.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
bail!("not installed: {}", unknown.join(", "));
}
Ok(crates.iter().cloned().collect())
}
fn check_versions<'a>(
entries: impl IntoIterator<Item = (&'a String, &'a Entry)>,
should_cancel: impl Fn() -> bool,
) -> Result<Option<Vec<Checked>>> {
let mut checked = Vec::new();
for (name, entry) in entries {
if should_cancel() {
return Ok(None);
}
let current = Version::parse(&entry.version)
.with_context(|| format!("manifest holds unparsable version for `{name}`"))?;
let versions = index::published_versions(name)?;
let latest = index::latest_relevant(&versions, ¤t)
.filter(|latest| *latest > current)
.unwrap_or_else(|| current.clone());
checked.push(Checked {
name: name.clone(),
current,
latest,
});
}
Ok(Some(checked))
}
fn describe_versions(releases: &[index::Release]) -> String {
let mut out = String::from(" versions:\n");
let mut sorted: Vec<&index::Release> = releases.iter().collect();
sorted.sort_by(|a, b| b.version.cmp(&a.version));
for release in sorted {
use std::fmt::Write as _;
let _ = writeln!(out, " {}", release_label(release));
}
out
}
fn release_label(release: &index::Release) -> String {
if release.yanked {
format!("{} [yanked]", release.version)
} else {
release.version.to_string()
}
}
fn describe_info(
name: &str,
releases: &[index::Release],
installed: Option<&Entry>,
also: &[prefixes::AlsoIn],
) -> String {
use std::fmt::Write as _;
let summary = index::summarize(releases);
let mut out = format!("{name}\n");
if let Some(stable) = &summary.latest_stable {
let _ = writeln!(out, " latest: {}", release_label(stable));
} else {
out.push_str(" latest: (no stable release)\n");
}
if let Some(pre) = &summary.latest_pre {
let _ = writeln!(out, " pre-release: {}", release_label(pre));
}
let _ = write!(out, " releases: {}", summary.total);
if summary.yanked > 0 {
let _ = write!(out, " ({} yanked)", summary.yanked);
}
out.push('\n');
let mut also_lines = String::new();
for other in also {
let prefix = text::sanitize(&other.prefix.display().to_string());
let _ = writeln!(also_lines, " also in: {prefix} @{}", other.version);
}
let Some(entry) = installed else {
out.push_str(" installed: no\n");
out.push_str(&also_lines);
return out;
};
let _ = write!(out, " installed: {}", entry.version);
let live: Vec<Version> = releases
.iter()
.filter(|r| !r.yanked)
.map(|r| r.version.clone())
.collect();
if live.is_empty() {
out.push_str(" (no non-yanked releases)\n");
} else {
let newer = Version::parse(&entry.version).ok().and_then(|current| {
index::latest_relevant(&live, ¤t).filter(|latest| *latest > current)
});
if let Some(latest) = newer {
let _ = writeln!(out, " (update available: {latest})");
} else {
out.push_str(" (up to date)\n");
}
}
let flag = |b: bool| if b { "yes" } else { "no" };
let _ = writeln!(out, " pinned: {}", flag(entry.pinned));
let _ = writeln!(out, " locked: {}", flag(entry.locked));
let _ = writeln!(out, " binaries: {}", entry.bins.join(", "));
out.push_str(&also_lines);
out
}
fn cmd_info(prefix: &Path, crates: &[String], versions: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
let also = prefixes::also_installed(prefix);
let mut names: Vec<&str> = Vec::new();
for name in crates {
if !names.contains(&name.as_str()) {
names.push(name);
}
}
let mut failures: Vec<anyhow::Error> = Vec::new();
let mut shown = 0usize;
for name in &names {
match index::releases(name) {
Ok(Some(releases)) => {
if shown > 0 {
println!();
}
print!(
"{}",
describe_info(
name,
&releases,
manifest.crates.get(*name),
also.get(*name).map_or(&[][..], Vec::as_slice)
)
);
if versions {
print!("{}", describe_versions(&releases));
}
shown += 1;
}
Ok(None) => failures.push(anyhow::anyhow!(
"{}; try `cargo lbin search {name}`",
index::not_found(name)
)),
Err(e) => failures.push(e),
}
}
if failures.is_empty() {
return Ok(());
}
if names.len() == 1 {
return Err(failures.remove(0));
}
for e in &failures {
eprintln!("error: {e:#}");
}
bail!("{} of {} lookups failed", failures.len(), names.len())
}
const SEARCH_DESCRIPTION_WIDTH: usize = 72;
fn format_search_hits(hits: &[api::Hit], installed: &BTreeMap<String, String>) -> String {
use std::fmt::Write as _;
let name_w = hits.iter().map(|h| h.name.len()).max().unwrap_or(0);
let version_w = hits.iter().map(|h| h.version.len()).max().unwrap_or(0);
let mut out = String::new();
for hit in hits {
let mark = if installed.contains_key(&hit.name) {
'*'
} else {
' '
};
let mut description: String = hit
.description
.chars()
.take(SEARCH_DESCRIPTION_WIDTH)
.collect();
if hit.description.chars().count() > SEARCH_DESCRIPTION_WIDTH {
description.push('…');
}
let _ = write!(
out,
"{mark} {:<name_w$} {:<version_w$} {description}",
hit.name, hit.version
);
if let Some(have) = installed.get(&hit.name) {
let _ = write!(out, " [installed {have}]");
}
out.push('\n');
}
out
}
fn cmd_search(prefix: &Path, query: &[String], limit: u8) -> Result<()> {
let query = query.join(" ");
let hits = api::search(&query, usize::from(limit))?;
if hits.is_empty() {
println!("no crates match `{query}`");
return Ok(());
}
let installed: BTreeMap<String, String> = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
.crates
.into_iter()
.map(|(name, entry)| (name, entry.version))
.collect()
};
print!("{}", format_search_hits(&hits, &installed));
if hits.iter().any(|h| installed.contains_key(&h.name)) {
println!("* installed under {}", prefix.display());
}
Ok(())
}
fn cmd_man(dir: &Path) -> Result<()> {
use clap::CommandFactory;
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let mut cmd = Cli::command();
cmd.build();
let write = |name: &str, cmd: clap::Command| -> Result<()> {
let mut buf = Vec::new();
clap_mangen::Man::new(cmd)
.title(name.to_uppercase())
.render(&mut buf)
.with_context(|| format!("rendering man page for {name}"))?;
let path = dir.join(format!("{name}.1"));
fs::write(&path, buf).with_context(|| format!("writing {}", path.display()))?;
println!("wrote {}", path.display());
Ok(())
};
write("cargo-lbin", cmd.clone().bin_name("cargo-lbin"))?;
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
write(
&format!("cargo-lbin-{}", sub.get_name()),
sub.clone()
.bin_name(format!("cargo-lbin {}", sub.get_name())),
)?;
}
Ok(())
}
fn cmd_completions(shell: clap_complete::Shell) {
use clap::CommandFactory;
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "cargo-lbin", &mut std::io::stdout());
}
const DOWNGRADE_CHOICES: usize = 10;
fn parse_choice(answer: &str, count: usize) -> Result<Option<usize>> {
let answer = answer.trim();
if answer.is_empty() || answer.eq_ignore_ascii_case("q") {
return Ok(None);
}
let n: usize = answer
.parse()
.with_context(|| format!("`{answer}` is not a number between 1 and {count}"))?;
if n == 0 || n > count {
bail!("`{n}` is not between 1 and {count}");
}
Ok(Some(n - 1))
}
fn cmd_downgrade(prefix: &Path, name: &str) -> Result<()> {
validate_name(name)?;
let entry = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
.crates
.remove(name)
.with_context(|| format!("`{name}` is not installed under {}", prefix.display()))?
};
let current = Version::parse(&entry.version)
.with_context(|| format!("manifest holds unparsable version for `{name}`"))?;
let releases = index::releases(name)?.ok_or_else(|| index::not_found(name))?;
let candidates = index::downgrade_candidates(&releases, ¤t);
if candidates.is_empty() {
println!("{name} {current} is installed; no older version to go back to");
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!(
"downgrade asks which version to install; without a terminal, use `cargo lbin install {name}@VERSION`"
);
}
println!("{name} {current} is installed; older versions on crates.io:");
let shown = &candidates[..candidates.len().min(DOWNGRADE_CHOICES)];
for (i, v) in shown.iter().enumerate() {
println!(" {}) {v}", i + 1);
}
if candidates.len() > shown.len() {
println!(
" and {} older; use `cargo lbin install {name}@VERSION` for one of those",
candidates.len() - shown.len()
);
}
print!(
"select a version to install (1-{}), or Enter/q to abort: ",
shown.len()
);
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let Some(pick) = parse_choice(&answer, shown.len())? else {
println!("aborted");
return Ok(());
};
let version = &shown[pick];
let cache = cache_dir()?;
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let fresh = manifest.crates.get(name).with_context(|| {
format!("`{name}` was removed while a version was being chosen; run the command again")
})?;
let fresh_version = Version::parse(&fresh.version)
.with_context(|| format!("manifest holds unparsable version for `{name}`"))?;
if fresh_version != current {
bail!(
"`{name}` changed from {current} to {fresh_version} while a version was being chosen; \
run the command again"
);
}
let locked = fresh.locked;
println!("downgrading {name} {current} -> {version}");
install_and_commit(
prefix,
&cache,
&mut manifest,
name,
Some(version),
locked,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Terminal,
)?;
Ok(())
}
fn cmd_checkupdate(prefix: &Path, json: bool) -> ExitCode {
let outcome = (|| {
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
Report::new(
prefix,
check_versions(&manifest.crates, || false)?.expect("a `|| false` token never cancels"),
)
})();
match outcome {
Ok(report) => {
if let Err(e) = cache_dir().and_then(|cache| report.store(&cache)) {
eprintln!("warning: could not save update report: {e:#}");
}
let any = report.crates.iter().any(Checked::is_outdated);
if json {
if let Err(e) = json::print(&json::CheckOutput::from_report(&report)) {
eprintln!("error: {e:#}");
return ExitCode::from(EXIT_ERROR);
}
} else {
for o in report.crates.iter().filter(|c| c.is_outdated()) {
println!("{} {} -> {}", o.name, o.current, o.latest);
}
}
if any {
ExitCode::from(EXIT_UPDATES)
} else {
ExitCode::from(EXIT_NO_UPDATES)
}
}
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::from(EXIT_ERROR)
}
}
}
fn confirm(prompt: &str) -> Result<bool> {
print!("{prompt} [y/N] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(matches!(answer.trim(), "y" | "Y" | "yes"))
}
fn cmd_update(prefix: &Path, crates: &[String], all: bool, yes: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let cache = cache_dir()?;
let snapshot = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
let skipped_pinned: Vec<&str> = if all {
snapshot
.crates
.iter()
.filter(|(_, entry)| entry.pinned)
.map(|(name, _)| name.as_str())
.collect()
} else {
Vec::new()
};
let targets: BTreeSet<String> = if all {
snapshot
.crates
.iter()
.filter(|(_, entry)| !entry.pinned)
.map(|(name, _)| name.clone())
.collect()
} else {
let targets = select_targets(&snapshot, crates)?;
refuse_pinned(&snapshot, crates)?;
targets
};
for name in &skipped_pinned {
println!(
"{name} {} [pinned, skipped]",
snapshot.crates[*name].version
);
}
let outdated: Vec<Checked> = check_versions(
snapshot
.crates
.iter()
.filter(|(name, _)| targets.contains(name.as_str())),
|| false,
)?
.expect("a `|| false` token never cancels")
.into_iter()
.filter(Checked::is_outdated)
.collect();
if !all {
for name in &targets {
if !outdated.iter().any(|o| &o.name == name) {
let version = snapshot.crates[name].version.as_str();
println!("{name} {version} is up to date");
}
}
}
if outdated.is_empty() {
if all && skipped_pinned.is_empty() {
println!("everything is up to date");
} else if all {
println!(
"nothing to update; {} pinned crate(s) skipped",
skipped_pinned.len()
);
}
return Ok(());
}
for o in &outdated {
println!("{} {} -> {}", o.name, o.current, o.latest);
}
if !yes && !confirm("proceed with update?")? {
println!("aborted");
return Ok(());
}
apply_updates(prefix, &cache, &outdated)
}
fn apply_updates(prefix: &Path, cache: &Path, outdated: &[Checked]) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
let total = outdated.len();
let mut updated = 0usize;
let mut skipped: Vec<&str> = Vec::new();
let mut failed: Vec<&str> = Vec::new();
for (i, o) in outdated.iter().enumerate() {
println!("[{}/{total}] {}", i + 1, o.name);
match manifest.crates.get(&o.name) {
Some(entry) if entry.version == o.current.to_string() && !entry.pinned => {
let locked = entry.locked;
match install_and_commit(
prefix,
cache,
&mut manifest,
&o.name,
None,
locked,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Terminal,
) {
Ok(_) => updated += 1,
Err(err) => {
eprintln!("error: updating `{}` failed: {err:#}", o.name);
failed.push(&o.name);
}
}
}
_ => {
eprintln!(
"skipping `{}`: state changed since the update was confirmed",
o.name
);
skipped.push(&o.name);
}
}
}
println!("updated {updated} of {total}");
let mut shortfall = Vec::new();
if !failed.is_empty() {
shortfall.push(format!("failed: {}", failed.join(", ")));
}
if !skipped.is_empty() {
shortfall.push(format!("skipped: {}", skipped.join(", ")));
}
if !shortfall.is_empty() {
bail!(
"{} of {total} updates not applied ({})",
total - updated,
shortfall.join("; ")
);
}
Ok(())
}
#[derive(Clone, Copy)]
enum PinPolicy {
Infer,
Exactly(bool),
}
pub(crate) struct MigrationSnapshot {
version: Version,
bins: Vec<String>,
locked: bool,
pinned: bool,
}
impl MigrationSnapshot {
#[cfg(feature = "tui")]
pub(crate) fn from_parts(
name: &str,
version: &str,
bins: Vec<String>,
locked: bool,
pinned: bool,
) -> Result<Self> {
Ok(Self {
version: Version::parse(version)
.with_context(|| format!("`{name}` has an unparseable version `{version}`"))?,
bins,
locked,
pinned,
})
}
fn capture(name: &str, entry: &Entry) -> Result<Self> {
let Entry {
version,
bins,
locked,
pinned,
} = entry;
Ok(Self {
version: Version::parse(version)
.with_context(|| format!("`{name}` has an unparseable version `{version}`"))?,
bins: bins.clone(),
locked: *locked,
pinned: *pinned,
})
}
fn still_matches(&self, entry: &Entry) -> bool {
let Entry {
version,
bins,
locked,
pinned,
} = entry;
Version::parse(version).is_ok_and(|v| v == self.version)
&& *bins == self.bins
&& *locked == self.locked
&& *pinned == self.pinned
}
}
#[derive(Debug)]
pub(crate) enum MigrateOutcome {
Moved {
already_retired: bool,
version: Version,
},
Incomplete(String),
}
fn plan_migration(
prefix: &Path,
to: &Path,
crates: &[String],
all: bool,
yes: bool,
) -> Result<Option<Vec<(String, MigrationSnapshot)>>> {
let snapshot_manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
let names: BTreeSet<String> = if all {
snapshot_manifest.crates.keys().cloned().collect()
} else {
select_targets(&snapshot_manifest, crates)?
};
if names.is_empty() {
bail!("nothing to migrate");
}
let mut snapshots: Vec<(String, MigrationSnapshot)> = Vec::with_capacity(names.len());
for name in names {
let snap = MigrationSnapshot::capture(&name, &snapshot_manifest.crates[&name])?;
snapshots.push((name, snap));
}
for (name, snap) in &snapshots {
println!(
"{name} {}: {} -> {} [{}]",
snap.version,
prefix.display(),
to.display(),
if snap.pinned {
"pinned; the exact version is rebuilt"
} else {
"unpinned; the latest version is installed"
}
);
}
if !yes && !confirm("proceed with migration?")? {
println!("aborted");
return Ok(None);
}
Ok(Some(snapshots))
}
fn cmd_migrate(prefix: &Path, to: &Path, crates: &[String], all: bool, yes: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
if prefix == to {
bail!(
"source and destination are the same prefix ({})",
prefix.display()
);
}
let cache = cache_dir()?;
let Some(snapshots) = plan_migration(prefix, to, crates, all, yes)? else {
return Ok(());
};
let total = snapshots.len();
let mut moved = 0usize;
let mut incomplete: Vec<&str> = Vec::new();
let mut failed: Vec<&str> = Vec::new();
let late_escalation = late_escalation_certain(
&placement_needs_privilege(privileged::Policy::for_prefix(prefix), prefix),
&placement_needs_privilege(privileged::Policy::for_prefix(to), to),
);
for (i, (name, snap)) in snapshots.iter().enumerate() {
println!("[{}/{total}] {name}", i + 1);
if late_escalation {
eprintln!("warning: {}", late_escalation_note(name, prefix));
}
match migrate_one(
prefix,
to,
&cache,
name,
snap,
&mut MigrateFrontend::Terminal,
) {
Ok(MigrateOutcome::Moved {
already_retired,
version,
}) => {
if already_retired {
println!(
"migrated {name} {version}: already retired from {}",
prefix.display()
);
} else {
println!(
"migrated {name} {version}: retired from {}",
prefix.display()
);
}
moved += 1;
}
Ok(MigrateOutcome::Incomplete(reason)) => {
eprintln!("warning: incomplete migration: {reason}");
incomplete.push(name);
}
Err(err) => {
eprintln!("error: migrating `{name}` failed: {err:#}");
failed.push(name);
}
}
}
println!("migrated {moved} of {total}");
let mut shortfall = Vec::new();
if !failed.is_empty() {
shortfall.push(format!(
"failed before the destination committed: {}",
failed.join(", ")
));
}
if !incomplete.is_empty() {
shortfall.push(format!(
"destination committed, source not retired: {}",
incomplete.join(", ")
));
}
if !shortfall.is_empty() {
bail!(
"{} of {total} migrations not completed ({})",
total - moved,
shortfall.join("; ")
);
}
Ok(())
}
pub(crate) enum MigrateFrontend<'a> {
Terminal,
#[cfg(not(feature = "tui"))]
#[allow(dead_code)]
Never(std::marker::PhantomData<&'a ()>),
#[cfg(feature = "tui")]
Captured {
on_line: &'a mut dyn FnMut(LineKind, &str),
before_placement: &'a mut dyn FnMut(&Path) -> Result<()>,
control: &'a BuildControl,
},
}
fn rebuild_at_destination(
source: &Path,
dest: &Path,
cache: &Path,
name: &str,
snap: &MigrationSnapshot,
frontend: &mut MigrateFrontend<'_>,
) -> Result<(Version, Vec<String>)> {
let _dest_lock = match frontend {
MigrateFrontend::Terminal => StateLock::acquire(dest, &Mode::Exclusive)?,
#[cfg(not(feature = "tui"))]
MigrateFrontend::Never(_) => unreachable!(),
#[cfg(feature = "tui")]
MigrateFrontend::Captured { on_line, .. } => StateLock::acquire_with(
dest,
&Mode::Exclusive,
privileged::Policy::for_prefix(dest).screen_owned(),
&mut |l| on_line(LineKind::Notice, l),
)?,
};
let mut dest_manifest = Manifest::load(dest)?;
if let Some(existing) = dest_manifest.crates.get(name) {
bail!(
"`{name}` is already installed under {} at {}; migrate refuses to overwrite \
it with the migrating install — remove one side first (no --force by design)",
dest.display(),
existing.version
);
}
let checkpoint_policy = match frontend {
MigrateFrontend::Terminal => privileged::Policy::for_prefix(source),
#[cfg(not(feature = "tui"))]
MigrateFrontend::Never(_) => unreachable!(),
#[cfg(feature = "tui")]
MigrateFrontend::Captured { .. } => privileged::Policy::for_prefix(source).screen_owned(),
};
let mut checkpoint = || -> Result<()> {
let advisory =
StateLock::try_acquire_with(source, &Mode::Shared, checkpoint_policy, &mut |_| {})?;
let Some(_lock) = advisory else {
bail!(
"source prefix {} is busy; aborting before the destination commits",
source.display()
);
};
let current = Manifest::load(source)?;
match current.crates.get(name) {
Some(entry) if snap.still_matches(entry) => Ok(()),
Some(_) => bail!(
"`{name}` changed under {} since the plan; aborting before the \
destination commits",
source.display()
),
None => bail!(
"`{name}` is no longer installed under {}; aborting before the \
destination commits",
source.display()
),
}
};
let version = snap.pinned.then_some(&snap.version);
let installed = match frontend {
MigrateFrontend::Terminal => install_and_commit(
dest,
cache,
&mut dest_manifest,
name,
version,
snap.locked,
PinPolicy::Exactly(snap.pinned),
ShadowReport::Deferred,
&mut Frontend::Checkpointed {
checkpoint: &mut checkpoint,
},
)?,
#[cfg(not(feature = "tui"))]
MigrateFrontend::Never(_) => unreachable!(),
#[cfg(feature = "tui")]
MigrateFrontend::Captured {
on_line,
before_placement,
control,
} => install_and_commit(
dest,
cache,
&mut dest_manifest,
name,
version,
snap.locked,
PinPolicy::Exactly(snap.pinned),
ShadowReport::Deferred,
&mut Frontend::Captured {
on_line: &mut **on_line,
before_placement: &mut **before_placement,
control,
checkpoint: Some(&mut checkpoint),
},
)?,
};
let bins = dest_manifest
.crates
.get(name)
.with_context(|| format!("`{name}` vanished from {} after its commit", dest.display()))?
.bins
.clone();
Ok((installed, bins))
}
fn migration_shadow_notes(dest: &Path, bins: &[String]) -> Vec<String> {
let notes = shadow_notes(dest, bins);
if !notes.is_empty() || bins.is_empty() {
return notes;
}
let (Some(path_var), Ok(cwd)) = (std::env::var_os("PATH"), std::env::current_dir()) else {
return notes;
};
let dest_bin = dest.join("bin");
if shadow::prefix_on_path(&path_var, &dest_bin, &cwd) {
return notes;
}
vec![text::sanitize(&format!(
"{} is not on PATH",
dest_bin.display()
))]
}
fn report_migration_shadows(dest: &Path, bins: &[String], frontend: &mut MigrateFrontend<'_>) {
for note in migration_shadow_notes(dest, bins) {
let line = format!("warning: {note}");
match frontend {
MigrateFrontend::Terminal => eprintln!("{line}"),
#[cfg(not(feature = "tui"))]
MigrateFrontend::Never(_) => unreachable!(),
#[cfg(feature = "tui")]
MigrateFrontend::Captured { on_line, .. } => on_line(LineKind::Warning, &line),
}
}
}
fn migrate_one(
source: &Path,
dest: &Path,
cache: &Path,
name: &str,
snap: &MigrationSnapshot,
frontend: &mut MigrateFrontend<'_>,
) -> Result<MigrateOutcome> {
let (installed, installed_bins) =
rebuild_at_destination(source, dest, cache, name, snap, frontend)?;
let retirement = retire_with_frontend(source, name, snap, frontend);
report_migration_shadows(dest, &installed_bins, frontend);
match retirement {
Ok(Retirement::Retired) => Ok(MigrateOutcome::Moved {
already_retired: false,
version: installed,
}),
Ok(Retirement::AlreadyGone) => Ok(MigrateOutcome::Moved {
already_retired: true,
version: installed,
}),
Ok(Retirement::Mismatch) => Ok(MigrateOutcome::Incomplete(format!(
"`{name}` {installed} is installed at {} and stays: the entry under {} changed \
during the migration, so the source is deliberately not retired — `remove` \
retires whichever side is wrong",
dest.display(),
source.display()
))),
Err(e) => Ok(MigrateOutcome::Incomplete(format!(
"`{name}` {installed} is installed at {} and stays; retiring it from {} did not \
complete: {e:#} — resolve that and `remove` the source installation, do not \
re-run the migration blindly (it will refuse: the destination already has the \
crate)",
dest.display(),
source.display()
))),
}
}
enum Retirement {
Retired,
AlreadyGone,
Mismatch,
}
fn retire_with_frontend(
source: &Path,
name: &str,
snap: &MigrationSnapshot,
frontend: &mut MigrateFrontend<'_>,
) -> Result<Retirement> {
match frontend {
MigrateFrontend::Terminal => {
let policy = privileged::Policy::for_prefix(source);
privileged::preauthorize(
source,
placement_needs_privilege(policy, source)?,
privileged::AuthPurpose::Retirement,
)?;
retire_source(source, name, snap, policy, &mut |l| eprintln!("{l}"))
}
#[cfg(not(feature = "tui"))]
MigrateFrontend::Never(_) => unreachable!(),
#[cfg(feature = "tui")]
MigrateFrontend::Captured {
on_line,
before_placement,
..
} => {
let policy = privileged::Policy::for_prefix(source).screen_owned();
if placement_needs_privilege(policy, source)? {
before_placement(source)?;
}
retire_source(source, name, snap, policy, &mut |l| {
on_line(LineKind::Notice, l);
})
}
}
}
fn retire_source(
source: &Path,
name: &str,
snap: &MigrationSnapshot,
policy: privileged::Policy,
notice: &mut dyn FnMut(&str),
) -> Result<Retirement> {
let _src_lock = StateLock::acquire_with(source, &Mode::Exclusive, policy, notice)?;
let mut src_manifest = Manifest::load(source)?;
match src_manifest.crates.get(name) {
Some(entry) if snap.still_matches(entry) => {}
Some(_) => return Ok(Retirement::Mismatch),
None => return Ok(Retirement::AlreadyGone),
}
let entry = src_manifest
.crates
.remove(name)
.expect("matched by the revalidation above");
let bin_dir = source.join("bin");
let paths: Vec<PathBuf> = entry.bins.iter().map(|b| bin_dir.join(b)).collect();
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
privileged::remove_files(policy, &refs)?;
src_manifest.store(source)?;
Ok(Retirement::Retired)
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with(names: &[&str]) -> Manifest {
let mut m = Manifest::default();
for n in names {
m.crates.insert(
(*n).to_owned(),
Entry {
version: "1.0.0".to_owned(),
bins: vec![(*n).to_owned()],
locked: false,
pinned: false,
},
);
}
m
}
#[test]
fn info_names_the_other_prefixes_copy() {
let rel = index::Release {
version: Version::parse("1.2.0").unwrap(),
yanked: false,
};
let releases = [rel];
let also = [prefixes::AlsoIn {
prefix: PathBuf::from("/usr/local"),
version: "1.1.0".to_owned(),
}];
let m = manifest_with(&["foo"]);
let out = describe_info("foo", &releases, m.crates.get("foo"), &also);
assert!(out.contains("binaries: foo"), "{out}");
assert!(out.contains("also in: /usr/local @1.1.0"), "{out}");
assert!(
out.find("binaries:").unwrap() < out.find("also in:").unwrap(),
"this prefix's facts first, the other prefix's after: {out}"
);
let out = describe_info("foo", &releases, None, &also);
assert!(out.contains("installed: no"), "{out}");
assert!(out.contains("also in: /usr/local @1.1.0"), "{out}");
let out = describe_info("foo", &releases, None, &[]);
assert!(!out.contains("also in:"), "{out}");
let hostile = [prefixes::AlsoIn {
prefix: PathBuf::from("/usr/\x1b[31mlocal"),
version: "1.1.0".to_owned(),
}];
let out = describe_info("foo", &releases, None, &hostile);
assert!(
!out.contains('\x1b'),
"the escape byte must not reach the terminal: {out:?}"
);
assert!(out.contains("also in:"), "{out}");
}
#[test]
fn versions_section_lists_history_in_descending_semver_with_yanked_marks() {
let rel = |v: &str, yanked: bool| index::Release {
version: Version::parse(v).unwrap(),
yanked,
};
let releases = [
rel("2.2.0", true),
rel("2.4.0", false),
rel("2.3.0", false),
rel("2.5.0-rc.1", false),
rel("2.3.1", false),
];
let out = describe_versions(&releases);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines,
[
" versions:",
" 2.5.0-rc.1",
" 2.4.0",
" 2.3.1",
" 2.3.0",
" 2.2.0 [yanked]",
],
"{out}"
);
}
#[test]
fn duplicate_install_warns_only_before_the_second_copy() {
use std::collections::BTreeMap;
let prefix = PathBuf::from("/home/u/.local");
let mut also: BTreeMap<String, Vec<prefixes::AlsoIn>> = BTreeMap::new();
also.insert(
"foo".to_owned(),
vec![prefixes::AlsoIn {
prefix: PathBuf::from("/usr/local"),
version: "1.2.3".to_owned(),
}],
);
let empty = Manifest::default();
let lines = duplicate_install_warnings_from(&also, &prefix, &empty, std::iter::once("foo"));
assert_eq!(
lines,
[
"warning: `foo` is already managed under /usr/local @1.2.3",
"this will install another copy under /home/u/.local",
"use `cargo lbin migrate foo --prefix=/usr/local --to=/home/u/.local` \
if you intended to move it",
],
"{lines:?}"
);
let spaced = PathBuf::from("/tmp/my lbin");
let lines = duplicate_install_warnings_from(&also, &spaced, &empty, std::iter::once("foo"));
assert!(
lines[2].contains("--to='/tmp/my lbin'"),
"a space is quoted into one argument: {lines:?}"
);
let hostile_shell = PathBuf::from("/tmp/$(touch owned)");
let lines =
duplicate_install_warnings_from(&also, &hostile_shell, &empty, std::iter::once("foo"));
assert!(
lines[2].contains("--to='/tmp/$(touch owned)'"),
"command substitution is neutralized by quoting: {lines:?}"
);
let quoted = PathBuf::from("/tmp/o'brien");
let lines = duplicate_install_warnings_from(&also, "ed, &empty, std::iter::once("foo"));
assert!(
lines[2].contains(r"--to='/tmp/o'\''brien'"),
"an apostrophe survives its own quoting: {lines:?}"
);
let local = manifest_with(&["foo"]);
assert!(
duplicate_install_warnings_from(&also, &prefix, &local, std::iter::once("foo"))
.is_empty(),
"a reinstall creates no second copy"
);
assert_eq!(
duplicate_install_warnings_from(&also, &prefix, &empty, std::iter::once("bar")),
Vec::<String>::new(),
"nothing managed elsewhere, nothing to warn about"
);
let mut hostile: BTreeMap<String, Vec<prefixes::AlsoIn>> = BTreeMap::new();
hostile.insert(
"foo".to_owned(),
vec![prefixes::AlsoIn {
prefix: PathBuf::from("/usr/\x1b[31mlocal"),
version: "1.2.3".to_owned(),
}],
);
let lines =
duplicate_install_warnings_from(&hostile, &prefix, &empty, std::iter::once("foo"));
assert_eq!(lines.len(), 3, "the warning itself stands: {lines:?}");
assert!(lines.iter().all(|l| !l.contains('\x1b')), "{lines:?}");
assert_eq!(
lines[2],
"use `cargo lbin migrate` with explicit --prefix/--to \
if you intended to move it",
"no honest spelling: the mechanism is named, worded so nobody \
mistakes it for a pasteable hint: {lines:?}"
);
}
#[test]
fn cross_prefix_duplicate_decision_matrix() {
use std::collections::BTreeMap;
let here = PathBuf::from("/tmp/custom prefix");
let mut also: BTreeMap<String, Vec<prefixes::AlsoIn>> = BTreeMap::new();
also.insert(
"both".to_owned(),
vec![
prefixes::AlsoIn {
prefix: PathBuf::from("/usr/local"),
version: "1.0.0".to_owned(),
},
prefixes::AlsoIn {
prefix: PathBuf::from("/home/u/.local"),
version: "1.1.0".to_owned(),
},
],
);
also.insert(
"there".to_owned(),
vec![prefixes::AlsoIn {
prefix: PathBuf::from("/usr/local"),
version: "2.0.0".to_owned(),
}],
);
let local = manifest_with(&["here-only", "there"]);
let d = cross_prefix_duplicates_from(&also, &here, &local, std::iter::once("here-only"));
assert!(d.is_empty(), "local-only never warns");
let d = cross_prefix_duplicates_from(&also, &here, &local, std::iter::once("there"));
assert!(
d.is_empty(),
"a standing duplicate is verify's, not install's"
);
let empty = Manifest::default();
let d = cross_prefix_duplicates_from(&also, &here, &empty, std::iter::once("there"));
assert_eq!(d.len(), 1);
assert_eq!(d[0].other_version, "2.0.0");
assert_eq!(
d[0].migrate_hint.as_deref(),
Some("cargo lbin migrate there --prefix=/usr/local --to='/tmp/custom prefix'"),
"{:?}",
d[0].migrate_hint
);
let d = cross_prefix_duplicates_from(&also, &here, &empty, std::iter::once("both"));
assert_eq!(d.len(), 2, "one entry per foreign managed copy");
assert_eq!(d[0].other_version, "1.0.0");
assert_eq!(d[1].other_version, "1.1.0");
assert!(
cross_prefix_duplicates_from(&also, &here, &empty, std::iter::once("nowhere"))
.is_empty()
);
let lines = duplicate_install_warning_lines(&d_all(&also, &here, &empty), &here);
assert_eq!(lines.len(), 3 * 3, "{lines:?}");
assert!(lines[0].contains("`both`") && lines[3].contains("`both`"));
assert!(lines[6].contains("`there`"));
}
fn d_all(
also: &std::collections::BTreeMap<String, Vec<prefixes::AlsoIn>>,
here: &Path,
manifest: &Manifest,
) -> Vec<CrossPrefixDuplicate> {
cross_prefix_duplicates_from(also, here, manifest, ["both", "there"].into_iter())
}
#[test]
fn cli_shape_is_verified() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn update_requires_explicit_selection() {
assert!(Cli::try_parse_from(["cargo-lbin", "update"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "update", "--all", "foo"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "update", "--all"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "list", "--json"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "checkupdate", "--json"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "--json", "list"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "install", "--json", "bat"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "update", "foo", "bar", "-y"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "info", "foo", "--versions"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "list", "--versions"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "lbin", "update", "--all"]).is_err());
}
#[test]
fn search_rows_align_and_mark_installed() {
let hits = [
api::Hit {
name: "scx_beerland".to_owned(),
version: "1.1.3".to_owned(),
description: "A sched_ext scheduler".to_owned(),
},
api::Hit {
name: "bat".to_owned(),
version: "0.26.0".to_owned(),
description: "x".repeat(SEARCH_DESCRIPTION_WIDTH + 5),
},
];
let installed = BTreeMap::from([("bat".to_owned(), "0.25.0".to_owned())]);
let out = format_search_hits(&hits, &installed);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(
lines[0].starts_with(" scx_beerland 1.1.3 A sched_ext scheduler"),
"{}",
lines[0]
);
assert!(
lines[1].starts_with("* bat 0.26.0 "),
"{}",
lines[1]
);
assert!(lines[1].ends_with(" [installed 0.25.0]"), "{}", lines[1]);
let desc = lines[1].rsplit(" ").nth(1).unwrap();
assert_eq!(desc.chars().count(), SEARCH_DESCRIPTION_WIDTH + 1);
assert!(desc.ends_with('…'));
}
#[test]
fn info_describes_installed_state_with_checkupdate_rules() {
let rel = |v: &str, yanked: bool| index::Release {
version: Version::parse(v).unwrap(),
yanked,
};
let releases = [
rel("1.0.0", false),
rel("1.1.0", true),
rel("1.2.0", false),
rel("2.0.0-rc.1", false),
];
let m = manifest_with(&["foo"]);
let installed = m.crates.get("foo");
let out = describe_info("foo", &releases, installed, &[]);
assert!(out.contains("latest: 1.2.0"), "{out}");
assert!(out.contains("pre-release: 2.0.0-rc.1"), "{out}");
assert!(out.contains("releases: 4 (1 yanked)"), "{out}");
assert!(
out.contains("installed: 1.0.0 (update available: 1.2.0)"),
"{out}"
);
assert!(out.contains("pinned: no"), "{out}");
assert!(out.contains("locked: no"), "{out}");
assert!(out.contains("binaries: foo"), "{out}");
let out = describe_info("foo", &releases, None, &[]);
assert!(out.contains("installed: no"), "{out}");
assert!(
!out.contains("pinned:") && !out.contains("binaries:"),
"no entry, no entry block: {out}"
);
let mut m = manifest_with(&["foo"]);
{
let e = m.crates.get_mut("foo").unwrap();
e.pinned = true;
e.locked = true;
e.bins = vec!["foo".into(), "fooctl".into()];
}
let out = describe_info("foo", &releases, m.crates.get("foo"), &[]);
assert!(out.contains("pinned: yes"), "{out}");
assert!(out.contains("locked: yes"), "{out}");
assert!(out.contains("binaries: foo, fooctl"), "{out}");
let mut m = manifest_with(&["foo"]);
m.crates.get_mut("foo").unwrap().version = "1.2.0".to_owned();
let out = describe_info("foo", &releases, m.crates.get("foo"), &[]);
assert!(out.contains("installed: 1.2.0 (up to date)"), "{out}");
let releases = [rel("1.0.0", false), rel("1.1.0", true)];
let out = describe_info("foo", &releases, installed, &[]);
assert!(out.contains("latest: 1.1.0 [yanked]"), "{out}");
assert!(out.contains("installed: 1.0.0 (up to date)"), "{out}");
let releases = [rel("1.0.0", true)];
let out = describe_info("foo", &releases, installed, &[]);
assert!(out.contains("latest: 1.0.0 [yanked]"), "{out}");
assert!(
out.contains("installed: 1.0.0 (no non-yanked releases)"),
"{out}"
);
assert!(
out.contains("pinned:") && out.contains("locked:") && out.contains("binaries: foo"),
"an all-yanked history still shows the entry block: {out}"
);
}
#[test]
fn pinned_crates_are_refused_by_name_all_at_once() {
let mut m = manifest_with(&["bat", "fd", "ripgrep"]);
m.crates.get_mut("bat").unwrap().pinned = true;
m.crates.get_mut("fd").unwrap().pinned = true;
let err = refuse_pinned(&m, &["ripgrep".into(), "bat".into(), "fd".into()])
.unwrap_err()
.to_string();
assert!(err.contains("bat") && err.contains("fd"), "{err}");
assert!(!err.contains("ripgrep"), "{err}");
assert!(err.contains("`cargo lbin unpin bat fd`"), "{err}");
assert!(refuse_pinned(&m, &["ripgrep".into(), "nope".into()]).is_ok());
}
#[test]
fn completions_cover_every_shell_and_every_command() {
use clap::{CommandFactory, ValueEnum};
let names: Vec<String> = Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_owned())
.collect();
assert_ne!(names, Vec::<String>::new());
for shell in clap_complete::Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(*shell, &mut Cli::command(), "cargo-lbin", &mut out);
let script = String::from_utf8(out).unwrap();
assert_ne!(script, "", "{shell}");
for name in &names {
assert!(script.contains(name.as_str()), "{shell}: missing `{name}`");
}
}
}
#[test]
fn downgrade_choice_is_a_number_or_nothing() {
assert_eq!(parse_choice("2", 3).unwrap(), Some(1));
assert_eq!(parse_choice(" 3\n", 3).unwrap(), Some(2));
assert_eq!(parse_choice("", 3).unwrap(), None);
assert_eq!(parse_choice("\n", 3).unwrap(), None);
assert_eq!(parse_choice("q", 3).unwrap(), None);
assert_eq!(parse_choice("Q", 3).unwrap(), None);
for bad in ["0", "4", "-1", "1.1.2", "one", "1 2"] {
assert!(parse_choice(bad, 3).is_err(), "{bad}");
}
}
#[test]
fn downgrade_command_takes_one_name() {
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade", "bat"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade", "bat", "fd"]).is_err());
assert!(Cli::try_parse_from(["cargo-lbin", "downgrade", "bat", "--yes"]).is_err());
}
#[test]
fn pin_commands_parse() {
assert!(Cli::try_parse_from(["cargo-lbin", "pin", "bat"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "unpin", "bat", "fd"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "pin"]).is_err());
}
#[test]
fn select_targets_reports_all_unknown_names_at_once() {
let m = manifest_with(&["foo", "bar"]);
let err = select_targets(&m, &["foo".into(), "nope".into(), "nada".into()])
.unwrap_err()
.to_string();
assert!(err.contains("nope") && err.contains("nada"), "{err}");
assert!(!err.contains("foo"), "{err}");
}
#[test]
fn select_targets_collapses_duplicates() {
let m = manifest_with(&["foo", "bar"]);
let targets = select_targets(&m, &["bar".into(), "foo".into(), "bar".into()]).unwrap();
assert_eq!(targets.into_iter().collect::<Vec<_>>(), ["bar", "foo"]);
}
#[test]
fn commit_entry_restores_memory_on_store_failure() {
let tmp = std::env::temp_dir().join("cargo-lbin-test-commit-entry");
let _ = std::fs::remove_dir_all(&tmp);
let prefix = tmp.join("prefix");
std::fs::create_dir_all(prefix.join("share")).unwrap();
std::fs::write(prefix.join("share/cargo-lbin"), b"").unwrap();
let entry = |v: &str| Entry {
version: v.to_owned(),
bins: vec!["foo".to_owned()],
locked: false,
pinned: false,
};
let mut m = manifest_with(&["foo"]);
assert!(
commit_entry(
&mut m,
&prefix,
privileged::Policy::for_prefix(&prefix),
"foo",
entry("2.0.0"),
)
.is_err()
);
assert_eq!(m.crates["foo"].version, "1.0.0");
let mut m = Manifest::default();
assert!(
commit_entry(
&mut m,
&prefix,
privileged::Policy::for_prefix(&prefix),
"foo",
entry("2.0.0"),
)
.is_err()
);
assert_eq!(m.crates.keys().collect::<Vec<_>>(), Vec::<&String>::new());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn obsolete_is_old_minus_new() {
let old = vec!["foo".to_owned(), "fooctl".to_owned()];
let new = vec!["foo".to_owned()];
assert_eq!(obsolete_bins(&old, &new), vec!["fooctl".to_owned()]);
assert_eq!(obsolete_bins(&new, &old), [] as [String; 0]);
assert_eq!(obsolete_bins(&old, &old), [] as [String; 0]);
}
#[test]
fn newly_introduced_is_new_minus_old() {
let old = vec!["foo".to_owned()];
let new = vec!["foo".to_owned(), "fooctl".to_owned()];
assert_eq!(newly_introduced_bins(&old, &new), vec!["fooctl".to_owned()]);
assert_eq!(newly_introduced_bins(&[], &new), new);
assert_eq!(newly_introduced_bins(&new, &new), [] as [String; 0]);
}
#[test]
fn rollback_set_tracks_only_new_names_actually_placed() {
let mut manifest = Manifest::default();
manifest.crates.insert(
"foo".to_owned(),
Entry {
version: "1.0.0".to_owned(),
bins: vec!["foo".to_owned()],
locked: false,
pinned: false,
},
);
let new_bins = vec!["foo".to_owned(), "fooctl".to_owned(), "fooadmin".to_owned()];
let bin_dir = Path::new("/nonexistent/bin");
let mut set = RollbackSet::snapshot(&manifest, "foo", &new_bins);
set.note_placed("foo", bin_dir.join("foo"));
assert_eq!(set.placed, [] as [PathBuf; 0]);
set.note_placed("fooctl", bin_dir.join("fooctl"));
assert_eq!(set.placed, vec![bin_dir.join("fooctl")]);
let fresh = RollbackSet::snapshot(&Manifest::default(), "bar", &new_bins);
assert_eq!(fresh.new_names, new_bins);
assert_eq!(fresh.placed, [] as [PathBuf; 0]);
}
#[test]
fn collisions_are_detected_before_placement() {
let dir = std::env::temp_dir().join("cargo-lbin-test-collision");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut manifest = Manifest::default();
manifest.crates.insert(
"owner".to_owned(),
Entry {
version: "1.0.0".to_owned(),
bins: vec!["shared".to_owned()],
locked: false,
pinned: false,
},
);
assert!(check_collisions(&manifest, "owner", &["shared".to_owned()], &dir).is_ok());
let err = check_collisions(&manifest, "intruder", &["shared".to_owned()], &dir)
.unwrap_err()
.to_string();
assert!(
err.contains("owner"),
"error should name the owning crate: {err}"
);
std::fs::write(dir.join("stray"), b"").unwrap();
assert!(check_collisions(&manifest, "newcrate", &["stray".to_owned()], &dir).is_err());
assert!(check_collisions(&manifest, "newcrate", &["fresh".to_owned()], &dir).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "tui")]
#[test]
fn cancel_and_placement_have_exactly_one_winner() {
let control = BuildControl::new();
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
assert!(control.cancelled());
let door = control.begin_placement().unwrap_err();
assert!(
door.downcast_ref::<BuildCancelled>().is_some(),
"cancel won the race, and says so by type"
);
assert!(matches!(
control.request_cancel(),
CancelOutcome::AlreadyStopping
));
let control = BuildControl::new();
control.begin_placement().unwrap();
assert!(matches!(control.request_cancel(), CancelOutcome::TooLate));
assert!(!control.cancelled(), "TooLate never flips the phase");
let control = BuildControl::new();
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
assert!(control.cancelled());
}
#[cfg(feature = "tui")]
#[test]
fn the_door_settles_ownership_before_the_checkpoint_runs() {
let control = BuildControl::new();
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
let mut ran = 0usize;
let mut frontend = Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &control,
checkpoint: Some(&mut || {
ran += 1;
Ok(())
}),
};
let err = frontend.placement_begins().unwrap_err();
assert!(err.downcast_ref::<BuildCancelled>().is_some());
let _ = frontend;
assert_eq!(ran, 0, "a cancelled operation never probes");
let control = BuildControl::new();
let mut ran = 0usize;
let mut frontend = Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &control,
checkpoint: Some(&mut || {
ran += 1;
Ok(())
}),
};
frontend.placement_begins().unwrap();
let _ = frontend;
assert_eq!(
ran, 1,
"the worker that crossed the door runs the checkpoint"
);
}
#[cfg(feature = "tui")]
#[test]
fn a_second_cancel_kills_a_term_ignoring_build() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-cancel-escalate");
let _ = fs::remove_dir_all(&root);
let fake_bin = root.join("fakebin");
let prefix = root.join("prefix");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let script = fake_bin.join("cargo");
fs::write(&script, "#!/bin/sh\ntrap '' TERM\nsleep 30\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let control = std::sync::Arc::new(BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
let cache = root.join("cache");
let prefix_w = prefix.clone();
let started = std::time::Instant::now();
let worker = std::thread::spawn(move || {
let mut manifest = Manifest::default();
install_and_commit(
&prefix_w,
&cache,
&mut manifest,
"stubborncrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &worker_control,
checkpoint: None,
},
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match control.request_cancel() {
CancelOutcome::Killed => break,
CancelOutcome::AlreadyStopping if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
other => panic!("expected Killed before the deadline, got {other:?}"),
}
}
let err = worker.join().unwrap().expect_err("SIGKILL ended the build");
assert!(
err.downcast_ref::<BuildCancelled>().is_some(),
"an escalated cancel is still the typed cancellation: {err:#}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"SIGKILL ended the build, not the sleep"
);
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_cancel_sweeps_group_members_that_ignore_sigterm() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-cancel-sweep");
let _ = fs::remove_dir_all(&root);
let fake_bin = root.join("fakebin");
let prefix = root.join("prefix");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let stray_pid_file = root.join("stray.pid");
let script = fake_bin.join("cargo");
fs::write(
&script,
format!(
"#!/bin/sh\n\
sh -c 'trap \"\" TERM; echo $$ > \"{pidfile}\"; while :; do sleep 0.1; done' &\n\
sleep 30\n",
pidfile = stray_pid_file.display()
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let control = std::sync::Arc::new(BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
let cache = root.join("cache");
let cache_w = cache.clone();
let prefix_w = prefix.clone();
let worker = std::thread::spawn(move || {
let mut manifest = Manifest::default();
install_and_commit(
&prefix_w,
&cache_w,
&mut manifest,
"straycrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &worker_control,
checkpoint: None,
},
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
let err = worker.join().unwrap().expect_err("cancelled");
assert!(err.downcast_ref::<BuildCancelled>().is_some(), "{err:#}");
let stray_pid: u32 = fs::read_to_string(&stray_pid_file)
.expect("the stray recorded its pid before the cancel")
.trim()
.parse()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while PathBuf::from(format!("/proc/{stray_pid}")).exists() {
assert!(
std::time::Instant::now() < deadline,
"the TERM-ignoring group member survived the cancel sweep"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
let leftovers: Vec<PathBuf> = fs::read_dir(cache.join(crate::stage::RUN_NAMESPACE))
.map(|entries| entries.map(|e| e.unwrap().path()).collect())
.unwrap_or_default();
match leftovers.as_slice() {
[] => {}
[run] => {
assert_eq!(
crate::stage::probe_lease(run),
crate::stage::LeaseState::Released,
"a vetoed run is released once its last inheritor exits"
);
assert!(
scan_stale_stages(&cache).unwrap().contains(run),
"and it is then ownerless debris, not an orphan nobody names"
);
}
other => panic!("one build leaves at most one run: {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn escalation_unwedges_a_partial_line_holding_the_pipe() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-cancel-partial");
let _ = fs::remove_dir_all(&root);
let fake_bin = root.join("fakebin");
let prefix = root.join("prefix");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let stray_pid_file = root.join("stray.pid");
let script = fake_bin.join("cargo");
fs::write(
&script,
format!(
"#!/bin/sh\n\
sh -c 'trap \"\" TERM; printf partial >&2; echo $$ > \"{pidfile}\"; while :; do sleep 1; done' &\n\
sleep 30\n",
pidfile = stray_pid_file.display()
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let control = std::sync::Arc::new(BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
let cache = root.join("cache");
let prefix_w = prefix.clone();
let started = std::time::Instant::now();
let worker = std::thread::spawn(move || {
let mut manifest = Manifest::default();
install_and_commit(
&prefix_w,
&cache,
&mut manifest,
"partialcrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &worker_control,
checkpoint: None,
},
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(matches!(control.request_cancel(), CancelOutcome::Killed));
let err = worker.join().unwrap().expect_err("cancelled");
assert!(
err.downcast_ref::<BuildCancelled>().is_some(),
"the unwedged worker still reports the typed cancellation: {err:#}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"the escalation ended the wedge, not the sleep"
);
let stray_pid: u32 = fs::read_to_string(&stray_pid_file)
.expect("the stray recorded its pid")
.trim()
.parse()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while PathBuf::from(format!("/proc/{stray_pid}")).exists() {
assert!(
std::time::Instant::now() < deadline,
"the pipe-holding stray survived the escalation"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_cancel_stops_a_running_captured_build() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-cancel-running");
let _ = fs::remove_dir_all(&root);
let fake_bin = root.join("fakebin");
let prefix = root.join("prefix");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let script = fake_bin.join("cargo");
fs::write(&script, "#!/bin/sh\nsleep 30\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let control = std::sync::Arc::new(BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
let cache = root.join("cache");
let cache_w = cache.clone();
let prefix_w = prefix.clone();
let started = std::time::Instant::now();
let worker = std::thread::spawn(move || {
let mut manifest = Manifest::default();
install_and_commit(
&prefix_w,
&cache_w,
&mut manifest,
"slowcrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &worker_control,
checkpoint: None,
},
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
let result = worker.join().unwrap();
let err = result.expect_err("a cancelled build never commits");
assert!(
err.downcast_ref::<BuildCancelled>().is_some(),
"the outcome is a typed cancellation, not an anonymous failure: {err:#}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"SIGTERM to the group ended the build, not the sleep"
);
assert!(
!Manifest::path(&prefix).exists() || Manifest::load(&prefix).unwrap().crates.is_empty(),
"nothing was recorded"
);
let logs = cache.join("logs");
assert!(
!logs.exists() || fs::read_dir(&logs).unwrap().next().is_none(),
"a cancelled build writes no failure log"
);
let leftovers: Vec<PathBuf> = fs::read_dir(cache.join(crate::stage::RUN_NAMESPACE))
.map(|entries| entries.map(|e| e.unwrap().path()).collect())
.unwrap_or_default();
match leftovers.as_slice() {
[] => {}
[run] => {
assert_eq!(
crate::stage::probe_lease(run),
crate::stage::LeaseState::Released,
"a vetoed run is released once its last inheritor exits"
);
assert!(
scan_stale_stages(&cache).unwrap().contains(run),
"and it is then ownerless debris, not an orphan nobody names"
);
}
other => panic!("one build leaves at most one run: {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn migration_snapshot_protects_every_entry_field() {
let base = Entry {
version: "1.2.3".into(),
bins: vec!["foo".into()],
locked: true,
pinned: true,
};
let snap = MigrationSnapshot::capture("foo", &base).unwrap();
assert!(snap.still_matches(&base));
let mut changed = base.clone();
changed.version = "1.2.4".into();
assert!(!snap.still_matches(&changed), "version is protected");
let mut changed = base.clone();
changed.bins.push("fooctl".into());
assert!(!snap.still_matches(&changed), "the bin set is protected");
let mut changed = base.clone();
changed.locked = false;
assert!(
!snap.still_matches(&changed),
"the locked flag is protected"
);
let mut changed = base.clone();
changed.pinned = false;
assert!(!snap.still_matches(&changed), "the pin is protected");
}
fn seeded_prefix(root: &Path, dir: &str, name: &str, locked: bool, pinned: bool) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let prefix = root.join(dir);
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
fs::write(prefix.join("bin").join(name), "#!/bin/sh\ntrue\n").unwrap();
fs::set_permissions(
prefix.join("bin").join(name),
fs::Permissions::from_mode(0o755),
)
.unwrap();
let mut manifest = Manifest::default();
manifest.crates.insert(
name.to_owned(),
Entry {
version: "0.1.0".into(),
bins: vec![name.to_owned()],
locked,
pinned,
},
);
manifest.store(&prefix).unwrap();
prefix
}
fn staging_fake(root: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let fake_bin = root.join("fakebin");
fs::create_dir_all(&fake_bin).unwrap();
let script = fake_bin.join("cargo");
fs::write(
&script,
format!(
"#!/bin/sh\n\
mkdir -p \"$4/bin\"\n\
printf '#!/bin/sh\\ntrue\\n' > \"$4/bin/{name}\"\n\
chmod 755 \"$4/bin/{name}\"\n\
printf '%s' '{{\"installs\":{{\"{name} 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)\":{{\"bins\":[\"{name}\"]}}}}}}' > \"$4/.crates2.json\"\n\
exit 0\n"
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn versioned_fake(root: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let fake_bin = root.join("fakebin");
fs::create_dir_all(&fake_bin).unwrap();
let script = fake_bin.join("cargo");
fs::write(
&script,
format!(
"#!/bin/sh\n\
ver=0.2.0\n\
for a in \"$@\"; do\n\
case \"$a\" in =*) ver=${{a#=}};; esac\n\
done\n\
mkdir -p \"$4/bin\"\n\
printf '#!/bin/sh\\ntrue\\n' > \"$4/bin/{name}\"\n\
chmod 755 \"$4/bin/{name}\"\n\
printf '%s' \"{{\\\"installs\\\":{{\\\"{name} $ver (registry+https://github.com/rust-lang/crates.io-index)\\\":{{\\\"bins\\\":[\\\"{name}\\\"]}}}}}}\" > \"$4/.crates2.json\"\n\
exit 0\n"
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
script
}
#[cfg(feature = "tui")]
fn two_bin_fake(root: &Path, name: &str, extra: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let fake_bin = root.join("fakebin");
fs::create_dir_all(&fake_bin).unwrap();
let script = fake_bin.join("cargo");
fs::write(
&script,
format!(
"#!/bin/sh\n\
ver=0.2.0\n\
for a in \"$@\"; do\n\
case \"$a\" in =*) ver=${{a#=}};; esac\n\
done\n\
mkdir -p \"$4/bin\"\n\
for b in {name} {extra}; do\n\
printf '#!/bin/sh\\ntrue\\n' > \"$4/bin/$b\"\n\
chmod 755 \"$4/bin/$b\"\n\
done\n\
printf '%s' \"{{\\\"installs\\\":{{\\\"{name} $ver (registry+https://github.com/rust-lang/crates.io-index)\\\":{{\\\"bins\\\":[\\\"{name}\\\",\\\"{extra}\\\"]}}}}}}\" > \"$4/.crates2.json\"\n\
exit 0\n"
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
script
}
#[cfg(feature = "tui")]
#[test]
fn a_migrations_report_scans_the_binaries_the_destination_committed() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-report-bins");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = root.join("dest");
let distro_bin = root.join("distro/bin");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
fs::create_dir_all(&distro_bin).unwrap();
let helper = distro_bin.join("okcrate-helper");
fs::write(&helper, "#!/bin/sh\n").unwrap();
fs::set_permissions(&helper, fs::Permissions::from_mode(0o755)).unwrap();
let _fake =
crate::stage::FakeCargo::install(&two_bin_fake(&root, "okcrate", "okcrate-helper"));
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
assert_eq!(snap.bins, vec!["okcrate".to_owned()], "the source's set");
let said = std::cell::RefCell::new(Vec::new());
let control = BuildControl::new();
let outcome = {
let mut on_line = |k: LineKind, l: &str| {
if matches!(k, LineKind::Warning) {
said.borrow_mut().push(l.to_owned());
}
};
let mut before_placement = |_: &Path| Ok(());
let old_path = std::env::var_os("PATH");
let scan_path = format!(
"{}:{}:{}",
distro_bin.display(),
dest.join("bin").display(),
old_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
);
unsafe { std::env::set_var("PATH", scan_path) };
let outcome = migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Captured {
on_line: &mut on_line,
before_placement: &mut before_placement,
control: &control,
},
);
match old_path {
Some(v) => unsafe { std::env::set_var("PATH", v) },
None => unsafe { std::env::remove_var("PATH") },
}
outcome
}
.unwrap();
assert!(
matches!(outcome, MigrateOutcome::Moved { .. }),
"{outcome:?}"
);
assert_eq!(
Manifest::load(&dest).unwrap().crates["okcrate"].bins,
vec!["okcrate".to_owned(), "okcrate-helper".to_owned()],
"the destination committed both binaries"
);
let said = said.into_inner();
assert!(
said.iter()
.any(|l| l.contains("okcrate-helper") && l.contains(&helper.display().to_string())),
"a binary the new version added is scanned too: {said:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_migrations_shadow_report_describes_the_state_phase_b_left() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-shadow-after-retire");
let _ = fs::remove_dir_all(&root);
let source_bin = root.join("source/bin");
let distro_bin = root.join("distro/bin");
let dest = root.join("dest");
for d in [&source_bin, &distro_bin, &dest.join("bin")] {
fs::create_dir_all(d).unwrap();
}
let put = |dir: &Path| {
let f = dir.join("tool");
fs::write(&f, "#!/bin/sh\n").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
};
let bins = vec!["tool".to_owned()];
let notes = |path: String| {
let _serial = crate::stage::no_spawned_children();
let old = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", path) };
let notes = migration_shadow_notes(&dest, &bins);
match old {
Some(v) => unsafe { std::env::set_var("PATH", v) },
None => unsafe { std::env::remove_var("PATH") },
}
notes
};
let with_dest = format!(
"{}:{}:{}",
source_bin.display(),
distro_bin.display(),
dest.join("bin").display()
);
put(&distro_bin);
let after = notes(with_dest.clone());
assert_eq!(after.len(), 1, "{after:?}");
assert!(
after[0].contains(&distro_bin.join("tool").display().to_string()),
"the copy that actually shadows now: {after:?}"
);
put(&source_bin);
let incomplete = notes(with_dest.clone());
assert_eq!(incomplete.len(), 1, "{incomplete:?}");
assert!(
incomplete[0].contains(&source_bin.join("tool").display().to_string()),
"a source that survived is news: {incomplete:?}"
);
fs::remove_file(source_bin.join("tool")).unwrap();
fs::remove_file(distro_bin.join("tool")).unwrap();
assert!(notes(with_dest).is_empty(), "silence is the honest answer");
let without_dest = format!("{}:{}", source_bin.display(), distro_bin.display());
let unreachable = notes(without_dest);
assert_eq!(
unreachable,
vec![format!("{} is not on PATH", dest.join("bin").display())],
"the binary is unreachable by name, and that is said plainly"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_unjudgeable_prefix_announces_nothing() {
let err = || Err(anyhow::anyhow!("only /usr/local may escalate"));
assert!(
late_escalation_certain(&Ok(true), &Ok(false)),
"the case it is for"
);
assert!(
!late_escalation_certain(&Ok(true), &err()),
"a destination nobody can judge is not a destination that needs nothing"
);
assert!(
!late_escalation_certain(&err(), &Ok(false)),
"nor is the source"
);
assert!(!late_escalation_certain(&err(), &err()));
assert!(!late_escalation_certain(&Ok(false), &Ok(false)));
assert!(
!late_escalation_certain(&Ok(true), &Ok(true)),
"a destination that escalates has already asked"
);
}
#[test]
fn the_late_escalation_note_promises_privilege_not_a_prompt() {
let note = late_escalation_note("scx_truther", Path::new("/usr/local"));
assert_eq!(
note,
"retiring `scx_truther` from /usr/local needs sudo after the build; \
a password may be requested then"
);
assert!(!note.contains("will ask"), "{note}");
let hostile = PathBuf::from("/usr/\x1b[31mlocal");
assert!(!late_escalation_note("foo", &hostile).contains('\x1b'));
}
#[test]
fn a_prefix_ending_in_bin_is_named_as_probably_one_level_too_deep() {
let note = bin_dir_prefix_note(Path::new("/home/u/.local/bin"))
.expect("a bin-suffixed prefix is worth a word");
assert!(note.contains("/home/u/.local/bin/bin"), "{note}");
assert!(note.contains("did you mean /home/u/.local?"), "{note}");
let relative = bin_dir_prefix_note(Path::new("bin")).expect("still worth a word");
assert!(relative.contains("binaries go to bin/bin"), "{relative}");
assert!(relative.contains("did you mean .?"), "{relative}");
assert!(bin_dir_prefix_note(Path::new("/usr/local")).is_none());
assert!(bin_dir_prefix_note(Path::new("/opt/binutils")).is_none());
assert!(bin_dir_prefix_note(Path::new("/")).is_none());
let hostile = PathBuf::from("/usr/\x1b[31mlocal/bin");
let note = bin_dir_prefix_note(&hostile).unwrap();
assert!(!note.contains('\x1b'), "{note}");
}
#[test]
fn migrate_rebuilds_at_the_destination_and_retires_the_source() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-moves");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", true, true);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&staging_fake(&root, "okcrate"));
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let outcome = migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Terminal,
)
.unwrap();
assert!(matches!(
&outcome,
MigrateOutcome::Moved {
already_retired: false,
version,
} if version.to_string() == "0.1.0"
));
let dest_manifest = Manifest::load(&dest).unwrap();
let entry = &dest_manifest.crates["okcrate"];
assert_eq!(entry.version, "0.1.0");
assert!(entry.pinned, "the pin bit travels with the crate");
assert!(entry.locked, "the --locked flag travels with the crate");
assert!(dest.join("bin/okcrate").is_file(), "rebuilt and placed");
let src_manifest = Manifest::load(&source).unwrap();
assert!(!src_manifest.crates.contains_key("okcrate"), "retired");
assert!(!source.join("bin/okcrate").exists(), "binary removed");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn migrate_carries_an_unpinned_entry_unpinned() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-unpinned");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&staging_fake(&root, "okcrate"));
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Terminal,
)
.unwrap();
assert!(
!Manifest::load(&dest).unwrap().crates["okcrate"].pinned,
"an unpinned crate arrives unpinned — the bit travels as stated policy"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_passes_a_healthy_prefix_and_names_every_broken_invariant() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-verify");
let _ = fs::remove_dir_all(&root);
let prefix = seeded_prefix(&root, "prefix", "okcrate", false, false);
let manifest = Manifest::load(&prefix).unwrap();
assert!(
verify_entries(&prefix, &manifest).0.is_empty(),
"a healthy prefix verifies clean"
);
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("is missing"), "{errors:?}");
assert!(
errors[0].message.contains("install okcrate"),
"the finding names the repair: {errors:?}"
);
assert!(
errors[0]
.message
.contains(&format!("--prefix={}", prefix.display())),
"the hint repairs the prefix that was audited, not the default: {errors:?}"
);
fs::create_dir(prefix.join("bin/okcrate")).unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
assert!(
errors[0].message.contains("not a regular file"),
"{errors:?}"
);
fs::remove_dir(prefix.join("bin/okcrate")).unwrap();
std::os::unix::fs::symlink("/bin/sh", prefix.join("bin/okcrate")).unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
assert!(
errors[0].message.contains("is a symlink"),
"a healthy target does not excuse the drift: {errors:?}"
);
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
std::os::unix::fs::symlink("/nonexistent/target", prefix.join("bin/okcrate")).unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
assert!(
errors[0].message.contains("is a symlink") && !errors[0].message.contains("missing"),
"{errors:?}"
);
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
fs::write(prefix.join("bin/okcrate"), "#!/bin/sh\ntrue\n").unwrap();
fs::set_permissions(
prefix.join("bin/okcrate"),
fs::Permissions::from_mode(0o644),
)
.unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
assert!(errors[0].message.contains("not executable"), "{errors:?}");
fs::set_permissions(
prefix.join("bin/okcrate"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
let mut pinned = Manifest::load(&prefix).unwrap();
pinned.crates.get_mut("okcrate").unwrap().pinned = true;
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
let (errors, _) = verify_entries(&prefix, &pinned);
assert!(
errors[0].message.contains("install okcrate@0.1.0"),
"the pinned remedy is the exact re-pin: {errors:?}"
);
let mut locked = Manifest::load(&prefix).unwrap();
locked.crates.get_mut("okcrate").unwrap().locked = true;
let (errors, _) = verify_entries(&prefix, &locked);
assert!(
errors[0].message.contains("--locked"),
"the locked remedy keeps the policy: {errors:?}"
);
fs::write(prefix.join("bin/okcrate"), "#!/bin/sh\ntrue\n").unwrap();
fs::set_permissions(
prefix.join("bin/okcrate"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
let mut broken = Manifest::load(&prefix).unwrap();
broken.crates.get_mut("okcrate").unwrap().version = "not-a-version".into();
let (errors, _) = verify_entries(&prefix, &broken);
assert!(errors[0].message.contains("unparseable"), "{errors:?}");
let mut dup = Manifest::load(&prefix).unwrap();
dup.crates.insert(
"othercrate".into(),
Entry {
version: "0.1.0".into(),
bins: vec!["okcrate".into()],
locked: false,
pinned: false,
},
);
let (errors, _) = verify_entries(&prefix, &dup);
assert!(
errors.iter().any(|e| e.message.contains("claimed by")
&& e.message.contains("`okcrate`")
&& e.message.contains("`othercrate`")),
"the duplicate claim names both owners: {errors:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn migrate_installs_the_latest_version_for_an_unpinned_crate() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-latest");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&versioned_fake(&root, "okcrate"));
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let outcome = migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Terminal,
)
.unwrap();
assert!(
matches!(
&outcome,
MigrateOutcome::Moved {
already_retired: false,
version,
} if version.to_string() == "0.2.0"
),
"the outcome speaks the installed version, not the plan's: {outcome:?}"
);
let entry = &Manifest::load(&dest).unwrap().crates["okcrate"];
assert_eq!(entry.version, "0.2.0", "unpinned migrates to latest");
assert!(!entry.pinned, "and stays unpinned — policy preserved");
assert!(
!Manifest::load(&source)
.unwrap()
.crates
.contains_key("okcrate"),
"the source 0.1.0 still matched its snapshot and was retired"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_never_prepares_the_prefix() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-ro");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("fresh");
fs::create_dir_all(&prefix).unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
assert_eq!(
report.crates,
Some(0),
"a fresh prefix is known-zero — knowledge, not absence"
);
assert!(report.errors.is_empty());
assert!(
!prefix.join("share").exists(),
"verify prepared state on a prefix it promised only to read"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_reaches_the_states_the_validated_loader_refuses() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-load");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
fs::write(
Manifest::path(&prefix),
r#"{"crates":{
"acrate":{"version":"not-a-version","bins":["shared"]},
"bcrate":{"version":"0.1.0","bins":["shared"]}
}}"#,
)
.unwrap();
assert!(
Manifest::load(&prefix).is_err(),
"the validated loader refuses this state; that is its job"
);
let manifest = Manifest::load_unvalidated(&prefix).unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
assert!(
errors.iter().any(|e| e.message.contains("unparseable")),
"the bad version became a finding: {errors:?}"
);
assert!(
errors.iter().any(|e| e.message.contains("claimed by")),
"the duplicate claim became a finding: {errors:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_reports_an_undeserializable_manifest_as_its_one_finding() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-garbage");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
fs::write(Manifest::path(&prefix), "not json at all").unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
assert_eq!(report.errors.len(), 1, "{:?}", report.errors);
assert!(
report.errors[0].message.contains("cannot be parsed"),
"the deepest inconsistency is a finding, not a failed audit: {:?}",
report.errors
);
assert_eq!(
report.crates, None,
"behind a missing brace may sit forty entries — the count is unknown"
);
assert!(
report.errors[0].message.contains("repair"),
"serde refused the bytes, so repair-or-restore is honest: {:?}",
report.errors
);
fs::write(Manifest::path(&prefix), b"\xff\xfe not utf8").unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
assert!(
report.errors[0].message.contains("cannot be parsed"),
"corrupt bytes are corruption, not I/O: {:?}",
report.errors
);
assert!(
report.errors[0].message.contains("repair"),
"read bytes that serde refused earn repair-or-restore: {:?}",
report.errors
);
fs::remove_file(Manifest::path(&prefix)).unwrap();
fs::create_dir(Manifest::path(&prefix)).unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
assert!(
report.errors[0].message.contains("cannot be inspected"),
"an I/O failure says nothing about the content: {:?}",
report.errors
);
assert!(
!report.errors[0].message.contains("restore"),
"no repair advice over bytes nobody has seen: {:?}",
report.errors
);
assert_eq!(report.crates, None, "unread bytes count nothing");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_distinguishes_missing_from_uninspectable() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-inspect");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(&prefix).unwrap();
fs::write(prefix.join("bin"), "a file, not a directory").unwrap();
let mut manifest = Manifest::default();
manifest.crates.insert(
"weird".into(),
Entry {
version: "0.1.0".into(),
bins: vec!["tool".into()],
locked: false,
pinned: false,
},
);
let (errors, _) = verify_entries(&prefix, &manifest);
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(
errors[0].message.contains("cannot be inspected"),
"not-NotFound is not \"missing\": {errors:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_reinstall_hint_is_safe_to_paste() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-quote");
let _ = fs::remove_dir_all(&root);
let prefix = seeded_prefix(&root, "cargo lbin's test", "okcrate", false, false);
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
let manifest = Manifest::load(&prefix).unwrap();
let (errors, _) = verify_entries(&prefix, &manifest);
let expected = format!("--prefix='{}/cargo lbin'\\''s test'", root.display());
assert!(
errors[0].message.contains(&expected),
"the prefix is single-quoted with the classic apostrophe dance:\n \
finding: {}\n expected fragment: {expected}",
errors[0]
);
let sneaky = seeded_prefix(&root, "with\nnewline", "okcrate", false, false);
fs::remove_file(sneaky.join("bin/okcrate")).unwrap();
let manifest = Manifest::load(&sneaky).unwrap();
let (errors, _) = verify_entries(&sneaky, &manifest);
assert!(
!errors[0].message.contains("cargo lbin install"),
"a command the sanitizer would falsify is no command: {errors:?}"
);
assert!(
errors[0].message.contains("cannot be spelled"),
"the finding says why no command is offered: {errors:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn pasteable_prefix_refuses_what_it_cannot_spell() {
use std::os::unix::ffi::OsStrExt;
assert_eq!(
pasteable_prefix(Path::new("/usr/local")).as_deref(),
Some("--prefix=/usr/local")
);
assert_eq!(
pasteable_prefix(Path::new("--weird")).as_deref(),
Some("--prefix=--weird")
);
assert_eq!(
pasteable_prefix(Path::new("/tmp/my lbin")).as_deref(),
Some("--prefix='/tmp/my lbin'")
);
assert_eq!(pasteable_prefix(Path::new("/tmp/a\nb")), None);
let non_utf8 = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/\xff"));
assert_eq!(
pasteable_prefix(non_utf8),
None,
"display() is lossy here; a lossy command is not a true one"
);
}
#[test]
fn shell_quote_leaves_boring_paths_bare() {
assert_eq!(shell_quote("/usr/local"), "/usr/local");
assert_eq!(shell_quote("/tmp/my lbin"), "'/tmp/my lbin'");
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
assert_eq!(shell_quote("$(reboot)"), "'$(reboot)'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn a_broken_entry_anywhere_silences_every_reinstall_hint() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-poison");
let _ = fs::remove_dir_all(&root);
let prefix = seeded_prefix(&root, "prefix", "okcrate", false, false);
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
let mut manifest = Manifest::load_unvalidated(&prefix).unwrap();
manifest.crates.insert(
"poison".into(),
Entry {
version: "not-a-version".into(),
bins: vec!["poison".into()],
locked: false,
pinned: false,
},
);
let (errors, _) = verify_entries(&prefix, &manifest);
let missing = errors
.iter()
.find(|e| e.message.contains("is missing"))
.expect("the sound crate's disk finding still exists");
assert!(
!missing.message.contains("cargo lbin install"),
"a hint that bounces off load is no hint: {missing}"
);
assert!(
missing
.message
.contains("once the manifest findings above are repaired"),
"the finding says why the command is withheld: {missing}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_mirrors_the_whole_validate_set_and_stats_only_sound_names() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-verify-mirror");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::write(prefix.join("outside"), "#!/bin/sh\ntrue\n").unwrap();
fs::set_permissions(prefix.join("outside"), fs::Permissions::from_mode(0o755)).unwrap();
fs::write(prefix.join("bin/good"), "#!/bin/sh\ntrue\n").unwrap();
fs::set_permissions(prefix.join("bin/good"), fs::Permissions::from_mode(0o755)).unwrap();
let mut manifest = Manifest::default();
manifest.crates.insert(
"0badname".into(),
Entry {
version: "0.1.0".into(),
bins: Vec::new(),
locked: false,
pinned: false,
},
);
manifest.crates.insert(
"weird".into(),
Entry {
version: "0.1.0".into(),
bins: vec!["../outside".into(), "good".into(), "good".into()],
locked: false,
pinned: false,
},
);
let (errors, checkable) = verify_entries(&prefix, &manifest);
assert!(
errors
.iter()
.any(|e| e.message.contains("not a valid crate name")),
"{errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.message.contains("declares no binaries")),
"{errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.message.contains("not one plain filename")),
"{errors:?}"
);
assert!(
errors.iter().any(|e| e.message.contains("listed twice")),
"{errors:?}"
);
assert!(
!errors.iter().any(|e| e.message.contains("outside")
&& (e.message.contains("missing") || e.message.contains("not executable"))),
"the path-like name produced a disk verdict — it was stat'd: {errors:?}"
);
assert_eq!(
checkable,
vec!["good".to_owned()],
"only sound names reach the PATH scan"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_findings_carry_their_subjects_as_data() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-data");
let _ = fs::remove_dir_all(&root);
let prefix = seeded_prefix(&root, "prefix", "okcrate", true, true);
fs::remove_file(prefix.join("bin/okcrate")).unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
let f = &report.errors[0];
assert_eq!(f.kind, "binary-missing");
assert_eq!(f.krate.as_deref(), Some("okcrate"));
assert_eq!(f.bin.as_deref(), Some("okcrate"));
assert_eq!(
f.path.as_deref(),
Some(prefix.join("bin/okcrate").as_path())
);
let hint = f
.hint
.as_deref()
.expect("a missing binary names its repair");
assert!(hint.starts_with("cargo lbin install okcrate@"), "{hint}");
assert!(
f.message.contains(hint),
"the human line embeds the same command the field carries"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_data_fields_keep_control_characters_unlaundered() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-raw");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
fs::write(
Manifest::path(&prefix),
r#"{"crates":{"esc\u001bcrate":{"version":"0.1.0","bins":["esccrate"]}}}"#,
)
.unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
let f = report
.errors
.iter()
.find(|f| f.kind == "invalid-crate-name")
.expect("the smuggled name is invalid");
assert!(
f.krate.as_deref().is_some_and(|k| k.contains('\u{1b}')),
"the crate field keeps the real bytes: {:?}",
f.krate
);
assert!(
!f.message.contains('\u{1b}'),
"the human line stays terminal-safe"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn verify_findings_are_terminal_safe() {
let root = std::env::temp_dir().join("cargo-lbin-test-verify-sanitize");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
fs::write(
Manifest::path(&prefix),
r#"{"crates":{"esccrate":{"version":"0.1.0\u001b[31m","bins":["esccrate"]}}}"#,
)
.unwrap();
let report = verify_prefix(&prefix, &mut |_| {}).unwrap();
assert!(
!report.errors.is_empty(),
"the smuggled version is at least unparseable"
);
for finding in report.errors.iter().chain(report.warnings.iter()) {
let line = &finding.message;
assert!(
!line.chars().any(|c| c.is_control() && c != '\t'),
"a finding reached the boundary with a control char: {line:?}"
);
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn man_writes_one_roff_page_per_command() {
let dir = std::env::temp_dir().join("cargo-lbin-test-man");
let _ = fs::remove_dir_all(&dir);
cmd_man(&dir).unwrap();
let top = fs::read_to_string(dir.join("cargo-lbin.1")).unwrap();
assert!(
top.contains("\n.TH CARGO-LBIN 1"),
"a titled roff page: {top:.80}"
);
for sub in ["install", "verify", "clean", "migrate", "man"] {
let page = dir.join(format!("cargo-lbin-{sub}.1"));
let text = fs::read_to_string(&page)
.unwrap_or_else(|e| panic!("{} missing: {e}", page.display()));
assert!(
text.contains(&format!("\n.TH CARGO-LBIN-{} 1", sub.to_uppercase())),
"{} carries its own title",
page.display()
);
assert!(
text.contains(&format!("cargo\\-lbin {sub}")),
"{}: the SYNOPSIS carries the real invocation",
page.display()
);
assert!(
text.contains("\\-\\-prefix") && text.contains("\\-\\-user"),
"{}: build() propagated the global flags onto the page",
page.display()
);
}
assert!(
!dir.join("cargo-lbin-help.1").exists(),
"the implicit help pseudo-command earns no page"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn clean_removes_exactly_what_was_asked_and_nothing_unasked() {
let cache = std::env::temp_dir().join("cargo-lbin-test-clean");
let _ = fs::remove_dir_all(&cache);
let live = cache.join("stage").join(std::process::id().to_string());
let dead = cache.join("stage").join(u32::MAX.to_string());
let junk = cache.join("stage").join("not-a-pid");
for d in [&live, &dead, &junk] {
fs::create_dir_all(d).unwrap();
}
let logs = cache.join("logs");
fs::create_dir_all(&logs).unwrap();
let old_log = logs.join("build-old.log");
let new_log = logs.join("build-new.log");
fs::write(&old_log, "old").unwrap();
fs::write(&new_log, "new").unwrap();
let ancient = std::time::SystemTime::now() - std::time::Duration::from_hours(90 * 24);
fs::File::options()
.write(true)
.open(&old_log)
.unwrap()
.set_modified(ancient)
.unwrap();
assert!(clean_cache(&cache, false, false, None).is_err());
assert!(
dead.exists() && old_log.exists(),
"the refusal removed nothing"
);
let named = scan_stale_stages(&cache).unwrap();
assert!(named.contains(&dead) && named.contains(&junk) && !named.contains(&live));
clean_cache(&cache, true, true, Some(30)).unwrap();
assert!(
dead.exists() && junk.exists() && old_log.exists(),
"dry-run removed something"
);
assert!(clean_cache(&cache, false, true, Some(u64::MAX)).is_err());
assert!(
dead.exists() && old_log.exists(),
"the overflow attempt removed nothing"
);
clean_cache(&cache, false, false, Some(30)).unwrap();
assert!(dead.exists() && junk.exists(), "unasked stages are spared");
assert!(!old_log.exists(), "the old log is gone");
assert!(new_log.exists(), "the fresh log stays");
clean_cache(&cache, false, true, None).unwrap();
assert!(live.exists(), "the living stage is spared");
assert!(
!dead.exists() && !junk.exists(),
"verify-named debris is gone"
);
assert!(new_log.exists(), "unasked logs are spared");
let _ = fs::remove_dir_all(&cache);
}
#[test]
fn clean_takes_the_lease_and_defers_to_a_reader_inside() {
use std::os::fd::AsRawFd;
let _serial = crate::stage::no_spawned_children();
let root = std::env::temp_dir().join("cargo-lbin-test-clean-lease");
let _ = fs::remove_dir_all(&root);
let cache = root.join("cache");
let released = cache
.join(crate::stage::RUN_NAMESPACE)
.join("1-00000000000000aa");
let probed = cache
.join(crate::stage::RUN_NAMESPACE)
.join("1-00000000000000bb");
crate::stage::released_run_fixture(&released);
crate::stage::released_run_fixture(&probed);
let reader = fs::File::open(probed.join(".lease")).unwrap();
assert_eq!(
unsafe { libc::flock(reader.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) },
0
);
let named = scan_stale_stages(&cache).unwrap();
assert!(named.contains(&released) && named.contains(&probed));
assert_eq!(
remove_stale_stage(&probed, false),
RemoveOutcome::Deferred,
"a reader inside is a deferral, not a removal and not a failure"
);
assert!(probed.exists(), "a deferred run is left standing");
clean_cache(&cache, false, true, None).unwrap();
assert!(!released.exists(), "a released run is taken and removed");
assert!(
probed.exists(),
"a reader inside defers the removal to a later pass"
);
assert_eq!(
remove_stale_stage(&released, false),
RemoveOutcome::AlreadyGone,
"a candidate gone before this pass is done, not failed"
);
drop(reader);
assert!(
crate::stage::eventually(std::time::Duration::from_secs(10), || {
clean_cache(&cache, false, true, None).unwrap();
!probed.exists()
}),
"the pass after the reader leaves finishes the job"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn clean_never_follows_a_symlinked_run_and_sweeps_cross_namespace_debris() {
let root = std::env::temp_dir().join("cargo-lbin-test-clean-toplink");
let _ = fs::remove_dir_all(&root);
let cache = root.join("cache");
let outside = root.join("outside");
fs::create_dir_all(outside.join("keep")).unwrap();
fs::write(outside.join(".lease"), b"").unwrap();
let linked = cache
.join(crate::stage::RUN_NAMESPACE)
.join("77-00000000000000ee");
fs::create_dir_all(cache.join(crate::stage::RUN_NAMESPACE)).unwrap();
std::os::unix::fs::symlink(&outside, &linked).unwrap();
let crosswise = cache.join("stage").join("77-00000000000000dd");
fs::create_dir_all(&crosswise).unwrap();
clean_cache(&cache, false, true, None).unwrap();
assert!(
fs::symlink_metadata(&linked).is_ok(),
"the symlinked run is unknown: spared, not a candidate"
);
assert!(
outside.join("keep").exists() && outside.join(".lease").exists(),
"zero traversal: the link's target is untouched by clean"
);
assert!(
!crosswise.exists(),
"cross-namespace debris goes through the plain path and is removed"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_lease_outlives_its_creator_while_any_inheritor_runs() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-lease-inheritance");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let cache = root.join("cache");
let fake_bin = root.join("fakebin");
fs::create_dir_all(&fake_bin).unwrap();
let pid_file = root.join("orphan.pid");
let stop_file = root.join("orphan.stop");
let script = fake_bin.join("cargo");
fs::write(
&script,
format!(
"#!/bin/sh\n\
sh -c 'echo $$ > \"{pid}\"; n=0; \
while [ ! -e \"{stop}\" ] && [ \"$n\" -lt 600 ]; do n=$((n+1)); sleep 0.05; done' &\n\
exit 1\n",
pid = pid_file.display(),
stop = stop_file.display()
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let err = install_and_commit(
&prefix,
&cache,
&mut Manifest::default(),
"ghostcrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Terminal,
)
.unwrap_err();
assert!(
format!("{err:#}").contains("failed"),
"the fake build fails by design: {err:#}"
);
let runs: Vec<PathBuf> = fs::read_dir(cache.join(crate::stage::RUN_NAMESPACE))
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(runs.len(), 1, "one failed run, kept: {runs:?}");
let run = runs[0].clone();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !pid_file.exists() {
assert!(
std::time::Instant::now() < deadline,
"the orphan never announced itself"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert_eq!(
crate::stage::probe_lease(&run),
crate::stage::LeaseState::Held,
"creator dead, inheritor alive: the lease stands"
);
assert_eq!(
scan_stale_stages(&cache).unwrap(),
Vec::<PathBuf>::new(),
"an owned run is nobody's debris"
);
clean_cache(&cache, false, true, None).unwrap();
assert!(run.exists(), "clean must not touch an owned run");
fs::write(&stop_file, b"").unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while crate::stage::probe_lease(&run) != crate::stage::LeaseState::Released {
assert!(
std::time::Instant::now() < deadline,
"the lease never released after the last inheritor exited"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
let stale = scan_stale_stages(&cache).unwrap();
assert!(
stale.contains(&run),
"with the last inheritor gone, released convicts"
);
assert!(
crate::stage::eventually(std::time::Duration::from_secs(10), || {
clean_cache(&cache, false, true, None).unwrap();
!run.exists()
}),
"clean takes the lease and finishes the job"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn check_versions_honors_the_cancel_before_the_first_request() {
let entry = Entry {
version: "1.0.0".into(),
bins: vec!["x".into()],
locked: false,
pinned: false,
};
let name = "anything".to_owned();
let result = check_versions([(&name, &entry)], || true).unwrap();
assert!(
result.is_none(),
"a cancelled run is an answer, not a report"
);
}
#[test]
fn scan_stale_stages_reports_dead_pids_and_spares_the_living() {
let _serial = crate::stage::no_spawned_children();
let root = std::env::temp_dir().join("cargo-lbin-test-verify-stages");
let _ = fs::remove_dir_all(&root);
let cache = root.join("cache");
assert_eq!(scan_stale_stages(&cache).unwrap(), Vec::<PathBuf>::new());
let live = cache.join("stage").join(std::process::id().to_string());
let dead = cache.join("stage").join(u32::MAX.to_string());
let junk = cache.join("stage").join("not-a-pid");
let junk_v2 = cache.join(crate::stage::RUN_NAMESPACE).join("not-a-run");
let window = cache
.join(crate::stage::RUN_NAMESPACE)
.join(format!("{}-0123456789abcdef", u32::MAX));
let legacy_in_v2 = cache
.join(crate::stage::RUN_NAMESPACE)
.join(std::process::id().to_string());
let leased_in_legacy = cache
.join("stage")
.join(format!("{}-00000000000000cd", u32::MAX));
let held = cache
.join(crate::stage::RUN_NAMESPACE)
.join(format!("{}-00000000000000aa", u32::MAX));
let released = cache
.join(crate::stage::RUN_NAMESPACE)
.join(format!("{}-00000000000000bb", u32::MAX));
let outside = root.join("outside");
fs::create_dir_all(outside.join("keep")).unwrap();
fs::write(outside.join(".lease"), b"").unwrap();
let linked = cache
.join(crate::stage::RUN_NAMESPACE)
.join(format!("{}-00000000000000ce", u32::MAX));
fs::create_dir_all(cache.join(crate::stage::RUN_NAMESPACE)).unwrap();
std::os::unix::fs::symlink(&outside, &linked).unwrap();
for d in [
&live,
&dead,
&junk,
&window,
&junk_v2,
&legacy_in_v2,
&leased_in_legacy,
] {
fs::create_dir_all(d).unwrap();
}
let _holder = crate::stage::Lease::acquire(&held).unwrap();
crate::stage::released_run_fixture(&released);
let stale = scan_stale_stages(&cache).unwrap();
assert!(
!stale.contains(&live),
"a running instance's stage is not debris"
);
assert!(stale.contains(&dead), "a dead PID's stage is debris");
assert!(stale.contains(&junk), "a non-PID name is debris");
assert!(
!stale.contains(&window),
"a run without a lease is unknown, and unknown is spared"
);
assert!(
!stale.contains(&held),
"a held lease is a live writer, dead PID or not"
);
assert!(
stale.contains(&released),
"a released lease is the one answer that convicts"
);
assert!(stale.contains(&junk_v2), "junk is junk in stage-v2 too");
assert!(
stale.contains(&legacy_in_v2),
"a bare PID in stage-v2 is debris: /proc has no vote outside stage/"
);
assert!(
stale.contains(&leased_in_legacy),
"a leased name in stage/ is debris: nothing legally writes one there"
);
assert!(
!stale.contains(&linked),
"a symlinked run path probes as unknown: spared, never followed"
);
assert!(
outside.join("keep").exists() && outside.join(".lease").exists(),
"zero traversal: the link's target is untouched by the scan"
);
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn the_placement_door_asks_first_and_a_refusal_stops_there() {
let seen = std::cell::RefCell::new(Vec::new());
let control = BuildControl::new();
let prefix = Path::new("/usr/local");
let mut frontend = Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |p: &Path| {
seen.borrow_mut().push(format!("auth:{}", p.display()));
Ok(())
},
control: &control,
checkpoint: Some(&mut || {
seen.borrow_mut().push("checkpoint".to_owned());
Ok(())
}),
};
authorize_placement(true, prefix, &mut frontend).unwrap();
assert_eq!(
seen.borrow().as_slice(),
["auth:/usr/local".to_owned(), "checkpoint".to_owned()],
"the password comes first, the cancel door second"
);
seen.borrow_mut().clear();
let fresh = BuildControl::new();
let mut frontend = Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |p: &Path| {
seen.borrow_mut().push(format!("auth:{}", p.display()));
Ok(())
},
control: &fresh,
checkpoint: Some(&mut || {
seen.borrow_mut().push("checkpoint".to_owned());
Ok(())
}),
};
authorize_placement(false, prefix, &mut frontend).unwrap();
assert_eq!(
seen.borrow().as_slice(),
["checkpoint".to_owned()],
"no escalation, no credentials"
);
seen.borrow_mut().clear();
let third = BuildControl::new();
let mut refusing = Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| bail!("sudo authentication failed"),
control: &third,
checkpoint: Some(&mut || {
seen.borrow_mut().push("checkpoint".to_owned());
Ok(())
}),
};
let err = authorize_placement(true, prefix, &mut refusing)
.expect_err("a denied password is a refusal");
assert!(
format!("{err:#}").contains("sudo authentication failed"),
"{err:#}"
);
assert!(
seen.borrow().is_empty(),
"a refusal at the door reaches nothing past it: {:?}",
seen.borrow()
);
}
#[cfg(feature = "tui")]
#[test]
fn a_refusal_after_the_build_says_what_was_and_was_not_done() {
let root = std::env::temp_dir().join("cargo-lbin-test-late-auth-refused");
let _ = fs::remove_dir_all(&root);
let prefix = root.join("prefix");
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&staging_fake(&root, "okcrate"));
let control = BuildControl::new();
let mut manifest = Manifest::default();
let err = install_and_commit(
&prefix,
&root.join("cache"),
&mut manifest,
"okcrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Captured {
on_line: &mut |_, _| {},
before_placement: &mut |_| Ok(()),
control: &control,
checkpoint: Some(&mut || bail!("sudo authentication failed")),
},
)
.expect_err("a refusal is a refusal");
let text = format!("{err:#}");
assert!(text.contains("the build finished"), "{text}");
assert!(text.contains("placement did not begin"), "{text}");
assert!(text.contains("manifest is unchanged"), "{text}");
assert!(text.contains("sudo authentication failed"), "{text}");
assert!(
!manifest.crates.contains_key("okcrate")
&& Manifest::load(&prefix).unwrap().crates.is_empty(),
"nothing is recorded that did not happen"
);
assert!(
!prefix.join("bin/okcrate").exists(),
"and nothing is placed"
);
let runs: Vec<PathBuf> = fs::read_dir(root.join("cache").join(crate::stage::RUN_NAMESPACE))
.map(|e| e.map(|e| e.unwrap().path()).collect())
.unwrap_or_default();
assert_eq!(runs.len(), 1, "the stage is kept as forensics: {runs:?}");
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_tui_downgrade_refuses_a_premise_the_manifest_no_longer_holds() {
let root = std::env::temp_dir().join("cargo-lbin-test-tui-downgrade-premise");
let _ = fs::remove_dir_all(&root);
let prefix = seeded_prefix(&root, "prefix", "okcrate", false, false);
let _fake = crate::stage::FakeCargo::install(&versioned_fake(&root, "okcrate"));
let target = Version::parse("0.0.9").unwrap();
let control = BuildControl::new();
let err = tui_downgrade_one(
&prefix,
"okcrate",
"0.3.0",
&target,
&mut |_, _| {},
&mut |_| Ok(()),
&control,
)
.expect_err("a stale premise builds nothing");
assert!(format!("{err:#}").contains("changed from"), "{err:#}");
assert_eq!(
Manifest::load(&prefix).unwrap().crates["okcrate"].version,
"0.1.0",
"and the prefix is untouched"
);
let gone = seeded_prefix(&root, "empty", "okcrate", false, false);
{
let mut m = Manifest::load(&gone).unwrap();
m.crates.remove("okcrate");
m.store(&gone).unwrap();
}
let err = tui_downgrade_one(
&gone,
"okcrate",
"0.1.0",
&target,
&mut |_, _| {},
&mut |_| Ok(()),
&control,
)
.expect_err("a removed crate is not resurrected");
assert!(format!("{err:#}").contains("was removed"), "{err:#}");
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_tui_downgrade_repins_at_the_chosen_version() {
let root = std::env::temp_dir().join("cargo-lbin-test-tui-downgrade-pin");
let _ = fs::remove_dir_all(&root);
let prefix = seeded_prefix(&root, "prefix", "okcrate", true, true);
let _fake = crate::stage::FakeCargo::install(&versioned_fake(&root, "okcrate"));
let target = Version::parse("0.0.9").unwrap();
let control = BuildControl::new();
tui_downgrade_one(
&prefix,
"okcrate",
"0.1.0",
&target,
&mut |_, _| {},
&mut |_| Ok(()),
&control,
)
.unwrap();
let entry = Manifest::load(&prefix).unwrap().crates["okcrate"].clone();
assert_eq!(entry.version, "0.0.9", "the chosen version landed");
assert!(
entry.pinned,
"a pinned crate stays pinned, at the new version"
);
assert!(entry.locked, "and its --locked setting is carried over");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn migrate_rebuilds_the_pinned_version_even_when_latest_is_newer() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-pinned-exact");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, true);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&versioned_fake(&root, "okcrate"));
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let outcome = migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Terminal,
)
.unwrap();
assert!(matches!(
&outcome,
MigrateOutcome::Moved {
already_retired: false,
version,
} if version.to_string() == "0.1.0"
));
let entry = &Manifest::load(&dest).unwrap().crates["okcrate"];
assert_eq!(entry.version, "0.1.0", "pinned rebuilds the exact version");
assert!(entry.pinned, "and stays pinned");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn migrate_refuses_a_crate_already_at_the_destination() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-refuses");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = seeded_prefix(&root, "dest", "okcrate", false, false);
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let err = migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Terminal,
)
.unwrap_err();
assert!(
format!("{err:#}").contains("no --force by design"),
"the refusal names the policy: {err:#}"
);
assert!(
Manifest::load(&source)
.unwrap()
.crates
.contains_key("okcrate"),
"the source is untouched by a refusal"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn migrate_aborts_before_the_destination_commits_when_the_source_changed() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-aborts");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let fake_bin = root.join("fakebin");
fs::create_dir_all(&fake_bin).unwrap();
let script = fake_bin.join("cargo");
let src_manifest_path = Manifest::path(&source);
fs::write(
&script,
format!(
"#!/bin/sh\n\
printf '%s' '{{\"version\":1,\"crates\":{{\"okcrate\":{{\"version\":\"0.2.0\",\"bins\":[\"okcrate\"],\"locked\":false,\"pinned\":false}}}}}}' > \"{}\"\n\
mkdir -p \"$4/bin\"\n\
printf '#!/bin/sh\\ntrue\\n' > \"$4/bin/okcrate\"\n\
chmod 755 \"$4/bin/okcrate\"\n\
printf '%s' '{{\"installs\":{{\"okcrate 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)\":{{\"bins\":[\"okcrate\"]}}}}}}' > \"$4/.crates2.json\"\n\
exit 0\n",
src_manifest_path.display()
),
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let err = migrate_one(
&source,
&dest,
&root.join("cache"),
"okcrate",
&snap,
&mut MigrateFrontend::Terminal,
)
.unwrap_err();
assert!(
format!("{err:#}").contains("aborting before the destination commits"),
"the abort names its moment: {err:#}"
);
assert!(
Manifest::load(&dest).unwrap().crates.is_empty(),
"the destination committed nothing"
);
assert!(
!dest.join("bin/okcrate").exists(),
"no binary was placed at the destination"
);
assert_eq!(
Manifest::load(&source).unwrap().crates["okcrate"].version,
"0.2.0",
"the source keeps its newer truth; migrate touched nothing"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn retirement_is_authoritative_and_touches_nothing_on_mismatch() {
let root = std::env::temp_dir().join("cargo-lbin-test-retire");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let mut m = Manifest::load(&source).unwrap();
m.crates.get_mut("okcrate").unwrap().version = "0.2.0".into();
m.store(&source).unwrap();
assert!(matches!(
retire_source(
&source,
"okcrate",
&snap,
privileged::Policy::for_prefix(&source),
&mut |_| {}
)
.unwrap(),
Retirement::Mismatch
));
assert!(
source.join("bin/okcrate").is_file(),
"mismatch removes nothing"
);
let mut m = Manifest::load(&source).unwrap();
m.crates.get_mut("okcrate").unwrap().version = "0.1.0".into();
m.store(&source).unwrap();
assert!(matches!(
retire_source(
&source,
"okcrate",
&snap,
privileged::Policy::for_prefix(&source),
&mut |_| {}
)
.unwrap(),
Retirement::Retired
));
assert!(!source.join("bin/okcrate").exists());
assert!(
!Manifest::load(&source)
.unwrap()
.crates
.contains_key("okcrate")
);
assert!(matches!(
retire_source(
&source,
"okcrate",
&snap,
privileged::Policy::for_prefix(&source),
&mut |_| {}
)
.unwrap(),
Retirement::AlreadyGone
));
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_captured_migration_moves_and_reports_data() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-captured");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", true, true);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&staging_fake(&root, "okcrate"));
let control = BuildControl::new();
let mut lines = 0usize;
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let outcome = tui_migrate_one(
&source,
&dest,
"okcrate",
&snap,
&mut |_, _| lines += 1,
&mut |_| Ok(()),
&control,
)
.unwrap();
assert!(matches!(
&outcome,
MigrateOutcome::Moved {
already_retired: false,
version,
} if version.to_string() == "0.1.0"
));
assert!(lines > 0, "the captured frontend streamed cargo's lines");
let entry = &Manifest::load(&dest).unwrap().crates["okcrate"];
assert!(entry.pinned && entry.locked, "both bits travelled");
assert!(
!Manifest::load(&source)
.unwrap()
.crates
.contains_key("okcrate"),
"retired"
);
assert!(
matches!(control.phase(), BuildPhase::Placement),
"the migration crossed the same door an install does"
);
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_frozen_plan_rejects_a_source_that_moved_on() {
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-frozen");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let _fake = crate::stage::FakeCargo::install(&staging_fake(&root, "okcrate"));
let snap =
MigrationSnapshot::from_parts("okcrate", "0.1.0", vec!["okcrate".into()], false, false)
.unwrap();
let mut m = Manifest::load(&source).unwrap();
m.crates.get_mut("okcrate").unwrap().version = "0.2.0".into();
m.store(&source).unwrap();
let control = BuildControl::new();
let err = tui_migrate_one(
&source,
&dest,
"okcrate",
&snap,
&mut |_, _| {},
&mut |_| Ok(()),
&control,
)
.unwrap_err();
assert!(
format!("{err:#}").contains("aborting before the destination commits"),
"the checkpoint rejected the stale plan: {err:#}"
);
assert!(
Manifest::load(&dest).unwrap().crates.is_empty(),
"nothing was migrated under a plan nobody confirmed"
);
assert_eq!(
Manifest::load(&source).unwrap().crates["okcrate"].version,
"0.2.0",
"the source keeps its newer truth"
);
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn a_cancelled_migration_touches_neither_prefix() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-migrate-cancel");
let _ = fs::remove_dir_all(&root);
let source = seeded_prefix(&root, "source", "okcrate", false, false);
let dest = root.join("dest");
fs::create_dir_all(dest.join("bin")).unwrap();
fs::create_dir_all(dest.join("share/cargo-lbin")).unwrap();
let fake_bin = root.join("fakebin");
fs::create_dir_all(&fake_bin).unwrap();
let script = fake_bin.join("cargo");
fs::write(&script, "#!/bin/sh\nsleep 30\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let control = std::sync::Arc::new(BuildControl::new());
let worker_control = std::sync::Arc::clone(&control);
let source_w = source.clone();
let dest_w = dest.clone();
let snap = MigrationSnapshot::capture(
"okcrate",
&Manifest::load(&source).unwrap().crates["okcrate"],
)
.unwrap();
let worker = std::thread::spawn(move || {
tui_migrate_one(
&source_w,
&dest_w,
"okcrate",
&snap,
&mut |_, _| {},
&mut |_| Ok(()),
&worker_control,
)
});
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(matches!(control.request_cancel(), CancelOutcome::Accepted));
let err = worker.join().unwrap().expect_err("cancelled");
assert!(
err.downcast_ref::<BuildCancelled>().is_some(),
"the migration's cancel is the same typed cancellation: {err:#}"
);
assert!(
Manifest::load(&dest).unwrap().crates.is_empty(),
"the destination committed nothing"
);
assert!(
Manifest::load(&source)
.unwrap()
.crates
.contains_key("okcrate"),
"the source is untouched — a cancelled migration is a no-op"
);
let _ = fs::remove_dir_all(&root);
}
#[cfg(feature = "tui")]
#[test]
fn captured_frontend_runs_the_whole_pipeline() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join("cargo-lbin-test-captured-pipeline");
let _ = fs::remove_dir_all(&root);
let fake_bin = root.join("fakebin");
let prefix = root.join("prefix");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(prefix.join("bin")).unwrap();
fs::create_dir_all(prefix.join("share/cargo-lbin")).unwrap();
let script = fake_bin.join("cargo");
fs::write(
&script,
"#!/bin/sh\n\
echo ' Compiling okcrate v0.1.0' >&2\n\
mkdir -p \"$4/bin\"\n\
printf '#!/bin/sh\\ntrue\\n' > \"$4/bin/okcrate\"\n\
chmod 755 \"$4/bin/okcrate\"\n\
printf '%s' '{\"installs\":{\"okcrate 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)\":{\"bins\":[\"okcrate\"]}}}' > \"$4/.crates2.json\"\n\
exit 0\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let _fake = crate::stage::FakeCargo::install(&script);
let cache = root.join("cache");
let mut manifest = Manifest::default();
let mut lines: Vec<(LineKind, String)> = Vec::new();
let mut checkpoints = 0usize;
let control = BuildControl::new();
let result = install_and_commit(
&prefix,
&cache,
&mut manifest,
"okcrate",
None,
false,
PinPolicy::Infer,
ShadowReport::OnCommit,
&mut Frontend::Captured {
on_line: &mut |k, l| lines.push((k, l.to_owned())),
before_placement: &mut |_| {
checkpoints += 1;
Ok(())
},
control: &control,
checkpoint: None,
},
);
result.unwrap();
assert!(
matches!(control.phase(), BuildPhase::Placement),
"a finished install crossed the placement door"
);
assert_eq!(
checkpoints, 0,
"a user-writable prefix never reaches the checkpoint — the \
frontend must not be made to poke sudo when nothing will \
escalate"
);
assert!(
lines
.iter()
.any(|(k, l)| *k == LineKind::Cargo && l.contains("Compiling okcrate")),
"cargo output reaches the frontend as cargo's: {lines:?}"
);
assert!(
lines
.iter()
.any(|(k, l)| *k == LineKind::Notice && l.starts_with("installed okcrate 0.1.0")),
"the pipeline note arrives classified, not as anonymous text: {lines:?}"
);
assert!(prefix.join("bin/okcrate").is_file(), "binary placed");
let stored = Manifest::load(&prefix).unwrap();
assert!(stored.crates.contains_key("okcrate"), "manifest committed");
let _ = fs::remove_dir_all(&root);
}
}