Repository: mozilla/cargo-vet (rust)

Issue:
Interactively display criteria and instructions when invoking inspect/diff
When someone encounters a cargo-vet failure for the first time, the output will tell them to run `inspect` or `diff` and indicate which criteria to audit for, but there's never an opportunity for the person to read the actual criteria until they're done with the audit and trying to certify. This is obviously not ideal for first-timers.

My proposal here is that `inspect` and `diff` should infer the criteria (with the same algorithm that `certify` uses), and print the description(s) to the terminal, perhaps along with some additional helpful instructions ("these audits impact the ecosystem", "ask for help if you're not sure", etc). The user then hits `enter` to continue with the inspect/diff (which currently results in more activities in the terminal, but will soon open up a web inspection view unless --local is passed).

We can then add a --non-interactive flag to skip the above.

Pull request:
Interactively display criteria and instructions for inspect and diff
This changes the behaviour of the inspect and diff instructions to now
predict the required criteria for the audit, and display the relevant
information up-front. A warning is shown if no criteria could be
determined, to help avoid accidentally unnecessary audits.

In addition, a partial audit for `syn` was added to the test project to
make it easier to test delta audit suggestions.

Fixes #200

Patch:
diff --git a/src/main.rs b/src/main.rs
index ee5c1d0..e314f25 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,18 +1,15 @@
 use std::collections::HashMap;
-use std::mem;
 use std::ops::Deref;
 use std::panic::panic_any;
-use std::path::Path;
 use std::time::Duration;
-use std::{fs::File, panic, path::PathBuf};
+use std::{fs::File, io, mem, panic, path::PathBuf};
 
-use cargo_metadata::{Metadata, Package};
+use cargo_metadata::{Metadata, Package, Version};
 use clap::{CommandFactory, Parser};
 use console::Term;
 use errors::{
-    AuditAsError, AuditAsErrors, CertifyError, DiffError, MinimizeUnauditedError,
-    NeedsAuditAsError, NeedsAuditAsErrors, ShouldntBeAuditAsError, ShouldntBeAuditAsErrors,
-    UserInfoError,
+    AuditAsError, AuditAsErrors, CertifyError, MinimizeUnauditedError, NeedsAuditAsError,
+    NeedsAuditAsErrors, ShouldntBeAuditAsError, ShouldntBeAuditAsErrors, UserInfoError,
 };
 use format::{CriteriaName, CriteriaStr, PackageName, PolicyEntry};
 use futures_util::future::join_all;
@@ -20,7 +17,7 @@ use miette::{miette, Context, IntoDiagnostic};
 use network::Network;
 use reqwest::Url;
 use serde::de::Deserialize;
-use tracing::{error, info, trace};
+use tracing::{error, info, trace, warn};
 
 use crate::cli::*;
 use crate::errors::{CommandError, DownloadError};
@@ -261,8 +258,6 @@ fn real_main() -> Result<(), miette::Report> {
     };
 
     match &partial_cfg.cli.command {
-        Some(Inspect(sub_args)) => return cmd_inspect(out, &partial_cfg, sub_args),
-        Some(Diff(sub_args)) => return cmd_diff(out, &partial_cfg, sub_args),
         Some(HelpMarkdown(sub_args)) => return cmd_help_md(out, &partial_cfg, sub_args),
         Some(Gc(sub_args)) => return cmd_gc(out, &partial_cfg, sub_args),
         _ => {
@@ -422,8 +417,9 @@ fn real_main() -> Result<(), miette::Report> {
         Some(FetchImports(sub_args)) => cmd_fetch_imports(out, &cfg, sub_args),
         Some(RegenerateUnaudited(sub_args)) => cmd_regenerate_unaudited(out, &cfg, sub_args),
         Some(DumpGraph(sub_args)) => cmd_dump_graph(out, &cfg, sub_args),
-        // Need to be non-exhaustive because freestanding commands were handled earlier
-        _ => unreachable!("did you add a new command and forget to implement it?"),
+        Some(Inspect(sub_args)) => cmd_inspect(out, &cfg, sub_args),
+        Some(Diff(sub_args)) => cmd_diff(out, &cfg, sub_args),
+        Some(HelpMarkdown(_)) | Some(Gc(_)) => unreachable!("handled earlier"),
     }
 }
 
@@ -498,23 +494,30 @@ pub fn init_files(
 
 fn cmd_inspect(
     out: &mut dyn Out,
-    cfg: &PartialConfig,
+    cfg: &Config,
     sub_args: &InspectArgs,
 ) -> Result<(), miette::Report> {
-    // Download a crate's source to a temp location for review
+    let store = Store::acquire(cfg)?;
     let cache = Cache::acquire(cfg)?;
     let network = Network::acquire(cfg);
+
+    let version = &sub_args.version;
+    let package = &*sub_args.package;
+
     // Record this command for magic in `vet certify`
     cache.set_last_fetch(FetchCommand::Inspect {
-        package: sub_args.package.clone(),
-        version: sub_args.version.clone(),
+        package: package.to_owned(),
+        version: version.clone(),
     });
 
-    let package = &*sub_args.package;
-
-    let fetched = tokio::runtime::Handle::current()
-        .block_on(cache.fetch_package(network.as_ref(), package, &sub_args.version))
-        .into_diagnostic()?;
+    let fetched = tokio::runtime::Handle::current().block_on(async {
+        let (pkg, eulas) = tokio::join!(
+            cache.fetch_package(network.as_ref(), package, version),
+            prompt_criteria_eulas(out, cfg, network.as_ref(), &store, package, None, version),
+        );
+        eulas.into_diagnostic()?;
+        pkg.into_diagnostic()
+    })?;
 
     #[cfg(target_family = "unix")]
     {
@@ -639,6 +642,12 @@ fn do_cmd_certify(
     let criteria_mapper = CriteriaMapper::new(&store.audits.criteria);
 
     let criteria_names = if sub_args.criteria.is_empty() {
+        let (from, to) = match &kind {
+            AuditKind::Full { version, .. } => (None, version),
+            AuditKind::Delta { delta, .. } => (Some(&delta.from), &delta.to),
+            _ => unreachable!(),
+        };
+
         // If we don't have explicit cli criteria, guess the criteria
         //
         // * Check what would cause `cargo vet` to encounter fewer errors
@@ -646,7 +655,7 @@ fn do_cmd_certify(
         // * Otherwise guess nothing
         //
         // Regardless of the guess, prompt the user to confirm (just needs to mash enter)
-        let mut chosen_criteria = guess_audit_criteria(cfg, store, &package, &kind);
+        let mut chosen_criteria = guess_audit_criteria(cfg, store, &package, from, to);
 
         // Prompt for criteria
         loop {
@@ -865,18 +874,9 @@ fn guess_audit_criteria(
     cfg: &Config,
     store: &Store,
     package: PackageStr<'_>,
-    kind: &AuditKind,
+    from: Option<&Version>,
+    to: &Version,
 ) -> Vec<String> {
-    // Convert the audit to a normalized delta to run against the resolver.
-    let delta = match kind {
-        AuditKind::Full { version, .. } => Delta {
-            from: resolver::ROOT_VERSION.clone(),
-            to: version.clone(),
-        },
-        AuditKind::Delta { delta, .. } => delta.clone(),
-        _ => return Vec::new(),
-    };
-
     // Attempt to resolve a normal `cargo vet`, and try to find criteria which
     // would heal some errors in that result if it fails.
     let criteria = resolver::resolve(
@@ -885,7 +885,7 @@ fn guess_audit_criteria(
         store,
         ResolveDepth::Deep,
     )
-    .compute_suggested_criteria(package, &delta);
+    .compute_suggested_criteria(package, from, to);
     if !criteria.is_empty() {
         return criteria;
     }
@@ -901,7 +901,86 @@ fn guess_audit_criteria(
         &store.clone_for_suggest(),
         ResolveDepth::Deep,
     )
-    .compute_suggested_criteria(package, &delta)
+    .compute_suggested_criteria(package, from, to)
+}
+
+/// Prompt the user to read the EULAs for the expected criteria which they will
+/// be certifying for with this diff or inspect command.
+///
+/// This method is async so it can be performed concurrently with waiting for
+/// the downloads to complete.
+async fn prompt_criteria_eulas(
+    out: &mut dyn Out,
+    cfg: &Config,
+    network: Option<&Network>,
+    store: &Store,
+    package: PackageStr<'_>,
+    from: Option<&Version>,
+    to: &Version,
+) -> Result<(), io::Error> {
+    let description = if let Some(from) = from {
+        format!(
+            "You are about to diff versions {} and {} of '{}'",
+            from, to, package
+        )
+    } else {
+        format!("You are about to inspect version {} of '{}'", to, package)
+    };
+
+    // Guess which criteria the user is going to be auditing the package for.
+    let criteria_names = guess_audit_criteria(cfg, store, package, from, to);
+
+    // FIXME: These `writeln` calls can do blocking I/O, but they hopefully
+    // shouldn't block long enough for it interfere with downloading packages in
+    // the background. We do the `read_line_with_prompt` call async.
+    if criteria_names.is_empty() {
+        writeln!(out, "{}", out.style().bold().apply_to(description))?;
+        warn!("unable to determine likely criteria, this may not be a relevant audit for this project.");
+    } else {
+        let eulas = join_all(criteria_names.iter().map(|criteria| async {
+            (
+                &criteria[..],
+                eula_for_criteria(network, &store.audits.criteria, criteria).await,
+            )
+        }))
+        .await;
+
+        for (idx, (criteria, eula)) in eulas.into_iter().enumerate() {
+            let prompt = if idx == 0 {
+                format!(
+                    "{}, likely to certify it for {:?}, which means:",
+                    description, criteria
+                )
+            } else {
+                format!("... and for {:?}, which means:", criteria)
+            };
+            writeln!(
+                out,
+                "{}\n\n  {}",
+                out.style().bold().apply_to(prompt),
+                eula.replace('\n', "\n  "),
+            )?;
+        }
+
+        writeln!(
+            out,
+            "{}",
+            out.style().bold().apply_to(
+                "Please read the above criteria and consider them when performing the audit."
+            )
+        )?;
+    }
+
+    writeln!(
+        out,
+        "{}",
+        out.style().bold().apply_to(
+            "Other software projects may rely on this audit. Ask for help if you're not sure."
+        )
+    )?;
+    out.read_line_with_prompt_async("(press ENTER to continue...)")
+        .await?;
+    Ok(())
 }
 
 fn cmd_record_violation(
@@ -1193,41 +1272,59 @@ pub fn minimize_unaudited(
     Ok(())
 }
 
-fn cmd_diff(
-    out: &mut dyn Out,
-    cfg: &PartialConfig,
-    sub_args: &DiffArgs,
-) -> Result<(), miette::Report> {
+fn cmd_diff(out: &mut dyn Out, cfg: &Config, sub_args: &DiffArgs) -> Result<(), miette::Report> {
+    let store = Store::acquire(cfg)?;
     let cache = Cache::acquire(cfg)?;
     let network = Network::acquire(cfg);
 
-    cache.set_last_fetch(FetchCommand::Diff {
-        package: sub_args.package.clone(),
-        version1: sub_args.version1.clone(),
-        version2: sub_args.version2.clone(),
-    });
-
+    let version1 = &sub_args.version1;
+    let version2 = &sub_args.version2;
     let package = &*sub_args.package;
 
-    writeln!(
-        out,
-        "fetching {} {} and {} ...",
-        sub_args.package, sub_args.version1, sub_args.version2,
-    )
-    .into_diagnostic()?;
+    // Record this command for magic in `vet certify`
+    cache.set_last_fetch(FetchCommand::Diff {
+        package: package.to_owned(),
+        version1: version1.clone(),
+        version2: version2.clone(),
+    });
 
-    let (fetched1, fetched2) = tokio::runtime::Handle::current()
-        .block_on(async {
-            tokio::try_join!(
-                cache.fetch_package(network.as_ref(), package, &sub_args.version1),
-                cache.fetch_package(network.as_ref(), package, &sub_args.version2)
+    let (fetched1, fetched2) = tokio::runtime::Handle::current().block_on(async {
+        // NOTE: don't `try_join` everything as we don't want to abort the
+        // prompt to the user if the download fails while it is being shown, as
+        // that could be disorienting.
+        let (pkgs, eulas) = tokio::join!(
+            async {
+                tokio::try_join!(
+                    cache.fetch_package(network.as_ref(), package, version1),
+                    cache.fetch_package(network.as_ref(), package, version2)
+                )
+            },
+            prompt_criteria_eulas(
+                out,
+                cfg,
+                network.as_ref(),
+                &store,
+                package,
+                Some(version1),
+                version2,
             )
-        })
-        .into_diagnostic()?;
+        );
+        eulas.into_diagnostic()?;
+        pkgs.into_diagnostic()
+    })?;
 
     writeln!(out).into_diagnostic()?;
 
-    diff_crate(out, cfg, &fetched1, &fetched2)?;
+    // FIXME: mask out .cargo_vcs_info.json
+
+    std::process::Command::new("git")
+        .arg("diff")
+        .arg("--no-index")
+        .arg(&fetched1)
+        .arg(&fetched2)
+        .status()
+        .map_err(CommandError::CommandFailed)
+        .into_diagnostic()?;
 
     Ok(())
 }
@@ -1491,37 +1588,6 @@ fn cmd_gc(out: &mut dyn Out, cfg: &PartialConfig, sub_args: &GcArgs) -> Result<(
 
 // Utils
 
-fn diff_crate(
-    _out: &mut dyn Out,
-    _cfg: &PartialConfig,
-    version1: &Path,
-    version2: &Path,
-) -> Result<(), DiffError> {
-    // ERRORS: arguably this is all proper fallible, but it would be fatal to
-    // `cargo vet diff`, the primary consumer, to not be able to diff
-
-    // FIXME: mask out .cargo_vcs_info.json
-    // FIXME: look into libgit2 vs just calling git
-
-    let status = std::process::Command::new("git")
-        .arg("diff")
-        .arg("--no-index")
-        .arg(version1)
-        .arg(version2)
-        .status()
-        .map_err(CommandError::CommandFailed)?;
-
-    let status = status.code().unwrap();
-
-    // 0 = empty
-    // 1 = some diff
-    if status != 0 && status != 1 {
-        Err(CommandError::BadStatus(status))?;
-    }
-
-    Ok(())
-}
-
 struct UserInfo {
     username: String,
     email: String,
diff --git a/src/out.rs b/src/out.rs
index 1f57091..a8bf502 100644
--- a/src/out.rs
+++ b/src/out.rs
@@ -6,7 +6,7 @@
 
 use crate::editor::Editor;
 use console::{Style, Term};
-use std::{fs::File, io};
+use std::{fs::File, future::Future, io, pin::Pin};
 
 /// Object-safe extension of `std::io::Write` with extra features for
 /// interacting with the terminal. Can be mocked in tests to allow them to test
@@ -24,11 +24,22 @@ pub trait Out: io::Write {
     }
 
     /// Ask the user a question, and read in a line with the user's response. If
-    /// there's no user able to respond, `None` will be returned instead.
+    /// there's no user able to respond, an error will be returned instead.
     fn read_line_with_prompt(&mut self, _prompt: &str) -> io::Result<String> {
         Err(io::ErrorKind::Unsupported.into())
     }
 
+    /// Like `read_line_with_prompt`, except will be run asynchronously when
+    /// possible in order to avoid blocking other futures running on the current
+    /// thread. This can be used to allow running background tasks, such as
+    /// fetching resources, while waiting for the user to respond.
+    fn read_line_with_prompt_async<'a>(
+        &'a mut self,
+        initial: &'a str,
+    ) -> Pin<Box<dyn Future<Output = io::Result<String>> + 'a>> {
+        Box::pin(async { self.read_line_with_prompt(initial) })
+    }
+
     /// Get a `Style` object which can be used to style text written to this
     /// user. Defaults to a disabled style object.
     fn style(&self) -> Style {
@@ -56,7 +67,20 @@ impl Out for Term {
     fn read_line_with_prompt(&mut self, prompt: &str) -> io::Result<String> {
         self.write_str(prompt)?;
         self.flush()?;
-        (&*self).read_line()
+        self.read_line()
+    }
+
+    fn read_line_with_prompt_async<'a>(
+        &'a mut self,
+        prompt: &'a str,
+    ) -> Pin<Box<dyn Future<Output = io::Result<String>> + 'a>> {
+        let mut this = self.clone();
+        let prompt = prompt.to_owned();
+        Box::pin(async {
+            tokio::task::spawn_blocking(move || this.read_line_with_prompt(&prompt))
+                .await
+                .map_err(|_| io::Error::new(io::ErrorKind::Other, "background task failed"))?
+        })
     }
 
     fn style(&self) -> Style {
diff --git a/src/resolver.rs b/src/resolver.rs
index 6119476..a4b735a 100644
--- a/src/resolver.rs
+++ b/src/resolver.rs
@@ -2543,7 +2543,8 @@ impl<'a> ResolveReport<'a> {
     pub fn compute_suggested_criteria(
         &self,
         package_name: PackageStr<'_>,
-        delta: &Delta,
+        from: Option<&Version>,
+        to: &Version,
     ) -> Vec<CriteriaName> {
         let fail = if let Conclusion::FailForVet(fail) = &self.conclusion {
             fail
@@ -2569,8 +2570,8 @@ impl<'a> ResolveReport<'a> {
                     reachable_from_target,
                 } = search_result
                 {
-                    if reachable_from_target.contains(&delta.to)
-                        && reachable_from_root.contains(&delta.from)
+                    if reachable_from_target.contains(to)
+                        && from.map_or(true, |v| reachable_from_root.contains(v))
                     {
                         criteria.set_criteria(criteria_idx);
                     }


Test patch:
diff --git a/src/tests/certify.rs b/src/tests/certify.rs
index 92d25f9..d0d800a 100644
--- a/src/tests/certify.rs
+++ b/src/tests/certify.rs
@@ -42,41 +42,46 @@ fn mock_simple_suggested_criteria() {
 
     let mut output = String::new();
     for (from, to, descr) in [
-        (0, DEFAULT_VER, "full audit"),
+        (None, DEFAULT_VER, "full audit"),
         // from existing audit
-        (2, DEFAULT_VER, "from weak-reviewed"),
-        (3, DEFAULT_VER, "from reviewed"),
-        (4, DEFAULT_VER, "from strong-reviewed"),
+        (Some(2), DEFAULT_VER, "from weak-reviewed"),
+        (Some(3), DEFAULT_VER, "from reviewed"),
+        (Some(4), DEFAULT_VER, "from strong-reviewed"),
         // to existing audit
-        (0, 6, "to strong-reviewed"),
-        (0, 7, "to reviewed"),
-        (0, 8, "to weak-reviewed"),
+        (None, 6, "to strong-reviewed"),
+        (None, 7, "to reviewed"),
+        (None, 8, "to weak-reviewed"),
         // bridge existing audits
-        (2, 6, "from weak-reviewed to strong-reviewed"),
-        (2, 7, "from weak-reviewed to reviewed"),
-        (2, 8, "from weak-reviewed to weak-reviewed"),
-        (3, 6, "from reviewed to strong-reviewed"),
-        (3, 7, "from reviewed to reviewed"),
-        (3, 8, "from reviewed to weak-reviewed"),
-        (4, 6, "from strong-reviewed to strong-reviewed"),
-        (4, 7, "from strong-reviewed to reviewed"),
-        (4, 8, "from strong-reviewed to weak-reviewed"),
+        (Some(2), 6, "from weak-reviewed to strong-reviewed"),
+        (Some(2), 7, "from weak-reviewed to reviewed"),
+        (Some(2), 8, "from weak-reviewed to weak-reviewed"),
+        (Some(3), 6, "from reviewed to strong-reviewed"),
+        (Some(3), 7, "from reviewed to reviewed"),
+        (Some(3), 8, "from reviewed to weak-reviewed"),
+        (Some(4), 6, "from strong-reviewed to strong-reviewed"),
+        (Some(4), 7, "from strong-reviewed to reviewed"),
+        (Some(4), 8, "from strong-reviewed to weak-reviewed"),
     ] {
-        let delta = Delta {
-            from: ver(from),
-            to: ver(to),
-        };
-        writeln!(output, "{} ({} -> {})", descr, delta.from, delta.to).unwrap();
+        let from = from.map(ver);
+        let to = ver(to);
+        writeln!(
+            output,
+            "{} ({} -> {})",
+            descr,
+            from.as_ref().map_or("root".to_owned(), |v| v.to_string()),
+            to
+        )
+        .unwrap();
         writeln!(
             output,
             "  third-party1: {:?}",
-            report.compute_suggested_criteria("third-party1", &delta)
+            report.compute_suggested_criteria("third-party1", from.as_ref(), &to)
         )
         .unwrap();
         writeln!(
             output,
             "  third-party2: {:?}",
-            report.compute_suggested_criteria("third-party2", &delta)
+            report.compute_suggested_criteria("third-party2", from.as_ref(), &to)
         )
         .unwrap();
     }
diff --git a/src/tests/snapshots/cargo_vet__tests__certify__mock-simple-suggested-criteria.snap b/src/tests/snapshots/cargo_vet__tests__certify__mock-simple-suggested-criteria.snap
index adc161d..b4f5a9f 100644
--- a/src/tests/snapshots/cargo_vet__tests__certify__mock-simple-suggested-criteria.snap
+++ b/src/tests/snapshots/cargo_vet__tests__certify__mock-simple-suggested-criteria.snap
@@ -2,7 +2,7 @@
 source: src/tests/certify.rs
 expression: output
 ---
-full audit (0.0.0 -> 10.0.0)
+full audit (root -> 10.0.0)
   third-party1: ["strong-reviewed"]
   third-party2: ["reviewed"]
 from weak-reviewed (2.0.0 -> 10.0.0)
@@ -14,13 +14,13 @@ from reviewed (3.0.0 -> 10.0.0)
 from strong-reviewed (4.0.0 -> 10.0.0)
   third-party1: ["strong-reviewed"]
   third-party2: ["reviewed"]
-to strong-reviewed (0.0.0 -> 6.0.0)
+to strong-reviewed (root -> 6.0.0)
   third-party1: ["strong-reviewed"]
   third-party2: ["reviewed"]
-to reviewed (0.0.0 -> 7.0.0)
+to reviewed (root -> 7.0.0)
   third-party1: ["reviewed"]
   third-party2: ["reviewed"]
-to weak-reviewed (0.0.0 -> 8.0.0)
+to weak-reviewed (root -> 8.0.0)
   third-party1: ["weak-reviewed"]
   third-party2: ["weak-reviewed"]
 from weak-reviewed to strong-reviewed (2.0.0 -> 6.0.0)
diff --git a/tests/diff-cache.toml b/tests/diff-cache.toml
index ca126b3..6d46886 100644
--- a/tests/diff-cache.toml
+++ b/tests/diff-cache.toml
@@ -1397,6 +1397,12 @@ raw = """
 """
 count = 57175
 
+[syn."1.0.0 -> 1.0.91"]
+raw = """
+ 93 files changed, 21776 insertions(+), 6701 deletions(-)
+"""
+count = 28477
+
 [synstructure."0.0.0 -> 0.12.6"]
 raw = """
  8 files changed, 2991 insertions(+)
diff --git a/tests/snapshots/test_cli__test-project-suggest-json.snap b/tests/snapshots/test_cli__test-project-suggest-json.snap
index fbd3019..98a552c 100644
--- a/tests/snapshots/test_cli__test-project-suggest-json.snap
+++ b/tests/snapshots/test_cli__test-project-suggest-json.snap
@@ -2046,6 +2046,21 @@ stdout:
             "to": "0.3.13"
           }
         },
+        {
+          "name": "syn",
+          "notable_parents": "tracing-attributes, and 2 others",
+          "suggested_criteria": [
+            "safe-to-deploy"
+          ],
+          "suggested_diff": {
+            "diffstat": {
+              "count": 28477,
+              "raw": " 93 files changed, 21776 insertions(+), 6701 deletions(-)\n"
+            },
+            "from": "1.0.0",
+            "to": "1.0.91"
+          }
+        },
         {
           "name": "unicode-normalization",
           "notable_parents": "idna",
@@ -2106,21 +2121,6 @@ stdout:
             "to": "0.2.15"
           }
         },
-        {
-          "name": "syn",
-          "notable_parents": "tracing-attributes, and 2 others",
-          "suggested_criteria": [
-            "safe-to-deploy"
-          ],
-          "suggested_diff": {
-            "diffstat": {
-              "count": 57175,
-              "raw": " 98 files changed, 57175 insertions(+)\n"
-            },
-            "from": "0.0.0",
-            "to": "1.0.91"
-          }
-        },
         {
           "name": "web-sys",
           "notable_parents": "reqwest, wasm-bindgen-futures",
@@ -3536,6 +3536,21 @@ stdout:
           "to": "0.3.13"
         }
       },
+      {
+        "name": "syn",
+        "notable_parents": "tracing-attributes, and 2 others",
+        "suggested_criteria": [
+          "safe-to-deploy"
+        ],
+        "suggested_diff": {
+          "diffstat": {
+            "count": 28477,
+            "raw": " 93 files changed, 21776 insertions(+), 6701 deletions(-)\n"
+          },
+          "from": "1.0.0",
+          "to": "1.0.91"
+        }
+      },
       {
         "name": "unicode-normalization",
         "notable_parents": "idna",
@@ -3596,21 +3611,6 @@ stdout:
           "to": "0.2.15"
         }
       },
-      {
-        "name": "syn",
-        "notable_parents": "tracing-attributes, and 2 others",
-        "suggested_criteria": [
-          "safe-to-deploy"
-        ],
-        "suggested_diff": {
-          "diffstat": {
-            "count": 57175,
-            "raw": " 98 files changed, 57175 insertions(+)\n"
-          },
-          "from": "0.0.0",
-          "to": "1.0.91"
-        }
-      },
       {
         "name": "web-sys",
         "notable_parents": "reqwest, wasm-bindgen-futures",
@@ -3642,7 +3642,7 @@ stdout:
         }
       }
     ],
-    "total_lines": 1771316
+    "total_lines": 1742618
   }
 }
 stderr:
diff --git a/tests/snapshots/test_cli__test-project-suggest.snap b/tests/snapshots/test_cli__test-project-suggest.snap
index 226aa4a..eb655c4 100644
--- a/tests/snapshots/test_cli__test-project-suggest.snap
+++ b/tests/snapshots/test_cli__test-project-suggest.snap
@@ -94,18 +94,18 @@ recommended audits for safe-to-deploy:
     cargo vet inspect futures-util 0.3.21                 (used by h2, hyper, reqwest)                                (25074 lines)
     cargo vet inspect hyper 0.14.18                       (used by reqwest, hyper-tls)                                (25617 lines)
     cargo vet inspect h2 0.3.13                           (used by hyper, reqwest)                                    (26066 lines)
+    cargo vet diff syn 1.0.0 1.0.91                       (used by tracing-attributes, and 2 others)                  (93 files changed, 21776 insertions(+), 6701 deletions(-))
     cargo vet inspect unicode-normalization 0.1.19        (used by idna)                                              (28628 lines)
     cargo vet inspect openssl 0.10.38                     (used by native-tls)                                        (28634 lines)
     cargo vet inspect idna 0.2.3                          (used by url)                                               (32538 lines)
     cargo vet inspect vcpkg 0.2.15                        (used by openssl-sys)                                       (42648 lines)
-    cargo vet inspect syn 1.0.91                          (used by tracing-attributes, and 2 others)                  (57175 lines)
     cargo vet inspect web-sys 0.3.57                      (used by reqwest, wasm-bindgen-futures)                     (197014 lines)
     cargo vet inspect encoding_rs 0.8.31                  (used by reqwest)                                           (507252 lines)
 
 recommended audits for safe-to-run:
     cargo vet inspect hermit-abi 0.1.19  (used by atty)  (938 lines)
 
-estimated audit backlog: 1771316 lines
+estimated audit backlog: 1742618 lines
 
 Use |cargo vet certify| to record the audits.
 
diff --git a/tests/test-project/supply-chain/audits.toml b/tests/test-project/supply-chain/audits.toml
index 2c5ea9c..3e46c75 100644
--- a/tests/test-project/supply-chain/audits.toml
+++ b/tests/test-project/supply-chain/audits.toml
@@ -70,6 +70,11 @@ version = "3.1.8"
 dependency-criteria = { atty = "safe-to-run", bitflags = ["audited", "fuzzed"] }
 notes = "test for custom criteria (low-grade and high-grade)"
 
+[[audits.syn]]
+criteria = "safe-to-deploy"
+version = "1.0.0"
+notes = "test for partial delta criteria"
+
 [[audits.unicode-bidi]]
 criteria = "safe-to-deploy"
 delta = "0.2.0 -> 0.3.7"