#![deny(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
#![cfg_attr(dylint_lib = "general", allow(crate_wide_allow))]
#![cfg_attr(dylint_lib = "supplementary", allow(nonexistent_path_in_comment))]
#[cfg(not(any(feature = "crates-index", feature = "tame-index")))]
compile_error!("At least one of the `crates-index` or `tame-index` features must be enabled");
use anyhow::{Context, Result, anyhow, bail, ensure};
use cargo_metadata::{
Dependency, DependencyKind, Metadata, MetadataCommand, Package, PackageName, semver::Version,
};
use clap::{Parser, crate_version};
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
use crates_index::{Error, GitIndex};
use elaborate::std::{path::PathContext, process::CommandContext, time::SystemTimeContext};
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
use home::cargo_home;
#[cfg(feature = "tame-index")]
use std::mem::ManuallyDrop;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
env::args,
ffi::OsStr,
io::{BufRead, IsTerminal},
path::{Path, PathBuf},
process::{Command, Stdio, exit},
str::FromStr,
sync::{
LazyLock,
atomic::{AtomicBool, Ordering},
},
time::{Duration, SystemTime},
};
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
use std::{fmt::Write, fs::File};
#[cfg(feature = "tame-index")]
use tame_index::{
IndexLocation, KrateName,
index::{IndexUrl, RemoteSparseIndex, sparse::SparseIndex},
utils::flock::LockOptions,
};
use tempfile::TempDir;
use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor};
use toml::{Table, Value};
pub mod flush;
pub mod github;
pub mod packaging;
mod curl;
mod on_disk_cache;
mod opts;
mod progress;
mod serialize;
mod verbose;
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
mod flock;
use github::{Github as _, GithubRepo, Impl as Github};
mod repo_status;
use repo_status::RepoStatus;
mod url;
use url::{Url, urls};
const SECS_PER_DAY: u64 = 24 * 60 * 60;
const USER_AGENT: &str = "cargo-unmaintained (github.com/trailofbits/cargo-unmaintained)";
#[derive(Debug, Parser)]
#[clap(bin_name = "cargo", display_name = "cargo")]
struct Cargo {
#[clap(subcommand)]
subcmd: CargoSubCommand,
}
#[derive(Debug, Parser)]
enum CargoSubCommand {
Unmaintained(Opts),
}
include!(concat!(env!("OUT_DIR"), "/after_help.rs"));
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Parser)]
#[remain::sorted]
#[clap(
version = crate_version!(),
about = "Find unmaintained packages in Rust projects",
after_help = AFTER_HELP
)]
struct Opts {
#[clap(
long,
help = "Whether all targets should be considered, rather than just the host"
)]
all_targets: bool,
#[clap(
long,
help = "When to use color: always, auto, or never",
default_value = "auto",
value_name = "WHEN"
)]
color: ColorChoice,
#[clap(
long,
help = "Exit as soon as an unmaintained package is found",
conflicts_with = "no_exit_code"
)]
fail_fast: bool,
#[clap(long, help = "Output JSON (experimental)")]
json: bool,
#[clap(
long,
help = "Age in days that a repository's last commit must not exceed for the repository to \
be considered current; 0 effectively disables this check, though ages are still \
reported",
value_name = "DAYS",
default_value = "365"
)]
max_age: u64,
#[cfg(all(feature = "on-disk-cache", not(windows)))]
#[clap(long, help = "Do not cache data on disk for future runs")]
no_cache: bool,
#[clap(
long,
help = "Do not set exit status when unmaintained packages are found",
conflicts_with = "fail_fast"
)]
no_exit_code: bool,
#[clap(long, help = "Do not show warnings")]
no_warnings: bool,
#[clap(
long,
short,
help = "Check only whether package NAME is unmaintained",
value_name = "NAME"
)]
package: Option<String>,
#[cfg(all(feature = "on-disk-cache", not(windows)))]
#[clap(long, help = "Remove all cached data from disk and exit")]
purge: bool,
#[cfg(not(windows))]
#[clap(
long,
help = "Read a personal access token from standard input and save it to \
$HOME/.config/cargo-unmaintained/token.txt"
)]
save_token: bool,
#[cfg(windows)]
#[clap(
long,
help = "Read a personal access token from standard input and save it to \
%LOCALAPPDATA%\\cargo-unmaintained\\token.txt"
)]
save_token: bool,
#[clap(long, help = "Show paths to unmaintained packages")]
tree: bool,
#[clap(long, help = "Show information about what cargo-unmaintained is doing")]
verbose: bool,
}
struct UnmaintainedPkg<'a> {
pkg: &'a Package,
repo_age: RepoStatus<'a, u64>,
newer_version_is_available: bool,
outdated_deps: Vec<OutdatedDep<'a>>,
}
struct OutdatedDep<'a> {
dep: &'a Dependency,
version_used: &'a Version,
version_latest: Version,
}
#[macro_export]
macro_rules! warn {
($fmt:expr, $($arg:tt)*) => {
if $crate::opts::get().no_warnings {
log::debug!($fmt, $($arg)*);
} else {
$crate::verbose::newline!();
$crate::PROGRESS.with_borrow_mut(|progress| progress.as_mut().map($crate::progress::Progress::newline));
eprintln!(concat!("warning: ", $fmt), $($arg)*);
}
};
}
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
#[allow(clippy::unwrap_used)]
fn create_index() -> GitIndex {
let _lock = lock_index().unwrap();
#[allow(clippy::panic)]
let mut index = match GitIndex::new_cargo_default() {
Ok(index) => index,
Err(error) => {
let mut msg = format!("failed to create index: {error}");
if let Error::MissingHead { repo_path, .. } = &error {
write!(
msg,
"\n\nIf the problem persists, consider deleting this directory: {}",
repo_path.display()
)
.unwrap();
}
panic!("{msg}");
}
};
if let Err(error) = index.update() {
warn!("failed to update index: {}", error);
}
index
}
#[cfg(feature = "tame-index")]
#[allow(clippy::expect_used)]
fn create_index() -> RemoteSparseIndex {
let index_url =
IndexUrl::crates_io(None, None, None).expect("failed to get crates.io index URL");
let index_location = IndexLocation::new(index_url);
let sparse_index = SparseIndex::new(index_location).expect("failed to create sparse index");
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.expect("failed to build HTTP client");
RemoteSparseIndex::new(sparse_index, client)
}
thread_local! {
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
static INDEX: LazyLock<GitIndex> = LazyLock::new(create_index);
#[cfg(feature = "tame-index")]
static INDEX: ManuallyDrop<LazyLock<RemoteSparseIndex>> = ManuallyDrop::new(LazyLock::new(create_index));
static PROGRESS: RefCell<Option<progress::Progress>> = const { RefCell::new(None) };
static GENERAL_STATUS_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, ()>>> = RefCell::new(HashMap::new());
static LATEST_VERSION_CACHE: RefCell<HashMap<String, Version>> = RefCell::new(HashMap::new());
static TIMESTAMP_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, SystemTime>>> = RefCell::new(HashMap::new());
static REPOSITORY_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, PathBuf>>> = RefCell::new(HashMap::new());
}
static TOKEN_FOUND: AtomicBool = AtomicBool::new(false);
pub fn run() -> Result<()> {
env_logger::init();
let Cargo {
subcmd: CargoSubCommand::Unmaintained(opts),
} = Cargo::parse_from(args());
opts::init(opts);
if opts::get().save_token {
return Github::save_token();
}
#[cfg(all(feature = "on-disk-cache", not(windows)))]
if opts::get().purge {
return on_disk_cache::purge_cache();
}
if Github::load_token(|_| Ok(()))? {
TOKEN_FOUND.store(true, Ordering::SeqCst);
}
match unmaintained() {
Ok(false) => exit(0),
Ok(true) => exit(1),
Err(error) => {
eprintln!("Error: {error:?}");
exit(2);
}
}
}
fn unmaintained() -> Result<bool> {
let mut unmaintained_pkgs = Vec::new();
let metadata = metadata()?;
let packages = packages(&metadata)?;
if TOKEN_FOUND.load(Ordering::SeqCst) {
prefetch_github_data(&packages);
}
eprintln!(
"Scanning {} packages and their dependencies{}",
packages.len(),
if opts::get().verbose {
""
} else {
" (pass --verbose for more information)"
}
);
if std::io::stderr().is_terminal() && !opts::get().verbose {
let width_pkg = packages.iter().map(|pkg| pkg.name.len()).max().unwrap_or(0);
PROGRESS.with_borrow_mut(|progress| {
let mut p = progress::Progress::new(packages.len());
p.set_width_msg(width_pkg);
*progress = Some(p);
});
}
for pkg in packages {
PROGRESS.with_borrow_mut(|progress| {
if let Some(progress) = progress.as_mut() {
progress.advance(&pkg.name);
}
});
if let Some(mut unmaintained_pkg) = is_unmaintained_package(&metadata, pkg)? {
let newer_version_is_available = newer_version_is_available(pkg)?;
if !newer_version_is_available || latest_version_is_unmaintained(&pkg.name)? {
unmaintained_pkg.newer_version_is_available = newer_version_is_available;
unmaintained_pkgs.push(unmaintained_pkg);
if opts::get().fail_fast {
break;
}
PROGRESS.with_borrow_mut(|progress| {
if let Some(progress) = progress.as_mut() {
let count = unmaintained_pkgs.len();
progress
.set_suffix(Some(format!(" ({count} unmaintained packages found)")));
}
});
}
}
}
PROGRESS.with_borrow_mut(|progress| {
if let Some(progress) = progress.as_mut() {
progress.finish();
}
});
if opts::get().json {
unmaintained_pkgs.sort_by_key(|unmaintained| &unmaintained.pkg.id);
let json = serde_json::to_string_pretty(&unmaintained_pkgs)?;
println!("{json}");
} else {
if unmaintained_pkgs.is_empty() {
eprintln!("No unmaintained packages found");
return Ok(false);
}
unmaintained_pkgs.sort_by_key(|unmaintained| unmaintained.repo_age.erase_url());
display_unmaintained_pkgs(&unmaintained_pkgs)?;
}
Ok(!opts::get().no_exit_code)
}
fn metadata() -> Result<Metadata> {
let mut command = metadata_command();
let tempdir: TempDir;
if let Some(name) = &opts::get().package {
tempdir = packaging::temp_package(name)?;
command.current_dir(tempdir.path());
}
command.exec().map_err(Into::into)
}
fn packages(metadata: &Metadata) -> Result<Vec<&Package>> {
let ignored_packages = ignored_packages(metadata)?;
for name in &ignored_packages {
if !metadata.packages.iter().any(|pkg| pkg.name == *name) {
warn!(
"workspace metadata says to ignore `{}`, but workspace does not depend upon `{}`",
name, name
);
}
}
filter_packages(metadata, &ignored_packages)
}
#[derive(serde::Deserialize)]
struct UnmaintainedMetadata {
ignore: Option<Vec<String>>,
}
fn ignored_packages(metadata: &Metadata) -> Result<HashSet<String>> {
let serde_json::Value::Object(object) = &metadata.workspace_metadata else {
return Ok(HashSet::default());
};
let Some(value) = object.get("unmaintained") else {
return Ok(HashSet::default());
};
let metadata = serde_json::value::from_value::<UnmaintainedMetadata>(value.clone())?;
Ok(metadata.ignore.unwrap_or_default().into_iter().collect())
}
fn filter_packages<'a>(
metadata: &'a Metadata,
ignored_packages: &HashSet<String>,
) -> Result<Vec<&'a Package>> {
let mut packages = Vec::new();
let metadata_latest_version_map = build_metadata_latest_version_map(metadata);
for pkg in &metadata.packages {
if metadata.workspace_members.contains(&pkg.id) {
continue;
}
if ignored_packages.contains(pkg.name.as_str()) {
continue;
}
#[allow(clippy::panic)]
let version = metadata_latest_version_map
.get(&pkg.name)
.unwrap_or_else(|| {
panic!(
"`metadata_latest_version_map` does not contain {}",
pkg.name
)
});
if pkg.version != *version {
continue;
}
if let Some(name) = &opts::get().package
&& pkg.name != *name
{
continue;
}
packages.push(pkg);
}
if let Some(name) = &opts::get().package {
if packages.len() >= 2 {
bail!("found multiple packages matching `{name}`: {packages:#?}");
}
if packages.is_empty() {
bail!("found no packages matching `{name}`");
}
}
Ok(packages)
}
fn build_metadata_latest_version_map(metadata: &Metadata) -> HashMap<PackageName, Version> {
let mut map: HashMap<PackageName, Version> = HashMap::new();
for pkg in &metadata.packages {
if let Some(version) = map.get_mut(&pkg.name) {
if *version < pkg.version {
*version = pkg.version.clone();
}
} else {
map.insert(pkg.name.clone(), pkg.version.clone());
}
}
map
}
fn prefetch_github_data(packages: &[&Package]) {
let mut repo_aliases = HashMap::new();
let mut seen_repos = HashSet::new();
let mut repos = Vec::new();
for pkg in packages {
let Some(github_repo) = github_repo_for_package(pkg) else {
continue;
};
let key = format!("{}/{}", github_repo.owner, github_repo.repo);
let aliases = repo_aliases.entry(key.clone()).or_insert_with(Vec::new);
for alias in urls(pkg) {
push_repo_alias(aliases, alias);
}
push_repo_alias(aliases, github_repo.url);
if seen_repos.insert(key) {
repos.push(github_repo);
}
}
if repos.is_empty() {
return;
}
eprintln!("Prefetching GitHub data for {} repositories", repos.len());
let repo_statuses = match Github::prefetch(&repos) {
Ok(repo_statuses) => repo_statuses,
Err(error) => {
warn!(
"failed to prefetch Github data; falling back to per-package queries: {}",
error
);
return;
}
};
for repo_status in repo_statuses {
let Some(url) = repo_status_url(&repo_status) else {
continue;
};
let Some(github_repo) = GithubRepo::from_url(url) else {
continue;
};
let key = format!("{}/{}", github_repo.owner, github_repo.repo);
if let Some(aliases) = repo_aliases.get(&key) {
for &alias in aliases {
cache_prefetched_github_data(repo_status, alias);
}
} else {
cache_prefetched_github_data(repo_status, url);
}
}
}
fn github_repo_for_package(pkg: &Package) -> Option<GithubRepo<'_>> {
let url_string = pkg.repository.as_ref()?;
let url = Url::from(url_string.as_str()).trim_trailing_slash();
GithubRepo::from_url(url)
}
fn push_repo_alias<'a>(aliases: &mut Vec<Url<'a>>, alias: Url<'a>) {
if !aliases.contains(&alias) {
aliases.push(alias);
}
}
fn repo_status_url<'a, T>(repo_status: &RepoStatus<'a, T>) -> Option<Url<'a>> {
match repo_status {
RepoStatus::Uncloneable(url)
| RepoStatus::Success(url, _)
| RepoStatus::Unassociated(url)
| RepoStatus::Nonexistent(url)
| RepoStatus::Archived(url) => Some(*url),
RepoStatus::Unnamed => None,
}
}
fn cache_prefetched_github_data(repo_status: RepoStatus<'_, SystemTime>, url: Url<'_>) {
match repo_status {
RepoStatus::Success(_, timestamp) => {
let url = url.leak();
GENERAL_STATUS_CACHE
.with_borrow_mut(|cache| cache.insert(url, RepoStatus::Success(url, ())));
TIMESTAMP_CACHE.with_borrow_mut(|cache| {
let url = GithubRepo::from_url(url).map_or(url, |github_repo| github_repo.url);
cache.insert(url, RepoStatus::Success(url, timestamp));
});
}
RepoStatus::Archived(_) => {
let url = url.leak();
GENERAL_STATUS_CACHE
.with_borrow_mut(|cache| cache.insert(url, RepoStatus::Archived(url)));
}
RepoStatus::Nonexistent(_) => {
let url = url.leak();
GENERAL_STATUS_CACHE
.with_borrow_mut(|cache| cache.insert(url, RepoStatus::Nonexistent(url)));
}
RepoStatus::Uncloneable(_) | RepoStatus::Unnamed | RepoStatus::Unassociated(_) => {}
}
}
fn newer_version_is_available(pkg: &Package) -> Result<bool> {
if pkg
.source
.as_ref()
.is_none_or(|source| !source.is_crates_io())
{
return Ok(false);
}
let latest_version = latest_version(&pkg.name)?;
Ok(pkg.version != latest_version)
}
fn latest_version_is_unmaintained(name: &str) -> Result<bool> {
let tempdir = packaging::temp_package(name)?;
let metadata = metadata_command().current_dir(tempdir.path()).exec()?;
#[allow(clippy::panic)]
let pkg = metadata
.packages
.iter()
.find(|pkg| name == pkg.name.as_str())
.unwrap_or_else(|| panic!("failed to find package `{name}`"));
let unmaintained_package = is_unmaintained_package(&metadata, pkg)?;
Ok(unmaintained_package.is_some())
}
fn metadata_command() -> MetadataCommand {
let mut command = MetadataCommand::new();
if !opts::get().all_targets {
command.other_options(["--filter-platform".to_owned(), HOST.clone()]);
}
command
}
#[allow(clippy::panic, clippy::unwrap_used)]
static HOST: LazyLock<String> = LazyLock::new(|| {
let mut command = Command::new("rustc");
command.arg("-Vv");
let output = command.output_wc().unwrap();
assert!(output.status.success(), "command failed: {command:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
stdout
.lines()
.find_map(|line| line.strip_prefix("host: "))
.map_or_else(|| panic!("failed to determine host"), str::to_owned)
});
fn is_unmaintained_package<'a>(
metadata: &'a Metadata,
pkg: &'a Package,
) -> Result<Option<UnmaintainedPkg<'a>>> {
if let Some(url_string) = &pkg.repository {
let can_use_github_api =
TOKEN_FOUND.load(Ordering::SeqCst) && url_string.starts_with("https://github.com/");
let url = url_string.as_str().into();
if can_use_github_api {
let repo_status = general_status(&pkg.name, url)?;
if repo_status.is_failure() {
return Ok(Some(UnmaintainedPkg {
pkg,
repo_age: repo_status.map_failure(),
newer_version_is_available: false,
outdated_deps: Vec::new(),
}));
}
}
let repo_status = clone_repository(pkg)?;
if repo_status.is_failure() {
if matches!(repo_status, RepoStatus::Uncloneable(_)) && curl::is_mercurial_repo(url)? {
return Ok(None);
}
return Ok(Some(UnmaintainedPkg {
pkg,
repo_age: repo_status.map_failure(),
newer_version_is_available: false,
outdated_deps: Vec::new(),
}));
}
}
let outdated_deps = outdated_deps(metadata, pkg)?;
if outdated_deps.is_empty() {
return Ok(None);
}
let repo_age = latest_commit_age(pkg)?;
if repo_age
.as_success()
.is_some_and(|(_, &age)| age < opts::get().max_age * SECS_PER_DAY)
{
return Ok(None);
}
Ok(Some(UnmaintainedPkg {
pkg,
repo_age,
newer_version_is_available: false,
outdated_deps,
}))
}
fn general_status(name: &str, url: Url) -> Result<RepoStatus<'static, ()>> {
GENERAL_STATUS_CACHE.with_borrow_mut(|general_status_cache| {
if let Some(&value) = general_status_cache.get(&url) {
return Ok(value);
}
let to_string: &dyn Fn(&RepoStatus<'static, ()>) -> String;
let (use_github_api, what, how) = if TOKEN_FOUND.load(Ordering::SeqCst)
&& url.as_str().starts_with("https://github.com/")
{
to_string = &RepoStatus::to_archival_status_string;
(true, "archival status", "GitHub API")
} else {
to_string = &RepoStatus::to_existence_string;
(false, "existence", "HTTP request")
};
verbose::wrap!(
|| {
let repo_status = if use_github_api {
Github::archival_status(url)
} else {
curl::existence(url)
}
.unwrap_or_else(|error| {
warn!("failed to determine `{}` {}: {}", name, what, error);
RepoStatus::Success(url, ())
})
.leak_url();
general_status_cache.insert(url.leak(), repo_status);
Ok(repo_status)
},
to_string,
"{} of `{}` using {}",
what,
name,
how
)
})
}
#[allow(clippy::unnecessary_wraps)]
fn outdated_deps<'a>(metadata: &'a Metadata, pkg: &'a Package) -> Result<Vec<OutdatedDep<'a>>> {
if !published(pkg) {
return Ok(Vec::new());
}
let mut deps = Vec::new();
for dep in &pkg.dependencies {
if dep.registry.is_some() {
continue;
}
if dep.path.is_some() {
continue;
}
if dep.kind == DependencyKind::Development {
continue;
}
let Some(dep_pkg) = resolved_package(metadata, pkg, dep) else {
debug_assert!(
dep.optional || (!opts::get().all_targets && dep.target.is_some()),
"failed for: {dep:?}"
);
continue;
};
let Ok(version_latest) = latest_version(&dep.name).map_err(|error| {
warn!("failed to get latest version of `{}`: {}", dep.name, error);
}) else {
continue;
};
if dep_pkg.version <= version_latest && !dep.req.matches(&version_latest) {
let versions = versions(&dep_pkg.name)?;
if versions
.iter()
.try_fold(false, |init, version| -> Result<_> {
if init {
return Ok(true);
}
let duration =
SystemTime::now().duration_since_wc(version.created_at.into())?;
let version_num = Version::parse(&version.num)?;
Ok(duration.as_secs() >= opts::get().max_age * SECS_PER_DAY
&& dep_pkg.version <= version_num
&& !dep.req.matches(&version_num))
})?
{
deps.push(OutdatedDep {
dep,
version_used: &dep_pkg.version,
version_latest,
});
}
}
}
deps.dedup_by(|lhs, rhs| lhs.dep.name == rhs.dep.name && lhs.dep.req == rhs.dep.req);
Ok(deps)
}
fn published(pkg: &Package) -> bool {
pkg.publish.as_deref() != Some(&[])
}
fn resolved_package<'a>(
metadata: &'a Metadata,
pkg: &Package,
dep: &Dependency,
) -> Option<&'a Package> {
let resolve = metadata.resolve.as_ref()?;
let node = resolve.nodes.iter().find(|node| pkg.id == node.id)?;
node.deps
.iter()
.filter(|node_dep| {
node_dep
.dep_kinds
.iter()
.any(|dep_kind| dep.kind == dep_kind.kind && dep.target == dep_kind.target)
})
.find_map(|node_dep| {
metadata.packages.iter().find(|dep_pkg| {
node_dep.pkg == dep_pkg.id
&& dep.name.as_str() == dep_pkg.name.as_str()
&& dep.req.matches(&dep_pkg.version)
})
})
}
fn latest_version(name: &str) -> Result<Version> {
LATEST_VERSION_CACHE.with_borrow_mut(|latest_version_cache| {
if let Some(version) = latest_version_cache.get(name) {
return Ok(version.clone());
}
verbose::wrap!(
|| {
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
let latest_version = {
let krate = INDEX.with(|index| {
let _ = LazyLock::force(index);
let _lock = lock_index()?;
index
.crate_(name)
.ok_or_else(|| anyhow!("failed to find `{name}` in index"))
})?;
let latest_version_index = krate
.highest_normal_version()
.ok_or_else(|| anyhow!("`{name}` has no normal version"))?;
Version::from_str(latest_version_index.version())?
};
#[cfg(feature = "tame-index")]
let latest_version = {
let krate_name = KrateName::crates_io(name)
.map_err(|e| anyhow!("invalid crate name `{name}`: {e}"))?;
let lock_options = LockOptions::cargo_package_lock(None)
.map_err(|e| anyhow!("failed to create cargo package lock: {e}"))?;
let lock = lock_options
.lock(|_| None)
.map_err(|e| anyhow!("failed to acquire cargo package lock: {e}"))?;
let krate = INDEX.with(|index| {
let _ = LazyLock::force(index);
let krate = index
.krate(krate_name, true, &lock)
.map_err(|e| anyhow!("failed to fetch `{name}` from index: {e}"))?;
krate.ok_or_else(|| anyhow!("failed to find `{name}` in index"))
})?;
let latest_version_index = krate
.highest_normal_version()
.ok_or_else(|| anyhow!("`{name}` has no normal version"))?;
Version::from_str(&latest_version_index.version)?
};
latest_version_cache.insert(name.to_owned(), latest_version.clone());
Ok(latest_version)
},
ToString::to_string,
"latest version of `{}` using crates.io index",
name
)
})
}
fn versions(name: &str) -> Result<Vec<crates_io_api::Version>> {
on_disk_cache::with_cache(|cache| -> Result<_> {
verbose::wrap!(
|| { cache.fetch_versions(name) },
|versions: &[crates_io_api::Version]| format!("{} versions", versions.len()),
"versions of `{}` using crates.io API",
name
)
})
}
fn latest_commit_age(pkg: &Package) -> Result<RepoStatus<'_, u64>> {
let repo_status = timestamp(pkg)?;
repo_status
.map(|timestamp| {
let duration = SystemTime::now().duration_since_wc(timestamp)?;
Ok(duration.as_secs())
})
.transpose()
}
fn timestamp(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
TIMESTAMP_CACHE.with_borrow_mut(|timestamp_cache| {
for url in urls(pkg) {
if let Some(&repo_status) = timestamp_cache.get(&url) {
let Some((url_timestamped, ×tamp)) = repo_status.as_success() else {
return Ok(repo_status);
};
assert_eq!(url, url_timestamped);
let repo_status = clone_repository(pkg)?;
let Some((url_cloned, _)) = repo_status.as_success() else {
return Ok(repo_status.map_failure());
};
assert_eq!(url, url_cloned);
return Ok(RepoStatus::Success(url, timestamp));
}
}
let repo_status = timestamp_uncached(pkg)?;
if let Some((url, _)) = repo_status.as_success() {
timestamp_cache.insert(url.leak(), repo_status.leak_url());
} else {
for url in urls(pkg) {
timestamp_cache.insert(url.leak(), repo_status.leak_url());
}
}
Ok(repo_status)
})
}
fn timestamp_uncached(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
if pkg.repository.is_none() {
return Ok(RepoStatus::Unnamed);
}
timestamp_from_clone(pkg)
}
fn timestamp_from_clone(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
let repo_status = clone_repository(pkg)?;
let Some((url, repo_dir)) = repo_status.as_success() else {
return Ok(repo_status.map_failure());
};
let mut command = Command::new("git");
command
.args(["log", "-1", "--pretty=format:%ct"])
.current_dir(repo_dir);
let output = command.output_wc()?;
ensure!(output.status.success(), "command failed: {command:?}");
let stdout = std::str::from_utf8(&output.stdout)?;
let secs = u64::from_str(stdout.trim_end())?;
let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
Ok(RepoStatus::Success(url, timestamp))
}
#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_unhandled_error))]
#[cfg_attr(dylint_lib = "supplementary", allow(commented_out_code))]
fn clone_repository(pkg: &Package) -> Result<RepoStatus<'_, PathBuf>> {
let repo_status = REPOSITORY_CACHE.with_borrow_mut(|repository_cache| -> Result<_> {
on_disk_cache::with_cache(|cache| -> Result<_> {
for url in urls(pkg) {
if let Some(repo_status) = repository_cache.get(&url) {
return Ok(repo_status.clone());
}
}
verbose::wrap!(
|| {
let url_and_dir = cache.clone_repository(pkg);
match url_and_dir {
Ok((url_string, repo_dir)) => {
let url = Url::from(url_string.as_str()).leak();
repository_cache
.insert(url, RepoStatus::Success(url, repo_dir.clone()).leak_url());
Ok(RepoStatus::Success(url, repo_dir))
}
Err(error) => {
let repo_status = if let Some(url_string) = &pkg.repository {
let url = url_string.as_str().into();
let existence = general_status(&pkg.name, url)?;
let repo_status = if existence.is_failure() {
existence.map_failure()
} else {
RepoStatus::Uncloneable(url)
};
warn!("failed to clone `{}`: {}", url_string, error);
repo_status
} else {
RepoStatus::Unnamed
};
for url in urls(pkg) {
repository_cache.insert(url.leak(), repo_status.clone().leak_url());
}
Ok(repo_status)
}
}
},
RepoStatus::to_membership_string,
"membership of `{}` using shallow clone",
pkg.name
)
})
})?;
let Some((url, repo_dir)) = repo_status.as_success() else {
return Ok(repo_status);
};
if membership_in_clone(pkg, repo_dir)? {
Ok(repo_status)
} else {
Ok(RepoStatus::Unassociated(url))
}
}
const LINE_PREFIX: &str = "D ";
fn membership_in_clone(pkg: &Package, repo_dir: &Path) -> Result<bool> {
let mut command = Command::new("git");
command.args(["status", "--porcelain"]);
command.current_dir(repo_dir);
command.stdout(Stdio::piped());
let mut child = command.spawn_wc()?;
#[allow(clippy::unwrap_used)]
let stdout = child.stdout.take().unwrap();
let reader = std::io::BufReader::new(stdout);
for result in reader.lines() {
let line = result.with_context(|| format!("failed to read `{}`", repo_dir.display()))?;
#[allow(clippy::panic)]
let path = line.strip_prefix(LINE_PREFIX).map_or_else(
|| panic!("cache is corrupt at `{}`", repo_dir.display()),
Path::new,
);
let filename = path.file_name_wc();
if filename.is_err() || filename.is_ok_and(|s| s != OsStr::new("Cargo.toml")) {
continue;
}
let contents = show(repo_dir, path)?;
let Ok(table) = contents.parse::<Table>()
else {
continue;
};
if table
.get("package")
.and_then(Value::as_table)
.and_then(|table| table.get("name"))
.and_then(Value::as_str)
== Some(&pkg.name)
{
return Ok(true);
}
}
Ok(false)
}
fn show(repo_dir: &Path, path: &Path) -> Result<String> {
let mut command = Command::new("git");
command.args(["show", &format!("HEAD:{}", path.display())]);
command.current_dir(repo_dir);
command.stdout(Stdio::piped());
let output = command.output_wc()?;
if !output.status.success() {
let error = String::from_utf8(output.stderr)?;
bail!(
"failed to read `{}` in `{}`: {}",
path.display(),
repo_dir.display(),
error
);
}
String::from_utf8(output.stdout).map_err(Into::into)
}
fn display_unmaintained_pkgs(unmaintained_pkgs: &[UnmaintainedPkg]) -> Result<()> {
let mut pkgs_needing_warning = Vec::new();
let mut at_least_one_newer_version_is_available = false;
for unmaintained_pkg in unmaintained_pkgs {
at_least_one_newer_version_is_available |= unmaintained_pkg.newer_version_is_available;
if display_unmaintained_pkg(unmaintained_pkg)? {
pkgs_needing_warning.push(unmaintained_pkg.pkg);
}
}
if at_least_one_newer_version_is_available {
println!(
"\n* a newer (though still seemingly unmaintained) version of the package is available"
);
}
if !pkgs_needing_warning.is_empty() {
warn!(
"the following packages' paths could not be printed:{}",
pkgs_needing_warning
.into_iter()
.map(|pkg| format!("\n {}@{}", pkg.name, pkg.version))
.collect::<String>()
);
}
Ok(())
}
#[cfg_attr(dylint_lib = "try_io_result", allow(try_io_result))]
fn display_unmaintained_pkg(unmaintained_pkg: &UnmaintainedPkg) -> Result<bool> {
use std::io::Write;
let mut stdout = StandardStream::stdout(opts::get().color);
let UnmaintainedPkg {
pkg,
repo_age,
newer_version_is_available,
outdated_deps,
} = unmaintained_pkg;
stdout.set_color(ColorSpec::new().set_fg(repo_age.color()))?;
write!(stdout, "{}", pkg.name)?;
stdout.set_color(ColorSpec::new().set_fg(None))?;
write!(stdout, " (")?;
repo_age.write(&mut stdout)?;
write!(stdout, ")")?;
if *newer_version_is_available {
write!(stdout, "*")?;
}
writeln!(stdout)?;
for OutdatedDep {
dep,
version_used,
version_latest,
} in outdated_deps
{
println!(
" {} (requirement: {}, version used: {}, latest: {})",
dep.name, dep.req, version_used, version_latest
);
}
if opts::get().tree {
let need_warning = display_path(&pkg.name, &pkg.version)?;
println!();
Ok(need_warning)
} else {
Ok(false)
}
}
fn display_path(name: &str, version: &Version) -> Result<bool> {
let spec = format!("{name}@{version}");
let mut command = Command::new("cargo");
command.args(["tree", "--workspace"]);
if opts::get().all_targets {
command.arg("--target=all");
} else {
command.args(["--target", HOST.as_str()]);
}
command.args(["--invert", &spec]);
let output = command.output_wc()?;
let stdout = String::from_utf8(output.stdout)?;
if stdout.split_ascii_whitespace().take(2).collect::<Vec<_>>() == [name, &format!("v{version}")]
{
print!("{stdout}");
Ok(false)
} else {
Ok(true)
}
}
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
static INDEX_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
let cargo_home = cargo_home().unwrap();
cargo_home.join("registry/index")
});
#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
fn lock_index() -> Result<File> {
flock::lock_path(&INDEX_PATH)
.with_context(|| format!("failed to lock `{}`", INDEX_PATH.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use assert_cmd::prelude::*;
use clap::CommandFactory;
#[test]
fn verify_cli() {
Opts::command().debug_assert();
}
#[cfg_attr(dylint_lib = "general", allow(non_thread_safe_call_in_test))]
#[test]
fn usage() {
std::process::Command::new("cargo")
.args([
"run",
"--bin=cargo-unmaintained",
"--",
"unmaintained",
"--help",
])
.assert()
.success()
.stdout(predicates::str::contains("Usage: cargo unmaintained"));
}
#[cfg_attr(dylint_lib = "general", allow(non_thread_safe_call_in_test))]
#[test]
fn version() {
std::process::Command::new("cargo")
.args([
"run",
"--bin=cargo-unmaintained",
"--",
"unmaintained",
"--version",
])
.assert()
.success()
.stdout(concat!(
"cargo-unmaintained ",
env!("CARGO_PKG_VERSION"),
"\n"
));
}
#[test]
fn repo_status_ord() {
let ys = vec![
RepoStatus::Uncloneable("f".into()),
RepoStatus::Unnamed,
RepoStatus::Success("e".into(), 0),
RepoStatus::Success("d".into(), 1),
RepoStatus::Unassociated("c".into()),
RepoStatus::Nonexistent("b".into()),
RepoStatus::Archived("a".into()),
];
let mut xs = ys.clone();
xs.sort_by_key(|repo_status| repo_status.erase_url());
assert_eq!(xs, ys);
}
}