Repository: mozilla/cargo-vet (rust)

Issue:
Certify after a version bump should remove a pre-existing exemption
Ran into this while updating the version of a crate that had never been audited before. I performed the version bump, certified the new version, and then had to manually remove the exemption from config.toml. I was told by @bholley that `cargo vet` should attempt to remove an exemption in this case and is likely being confused by the version bump.

STR:
```bash
mkdir test
cd test

cargo init --library
# add wast 43.0.0
vim Cargo.toml
cargo vet init
# note exemptions.wast exists in config.toml

vim Cargo.toml # bump wast to 44.0.0
cargo update
cargo vet # fails!

cargo vet certify wast 44.0.0
cargo vet # succeeds!

# note exemptions.wast 43.0.0 still exists in config.toml
```

Pull request:
Automatically minimize exemptions when running certain commands
This patch changes many commands to automatically minimize exemptions
when run under normal operation. This behaviour can be disabled using a
new command line flag: --no-minimize-exemptions.

This exemption minimizing algorithm is based on the regenerate
algorithm, but with extra guards in place to make sure that no new
exemptions can be added, and exemptions cannot have their scope
expanded.

In addition, extra flags were added to regenerate_exemptions to control
this behaviour as well as importing behaviour.

Fixes #262

Patch:
diff --git a/src/cli.rs b/src/cli.rs
index a581255..f19d89d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -64,6 +64,12 @@ pub struct Cli {
     #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
     pub frozen: bool,
 
+    /// Prevent commands such as `check` and `certify` from automatically
+    /// cleaning up unused exemptions.
+    #[clap(long, action)]
+    #[clap(help_heading = "GLOBAL OPTIONS", global = true)]
+    pub no_minimize_exemptions: bool,
+
     /// How verbose logging should be (log level)
     #[clap(long, action)]
     #[clap(default_value_t = LevelFilter::WARN)]
diff --git a/src/main.rs b/src/main.rs
index 534f990..b9511f2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,7 +23,7 @@ use thiserror::Error;
 use tracing::{error, info, trace, warn};
 
 use crate::cli::*;
-use crate::errors::{CommandError, DownloadError};
+use crate::errors::{CommandError, DownloadError, RegenerateExemptionsError};
 use crate::format::{
     AuditEntry, AuditKind, AuditsFile, ConfigFile, CriteriaEntry, Delta, DependencyCriteria,
     ExemptedDependency, FetchCommand, ImportsFile, MetaConfig, MetaConfigInstance, PackageStr,
@@ -612,9 +612,13 @@ fn cmd_certify(
 
     do_cmd_certify(out, cfg, sub_args, &mut store, network.as_ref(), last_fetch)?;
 
-    // Re-run the resolver after adding the new `certify`. This will be used to
-    // potentially update imports.
-    store.maybe_update_imports_file(cfg, false);
+    // Minimize exemptions after adding the new `certify`. This will be used to
+    // potentially update imports, and remove now-unnecessary exemptions.
+    // Explicitly disallow new exemptions so that exemptions are only updated
+    // once we start passing vet.
+    match resolver::regenerate_exemptions(cfg, &mut store, false, false) {
+        Ok(()) | Err(RegenerateExemptionsError::ViolationConflict) => {}
+    }
 
     store.commit()?;
     Ok(())
@@ -1277,7 +1281,13 @@ fn cmd_regenerate_imports(
 
     let network = Network::acquire(cfg);
     let mut store = Store::acquire(cfg, network.as_ref(), true)?;
-    store.maybe_update_imports_file(cfg, true);
+
+    // NOTE: Explicitly ignore the `ViolationConflict` error, as we still want
+    // to update imports in that case.
+    match resolver::regenerate_exemptions(cfg, &mut store, false, true) {
+        Ok(()) | Err(RegenerateExemptionsError::ViolationConflict) => {}
+    }
+
     store.commit()?;
     Ok(())
 }
@@ -1346,7 +1356,7 @@ fn cmd_regenerate_exemptions(
     let network = Network::acquire(cfg);
     let mut store = Store::acquire(cfg, network.as_ref(), false)?;
 
-    resolver::regenerate_exemptions(cfg, &mut store)?;
+    resolver::regenerate_exemptions(cfg, &mut store, true, false)?;
 
     // We were successful, commit the store
     store.commit()?;
@@ -1486,7 +1496,15 @@ fn cmd_check(out: &Arc<dyn Out>, cfg: &Config, sub_args: &CheckArgs) -> Result<(
         // Err(eyre!("report contains errors"))?;
         panic_any(ExitPanic(-1));
     } else {
-        store.imports = store.get_updated_imports_file(&report.graph, &report.conclusion, false);
+        if !cfg.cli.locked {
+            #[allow(clippy::single_match)]
+            match resolver::regenerate_exemptions(cfg, &mut store, false, false) {
+                Err(RegenerateExemptionsError::ViolationConflict) => {
+                    unreachable!("unexpeced violation conflict regenerating exemptions?")
+                }
+                Ok(()) => {}
+            }
+        }
         store.commit()?;
     }
 
@@ -1515,7 +1533,12 @@ fn cmd_fetch_imports(
     let network = Network::acquire(cfg);
     let mut store = Store::acquire(cfg, network.as_ref(), false)?;
 
-    store.maybe_update_imports_file(cfg, true);
+    // NOTE: Explicitly ignore the `ViolationConflict` error, as we still want
+    // to update imports in that case.
+    match resolver::regenerate_exemptions(cfg, &mut store, false, true) {
+        Ok(()) | Err(RegenerateExemptionsError::ViolationConflict) => {}
+    }
+
     store.commit()?;
 
     Ok(())
diff --git a/src/resolver.rs b/src/resolver.rs
index 5e13d6c..335fc70 100644
--- a/src/resolver.rs
+++ b/src/resolver.rs
@@ -3150,6 +3150,8 @@ impl FailForViolationConflict {
 pub fn regenerate_exemptions(
     cfg: &Config,
     store: &mut Store,
+    allow_new_exemptions: bool,
+    force_update_imports: bool,
 ) -> Result<(), RegenerateExemptionsError> {
     // While minimizing exemptions, a number of different calls to the resolver
     // will be made using the same DepGraph and CriteriaMapper, which will be
@@ -3165,6 +3167,19 @@ pub fn regenerate_exemptions(
         &store.config.imports,
     );
 
+    // If we're not allowing new exemptions, we must already be passing `vet`.
+    // If we aren't, we can't do anything, so just update imports and return
+    // immediately. We'll also do this if automatic exemption minimization is
+    // disabled through the CLI.
+    if !allow_new_exemptions {
+        let (_, conclusion) = resolve_core(&graph, store, &criteria_mapper, ResolveDepth::Shallow);
+        if !matches!(conclusion, Conclusion::Success(_)) || cfg.cli.no_minimize_exemptions {
+            store.imports =
+                store.get_updated_imports_file(&graph, &conclusion, force_update_imports);
+            return Ok(());
+        }
+    }
+
     // Clear out the existing exemptions, starting with a clean slate. We'll
     // re-add exemptions as-needed.
     let old_exemptions = mem::take(&mut store.config.exemptions);
@@ -3220,17 +3235,33 @@ pub fn regenerate_exemptions(
         let fail = match &conclusion {
             Conclusion::FailForVet(fail) => fail,
             Conclusion::FailForViolationConflict(..) => {
+                // Also force an update even if we're seeing a violation
+                // conflict (to save the potentially-newly-imported violation),
+                // although our caller is unlikely to commit these changes.
+                store.imports =
+                    store.get_updated_imports_file(&graph, &conclusion, force_update_imports);
                 return Err(RegenerateExemptionsError::ViolationConflict);
             }
             Conclusion::Success(..) => {
                 // We succeeded! Whatever exemptions we've recorded so-far are
                 // suficient, so we're done. Record any imports which ended up
                 // being required for the vet to pass.
-                store.imports = store.get_updated_imports_file(&graph, &conclusion, false);
+                store.imports =
+                    store.get_updated_imports_file(&graph, &conclusion, force_update_imports);
                 return Ok(());
             }
         };
 
+        let mut made_progress = false;
+        let mut update_useful_criteria =
+            |potential: &mut PotentialExemption<'_>, new_criteria: &CriteriaSet| {
+                if potential.useful_criteria.contains(new_criteria) {
+                    return;
+                }
+                made_progress = true;
+                potential.useful_criteria.unioned_with(new_criteria);
+            };
+
         for (&failure_idx, failure) in &fail.failures {
             let package = &graph.nodes[failure_idx];
             let result = &results[failure_idx];
@@ -3261,7 +3292,9 @@ pub fn regenerate_exemptions(
                             // `suggest = false` or `dependency_criteria`) to
                             // expand the allowed criteria, so record the
                             // existing criteria as our maximum.
-                            if potential.is_special() {
+                            // We also don't allow expanding criteria if we
+                            // aren't allowing new exemptions.
+                            if potential.is_special() || !allow_new_exemptions {
                                 potential.max_criteria = criteria_mapper
                                     .criteria_from_namespaced_list(
                                         &CriteriaNamespace::Local,
@@ -3271,39 +3304,43 @@ pub fn regenerate_exemptions(
                             potential
                         })
                         .collect();
-                    // Next, for criteria with `suggest = false` (but without
-                    // `dependency_criteria`), we'll consider adding a
-                    // suggestable expanded exemption at the same version
-                    // without criteria limitations, to try to pin exemptions at
-                    // specific past versions.
-                    for exemption in existing_exemptions {
-                        if !exemption.suggest && exemption.dependency_criteria.is_empty() {
-                            potentials.push(PotentialExemption {
-                                version: &exemption.version,
-                                max_criteria: criteria_mapper.all_criteria(),
-                                useful_criteria: criteria_mapper.no_criteria(),
-                                suggest: true,
-                                dependency_criteria: &no_dependency_criteria,
-                                notes: &exemption.notes,
-                            })
+                    // The existing exemptions are the only potential exemptions
+                    // unless we're allowing adding new exemptions.
+                    if allow_new_exemptions {
+                        // Next, for criteria with `suggest = false` (but without
+                        // `dependency_criteria`), we'll consider adding a
+                        // suggestable expanded exemption at the same version
+                        // without criteria limitations, to try to pin exemptions at
+                        // specific past versions.
+                        for exemption in existing_exemptions {
+                            if !exemption.suggest && exemption.dependency_criteria.is_empty() {
+                                potentials.push(PotentialExemption {
+                                    version: &exemption.version,
+                                    max_criteria: criteria_mapper.all_criteria(),
+                                    useful_criteria: criteria_mapper.no_criteria(),
+                                    suggest: true,
+                                    dependency_criteria: &no_dependency_criteria,
+                                    notes: &exemption.notes,
+                                })
+                            }
                         }
+                        // Then, if none of those apply, we'll consider adding a new
+                        // exemption for each version in the DepGraph.
+                        potentials.extend(
+                            pkg_versions_by_name
+                                .get(package.name)
+                                .expect("no versions of failed package?")
+                                .iter()
+                                .map(|version| PotentialExemption {
+                                    version,
+                                    max_criteria: criteria_mapper.all_criteria(),
+                                    useful_criteria: criteria_mapper.no_criteria(),
+                                    suggest: true,
+                                    dependency_criteria: &no_dependency_criteria,
+                                    notes: &None,
+                                }),
+                        );
                     }
-                    // Then, if none of those apply, we'll consider adding a new
-                    // exemption for each version in the DepGraph.
-                    potentials.extend(
-                        pkg_versions_by_name
-                            .get(package.name)
-                            .expect("no versions of failed package?")
-                            .iter()
-                            .map(|version| PotentialExemption {
-                                version,
-                                max_criteria: criteria_mapper.all_criteria(),
-                                useful_criteria: criteria_mapper.no_criteria(),
-                                suggest: true,
-                                dependency_criteria: &no_dependency_criteria,
-                                notes: &None,
-                            }),
-                    );
                     potentials
                 });
 
@@ -3342,9 +3379,10 @@ pub fn regenerate_exemptions(
                             // something, but continue to see if we find any
                             // other "special" exemptions.
                             found = true;
-                            potential
-                                .useful_criteria
-                                .unioned_with(&criteria_mapper.implied_criteria[implied_idx]);
+                            update_useful_criteria(
+                                potential,
+                                &criteria_mapper.implied_criteria[implied_idx],
+                            );
                         }
                     }
                     if !found {
@@ -3372,46 +3410,67 @@ pub fn regenerate_exemptions(
                     if potential.max_criteria.has_criteria(criteria_idx)
                         && reachable_from_target.contains(potential.version)
                     {
-                        potential
-                            .useful_criteria
-                            .unioned_with(&criteria_mapper.implied_criteria[criteria_idx]);
+                        update_useful_criteria(
+                            potential,
+                            &criteria_mapper.implied_criteria[criteria_idx],
+                        );
                         continue 'min_criteria;
                     }
                 }
 
-                // We should always find an exemption which satisfies the
-                // criteria due to us adding a potential exemption for each
-                // failed version which can satisfy any criteria.
-                unreachable!("couldn't find an exemption which satisfies the criteria?");
+                if allow_new_exemptions {
+                    // We should always find an exemption which satisfies the
+                    // criteria due to us adding a potential exemption for each
+                    // failed version which can satisfy any criteria.
+                    unreachable!("couldn't find an exemption which satisfies the criteria?");
+                }
+
+                // If we're not allowing new exemptions, we at least know that
+                // the vet _can_ pass with the existing set of exemptions.
+                // Conservatively enable every criteria for every existing
+                // exemption for this crate.
+                for potential in &mut potential_exemptions[..] {
+                    let new_criteria = potential.max_criteria.clone();
+                    update_useful_criteria(potential, &new_criteria);
+                }
+                break;
             }
         }
 
-        // Update `store.config.exemptions` to reflect changed exemptions and
-        // loop back around.
-        store.config.exemptions = potential_exemptions
-            .iter()
-            .filter_map(|(&package_name, potential)| {
-                let mut exemptions: Vec<_> = potential
-                    .iter()
-                    .filter(|potential| !potential.useful_criteria.is_empty())
-                    .map(|potential| ExemptedDependency {
-                        version: potential.version.clone(),
-                        criteria: criteria_mapper
-                            .criteria_names(&potential.useful_criteria)
-                            .map(|criteria| criteria.to_owned().into())
-                            .collect(),
-                        suggest: potential.suggest,
-                        dependency_criteria: potential.dependency_criteria.clone(),
-                        notes: potential.notes.clone(),
-                    })
-                    .collect();
-                exemptions.sort();
-                if exemptions.is_empty() {
-                    None
-                } else {
-                    Some((package_name.to_owned(), exemptions))
-                }
-            })
-            .collect();
+        if made_progress {
+            // Update `store.config.exemptions` to reflect changed exemptions and
+            // loop back around.
+            store.config.exemptions = potential_exemptions
+                .iter()
+                .filter_map(|(&package_name, potential)| {
+                    let mut exemptions: Vec<_> = potential
+                        .iter()
+                        .filter(|potential| !potential.useful_criteria.is_empty())
+                        .map(|potential| ExemptedDependency {
+                            version: potential.version.clone(),
+                            criteria: criteria_mapper
+                                .criteria_names(&potential.useful_criteria)
+                                .map(|criteria| criteria.to_owned().into())
+                                .collect(),
+                            suggest: potential.suggest,
+                            dependency_criteria: potential.dependency_criteria.clone(),
+                            notes: potential.notes.clone(),
+                        })
+                        .collect();
+                    exemptions.sort();
+                    if exemptions.is_empty() {
+                        None
+                    } else {
+                        Some((package_name.to_owned(), exemptions))
+                    }
+                })
+                .collect();
+        } else {
+            assert!(
+                !allow_new_exemptions,
+                "failed to make progress while allowing new exemptions?"
+            );
+            store.config.exemptions = old_exemptions.clone();
+        }
     }
 }
diff --git a/src/storage.rs b/src/storage.rs
index fc50f19..d4f8a66 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -637,19 +637,6 @@ impl Store {
 
         new_imports
     }
-
-    pub fn maybe_update_imports_file(&mut self, cfg: &Config, force_update: bool) {
-        let report = resolver::resolve(
-            &cfg.metadata,
-            cfg.cli.filter_graph.as_ref(),
-            self,
-            // Resolve depth only impacts error results, so we can use Shallow
-            // to do less work.
-            resolver::ResolveDepth::Shallow,
-        );
-        self.imports =
-            self.get_updated_imports_file(&report.graph, &report.conclusion, force_update);
-    }
 }
 
 /// Process imported audits from the network, generating a `LiveImports`


Test patch:
diff --git a/src/tests/import.rs b/src/tests/import.rs
index 1f71f68..95722ba 100644
--- a/src/tests/import.rs
+++ b/src/tests/import.rs
@@ -725,7 +725,7 @@ fn peer_audits_exemption_minimize() {
     // Capture the old imports before minimizing exemptions
     let old = store.mock_commit();
 
-    crate::resolver::regenerate_exemptions(&mock_cfg(&metadata), &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&mock_cfg(&metadata), &mut store, true, false).unwrap();
 
     // Capture after minimizing exemptions, and generate a diff.
     let new = store.mock_commit();
diff --git a/src/tests/regenerate_unaudited.rs b/src/tests/regenerate_unaudited.rs
index 6fe422a..7191a41 100644
--- a/src/tests/regenerate_unaudited.rs
+++ b/src/tests/regenerate_unaudited.rs
@@ -30,7 +30,7 @@ fn builtin_simple_exemptions_not_a_real_dep_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-not-a-real-dep-regenerate", exemptions);
@@ -56,7 +56,7 @@ fn builtin_simple_deps_exemptions_overbroad_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-unaudited-overbroad-regenerate", exemptions);
@@ -85,7 +85,7 @@ fn builtin_complex_exemptions_twins_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-unaudited-twins-regenerate", exemptions);
@@ -114,7 +114,7 @@ fn builtin_complex_exemptions_partial_twins_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -150,7 +150,7 @@ fn builtin_simple_exemptions_in_delta_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-unaudited-in-delta-regenerate", exemptions);
@@ -183,7 +183,7 @@ fn builtin_simple_exemptions_in_full_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-unaudited-in-full-regenerate", exemptions);
@@ -214,7 +214,7 @@ fn builtin_simple_deps_exemptions_adds_uneeded_criteria_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -250,7 +250,7 @@ fn builtin_dev_detection_exemptions_adds_uneeded_criteria_indirect_regenerate()
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -282,7 +282,7 @@ fn builtin_simple_exemptions_extra_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-unaudited-extra-regenerate", exemptions);
@@ -311,7 +311,7 @@ fn builtin_simple_exemptions_in_direct_full_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -371,7 +371,7 @@ fn builtin_simple_exemptions_nested_weaker_req_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -432,7 +432,7 @@ fn builtin_simple_exemptions_nested_stronger_req_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -461,7 +461,7 @@ fn builtin_simple_audit_as_default_root_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -494,7 +494,7 @@ fn builtin_simple_audit_as_weaker_root_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-audit-as-weaker-root-regenerate", exemptions);
@@ -522,7 +522,7 @@ fn builtin_simple_exemptions_larger_diff_regenerate() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!(
@@ -582,8 +582,169 @@ fn builtin_simple_exemptions_broaden_basic() {
 
     let mut store = Store::mock(config, audits, imports);
     let cfg = mock_cfg(&metadata);
-    crate::resolver::regenerate_exemptions(&cfg, &mut store).unwrap();
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
 
     let exemptions = get_exemptions(&store);
     insta::assert_snapshot!("builtin-simple-exemptions-broaden-basic", exemptions);
 }
+
+#[test]
+fn builtin_simple_exemptions_regenerate_merge() {
+    // (Pass) minimize_exemptions will merge exemptions if it's allowed to.
+
+    let _enter = TEST_RUNTIME.enter();
+    let mock = MockMetadata::simple();
+
+    let metadata = mock.metadata();
+    let (mut config, mut audits, imports) = builtin_files_full_audited(&metadata);
+
+    audits.criteria.insert(
+        "criteria1".to_owned(),
+        CriteriaEntry {
+            description: Some(String::new()),
+            description_url: None,
+            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
+        },
+    );
+    audits.criteria.insert(
+        "criteria2".to_owned(),
+        CriteriaEntry {
+            description: Some(String::new()),
+            description_url: None,
+            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
+        },
+    );
+
+    audits.audits.insert(
+        "third-party1".to_owned(),
+        vec![full_audit_dep(
+            ver(DEFAULT_VER),
+            SAFE_TO_DEPLOY,
+            [("transitive-third-party1", ["criteria1", "criteria2"])],
+        )],
+    );
+
+    audits.audits.insert(
+        "transitive-third-party1".to_owned(),
+        vec![
+            delta_audit(ver(5), ver(DEFAULT_VER), "criteria1"),
+            delta_audit(ver(5), ver(DEFAULT_VER), "criteria2"),
+        ],
+    );
+    config.exemptions.insert(
+        "transitive-third-party1".to_owned(),
+        vec![
+            exemptions(ver(5), "criteria1"),
+            exemptions(ver(5), "criteria2"),
+        ],
+    );
+
+    let mut store = Store::mock(config, audits, imports);
+    let cfg = mock_cfg(&metadata);
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, true, false).unwrap();
+
+    let exemptions = get_exemptions(&store);
+    insta::assert_snapshot!("builtin-simple-exemptions-regenerate-merge", exemptions);
+}
+
+#[test]
+fn builtin_simple_exemptions_regenerate_merge_nonew() {
+    // (Pass) minimize_exemptions will not merge exemptions if it's not allowed to.
+
+    let _enter = TEST_RUNTIME.enter();
+    let mock = MockMetadata::simple();
+
+    let metadata = mock.metadata();
+    let (mut config, mut audits, imports) = builtin_files_full_audited(&metadata);
+
+    audits.criteria.insert(
+        "criteria1".to_owned(),
+        CriteriaEntry {
+            description: Some(String::new()),
+            description_url: None,
+            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
+        },
+    );
+    audits.criteria.insert(
+        "criteria2".to_owned(),
+        CriteriaEntry {
+            description: Some(String::new()),
+            description_url: None,
+            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
+        },
+    );
+
+    audits.audits.insert(
+        "third-party1".to_owned(),
+        vec![full_audit_dep(
+            ver(DEFAULT_VER),
+            SAFE_TO_DEPLOY,
+            [("transitive-third-party1", ["criteria1", "criteria2"])],
+        )],
+    );
+
+    audits.audits.insert(
+        "transitive-third-party1".to_owned(),
+        vec![
+            delta_audit(ver(5), ver(DEFAULT_VER), "criteria1"),
+            delta_audit(ver(5), ver(DEFAULT_VER), "criteria2"),
+        ],
+    );
+    config.exemptions.insert(
+        "transitive-third-party1".to_owned(),
+        vec![
+            exemptions(ver(5), "criteria1"),
+            exemptions(ver(5), "criteria2"),
+        ],
+    );
+
+    let mut store = Store::mock(config, audits, imports);
+    let cfg = mock_cfg(&metadata);
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, false, false).unwrap();
+
+    let exemptions = get_exemptions(&store);
+    insta::assert_snapshot!(
+        "builtin-simple-exemptions-regenerate-merge-nonew",
+        exemptions
+    );
+}
+
+#[test]
+fn builtin_simple_exemptions_regenerate_nonew_failed() {
+    // (Pass) minimize_exemptions will be a no-op if no new exemptions are
+    // allowed, and vet is failing.
+
+    let _enter = TEST_RUNTIME.enter();
+    let mock = MockMetadata::simple();
+
+    let metadata = mock.metadata();
+    let (mut config, audits, imports) = builtin_files_inited(&metadata);
+
+    config.exemptions.clear();
+
+    config.exemptions.insert(
+        "transitive-third-party1".to_owned(),
+        vec![
+            exemptions(ver(300), SAFE_TO_DEPLOY),
+            exemptions(ver(400), SAFE_TO_DEPLOY),
+        ],
+    );
+    config.exemptions.insert(
+        "third-party1".to_owned(),
+        vec![exemptions(ver(300), SAFE_TO_DEPLOY)],
+    );
+    config.exemptions.insert(
+        "random-crate".to_owned(),
+        vec![exemptions(ver(300), SAFE_TO_DEPLOY)],
+    );
+
+    let mut store = Store::mock(config, audits, imports);
+    let cfg = mock_cfg(&metadata);
+    crate::resolver::regenerate_exemptions(&cfg, &mut store, false, false).unwrap();
+
+    let exemptions = get_exemptions(&store);
+    insta::assert_snapshot!(
+        "builtin-simple-exemptions-regenerate-nonew-failed",
+        exemptions
+    );
+}
diff --git a/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-merge-nonew.snap b/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-merge-nonew.snap
new file mode 100644
index 0000000..99db240
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-merge-nonew.snap
@@ -0,0 +1,12 @@
+---
+source: src/tests/regenerate_unaudited.rs
+expression: exemptions
+---
+[[transitive-third-party1]]
+version = "5.0.0"
+criteria = "criteria1"
+
+[[transitive-third-party1]]
+version = "5.0.0"
+criteria = "criteria2"
+
diff --git a/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-merge.snap b/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-merge.snap
new file mode 100644
index 0000000..cde56de
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-merge.snap
@@ -0,0 +1,11 @@
+---
+source: src/tests/regenerate_unaudited.rs
+expression: exemptions
+---
+[[transitive-third-party1]]
+version = "5.0.0"
+criteria = [
+    "criteria1",
+    "criteria2",
+]
+
diff --git a/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-nonew-failed.snap b/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-nonew-failed.snap
new file mode 100644
index 0000000..adf9cbc
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__regenerate_unaudited__builtin-simple-exemptions-regenerate-nonew-failed.snap
@@ -0,0 +1,20 @@
+---
+source: src/tests/regenerate_unaudited.rs
+expression: exemptions
+---
+[[random-crate]]
+version = "300.0.0"
+criteria = "safe-to-deploy"
+
+[[third-party1]]
+version = "300.0.0"
+criteria = "safe-to-deploy"
+
+[[transitive-third-party1]]
+version = "300.0.0"
+criteria = "safe-to-deploy"
+
+[[transitive-third-party1]]
+version = "400.0.0"
+criteria = "safe-to-deploy"
+
diff --git a/tests/snapshots/test_cli__long-help.snap b/tests/snapshots/test_cli__long-help.snap
index 1e38b2c..4914e2d 100644
--- a/tests/snapshots/test_cli__long-help.snap
+++ b/tests/snapshots/test_cli__long-help.snap
@@ -50,6 +50,10 @@ GLOBAL OPTIONS:
             Avoid the network entirely, requiring either that the cargo cache is populated or the
             dependencies are vendored. Requires --locked
 
+        --no-minimize-exemptions
+            Prevent commands such as `check` and `certify` from automatically cleaning up unused
+            exemptions
+
         --verbose <VERBOSE>
             How verbose logging should be (log level)
             
diff --git a/tests/snapshots/test_cli__markdown-help.snap b/tests/snapshots/test_cli__markdown-help.snap
index a26b563..cfc20ba 100644
--- a/tests/snapshots/test_cli__markdown-help.snap
+++ b/tests/snapshots/test_cli__markdown-help.snap
@@ -59,6 +59,10 @@ Do not fetch new imported audits
 Avoid the network entirely, requiring either that the cargo cache is populated or the
 dependencies are vendored. Requires --locked
 
+#### `--no-minimize-exemptions`
+Prevent commands such as `check` and `certify` from automatically cleaning up unused
+exemptions
+
 #### `--verbose <VERBOSE>`
 How verbose logging should be (log level)
 
diff --git a/tests/snapshots/test_cli__short-help.snap b/tests/snapshots/test_cli__short-help.snap
index 03463ea..a70bb8e 100644
--- a/tests/snapshots/test_cli__short-help.snap
+++ b/tests/snapshots/test_cli__short-help.snap
@@ -35,6 +35,10 @@ GLOBAL OPTIONS:
             Avoid the network entirely, requiring either that the cargo cache is populated or the
             dependencies are vendored. Requires --locked
 
+        --no-minimize-exemptions
+            Prevent commands such as `check` and `certify` from automatically cleaning up unused
+            exemptions
+
         --verbose <VERBOSE>
             How verbose logging should be (log level) [default: warn] [possible values: off, error,
             warn, info, debug, trace]
diff --git a/tests/test-project/supply-chain/config.toml b/tests/test-project/supply-chain/config.toml
index 19a3097..8a922c9 100644
--- a/tests/test-project/supply-chain/config.toml
+++ b/tests/test-project/supply-chain/config.toml
@@ -82,7 +82,7 @@ criteria = "safe-to-deploy"
 
 [[exemptions.hermit-abi]]
 version = "0.1.19"
-criteria = "safe-to-deploy"
+criteria = "safe-to-run"
 
 [[exemptions.http]]
 version = "0.2.6"