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