Skip to main content

cargo_unmaintained/
lib.rs

1//! Implementation details for `cargo-unmaintained`
2//!
3//! `cargo-unmaintained` is a tool for finding unmaintained packages in Rust
4//! projects.
5//!
6//! The tool's main source file is at `src/bin/cargo-unmaintained.rs`.
7
8#![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            // smoelius: I once saw a recommendation to delete a corrupt crates.io index, but I
222            // cannot remember where. Maybe the following?
223            // https://github.com/rust-lang/cargo/issues/2403#issuecomment-186874266
224            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    // smoelius: Use `ManuallyDrop` to prevent the destructor from running during process exit. On
259    // Windows, `std::process::exit` triggers thread-local destructors, and the `RemoteSparseIndex`
260    // destructor can panic during shutdown.
261    #[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    // smoelius: The next four statics are "in-memory" caches.
265    // smoelius: Note that repositories are (currently) stored in both an in-memory cache and an
266    // on-disk cache. The former is keyed by url; the latter is keyed by package.
267    // smoelius: A reason for having the former is the following. Multiple packages map to the same
268    // url, and multiple urls map to the same shortened url. Thus, a cache keyed by url has a
269    // greater chance of a cache hit.
270    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        // smoelius: Currently, if additional options are passed besides --save-token, they are
289        // ignored and no error is emitted. This is ugly.
290        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            // smoelius: Before considering a package unmaintained, verify that its latest version
351            // would be considered unmaintained as well. Note that we still report the details of
352            // the version currently used. We may want to revisit this in the future.
353            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    // smoelius: See tests/snapbox.rs for another use of this conditional initialization trick.
403    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    // smoelius: If a project relies on multiple versions of a package, check only the latest one.
451    let metadata_latest_version_map = build_metadata_latest_version_map(metadata);
452
453    for pkg in &metadata.packages {
454        // smoelius: Don't consider whether workspace members are unmaintained.
455        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                // The repository has not been cloned yet, so using the canonical GitHub URL as the
603                // timestamp cache key is optimistic. A later `clone_repository` call must still
604                // succeed before this timestamp is used.
605                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    // smoelius: Note that `other_options` replaces previous options; it does not add to them. So if
657    // additional options are needed, they must be added here.
658    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            // smoelius: Mercurial repos get a pass.
702            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        // smoelius: Don't check dependencies in private registries.
784        if dep.registry.is_some() {
785            continue;
786        }
787        // smoelius: Don't check dependencies specified by path.
788        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            // smoelius: An unresolved dependency must either be optional and disabled or have been
796            // excluded by `--filter-platform`. Note that this assertion does not flag
797            // platform-gated dependencies that apply to the host but fail to resolve. Flagging
798            // those would require the host's `cfg`s and use of, e.g., `Platform::matches`.
799            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            // smoelius: I don't understand why a package can fail to be in the index, but I have
807            // seen it happen.
808            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            // smoelius: Require at least one incompatible version of the dependency that is more
815            // than `max_age` days old.
816            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    // smoelius: A dependency could appear more than once, e.g., because it is used with different
839    // features as a normal and as a development dependency.
840    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        // smoelius: Check both the regular and the shortened url.
950        for url in urls(pkg) {
951            if let Some(&repo_status) = timestamp_cache.get(&url) {
952                // smoelius: If a previous attempt to timestamp the repository failed (e.g., because
953                // of spurious network errors), then don't bother checking the repository cache.
954                let Some((url_timestamped, &timestamp)) = repo_status.as_success() else {
955                    return Ok(repo_status);
956                };
957                assert_eq!(url, url_timestamped);
958                // smoelius: `pkg`'s repository could contain other packages that were already
959                // timestamped. Thus, `pkg`'s repository could already be in the timestamp cache.
960                // But in that case, we still need to verify that `pkg` appears in its repository.
961                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            // smoelius: In the event of failure, set all urls associated with the
974            // repository.
975            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            // smoelius: Check all urls associated with the package.
1018            for url in urls(pkg) {
1019                if let Some(repo_status) = repository_cache.get(&url) {
1020                    return Ok(repo_status.clone());
1021                }
1022            }
1023            // smoelius: To make verbose printing easier, "membership" is printed regardless of the
1024            // check's purpose, and the `Purpose` type was removed.
1025            /* let what = match purpose {
1026                Purpose::Membership => "membership",
1027                Purpose::Timestamp => "timestamp",
1028            }; */
1029            verbose::wrap!(
1030                || {
1031                    let url_and_dir = cache.clone_repository(pkg);
1032                    match url_and_dir {
1033                        Ok((url_string, repo_dir)) => {
1034                            // smoelius: Note the use of `leak` in the next line. But the url is
1035                            // acting as a key in a global map, so it is not so bad.
1036                            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                                // smoelius: If cloning failed because the repository does not
1045                                // exist, adjust the repo status.
1046                                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                            // smoelius: In the event of failure, set all urls associated with
1058                            // the repository.
1059                            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    // smoelius: Even if `purpose` is `Purpose::Timestamp`, verify that `pkg` is a member of the
1078    // repository.
1079    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        /* smoelius: This "failed to parse" warning is a little too noisy.
1111        .map_err(|error| {
1112            warn!(
1113                "failed to parse {:?}: {}",
1114                path,
1115                error.to_string().trim_end()
1116            );
1117        }) */
1118        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    // smoelius: Hack. It appears that `cargo tree` does not print proc-macros used by proc-macros.
1231    // For now, check whether stdout begins as expected. If not, ignore it and ultimately emit a
1232    // warning.
1233    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}