1#![deny(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
9#![cfg_attr(dylint_lib = "general", allow(crate_wide_allow))]
10#![cfg_attr(dylint_lib = "supplementary", allow(nonexistent_path_in_comment))]
11
12#[cfg(not(any(feature = "crates-index", feature = "tame-index")))]
13compile_error!("At least one of the `crates-index` or `tame-index` features must be enabled");
14
15use anyhow::{Context, Result, anyhow, bail, ensure};
16use cargo_metadata::{
17 Dependency, DependencyKind, Metadata, MetadataCommand, Package, PackageName,
18 semver::{Version, VersionReq},
19};
20use clap::{Parser, crate_version};
21#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
22use crates_index::{Error, GitIndex};
23use elaborate::std::{path::PathContext, process::CommandContext, time::SystemTimeContext};
24#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
25use home::cargo_home;
26#[cfg(feature = "tame-index")]
27use std::mem::ManuallyDrop;
28use std::{
29 cell::RefCell,
30 collections::{HashMap, HashSet},
31 env::args,
32 ffi::OsStr,
33 io::{BufRead, IsTerminal},
34 path::{Path, PathBuf},
35 process::{Command, Stdio, exit},
36 str::FromStr,
37 sync::{
38 LazyLock,
39 atomic::{AtomicBool, Ordering},
40 },
41 time::{Duration, SystemTime},
42};
43#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
44use std::{fmt::Write, fs::File};
45#[cfg(feature = "tame-index")]
46use tame_index::{
47 IndexLocation, KrateName,
48 index::{IndexUrl, RemoteSparseIndex, sparse::SparseIndex},
49 utils::flock::LockOptions,
50};
51use tempfile::TempDir;
52use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor};
53use toml::{Table, Value};
54
55pub mod flush;
56pub mod github;
57pub mod packaging;
58
59mod curl;
60mod on_disk_cache;
61mod opts;
62mod progress;
63mod serialize;
64mod verbose;
65
66#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
67mod flock;
68
69use github::{Github as _, GithubRepo, Impl as Github};
70
71mod repo_status;
72use repo_status::RepoStatus;
73
74mod url;
75use url::{Url, urls};
76
77const SECS_PER_DAY: u64 = 24 * 60 * 60;
78
79const USER_AGENT: &str = "cargo-unmaintained (github.com/trailofbits/cargo-unmaintained)";
80
81#[derive(Debug, Parser)]
82#[clap(bin_name = "cargo", display_name = "cargo")]
83struct Cargo {
84 #[clap(subcommand)]
85 subcmd: CargoSubCommand,
86}
87
88#[derive(Debug, Parser)]
89enum CargoSubCommand {
90 Unmaintained(Opts),
91}
92
93include!(concat!(env!("OUT_DIR"), "/after_help.rs"));
94
95#[allow(clippy::struct_excessive_bools)]
96#[derive(Debug, Parser)]
97#[remain::sorted]
98#[clap(
99 version = crate_version!(),
100 about = "Find unmaintained packages in Rust projects",
101 after_help = AFTER_HELP
102)]
103struct Opts {
104 #[clap(
105 long,
106 help = "Whether all targets should be considered, rather than just the host"
107 )]
108 all_targets: bool,
109
110 #[clap(
111 long,
112 help = "When to use color: always, auto, or never",
113 default_value = "auto",
114 value_name = "WHEN"
115 )]
116 color: ColorChoice,
117
118 #[clap(
119 long,
120 help = "Exit as soon as an unmaintained package is found",
121 conflicts_with = "no_exit_code"
122 )]
123 fail_fast: bool,
124
125 #[clap(long, help = "Output JSON (experimental)")]
126 json: bool,
127
128 #[clap(
129 long,
130 help = "Age in days that a repository's last commit must not exceed for the repository to \
131 be considered current; 0 effectively disables this check, though ages are still \
132 reported",
133 value_name = "DAYS",
134 default_value = "365"
135 )]
136 max_age: u64,
137
138 #[cfg(all(feature = "on-disk-cache", not(windows)))]
139 #[clap(long, help = "Do not cache data on disk for future runs")]
140 no_cache: bool,
141
142 #[clap(
143 long,
144 help = "Do not set exit status when unmaintained packages are found",
145 conflicts_with = "fail_fast"
146 )]
147 no_exit_code: bool,
148
149 #[clap(long, help = "Do not show warnings")]
150 no_warnings: bool,
151
152 #[clap(
153 long,
154 short,
155 help = "Check only whether package NAME is unmaintained",
156 value_name = "NAME"
157 )]
158 package: Option<String>,
159
160 #[cfg(all(feature = "on-disk-cache", not(windows)))]
161 #[clap(long, help = "Remove all cached data from disk and exit")]
162 purge: bool,
163
164 #[cfg(not(windows))]
165 #[clap(
166 long,
167 help = "Read a personal access token from standard input and save it to \
168 $HOME/.config/cargo-unmaintained/token.txt"
169 )]
170 save_token: bool,
171
172 #[cfg(windows)]
173 #[clap(
174 long,
175 help = "Read a personal access token from standard input and save it to \
176 %LOCALAPPDATA%\\cargo-unmaintained\\token.txt"
177 )]
178 save_token: bool,
179
180 #[clap(long, help = "Show paths to unmaintained packages")]
181 tree: bool,
182
183 #[clap(long, help = "Show information about what cargo-unmaintained is doing")]
184 verbose: bool,
185}
186
187struct UnmaintainedPkg<'a> {
188 pkg: &'a Package,
189 repo_age: RepoStatus<'a, u64>,
190 newer_version_is_available: bool,
191 outdated_deps: Vec<OutdatedDep<'a>>,
192}
193
194struct OutdatedDep<'a> {
195 dep: &'a Dependency,
196 version_used: &'a Version,
197 version_latest: Version,
198}
199
200struct DepReq<'a> {
201 name: &'a str,
202 req: VersionReq,
203}
204
205impl<'a> DepReq<'a> {
206 #[allow(dead_code)]
207 fn new(name: &'a str, req: VersionReq) -> Self {
208 Self { name, req }
209 }
210
211 fn matches(&self, pkg: &Package) -> bool {
212 self.name == pkg.name.as_str() && self.req.matches(&pkg.version)
213 }
214}
215
216impl<'a> From<&'a Dependency> for DepReq<'a> {
217 fn from(value: &'a Dependency) -> Self {
218 Self {
219 name: &value.name,
220 req: value.req.clone(),
221 }
222 }
223}
224
225#[macro_export]
226macro_rules! warn {
227 ($fmt:expr, $($arg:tt)*) => {
228 if $crate::opts::get().no_warnings {
229 log::debug!($fmt, $($arg)*);
230 } else {
231 $crate::verbose::newline!();
232 $crate::PROGRESS.with_borrow_mut(|progress| progress.as_mut().map($crate::progress::Progress::newline));
233 eprintln!(concat!("warning: ", $fmt), $($arg)*);
234 }
235 };
236}
237
238#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
239#[allow(clippy::unwrap_used)]
240fn create_index() -> GitIndex {
241 let _lock = lock_index().unwrap();
242 #[allow(clippy::panic)]
243 let mut index = match GitIndex::new_cargo_default() {
244 Ok(index) => index,
245 Err(error) => {
246 let mut msg = format!("failed to create index: {error}");
247 if let Error::MissingHead { repo_path, .. } = &error {
251 write!(
252 msg,
253 "\n\nIf the problem persists, consider deleting this directory: {}",
254 repo_path.display()
255 )
256 .unwrap();
257 }
258 panic!("{msg}");
259 }
260 };
261 if let Err(error) = index.update() {
262 warn!("failed to update index: {}", error);
263 }
264 index
265}
266
267#[cfg(feature = "tame-index")]
268#[allow(clippy::expect_used)]
269fn create_index() -> RemoteSparseIndex {
270 let index_url =
271 IndexUrl::crates_io(None, None, None).expect("failed to get crates.io index URL");
272 let index_location = IndexLocation::new(index_url);
273 let sparse_index = SparseIndex::new(index_location).expect("failed to create sparse index");
274 let client = reqwest::blocking::Client::builder()
275 .user_agent(USER_AGENT)
276 .build()
277 .expect("failed to build HTTP client");
278 RemoteSparseIndex::new(sparse_index, client)
279}
280
281thread_local! {
282 #[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
283 static INDEX: LazyLock<GitIndex> = LazyLock::new(create_index);
284 #[cfg(feature = "tame-index")]
288 static INDEX: ManuallyDrop<LazyLock<RemoteSparseIndex>> = ManuallyDrop::new(LazyLock::new(create_index));
289 static PROGRESS: RefCell<Option<progress::Progress>> = const { RefCell::new(None) };
290 static GENERAL_STATUS_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, ()>>> = RefCell::new(HashMap::new());
297 static LATEST_VERSION_CACHE: RefCell<HashMap<String, Version>> = RefCell::new(HashMap::new());
298 static TIMESTAMP_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, SystemTime>>> = RefCell::new(HashMap::new());
299 static REPOSITORY_CACHE: RefCell<HashMap<Url<'static>, RepoStatus<'static, PathBuf>>> = RefCell::new(HashMap::new());
300}
301
302static TOKEN_FOUND: AtomicBool = AtomicBool::new(false);
303
304pub fn run() -> Result<()> {
305 env_logger::init();
306
307 let Cargo {
308 subcmd: CargoSubCommand::Unmaintained(opts),
309 } = Cargo::parse_from(args());
310
311 opts::init(opts);
312
313 if opts::get().save_token {
314 return Github::save_token();
317 }
318
319 #[cfg(all(feature = "on-disk-cache", not(windows)))]
320 if opts::get().purge {
321 return on_disk_cache::purge_cache();
322 }
323
324 if Github::load_token(|_| Ok(()))? {
325 TOKEN_FOUND.store(true, Ordering::SeqCst);
326 }
327
328 match unmaintained() {
329 Ok(false) => exit(0),
330 Ok(true) => exit(1),
331 Err(error) => {
332 eprintln!("Error: {error:?}");
333 exit(2);
334 }
335 }
336}
337
338fn unmaintained() -> Result<bool> {
339 let mut unmaintained_pkgs = Vec::new();
340
341 let metadata = metadata()?;
342
343 let packages = packages(&metadata)?;
344
345 if TOKEN_FOUND.load(Ordering::SeqCst) {
346 prefetch_github_data(&packages);
347 }
348
349 eprintln!(
350 "Scanning {} packages and their dependencies{}",
351 packages.len(),
352 if opts::get().verbose {
353 ""
354 } else {
355 " (pass --verbose for more information)"
356 }
357 );
358
359 if std::io::stderr().is_terminal() && !opts::get().verbose {
360 PROGRESS
361 .with_borrow_mut(|progress| *progress = Some(progress::Progress::new(packages.len())));
362 }
363
364 for pkg in packages {
365 PROGRESS.with_borrow_mut(|progress| {
366 progress
367 .as_mut()
368 .map_or(Ok(()), |progress| progress.advance(&pkg.name))
369 })?;
370
371 if let Some(mut unmaintained_pkg) = is_unmaintained_package(&metadata, pkg)? {
372 let newer_version_is_available = newer_version_is_available(pkg)?;
376 if !newer_version_is_available || latest_version_is_unmaintained(&pkg.name)? {
377 unmaintained_pkg.newer_version_is_available = newer_version_is_available;
378 unmaintained_pkgs.push(unmaintained_pkg);
379
380 if opts::get().fail_fast {
381 break;
382 }
383 }
384 }
385 }
386
387 PROGRESS
388 .with_borrow_mut(|progress| progress.as_mut().map_or(Ok(()), progress::Progress::finish))?;
389
390 if opts::get().json {
391 unmaintained_pkgs.sort_by_key(|unmaintained| &unmaintained.pkg.id);
392
393 let json = serde_json::to_string_pretty(&unmaintained_pkgs)?;
394
395 println!("{json}");
396 } else {
397 if unmaintained_pkgs.is_empty() {
398 eprintln!("No unmaintained packages found");
399 return Ok(false);
400 }
401
402 unmaintained_pkgs.sort_by_key(|unmaintained| unmaintained.repo_age.erase_url());
403
404 display_unmaintained_pkgs(&unmaintained_pkgs)?;
405 }
406
407 Ok(!opts::get().no_exit_code)
408}
409
410fn metadata() -> Result<Metadata> {
411 let mut command = MetadataCommand::new();
412
413 let tempdir: TempDir;
415
416 if let Some(name) = &opts::get().package {
417 tempdir = packaging::temp_package(name)?;
418 command.current_dir(tempdir.path());
419 }
420
421 command.exec().map_err(Into::into)
422}
423
424fn packages(metadata: &Metadata) -> Result<Vec<&Package>> {
425 let ignored_packages = ignored_packages(metadata)?;
426
427 for name in &ignored_packages {
428 if !metadata.packages.iter().any(|pkg| pkg.name == *name) {
429 warn!(
430 "workspace metadata says to ignore `{}`, but workspace does not depend upon `{}`",
431 name, name
432 );
433 }
434 }
435
436 filter_packages(metadata, &ignored_packages)
437}
438
439#[derive(serde::Deserialize)]
440struct UnmaintainedMetadata {
441 ignore: Option<Vec<String>>,
442}
443
444fn ignored_packages(metadata: &Metadata) -> Result<HashSet<String>> {
445 let serde_json::Value::Object(object) = &metadata.workspace_metadata else {
446 return Ok(HashSet::default());
447 };
448 let Some(value) = object.get("unmaintained") else {
449 return Ok(HashSet::default());
450 };
451 let metadata = serde_json::value::from_value::<UnmaintainedMetadata>(value.clone())?;
452 Ok(metadata.ignore.unwrap_or_default().into_iter().collect())
453}
454
455fn filter_packages<'a>(
456 metadata: &'a Metadata,
457 ignored_packages: &HashSet<String>,
458) -> Result<Vec<&'a Package>> {
459 let mut packages = Vec::new();
460
461 let metadata_latest_version_map = build_metadata_latest_version_map(metadata);
463
464 for pkg in &metadata.packages {
465 if metadata.workspace_members.contains(&pkg.id) {
467 continue;
468 }
469
470 if ignored_packages.contains(pkg.name.as_str()) {
471 continue;
472 }
473
474 #[allow(clippy::panic)]
475 let version = metadata_latest_version_map
476 .get(&pkg.name)
477 .unwrap_or_else(|| {
478 panic!(
479 "`metadata_latest_version_map` does not contain {}",
480 pkg.name
481 )
482 });
483
484 if pkg.version != *version {
485 continue;
486 }
487
488 if let Some(name) = &opts::get().package
489 && pkg.name != *name
490 {
491 continue;
492 }
493
494 packages.push(pkg);
495 }
496
497 if let Some(name) = &opts::get().package {
498 if packages.len() >= 2 {
499 bail!("found multiple packages matching `{name}`: {packages:#?}");
500 }
501
502 if packages.is_empty() {
503 bail!("found no packages matching `{name}`");
504 }
505 }
506
507 Ok(packages)
508}
509
510fn build_metadata_latest_version_map(metadata: &Metadata) -> HashMap<PackageName, Version> {
511 let mut map: HashMap<PackageName, Version> = HashMap::new();
512
513 for pkg in &metadata.packages {
514 if let Some(version) = map.get_mut(&pkg.name) {
515 if *version < pkg.version {
516 *version = pkg.version.clone();
517 }
518 } else {
519 map.insert(pkg.name.clone(), pkg.version.clone());
520 }
521 }
522
523 map
524}
525
526fn prefetch_github_data(packages: &[&Package]) {
527 let mut repo_aliases = HashMap::new();
528 let mut seen_repos = HashSet::new();
529 let mut repos = Vec::new();
530
531 for pkg in packages {
532 let Some(github_repo) = github_repo_for_package(pkg) else {
533 continue;
534 };
535
536 let key = format!("{}/{}", github_repo.owner, github_repo.repo);
537 let aliases = repo_aliases.entry(key.clone()).or_insert_with(Vec::new);
538 for alias in urls(pkg) {
539 push_repo_alias(aliases, alias);
540 }
541 push_repo_alias(aliases, github_repo.url);
542
543 if seen_repos.insert(key) {
544 repos.push(github_repo);
545 }
546 }
547
548 if repos.is_empty() {
549 return;
550 }
551
552 eprintln!("Prefetching GitHub data for {} repositories", repos.len());
553
554 let repo_statuses = match Github::prefetch(&repos) {
555 Ok(repo_statuses) => repo_statuses,
556 Err(error) => {
557 warn!(
558 "failed to prefetch Github data; falling back to per-package queries: {}",
559 error
560 );
561 return;
562 }
563 };
564
565 for repo_status in repo_statuses {
566 let Some(url) = repo_status_url(&repo_status) else {
567 continue;
568 };
569 let Some(github_repo) = GithubRepo::from_url(url) else {
570 continue;
571 };
572 let key = format!("{}/{}", github_repo.owner, github_repo.repo);
573 if let Some(aliases) = repo_aliases.get(&key) {
574 for &alias in aliases {
575 cache_prefetched_github_data(repo_status, alias);
576 }
577 } else {
578 cache_prefetched_github_data(repo_status, url);
579 }
580 }
581}
582
583fn github_repo_for_package(pkg: &Package) -> Option<GithubRepo<'_>> {
584 let url_string = pkg.repository.as_ref()?;
585 let url = Url::from(url_string.as_str()).trim_trailing_slash();
586 GithubRepo::from_url(url)
587}
588
589fn push_repo_alias<'a>(aliases: &mut Vec<Url<'a>>, alias: Url<'a>) {
590 if !aliases.contains(&alias) {
591 aliases.push(alias);
592 }
593}
594
595fn repo_status_url<'a, T>(repo_status: &RepoStatus<'a, T>) -> Option<Url<'a>> {
596 match repo_status {
597 RepoStatus::Uncloneable(url)
598 | RepoStatus::Success(url, _)
599 | RepoStatus::Unassociated(url)
600 | RepoStatus::Nonexistent(url)
601 | RepoStatus::Archived(url) => Some(*url),
602 RepoStatus::Unnamed => None,
603 }
604}
605
606fn cache_prefetched_github_data(repo_status: RepoStatus<'_, SystemTime>, url: Url<'_>) {
607 match repo_status {
608 RepoStatus::Success(_, timestamp) => {
609 let url = url.leak();
610 GENERAL_STATUS_CACHE
611 .with_borrow_mut(|cache| cache.insert(url, RepoStatus::Success(url, ())));
612 TIMESTAMP_CACHE.with_borrow_mut(|cache| {
613 let url = GithubRepo::from_url(url).map_or(url, |github_repo| github_repo.url);
617 cache.insert(url, RepoStatus::Success(url, timestamp));
618 });
619 }
620 RepoStatus::Archived(_) => {
621 let url = url.leak();
622 GENERAL_STATUS_CACHE
623 .with_borrow_mut(|cache| cache.insert(url, RepoStatus::Archived(url)));
624 }
625 RepoStatus::Nonexistent(_) => {
626 let url = url.leak();
627 GENERAL_STATUS_CACHE
628 .with_borrow_mut(|cache| cache.insert(url, RepoStatus::Nonexistent(url)));
629 }
630 RepoStatus::Uncloneable(_) | RepoStatus::Unnamed | RepoStatus::Unassociated(_) => {}
631 }
632}
633
634fn newer_version_is_available(pkg: &Package) -> Result<bool> {
635 if pkg
636 .source
637 .as_ref()
638 .is_none_or(|source| !source.is_crates_io())
639 {
640 return Ok(false);
641 }
642
643 let latest_version = latest_version(&pkg.name)?;
644
645 Ok(pkg.version != latest_version)
646}
647
648fn latest_version_is_unmaintained(name: &str) -> Result<bool> {
649 let tempdir = packaging::temp_package(name)?;
650
651 let metadata = MetadataCommand::new().current_dir(tempdir.path()).exec()?;
652
653 #[allow(clippy::panic)]
654 let pkg = metadata
655 .packages
656 .iter()
657 .find(|pkg| name == pkg.name.as_str())
658 .unwrap_or_else(|| panic!("failed to find package `{name}`"));
659
660 let unmaintained_package = is_unmaintained_package(&metadata, pkg)?;
661
662 Ok(unmaintained_package.is_some())
663}
664
665fn is_unmaintained_package<'a>(
666 metadata: &'a Metadata,
667 pkg: &'a Package,
668) -> Result<Option<UnmaintainedPkg<'a>>> {
669 if let Some(url_string) = &pkg.repository {
670 let can_use_github_api =
671 TOKEN_FOUND.load(Ordering::SeqCst) && url_string.starts_with("https://github.com/");
672
673 let url = url_string.as_str().into();
674
675 if can_use_github_api {
676 let repo_status = general_status(&pkg.name, url)?;
677 if repo_status.is_failure() {
678 return Ok(Some(UnmaintainedPkg {
679 pkg,
680 repo_age: repo_status.map_failure(),
681 newer_version_is_available: false,
682 outdated_deps: Vec::new(),
683 }));
684 }
685 }
686
687 let repo_status = clone_repository(pkg)?;
688 if repo_status.is_failure() {
689 if matches!(repo_status, RepoStatus::Uncloneable(_)) && curl::is_mercurial_repo(url)? {
691 return Ok(None);
692 }
693 return Ok(Some(UnmaintainedPkg {
694 pkg,
695 repo_age: repo_status.map_failure(),
696 newer_version_is_available: false,
697 outdated_deps: Vec::new(),
698 }));
699 }
700 }
701
702 let outdated_deps = outdated_deps(metadata, pkg)?;
703
704 if outdated_deps.is_empty() {
705 return Ok(None);
706 }
707
708 let repo_age = latest_commit_age(pkg)?;
709
710 if repo_age
711 .as_success()
712 .is_some_and(|(_, &age)| age < opts::get().max_age * SECS_PER_DAY)
713 {
714 return Ok(None);
715 }
716
717 Ok(Some(UnmaintainedPkg {
718 pkg,
719 repo_age,
720 newer_version_is_available: false,
721 outdated_deps,
722 }))
723}
724
725fn general_status(name: &str, url: Url) -> Result<RepoStatus<'static, ()>> {
726 GENERAL_STATUS_CACHE.with_borrow_mut(|general_status_cache| {
727 if let Some(&value) = general_status_cache.get(&url) {
728 return Ok(value);
729 }
730 let to_string: &dyn Fn(&RepoStatus<'static, ()>) -> String;
731 let (use_github_api, what, how) = if TOKEN_FOUND.load(Ordering::SeqCst)
732 && url.as_str().starts_with("https://github.com/")
733 {
734 to_string = &RepoStatus::to_archival_status_string;
735 (true, "archival status", "GitHub API")
736 } else {
737 to_string = &RepoStatus::to_existence_string;
738 (false, "existence", "HTTP request")
739 };
740 verbose::wrap!(
741 || {
742 let repo_status = if use_github_api {
743 Github::archival_status(url)
744 } else {
745 curl::existence(url)
746 }
747 .unwrap_or_else(|error| {
748 warn!("failed to determine `{}` {}: {}", name, what, error);
749 RepoStatus::Success(url, ())
750 })
751 .leak_url();
752 general_status_cache.insert(url.leak(), repo_status);
753 Ok(repo_status)
754 },
755 to_string,
756 "{} of `{}` using {}",
757 what,
758 name,
759 how
760 )
761 })
762}
763
764#[allow(clippy::panic, clippy::unwrap_used)]
765static HOST: LazyLock<String> = LazyLock::new(|| {
766 let mut command = Command::new("rustc");
767 command.arg("-Vv");
768 let output = command.output_wc().unwrap();
769 assert!(output.status.success(), "command failed: {command:?}");
770 let stdout = String::from_utf8(output.stdout).unwrap();
771 stdout
772 .lines()
773 .find_map(|line| line.strip_prefix("host: "))
774 .map_or_else(|| panic!("failed to determine host"), str::to_owned)
775});
776
777#[allow(clippy::unnecessary_wraps)]
778fn outdated_deps<'a>(metadata: &'a Metadata, pkg: &'a Package) -> Result<Vec<OutdatedDep<'a>>> {
779 if !published(pkg) {
780 return Ok(Vec::new());
781 }
782 let mut deps = Vec::new();
783 for dep in &pkg.dependencies {
784 if !opts::get().all_targets
786 && let Some(platform) = &dep.target
787 && !platform.matches(&HOST, &[])
788 {
789 continue;
790 }
791 if dep.registry.is_some() {
793 continue;
794 }
795 if dep.path.is_some() {
797 continue;
798 }
799 let Some(dep_pkg) = find_packages(metadata, dep.into()).next() else {
800 debug_assert!(dep.kind == DependencyKind::Development || dep.optional);
801 continue;
802 };
803 let Ok(version_latest) = latest_version(&dep.name).map_err(|error| {
804 warn!("failed to get latest version of `{}`: {}", dep.name, error);
807 }) else {
808 continue;
809 };
810 if dep_pkg.version <= version_latest && !dep.req.matches(&version_latest) {
811 let versions = versions(&dep_pkg.name)?;
812 if versions
815 .iter()
816 .try_fold(false, |init, version| -> Result<_> {
817 if init {
818 return Ok(true);
819 }
820 let duration =
821 SystemTime::now().duration_since_wc(version.created_at.into())?;
822 let version_num = Version::parse(&version.num)?;
823 Ok(duration.as_secs() >= opts::get().max_age * SECS_PER_DAY
824 && dep_pkg.version <= version_num
825 && !dep.req.matches(&version_num))
826 })?
827 {
828 deps.push(OutdatedDep {
829 dep,
830 version_used: &dep_pkg.version,
831 version_latest,
832 });
833 }
834 }
835 }
836 deps.dedup_by(|lhs, rhs| lhs.dep.name == rhs.dep.name && lhs.dep.req == rhs.dep.req);
839 Ok(deps)
840}
841
842fn published(pkg: &Package) -> bool {
843 pkg.publish.as_deref() != Some(&[])
844}
845
846fn find_packages<'a>(
847 metadata: &'a Metadata,
848 dep_req: DepReq<'a>,
849) -> impl Iterator<Item = &'a Package> {
850 metadata
851 .packages
852 .iter()
853 .filter(move |pkg| dep_req.matches(pkg))
854}
855
856fn latest_version(name: &str) -> Result<Version> {
857 LATEST_VERSION_CACHE.with_borrow_mut(|latest_version_cache| {
858 if let Some(version) = latest_version_cache.get(name) {
859 return Ok(version.clone());
860 }
861 verbose::wrap!(
862 || {
863 #[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
864 let latest_version = {
865 let krate = INDEX.with(|index| {
866 let _ = LazyLock::force(index);
867 let _lock = lock_index()?;
868 index
869 .crate_(name)
870 .ok_or_else(|| anyhow!("failed to find `{name}` in index"))
871 })?;
872 let latest_version_index = krate
873 .highest_normal_version()
874 .ok_or_else(|| anyhow!("`{name}` has no normal version"))?;
875 Version::from_str(latest_version_index.version())?
876 };
877 #[cfg(feature = "tame-index")]
878 let latest_version = {
879 let krate_name = KrateName::crates_io(name)
880 .map_err(|e| anyhow!("invalid crate name `{name}`: {e}"))?;
881 let lock_options = LockOptions::cargo_package_lock(None)
882 .map_err(|e| anyhow!("failed to create cargo package lock: {e}"))?;
883 let lock = lock_options
884 .lock(|_| None)
885 .map_err(|e| anyhow!("failed to acquire cargo package lock: {e}"))?;
886 let krate = INDEX.with(|index| {
887 let _ = LazyLock::force(index);
888 let krate = index
889 .krate(krate_name, true, &lock)
890 .map_err(|e| anyhow!("failed to fetch `{name}` from index: {e}"))?;
891 krate.ok_or_else(|| anyhow!("failed to find `{name}` in index"))
892 })?;
893 let latest_version_index = krate
894 .highest_normal_version()
895 .ok_or_else(|| anyhow!("`{name}` has no normal version"))?;
896 Version::from_str(&latest_version_index.version)?
897 };
898 latest_version_cache.insert(name.to_owned(), latest_version.clone());
899 Ok(latest_version)
900 },
901 ToString::to_string,
902 "latest version of `{}` using crates.io index",
903 name
904 )
905 })
906}
907
908fn versions(name: &str) -> Result<Vec<crates_io_api::Version>> {
909 on_disk_cache::with_cache(|cache| -> Result<_> {
910 verbose::wrap!(
911 || { cache.fetch_versions(name) },
912 |versions: &[crates_io_api::Version]| format!("{} versions", versions.len()),
913 "versions of `{}` using crates.io API",
914 name
915 )
916 })
917}
918
919fn latest_commit_age(pkg: &Package) -> Result<RepoStatus<'_, u64>> {
920 let repo_status = timestamp(pkg)?;
921
922 repo_status
923 .map(|timestamp| {
924 let duration = SystemTime::now().duration_since_wc(timestamp)?;
925
926 Ok(duration.as_secs())
927 })
928 .transpose()
929}
930
931fn timestamp(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
932 TIMESTAMP_CACHE.with_borrow_mut(|timestamp_cache| {
933 for url in urls(pkg) {
935 if let Some(&repo_status) = timestamp_cache.get(&url) {
936 let Some((url_timestamped, ×tamp)) = repo_status.as_success() else {
939 return Ok(repo_status);
940 };
941 assert_eq!(url, url_timestamped);
942 let repo_status = clone_repository(pkg)?;
946 let Some((url_cloned, _)) = repo_status.as_success() else {
947 return Ok(repo_status.map_failure());
948 };
949 assert_eq!(url, url_cloned);
950 return Ok(RepoStatus::Success(url, timestamp));
951 }
952 }
953 let repo_status = timestamp_uncached(pkg)?;
954 if let Some((url, _)) = repo_status.as_success() {
955 timestamp_cache.insert(url.leak(), repo_status.leak_url());
956 } else {
957 for url in urls(pkg) {
960 timestamp_cache.insert(url.leak(), repo_status.leak_url());
961 }
962 }
963 Ok(repo_status)
964 })
965}
966
967fn timestamp_uncached(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
968 if pkg.repository.is_none() {
969 return Ok(RepoStatus::Unnamed);
970 }
971
972 timestamp_from_clone(pkg)
973}
974
975fn timestamp_from_clone(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
976 let repo_status = clone_repository(pkg)?;
977
978 let Some((url, repo_dir)) = repo_status.as_success() else {
979 return Ok(repo_status.map_failure());
980 };
981
982 let mut command = Command::new("git");
983 command
984 .args(["log", "-1", "--pretty=format:%ct"])
985 .current_dir(repo_dir);
986 let output = command.output_wc()?;
987 ensure!(output.status.success(), "command failed: {command:?}");
988
989 let stdout = std::str::from_utf8(&output.stdout)?;
990 let secs = u64::from_str(stdout.trim_end())?;
991 let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
992
993 Ok(RepoStatus::Success(url, timestamp))
994}
995
996#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_unhandled_error))]
997#[cfg_attr(dylint_lib = "supplementary", allow(commented_out_code))]
998fn clone_repository(pkg: &Package) -> Result<RepoStatus<'_, PathBuf>> {
999 let repo_status = REPOSITORY_CACHE.with_borrow_mut(|repository_cache| -> Result<_> {
1000 on_disk_cache::with_cache(|cache| -> Result<_> {
1001 for url in urls(pkg) {
1003 if let Some(repo_status) = repository_cache.get(&url) {
1004 return Ok(repo_status.clone());
1005 }
1006 }
1007 verbose::wrap!(
1014 || {
1015 let url_and_dir = cache.clone_repository(pkg);
1016 match url_and_dir {
1017 Ok((url_string, repo_dir)) => {
1018 let url = Url::from(url_string.as_str()).leak();
1021 repository_cache
1022 .insert(url, RepoStatus::Success(url, repo_dir.clone()).leak_url());
1023 Ok(RepoStatus::Success(url, repo_dir))
1024 }
1025 Err(error) => {
1026 let repo_status = if let Some(url_string) = &pkg.repository {
1027 let url = url_string.as_str().into();
1028 let existence = general_status(&pkg.name, url)?;
1031 let repo_status = if existence.is_failure() {
1032 existence.map_failure()
1033 } else {
1034 RepoStatus::Uncloneable(url)
1035 };
1036 warn!("failed to clone `{}`: {}", url_string, error);
1037 repo_status
1038 } else {
1039 RepoStatus::Unnamed
1040 };
1041 for url in urls(pkg) {
1044 repository_cache.insert(url.leak(), repo_status.clone().leak_url());
1045 }
1046 Ok(repo_status)
1047 }
1048 }
1049 },
1050 RepoStatus::to_membership_string,
1051 "membership of `{}` using shallow clone",
1052 pkg.name
1053 )
1054 })
1055 })?;
1056
1057 let Some((url, repo_dir)) = repo_status.as_success() else {
1058 return Ok(repo_status);
1059 };
1060
1061 if membership_in_clone(pkg, repo_dir)? {
1064 Ok(repo_status)
1065 } else {
1066 Ok(RepoStatus::Unassociated(url))
1067 }
1068}
1069
1070const LINE_PREFIX: &str = "D ";
1071
1072fn membership_in_clone(pkg: &Package, repo_dir: &Path) -> Result<bool> {
1073 let mut command = Command::new("git");
1074 command.args(["status", "--porcelain"]);
1075 command.current_dir(repo_dir);
1076 command.stdout(Stdio::piped());
1077 let mut child = command.spawn_wc()?;
1078 #[allow(clippy::unwrap_used)]
1079 let stdout = child.stdout.take().unwrap();
1080 let reader = std::io::BufReader::new(stdout);
1081 for result in reader.lines() {
1082 let line = result.with_context(|| format!("failed to read `{}`", repo_dir.display()))?;
1083 #[allow(clippy::panic)]
1084 let path = line.strip_prefix(LINE_PREFIX).map_or_else(
1085 || panic!("cache is corrupt at `{}`", repo_dir.display()),
1086 Path::new,
1087 );
1088 let filename = path.file_name_wc();
1089 if filename.is_err() || filename.is_ok_and(|s| s != OsStr::new("Cargo.toml")) {
1090 continue;
1091 }
1092 let contents = show(repo_dir, path)?;
1093 let Ok(table) = contents.parse::<Table>()
1094 else {
1103 continue;
1104 };
1105 if table
1106 .get("package")
1107 .and_then(Value::as_table)
1108 .and_then(|table| table.get("name"))
1109 .and_then(Value::as_str)
1110 == Some(&pkg.name)
1111 {
1112 return Ok(true);
1113 }
1114 }
1115
1116 Ok(false)
1117}
1118
1119fn show(repo_dir: &Path, path: &Path) -> Result<String> {
1120 let mut command = Command::new("git");
1121 command.args(["show", &format!("HEAD:{}", path.display())]);
1122 command.current_dir(repo_dir);
1123 command.stdout(Stdio::piped());
1124 let output = command.output_wc()?;
1125 if !output.status.success() {
1126 let error = String::from_utf8(output.stderr)?;
1127 bail!(
1128 "failed to read `{}` in `{}`: {}",
1129 path.display(),
1130 repo_dir.display(),
1131 error
1132 );
1133 }
1134 String::from_utf8(output.stdout).map_err(Into::into)
1135}
1136
1137fn display_unmaintained_pkgs(unmaintained_pkgs: &[UnmaintainedPkg]) -> Result<()> {
1138 let mut pkgs_needing_warning = Vec::new();
1139 let mut at_least_one_newer_version_is_available = false;
1140 for unmaintained_pkg in unmaintained_pkgs {
1141 at_least_one_newer_version_is_available |= unmaintained_pkg.newer_version_is_available;
1142 if display_unmaintained_pkg(unmaintained_pkg)? {
1143 pkgs_needing_warning.push(unmaintained_pkg.pkg);
1144 }
1145 }
1146 if at_least_one_newer_version_is_available {
1147 println!(
1148 "\n* a newer (though still seemingly unmaintained) version of the package is available"
1149 );
1150 }
1151 if !pkgs_needing_warning.is_empty() {
1152 warn!(
1153 "the following packages' paths could not be printed:{}",
1154 pkgs_needing_warning
1155 .into_iter()
1156 .map(|pkg| format!("\n {}@{}", pkg.name, pkg.version))
1157 .collect::<String>()
1158 );
1159 }
1160 Ok(())
1161}
1162
1163#[cfg_attr(dylint_lib = "try_io_result", allow(try_io_result))]
1164fn display_unmaintained_pkg(unmaintained_pkg: &UnmaintainedPkg) -> Result<bool> {
1165 use std::io::Write;
1166 let mut stdout = StandardStream::stdout(opts::get().color);
1167 let UnmaintainedPkg {
1168 pkg,
1169 repo_age,
1170 newer_version_is_available,
1171 outdated_deps,
1172 } = unmaintained_pkg;
1173 stdout.set_color(ColorSpec::new().set_fg(repo_age.color()))?;
1174 write!(stdout, "{}", pkg.name)?;
1175 stdout.set_color(ColorSpec::new().set_fg(None))?;
1176 write!(stdout, " (")?;
1177 repo_age.write(&mut stdout)?;
1178 write!(stdout, ")")?;
1179 if *newer_version_is_available {
1180 write!(stdout, "*")?;
1181 }
1182 writeln!(stdout)?;
1183 for OutdatedDep {
1184 dep,
1185 version_used,
1186 version_latest,
1187 } in outdated_deps
1188 {
1189 println!(
1190 " {} (requirement: {}, version used: {}, latest: {})",
1191 dep.name, dep.req, version_used, version_latest
1192 );
1193 }
1194 if opts::get().tree {
1195 let need_warning = display_path(&pkg.name, &pkg.version)?;
1196 println!();
1197 Ok(need_warning)
1198 } else {
1199 Ok(false)
1200 }
1201}
1202
1203fn display_path(name: &str, version: &Version) -> Result<bool> {
1204 let spec = format!("{name}@{version}");
1205 let mut command = Command::new("cargo");
1206 command.args(["tree", "--workspace", "--target=all", "--invert", &spec]);
1207 let output = command.output_wc()?;
1208 let stdout = String::from_utf8(output.stdout)?;
1212 if stdout.split_ascii_whitespace().take(2).collect::<Vec<_>>() == [name, &format!("v{version}")]
1213 {
1214 print!("{stdout}");
1215 Ok(false)
1216 } else {
1217 Ok(true)
1218 }
1219}
1220
1221#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
1222static INDEX_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
1223 #[allow(clippy::unwrap_used)]
1224 let cargo_home = cargo_home().unwrap();
1225 cargo_home.join("registry/index")
1226});
1227
1228#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
1229fn lock_index() -> Result<File> {
1230 flock::lock_path(&INDEX_PATH)
1231 .with_context(|| format!("failed to lock `{}`", INDEX_PATH.display()))
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237 use assert_cmd::prelude::*;
1238 use clap::CommandFactory;
1239
1240 #[test]
1241 fn verify_cli() {
1242 Opts::command().debug_assert();
1243 }
1244
1245 #[cfg_attr(dylint_lib = "general", allow(non_thread_safe_call_in_test))]
1246 #[test]
1247 fn usage() {
1248 std::process::Command::new("cargo")
1249 .args([
1250 "run",
1251 "--bin=cargo-unmaintained",
1252 "--",
1253 "unmaintained",
1254 "--help",
1255 ])
1256 .assert()
1257 .success()
1258 .stdout(predicates::str::contains("Usage: cargo unmaintained"));
1259 }
1260
1261 #[cfg_attr(dylint_lib = "general", allow(non_thread_safe_call_in_test))]
1262 #[test]
1263 fn version() {
1264 std::process::Command::new("cargo")
1265 .args([
1266 "run",
1267 "--bin=cargo-unmaintained",
1268 "--",
1269 "unmaintained",
1270 "--version",
1271 ])
1272 .assert()
1273 .success()
1274 .stdout(format!(
1275 "cargo-unmaintained {}\n",
1276 env!("CARGO_PKG_VERSION")
1277 ));
1278 }
1279
1280 #[test]
1281 fn repo_status_ord() {
1282 let ys = vec![
1283 RepoStatus::Uncloneable("f".into()),
1284 RepoStatus::Unnamed,
1285 RepoStatus::Success("e".into(), 0),
1286 RepoStatus::Success("d".into(), 1),
1287 RepoStatus::Unassociated("c".into()),
1288 RepoStatus::Nonexistent("b".into()),
1289 RepoStatus::Archived("a".into()),
1290 ];
1291 let mut xs = ys.clone();
1292 xs.sort_by_key(|repo_status| repo_status.erase_url());
1293 assert_eq!(xs, ys);
1294 }
1295}