Repository: mozilla/cargo-vet
Pull request #434 (2023-03-10 21:23:01)

Add support for registry suggestions
This adds suggestions from the registry to the `cargo vet` and `cargo vet suggest` suggestion output. This is done by fetching the registry and the corresponding audits files when generating suggestions, and comparing the suggestion output from before and after to see if it would make an improvement.

Currently this adds no information to the JSON output to encode the registry suggestion information, though we may want to encode it somehow in the future.

Fixes https://github.com/mozilla/cargo-vet/issues/104

mystor: Builds on top of #433, so leaving as a draft until that is merged.
mystor: Also requesting a review from @bholley to look over the ux

Patch:
diff --git a/src/cli.rs b/src/cli.rs
index 4e197ec..a5c3a37 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -3,7 +3,7 @@ use std::{path::PathBuf, str::FromStr};
 use clap::{Parser, Subcommand, ValueEnum};
 use tracing::level_filters::LevelFilter;
 
-use crate::format::{CriteriaName, PackageName, VersionReq, VetVersion};
+use crate::format::{CriteriaName, ImportName, PackageName, VersionReq, VetVersion};
 
 #[derive(Parser)]
 #[clap(version, about, long_about = None)]
@@ -69,6 +69,12 @@ pub struct Cli {
     #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
     pub no_minimize_exemptions: bool,
 
+    /// Prevent commands such as `check` and `suggest` from suggesting registry
+    /// imports.
+    #[clap(long, action)]
+    #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
+    pub no_registry_suggestions: bool,
+
     /// How verbose logging should be (log level)
     #[clap(long, action)]
     #[clap(default_value_t = LevelFilter::WARN)]
@@ -256,6 +262,13 @@ pub enum Commands {
     #[clap(disable_version_flag = true)]
     Certify(CertifyArgs),
 
+    /// Import a new peer's imports
+    ///
+    /// If invoked without a URL parameter, it will look up the named peer in
+    /// the cargo-vet registry, and import that peer.
+    #[clap(disable_version_flag = true)]
+    Import(ImportArgs),
+
     /// Explicitly regenerate various pieces of information
     ///
     /// There are several things that `cargo vet` *can* do for you automatically
@@ -492,6 +505,20 @@ pub struct CertifyArgs {
     pub force: bool,
 }
 
+/// Import a new peer
+#[derive(clap::Args)]
+pub struct ImportArgs {
+    /// The name of the peer to import
+    #[clap(action)]
+    pub name: ImportName,
+    /// The URL of the peer's audits.toml file.
+    ///
+    /// If a URL is not provided, a peer with the given name will be looked up
+    /// in the cargo-vet registry to determine the import URL.
+    #[clap(action)]
+    pub url: Option<String>,
+}
+
 /// Forbids the given version
 #[derive(clap::Args)]
 pub struct RecordViolationArgs {
diff --git a/src/errors.rs b/src/errors.rs
index f51dd90..bf329e0 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -757,6 +757,9 @@ pub enum SuggestError {
     #[error(transparent)]
     #[diagnostic(transparent)]
     CacheAcquire(#[from] CacheAcquireError),
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    FetchAudit(#[from] FetchAuditError),
 }
 
 //////////////////////////////////////////////////////////
diff --git a/src/format.rs b/src/format.rs
index 391d0d7..f9e8733 100644
--- a/src/format.rs
+++ b/src/format.rs
@@ -957,6 +957,26 @@ pub struct CratesAPICrate {
     pub versions: Vec<CratesAPIVersion>,
 }
 
+////////////////////////////////////////////////////////////////////////////////////
+//                                                                                //
+//                                                                                //
+//                                                                                //
+//                               registry.toml                                    //
+//                                                                                //
+//                                                                                //
+//                                                                                //
+////////////////////////////////////////////////////////////////////////////////////
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct RegistryFile {
+    pub registry: SortedMap<ImportName, RegistryEntry>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct RegistryEntry {
+    pub url: String,
+}
+
 ////////////////////////////////////////////////////////////////////////////////////
 //                                                                                //
 //                                                                                //
diff --git a/src/main.rs b/src/main.rs
index 9a9dfc2..7b09086 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -28,6 +28,7 @@ use out::{progress_bar, IncProgressOnDrop};
 use reqwest::Url;
 use serde::de::Deserialize;
 use serialization::spanned::Spanned;
+use storage::fetch_registry;
 use thiserror::Error;
 use tracing::{error, info, trace, warn};
 
@@ -36,8 +37,8 @@ use crate::criteria::CriteriaMapper;
 use crate::errors::{CommandError, DownloadError, FetchAndDiffError, FetchError, SourceFile};
 use crate::format::{
     AuditEntry, AuditKind, AuditsFile, ConfigFile, CratesUserId, CriteriaEntry, ExemptedDependency,
-    FetchCommand, ImportsFile, MetaConfig, MetaConfigInstance, PackageStr, SortedMap, StoreInfo,
-    WildcardEntry,
+    FetchCommand, ImportsFile, MetaConfig, MetaConfigInstance, PackageStr, RemoteImport, SortedMap,
+    StoreInfo, WildcardEntry,
 };
 use crate::git_tool::Pager;
 use crate::out::{indeterminate_spinner, Out, StderrLogWriter, MULTIPROGRESS};
@@ -464,6 +465,7 @@ fn real_main() -> Result<(), miette::Report> {
         Some(Check(sub_args)) => cmd_check(&out, &cfg, sub_args),
         Some(Init(sub_args)) => cmd_init(&out, &cfg, sub_args),
         Some(Certify(sub_args)) => cmd_certify(&out, &cfg, sub_args),
+        Some(Import(sub_args)) => cmd_import(&out, &cfg, sub_args),
         Some(AddExemption(sub_args)) => cmd_add_exemption(&out, &cfg, sub_args),
         Some(RecordViolation(sub_args)) => cmd_record_violation(&out, &cfg, sub_args),
         Some(Suggest(sub_args)) => cmd_suggest(&out, &cfg, sub_args),
@@ -1105,7 +1107,7 @@ fn guess_audit_criteria(
     resolver::resolve(
         &cfg.metadata,
         cfg.cli.filter_graph.as_ref(),
-        &store.clone_for_suggest(),
+        &store.clone_for_suggest(true),
     )
     .compute_suggested_criteria(package, from, to)
 }
@@ -1198,6 +1200,67 @@ async fn prompt_criteria_eulas(
     Ok(())
 }
 
+fn cmd_import(
+    _out: &Arc<dyn Out>,
+    cfg: &Config,
+    sub_args: &ImportArgs,
+) -> Result<(), miette::Report> {
+    let Some(network) = Network::acquire(cfg) else {
+        return Err(miette!("`cargo vet import` cannot be run while frozen"));
+    };
+
+    // Determine the URL for the import, potentially fetching the registry to
+    // find it.
+    let registry_file;
+    let import_url = match &sub_args.url {
+        Some(url) => url,
+        None => {
+            registry_file = tokio::runtime::Handle::current().block_on(fetch_registry(&network))?;
+            registry_file
+                .registry
+                .get(&sub_args.name)
+                .ok_or_else(|| miette!("no peer named {} found in the registry", &sub_args.name))
+                .map(|entry| &entry.url)?
+        }
+    };
+
+    let mut store = Store::acquire_offline(cfg)?;
+
+    // Insert a new entry for the new import.
+    use std::collections::btree_map::Entry;
+    match store.config.imports.entry(sub_args.name.clone()) {
+        Entry::Occupied(_) => {
+            return Err(miette!(
+                "a peer named '{}', has already been imported",
+                &sub_args.name
+            ))
+        }
+        Entry::Vacant(vacant) => {
+            vacant.insert(RemoteImport {
+                url: import_url.clone(),
+                exclude: Vec::new(),
+                criteria_map: SortedMap::new(),
+            });
+        }
+    }
+
+    // After adding the new entry, go online, this will fetch the new import.
+    let cache = Cache::acquire(cfg)?;
+    tokio::runtime::Handle::current().block_on(store.go_online(cfg, &network, &cache, false))?;
+
+    // Update the store state, pruning unnecessary exemptions, and cleaning out
+    // unnecessary old imports.
+    resolver::update_store(cfg, &mut store, |_| resolver::UpdateMode {
+        search_mode: resolver::SearchMode::PreferFreshImports,
+        prune_exemptions: true,
+        prune_imports: true,
+    });
+
+    store.commit()?;
+
+    Ok(())
+}
+
 fn cmd_record_violation(
     out: &Arc<dyn Out>,
     cfg: &Config,
@@ -1341,11 +1404,11 @@ fn cmd_suggest(
     // Run the checker to validate that the current set of deps is covered by the current cargo vet store
     trace!("suggesting...");
     let network = Network::acquire(cfg);
-    let suggest_store = Store::acquire(cfg, network.as_ref(), false)?.clone_for_suggest();
+    let suggest_store = Store::acquire(cfg, network.as_ref(), false)?.clone_for_suggest(true);
 
     // DO THE THING!!!!
     let report = resolver::resolve(&cfg.metadata, cfg.cli.filter_graph.as_ref(), &suggest_store);
-    let suggest = report.compute_suggest(cfg, network.as_ref(), true)?;
+    let suggest = report.compute_suggest(cfg, &suggest_store, network.as_ref())?;
     match cfg.cli.output_format {
         OutputFormat::Human => report
             .print_suggest_human(out, cfg, suggest.as_ref())
@@ -1630,7 +1693,7 @@ fn cmd_check(
 
     // Bare `cargo vet` shouldn't suggest in CI
     let suggest = if !cfg.cli.locked {
-        report.compute_suggest(cfg, network.as_ref(), true)?
+        report.compute_suggest(cfg, &store, network.as_ref())?
     } else {
         None
     };
diff --git a/src/resolver.rs b/src/resolver.rs
index 7168269..67c629d 100644
--- a/src/resolver.rs
+++ b/src/resolver.rs
@@ -47,9 +47,9 @@ use serde::{Deserialize, Serialize};
 use std::cmp::Reverse;
 use std::collections::BinaryHeap;
 use std::sync::Arc;
-use tracing::{error, trace, trace_span};
+use tracing::{error, trace, trace_span, warn};
 
-use crate::cli::{DumpGraphArgs, GraphFilter, GraphFilterProperty, GraphFilterQuery};
+use crate::cli::{DumpGraphArgs, GraphFilter, GraphFilterProperty, GraphFilterQuery, OutputFormat};
 use crate::criteria::{CriteriaMapper, CriteriaSet};
 use crate::errors::SuggestError;
 use crate::format::{
@@ -143,6 +143,7 @@ pub struct SuggestItem {
     pub suggested_criteria: CriteriaSet,
     pub suggested_diff: DiffRecommendation,
     pub notable_parents: String,
+    pub registry_suggestion: Vec<RegistrySuggestion>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -152,6 +153,13 @@ pub struct DiffRecommendation {
     pub diffstat: DiffStat,
 }
 
+#[derive(Debug, Clone)]
+pub struct RegistrySuggestion {
+    pub name: ImportName,
+    pub url: String,
+    pub diff: DiffRecommendation,
+}
+
 /// An "interned" cargo PackageId which is used to uniquely identify packages throughout
 /// the code. This is simpler and faster than actually using PackageIds (strings) or name+version.
 /// In the current implementation it can be used to directly index into the `graph` or `results`.
@@ -910,7 +918,7 @@ fn resolve_audits(
                 return None; // first-party crates don't need audits
             }
 
-            let audit_graph = AuditGraph::build(store, criteria_mapper, package.name)
+            let audit_graph = AuditGraph::build(store, criteria_mapper, package.name, None)
                 .map_err(|v| violations.push((pkgidx, v)))
                 .ok()?;
 
@@ -984,6 +992,7 @@ impl<'a> AuditGraph<'a> {
         store: &'a Store,
         criteria_mapper: &CriteriaMapper,
         package: PackageStr<'_>,
+        extra_audits_file: Option<&'a AuditsFile>,
     ) -> Result<Self, Vec<ViolationConflict>> {
         // Pre-build the namespaces for each audit so that we can take a reference
         // to each one as-needed rather than cloning the name each time.
@@ -1005,7 +1014,14 @@ impl<'a> AuditGraph<'a> {
                     audits_file,
                 )
             })
-            .chain([(None, &None, &store.audits)]);
+            .chain([(None, &None, &store.audits)])
+            .chain(
+                // Consider extra audits as local for now - we don't care about
+                // how the audits from it are prioritized.
+                extra_audits_file
+                    .iter()
+                    .map(|&audits_file| (None, &None, audits_file)),
+            );
 
         // Iterator over every normal audit.
         let all_audits =
@@ -1507,8 +1523,8 @@ impl<'a> ResolveReport<'a> {
     pub fn compute_suggest(
         &self,
         cfg: &Config,
+        store: &Store,
         network: Option<&Network>,
-        allow_deltas: bool,
     ) -> Result<Option<Suggest>, SuggestError> {
         let _suggest_span = trace_span!("suggest").entered();
         let fail = if let Conclusion::FailForVet(fail) = &self.conclusion {
@@ -1520,6 +1536,20 @@ impl<'a> ResolveReport<'a> {
 
         let cache = Cache::acquire(cfg)?;
 
+        let mut store = store.clone_for_suggest(false);
+        let registry = if let (false, OutputFormat::Human, Some(network)) = (
+            cfg.cli.no_registry_suggestions,
+            cfg.cli.output_format,
+            network,
+        ) {
+            Some(
+                tokio::runtime::Handle::current()
+                    .block_on(store.fetch_registry_audits(cfg, network, &cache))?,
+            )
+        } else {
+            None
+        };
+
         let suggest_progress =
             progress_bar("Suggesting", "relevant audits", fail.failures.len() as u64);
 
@@ -1562,130 +1592,78 @@ impl<'a> ResolveReport<'a> {
                         reverse_deps.join(", ")
                     };
 
-                    // Collect up the details of how we failed
-                    let mut from_root = None::<SortedSet<&Option<VetVersion>>>;
-                    let mut from_target = None::<SortedSet<&Option<VetVersion>>>;
-                    for criteria_idx in audit_failure.criteria_failures.indices() {
-                        let search_result = &result.search_results[criteria_idx];
-                        if let Err(SearchFailure {
-                            reachable_from_root,
-                            reachable_from_target,
-                        }) = search_result
-                        {
-                            if let (Some(from_root), Some(from_target)) =
-                                (from_root.as_mut(), from_target.as_mut())
-                            {
-                                // FIXME: this is horrible but I'm tired and this avoids false-positives
-                                // and duplicates. This does the right thing in the common cases, by
-                                // restricting ourselves to the reachable nodes that are common to all
-                                // failures, so that we can suggest just one change that will fix
-                                // everything.
-                                from_root.retain(|ver| reachable_from_root.contains(ver));
-                                from_target.retain(|ver| reachable_from_target.contains(ver));
-                            } else {
-                                // We only have sources available for the
-                                // package itself, and any non-git versions.
-                                let version_has_sources = |ver: &&Option<VetVersion>| -> bool {
-                                    ver.as_ref() == Some(&package.version)
-                                        || !matches!(
-                                            ver,
-                                            Some(VetVersion {
-                                                git_rev: Some(_),
-                                                ..
-                                            })
+                    let suggested_diff = suggest_delta(
+                        &cfg.metadata,
+                        network,
+                        &cache,
+                        package,
+                        audit_failure
+                            .criteria_failures
+                            .indices()
+                            .map(|criteria_idx| {
+                                result.search_results[criteria_idx].as_ref().unwrap_err()
+                            }),
+                    )
+                    .await?;
+
+                    let mut registry_suggestion: Vec<_> = join_all(registry.iter().flatten().map(
+                        |(name, entry, audits)| async {
+                            let audit_graph = AuditGraph::build(
+                                &store,
+                                &self.criteria_mapper,
+                                package.name,
+                                Some(audits),
+                            )
+                            .ok()?;
+
+                            let failures: Vec<_> = audit_failure
+                                .criteria_failures
+                                .indices()
+                                .filter_map(|criteria_idx| {
+                                    audit_graph
+                                        .search(
+                                            criteria_idx,
+                                            &package.version,
+                                            SearchMode::PreferExemptions,
                                         )
-                                };
-                                from_root = Some(
-                                    reachable_from_root
-                                        .iter()
-                                        .filter(version_has_sources)
-                                        .collect(),
-                                );
-                                from_target = Some(
-                                    reachable_from_target
-                                        .iter()
-                                        .filter(version_has_sources)
-                                        .collect(),
-                                );
-                            }
-                        } else {
-                            unreachable!("messed up suggest...");
-                        }
-                    }
-
-                    // Now suggest solutions of those failures
-                    let mut candidates = SortedSet::new();
-                    if allow_deltas {
-                        // If we're allowed deltas than try to find a bridge from src and dest
-                        for &dest in from_target.as_ref().unwrap() {
-                            let mut closest_above = None;
-                            let mut closest_below = None;
-                            for &src in from_root.as_ref().unwrap() {
-                                if src < dest {
-                                    if let Some(closest) = closest_below {
-                                        if src > closest {
-                                            closest_below = Some(src);
-                                        }
-                                    } else {
-                                        closest_below = Some(src);
-                                    }
-                                } else if let Some(closest) = closest_above {
-                                    if src < closest {
-                                        closest_above = Some(src);
-                                    }
-                                } else {
-                                    closest_above = Some(src);
-                                }
-                            }
-
-                            for closest in closest_below.into_iter().chain(closest_above) {
-                                candidates.insert(Delta {
-                                    from: closest.clone(),
-                                    to: dest.clone().unwrap(),
-                                });
-                            }
-                        }
-                    } else {
-                        // If we're not allowing deltas, just try everything reachable from the target
-                        for &dest in from_target.as_ref().unwrap() {
-                            candidates.insert(Delta {
-                                from: None,
-                                to: dest.clone().unwrap(),
-                            });
-                        }
-                    }
+                                        .err()
+                                })
+                                .collect();
+
+                            let registry_suggested_diff = suggest_delta(
+                                &cfg.metadata,
+                                network,
+                                &cache,
+                                package,
+                                failures.iter(),
+                            )
+                            .await?;
 
-                    let diffstats = join_all(candidates.iter().map(|delta| async {
-                        match cache
-                            .fetch_and_diffstat_package(&cfg.metadata, network, package.name, delta)
-                            .await
-                        {
-                            Ok(diffstat) => Some(DiffRecommendation {
-                                diffstat,
-                                from: delta.from.clone(),
-                                to: delta.to.clone(),
-                            }),
-                            Err(err) => {
-                                // We don't want to actually error out completely here,
-                                // as other packages might still successfully diff!
-                                error!(
-                                    "error diffing {}:{}: {:?}",
-                                    package.name, package.version, err
-                                );
+                            if registry_suggested_diff.diffstat.count()
+                                < suggested_diff.diffstat.count()
+                            {
+                                Some(RegistrySuggestion {
+                                    name: name.clone(),
+                                    url: entry.url.clone(),
+                                    diff: registry_suggested_diff,
+                                })
+                            } else {
                                 None
                             }
-                        }
-                    }))
-                    .await;
+                        },
+                    ))
+                    .await
+                    .into_iter()
+                    .flatten()
+                    .collect();
+                    registry_suggestion.sort_by_key(|suggestion| suggestion.diff.diffstat.count());
 
                     Some(SuggestItem {
                         package: failure_idx,
-                        suggested_diff: diffstats
-                            .into_iter()
-                            .flatten()
-                            .min_by_key(|diff| diff.diffstat.count())?,
+                        suggested_diff,
                         suggested_criteria: audit_failure.criteria_failures.clone(),
                         notable_parents,
+                        registry_suggestion,
                     })
                 },
             )))
@@ -1996,7 +1974,7 @@ impl Suggest {
                         Some(_) => format!("({})", item.suggested_diff.diffstat),
                         None => format!("({} lines)", item.suggested_diff.diffstat.count()),
                     };
-                    (cmd, parents, diffstat)
+                    (cmd, parents, diffstat, item)
                 })
                 .collect::<Vec<_>>();
 
@@ -2007,7 +1985,7 @@ impl Suggest {
                 max1 = max1.max(console::measure_text_width(s1));
             }
 
-            for (s0, s1, s2) in strings {
+            for (s0, s1, s2, item) in strings {
                 write!(
                     out,
                     "{}",
@@ -2017,6 +1995,24 @@ impl Suggest {
                         .apply_to(format_args!("    {s0:max0$}"))
                 );
                 writeln!(out, "  {s1:max1$}  {s2}");
+
+                let dim = out.style().dim();
+                for suggestion in &item.registry_suggestion {
+                    writeln!(
+                        out,
+                        "      {} {} {}",
+                        dim.clone().apply_to("NOTE:"),
+                        dim.clone()
+                            .cyan()
+                            .bold()
+                            .apply_to(format_args!("cargo vet import {}", suggestion.name)),
+                        dim.clone()
+                            .apply_to(match suggestion.diff.diffstat.count() {
+                                0 => "would eliminate this".to_owned(),
+                                n => format!("would reduce this to a {n}-line diff"),
+                            }),
+                    );
+                }
             }
 
             writeln!(out);
@@ -2161,6 +2157,114 @@ impl FailForViolationConflict {
     }
 }
 
+async fn suggest_delta(
+    metadata: &cargo_metadata::Metadata,
+    network: Option<&Network>,
+    cache: &Cache,
+    package: &PackageNode<'_>,
+    failures: impl Iterator<Item = &SearchFailure>,
+) -> Option<DiffRecommendation> {
+    // Collect up the details of how we failed
+    struct Reachable<'a> {
+        from_root: SortedSet<&'a Option<VetVersion>>,
+        from_target: SortedSet<&'a Option<VetVersion>>,
+    }
+    let mut reachable = None::<Reachable<'_>>;
+    for SearchFailure {
+        reachable_from_root,
+        reachable_from_target,
+    } in failures
+    {
+        if let Some(Reachable {
+            from_root,
+            from_target,
+        }) = reachable.as_mut()
+        {
+            // This does the right thing in the common cases, by restricting
+            // ourselves to the reachable nodes that are common to all failures,
+            // so that we can suggest just one change that will fix everything.
+            from_root.retain(|ver| reachable_from_root.contains(ver));
+            from_target.retain(|ver| reachable_from_target.contains(ver));
+        } else {
+            // We only have sources available for the package itself, and any
+            // non-git versions.
+            let version_has_sources = |ver: &&Option<VetVersion>| -> bool {
+                ver.as_ref() == Some(&package.version)
+                    || !matches!(
+                        ver,
+                        Some(VetVersion {
+                            git_rev: Some(_),
+                            ..
+                        })
+                    )
+            };
+            reachable = Some(Reachable {
+                from_root: reachable_from_root
+                    .iter()
+                    .filter(version_has_sources)
+                    .collect(),
+                from_target: reachable_from_target
+                    .iter()
+                    .filter(version_has_sources)
+                    .collect(),
+            });
+        }
+    }
+
+    let Some(Reachable { from_root, from_target }) = &reachable else {
+        // Nothing failed, return a dummy suggestion for an empty diff.
+        return Some(DiffRecommendation {
+            from: Some(package.version.clone()),
+            to: package.version.clone(),
+            diffstat: DiffStat { insertions: 0, deletions: 0, files_changed: 0 },
+        });
+    };
+
+    // Now suggest solutions of those failures
+    let mut candidates = SortedSet::new();
+    for &dest in from_target {
+        let closest_above = from_root.range::<&Option<VetVersion>, _>(dest..).next();
+        let closest_below = from_root
+            .range::<&Option<VetVersion>, _>(..dest)
+            .next_back();
+
+        for &closest in closest_below.into_iter().chain(closest_above) {
+            candidates.insert(Delta {
+                from: closest.clone(),
+                to: dest.clone().unwrap(),
+            });
+        }
+    }
+
+    let diffstats = join_all(candidates.iter().map(|delta| async {
+        match cache
+            .fetch_and_diffstat_package(metadata, network, package.name, delta)
+            .await
+        {
+            Ok(diffstat) => Some(DiffRecommendation {
+                diffstat,
+                from: delta.from.clone(),
+                to: delta.to.clone(),
+            }),
+            Err(err) => {
+                // We don't want to actually error out completely here,
+                // as other packages might still successfully diff!
+                error!(
+                    "error diffing {}:{}: {:?}",
+                    package.name, package.version, err
+                );
+                None
+            }
+        }
+    }))
+    .await;
+
+    diffstats
+        .into_iter()
+        .flatten()
+        .min_by_key(|diff| diff.diffstat.count())
+}
+
 /// Resolve which entries in the store and imports.lock are required to
 /// successfully audit this package. May return `None` if the package cannot
 /// successfully vet given the restrictions placed upon it.
@@ -2195,7 +2299,7 @@ fn resolve_package_required_entries(
         return Some(SortedMap::new());
     }
 
-    let Ok(audit_graph) = AuditGraph::build(store, criteria_mapper, package_name) else {
+    let Ok(audit_graph) = AuditGraph::build(store, criteria_mapper, package_name, None) else {
         // There were violations when building the audit graph, return `None` to
         // indicate that this package is failing.
         return None;
diff --git a/src/storage.rs b/src/storage.rs
index b2ec77d..500b77b 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -10,7 +10,7 @@ use std::{
 
 use cargo_metadata::semver;
 use flate2::read::GzDecoder;
-use futures_util::future::try_join_all;
+use futures_util::future::{join_all, try_join_all};
 use miette::SourceOffset;
 use reqwest::Url;
 use serde::{Deserialize, Serialize};
@@ -31,11 +31,11 @@ use crate::{
     flock::{FileLock, Filesystem},
     format::{
         self, AuditEntry, AuditKind, AuditedDependencies, AuditsFile, CommandHistory, ConfigFile,
-        CratesAPICrate, CratesPublisher, CriteriaEntry, CriteriaName, Delta, DiffCache, DiffStat,
-        FastMap, FastSet, FetchCommand, ForeignAuditsFile, ImportName, ImportsFile, MetaConfig,
-        PackageName, PackageStr, PublisherCache, PublisherCacheEntry, PublisherCacheUser,
-        PublisherCacheVersion, SortedMap, VetVersion, WildcardAudits, WildcardEntry,
-        SAFE_TO_DEPLOY, SAFE_TO_RUN,
+        CratesAPICrate, CratesPublisher, CriteriaEntry, CriteriaName, CriteriaStr, Delta,
+        DiffCache, DiffStat, FastMap, FastSet, FetchCommand, ForeignAuditsFile, ImportName,
+        ImportsFile, MetaConfig, PackageName, PackageStr, PublisherCache, PublisherCacheEntry,
+        PublisherCacheUser, PublisherCacheVersion, RegistryEntry, RegistryFile, RemoteImport,
+        SortedMap, VetVersion, WildcardAudits, WildcardEntry, SAFE_TO_DEPLOY, SAFE_TO_RUN,
     },
     network::Network,
     out::{indeterminate_spinner, progress_bar, IncProgressOnDrop},
@@ -94,6 +94,10 @@ const MAX_CONCURRENT_DIFFS: usize = 40;
 // Re-check if a relevant version was not published every day.
 const NONINDEX_VERSION_PUBLISHER_REFRESH_DAYS: i64 = 1;
 
+// Url of the registry.
+pub const REGISTRY_URL: &str =
+    "https://raw.githubusercontent.com/mozilla/cargo-vet/main/registry.toml";
+
 struct StoreLock {
     config: FileLock,
 }
@@ -193,18 +197,6 @@ impl Store {
     }
 
     pub fn acquire_offline(cfg: &Config) -> Result<Self, StoreAcquireError> {
-        Self::acquire(cfg, None, false)
-    }
-
-    /// Acquire an existing store
-    ///
-    /// If `network` is passed and `!cfg.cli.locked`, this will fetch remote
-    /// imports to use for comparison purposes.
-    pub fn acquire(
-        cfg: &Config,
-        network: Option<&Network>,
-        allow_criteria_changes: bool,
-    ) -> Result<Self, StoreAcquireError> {
         let root = cfg.metacfg.store_path();
 
         // Before we do anything else, acquire an exclusive lock on the
@@ -218,43 +210,12 @@ impl Store {
         let (imports_src, imports): (_, ImportsFile) =
             load_toml(IMPORTS_LOCK, lock.read_imports()?)?;
 
-        // If this command isn't locked, and the network is available, fetch the
-        // live state of imported audits.
-        let live_imports = if let (false, Some(network)) = (cfg.cli.locked, network) {
-            let fetched_audits = tokio::runtime::Handle::current()
-                .block_on(fetch_imported_audits(network, &config))?;
-            let mut live_imports = process_imported_audits(
-                fetched_audits,
-                &audits,
-                &config,
-                &imports,
-                allow_criteria_changes,
-            )?;
-            // XXX: Allow callers to hold on to this cache so we don't need to
-            // re-acquire it after `Store::acquire`.
-            let cache = Cache::acquire(cfg).map_err(Box::new)?;
-            import_publisher_versions(
-                &cfg.metadata,
-                network,
-                &cache,
-                &wildcard_audits_packages(&audits, &live_imports),
-                &config,
-                &audits,
-                &imports,
-                &mut live_imports,
-            )
-            .map_err(Box::new)?;
-            Some(live_imports)
-        } else {
-            None
-        };
-
         let store = Self {
             lock: Some(lock),
             config,
             audits,
             imports,
-            live_imports,
+            live_imports: None,
             config_src,
             audits_src,
             imports_src,
@@ -269,6 +230,69 @@ impl Store {
         Ok(store)
     }
 
+    /// Acquire an existing store
+    ///
+    /// If `network` is passed and `!cfg.cli.locked`, this will fetch remote
+    /// imports to use for comparison purposes.
+    pub fn acquire(
+        cfg: &Config,
+        network: Option<&Network>,
+        allow_criteria_changes: bool,
+    ) -> Result<Self, StoreAcquireError> {
+        let mut this = Self::acquire_offline(cfg)?;
+        if let Some(network) = network {
+            let cache = Cache::acquire(cfg).map_err(Box::new)?;
+            tokio::runtime::Handle::current().block_on(this.go_online(
+                cfg,
+                network,
+                &cache,
+                allow_criteria_changes,
+            ))?;
+
+            // re-validate
+            let today = <chrono::DateTime<chrono::Utc>>::from(SystemTime::now()).date_naive();
+            this.validate(today, cfg.cli.locked)?;
+        }
+        Ok(this)
+    }
+
+    pub async fn go_online(
+        &mut self,
+        cfg: &Config,
+        network: &Network,
+        cache: &Cache,
+        allow_criteria_changes: bool,
+    ) -> Result<(), StoreAcquireError> {
+        if cfg.cli.locked {
+            return Ok(());
+        }
+
+        // If this command isn't locked, and the network is available, fetch the
+        // live state of imported audits.
+        let fetched_audits = fetch_imported_audits(network, &self.config).await?;
+        let mut live_imports = process_imported_audits(
+            fetched_audits,
+            &self.audits,
+            &self.config,
+            &self.imports,
+            allow_criteria_changes,
+        )?;
+        import_publisher_versions(
+            &cfg.metadata,
+            network,
+            cache,
+            &wildcard_audits_packages(&self.audits, &live_imports),
+            &self.config,
+            &self.audits,
+            &self.imports,
+            &mut live_imports,
+        )
+        .await
+        .map_err(Box::new)?;
+        self.live_imports = Some(live_imports);
+        Ok(())
+    }
+
     /// Create a mock store
     #[cfg(test)]
     pub fn mock(config: ConfigFile, audits: AuditsFile, imports: ImportsFile) -> Self {
@@ -308,17 +332,18 @@ impl Store {
             allow_criteria_changes,
         )?;
         let cache = Cache::acquire(cfg).map_err(Box::new)?;
-        import_publisher_versions(
-            &cfg.metadata,
-            network,
-            &cache,
-            &wildcard_audits_packages(&audits, &live_imports),
-            &config,
-            &audits,
-            &imports,
-            &mut live_imports,
-        )
-        .map_err(Box::new)?;
+        tokio::runtime::Handle::current()
+            .block_on(import_publisher_versions(
+                &cfg.metadata,
+                network,
+                &cache,
+                &wildcard_audits_packages(&audits, &live_imports),
+                &config,
+                &audits,
+                &imports,
+                &mut live_imports,
+            ))
+            .map_err(Box::new)?;
 
         let store = Self {
             lock: None,
@@ -368,14 +393,15 @@ impl Store {
 
     /// Create a clone of the store for use to resolve `suggest`.
     ///
-    /// This cloned store will not contain `exemptions` entries from the config,
-    /// unless they're marked as `suggest = false`, such that the resolver will
-    /// identify these missing audits when generating a report.
+    /// If `clear_exemptions` is passed, this cloned store will not contain
+    /// `exemptions` entries from the config, unless they're marked as `suggest
+    /// = false`, such that the resolver will identify these missing audits when
+    /// generating a report.
     ///
     /// Unlike the primary store created with `Store::acquire` or
     /// `Store::create`, this store will not hold the store lock, and cannot be
     /// committed to disk by calling `commit()`.
-    pub fn clone_for_suggest(&self) -> Self {
+    pub fn clone_for_suggest(&self, clear_exemptions: bool) -> Self {
         let mut clone = Self {
             lock: None,
             config: self.config.clone(),
@@ -386,9 +412,11 @@ impl Store {
             audits_src: self.audits_src.clone(),
             imports_src: self.imports_src.clone(),
         };
-        // Delete all exemptions entries except those that are suggest=false
-        for versions in &mut clone.config.exemptions.values_mut() {
-            versions.retain(|e| !e.suggest);
+        if clear_exemptions {
+            // Delete all exemptions entries except those that are suggest=false
+            for versions in &mut clone.config.exemptions.values_mut() {
+                versions.retain(|e| !e.suggest);
+            }
         }
         clone
     }
@@ -672,7 +700,7 @@ impl Store {
     ) -> Result<&[CratesPublisher], CertifyError> {
         if let (Some(network), Some(live_imports)) = (network, self.live_imports.as_mut()) {
             let cache = Cache::acquire(cfg)?;
-            import_publisher_versions(
+            tokio::runtime::Handle::current().block_on(import_publisher_versions(
                 &cfg.metadata,
                 network,
                 &cache,
@@ -681,7 +709,7 @@ impl Store {
                 &self.audits,
                 &self.imports,
                 live_imports,
-            )?;
+            ))?;
 
             Ok(live_imports
                 .publisher
@@ -692,6 +720,96 @@ impl Store {
             Ok(&[])
         }
     }
+
+    /// Called when suggesting in order to fetch all audits from potential peers
+    /// in the registry, in case a registry import could solve an encountered
+    /// problem.
+    pub async fn fetch_registry_audits(
+        &mut self,
+        cfg: &Config,
+        network: &Network,
+        cache: &Cache,
+    ) -> Result<Vec<(ImportName, RegistryEntry, AuditsFile)>, FetchAuditError> {
+        let mut registry_file = fetch_registry(network).await?;
+
+        // Filter out registry entries which are already imported or would
+        // conflict with an existing import (i.e. an import exists with the same
+        // name or url).
+        registry_file.registry.retain(|name, entry| {
+            !self.config.imports.contains_key(name)
+                && !self
+                    .config
+                    .imports
+                    .values()
+                    .any(|import| import.url == entry.url)
+        });
+
+        let registry_entries = {
+            let progress_bar = progress_bar(
+                "Fetching",
+                "registry audits",
+                registry_file.registry.len() as u64,
+            );
+            let local_criteria_mapper = CriteriaMapper::new(&self.audits.criteria);
+            join_all(
+                registry_file
+                    .registry
+                    .iter()
+                    .map(|(name, entry)| (name.clone(), entry.clone()))
+                    .map(|(name, entry)| async {
+                        let _guard = IncProgressOnDrop(&progress_bar, 1);
+                        match fetch_imported_audit(network, &name, &entry.url).await {
+                            Ok(audit_file) => {
+                                let audit_file = process_imported_audit(
+                                    audit_file,
+                                    &local_criteria_mapper,
+                                    &RemoteImport {
+                                        url: entry.url.clone(),
+                                        ..Default::default()
+                                    },
+                                    None,
+                                    |_, _, _| (),
+                                );
+                                Some((name, entry, audit_file))
+                            }
+                            Err(error) => {
+                                error!("Error fetching registry audits for '{name}': {error:?}");
+                                None
+                            }
+                        }
+                    }),
+            )
+            .await
+            .into_iter()
+            .flatten()
+            .collect::<Vec<_>>()
+        };
+
+        // Re-run import_publisher_versions to ensure that we have all publisher
+        // information for any potential wildcard audit imports.
+        // Note: This is the only reason we need mutable access to the store.
+        // XXX: Consider limiting further to only packages which are currently
+        // failing to vet?
+        // XXX: Consider making this fetch async?
+        let wildcard_packages = registry_entries
+            .iter()
+            .flat_map(|(_, _, audits_file)| audits_file.wildcard_audits.keys())
+            .cloned()
+            .collect::<FastSet<_>>();
+        import_publisher_versions(
+            &cfg.metadata,
+            network,
+            cache,
+            &wildcard_packages,
+            &self.config,
+            &self.audits,
+            &self.imports,
+            self.live_imports.as_mut().unwrap(),
+        )
+        .await?;
+
+        Ok(registry_entries)
+    }
 }
 
 /// Process imported audits from the network, generating a `LiveImports`
@@ -710,157 +828,174 @@ fn process_imported_audits(
     let mut changed_criteria = Vec::new();
 
     let local_criteria_mapper = CriteriaMapper::new(&local_audits_file.criteria);
-    for (import_name, mut audits_file) in fetched_audits {
+    for (import_name, audits_file) in fetched_audits {
         let config = config_file
             .imports
             .get(&import_name)
             .expect("fetched audit without config?");
+        let existing_audits_file = imports_lock.audits.get(&import_name);
 
-        // Remove any excluded audits from the live copy. We'll effectively
-        // pretend they don't exist upstream.
-        for excluded in &config.exclude {
-            audits_file.audits.remove(excluded);
-        }
-
-        // Construct a mapping from the foreign criteria namespace into the
-        // local criteria namespace based on the criteria map from the config.
-        let foreign_criteria_mapper = CriteriaMapper::new(&audits_file.criteria);
-        let foreign_to_local_mapping: Vec<_> = foreign_criteria_mapper
-            .all_criteria_names()
-            .map(|foreign_name| {
-                // NOTE: We try the map before we check for built-in criteria to
-                // allow overriding the default behaviour.
-                if let Some(mapped) = config.criteria_map.get(foreign_name) {
-                    local_criteria_mapper.criteria_from_list(mapped)
-                } else if foreign_name == SAFE_TO_DEPLOY {
-                    local_criteria_mapper.criteria_from_list([SAFE_TO_DEPLOY])
-                } else if foreign_name == SAFE_TO_RUN {
-                    local_criteria_mapper.criteria_from_list([SAFE_TO_RUN])
-                } else {
-                    local_criteria_mapper.no_criteria()
+        let audits_file = process_imported_audit(
+            audits_file,
+            &local_criteria_mapper,
+            config,
+            existing_audits_file,
+            |criteria_name, old_desc, new_desc| {
+                if !allow_criteria_changes {
+                    // Compare the new criteria descriptions with existing criteria
+                    // descriptions. If the description already exists, record a
+                    // CriteriaChangeError.
+                    changed_criteria.push(CriteriaChangeError {
+                        import_name: import_name.clone(),
+                        criteria_name: criteria_name.to_owned(),
+                        unified_diff: unified_diff(Algorithm::Myers, old_desc, new_desc, 5, None),
+                    });
                 }
-            })
-            .collect();
+            },
+        );
 
-        // Helper to re-write foreign criteria into the local criteria
-        // namespace.
-        let make_criteria_local = |criteria: &mut Vec<Spanned<CriteriaName>>| {
-            let foreign_set = foreign_criteria_mapper.criteria_from_list(&*criteria);
-            let mut local_set = local_criteria_mapper.no_criteria();
-            for foreign_criteria_idx in foreign_set.indices() {
-                local_set.unioned_with(&foreign_to_local_mapping[foreign_criteria_idx]);
+        // Now add the new import
+        new_imports.audits.insert(import_name, audits_file);
+    }
+
+    if !changed_criteria.is_empty() {
+        return Err(CriteriaChangeErrors {
+            errors: changed_criteria,
+        });
+    }
+
+    // FIXME: Consider doing some additional validation on these audits
+    // before returning?
+
+    Ok(new_imports)
+}
+
+fn process_imported_audit(
+    mut audits_file: AuditsFile,
+    local_criteria_mapper: &CriteriaMapper,
+    config: &RemoteImport,
+    existing_audits_file: Option<&AuditsFile>,
+    mut on_changed_criteria_description: impl FnMut(CriteriaStr<'_>, &str, &str),
+) -> AuditsFile {
+    // Remove any excluded audits from the live copy. We'll effectively
+    // pretend they don't exist upstream.
+    for excluded in &config.exclude {
+        audits_file.audits.remove(excluded);
+    }
+
+    // Construct a mapping from the foreign criteria namespace into the
+    // local criteria namespace based on the criteria map from the config.
+    let foreign_criteria_mapper = CriteriaMapper::new(&audits_file.criteria);
+    let foreign_to_local_mapping: Vec<_> = foreign_criteria_mapper
+        .all_criteria_names()
+        .map(|foreign_name| {
+            // NOTE: We try the map before we check for built-in criteria to
+            // allow overriding the default behaviour.
+            if let Some(mapped) = config.criteria_map.get(foreign_name) {
+                local_criteria_mapper.criteria_from_list(mapped)
+            } else if foreign_name == SAFE_TO_DEPLOY {
+                local_criteria_mapper.criteria_from_list([SAFE_TO_DEPLOY])
+            } else if foreign_name == SAFE_TO_RUN {
+                local_criteria_mapper.criteria_from_list([SAFE_TO_RUN])
+            } else {
+                local_criteria_mapper.no_criteria()
             }
-            *criteria = local_criteria_mapper
-                .criteria_names(&local_set)
-                .map(|name| name.to_owned().into())
-                .collect();
-        };
+        })
+        .collect();
 
-        // By default all audits read from the network are fresh.
-        //
-        // Note: This may leave behind useless audits which imply no criteria,
-        // but that's OK - we'll never choose to import them. In the future we
-        // might want to trim them.
-        for audit_entry in audits_file.audits.values_mut().flat_map(|v| v.iter_mut()) {
-            audit_entry.is_fresh_import = true;
-            make_criteria_local(&mut audit_entry.criteria);
-        }
-        for audit_entry in audits_file
-            .wildcard_audits
-            .values_mut()
-            .flat_map(|v| v.iter_mut())
-        {
-            audit_entry.is_fresh_import = true;
-            make_criteria_local(&mut audit_entry.criteria);
+    // Helper to re-write foreign criteria into the local criteria
+    // namespace.
+    let make_criteria_local = |criteria: &mut Vec<Spanned<CriteriaName>>| {
+        let foreign_set = foreign_criteria_mapper.criteria_from_list(&*criteria);
+        let mut local_set = local_criteria_mapper.no_criteria();
+        for foreign_criteria_idx in foreign_set.indices() {
+            local_set.unioned_with(&foreign_to_local_mapping[foreign_criteria_idx]);
         }
+        *criteria = local_criteria_mapper
+            .criteria_names(&local_set)
+            .map(|name| name.to_owned().into())
+            .collect();
+    };
 
-        // Now that we're done with foreign criteria, trim the set to only
-        // contain mapped criteria, as we don't care about other criteria, so
-        // shouldn't bother importing them.
-        //
-        // We also clear out description_url and implies, as those fields will
-        // not be used locally.
-        audits_file.criteria.retain(|name, entry| {
-            entry.description_url = None;
-            entry.implies = Vec::new();
-            config.criteria_map.contains_key(name)
-        });
-
-        // If we have an existing audits file for these imports, compare against it.
-        if let Some(existing_audits_file) = imports_lock.audits.get(&import_name) {
-            if !allow_criteria_changes {
-                // Compare the new criteria descriptions with existing criteria
-                // descriptions. If the description already exists, record a
-                // CriteriaChangeError.
-                for (criteria_name, old_entry) in &existing_audits_file.criteria {
-                    if let Some(new_entry) = audits_file.criteria.get(criteria_name) {
-                        let old_desc = old_entry.description.as_ref().unwrap();
-                        let new_desc = new_entry.description.as_ref().unwrap();
-                        if old_desc != new_desc {
-                            changed_criteria.push(CriteriaChangeError {
-                                import_name: import_name.clone(),
-                                criteria_name: criteria_name.to_owned(),
-                                unified_diff: unified_diff(
-                                    Algorithm::Myers,
-                                    old_desc,
-                                    new_desc,
-                                    5,
-                                    None,
-                                ),
-                            });
-                        }
-                    }
+    // By default all audits read from the network are fresh.
+    //
+    // Note: This may leave behind useless audits which imply no criteria,
+    // but that's OK - we'll never choose to import them. In the future we
+    // might want to trim them.
+    for audit_entry in audits_file.audits.values_mut().flat_map(|v| v.iter_mut()) {
+        audit_entry.is_fresh_import = true;
+        make_criteria_local(&mut audit_entry.criteria);
+    }
+    for audit_entry in audits_file
+        .wildcard_audits
+        .values_mut()
+        .flat_map(|v| v.iter_mut())
+    {
+        audit_entry.is_fresh_import = true;
+        make_criteria_local(&mut audit_entry.criteria);
+    }
+
+    // Now that we're done with foreign criteria, trim the set to only
+    // contain mapped criteria, as we don't care about other criteria, so
+    // shouldn't bother importing them.
+    //
+    // We also clear out description_url and implies, as those fields will
+    // not be used locally.
+    audits_file.criteria.retain(|name, entry| {
+        entry.description_url = None;
+        entry.implies = Vec::new();
+        config.criteria_map.contains_key(name)
+    });
+
+    // If we have an existing audits file for these imports, compare against it.
+    if let Some(existing_audits_file) = existing_audits_file {
+        // Compare the new criteria descriptions with existing criteria
+        // descriptions. If the description already exists, notify our caller.
+        for (criteria_name, old_entry) in &existing_audits_file.criteria {
+            if let Some(new_entry) = audits_file.criteria.get(criteria_name) {
+                let old_desc = old_entry.description.as_ref().unwrap();
+                let new_desc = new_entry.description.as_ref().unwrap();
+                if old_desc != new_desc {
+                    on_changed_criteria_description(criteria_name, old_desc, new_desc);
                 }
             }
+        }
 
-            // Compare the new audits with existing audits. If an audit already
-            // existed in the existing audits file, mark it as non-fresh.
-            for (package, existing_audits) in &existing_audits_file.audits {
-                let new_audits = audits_file
-                    .audits
-                    .get_mut(package)
-                    .map(|v| &mut v[..])
-                    .unwrap_or(&mut []);
-                for existing_audit in existing_audits {
-                    for new_audit in &mut *new_audits {
-                        if new_audit.is_fresh_import && new_audit.same_audit_as(existing_audit) {
-                            new_audit.is_fresh_import = false;
-                            break;
-                        }
+        // Compare the new audits with existing audits. If an audit already
+        // existed in the existing audits file, mark it as non-fresh.
+        for (package, existing_audits) in &existing_audits_file.audits {
+            let new_audits = audits_file
+                .audits
+                .get_mut(package)
+                .map(|v| &mut v[..])
+                .unwrap_or(&mut []);
+            for existing_audit in existing_audits {
+                for new_audit in &mut *new_audits {
+                    if new_audit.is_fresh_import && new_audit.same_audit_as(existing_audit) {
+                        new_audit.is_fresh_import = false;
+                        break;
                     }
                 }
             }
-            for (package, existing_audits) in &existing_audits_file.wildcard_audits {
-                let new_audits = audits_file
-                    .wildcard_audits
-                    .get_mut(package)
-                    .map(|v| &mut v[..])
-                    .unwrap_or(&mut []);
-                for existing_audit in existing_audits {
-                    for new_audit in &mut *new_audits {
-                        if new_audit.is_fresh_import && new_audit.same_audit_as(existing_audit) {
-                            new_audit.is_fresh_import = false;
-                            break;
-                        }
+        }
+        for (package, existing_audits) in &existing_audits_file.wildcard_audits {
+            let new_audits = audits_file
+                .wildcard_audits
+                .get_mut(package)
+                .map(|v| &mut v[..])
+                .unwrap_or(&mut []);
+            for existing_audit in existing_audits {
+                for new_audit in &mut *new_audits {
+                    if new_audit.is_fresh_import && new_audit.same_audit_as(existing_audit) {
+                        new_audit.is_fresh_import = false;
+                        break;
                     }
                 }
             }
         }
-
-        // Now add the new import
-        new_imports.audits.insert(import_name, audits_file);
-    }
-
-    if !changed_criteria.is_empty() {
-        return Err(CriteriaChangeErrors {
-            errors: changed_criteria,
-        });
     }
 
-    // FIXME: Consider doing some additional validation on these audits
-    // before returning?
-
-    Ok(new_imports)
+    audits_file
 }
 
 /// Fetch all declared imports from the network, filling in any criteria
@@ -1144,7 +1279,7 @@ fn wildcard_audits_packages(
 }
 
 #[allow(clippy::too_many_arguments)]
-fn import_publisher_versions(
+async fn import_publisher_versions(
     metadata: &cargo_metadata::Metadata,
     network: &Network,
     cache: &Cache,
@@ -1202,7 +1337,7 @@ fn import_publisher_versions(
         // Access the set of publishers. We provide the set of relevant versions
         // to help decide whether or not to fetch new publisher information from
         // crates.io, to reduce API activity.
-        let publishers = cache.get_publishers(network, pkg_name, versions)?;
+        let publishers = cache.get_publishers(network, pkg_name, versions).await?;
         relevant_publishers.push((pkg_name, publishers));
     }
 
@@ -1246,6 +1381,24 @@ fn import_publisher_versions(
     Ok(())
 }
 
+pub async fn fetch_registry(network: &Network) -> Result<RegistryFile, FetchAuditError> {
+    let registry_url = Url::parse(REGISTRY_URL).unwrap();
+    let registry_bytes = network.download(registry_url).await?;
+    let registry_string = String::from_utf8(registry_bytes).map_err(LoadTomlError::from)?;
+    let registry_source = SourceFile::new("cargo-vet registry", registry_string.clone());
+    let registry_file: RegistryFile = toml::de::from_str(&registry_string)
+        .map_err(|error| {
+            let (line, col) = error.line_col().unwrap_or((0, 0));
+            TomlParseError {
+                source_code: registry_source,
+                span: SourceOffset::from_location(&registry_string, line + 1, col + 1),
+                error,
+            }
+        })
+        .map_err(LoadTomlError::from)?;
+    Ok(registry_file)
+}
+
 /// A Registry in CARGO_HOME (usually the crates.io one)
 pub struct CargoRegistry {
     /// The queryable index
@@ -1956,7 +2109,7 @@ impl Cache {
     /// crates. Versions for each crate are also specified in order to avoid
     /// hitting the network in the case where the cache already has the relevant
     /// information.
-    fn get_publishers(
+    async fn get_publishers(
         &self,
         network: &Network,
         name: PackageStr<'_>,
@@ -2025,10 +2178,7 @@ impl Cache {
         let url = Url::parse(&format!("https://crates.io/api/v1/crates/{name}"))
             .expect("invalid crate name");
 
-        // NOTE: Our caller isn't able to do anything else at the same
-        // time, and we don't want to do multiple crates.io API calls at
-        // the same time, so we'll do the network fetch sync for now.
-        let response = tokio::runtime::Handle::current().block_on(network.download(url))?;
+        let response = network.download(url).await?;
         let result = load_json::<CratesAPICrate>(&response[..])?.versions;
 
         // Update the users cache and individual crates caches, and return our


Test patch:
diff --git a/src/tests/mod.rs b/src/tests/mod.rs
index 54b47a2..d791da1 100644
--- a/src/tests/mod.rs
+++ b/src/tests/mod.rs
@@ -33,9 +33,12 @@ use crate::{
 /// Unlike a normal `assert_snapshot!` the snapshot name isn't inferred by this
 /// macro, as multiple snapshots with different names need to be generated.
 macro_rules! assert_report_snapshot {
-    ($name:expr, $metadata:expr, $store:expr) => {{
+    ($name:expr, $metadata:expr, $store:expr) => {
+        assert_report_snapshot!($name, $metadata, $store, None);
+    };
+    ($name:expr, $metadata:expr, $store:expr, $network:expr) => {{
         let report = $crate::resolver::resolve(&$metadata, None, &$store);
-        let (human, json) = $crate::tests::get_reports(&$metadata, report);
+        let (human, json) = $crate::tests::get_reports(&$metadata, report, &$store, $network);
         insta::assert_snapshot!($name, human);
         insta::assert_snapshot!(concat!($name, ".json"), json);
     }};
@@ -1087,13 +1090,18 @@ where
     }
 }
 
-fn get_reports(metadata: &Metadata, report: ResolveReport) -> (String, String) {
+fn get_reports(
+    metadata: &Metadata,
+    report: ResolveReport,
+    store: &Store,
+    network: Option<&Network>,
+) -> (String, String) {
     // FIXME: Figure out how to handle disabling output colours better in tests.
     console::set_colors_enabled(false);
     console::set_colors_enabled_stderr(false);
 
     let cfg = mock_cfg(metadata);
-    let suggest = report.compute_suggest(&cfg, None, true).unwrap();
+    let suggest = report.compute_suggest(&cfg, store, network).unwrap();
 
     let human_output = BasicTestOutput::new();
     report
diff --git a/src/tests/snapshots/cargo_vet__tests__vet__builtin_simple_registry_suggestions.json.snap b/src/tests/snapshots/cargo_vet__tests__vet__builtin_simple_registry_suggestions.json.snap
new file mode 100644
index 0000000..a25557a
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__vet__builtin_simple_registry_suggestions.json.snap
@@ -0,0 +1,135 @@
+---
+source: src/tests/vet.rs
+expression: json
+---
+{
+  "conclusion": "fail (vetting)",
+  "failures": [
+    {
+      "name": "third-party1",
+      "version": "10.0.0",
+      "missing_criteria": [
+        "safe-to-deploy"
+      ]
+    },
+    {
+      "name": "third-party2",
+      "version": "10.0.0",
+      "missing_criteria": [
+        "safe-to-deploy"
+      ]
+    },
+    {
+      "name": "transitive-third-party1",
+      "version": "10.0.0",
+      "missing_criteria": [
+        "safe-to-deploy"
+      ]
+    }
+  ],
+  "suggest": {
+    "suggestions": [
+      {
+        "name": "third-party1",
+        "notable_parents": "first-party",
+        "suggested_criteria": [
+          "safe-to-deploy"
+        ],
+        "suggested_diff": {
+          "from": null,
+          "to": "10.0.0",
+          "diffstat": {
+            "insertions": 100,
+            "deletions": 0,
+            "files_changed": 1
+          }
+        }
+      },
+      {
+        "name": "third-party2",
+        "notable_parents": "first-party",
+        "suggested_criteria": [
+          "safe-to-deploy"
+        ],
+        "suggested_diff": {
+          "from": null,
+          "to": "10.0.0",
+          "diffstat": {
+            "insertions": 100,
+            "deletions": 0,
+            "files_changed": 1
+          }
+        }
+      },
+      {
+        "name": "transitive-third-party1",
+        "notable_parents": "third-party1",
+        "suggested_criteria": [
+          "safe-to-deploy"
+        ],
+        "suggested_diff": {
+          "from": null,
+          "to": "10.0.0",
+          "diffstat": {
+            "insertions": 100,
+            "deletions": 0,
+            "files_changed": 1
+          }
+        }
+      }
+    ],
+    "suggest_by_criteria": {
+      "safe-to-deploy": [
+        {
+          "name": "third-party1",
+          "notable_parents": "first-party",
+          "suggested_criteria": [
+            "safe-to-deploy"
+          ],
+          "suggested_diff": {
+            "from": null,
+            "to": "10.0.0",
+            "diffstat": {
+              "insertions": 100,
+              "deletions": 0,
+              "files_changed": 1
+            }
+          }
+        },
+        {
+          "name": "third-party2",
+          "notable_parents": "first-party",
+          "suggested_criteria": [
+            "safe-to-deploy"
+          ],
+          "suggested_diff": {
+            "from": null,
+            "to": "10.0.0",
+            "diffstat": {
+              "insertions": 100,
+              "deletions": 0,
+              "files_changed": 1
+            }
+          }
+        },
+        {
+          "name": "transitive-third-party1",
+          "notable_parents": "third-party1",
+          "suggested_criteria": [
+            "safe-to-deploy"
+          ],
+          "suggested_diff": {
+            "from": null,
+            "to": "10.0.0",
+            "diffstat": {
+              "insertions": 100,
+              "deletions": 0,
+              "files_changed": 1
+            }
+          }
+        }
+      ]
+    },
+    "total_lines": 300
+  }
+}
diff --git a/src/tests/snapshots/cargo_vet__tests__vet__builtin_simple_registry_suggestions.snap b/src/tests/snapshots/cargo_vet__tests__vet__builtin_simple_registry_suggestions.snap
new file mode 100644
index 0000000..0c65631
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__vet__builtin_simple_registry_suggestions.snap
@@ -0,0 +1,24 @@
+---
+source: src/tests/vet.rs
+expression: human
+---
+Vetting Failed!
+
+3 unvetted dependencies:
+  third-party1:10.0.0 missing ["safe-to-deploy"]
+  third-party2:10.0.0 missing ["safe-to-deploy"]
+  transitive-third-party1:10.0.0 missing ["safe-to-deploy"]
+
+recommended audits for safe-to-deploy:
+    cargo vet inspect third-party1 10.0.0             (used by first-party)   (100 lines)
+      NOTE: cargo vet import peer-company would eliminate this
+    cargo vet inspect third-party2 10.0.0             (used by first-party)   (100 lines)
+      NOTE: cargo vet import rival-company would eliminate this
+      NOTE: cargo vet import peer-company would reduce this to a 1-line diff
+    cargo vet inspect transitive-third-party1 10.0.0  (used by third-party1)  (100 lines)
+      NOTE: cargo vet import rival-company would reduce this to a 36-line diff
+
+estimated audit backlog: 300 lines
+
+Use |cargo vet certify| to record the audits.
+
diff --git a/src/tests/vet.rs b/src/tests/vet.rs
index ff030c4..4af2952 100644
--- a/src/tests/vet.rs
+++ b/src/tests/vet.rs
@@ -1,3 +1,5 @@
+use crate::format::{RegistryEntry, RegistryFile};
+
 use super::*;
 
 #[test]
@@ -2537,3 +2539,93 @@ fn builtin_simple_mega_foreign_tag_team() {
 
     assert_report_snapshot!("builtin_simple_mega_foreign_tag_team", metadata, store);
 }
+
+#[test]
+fn builtin_simple_registry_suggestions() {
+    // (Pass) After a failing audit, registry suggestions may be proposed if run
+    // with network access.
+
+    let _enter = TEST_RUNTIME.enter();
+    let mock = MockMetadata::simple();
+
+    let metadata = mock.metadata();
+    let (config, audits, imports) = builtin_files_no_exemptions(&metadata);
+
+    let mut network = Network::new_mock();
+    network.mock_serve_toml(
+        crate::storage::REGISTRY_URL,
+        &RegistryFile {
+            registry: [
+                (
+                    FOREIGN.to_owned(),
+                    RegistryEntry {
+                        url: FOREIGN_URL.to_owned(),
+                    },
+                ),
+                (
+                    OTHER_FOREIGN.to_owned(),
+                    RegistryEntry {
+                        url: OTHER_FOREIGN_URL.to_owned(),
+                    },
+                ),
+            ]
+            .into_iter()
+            .collect(),
+        },
+    );
+    network.mock_serve_toml(
+        FOREIGN_URL,
+        &AuditsFile {
+            criteria: SortedMap::new(),
+            wildcard_audits: SortedMap::new(),
+            audits: [
+                (
+                    "third-party1".to_owned(),
+                    vec![full_audit(ver(DEFAULT_VER), SAFE_TO_DEPLOY)],
+                ),
+                (
+                    "third-party2".to_owned(),
+                    vec![delta_audit(ver(1), ver(DEFAULT_VER), SAFE_TO_DEPLOY)],
+                ),
+            ]
+            .into_iter()
+            .collect(),
+        },
+    );
+    network.mock_serve_toml(
+        OTHER_FOREIGN_URL,
+        &AuditsFile {
+            criteria: SortedMap::new(),
+            wildcard_audits: SortedMap::new(),
+            audits: [
+                (
+                    "transitive-third-party1".to_owned(),
+                    vec![full_audit(ver(8), SAFE_TO_DEPLOY)],
+                ),
+                (
+                    "third-party2".to_owned(),
+                    vec![full_audit(ver(DEFAULT_VER), SAFE_TO_DEPLOY)],
+                ),
+            ]
+            .into_iter()
+            .collect(),
+        },
+    );
+
+    let store = Store::mock_online(
+        &mock_cfg(&metadata),
+        config,
+        audits,
+        imports,
+        &network,
+        true,
+    )
+    .unwrap();
+
+    assert_report_snapshot!(
+        "builtin_simple_registry_suggestions",
+        metadata,
+        store,
+        Some(&network)
+    );
+}
diff --git a/tests/cache/diff-cache.toml b/tests/cache/diff-cache.toml
index 912a25e..9023351 100644
--- a/tests/cache/diff-cache.toml
+++ b/tests/cache/diff-cache.toml
@@ -40,6 +40,11 @@ insertions = 1368
 deletions = 0
 files_changed = 10
 
+[diffs.fastrand."1.9.0 -> 1.7.0"]
+insertions = 24
+deletions = 107
+files_changed = 6
+
 [diffs.fnv."1.0.7"]
 insertions = 730
 deletions = 0
@@ -60,6 +65,11 @@ insertions = 689
 deletions = 0
 files_changed = 5
 
+[diffs.form_urlencoded."1.1.0 -> 1.0.1"]
+insertions = 19
+deletions = 13
+files_changed = 3
+
 [diffs.futures-channel."0.3.21"]
 insertions = 3992
 deletions = 0
@@ -95,6 +105,11 @@ insertions = 14603
 deletions = 0
 files_changed = 31
 
+[diffs.hashbrown."0.12.3 -> 0.11.2"]
+insertions = 894
+deletions = 5219
+files_changed = 23
+
 [diffs.hermit-abi."0.1.19"]
 insertions = 932
 deletions = 0
@@ -135,6 +150,11 @@ insertions = 32532
 deletions = 0
 files_changed = 17
 
+[diffs.idna."0.3.0 -> 0.2.3"]
+insertions = 62
+deletions = 79
+files_changed = 8
+
 [diffs.indexmap."1.8.1"]
 insertions = 9445
 deletions = 0
@@ -175,6 +195,11 @@ insertions = 5625
 deletions = 0
 files_changed = 19
 
+[diffs.log."0.4.17 -> 0.4.16"]
+insertions = 19
+deletions = 82
+files_changed = 6
+
 [diffs.matches."0.1.9"]
 insertions = 210
 deletions = 0
@@ -240,6 +265,11 @@ insertions = 702
 deletions = 0
 files_changed = 5
 
+[diffs.percent-encoding."2.2.0 -> 2.1.0"]
+insertions = 40
+deletions = 69
+files_changed = 4
+
 [diffs.pin-project-lite."0.2.8"]
 insertions = 6100
 deletions = 0
@@ -265,6 +295,16 @@ insertions = 5691
 deletions = 0
 files_changed = 19
 
+[diffs.proc-macro2."1.0.39 -> 1.0.37"]
+insertions = 16
+deletions = 71
+files_changed = 14
+
+[diffs.proc-macro2."1.0.39 -> 1.0.37@git:4445659b0f753a928059244c875a58bb12f791e9"]
+insertions = 7
+deletions = 6
+files_changed = 12
+
 [diffs.quote."1.0.18"]
 insertions = 3838
 deletions = 0
@@ -355,6 +395,11 @@ insertions = 16751
 deletions = 0
 files_changed = 25
 
+[diffs.tinyvec."1.6.0 -> 1.5.1"]
+insertions = 12
+deletions = 304
+files_changed = 6
+
 [diffs.tinyvec_macros."0.1.0"]
 insertions = 81
 deletions = 0
@@ -410,6 +455,16 @@ insertions = 3217
 deletions = 0
 files_changed = 20
 
+[diffs.unicode-bidi."0.3.8 -> 0.2.0"]
+insertions = 595493
+deletions = 3137
+files_changed = 23
+
+[diffs.unicode-bidi."0.3.8 -> 0.3.7"]
+insertions = 526
+deletions = 849
+files_changed = 14
+
 [diffs.unicode-normalization."0.1.19"]
 insertions = 28622
 deletions = 0
@@ -425,6 +480,11 @@ insertions = 16344
 deletions = 0
 files_changed = 15
 
+[diffs.url."2.3.1 -> 2.2.2"]
+insertions = 1509
+deletions = 2748
+files_changed = 13
+
 [diffs.vcpkg."0.2.15"]
 insertions = 42642
 deletions = 0
@@ -470,6 +530,11 @@ insertions = 515
 deletions = 0
 files_changed = 7
 
+[diffs.wasm-bindgen-shared."0.2.83 -> 0.2.80"]
+insertions = 7
+deletions = 10
+files_changed = 4
+
 [diffs.web-sys."0.3.57"]
 insertions = 197013
 deletions = 0
diff --git a/tests/snapshots/test_cli__long-help.snap b/tests/snapshots/test_cli__long-help.snap
index 053ac42..59cdac8 100644
--- a/tests/snapshots/test_cli__long-help.snap
+++ b/tests/snapshots/test_cli__long-help.snap
@@ -47,6 +47,9 @@ GLOBAL OPTIONS:
             Prevent commands such as `check` and `certify` from automatically cleaning up unused
             exemptions
 
+        --no-registry-suggestions
+            Prevent commands such as `check` and `suggest` from suggesting registry imports
+
         --verbose <VERBOSE>
             How verbose logging should be (log level)
             
@@ -132,6 +135,8 @@ SUBCOMMANDS:
             Yield a diff against the last reviewed version
     certify
             Mark a package as audited
+    import
+            Import a new peer's imports
     regenerate
             Explicitly regenerate various pieces of information
     add-exemption
diff --git a/tests/snapshots/test_cli__markdown-help.snap b/tests/snapshots/test_cli__markdown-help.snap
index 997542e..aa8c51a 100644
--- a/tests/snapshots/test_cli__markdown-help.snap
+++ b/tests/snapshots/test_cli__markdown-help.snap
@@ -56,6 +56,9 @@ dependencies are vendored. Requires --locked
 Prevent commands such as `check` and `certify` from automatically cleaning up unused
 exemptions
 
+#### `--no-registry-suggestions`
+Prevent commands such as `check` and `suggest` from suggesting registry imports
+
 #### `--verbose <VERBOSE>`
 How verbose logging should be (log level)
 
@@ -135,6 +138,7 @@ graph
 * [inspect](#cargo-vet-inspect): Fetch the source of a package
 * [diff](#cargo-vet-diff): Yield a diff against the last reviewed version
 * [certify](#cargo-vet-certify): Mark a package as audited
+* [import](#cargo-vet-import): Import a new peer's imports
 * [regenerate](#cargo-vet-regenerate): Explicitly regenerate various pieces of information
 * [add-exemption](#cargo-vet-add-exemption): Mark a package as exempted from review
 * [record-violation](#cargo-vet-record-violation): Declare that some versions of a package violate certain audit criteria
@@ -387,6 +391,35 @@ Print help information
 ### GLOBAL OPTIONS
 This subcommand accepts all the [global options](#global-options)
 
+<br><br><br>
+## cargo vet import
+Import a new peer's imports
+
+If invoked without a URL parameter, it will look up the named peer in the cargo-vet registry, and
+import that peer.
+
+### USAGE
+```
+cargo vet import [OPTIONS] <NAME> [URL]
+```
+
+### ARGS
+#### `<NAME>`
+The name of the peer to import
+
+#### `<URL>`
+The URL of the peer's audits.toml file.
+
+If a URL is not provided, a peer with the given name will be looked up in the cargo-vet
+registry to determine the import URL.
+
+### OPTIONS
+#### `-h, --help`
+Print help information
+
+### GLOBAL OPTIONS
+This subcommand accepts all the [global options](#global-options)
+
 <br><br><br>
 ## cargo vet regenerate
 Explicitly regenerate various pieces of information
diff --git a/tests/snapshots/test_cli__short-help.snap b/tests/snapshots/test_cli__short-help.snap
index 9671ec4..2968501 100644
--- a/tests/snapshots/test_cli__short-help.snap
+++ b/tests/snapshots/test_cli__short-help.snap
@@ -38,6 +38,9 @@ GLOBAL OPTIONS:
             Prevent commands such as `check` and `certify` from automatically cleaning up unused
             exemptions
 
+        --no-registry-suggestions
+            Prevent commands such as `check` and `suggest` from suggesting registry imports
+
         --verbose <VERBOSE>
             How verbose logging should be (log level) [default: warn] [possible values: off, error,
             warn, info, debug, trace]
@@ -64,6 +67,7 @@ SUBCOMMANDS:
     inspect             Fetch the source of a package
     diff                Yield a diff against the last reviewed version
     certify             Mark a package as audited
+    import              Import a new peer's imports
     regenerate          Explicitly regenerate various pieces of information
     add-exemption       Mark a package as exempted from review
     record-violation    Declare that some versions of a package violate certain audit criteria
diff --git a/tests/test-cli.rs b/tests/test-cli.rs
index 98a0e37..903f1ac 100644
--- a/tests/test-cli.rs
+++ b/tests/test-cli.rs
@@ -192,6 +192,7 @@ fn test_project_suggest() {
         .arg("suggest")
         .arg("--cache-dir")
         .arg("../cache")
+        .arg("--no-registry-suggestions")
         .arg("--manifest-path")
         .arg("Cargo.toml")
         .stdout(Stdio::piped())
@@ -215,6 +216,7 @@ fn test_project_suggest_json() {
         .arg("suggest")
         .arg("--cache-dir")
         .arg("../cache")
+        .arg("--no-registry-suggestions")
         .arg("--manifest-path")
         .arg("Cargo.toml")
         .arg("--output-format=json")