Repository: mozilla/cargo-vet (language: python)

Issue:
Better behavior for not-yet-published audit-as-crates-io crates
In the last release, we added better heuristics for detecting potential matches between first-party and published crates. Such potential matches require `audit-as-crates-io` entries. In wasmtime, there were ~50 [new entries](https://github.com/bytecodealliance/wasmtime/commit/5610cbf71068ba8d1154cff22b049561548c9ed9#diff-331b3f5fea27183313d109f512bc51e8a21ccd8584681b45c448bfa9af8d33eeR25).

These entries are new because wasmtime uses a branch-for-release-then-bump model, and so the local crates are always at least one version ahead of what's published on crates.io. This is a situation that we've struggled to handle well in cargo-vet. For git dependencies, we can require a precise audit chain all the way to the exact commit being imported. But for path dependencies, we currently just require `audit-as-crates-io` to be set to `false` if the local version is ahead of the published version.

The wasmtime case seems like motivation to do better here. In principle, I think the right way to support `audit-as-crates-io` in this situation is to require an audit for the most-recently-published version (that's also below the current version).

We could simply institute this behavior automatically (setting `audit-as-crates-io=true` causes the cargo-vet algorithm to just replace the explicit version of such a first-party crate with the most-recently-published version), though this would have the unfortunate effect of allowing a previously-passing `cargo vet` to start failing when a new version is published upstream.

I think we probably want to warn rather than error in that case, which means we need breadcrumbs of the previously-passing state. Not sure whether it's better to put those in the policy entry, in imports.lock, or if there's some more elegant situation.

@mystor WDYT?


Discussion / hints:
In general, we use `imports.lock` as the file in which we store information like this cached from previous runs such as which imported audits and publisher information was required.

We could theoretically introduce a new table into that file, like `unpublished`, which documents that a particular version was unpublished, and that a different version was audited instead, like this:

```toml
[[unpublished.cranelift]]
version = "1.0.1"
audited-as = "1.0.0"
```

These would need to be tracked with our imports backend in a similar manner to other imported audits, and would effectively act as an implicit wildcard delta audit from `1.0.0` to `1.0.1` for the given crate as far as the resolver is concerned. We would create these entries (if one doesn't already exist) when running unlocked if a given crate is unpublished, but is marked as `audit-as-crates-io`, arbitrarily selecting the nearest version under the given version (or above if no version is found below?).

After the crate is published, `cargo vet --locked` commands would continue to succeed because of the existing unpublished entry, and would be pruned if a real audit was introduced to satisfy the new version. We could then make commands like `cargo vet` log warnings after being run if a crate was previously audited as a different version but has since been published or similar.

I don't love the extra complexity we're layering on top of the existing complexity of the import codepath, but I think it's probably the easiest way to make a feature like this work, and we've already put in most of the work for other types of "imports".

Does that sound like it would work as an approach and satisfy the wasmtime use-case?
@mystor Thanks, this design sounds right to me. Please make it happen. 

Fix patch:
diff --git a/book/src/first-party-code.md b/book/src/first-party-code.md
index 71ef898b..73e702dd 100644
--- a/book/src/first-party-code.md
+++ b/book/src/first-party-code.md
@@ -42,6 +42,12 @@ and this is what you should review before certifying, since others in the
 ecosystem may rely on your audits when using the original crate without your
 particular modifications.
 
+If audit-as-crates-io is enabled for a path dependency with a version which has
+not been published on crates.io, cargo-vet will instead require an audit of the
+latest published version before the local version, ensuring all audits
+correspond to a crate on crates.io[^2]. If the local version is later published,
+`cargo vet` will warn you, allowing you to update your audits.
+
 ## Footnotes
 
 [^1]: To enable an easy setup experience, `cargo vet init` will attempt to guess the
@@ -51,3 +57,10 @@ present it will guess `true` if either the `description` or `repository` fields
 `Cargo.toml` are non-empty and match the current values on crates.io. This behavior
 can also be triggered for newly-added dependencies with `cargo vet regenerate
 audit-as-crates-io`, but you should verify the results.
+
+[^2]: Which version is used for an unpublished crate will be recorded in
+imports.lock to ensure that `cargo vet` will continue to pass as new versions
+are published. Stale `unpublished` entries will be cleaned up by `prune` when
+they are no longer required for `cargo vet` to pass, and can also be regenerated
+using `cargo vet regenerate unpublished`, though this may cause `cargo vet` to
+start failing.
diff --git a/src/cli.rs b/src/cli.rs
index 38d82155..b0d360be 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -423,11 +423,20 @@ pub enum RegenerateSubcommands {
     #[clap(disable_version_flag = true)]
     Imports(RegenerateImportsArgs),
 
-    /// Regenerate you audit-as-crates-io entries to make `check` pass
+    /// Add `audit-as-crates-io` to the policy entry for all crates which require one.
     ///
-    /// This will just set any problematic entries to `audit-as-crates-io = false`.
+    /// Crates which have a matching `description` and `repository` entry to a
+    /// published crate on crates.io will be marked as `audit-as-crates-io = true`.
     #[clap(disable_version_flag = true)]
     AuditAsCratesIo(RegenerateAuditAsCratesIoArgs),
+
+    /// Remove all outdated `unpublished` entries for crates which have since
+    /// been published, or should now be audited as a more-recent version.
+    ///
+    /// Unlike `cargo vet prune`, this will remove outdated `unpublished`
+    /// entries even if it will cause `check` to start failing.
+    #[clap(disable_version_flag = true)]
+    Unpublished(RegenerateUnpublishedArgs),
 }
 
 #[derive(clap::Args)]
@@ -669,6 +678,9 @@ pub struct RegenerateImportsArgs {}
 #[derive(clap::Args)]
 pub struct RegenerateAuditAsCratesIoArgs {}
 
+#[derive(clap::Args)]
+pub struct RegenerateUnpublishedArgs {}
+
 #[derive(clap::Args)]
 pub struct AggregateArgs {
     /// Path to a file containing a list of URLs to aggregate the audits from.
diff --git a/src/criteria.rs b/src/criteria.rs
index 0c38cad1..ac08ee56 100644
--- a/src/criteria.rs
+++ b/src/criteria.rs
@@ -132,6 +132,11 @@ impl CriteriaMapper {
         CriteriaSet::none(self.len())
     }
 
+    /// Get a CriteriaSet of the correct size for this CriteriaMap containing all criteria
+    pub fn all_criteria(&self) -> CriteriaSet {
+        CriteriaSet::all(self.len())
+    }
+
     /// Like [`CriteriaSet::indices`] but uses knowledge of things like
     /// `implies` relationships to remove redundant information. For
     /// instance, if safe-to-deploy is set, we don't also yield safe-to-run.
@@ -182,6 +187,13 @@ impl CriteriaSet {
         );
         CriteriaSet(0)
     }
+    pub fn all(count: usize) -> Self {
+        assert!(
+            count <= MAX_CRITERIA,
+            "{MAX_CRITERIA} was not Enough For Everyone ({count} criteria)"
+        );
+        CriteriaSet((1u64 << count).wrapping_sub(1))
+    }
     pub fn set_criteria(&mut self, idx: usize) {
         self.0 |= 1 << idx;
     }
diff --git a/src/format.rs b/src/format.rs
index f3506989..98617d3b 100644
--- a/src/format.rs
+++ b/src/format.rs
@@ -88,6 +88,12 @@ impl VetVersion {
             })
         }
     }
+
+    /// Check if this VetVersion exactly matches the given semver version with
+    /// no git revision metadata.
+    pub fn equals_semver(&self, semver: &semver::Version) -> bool {
+        self.git_rev.is_none() && &self.semver == semver
+    }
 }
 impl fmt::Display for VetVersion {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
@@ -904,6 +910,9 @@ impl Default for CargoVetConfig {
 
 #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
 pub struct ImportsFile {
+    #[serde(default)]
+    #[serde(skip_serializing_if = "SortedMap::is_empty")]
+    pub unpublished: SortedMap<PackageName, Vec<UnpublishedEntry>>,
     #[serde(default)]
     #[serde(skip_serializing_if = "SortedMap::is_empty")]
     pub publisher: SortedMap<PackageName, Vec<CratesPublisher>>,
@@ -932,6 +941,23 @@ pub struct CratesPublisher {
     pub is_fresh_import: bool,
 }
 
+// Information about a specific crate being unpublished
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct UnpublishedEntry {
+    // NOTE: This will only ever be a `semver::Version`, however the resolver
+    // code works on borrowed `VetVersion` instances, so we use one here so it
+    // is easier to use within the resolver.
+    pub version: VetVersion,
+    pub audited_as: VetVersion,
+    /// Set to `true` if `version` was not published when acquiring the Store.
+    /// Always set to `false` when locked.
+    #[serde(skip)]
+    pub still_unpublished: bool,
+    /// See `AuditEntry::is_fresh_import`.
+    #[serde(skip)]
+    pub is_fresh_import: bool,
+}
+
 ////////////////////////////////////////////////////////////////////////////////////
 //                                                                                //
 //                                                                                //
diff --git a/src/main.rs b/src/main.rs
index b668e932..ba920898 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -102,6 +102,7 @@ pub trait PackageExt {
     fn is_third_party(&self, policy: &Policy) -> bool;
     fn is_crates_io(&self) -> bool;
     fn policy_entry<'a>(&self, policy: &'a Policy) -> Option<&'a PolicyEntry>;
+    fn git_rev(&self) -> Option<String>;
     fn vet_version(&self) -> VetVersion;
 }
 
@@ -126,14 +127,18 @@ impl PackageExt for Package {
         policy.get(&self.name, &self.vet_version())
     }
 
+    fn git_rev(&self) -> Option<String> {
+        self.source.as_ref().and_then(|s| {
+            let git_source = s.repr.strip_prefix("git+")?;
+            let source_url = Url::parse(git_source).ok()?;
+            Some(source_url.fragment()?.to_owned())
+        })
+    }
+
     fn vet_version(&self) -> VetVersion {
         VetVersion {
             semver: self.version.clone(),
-            git_rev: self.source.as_ref().and_then(|s| {
-                let git_source = s.repr.strip_prefix("git+")?;
-                let source_url = Url::parse(git_source).ok()?;
-                Some(source_url.fragment()?.to_owned())
-            }),
+            git_rev: self.git_rev(),
         }
     }
 }
@@ -514,6 +519,7 @@ fn real_main() -> Result<(), miette::Report> {
         Some(Regenerate(AuditAsCratesIo(sub_args))) => {
             cmd_regenerate_audit_as(&out, &cfg, sub_args)
         }
+        Some(Regenerate(Unpublished(sub_args))) => cmd_regenerate_unpublished(&out, &cfg, sub_args),
         Some(Renew(sub_args)) => cmd_renew(&out, &cfg, sub_args),
         Some(Aggregate(_)) | Some(HelpMarkdown(_)) | Some(Gc(_)) => unreachable!("handled earlier"),
     }
@@ -1658,6 +1664,48 @@ fn cmd_regenerate_audit_as(
     Ok(())
 }
 
+fn cmd_regenerate_unpublished(
+    out: &Arc<dyn Out>,
+    cfg: &Config,
+    _sub_args: &RegenerateUnpublishedArgs,
+) -> Result<(), miette::Report> {
+    trace!("regenerating unpublished entries...");
+
+    if cfg.cli.locked {
+        // ERRORS: just a warning that you're holding it wrong, unclear if immediate or buffered,
+        // or if this should be a hard error, or if we should ignore the --locked flag and
+        // just do it anyway
+        writeln!(
+            out,
+            "warning: ran `regenerate unpublished` with --locked, this won't do anything!"
+        );
+        return Ok(());
+    }
+
+    let network = Network::acquire(cfg);
+    let mut store = Store::acquire(cfg, network.as_ref(), false)?;
+
+    // Strip all non-fresh entries from the unpublished table, marking the
+    // previously fresh entries as non-fresh.
+    if let Some(live_imports) = &mut store.live_imports {
+        for unpublished in live_imports.unpublished.values_mut() {
+            unpublished.retain_mut(|u| std::mem::replace(&mut u.is_fresh_import, false));
+        }
+    }
+
+    // Run a minimal store update to import new entries which would now be
+    // required for `check` to pass. Note that this won't ensure `check`
+    // actually passes after the change.
+    resolver::update_store(cfg, &mut store, |_| resolver::UpdateMode {
+        search_mode: resolver::SearchMode::PreferExemptions,
+        prune_exemptions: false,
+        prune_imports: false,
+    });
+
+    store.commit()?;
+    Ok(())
+}
+
 fn cmd_renew(out: &Arc<dyn Out>, cfg: &Config, sub_args: &RenewArgs) -> Result<(), miette::Report> {
     trace!("renewing wildcard audits");
     let mut store = Store::acquire_offline(cfg)?;
@@ -1780,10 +1828,9 @@ async fn fix_audit_as(
             match error {
                 AuditAsError::NeedsAuditAs(NeedsAuditAsErrors { errors }) => {
                     for err in errors {
-                        // We'll default audit-as-crates-io to true if not only
-                        // a package with matching version exists on crates.io,
-                        // but the crate's description or repository matches as
-                        // well.
+                        // We'll default audit-as-crates-io to true if the
+                        // crate's description or repository matches an existing
+                        // package on crates.io.
                         //
                         // XXX: This is just indended to reduce the chance of
                         // false positives, but is certainly a bit of a loose
@@ -1803,26 +1850,8 @@ async fn fix_audit_as(
                                     }
                                 };
 
-                            let crates_io_versions =
-                                match cache.get_versions(network, &err.package).await {
-                                    Ok(v) => v,
-                                    Err(e) => {
-                                        warn!("crate versions error for {}: {e}", &err.package);
-                                        Default::default()
-                                    }
-                                };
-
                             cfg.metadata.packages.iter().any(|p| {
-                                p.name == err.package
-                                    && err
-                                        .version
-                                        .as_ref()
-                                        .map(|v| {
-                                            v == &p.vet_version()
-                                                && crates_io_versions.contains(&v.semver)
-                                        })
-                                        .unwrap_or(true)
-                                    && crates_api_metadata.consider_as_same(p)
+                                p.name == err.package && crates_api_metadata.consider_as_same(p)
                             })
                         } else {
                             false
@@ -2071,6 +2100,19 @@ fn cmd_check(
                 warn!("  Consider running `cargo vet prune` to prune unnecessary imports.");
             }
 
+            // Check if we have `unpublished` entries for crates which have since been published.
+            let since_published: Vec<_> = pruned_imports
+                .unpublished
+                .iter()
+                .filter(|(_, unpublished)| unpublished.iter().any(|u| !u.still_unpublished))
+                .map(|(package, _)| package)
+                .collect();
+            if !since_published.is_empty() {
+                let published = string_format::FormatShortList::new(since_published);
+                warn!("Your supply-chain depends on previously unpublished versions of {published} which have since been published.");
+                warn!("  Consider running `cargo vet regenerate unpublished` to remove these entries.");
+            }
+
             // Warn about wildcard audits which will be expiring soon or have expired.
             let expiry = WildcardAuditRenewal::expiring(cfg, &mut store);
 
@@ -2766,12 +2808,9 @@ async fn check_audit_as_crates_io(
                 //
                 // The caching logic already does this for us as an optimization, but since we may
                 // need to look at the specific versions later, we fetch it anyway.
-                let versions = cache.get_versions(network, &package.name).await.ok();
                 let mut matches_crates_io_package = false;
-                if versions.is_some() {
-                    if let Ok(metadata) = cache.get_crate_metadata(network, &package.name).await {
-                        matches_crates_io_package = metadata.consider_as_same(package);
-                    }
+                if let Ok(metadata) = cache.get_crate_metadata(network, &package.name).await {
+                    matches_crates_io_package = metadata.consider_as_same(package);
                 }
 
                 if matches_crates_io_package && audit_policy.is_none() {
@@ -2779,16 +2818,8 @@ async fn check_audit_as_crates_io(
                     // on crates.io: having no policy is an error.
                     return Some((CheckAction::NeedAuditAs, package));
                 }
-                if audit_policy == Some(true) {
-                    // Check whether there is a matching version on crates.io. No matching
-                    // version is considered an error.
-                    if !matches_crates_io_package
-                        || !versions
-                            .expect("metadata implies versions")
-                            .contains(&package.version)
-                    {
-                        return Some((CheckAction::ShouldntBeAuditAs, package));
-                    }
+                if !matches_crates_io_package && audit_policy == Some(true) {
+                    return Some((CheckAction::ShouldntBeAuditAs, package));
                 }
                 None
             }
diff --git a/src/network.rs b/src/network.rs
index 6a69eb9b..48d054d1 100644
--- a/src/network.rs
+++ b/src/network.rs
@@ -248,12 +248,15 @@ impl Network {
                 .get(&url)
                 .cloned()
                 // The error is complete nonsense, but this is test-only.
-                .ok_or(DownloadError::FailedToWriteDownload {
-                    target: url.to_string().into(),
-                    error: std::io::Error::new(
-                        std::io::ErrorKind::Other,
-                        format!("mock network does not support URL: {url}"),
-                    ),
+                .ok_or_else(|| {
+                    tracing::warn!("Attempt to fetch unsupported URL from mock network: {url}");
+                    DownloadError::FailedToWriteDownload {
+                        target: url.to_string().into(),
+                        error: std::io::Error::new(
+                            std::io::ErrorKind::Other,
+                            format!("mock network does not support URL: {url}"),
+                        ),
+                    }
                 })?;
             return Ok(Response::Mock(Some(chunk)));
         }
diff --git a/src/resolver.rs b/src/resolver.rs
index eb3cd0bb..2d873f0f 100644
--- a/src/resolver.rs
+++ b/src/resolver.rs
@@ -58,7 +58,7 @@ use crate::format::{
     DiffStat, ExemptedDependency, FastMap, FastSet, ImportName, ImportsFile, JsonPackage,
     JsonReport, JsonReportConclusion, JsonReportFailForVet, JsonReportFailForViolationConflict,
     JsonReportSuccess, JsonSuggest, JsonSuggestItem, JsonVetFailure, PackageName, PackageStr,
-    Policy, VetVersion, WildcardEntry,
+    Policy, UnpublishedEntry, VetVersion, WildcardEntry,
 };
 use crate::format::{SortedMap, SortedSet};
 use crate::network::Network;
@@ -299,6 +299,8 @@ pub enum DeltaEdgeOrigin {
     Trusted { publisher_index: usize },
     /// This edge represents an exemption from the local config.toml.
     Exemption { exemption_index: usize },
+    /// This edge represents an unpublished entry in imports.lock.
+    Unpublished { unpublished_index: usize },
     /// This edge represents brand new exemption which didn't previously exist
     /// in the audit graph. Will only ever be produced from
     /// SearchMode::RegenerateExemptions.
@@ -323,6 +325,9 @@ pub enum RequiredEntry {
     Exemption {
         exemption_index: usize,
     },
+    Unpublished {
+        unpublished_index: usize,
+    },
     // NOTE: This variant must come last, as code in `update_store` depends on
     // `FreshExemption` entries sorting after all other entries.
     FreshExemption {
@@ -956,8 +961,15 @@ fn resolve_audits(
                         needed_exemptions |= path
                             .iter()
                             .any(|o| matches!(o, DeltaEdgeOrigin::Exemption { .. }));
-                        directly_exempted |=
-                            path.len() == 1 && matches!(path[0], DeltaEdgeOrigin::Exemption { .. });
+                        // Ignore `Unpublished` entries when deciding if a crate
+                        // is directly exempted.
+                        directly_exempted |= path.iter().all(|o| {
+                            matches!(
+                                o,
+                                DeltaEdgeOrigin::Exemption { .. }
+                                    | DeltaEdgeOrigin::Unpublished { .. }
+                            )
+                        });
                     }
                     Err(_) => criteria_failures.set_criteria(criteria_idx),
                 }
@@ -1096,6 +1108,12 @@ impl<'a> AuditGraph<'a> {
             .map(|v| &v[..])
             .unwrap_or(&[]);
 
+        let unpublished = store
+            .unpublished()
+            .get(package)
+            .map(|v| &v[..])
+            .unwrap_or(&[]);
+
         let exemptions = store.config.exemptions.get(package);
 
         let mut forward_audits = DirectedAuditGraph::new();
@@ -1193,6 +1211,28 @@ impl<'a> AuditGraph<'a> {
             }
         }
 
+        // For each unpublished entry for the crate we're aware of, generate a delta audit for that edge.
+        for (unpublished_index, unpublished) in unpublished.iter().enumerate() {
+            let from_ver = Some(&unpublished.audited_as);
+            let to_ver = Some(&unpublished.version);
+            let criteria = criteria_mapper.all_criteria();
+            let origin = DeltaEdgeOrigin::Unpublished { unpublished_index };
+            let is_fresh_import = unpublished.is_fresh_import;
+
+            forward_audits.entry(from_ver).or_default().push(DeltaEdge {
+                version: to_ver,
+                criteria: criteria.clone(),
+                origin: origin.clone(),
+                is_fresh_import,
+            });
+            backward_audits.entry(to_ver).or_default().push(DeltaEdge {
+                version: from_ver,
+                criteria,
+                origin,
+                is_fresh_import,
+            });
+        }
+
         // Exempted entries are equivalent to full-audits
         if let Some(alloweds) = exemptions {
             for (exemption_index, allowed) in alloweds.iter().enumerate() {
@@ -1398,8 +1438,10 @@ fn search_for_path(
     enum CaveatLevel {
         None,
         PreferredExemption,
+        PreferredUnpublished,
         FreshImport,
         Exemption,
+        Unpublished,
         FreshExemption,
     }
 
@@ -1521,6 +1563,17 @@ fn search_for_path(
                 }
                 DeltaEdgeOrigin::Exemption { .. } => CaveatLevel::Exemption,
                 DeltaEdgeOrigin::FreshExemption { .. } => unreachable!(),
+                DeltaEdgeOrigin::Unpublished { .. } => match mode {
+                    // When preferring exemptions, prefer existing unpublished
+                    // entries to avoid imports.lock churn.
+                    SearchMode::PreferExemptions if !edge.is_fresh_import => {
+                        CaveatLevel::PreferredUnpublished
+                    }
+                    SearchMode::PreferExemptions => CaveatLevel::Unpublished,
+                    // Otherwise, prefer fresh to avoid outdated versions.
+                    _ if edge.is_fresh_import => CaveatLevel::PreferredUnpublished,
+                    _ => CaveatLevel::Unpublished,
+                },
                 _ if edge.is_fresh_import => CaveatLevel::FreshImport,
                 _ => CaveatLevel::None,
             };
@@ -2397,6 +2450,10 @@ async fn suggest_delta(
     failures: impl Iterator<Item = &SearchFailure>,
     warnings: &RefCell<Vec<String>>,
 ) -> Option<DiffRecommendation> {
+    // Fetch the set of known versions from crates.io so we know which versions
+    // we'll have sources for.
+    let known_versions = cache.get_versions(network, package.name).await.ok();
+
     // Collect up the details of how we failed
     struct Reachable<'a> {
         from_root: SortedSet<&'a Option<VetVersion>>,
@@ -2419,17 +2476,20 @@ async fn suggest_delta(
             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(_),
-                            ..
-                        })
-                    )
+                // We always have sources for an empty crate.
+                let Some(ver) = ver else { return true; };
+                // We only have git sources for the package itself.
+                if ver.git_rev.is_some() {
+                    return ver == &package.version;
+                }
+                // We have sources if the version has been published to crates.io.
+                //
+                // For testing fallbacks, assume we always have sources if the
+                // index is unavailable.
+                known_versions
+                    .as_ref()
+                    .map_or(true, |versions| versions.contains(&ver.semver))
             };
             reachable = Some(Reachable {
                 from_root: reachable_from_root
@@ -2588,6 +2648,9 @@ fn resolve_package_required_entries(
                     DeltaEdgeOrigin::Trusted { publisher_index } => {
                         add_entry(RequiredEntry::Publisher { publisher_index })
                     }
+                    DeltaEdgeOrigin::Unpublished { unpublished_index } => {
+                        add_entry(RequiredEntry::Unpublished { unpublished_index })
+                    }
                     DeltaEdgeOrigin::StoredLocalAudit { .. } => {}
                 }
             }
@@ -2599,6 +2662,7 @@ fn resolve_package_required_entries(
 }
 
 /// Per-package options to control store pruning.
+#[derive(Copy, Clone)]
 pub struct UpdateMode {
     pub search_mode: SearchMode,
     pub prune_exemptions: bool,
@@ -2654,6 +2718,7 @@ pub(crate) fn get_store_updates(
     let no_required_entries = Some(SortedMap::new());
 
     let mut new_imports = ImportsFile {
+        unpublished: SortedMap::new(),
         publisher: SortedMap::new(),
         audits: SortedMap::new(),
     };
@@ -2796,6 +2861,46 @@ pub(crate) fn get_store_updates(
         }
     }
 
+    // Determine which live publisher information to keep in the imports.lock file.
+    for (pkgname, unpublished) in store.unpublished() {
+        // Although `unpublished` entries are stored in imports.lock, they're
+        // more like automatically-managed delta-exemptions than imports, so
+        // we'll prune them when pruning exemptions.
+        let prune_exemptions = mode(&pkgname[..]).prune_exemptions;
+        let required_entries = required_entries
+            .get(&pkgname[..])
+            .unwrap_or(&no_required_entries);
+        let mut unpublished: Vec<_> = unpublished
+            .iter()
+            .enumerate()
+            .filter(|&(unpublished_index, entry)| {
+                // Keep existing if we're not pruning exemptions.
+                if !prune_exemptions && !entry.is_fresh_import {
+                    return true;
+                }
+
+                if let Some(required_entries) = required_entries {
+                    required_entries.contains_key(&RequiredEntry::Unpublished { unpublished_index })
+                } else {
+                    !entry.is_fresh_import
+                }
+            })
+            .map(|(_, entry)| UnpublishedEntry {
+                is_fresh_import: false,
+                ..entry.clone()
+            })
+            .collect();
+        unpublished.sort();
+        // Clean up any duplicate Unpublished entries now that `is_fresh_import`
+        // has been cleared.  This ensures that even if we end up using
+        // `PreferFreshImports` when not pruning exemptions, we won't end up
+        // with duplicate unpublished entries.
+        unpublished.dedup();
+        if !unpublished.is_empty() {
+            new_imports.unpublished.insert(pkgname.clone(), unpublished);
+        }
+    }
+
     let mut all_new_exemptions = SortedMap::new();
 
     // Enumerate existing exemptions to check for criteria changes.
diff --git a/src/storage.rs b/src/storage.rs
index 7fb828ae..50ec3c7e 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -37,7 +37,7 @@ use crate::{
         CriteriaName, CriteriaStr, Delta, DiffCache, DiffStat, FastMap, FastSet, FetchCommand,
         ForeignAuditsFile, ImportName, ImportsFile, MetaConfig, PackageName, PackageStr,
         RegistryEntry, RegistryFile, SortedMap, StoreVersion, TrustEntry, TrustedPackages,
-        VetVersion, WildcardAudits, WildcardEntry, SAFE_TO_DEPLOY, SAFE_TO_RUN,
+        UnpublishedEntry, VetVersion, WildcardAudits, WildcardEntry, SAFE_TO_DEPLOY, SAFE_TO_RUN,
     },
     network::Network,
     out::{progress_bar, IncProgressOnDrop},
@@ -177,6 +177,7 @@ impl Store {
                 exemptions: SortedMap::new(),
             },
             imports: ImportsFile {
+                unpublished: SortedMap::new(),
                 publisher: SortedMap::new(),
                 audits: SortedMap::new(),
             },
@@ -284,6 +285,16 @@ impl Store {
             fetch_imported_audits(network, &local_criteria_mapper, &self.config).await?;
         let mut live_imports =
             process_imported_audits(fetched_audits, &self.imports, allow_criteria_changes)?;
+        import_unpublished_entries(
+            &cfg.metadata,
+            network,
+            cache,
+            &self.config,
+            &self.imports,
+            &mut live_imports,
+        )
+        .await
+        .map_err(Box::new)?;
         import_publisher_versions(
             &cfg.metadata,
             network,
@@ -339,6 +350,16 @@ impl Store {
         let mut live_imports =
             process_imported_audits(fetched_audits, &imports, allow_criteria_changes)?;
         let cache = Cache::acquire(cfg).map_err(Box::new)?;
+        tokio::runtime::Handle::current()
+            .block_on(import_unpublished_entries(
+                &cfg.metadata,
+                network,
+                &cache,
+                &config,
+                &imports,
+                &mut live_imports,
+            ))
+            .map_err(Box::new)?;
         tokio::runtime::Handle::current()
             .block_on(import_publisher_versions(
                 &cfg.metadata,
@@ -422,9 +443,17 @@ impl Store {
         };
         if clear_exemptions {
             // Delete all exemptions entries except those that are suggest=false
-            for versions in &mut clone.config.exemptions.values_mut() {
+            for versions in clone.config.exemptions.values_mut() {
                 versions.retain(|e| !e.suggest);
             }
+
+            // If we have a live_imports, clear all stale unpublished entries so
+            // we suggest audits to replace them.
+            if let Some(live_imports) = &mut clone.live_imports {
+                for unpublished in live_imports.unpublished.values_mut() {
+                    unpublished.retain(|e| e.is_fresh_import);
+                }
+            }
         }
         clone
     }
@@ -453,6 +482,18 @@ impl Store {
         }
     }
 
+    /// Returns the set of unpublished information which should be operated upon.
+    ///
+    /// If the store was acquired unlocked, whis may include unpublished
+    /// information which is not stored in imports.lock, otherwise it will only
+    /// contain imports stored locally.
+    pub fn unpublished(&self) -> &SortedMap<PackageName, Vec<UnpublishedEntry>> {
+        match &self.live_imports {
+            Some(live_imports) => &live_imports.unpublished,
+            None => &self.imports.unpublished,
+        }
+    }
+
     /// Commit the store's contents back to disk
     pub fn commit(self) -> Result<(), StoreCommitError> {
         // TODO: make this truly transactional?
@@ -819,6 +860,7 @@ fn process_imported_audits(
     allow_criteria_changes: bool,
 ) -> Result<ImportsFile, CriteriaChangeErrors> {
     let mut new_imports = ImportsFile {
+        unpublished: SortedMap::new(),
         publisher: SortedMap::new(),
         audits: SortedMap::new(),
     };
@@ -1348,6 +1390,78 @@ fn parse_imported_trust_entry(
     Some(audit)
 }
 
+async fn import_unpublished_entries(
+    metadata: &cargo_metadata::Metadata,
+    network: &Network,
+    cache: &Cache,
+    config_file: &ConfigFile,
+    imports_lock: &ImportsFile,
+    live_imports: &mut ImportsFile,
+) -> Result<(), CrateInfoError> {
+    // We always persist any unpublished entries from the imports.lock into
+    // live-imports, even if the version has since been published, as it may be
+    // necessary for `cargo vet` to pass.
+    live_imports.unpublished = imports_lock.unpublished.clone();
+
+    // Find all packages which are forced to be audit-as-crates-io, and check if
+    // they are actually published. We also skip git versions, as those can
+    // always be audit-as-crates-io.
+    let audit_as_packages = crate::first_party_packages_strict(metadata, config_file)
+        .filter(|package| package.is_third_party(&config_file.policy))
+        .filter(|package| package.git_rev().is_none());
+    for package in audit_as_packages {
+        // If we have no versions for the crate, it cannot be
+        // audit-as-crates-io, so treat it as an error.
+        // FIXME: better errors here?
+        let versions = cache.get_versions(Some(network), &package.name).await?;
+
+        // Pick which verison of the crate we'd audit as. We prefer the exact
+        // version of the crate, followed by the largest version below, and then
+        // finally the smallest version above.
+        let max_below = versions.iter().filter(|&v| v <= &package.version).max();
+        let audited_as = max_below
+            .or_else(|| versions.iter().filter(|&v| v > &package.version).min())
+            .expect("There must be at least one version");
+
+        // The exact version is published, no unpublished entries are required.
+        if audited_as == &package.version {
+            continue;
+        }
+
+        let unpublished = live_imports
+            .unpublished
+            .entry(package.name.clone())
+            .or_default();
+
+        // Mark each existing entry for this version as `still_unpublished`, as
+        // we now know this version is still unpublished.
+        for entry in &mut *unpublished {
+            if entry.version.equals_semver(&package.version) {
+                entry.still_unpublished = true;
+            }
+        }
+
+        // Push an entry for this audited_as marked as `is_fresh_import`.
+        //
+        // NOTE: We intentionally add a new entry even if there is an
+        // "identical" one already. This allows edge prioritization logic to
+        // prefer stale entries when not pruning, and fresh ones while pruning,
+        // to keep the unaudited delta as small as possible without unnecessary
+        // imports.lock churn. Only one of the two entries should ever appear in
+        // imports.lock.
+        unpublished.push(UnpublishedEntry {
+            version: package.vet_version(),
+            audited_as: VetVersion {
+                semver: audited_as.clone(),
+                git_rev: None,
+            },
+            still_unpublished: true,
+            is_fresh_import: true,
+        });
+    }
+    Ok(())
+}
+
 fn wildcard_audits_packages(
     audits_file: &AuditsFile,
     imports_file: &ImportsFile,
@@ -1618,7 +1732,7 @@ impl Cache {
                 state: Mutex::new(CacheState {
                     diff_cache: DiffCache::default(),
                     command_history: CommandHistory::default(),
-                    crates_cache: crate::tests::mock_crates_cache(),
+                    crates_cache: CratesCache::default(),
                     fetched_packages: FastMap::new(),
                     diffed: FastMap::new(),
                 }),


Test patch:
diff --git a/src/tests/audit_as_crates_io.rs b/src/tests/audit_as_crates_io.rs
index 08ae3d22..85757ba9 100644
--- a/src/tests/audit_as_crates_io.rs
+++ b/src/tests/audit_as_crates_io.rs
@@ -1,50 +1,47 @@
 use super::*;
 
+fn build_registry(network: &mut Network, extra_packages: &[&str]) {
+    let mut packages = vec![
+        "root-package",
+        "first-party",
+        "firstA",
+        "firstAB",
+        "firstB",
+        "firstB-nodeps",
+        "descriptive",
+    ];
+    packages.extend_from_slice(extra_packages);
+
+    let mut registry = MockRegistryBuilder::new();
+    registry.user(1, "user1", "User One");
+    for package in packages {
+        registry.package_m(
+            package,
+            CratesAPICrateMetadata {
+                description: Some(if package == "descriptive" {
+                    "descriptive".to_owned()
+                } else {
+                    "whatever".to_owned()
+                }),
+                repository: None,
+            },
+            &[reg_published_by(ver(DEFAULT_VER), Some(1), "2022-01-01")],
+        );
+    }
+    registry.serve(network);
+}
+
 fn get_audit_as_crates_io(cfg: &Config, store: &Store, add_packages_to_index: bool) -> String {
     let mut cache = crate::storage::Cache::acquire(cfg).unwrap();
     let mut network = crate::network::Network::new_mock();
-    if add_packages_to_index {
-        network.mock_serve_json(
-            "https://crates.io/api/v1/crates/first",
-            &serde_json::json!({
-                "crate": { "description": "whatever" },
-                "versions": [
-                    {
-                        "crate": "first",
-                        "created_at": "2022-01-01T00:00:00.000000+00:00",
-                        "num": "10.0.0",
-                        "published_by": {
-                            "id": 1,
-                            "login": "user1",
-                            "name": "User One",
-                            "url": "https://github.com/user1"
-                        }
-                    }
-                ]
-            }),
-        );
-        network_mock_index(&mut network, "first", &["10.0.0"]);
-        network.mock_serve_json(
-            "https://crates.io/api/v1/crates/root",
-            &serde_json::json!({
-                "crate": { "description": "whatever" },
-                "versions": [
-                    {
-                        "crate": "root",
-                        "created_at": "2022-01-01T00:00:00.000000+00:00",
-                        "num": "10.0.0",
-                        "published_by": {
-                            "id": 1,
-                            "login": "user1",
-                            "name": "User One",
-                            "url": "https://github.com/user1"
-                        }
-                    }
-                ]
-            }),
-        );
-        network_mock_index(&mut network, "root", &["10.0.0"]);
-    }
+    build_registry(
+        &mut network,
+        if add_packages_to_index {
+            &["first", "root"]
+        } else {
+            &[]
+        },
+    );
     let res = tokio::runtime::Handle::current().block_on(crate::check_audit_as_crates_io(
         cfg,
         store,
@@ -59,7 +56,8 @@ fn get_audit_as_crates_io(cfg: &Config, store: &Store, add_packages_to_index: bo
 
 fn get_audit_as_crates_io_json(cfg: &Config, store: &Store) -> String {
     let mut cache = crate::storage::Cache::acquire(cfg).unwrap();
-    let network = crate::network::Network::new_mock();
+    let mut network = crate::network::Network::new_mock();
+    build_registry(&mut network, &[]);
     let res = tokio::runtime::Handle::current().block_on(crate::check_audit_as_crates_io(
         cfg,
         store,
diff --git a/src/tests/certify.rs b/src/tests/certify.rs
index 26ce3f39..dea4428f 100644
--- a/src/tests/certify.rs
+++ b/src/tests/certify.rs
@@ -252,37 +252,18 @@ fn mock_wildcard_certify_flow() {
     };
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 5,
-                        "login": "otheruser",
-                        "name": "Other User",
-                        "url": "https://github.com/otheruser"
-                    }
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party1", &["9.0.0", "10.0.0"]);
+
+    MockRegistryBuilder::new()
+        .user(2, "testuser", "Test user")
+        .user(5, "otheruser", "Other User")
+        .package(
+            "third-party1",
+            &[
+                reg_published_by(ver(9), Some(5), "2022-10-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+            ],
+        )
+        .serve(&mut network);
 
     let mut store = Store::mock_online(&cfg, config, audits, imports, &network, true)
         .expect("store acquisition failed");
@@ -327,42 +308,17 @@ fn mock_trust_flow_simple() {
     };
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "1.0.0",
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party1", &["1.0.0", "9.0.0", "10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(2, "testuser", "Test user")
+        .package(
+            "third-party1",
+            &[
+                reg_published_by(ver(1), None, "2022-10-12"),
+                reg_published_by(ver(9), Some(2), "2022-10-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+            ],
+        )
+        .serve(&mut network);
 
     let mut store = Store::mock_online(&cfg, config, audits, imports, &network, true)
         .expect("store acquisition failed");
@@ -406,42 +362,18 @@ fn mock_trust_flow_ambiguous() {
     };
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "1.0.0",
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 5,
-                        "login": "otheruser",
-                        "name": "Other user",
-                        "url": "https://github.com/otheruser"
-                    }
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party1", &["1.0.0", "9.0.0", "10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(2, "testuser", "Test user")
+        .user(5, "otheruser", "Other user")
+        .package(
+            "third-party1",
+            &[
+                reg_published_by(ver(1), None, "2022-10-12"),
+                reg_published_by(ver(9), Some(5), "2022-10-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+            ],
+        )
+        .serve(&mut network);
 
     let mut store = Store::mock_online(&cfg, config, audits, imports, &network, true)
         .expect("store acquisition failed");
@@ -474,42 +406,18 @@ fn mock_trust_flow_explicit() {
     };
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "1.0.0",
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 5,
-                        "login": "otheruser",
-                        "name": "Other user",
-                        "url": "https://github.com/otheruser"
-                    }
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party1", &["1.0.0", "9.0.0", "10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(2, "testuser", "Test user")
+        .user(5, "otheruser", "Other user")
+        .package(
+            "third-party1",
+            &[
+                reg_published_by(ver(1), None, "2022-10-12"),
+                reg_published_by(ver(9), Some(5), "2022-10-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+            ],
+        )
+        .serve(&mut network);
 
     let mut store = Store::mock_online(&cfg, config, audits, imports, &network, true)
         .expect("store acquisition failed");
@@ -553,104 +461,30 @@ fn mock_trust_flow_all() {
     };
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "1.0.0",
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 5,
-                        "login": "otheruser",
-                        "name": "Other user",
-                        "url": "https://github.com/otheruser"
-                    }
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party1", &["1.0.0", "9.0.0", "10.0.0"]);
-
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/transitive-third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "1.0.0",
-                },
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-10-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(
-        &mut network,
-        "transitive-third-party1",
-        &["1.0.0", "9.0.0", "10.0.0"],
-    );
-
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party2",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party2",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party2", &["10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(2, "testuser", "Test user")
+        .user(5, "otheruser", "Other user")
+        .package(
+            "third-party1",
+            &[
+                reg_published_by(ver(1), None, "2022-10-12"),
+                reg_published_by(ver(9), Some(5), "2022-10-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+            ],
+        )
+        .package(
+            "transitive-third-party1",
+            &[
+                reg_published_by(ver(1), None, "2022-10-12"),
+                reg_published_by(ver(9), Some(2), "2022-10-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+            ],
+        )
+        .package(
+            "third-party2",
+            &[reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12")],
+        )
+        .serve(&mut network);
 
     let mut store = Store::mock_online(&cfg, config, audits, imports, &network, true)
         .expect("store acquisition failed");
diff --git a/src/tests/import.rs b/src/tests/import.rs
index bd3c6e09..1c1b96e8 100644
--- a/src/tests/import.rs
+++ b/src/tests/import.rs
@@ -188,26 +188,13 @@ fn existing_peer_skip_import() {
 
     let mut network = Network::new_mock();
     network.mock_serve_toml(FOREIGN_URL, &new_foreign_audits);
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party2",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party2",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 1,
-                        "login": "user1",
-                        "name": "User One",
-                        "url": "https://github.com/user1"
-                    }
-                }
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party2", &["10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(1, "user1", "User One")
+        .package(
+            "third-party2",
+            &[reg_published_by(ver(DEFAULT_VER), Some(1), "2022-12-12")],
+        )
+        .serve(&mut network);
 
     let store = Store::mock_online(&cfg, config, audits, imports, &network, true).unwrap();
 
@@ -1637,68 +1624,24 @@ fn import_wildcard_audit_publisher() {
     let cfg = mock_cfg(&metadata);
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "user2",
-                        "name": "User Two",
-                        "url": "https://github.com/user2"
-                    }
-                },
-                {
-                    "crate": "third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "5.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "user2",
-                        "name": "User Two",
-                        "url": "https://github.com/user2"
-                    }
-                }
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party1", &["5.0.0", "10.0.0"]);
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/third-party2",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "third-party2",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 1,
-                        "login": "user1",
-                        "name": "User One",
-                        "url": "https://github.com/user1"
-                    }
-                },
-                {
-                    "crate": "third-party2",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "5.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "user2",
-                        "name": "User Two",
-                        "url": "https://github.com/user2"
-                    }
-                }
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "third-party2", &["5.0.0", "10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(1, "user1", "User One")
+        .user(2, "user2", "User Two")
+        .package(
+            "third-party1",
+            &[
+                reg_published_by(ver(DEFAULT_VER), Some(2), "2022-12-12"),
+                reg_published_by(ver(5), Some(2), "2022-12-12"),
+            ],
+        )
+        .package(
+            "third-party2",
+            &[
+                reg_published_by(ver(DEFAULT_VER), Some(1), "2022-12-12"),
+                reg_published_by(ver(5), Some(2), "2022-12-12"),
+            ],
+        )
+        .serve(&mut network);
     network.mock_serve_toml(FOREIGN_URL, &new_foreign_audits);
 
     let store = Store::mock_online(&cfg, config, audits, imports, &network, true).unwrap();
diff --git a/src/tests/mod.rs b/src/tests/mod.rs
index 5e619b8f..adfff592 100644
--- a/src/tests/mod.rs
+++ b/src/tests/mod.rs
@@ -12,10 +12,11 @@ use serde_json::{json, Value};
 
 use crate::{
     format::{
-        AuditEntry, AuditKind, AuditsFile, ConfigFile, CratesPublisher, CriteriaEntry, CriteriaMap,
-        CriteriaName, CriteriaStr, ExemptedDependency, ImportsFile, MetaConfig, PackageName,
-        PackagePolicyEntry, PackageStr, PolicyEntry, SortedMap, SortedSet, TrustEntry, VersionReq,
-        VetVersion, WildcardEntry, SAFE_TO_DEPLOY, SAFE_TO_RUN,
+        AuditEntry, AuditKind, AuditsFile, ConfigFile, CratesAPICrate, CratesAPICrateMetadata,
+        CratesAPIUser, CratesAPIVersion, CratesPublisher, CratesUserId, CriteriaEntry, CriteriaMap,
+        CriteriaName, CriteriaStr, ExemptedDependency, FastMap, ImportsFile, MetaConfig,
+        PackageName, PackagePolicyEntry, PackageStr, PolicyEntry, SortedMap, SortedSet, TrustEntry,
+        VersionReq, VetVersion, WildcardEntry, SAFE_TO_DEPLOY, SAFE_TO_RUN,
     },
     git_tool::Editor,
     network::Network,
@@ -54,6 +55,7 @@ mod registry;
 mod renew;
 mod store_parsing;
 mod trusted;
+mod unpublished;
 mod vet;
 mod violations;
 mod wildcard;
@@ -97,56 +99,6 @@ lazy_static::lazy_static! {
 
 }
 
-pub fn mock_crates_cache() -> crate::format::CratesCache {
-    let now: chrono::DateTime<chrono::Utc> = std::time::SystemTime::now().into();
-    let mut cache = crate::format::CratesCache::default();
-
-    for pkg_name in [
-        "root-package",
-        "first-party",
-        "firstA",
-        "firstAB",
-        "firstB",
-        "firstB-nodeps",
-        "descriptive",
-    ] {
-        cache.crates.insert(
-            pkg_name.into(),
-            crate::format::CratesCacheEntry {
-                last_fetched: now,
-                versions: [(
-                    semver::Version {
-                        major: DEFAULT_VER,
-                        minor: 0,
-                        patch: 0,
-                        pre: Default::default(),
-                        build: Default::default(),
-                    },
-                    Some(crate::format::CratesCacheVersionDetails {
-                        created_at: now,
-                        published_by: None,
-                    }),
-                )]
-                .into_iter()
-                .collect(),
-                metadata: Some(crate::format::CratesAPICrateMetadata {
-                    description: Some(
-                        if pkg_name == "descriptive" {
-                            "descriptive"
-                        } else {
-                            "whatever"
-                        }
-                        .into(),
-                    ),
-                    repository: None,
-                }),
-            },
-        );
-    }
-
-    cache
-}
-
 struct MockMetadata {
     packages: Vec<MockPackage>,
     pkgids: Vec<String>,
@@ -351,6 +303,22 @@ fn publisher_entry(version: VetVersion, user_id: u64) -> CratesPublisher {
     }
 }
 
+fn publisher_entry_named(
+    version: VetVersion,
+    user_id: u64,
+    login: &str,
+    name: &str,
+) -> CratesPublisher {
+    CratesPublisher {
+        version,
+        when: chrono::NaiveDate::from_ymd_opt(2022, 12, 15).unwrap(),
+        user_id,
+        user_login: login.to_owned(),
+        user_name: Some(name.to_owned()),
+        is_fresh_import: false,
+    }
+}
+
 fn default_policy() -> PolicyEntry {
     PolicyEntry {
         audit_as_crates_io: None,
@@ -900,6 +868,7 @@ fn init_files(
         trusted: SortedMap::new(),
     };
     let imports = ImportsFile {
+        unpublished: SortedMap::new(),
         publisher: SortedMap::new(),
         audits: SortedMap::new(),
     };
@@ -1217,31 +1186,148 @@ fn diff_store_commits(old: &SortedMap<String, String>, new: &SortedMap<String, S
     result
 }
 
-fn network_mock_index(network: &mut Network, package: &str, versions: &[&str]) {
-    // To keep things simple, only handle the URL for 4+ characters in package names.
-    assert!(package.len() >= 4);
-    network.mock_serve(
-        format!(
-            "https://index.crates.io/{}/{}/{package}",
-            &package[0..2],
-            &package[2..4]
-        ),
-        versions
-            .iter()
-            .map(|v| {
-                serde_json::to_string(&json!({
-                    "name": package,
-                    "vers": v,
-                    "deps": [],
-                    "cksum": "90527ab4abff2f0608cdb1a78e2349180e1d92059f59b5a65ce2a1a15a499b73",
-                    "features": {},
-                    "yanked": false
-                }))
-                .unwrap()
-            })
-            .collect::<Vec<_>>()
-            .join("\n"),
+#[derive(Clone)]
+struct MockRegistryVersion {
+    version: semver::Version,
+    published_by: Option<CratesUserId>,
+    created_at: chrono::DateTime<chrono::Utc>,
+}
+
+fn reg_published_by(
+    version: VetVersion,
+    published_by: Option<CratesUserId>,
+    when: &str,
+) -> MockRegistryVersion {
+    assert!(
+        version.git_rev.is_none(),
+        "cannot publish a git version to registry"
     );
+    MockRegistryVersion {
+        version: version.semver,
+        published_by,
+        created_at: chrono::DateTime::from_utc(
+            chrono::NaiveDateTime::new(
+                when.parse::<chrono::NaiveDate>().unwrap(),
+                chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
+            ),
+            chrono::Utc,
+        ),
+    }
+}
+
+struct MockRegistryPackage {
+    versions: Vec<MockRegistryVersion>,
+    metadata: CratesAPICrateMetadata,
+}
+
+#[derive(Default)]
+struct MockRegistryBuilder {
+    users: FastMap<CratesUserId, CratesAPIUser>,
+    packages: FastMap<PackageName, MockRegistryPackage>,
+}
+
+impl MockRegistryBuilder {
+    fn new() -> Self {
+        Default::default()
+    }
+
+    fn user(&mut self, id: CratesUserId, login: &str, name: &str) -> &mut Self {
+        self.users.insert(
+            id,
+            CratesAPIUser {
+                id,
+                login: login.to_owned(),
+                name: Some(name.to_owned()),
+            },
+        );
+        self
+    }
+
+    fn package(&mut self, name: PackageStr<'_>, versions: &[MockRegistryVersion]) -> &mut Self {
+        self.package_m(
+            name,
+            CratesAPICrateMetadata {
+                description: None,
+                repository: None,
+            },
+            versions,
+        )
+    }
+
+    fn package_m(
+        &mut self,
+        name: PackageStr<'_>,
+        metadata: CratesAPICrateMetadata,
+        versions: &[MockRegistryVersion],
+    ) -> &mut Self {
+        // To keep things simple, only handle the URL for 4+ characters in package names for now.
+        assert!(name.len() >= 4);
+        self.packages.insert(
+            name.to_owned(),
+            MockRegistryPackage {
+                metadata,
+                versions: versions.to_owned(),
+            },
+        );
+        self
+    }
+
+    fn serve(&self, network: &mut Network) {
+        for (name, pkg) in &self.packages {
+            // Serve the index entry as part of the http index.
+            network.mock_serve(
+                format!(
+                    "https://index.crates.io/{}/{}/{name}",
+                    &name[0..2],
+                    &name[2..4]
+                ).to_ascii_lowercase(),
+               pkg.versions
+                    .iter()
+                    .map(|v| {
+                        serde_json::to_string(&json!({
+                            "name": name,
+                            "vers": &v.version,
+                            "deps": [],
+                            "cksum": "90527ab4abff2f0608cdb1a78e2349180e1d92059f59b5a65ce2a1a15a499b73",
+                            "features": {},
+                            "yanked": false
+                        }))
+                        .unwrap()
+                    })
+                    .collect::<Vec<_>>()
+                    .join("\n"),
+            );
+
+            // Serve the crates.io API to match the http index and host extra metadata.
+            //
+            // NOTE: crates.io actually serves the API case-insensitively,
+            // unlike the http index, which is case-sensitive (and lowercase).
+            // Preserving case here matches how we currently construct the API
+            // url internally, but may need to be changed in the future.
+            network.mock_serve_json(
+                format!("https://crates.io/api/v1/crates/{name}"),
+                &CratesAPICrate {
+                    crate_data: pkg.metadata.clone(),
+                    versions: pkg
+                        .versions
+                        .iter()
+                        .map(|v| CratesAPIVersion {
+                            created_at: v.created_at,
+                            num: v.version.clone(),
+                            published_by: v.published_by.map(|id| {
+                                let user = &self.users[&id];
+                                CratesAPIUser {
+                                    id,
+                                    login: user.login.clone(),
+                                    name: user.name.clone(),
+                                }
+                            }),
+                        })
+                        .collect(),
+                },
+            )
+        }
+    }
 }
 
 // TESTING BACKLOG:
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_blank_regenerate_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_blank_regenerate_exemptions.snap
new file mode 100644
index 00000000..0c71fd2f
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_blank_regenerate_exemptions.snap
@@ -0,0 +1,28 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml:
+ 
+ # cargo-vet config file
+ 
+ [cargo-vet]
+ version = "1.0"
+ 
+ [policy.descriptive]
+ audit-as-crates-io = true
++
++[[exemptions.descriptive]]
++version = "9.0.0"
++criteria = "safe-to-deploy"
+
+imports.lock:
+ 
+ # cargo-vet imports lock
++
++[[unpublished.descriptive]]
++version = "10.0.0"
++audited_as = "9.0.0"
+
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prefer_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prefer_exemptions.snap
new file mode 100644
index 00000000..82d4a3ae
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prefer_exemptions.snap
@@ -0,0 +1,8 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock: (unchanged)
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prefer_fresh.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prefer_fresh.snap
new file mode 100644
index 00000000..82d4a3ae
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prefer_fresh.snap
@@ -0,0 +1,8 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock: (unchanged)
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prune.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prune.snap
new file mode 100644
index 00000000..82d4a3ae
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_prune.snap
@@ -0,0 +1,8 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock: (unchanged)
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_regenerate_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_regenerate_exemptions.snap
new file mode 100644
index 00000000..82d4a3ae
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_full_regenerate_exemptions.snap
@@ -0,0 +1,8 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock: (unchanged)
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_prefer_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_prefer_exemptions.snap
new file mode 100644
index 00000000..145db011
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_prefer_exemptions.snap
@@ -0,0 +1,29 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock:
+ 
+ # cargo-vet imports lock
+ 
++[[unpublished.descriptive]]
++version = "10.0.0"
++audited_as = "9.0.0"
++
+ [[publisher.descriptive]]
+ version = "8.0.0"
+ when = "2022-12-15"
+ user-id = 1
+ user-login = "user1"
+ user-name = "User One"
++
++[[publisher.descriptive]]
++version = "9.0.0"
++when = "2022-12-15"
++user-id = 1
++user-login = "user1"
++user-name = "User One"
+
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_prune.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_prune.snap
new file mode 100644
index 00000000..d5ad564a
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_prune.snap
@@ -0,0 +1,23 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock:
+ 
+ # cargo-vet imports lock
+ 
++[[unpublished.descriptive]]
++version = "10.0.0"
++audited_as = "9.0.0"
++
+ [[publisher.descriptive]]
+-version = "8.0.0"
++version = "9.0.0"
+ when = "2022-12-15"
+ user-id = 1
+ user-login = "user1"
+ user-name = "User One"
+
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_regenerate_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_regenerate_exemptions.snap
new file mode 100644
index 00000000..d5ad564a
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_nounpublished_regenerate_exemptions.snap
@@ -0,0 +1,23 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock:
+ 
+ # cargo-vet imports lock
+ 
++[[unpublished.descriptive]]
++version = "10.0.0"
++audited_as = "9.0.0"
++
+ [[publisher.descriptive]]
+-version = "8.0.0"
++version = "9.0.0"
+ when = "2022-12-15"
+ user-id = 1
+ user-login = "user1"
+ user-name = "User One"
+
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prefer_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prefer_exemptions.snap
new file mode 100644
index 00000000..82d4a3ae
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prefer_exemptions.snap
@@ -0,0 +1,8 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock: (unchanged)
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prefer_fresh.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prefer_fresh.snap
new file mode 100644
index 00000000..2208d9c2
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prefer_fresh.snap
@@ -0,0 +1,24 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock:
+ 
+ # cargo-vet imports lock
+ 
+ [[unpublished.descriptive]]
+ version = "10.0.0"
+-audited_as = "8.0.0"
++audited_as = "9.0.0"
+ 
+ [[publisher.descriptive]]
+-version = "8.0.0"
++version = "9.0.0"
+ when = "2022-12-15"
+ user-id = 1
+ user-login = "user1"
+ user-name = "User One"
+
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prune.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prune.snap
new file mode 100644
index 00000000..2208d9c2
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_prune.snap
@@ -0,0 +1,24 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock:
+ 
+ # cargo-vet imports lock
+ 
+ [[unpublished.descriptive]]
+ version = "10.0.0"
+-audited_as = "8.0.0"
++audited_as = "9.0.0"
+ 
+ [[publisher.descriptive]]
+-version = "8.0.0"
++version = "9.0.0"
+ when = "2022-12-15"
+ user-id = 1
+ user-login = "user1"
+ user-name = "User One"
+
+
diff --git a/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_regenerate_exemptions.snap b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_regenerate_exemptions.snap
new file mode 100644
index 00000000..2208d9c2
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__unpublished__audit_as_crates_io_unpublished_wildcard_regenerate_exemptions.snap
@@ -0,0 +1,24 @@
+---
+source: src/tests/unpublished.rs
+expression: output
+---
+audits.toml: (unchanged)
+config.toml: (unchanged)
+imports.lock:
+ 
+ # cargo-vet imports lock
+ 
+ [[unpublished.descriptive]]
+ version = "10.0.0"
+-audited_as = "8.0.0"
++audited_as = "9.0.0"
+ 
+ [[publisher.descriptive]]
+-version = "8.0.0"
++version = "9.0.0"
+ when = "2022-12-15"
+ user-id = 1
+ user-login = "user1"
+ user-name = "User One"
+
+
diff --git a/src/tests/trusted.rs b/src/tests/trusted.rs
index b3e07a9b..c9c687a8 100644
--- a/src/tests/trusted.rs
+++ b/src/tests/trusted.rs
@@ -120,26 +120,13 @@ fn trusted_suggest_local() {
     );
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/transitive-third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 1,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "transitive-third-party1", &["10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(1, "testuser", "Test user")
+        .package(
+            "transitive-third-party1",
+            &[reg_published_by(ver(DEFAULT_VER), Some(1), "2022-12-12")],
+        )
+        .serve(&mut network);
 
     let cfg = mock_cfg(&metadata);
 
@@ -179,26 +166,13 @@ fn trusted_suggest_import() {
 
     let mut network = Network::new_mock();
     network.mock_serve_toml(FOREIGN_URL, &new_foreign_audits);
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/transitive-third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 1,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "transitive-third-party1", &["10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(1, "testuser", "Test user")
+        .package(
+            "transitive-third-party1",
+            &[reg_published_by(ver(DEFAULT_VER), Some(1), "2022-12-12")],
+        )
+        .serve(&mut network);
 
     let cfg = mock_cfg(&metadata);
 
@@ -247,26 +221,13 @@ fn trusted_suggest_import_multiple() {
     let mut network = Network::new_mock();
     network.mock_serve_toml(FOREIGN_URL, &new_foreign_audits);
     network.mock_serve_toml(OTHER_FOREIGN_URL, &new_foreign_audits);
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/transitive-third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 1,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(&mut network, "transitive-third-party1", &["10.0.0"]);
+    MockRegistryBuilder::new()
+        .user(1, "testuser", "Test user")
+        .package(
+            "transitive-third-party1",
+            &[reg_published_by(ver(DEFAULT_VER), Some(1), "2022-12-12")],
+        )
+        .serve(&mut network);
 
     let cfg = mock_cfg(&metadata);
 
@@ -294,41 +255,17 @@ fn trusted_suggest_local_ambiguous() {
     );
 
     let mut network = Network::new_mock();
-    network.mock_serve_json(
-        "https://crates.io/api/v1/crates/transitive-third-party1",
-        &serde_json::json!({
-            "crate": { "description": "description" },
-            "versions": [
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "9.0.0",
-                    "published_by": {
-                        "id": 2,
-                        "login": "otheruser",
-                        "name": "Other user",
-                        "url": "https://github.com/otheruser"
-                    }
-                },
-                {
-                    "crate": "transitive-third-party1",
-                    "created_at": "2022-12-12T04:51:37.251648+00:00",
-                    "num": "10.0.0",
-                    "published_by": {
-                        "id": 1,
-                        "login": "testuser",
-                        "name": "Test user",
-                        "url": "https://github.com/testuser"
-                    }
-                },
-            ]
-        }),
-    );
-    network_mock_index(
-        &mut network,
-        "transitive-third-party1",
-        &["9.0.0", "10.0.0"],
-    );
+    MockRegistryBuilder::new()
+        .user(1, "testuser", "Test user")
+        .user(2, "otheruser", "Other user")
+        .package(
+            "transitive-third-party1",
+            &[
+                reg_published_by(ver(9), Some(2), "2022-12-12"),
+                reg_published_by(ver(DEFAULT_VER), Some(1), "2022-12-12"),
+            ],
+        )
+        .serve(&mut network);
 
     let cfg = mock_cfg(&metadata);
 
diff --git a/src/tests/unpublished.rs b/src/tests/unpublished.rs
new file mode 100644
index 00000000..733eb737
--- /dev/null
+++ b/src/tests/unpublished.rs
@@ -0,0 +1,218 @@
+use super::*;
+
+#[derive(Copy, Clone, Eq, PartialEq)]
+enum UnpublishedInitialState {
+    // wildcard audit applies to 8 and 9, unpublished from 8 to 10
+    WildcardAudit,
+    // full audit applying to 8. unpublished from 8 to 10
+    FullAudit,
+    // wildcard audit with cached publisher applies to 8. Live publisher applies
+    // to 9. No unpublished entry.
+    WildcardNoUnpublished,
+    // no audits/unpublished entries
+    Nothing,
+}
+
+/// Helper used by a couple of tests. Runs `update_store` against a common store
+/// state with the given update mode.
+fn unpublished_basic_regenerate(
+    initial: UnpublishedInitialState,
+    mode: crate::resolver::UpdateMode,
+) -> String {
+    let _enter = TEST_RUNTIME.enter();
+
+    let mock = MockMetadata::descriptive();
+    let metadata = mock.metadata();
+    let (mut config, mut audits, mut imports) = builtin_files_inited(&metadata);
+
+    config.exemptions.remove("descriptive");
+    config
+        .policy
+        .insert("descriptive".to_owned(), audit_as_policy(Some(true)));
+    imports.unpublished.insert(
+        "descriptive".to_owned(),
+        vec![crate::format::UnpublishedEntry {
+            version: ver(DEFAULT_VER),
+            audited_as: ver(8),
+            still_unpublished: false,
+            is_fresh_import: false,
+        }],
+    );
+
+    match initial {
+        UnpublishedInitialState::FullAudit => {
+            audits.audits.insert(
+                "descriptive".to_owned(),
+                vec![full_audit(ver(8), SAFE_TO_DEPLOY)],
+            );
+        }
+        UnpublishedInitialState::WildcardAudit | UnpublishedInitialState::WildcardNoUnpublished => {
+            audits.wildcard_audits.insert(
+                "descriptive".to_owned(),
+                vec![wildcard_audit(1, SAFE_TO_DEPLOY)],
+            );
+            imports.publisher.insert(
+                "descriptive".to_owned(),
+                vec![publisher_entry_named(ver(8), 1, "user1", "User One")],
+            );
+            if initial == UnpublishedInitialState::WildcardNoUnpublished {
+                imports.unpublished.remove("descriptive");
+            }
+        }
+        UnpublishedInitialState::Nothing => {
+            imports.unpublished.remove("descriptive");
+        }
+    }
+
+    let mut network = Network::new_mock();
+    MockRegistryBuilder::new()
+        .user(1, "user1", "User One")
+        .package(
+            "descriptive",
+            &[
+                reg_published_by(ver(8), Some(1), "2022-12-15"),
+                reg_published_by(ver(9), Some(1), "2022-12-15"),
+            ],
+        )
+        .serve(&mut network);
+
+    let cfg = mock_cfg(&metadata);
+
+    let mut store = Store::mock_online(&cfg, config, audits, imports, &network, false).unwrap();
+
+    let old = store.mock_commit();
+    crate::resolver::update_store(&mock_cfg(&metadata), &mut store, |_| mode);
+    let new = store.mock_commit();
+
+    diff_store_commits(&old, &new)
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_blank_regenerate_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::Nothing,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::RegenerateExemptions,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_full_regenerate_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::FullAudit,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::RegenerateExemptions,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_full_prune() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::FullAudit,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::PreferFreshImports,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_full_prefer_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::FullAudit,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::PreferExemptions,
+            prune_exemptions: false,
+            prune_imports: false,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_wildcard_regenerate_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::WildcardAudit,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::RegenerateExemptions,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_wildcard_prune() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::WildcardAudit,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::PreferFreshImports,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_wildcard_prefer_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::WildcardAudit,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::PreferExemptions,
+            prune_exemptions: false,
+            prune_imports: false,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_wildcard_nounpublished_regenerate_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::WildcardNoUnpublished,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::RegenerateExemptions,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_wildcard_nounpublished_prune() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::WildcardNoUnpublished,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::PreferFreshImports,
+            prune_exemptions: true,
+            prune_imports: true,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
+
+#[test]
+fn audit_as_crates_io_unpublished_wildcard_nounpublished_prefer_exemptions() {
+    let output = unpublished_basic_regenerate(
+        UnpublishedInitialState::WildcardNoUnpublished,
+        crate::resolver::UpdateMode {
+            search_mode: crate::resolver::SearchMode::PreferExemptions,
+            prune_exemptions: false,
+            prune_imports: false,
+        },
+    );
+    insta::assert_snapshot!(output);
+}
diff --git a/tests/snapshots/test_cli__markdown-help.snap b/tests/snapshots/test_cli__markdown-help.snap
index a5fcdf30..2fafe3f0 100644
--- a/tests/snapshots/test_cli__markdown-help.snap
+++ b/tests/snapshots/test_cli__markdown-help.snap
@@ -503,7 +503,9 @@ This subcommand accepts all the [global options](#global-options)
 ### SUBCOMMANDS
 * [exemptions](#cargo-vet-exemptions): Regenerate your exemptions to make `check` pass minimally
 * [imports](#cargo-vet-imports): Regenerate your imports and accept changes to criteria
-* [audit-as-crates-io](#cargo-vet-audit-as-crates-io): Regenerate you audit-as-crates-io entries to make `check` pass
+* [audit-as-crates-io](#cargo-vet-audit-as-crates-io): Add `audit-as-crates-io` to the policy entry for all crates which require one
+* [unpublished](#cargo-vet-unpublished): Remove all outdated `unpublished` entries for crates which have since been published, or
+should now be audited as a more-recent version
 * [help](#cargo-vet-help): Print this message or the help of the given subcommand(s)
 
 <br><br><br>
@@ -551,9 +553,10 @@ This subcommand accepts all the [global options](#global-options)
 
 <br><br><br>
 ## cargo vet audit-as-crates-io
-Regenerate you audit-as-crates-io entries to make `check` pass
+Add `audit-as-crates-io` to the policy entry for all crates which require one.
 
-This will just set any problematic entries to `audit-as-crates-io = false`.
+Crates which have a matching `description` and `repository` entry to a published crate on crates.io
+will be marked as `audit-as-crates-io = true`.
 
 ### USAGE
 ```
@@ -567,6 +570,26 @@ Print help information
 ### GLOBAL OPTIONS
 This subcommand accepts all the [global options](#global-options)
 
+<br><br><br>
+## cargo vet unpublished
+Remove all outdated `unpublished` entries for crates which have since been published, or should now
+be audited as a more-recent version.
+
+Unlike `cargo vet prune`, this will remove outdated `unpublished` entries even if it will cause
+`check` to start failing.
+
+### USAGE
+```
+cargo vet regenerate unpublished [OPTIONS]
+```
+
+### OPTIONS
+#### `-h, --help`
+Print help information
+
+### GLOBAL OPTIONS
+This subcommand accepts all the [global options](#global-options)
+
 <br><br><br>
 ## cargo vet help
 Print this message or the help of the given subcommand(s)