mod index;
mod lock;
mod manifest;
mod privileged;
mod report;
mod stage;
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::BTreeSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use validate::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",
default_value = "/usr/local"
)]
prefix: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Install {
#[arg(required = true)]
crates: Vec<String>,
#[arg(long)]
locked: bool,
},
Remove {
#[arg(required = true)]
crates: Vec<String>,
},
List,
Search {
#[arg(required = true)]
crates: Vec<String>,
},
Checkupdate,
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 cli = Cli::parse_from(args);
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);
}
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::List => cmd_list(&cli.prefix),
Cmd::Search { ref crates } => cmd_search(&cli.prefix, crates),
Cmd::Checkupdate => return cmd_checkupdate(&cli.prefix),
Cmd::Update {
ref crates,
all,
yes,
} => cmd_update(&cli.prefix, 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::Escalation, 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(())
}
fn install_and_commit(
prefix: &Path,
cache: &Path,
manifest: &mut Manifest,
name: &str,
locked: bool,
) -> Result<()> {
validate_name(name)?;
let policy = privileged::Escalation::for_prefix(prefix);
let _ = policy.probe_destination(&prefix.join("bin"))?;
let stage_dir = cache
.join("stage")
.join(std::process::id().to_string())
.join(name);
if stage_dir.exists() {
fs::remove_dir_all(&stage_dir)
.with_context(|| format!("clearing stale stage {}", stage_dir.display()))?;
}
let built = stage::build(name, locked, &stage_dir)?;
check_collisions(manifest, name, &built.bins, &prefix.join("bin"))?;
let mut rollback = RollbackSet::snapshot(manifest, name, &built.bins);
if let Err(err) = place_and_commit(prefix, policy, manifest, name, built, locked, &mut rollback)
{
rollback_new_bins(policy, &rollback.placed);
return Err(err);
}
let _ = fs::remove_dir_all(&stage_dir);
if let Some(pid_dir) = stage_dir.parent() {
let _ = fs::remove_dir(pid_dir);
}
Ok(())
}
fn place_and_commit(
prefix: &Path,
policy: privileged::Escalation,
manifest: &mut Manifest,
name: &str,
built: stage::Built,
locked: bool,
rollback: &mut RollbackSet,
) -> 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)?;
println!("removed obsolete binaries: {}", obsolete.join(", "));
}
}
let bins_list = built.bins.join(", ");
commit_entry(
manifest,
prefix,
name,
Entry {
version: built.version.to_string(),
bins: built.bins,
locked,
},
)?;
println!(
"installed {name} {} -> {} ({bins_list})",
built.version,
bin_dir.display(),
);
Ok(())
}
fn commit_entry(manifest: &mut Manifest, prefix: &Path, name: &str, entry: Entry) -> Result<()> {
let previous = manifest.crates.insert(name.to_owned(), entry);
if let Err(err) = manifest.store(prefix) {
if let Some(old) = previous {
manifest.crates.insert(name.to_owned(), old);
} else {
manifest.crates.remove(name);
}
return Err(err);
}
Ok(())
}
fn cmd_install(prefix: &Path, crates: &[String], locked: bool) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let cache = cache_dir()?;
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let mut manifest = Manifest::load(prefix)?;
for name in crates {
install_and_commit(prefix, &cache, &mut manifest, name, locked)?;
}
Ok(())
}
fn cmd_remove(prefix: &Path, crates: &[String]) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Exclusive)?;
let policy = privileged::Escalation::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) -> Result<()> {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
let manifest = Manifest::load(prefix)?;
if manifest.crates.is_empty() {
println!("no crates installed under {}", prefix.display());
return Ok(());
}
let report = match cache_dir().and_then(|cache| Report::load(&cache, prefix)) {
Ok(report) => report,
Err(e) => {
eprintln!("warning: {e:#}");
None
}
};
for (name, entry) in &manifest.crates {
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,
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)>,
) -> Result<Vec<Checked>> {
let mut checked = Vec::new();
for (name, entry) in entries {
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(checked)
}
fn release_label(release: &index::Release) -> String {
if release.yanked {
format!("{} [yanked]", release.version)
} else {
release.version.to_string()
}
}
fn describe_search(name: &str, releases: &[index::Release], installed: Option<&Entry>) -> 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 Some(entry) = installed else {
out.push_str(" installed: no\n");
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");
return out;
}
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");
}
out
}
fn cmd_search(prefix: &Path, crates: &[String]) -> Result<()> {
for name in crates {
validate_name(name)?;
}
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(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(releases) => {
if shown > 0 {
println!();
}
print!(
"{}",
describe_search(name, &releases, manifest.crates.get(*name))
);
shown += 1;
}
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())
}
fn cmd_checkupdate(prefix: &Path) -> ExitCode {
let outcome = (|| {
let manifest = {
let _lock = StateLock::acquire(prefix, &Mode::Shared)?;
Manifest::load(prefix)?
};
check_versions(&manifest.crates)
})();
match outcome {
Ok(checked) => {
let stored = Report::new(prefix, checked.clone())
.and_then(|report| cache_dir().map(|cache| (report, cache)))
.and_then(|(report, cache)| report.store(&cache));
if let Err(e) = stored {
eprintln!("warning: could not save update report: {e:#}");
}
let mut any = false;
for o in checked.iter().filter(|c| c.is_outdated()) {
println!("{} {} -> {}", o.name, o.current, o.latest);
any = true;
}
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 targets: BTreeSet<String> = if all {
snapshot.crates.keys().cloned().collect()
} else {
select_targets(&snapshot, crates)?
};
let outdated: Vec<Checked> = check_versions(
snapshot
.crates
.iter()
.filter(|(name, _)| targets.contains(name.as_str())),
)?
.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 {
println!("everything is up to date");
}
return Ok(());
}
for o in &outdated {
println!("{} {} -> {}", o.name, o.current, o.latest);
}
if !yes && !confirm("proceed with update?")? {
println!("aborted");
return Ok(());
}
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() => {
let locked = entry.locked;
match install_and_commit(prefix, &cache, &mut manifest, &o.name, locked) {
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(())
}
#[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,
},
);
}
m
}
#[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", "update", "foo", "bar", "-y"]).is_ok());
assert!(Cli::try_parse_from(["cargo-lbin", "lbin", "update", "--all"]).is_err());
}
#[test]
fn search_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_search("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}"
);
let out = describe_search("foo", &releases, None);
assert!(out.contains("installed: no"), "{out}");
let mut m = manifest_with(&["foo"]);
m.crates.get_mut("foo").unwrap().version = "1.2.0".to_owned();
let out = describe_search("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_search("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_search("foo", &releases, installed);
assert!(out.contains("latest: 1.0.0 [yanked]"), "{out}");
assert!(
out.contains("installed: 1.0.0 (no non-yanked releases)"),
"{out}"
);
}
#[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,
};
let mut m = manifest_with(&["foo"]);
assert!(commit_entry(&mut m, &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, "foo", entry("2.0.0")).is_err());
assert!(m.crates.is_empty());
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,
},
);
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,
},
);
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);
}
}