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 if dep.kind == DependencyKind::Development {
800 continue;
801 }
802 let Some(dep_pkg) = find_packages(metadata, dep.into()).next() else {
803 debug_assert!(dep.optional);
804 continue;
805 };
806 let Ok(version_latest) = latest_version(&dep.name).map_err(|error| {
807 warn!("failed to get latest version of `{}`: {}", dep.name, error);
810 }) else {
811 continue;
812 };
813 if dep_pkg.version <= version_latest && !dep.req.matches(&version_latest) {
814 let versions = versions(&dep_pkg.name)?;
815 if versions
818 .iter()
819 .try_fold(false, |init, version| -> Result<_> {
820 if init {
821 return Ok(true);
822 }
823 let duration =
824 SystemTime::now().duration_since_wc(version.created_at.into())?;
825 let version_num = Version::parse(&version.num)?;
826 Ok(duration.as_secs() >= opts::get().max_age * SECS_PER_DAY
827 && dep_pkg.version <= version_num
828 && !dep.req.matches(&version_num))
829 })?
830 {
831 deps.push(OutdatedDep {
832 dep,
833 version_used: &dep_pkg.version,
834 version_latest,
835 });
836 }
837 }
838 }
839 deps.dedup_by(|lhs, rhs| lhs.dep.name == rhs.dep.name && lhs.dep.req == rhs.dep.req);
842 Ok(deps)
843}
844
845fn published(pkg: &Package) -> bool {
846 pkg.publish.as_deref() != Some(&[])
847}
848
849fn find_packages<'a>(
850 metadata: &'a Metadata,
851 dep_req: DepReq<'a>,
852) -> impl Iterator<Item = &'a Package> {
853 metadata
854 .packages
855 .iter()
856 .filter(move |pkg| dep_req.matches(pkg))
857}
858
859fn latest_version(name: &str) -> Result<Version> {
860 LATEST_VERSION_CACHE.with_borrow_mut(|latest_version_cache| {
861 if let Some(version) = latest_version_cache.get(name) {
862 return Ok(version.clone());
863 }
864 verbose::wrap!(
865 || {
866 #[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
867 let latest_version = {
868 let krate = INDEX.with(|index| {
869 let _ = LazyLock::force(index);
870 let _lock = lock_index()?;
871 index
872 .crate_(name)
873 .ok_or_else(|| anyhow!("failed to find `{name}` in index"))
874 })?;
875 let latest_version_index = krate
876 .highest_normal_version()
877 .ok_or_else(|| anyhow!("`{name}` has no normal version"))?;
878 Version::from_str(latest_version_index.version())?
879 };
880 #[cfg(feature = "tame-index")]
881 let latest_version = {
882 let krate_name = KrateName::crates_io(name)
883 .map_err(|e| anyhow!("invalid crate name `{name}`: {e}"))?;
884 let lock_options = LockOptions::cargo_package_lock(None)
885 .map_err(|e| anyhow!("failed to create cargo package lock: {e}"))?;
886 let lock = lock_options
887 .lock(|_| None)
888 .map_err(|e| anyhow!("failed to acquire cargo package lock: {e}"))?;
889 let krate = INDEX.with(|index| {
890 let _ = LazyLock::force(index);
891 let krate = index
892 .krate(krate_name, true, &lock)
893 .map_err(|e| anyhow!("failed to fetch `{name}` from index: {e}"))?;
894 krate.ok_or_else(|| anyhow!("failed to find `{name}` in index"))
895 })?;
896 let latest_version_index = krate
897 .highest_normal_version()
898 .ok_or_else(|| anyhow!("`{name}` has no normal version"))?;
899 Version::from_str(&latest_version_index.version)?
900 };
901 latest_version_cache.insert(name.to_owned(), latest_version.clone());
902 Ok(latest_version)
903 },
904 ToString::to_string,
905 "latest version of `{}` using crates.io index",
906 name
907 )
908 })
909}
910
911fn versions(name: &str) -> Result<Vec<crates_io_api::Version>> {
912 on_disk_cache::with_cache(|cache| -> Result<_> {
913 verbose::wrap!(
914 || { cache.fetch_versions(name) },
915 |versions: &[crates_io_api::Version]| format!("{} versions", versions.len()),
916 "versions of `{}` using crates.io API",
917 name
918 )
919 })
920}
921
922fn latest_commit_age(pkg: &Package) -> Result<RepoStatus<'_, u64>> {
923 let repo_status = timestamp(pkg)?;
924
925 repo_status
926 .map(|timestamp| {
927 let duration = SystemTime::now().duration_since_wc(timestamp)?;
928
929 Ok(duration.as_secs())
930 })
931 .transpose()
932}
933
934fn timestamp(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
935 TIMESTAMP_CACHE.with_borrow_mut(|timestamp_cache| {
936 for url in urls(pkg) {
938 if let Some(&repo_status) = timestamp_cache.get(&url) {
939 let Some((url_timestamped, ×tamp)) = repo_status.as_success() else {
942 return Ok(repo_status);
943 };
944 assert_eq!(url, url_timestamped);
945 let repo_status = clone_repository(pkg)?;
949 let Some((url_cloned, _)) = repo_status.as_success() else {
950 return Ok(repo_status.map_failure());
951 };
952 assert_eq!(url, url_cloned);
953 return Ok(RepoStatus::Success(url, timestamp));
954 }
955 }
956 let repo_status = timestamp_uncached(pkg)?;
957 if let Some((url, _)) = repo_status.as_success() {
958 timestamp_cache.insert(url.leak(), repo_status.leak_url());
959 } else {
960 for url in urls(pkg) {
963 timestamp_cache.insert(url.leak(), repo_status.leak_url());
964 }
965 }
966 Ok(repo_status)
967 })
968}
969
970fn timestamp_uncached(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
971 if pkg.repository.is_none() {
972 return Ok(RepoStatus::Unnamed);
973 }
974
975 timestamp_from_clone(pkg)
976}
977
978fn timestamp_from_clone(pkg: &Package) -> Result<RepoStatus<'_, SystemTime>> {
979 let repo_status = clone_repository(pkg)?;
980
981 let Some((url, repo_dir)) = repo_status.as_success() else {
982 return Ok(repo_status.map_failure());
983 };
984
985 let mut command = Command::new("git");
986 command
987 .args(["log", "-1", "--pretty=format:%ct"])
988 .current_dir(repo_dir);
989 let output = command.output_wc()?;
990 ensure!(output.status.success(), "command failed: {command:?}");
991
992 let stdout = std::str::from_utf8(&output.stdout)?;
993 let secs = u64::from_str(stdout.trim_end())?;
994 let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
995
996 Ok(RepoStatus::Success(url, timestamp))
997}
998
999#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_unhandled_error))]
1000#[cfg_attr(dylint_lib = "supplementary", allow(commented_out_code))]
1001fn clone_repository(pkg: &Package) -> Result<RepoStatus<'_, PathBuf>> {
1002 let repo_status = REPOSITORY_CACHE.with_borrow_mut(|repository_cache| -> Result<_> {
1003 on_disk_cache::with_cache(|cache| -> Result<_> {
1004 for url in urls(pkg) {
1006 if let Some(repo_status) = repository_cache.get(&url) {
1007 return Ok(repo_status.clone());
1008 }
1009 }
1010 verbose::wrap!(
1017 || {
1018 let url_and_dir = cache.clone_repository(pkg);
1019 match url_and_dir {
1020 Ok((url_string, repo_dir)) => {
1021 let url = Url::from(url_string.as_str()).leak();
1024 repository_cache
1025 .insert(url, RepoStatus::Success(url, repo_dir.clone()).leak_url());
1026 Ok(RepoStatus::Success(url, repo_dir))
1027 }
1028 Err(error) => {
1029 let repo_status = if let Some(url_string) = &pkg.repository {
1030 let url = url_string.as_str().into();
1031 let existence = general_status(&pkg.name, url)?;
1034 let repo_status = if existence.is_failure() {
1035 existence.map_failure()
1036 } else {
1037 RepoStatus::Uncloneable(url)
1038 };
1039 warn!("failed to clone `{}`: {}", url_string, error);
1040 repo_status
1041 } else {
1042 RepoStatus::Unnamed
1043 };
1044 for url in urls(pkg) {
1047 repository_cache.insert(url.leak(), repo_status.clone().leak_url());
1048 }
1049 Ok(repo_status)
1050 }
1051 }
1052 },
1053 RepoStatus::to_membership_string,
1054 "membership of `{}` using shallow clone",
1055 pkg.name
1056 )
1057 })
1058 })?;
1059
1060 let Some((url, repo_dir)) = repo_status.as_success() else {
1061 return Ok(repo_status);
1062 };
1063
1064 if membership_in_clone(pkg, repo_dir)? {
1067 Ok(repo_status)
1068 } else {
1069 Ok(RepoStatus::Unassociated(url))
1070 }
1071}
1072
1073const LINE_PREFIX: &str = "D ";
1074
1075fn membership_in_clone(pkg: &Package, repo_dir: &Path) -> Result<bool> {
1076 let mut command = Command::new("git");
1077 command.args(["status", "--porcelain"]);
1078 command.current_dir(repo_dir);
1079 command.stdout(Stdio::piped());
1080 let mut child = command.spawn_wc()?;
1081 #[allow(clippy::unwrap_used)]
1082 let stdout = child.stdout.take().unwrap();
1083 let reader = std::io::BufReader::new(stdout);
1084 for result in reader.lines() {
1085 let line = result.with_context(|| format!("failed to read `{}`", repo_dir.display()))?;
1086 #[allow(clippy::panic)]
1087 let path = line.strip_prefix(LINE_PREFIX).map_or_else(
1088 || panic!("cache is corrupt at `{}`", repo_dir.display()),
1089 Path::new,
1090 );
1091 let filename = path.file_name_wc();
1092 if filename.is_err() || filename.is_ok_and(|s| s != OsStr::new("Cargo.toml")) {
1093 continue;
1094 }
1095 let contents = show(repo_dir, path)?;
1096 let Ok(table) = contents.parse::<Table>()
1097 else {
1106 continue;
1107 };
1108 if table
1109 .get("package")
1110 .and_then(Value::as_table)
1111 .and_then(|table| table.get("name"))
1112 .and_then(Value::as_str)
1113 == Some(&pkg.name)
1114 {
1115 return Ok(true);
1116 }
1117 }
1118
1119 Ok(false)
1120}
1121
1122fn show(repo_dir: &Path, path: &Path) -> Result<String> {
1123 let mut command = Command::new("git");
1124 command.args(["show", &format!("HEAD:{}", path.display())]);
1125 command.current_dir(repo_dir);
1126 command.stdout(Stdio::piped());
1127 let output = command.output_wc()?;
1128 if !output.status.success() {
1129 let error = String::from_utf8(output.stderr)?;
1130 bail!(
1131 "failed to read `{}` in `{}`: {}",
1132 path.display(),
1133 repo_dir.display(),
1134 error
1135 );
1136 }
1137 String::from_utf8(output.stdout).map_err(Into::into)
1138}
1139
1140fn display_unmaintained_pkgs(unmaintained_pkgs: &[UnmaintainedPkg]) -> Result<()> {
1141 let mut pkgs_needing_warning = Vec::new();
1142 let mut at_least_one_newer_version_is_available = false;
1143 for unmaintained_pkg in unmaintained_pkgs {
1144 at_least_one_newer_version_is_available |= unmaintained_pkg.newer_version_is_available;
1145 if display_unmaintained_pkg(unmaintained_pkg)? {
1146 pkgs_needing_warning.push(unmaintained_pkg.pkg);
1147 }
1148 }
1149 if at_least_one_newer_version_is_available {
1150 println!(
1151 "\n* a newer (though still seemingly unmaintained) version of the package is available"
1152 );
1153 }
1154 if !pkgs_needing_warning.is_empty() {
1155 warn!(
1156 "the following packages' paths could not be printed:{}",
1157 pkgs_needing_warning
1158 .into_iter()
1159 .map(|pkg| format!("\n {}@{}", pkg.name, pkg.version))
1160 .collect::<String>()
1161 );
1162 }
1163 Ok(())
1164}
1165
1166#[cfg_attr(dylint_lib = "try_io_result", allow(try_io_result))]
1167fn display_unmaintained_pkg(unmaintained_pkg: &UnmaintainedPkg) -> Result<bool> {
1168 use std::io::Write;
1169 let mut stdout = StandardStream::stdout(opts::get().color);
1170 let UnmaintainedPkg {
1171 pkg,
1172 repo_age,
1173 newer_version_is_available,
1174 outdated_deps,
1175 } = unmaintained_pkg;
1176 stdout.set_color(ColorSpec::new().set_fg(repo_age.color()))?;
1177 write!(stdout, "{}", pkg.name)?;
1178 stdout.set_color(ColorSpec::new().set_fg(None))?;
1179 write!(stdout, " (")?;
1180 repo_age.write(&mut stdout)?;
1181 write!(stdout, ")")?;
1182 if *newer_version_is_available {
1183 write!(stdout, "*")?;
1184 }
1185 writeln!(stdout)?;
1186 for OutdatedDep {
1187 dep,
1188 version_used,
1189 version_latest,
1190 } in outdated_deps
1191 {
1192 println!(
1193 " {} (requirement: {}, version used: {}, latest: {})",
1194 dep.name, dep.req, version_used, version_latest
1195 );
1196 }
1197 if opts::get().tree {
1198 let need_warning = display_path(&pkg.name, &pkg.version)?;
1199 println!();
1200 Ok(need_warning)
1201 } else {
1202 Ok(false)
1203 }
1204}
1205
1206fn display_path(name: &str, version: &Version) -> Result<bool> {
1207 let spec = format!("{name}@{version}");
1208 let mut command = Command::new("cargo");
1209 command.args(["tree", "--workspace", "--target=all", "--invert", &spec]);
1210 let output = command.output_wc()?;
1211 let stdout = String::from_utf8(output.stdout)?;
1215 if stdout.split_ascii_whitespace().take(2).collect::<Vec<_>>() == [name, &format!("v{version}")]
1216 {
1217 print!("{stdout}");
1218 Ok(false)
1219 } else {
1220 Ok(true)
1221 }
1222}
1223
1224#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
1225static INDEX_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
1226 #[allow(clippy::unwrap_used)]
1227 let cargo_home = cargo_home().unwrap();
1228 cargo_home.join("registry/index")
1229});
1230
1231#[cfg(all(feature = "crates-index", not(feature = "tame-index")))]
1232fn lock_index() -> Result<File> {
1233 flock::lock_path(&INDEX_PATH)
1234 .with_context(|| format!("failed to lock `{}`", INDEX_PATH.display()))
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use assert_cmd::prelude::*;
1241 use clap::CommandFactory;
1242
1243 #[test]
1244 fn verify_cli() {
1245 Opts::command().debug_assert();
1246 }
1247
1248 #[cfg_attr(dylint_lib = "general", allow(non_thread_safe_call_in_test))]
1249 #[test]
1250 fn usage() {
1251 std::process::Command::new("cargo")
1252 .args([
1253 "run",
1254 "--bin=cargo-unmaintained",
1255 "--",
1256 "unmaintained",
1257 "--help",
1258 ])
1259 .assert()
1260 .success()
1261 .stdout(predicates::str::contains("Usage: cargo unmaintained"));
1262 }
1263
1264 #[cfg_attr(dylint_lib = "general", allow(non_thread_safe_call_in_test))]
1265 #[test]
1266 fn version() {
1267 std::process::Command::new("cargo")
1268 .args([
1269 "run",
1270 "--bin=cargo-unmaintained",
1271 "--",
1272 "unmaintained",
1273 "--version",
1274 ])
1275 .assert()
1276 .success()
1277 .stdout(concat!(
1278 "cargo-unmaintained ",
1279 env!("CARGO_PKG_VERSION"),
1280 "\n"
1281 ));
1282 }
1283
1284 #[test]
1285 fn repo_status_ord() {
1286 let ys = vec![
1287 RepoStatus::Uncloneable("f".into()),
1288 RepoStatus::Unnamed,
1289 RepoStatus::Success("e".into(), 0),
1290 RepoStatus::Success("d".into(), 1),
1291 RepoStatus::Unassociated("c".into()),
1292 RepoStatus::Nonexistent("b".into()),
1293 RepoStatus::Archived("a".into()),
1294 ];
1295 let mut xs = ys.clone();
1296 xs.sort_by_key(|repo_status| repo_status.erase_url());
1297 assert_eq!(xs, ys);
1298 }
1299}