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,
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            // smoelius: I once saw a recommendation to delete a corrupt crates.io index, but I
248            // cannot remember where. Maybe the following?
249            // https://github.com/rust-lang/cargo/issues/2403#issuecomment-186874266
250            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    // smoelius: Use `ManuallyDrop` to prevent the destructor from running during process exit. On
285    // Windows, `std::process::exit` triggers thread-local destructors, and the `RemoteSparseIndex`
286    // destructor can panic during shutdown.
287    #[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    // smoelius: The next four statics are "in-memory" caches.
291    // smoelius: Note that repositories are (currently) stored in both an in-memory cache and an
292    // on-disk cache. The former is keyed by url; the latter is keyed by package.
293    // smoelius: A reason for having the former is the following. Multiple packages map to the same
294    // url, and multiple urls map to the same shortened url. Thus, a cache keyed by url has a
295    // greater chance of a cache hit.
296    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        // smoelius: Currently, if additional options are passed besides --save-token, they are
315        // ignored and no error is emitted. This is ugly.
316        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            // smoelius: Before considering a package unmaintained, verify that its latest version
373            // would be considered unmaintained as well. Note that we still report the details of
374            // the version currently used. We may want to revisit this in the future.
375            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    // smoelius: See tests/snapbox.rs for another use of this conditional initialization trick.
414    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    // smoelius: If a project relies on multiple versions of a package, check only the latest one.
462    let metadata_latest_version_map = build_metadata_latest_version_map(metadata);
463
464    for pkg in &metadata.packages {
465        // smoelius: Don't consider whether workspace members are unmaintained.
466        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                // The repository has not been cloned yet, so using the canonical GitHub URL as the
614                // timestamp cache key is optimistic. A later `clone_repository` call must still
615                // succeed before this timestamp is used.
616                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            // smoelius: Mercurial repos get a pass.
690            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        // smoelius: Skip dependencies with specified platforms that are not the host.
785        if !opts::get().all_targets
786            && let Some(platform) = &dep.target
787            && !platform.matches(&HOST, &[])
788        {
789            continue;
790        }
791        // smoelius: Don't check dependencies in private registries.
792        if dep.registry.is_some() {
793            continue;
794        }
795        // smoelius: Don't check dependencies specified by path.
796        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            // smoelius: I don't understand why a package can fail to be in the index, but I have
808            // seen it happen.
809            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            // smoelius: Require at least one incompatible version of the dependency that is more
816            // than `max_age` days old.
817            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    // smoelius: A dependency could appear more than once, e.g., because it is used with different
840    // features as a normal and as a development dependency.
841    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        // smoelius: Check both the regular and the shortened url.
937        for url in urls(pkg) {
938            if let Some(&repo_status) = timestamp_cache.get(&url) {
939                // smoelius: If a previous attempt to timestamp the repository failed (e.g., because
940                // of spurious network errors), then don't bother checking the repository cache.
941                let Some((url_timestamped, &timestamp)) = repo_status.as_success() else {
942                    return Ok(repo_status);
943                };
944                assert_eq!(url, url_timestamped);
945                // smoelius: `pkg`'s repository could contain other packages that were already
946                // timestamped. Thus, `pkg`'s repository could already be in the timestamp cache.
947                // But in that case, we still need to verify that `pkg` appears in its repository.
948                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            // smoelius: In the event of failure, set all urls associated with the
961            // repository.
962            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            // smoelius: Check all urls associated with the package.
1005            for url in urls(pkg) {
1006                if let Some(repo_status) = repository_cache.get(&url) {
1007                    return Ok(repo_status.clone());
1008                }
1009            }
1010            // smoelius: To make verbose printing easier, "membership" is printed regardless of the
1011            // check's purpose, and the `Purpose` type was removed.
1012            /* let what = match purpose {
1013                Purpose::Membership => "membership",
1014                Purpose::Timestamp => "timestamp",
1015            }; */
1016            verbose::wrap!(
1017                || {
1018                    let url_and_dir = cache.clone_repository(pkg);
1019                    match url_and_dir {
1020                        Ok((url_string, repo_dir)) => {
1021                            // smoelius: Note the use of `leak` in the next line. But the url is
1022                            // acting as a key in a global map, so it is not so bad.
1023                            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                                // smoelius: If cloning failed because the repository does not
1032                                // exist, adjust the repo status.
1033                                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                            // smoelius: In the event of failure, set all urls associated with
1045                            // the repository.
1046                            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    // smoelius: Even if `purpose` is `Purpose::Timestamp`, verify that `pkg` is a member of the
1065    // repository.
1066    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        /* smoelius: This "failed to parse" warning is a little too noisy.
1098        .map_err(|error| {
1099            warn!(
1100                "failed to parse {:?}: {}",
1101                path,
1102                error.to_string().trim_end()
1103            );
1104        }) */
1105        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    // smoelius: Hack. It appears that `cargo tree` does not print proc-macros used by proc-macros.
1212    // For now, check whether stdout begins as expected. If not, ignore it and ultimately emit a
1213    // warning.
1214    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}