Repository: mozilla/cargo-vet (rust)

Issue:
Implement `cargo vet aggregate`
The current docs recommend handling multiple repositories with either star-topological imports or certifying audits in a dedicated repo. Neither of these is entirely ideal: the former is unwieldy to configure, and the latter is unwieldy to use.

A better solution is to have a single bot-managed repository aggregate audits from all the individual repositories into a single combined record. This enables two things:
1. Each of the individual N repositories can then import this single aggregated record rather than directly referencing all N-1 other repositories.
2. External consumers don't need to concern themselves with the repository details and can just do a single import for that organization.

I think the smoothest way to do this would be to implement a `cargo vet aggregate` subcommand which knows how to fetch and merge a list of audit file URLs. We could then include instructions in the doc for setting up a GitHub repo with a cron-driven Action to poll the relevant URLs every five minutes and commit any updates. 

Pull request description:
Implement cargo vet aggregate
This feature allows specifying multiple sources in a `sources.list` file which will be processed to generate a new source file, containing all of the audits from each listed source. Criteria are merged from these sources by-name, with the descriptions and implies sets requried to be identical between criteria with the same name from different sources.

The chain of what imports were used for a given criteria or audit is now tracked in the .toml file as well, using an aggregated-from field on the relevant entries.

Fixes #83

Patch:
diff --git a/src/cli.rs b/src/cli.rs
index 021b518..3896e62 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -322,6 +322,15 @@ pub enum Commands {
     #[clap(disable_version_flag = true)]
     FetchImports(FetchImportsArgs),
 
+    /// Fetch and merge audits from multiple sources into a single `audits.toml`
+    /// file.
+    ///
+    /// Will fetch the audits from each URL in the provided file, combining them
+    /// into a single file. Custom criteria will be merged by-name, and must
+    /// have identical descriptions in each source audit file.
+    #[clap(disable_version_flag = true)]
+    Aggregate(AggregateArgs),
+
     /// Print the cargo build graph as understood by `cargo vet`
     ///
     /// This is a debugging command, the output's format is not guaranteed.
@@ -570,6 +579,13 @@ pub struct RegenerateImportsArgs {}
 #[derive(clap::Args)]
 pub struct RegenerateAuditAsCratesIoArgs {}
 
+#[derive(clap::Args)]
+pub struct AggregateArgs {
+    /// Path to a file containing a list of URLs to aggregate the audits from.
+    #[clap(action)]
+    pub sources: PathBuf,
+}
+
 #[derive(clap::Args)]
 pub struct HelpMarkdownArgs {}
 
diff --git a/src/errors.rs b/src/errors.rs
index 46f3844..431c0f9 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -4,7 +4,7 @@ use cargo_metadata::Version;
 use miette::{Diagnostic, NamedSource, SourceOffset, SourceSpan};
 use thiserror::Error;
 
-use crate::format::{ForeignCriteriaName, ImportName, PackageName};
+use crate::format::{CriteriaName, ForeignCriteriaName, ImportName, PackageName};
 
 pub type SourceFile = Arc<NamedSource>;
 
@@ -557,6 +557,82 @@ pub enum FlockError {
     ),
 }
 
+//////////////////////////////////////////////////////////
+// AggregateErrors
+//////////////////////////////////////////////////////////
+
+#[derive(Debug, Error, Diagnostic)]
+#[error("there were errors aggregating source audit files")]
+#[diagnostic()]
+pub struct AggregateErrors {
+    #[related]
+    pub errors: Vec<AggregateError>,
+}
+
+#[derive(Debug, Error, Diagnostic)]
+#[non_exhaustive]
+pub enum AggregateError {
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    CriteriaDescriptionMismatch(AggregateCriteriaDescriptionMismatchError),
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    ImpliesMismatch(AggregateImpliesMismatchError),
+}
+
+#[derive(Debug, Error, Diagnostic)]
+#[non_exhaustive]
+#[error("criteria description mismatch for {criteria_name}\n{first}\n{second}")]
+pub struct AggregateCriteriaDescriptionMismatchError {
+    pub criteria_name: CriteriaName,
+    pub first: AggregateCriteriaDescription,
+    pub second: AggregateCriteriaDescription,
+}
+
+#[derive(Debug)]
+pub struct AggregateCriteriaDescription {
+    pub source: String,
+    pub description: Option<String>,
+    pub description_url: Option<String>,
+}
+
+impl Display for AggregateCriteriaDescription {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        if let Some(description) = &self.description {
+            write!(f, "{}:\n{}", self.source, description)
+        } else if let Some(description_url) = &self.description_url {
+            write!(f, "{}:\n(URL) {}", self.source, description_url)
+        } else {
+            write!(f, "{}:\n(no description)", self.source)
+        }
+    }
+}
+
+#[derive(Debug, Error, Diagnostic)]
+#[non_exhaustive]
+#[error("implied criteria mismatch for {criteria_name}\n{first}\n{second}")]
+pub struct AggregateImpliesMismatchError {
+    pub criteria_name: CriteriaName,
+    pub first: AggregateCriteriaImplies,
+    pub second: AggregateCriteriaImplies,
+}
+
+#[derive(Debug)]
+pub struct AggregateCriteriaImplies {
+    pub source: String,
+    pub implies: Vec<CriteriaName>,
+}
+
+impl Display for AggregateCriteriaImplies {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(f, "{}:", self.source)?;
+        for implied in &self.implies {
+            write!(f, "\n - {}", implied)?;
+        }
+        Ok(())
+    }
+}
+
 //////////////////////////////////////////////////////////
 // TomlError/JsonError
 //////////////////////////////////////////////////////////
diff --git a/src/format.rs b/src/format.rs
index 6ef16ff..9839062 100644
--- a/src/format.rs
+++ b/src/format.rs
@@ -148,6 +148,12 @@ pub struct CriteriaEntry {
     #[serde(default)]
     #[serde(with = "serialization::string_or_vec")]
     pub implies: Vec<Spanned<CriteriaName>>,
+    /// Chain of sources this criteria was aggregated from, most recent last.
+    #[serde(rename = "aggregated-from")]
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    #[serde(default)]
+    #[serde(with = "serialization::string_or_vec")]
+    pub aggregated_from: Vec<Spanned<String>>,
 }
 
 /// This is conceptually an enum
@@ -159,6 +165,8 @@ pub struct AuditEntry {
     pub criteria: Vec<Spanned<CriteriaName>>,
     pub kind: AuditKind,
     pub notes: Option<String>,
+    /// Chain of sources this audit was aggregated from, most recent last.
+    pub aggregated_from: Vec<Spanned<String>>,
     /// A non-serialized member which indicates whether this audit is a "fresh"
     /// audit. This will be set for all audits imported found in the remote
     /// audits file which aren't also found in the local `imports.lock` cache.
diff --git a/src/main.rs b/src/main.rs
index c4f6629..28aa534 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
 use std::collections::HashMap;
+use std::io::{BufRead, BufReader};
 use std::ops::Deref;
 use std::panic::panic_any;
 use std::sync::{Arc, Mutex};
@@ -9,15 +10,19 @@ use cargo_metadata::{Metadata, Package, Version};
 use clap::{CommandFactory, Parser};
 use console::Term;
 use errors::{
-    AuditAsError, AuditAsErrors, CacheAcquireError, CertifyError, NeedsAuditAsError,
-    NeedsAuditAsErrors, ShouldntBeAuditAsError, ShouldntBeAuditAsErrors, UserInfoError,
+    AggregateCriteriaDescription, AggregateCriteriaDescriptionMismatchError,
+    AggregateCriteriaImplies, AggregateError, AggregateErrors, AggregateImpliesMismatchError,
+    AuditAsError, AuditAsErrors, CacheAcquireError, CertifyError, FetchAuditError, LoadTomlError,
+    NeedsAuditAsError, NeedsAuditAsErrors, ShouldntBeAuditAsError, ShouldntBeAuditAsErrors,
+    TomlParseError, UserInfoError,
 };
 use format::{CriteriaName, CriteriaStr, PackageName, PolicyEntry};
-use futures_util::future::join_all;
+use futures_util::future::{join_all, try_join_all};
 use indicatif::ProgressDrawTarget;
 use lazy_static::lazy_static;
-use miette::{miette, Context, Diagnostic, IntoDiagnostic};
+use miette::{miette, Context, Diagnostic, IntoDiagnostic, NamedSource, SourceOffset};
 use network::Network;
+use out::{progress_bar, IncProgressOnDrop};
 use reqwest::Url;
 use serde::de::Deserialize;
 use serialization::spanned::Spanned;
@@ -290,6 +295,7 @@ fn real_main() -> Result<(), miette::Report> {
     };
 
     match &partial_cfg.cli.command {
+        Some(Aggregate(sub_args)) => return cmd_aggregate(&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),
         _ => {
@@ -445,7 +451,7 @@ fn real_main() -> Result<(), miette::Report> {
         Some(Regenerate(AuditAsCratesIo(sub_args))) => {
             cmd_regenerate_audit_as(&out, &cfg, sub_args)
         }
-        Some(HelpMarkdown(_)) | Some(Gc(_)) => unreachable!("handled earlier"),
+        Some(Aggregate(_)) | Some(HelpMarkdown(_)) | Some(Gc(_)) => unreachable!("handled earlier"),
     }
 }
 
@@ -930,6 +936,7 @@ fn do_cmd_certify(
             .collect(),
         who,
         notes,
+        aggregated_from: vec![],
         is_fresh_import: false,
     };
 
@@ -1158,6 +1165,7 @@ fn cmd_record_violation(
         criteria,
         who,
         notes,
+        aggregated_from: vec![],
         is_fresh_import: false,
     };
 
@@ -1562,6 +1570,158 @@ fn cmd_fetch_imports(
     Ok(())
 }
 
+fn cmd_aggregate(
+    out: &Arc<dyn Out>,
+    cfg: &PartialConfig,
+    sub_args: &AggregateArgs,
+) -> Result<(), miette::Report> {
+    let network =
+        Network::acquire(cfg).ok_or_else(|| miette!("cannot aggregate imports when --frozen"))?;
+
+    let mut urls = Vec::new();
+    {
+        let sources_file = BufReader::new(
+            File::open(&sub_args.sources)
+                .into_diagnostic()
+                .wrap_err("failed to open sources file")?,
+        );
+        for line_result in sources_file.lines() {
+            let line = line_result
+                .into_diagnostic()
+                .wrap_err("failed to read sources file")?;
+            let trimmed = line.trim();
+            if trimmed.is_empty() || trimmed.starts_with('#') {
+                // Ignore comment and empty lines.
+                continue;
+            }
+            urls.push(
+                Url::parse(trimmed)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("failed to parse url: {:?}", trimmed))?,
+            );
+        }
+    }
+
+    let progress_bar = progress_bar("Fetching", "source audits", urls.len() as u64);
+    let sources = tokio::runtime::Handle::current()
+        .block_on(try_join_all(urls.into_iter().map(|url| async {
+            let _guard = IncProgressOnDrop(&progress_bar, 1);
+            let url_string = url.to_string();
+            let audit_bytes = network.download(url).await?;
+            let audit_string = String::from_utf8(audit_bytes).map_err(LoadTomlError::from)?;
+            let audit_source = Arc::new(NamedSource::new(url_string.clone(), audit_string.clone()));
+            let audit_file: AuditsFile = toml::de::from_str(&audit_string)
+                .map_err(|error| {
+                    let (line, col) = error.line_col().unwrap_or((0, 0));
+                    TomlParseError {
+                        source_code: audit_source,
+                        span: SourceOffset::from_location(&audit_string, line + 1, col + 1),
+                        error,
+                    }
+                })
+                .map_err(LoadTomlError::from)?;
+            Ok::<_, FetchAuditError>((url_string, audit_file))
+        })))
+        .into_diagnostic()?;
+
+    let merged_audits = do_aggregate_audits(sources).into_diagnostic()?;
+    let document = serialization::to_formatted_toml(merged_audits).into_diagnostic()?;
+    write!(out, "{}", document);
+    Ok(())
+}
+
+fn do_aggregate_audits(sources: Vec<(String, AuditsFile)>) -> Result<AuditsFile, AggregateErrors> {
+    let mut errors = Vec::new();
+    let mut aggregate = AuditsFile {
+        criteria: SortedMap::new(),
+        audits: SortedMap::new(),
+    };
+
+    for (source, audit_file) in sources {
+        // Add each criteria from the original source, managing duplicates by
+        // ensuring that their descriptions map 1:1.
+        for (criteria_name, mut criteria_entry) in audit_file.criteria {
+            match aggregate.criteria.entry(criteria_name) {
+                std::collections::btree_map::Entry::Vacant(vacant) => {
+                    criteria_entry.aggregated_from.push(source.clone().into());
+                    vacant.insert(criteria_entry);
+                }
+                std::collections::btree_map::Entry::Occupied(occupied) => {
+                    let prev_source = occupied
+                        .get()
+                        .aggregated_from
+                        .last()
+                        .map(|s| s.to_string())
+                        .unwrap_or_default();
+                    // NOTE: We don't record the new `aggregated_from` chain in
+                    // this case, as we already have a chain for the existing
+                    // entry which we don't want to clobber. This means that
+                    // source order in the `sources.list` file can impact where
+                    // your criteria are credited to originate from.
+                    if occupied.get().description != criteria_entry.description
+                        || occupied.get().description_url != criteria_entry.description_url
+                    {
+                        errors.push(AggregateError::CriteriaDescriptionMismatch(
+                            AggregateCriteriaDescriptionMismatchError {
+                                criteria_name: occupied.key().to_owned(),
+                                first: AggregateCriteriaDescription {
+                                    source: prev_source.clone(),
+                                    description: occupied.get().description.clone(),
+                                    description_url: occupied.get().description_url.clone(),
+                                },
+                                second: AggregateCriteriaDescription {
+                                    source: source.clone(),
+                                    description: criteria_entry.description.clone(),
+                                    description_url: criteria_entry.description_url.clone(),
+                                },
+                            },
+                        ))
+                    }
+                    if occupied.get().implies != criteria_entry.implies {
+                        errors.push(AggregateError::ImpliesMismatch(
+                            AggregateImpliesMismatchError {
+                                criteria_name: occupied.key().to_owned(),
+                                first: AggregateCriteriaImplies {
+                                    source: prev_source.clone(),
+                                    implies: occupied
+                                        .get()
+                                        .implies
+                                        .iter()
+                                        .map(|c| c.to_string())
+                                        .collect(),
+                                },
+                                second: AggregateCriteriaImplies {
+                                    source: source.clone(),
+                                    implies: criteria_entry
+                                        .implies
+                                        .iter()
+                                        .map(|c| c.to_string())
+                                        .collect(),
+                                },
+                            },
+                        ));
+                    }
+                }
+            }
+        }
+        for (package_name, audits) in audit_file.audits {
+            aggregate
+                .audits
+                .entry(package_name)
+                .or_default()
+                .extend(audits.into_iter().map(|mut audit_entry| {
+                    audit_entry.aggregated_from.push(source.clone().into());
+                    audit_entry
+                }));
+        }
+    }
+    if errors.is_empty() {
+        Ok(aggregate)
+    } else {
+        Err(AggregateErrors { errors })
+    }
+}
+
 fn cmd_dump_graph(
     out: &Arc<dyn Out>,
     cfg: &Config,
diff --git a/src/resolver.rs b/src/resolver.rs
index e3b6af7..540f25d 100644
--- a/src/resolver.rs
+++ b/src/resolver.rs
@@ -3142,7 +3142,7 @@ impl FailForViolationConflict {
                 }
             }
             if !entry.who.is_empty() {
-                writeln!(out, "");
+                writeln!(out);
             }
             if let Some(notes) = &entry.notes {
                 writeln!(out, "      notes: {notes}");
diff --git a/src/serialization.rs b/src/serialization.rs
index 2b50b9b..ca956b3 100644
--- a/src/serialization.rs
+++ b/src/serialization.rs
@@ -206,6 +206,11 @@ pub mod audit {
         #[serde(default)]
         dependency_criteria: DependencyCriteria,
         notes: Option<String>,
+        #[serde(rename = "aggregated-from")]
+        #[serde(skip_serializing_if = "Vec::is_empty")]
+        #[serde(with = "string_or_vec")]
+        #[serde(default)]
+        pub aggregated_from: Vec<Spanned<String>>,
     }
 
     impl TryFrom<AuditEntryAll> for AuditEntry {
@@ -244,6 +249,7 @@ pub mod audit {
                 notes: val.notes,
                 criteria: val.criteria,
                 kind: kind?,
+                aggregated_from: val.aggregated_from,
                 // By default, always read entries as non-fresh. The import code
                 // will set this flag to true for imported entries.
                 is_fresh_import: false,
@@ -283,6 +289,7 @@ pub mod audit {
                 delta,
                 violation,
                 dependency_criteria,
+                aggregated_from: val.aggregated_from,
             }
         }
     }
@@ -627,6 +634,7 @@ mod test {
                         dependency_criteria: dc_long,
                     },
                     notes: Some("notes go here!".to_owned()),
+                    aggregated_from: vec![],
                     is_fresh_import: false, // ignored
                 },
                 AuditEntry {
@@ -637,6 +645,7 @@ mod test {
                         dependency_criteria: dc_short,
                     },
                     notes: Some("notes go here!".to_owned()),
+                    aggregated_from: vec![],
                     is_fresh_import: true, // ignored
                 },
             ],


Test patch:
diff --git a/src/tests/aggregate.rs b/src/tests/aggregate.rs
new file mode 100644
index 0000000..0f22984
--- /dev/null
+++ b/src/tests/aggregate.rs
@@ -0,0 +1,231 @@
+use insta::assert_snapshot;
+
+use super::*;
+
+fn mock_aggregate(sources: Vec<(String, AuditsFile)>) -> String {
+    match crate::do_aggregate_audits(sources) {
+        Ok(merged) => crate::serialization::to_formatted_toml(merged)
+            .unwrap()
+            .to_string(),
+        Err(error) => format!("{:?}", miette::Report::new(error)),
+    }
+}
+
+#[test]
+fn test_merge_audits_files_basic() {
+    let _enter = TEST_RUNTIME.enter();
+
+    let audits_files = vec![
+        (
+            "https://source1.example.com/supply_chain/audits.toml".to_owned(),
+            AuditsFile {
+                criteria: [].into_iter().collect(),
+                audits: [(
+                    "package1".to_owned(),
+                    vec![full_audit(ver(DEFAULT_VER), "safe-to-deploy")],
+                )]
+                .into_iter()
+                .collect(),
+            },
+        ),
+        (
+            "https://source2.example.com/supply_chain/audits.toml".to_owned(),
+            AuditsFile {
+                criteria: [].into_iter().collect(),
+                audits: [
+                    (
+                        "package1".to_owned(),
+                        vec![
+                            full_audit(ver(5), "safe-to-deploy"),
+                            full_audit(ver(DEFAULT_VER), "safe-to-deploy"),
+                        ],
+                    ),
+                    (
+                        "package2".to_owned(),
+                        vec![full_audit(ver(DEFAULT_VER), "safe-to-deploy")],
+                    ),
+                ]
+                .into_iter()
+                .collect(),
+            },
+        ),
+    ];
+
+    let output = mock_aggregate(audits_files);
+    assert_snapshot!(output);
+}
+
+#[test]
+fn test_merge_audits_files_custom_criteria() {
+    let _enter = TEST_RUNTIME.enter();
+
+    let audits_files = vec![
+        (
+            "https://source1.example.com/supply_chain/audits.toml".to_owned(),
+            AuditsFile {
+                criteria: [
+                    (
+                        "criteria1".to_owned(),
+                        CriteriaEntry {
+                            implies: vec![],
+                            description: Some("Criteria 1".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec!["https://elsewhere.example.com/audits.toml"
+                                .to_owned()
+                                .into()],
+                        },
+                    ),
+                    (
+                        "criteria2".to_owned(),
+                        CriteriaEntry {
+                            implies: vec![],
+                            description: Some("Criteria 2".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec![],
+                        },
+                    ),
+                ]
+                .into_iter()
+                .collect(),
+                audits: [(
+                    "package1".to_owned(),
+                    vec![
+                        full_audit(ver(DEFAULT_VER), "criteria1"),
+                        full_audit(ver(DEFAULT_VER), "criteria2"),
+                    ],
+                )]
+                .into_iter()
+                .collect(),
+            },
+        ),
+        (
+            "https://source2.example.com/supply_chain/audits.toml".to_owned(),
+            AuditsFile {
+                criteria: [
+                    (
+                        "criteria1".to_owned(),
+                        CriteriaEntry {
+                            implies: vec![],
+                            description: Some("Criteria 1".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec!["https://beyond.example.com/audits.toml"
+                                .to_owned()
+                                .into()],
+                        },
+                    ),
+                    (
+                        "criteria3".to_owned(),
+                        CriteriaEntry {
+                            implies: vec![],
+                            description: Some("Criteria 3".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec![],
+                        },
+                    ),
+                ]
+                .into_iter()
+                .collect(),
+                audits: [
+                    (
+                        "package1".to_owned(),
+                        vec![full_audit(ver(DEFAULT_VER), "criteria3")],
+                    ),
+                    (
+                        "package2".to_owned(),
+                        vec![full_audit(ver(DEFAULT_VER), "criteria1")],
+                    ),
+                ]
+                .into_iter()
+                .collect(),
+            },
+        ),
+    ];
+
+    let output = mock_aggregate(audits_files);
+    assert_snapshot!(output);
+}
+
+#[test]
+fn test_merge_audits_files_custom_criteria_conflict() {
+    let _enter = TEST_RUNTIME.enter();
+
+    let audits_files = vec![
+        (
+            "https://source1.example.com/supply_chain/audits.toml".to_owned(),
+            AuditsFile {
+                criteria: [
+                    (
+                        "criteria1".to_owned(),
+                        CriteriaEntry {
+                            implies: vec![],
+                            description: Some("Criteria 1".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec![],
+                        },
+                    ),
+                    (
+                        "criteria2".to_owned(),
+                        CriteriaEntry {
+                            implies: vec!["criteria1".to_owned().into()],
+                            description: Some("Criteria 2".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec![],
+                        },
+                    ),
+                    (
+                        "criteria3".to_owned(),
+                        CriteriaEntry {
+                            implies: vec!["criteria2".to_owned().into()],
+                            description: None,
+                            description_url: Some("https://criteria3".to_owned()),
+                            aggregated_from: vec![],
+                        },
+                    ),
+                ]
+                .into_iter()
+                .collect(),
+                audits: [].into_iter().collect(),
+            },
+        ),
+        (
+            "https://source2.example.com/supply_chain/audits.toml".to_owned(),
+            AuditsFile {
+                criteria: [
+                    (
+                        "criteria1".to_owned(),
+                        CriteriaEntry {
+                            implies: vec![],
+                            description: Some("Criteria 1 (alt)".to_owned()),
+                            description_url: None,
+                            aggregated_from: vec![],
+                        },
+                    ),
+                    (
+                        "criteria2".to_owned(),
+                        CriteriaEntry {
+                            implies: vec!["criteria1".to_owned().into()],
+                            description: None,
+                            description_url: Some("https://criteria2".to_owned()),
+                            aggregated_from: vec![],
+                        },
+                    ),
+                    (
+                        "criteria3".to_owned(),
+                        CriteriaEntry {
+                            implies: vec!["criteria1".to_owned().into()],
+                            description: None,
+                            description_url: Some("https://criteria3.alt".to_owned()),
+                            aggregated_from: vec![],
+                        },
+                    ),
+                ]
+                .into_iter()
+                .collect(),
+                audits: [].into_iter().collect(),
+            },
+        ),
+    ];
+
+    let output = mock_aggregate(audits_files);
+    assert_snapshot!(output);
+}
diff --git a/src/tests/import.rs b/src/tests/import.rs
index 95722ba..81a3df4 100644
--- a/src/tests/import.rs
+++ b/src/tests/import.rs
@@ -337,16 +337,9 @@ fn existing_peer_import_custom_criteria() {
     };
 
     let new_foreign_audits = AuditsFile {
-        criteria: [(
-            "fuzzed".to_string(),
-            CriteriaEntry {
-                implies: vec![],
-                description: Some("fuzzed".to_string()),
-                description_url: None,
-            },
-        )]
-        .into_iter()
-        .collect(),
+        criteria: [("fuzzed".to_string(), criteria("fuzzed"))]
+            .into_iter()
+            .collect(),
         audits: [(
             "third-party2".to_owned(),
             vec![
@@ -395,16 +388,9 @@ fn new_audit_for_unused_criteria_basic() {
     let metadata = mock.metadata();
     let (mut config, audits, mut imports) = builtin_files_full_audited(&metadata);
     let old_foreign_audits = AuditsFile {
-        criteria: [(
-            "fuzzed".to_string(),
-            CriteriaEntry {
-                implies: vec![],
-                description: Some("fuzzed".to_string()),
-                description_url: None,
-            },
-        )]
-        .into_iter()
-        .collect(),
+        criteria: [("fuzzed".to_string(), criteria("fuzzed"))]
+            .into_iter()
+            .collect(),
         audits: [(
             "third-party2".to_owned(),
             vec![full_audit(ver(DEFAULT_VER), SAFE_TO_DEPLOY)],
@@ -457,16 +443,9 @@ fn new_audit_for_unused_criteria_transitive() {
     let metadata = mock.metadata();
     let (mut config, audits, mut imports) = builtin_files_full_audited(&metadata);
     let old_foreign_audits = AuditsFile {
-        criteria: [(
-            "fuzzed".to_string(),
-            CriteriaEntry {
-                implies: vec![],
-                description: Some("fuzzed".to_string()),
-                description_url: None,
-            },
-        )]
-        .into_iter()
-        .collect(),
+        criteria: [("fuzzed".to_string(), criteria("fuzzed"))]
+            .into_iter()
+            .collect(),
         audits: [(
             "third-party1".to_owned(),
             vec![full_audit(ver(DEFAULT_VER), SAFE_TO_DEPLOY)],
diff --git a/src/tests/mod.rs b/src/tests/mod.rs
index dc48061..4370233 100644
--- a/src/tests/mod.rs
+++ b/src/tests/mod.rs
@@ -38,6 +38,7 @@ macro_rules! assert_report_snapshot {
     }};
 }
 
+mod aggregate;
 mod audit_as_crates_io;
 mod certify;
 mod import;
@@ -68,6 +69,7 @@ lazy_static::lazy_static! {
             Box::new(
                 miette::MietteHandlerOpts::new()
                     .graphical_theme(graphical_theme)
+                    .width(80)
                     .build()
             )
         })).expect("Failed to initialize error handler");
@@ -315,6 +317,7 @@ fn delta_audit(from: Version, to: Version, criteria: CriteriaStr) -> AuditEntry
             to,
             dependency_criteria: DependencyCriteria::default(),
         },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -348,6 +351,7 @@ fn delta_audit_dep(
                 })
                 .collect(),
         },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -361,6 +365,7 @@ fn full_audit(version: Version, criteria: CriteriaStr) -> AuditEntry {
             version,
             dependency_criteria: DependencyCriteria::default(),
         },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -377,6 +382,7 @@ fn full_audit_m(
             version,
             dependency_criteria: DependencyCriteria::default(),
         },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -407,6 +413,7 @@ fn full_audit_dep(
                 })
                 .collect(),
         },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -417,6 +424,7 @@ fn violation_hard(version: VersionReq) -> AuditEntry {
         notes: None,
         criteria: vec![SAFE_TO_RUN.to_string().into()],
         kind: AuditKind::Violation { violation: version },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -427,6 +435,7 @@ fn violation(version: VersionReq, criteria: CriteriaStr) -> AuditEntry {
         notes: None,
         criteria: vec![criteria.to_string().into()],
         kind: AuditKind::Violation { violation: version },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -440,6 +449,7 @@ fn violation_m(
         notes: None,
         criteria: criteria.into_iter().map(|s| s.into().into()).collect(),
         kind: AuditKind::Violation { violation: version },
+        aggregated_from: vec![],
         is_fresh_import: false,
     }
 }
@@ -490,6 +500,27 @@ fn dep_policy(
     }
 }
 
+fn criteria(description: &str) -> CriteriaEntry {
+    CriteriaEntry {
+        description: Some(description.to_owned()),
+        description_url: None,
+        implies: vec![],
+        aggregated_from: vec![],
+    }
+}
+
+fn criteria_implies(
+    description: &str,
+    implies: impl IntoIterator<Item = impl Into<CriteriaName>>,
+) -> CriteriaEntry {
+    CriteriaEntry {
+        description: Some(description.to_owned()),
+        description_url: None,
+        implies: implies.into_iter().map(|s| s.into().into()).collect(),
+        aggregated_from: vec![],
+    }
+}
+
 impl MockMetadata {
     fn simple() -> Self {
         // A simple dependency tree to test basic functionality on.
@@ -917,36 +948,14 @@ fn files_inited(metadata: &Metadata) -> (ConfigFile, AuditsFile, ImportsFile) {
     audits.criteria = SortedMap::from_iter(vec![
         (
             "strong-reviewed".to_string(),
-            CriteriaEntry {
-                implies: vec!["reviewed".to_string().into()],
-                description: Some("strongly reviewed".to_string()),
-                description_url: None,
-            },
+            criteria_implies("strongly reviewed", ["reviewed"]),
         ),
         (
             "reviewed".to_string(),
-            CriteriaEntry {
-                implies: vec!["weak-reviewed".to_string().into()],
-                description: Some("reviewed".to_string()),
-                description_url: None,
-            },
-        ),
-        (
-            "weak-reviewed".to_string(),
-            CriteriaEntry {
-                implies: vec![],
-                description: Some("weakly reviewed".to_string()),
-                description_url: None,
-            },
-        ),
-        (
-            "fuzzed".to_string(),
-            CriteriaEntry {
-                implies: vec![],
-                description: Some("fuzzed".to_string()),
-                description_url: None,
-            },
+            criteria_implies("reviewed", ["weak-reviewed"]),
         ),
+        ("weak-reviewed".to_string(), criteria("weakly reviewed")),
+        ("fuzzed".to_string(), criteria("fuzzed")),
     ]);
 
     // Make the root packages use our custom criteria instead of the builtins
diff --git a/src/tests/regenerate_unaudited.rs b/src/tests/regenerate_unaudited.rs
index 7191a41..ba848b5 100644
--- a/src/tests/regenerate_unaudited.rs
+++ b/src/tests/regenerate_unaudited.rs
@@ -600,19 +600,11 @@ fn builtin_simple_exemptions_regenerate_merge() {
 
     audits.criteria.insert(
         "criteria1".to_owned(),
-        CriteriaEntry {
-            description: Some(String::new()),
-            description_url: None,
-            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
-        },
+        criteria_implies("", [SAFE_TO_DEPLOY]),
     );
     audits.criteria.insert(
         "criteria2".to_owned(),
-        CriteriaEntry {
-            description: Some(String::new()),
-            description_url: None,
-            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
-        },
+        criteria_implies("", [SAFE_TO_DEPLOY]),
     );
 
     audits.audits.insert(
@@ -659,19 +651,11 @@ fn builtin_simple_exemptions_regenerate_merge_nonew() {
 
     audits.criteria.insert(
         "criteria1".to_owned(),
-        CriteriaEntry {
-            description: Some(String::new()),
-            description_url: None,
-            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
-        },
+        criteria_implies("", [SAFE_TO_DEPLOY]),
     );
     audits.criteria.insert(
         "criteria2".to_owned(),
-        CriteriaEntry {
-            description: Some(String::new()),
-            description_url: None,
-            implies: vec![SAFE_TO_DEPLOY.to_owned().into()],
-        },
+        criteria_implies("", [SAFE_TO_DEPLOY]),
     );
 
     audits.audits.insert(
diff --git a/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_basic.snap b/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_basic.snap
new file mode 100644
index 0000000..7022e7c
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_basic.snap
@@ -0,0 +1,25 @@
+---
+source: src/tests/aggregate.rs
+expression: output
+---
+
+[[audits.package1]]
+criteria = "safe-to-deploy"
+version = "10.0.0"
+aggregated-from = "https://source1.example.com/supply_chain/audits.toml"
+
+[[audits.package1]]
+criteria = "safe-to-deploy"
+version = "5.0.0"
+aggregated-from = "https://source2.example.com/supply_chain/audits.toml"
+
+[[audits.package1]]
+criteria = "safe-to-deploy"
+version = "10.0.0"
+aggregated-from = "https://source2.example.com/supply_chain/audits.toml"
+
+[[audits.package2]]
+criteria = "safe-to-deploy"
+version = "10.0.0"
+aggregated-from = "https://source2.example.com/supply_chain/audits.toml"
+
diff --git a/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_custom_criteria.snap b/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_custom_criteria.snap
new file mode 100644
index 0000000..5cc3bc3
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_custom_criteria.snap
@@ -0,0 +1,40 @@
+---
+source: src/tests/aggregate.rs
+expression: output
+---
+
+[criteria.criteria1]
+description = "Criteria 1"
+aggregated-from = [
+    "https://elsewhere.example.com/audits.toml",
+    "https://source1.example.com/supply_chain/audits.toml",
+]
+
+[criteria.criteria2]
+description = "Criteria 2"
+aggregated-from = "https://source1.example.com/supply_chain/audits.toml"
+
+[criteria.criteria3]
+description = "Criteria 3"
+aggregated-from = "https://source2.example.com/supply_chain/audits.toml"
+
+[[audits.package1]]
+criteria = "criteria1"
+version = "10.0.0"
+aggregated-from = "https://source1.example.com/supply_chain/audits.toml"
+
+[[audits.package1]]
+criteria = "criteria2"
+version = "10.0.0"
+aggregated-from = "https://source1.example.com/supply_chain/audits.toml"
+
+[[audits.package1]]
+criteria = "criteria3"
+version = "10.0.0"
+aggregated-from = "https://source2.example.com/supply_chain/audits.toml"
+
+[[audits.package2]]
+criteria = "criteria1"
+version = "10.0.0"
+aggregated-from = "https://source2.example.com/supply_chain/audits.toml"
+
diff --git a/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_custom_criteria_conflict.snap b/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_custom_criteria_conflict.snap
new file mode 100644
index 0000000..a5bdc10
--- /dev/null
+++ b/src/tests/snapshots/cargo_vet__tests__aggregate__merge_audits_files_custom_criteria_conflict.snap
@@ -0,0 +1,32 @@
+---
+source: src/tests/aggregate.rs
+expression: output
+---
+
+  × there were errors aggregating source audit files
+
+Error: 
+  × criteria description mismatch for criteria1
+  │ https://source1.example.com/supply_chain/audits.toml:
+  │ Criteria 1
+  │ https://source2.example.com/supply_chain/audits.toml:
+  │ Criteria 1 (alt)
+Error: 
+  × criteria description mismatch for criteria2
+  │ https://source1.example.com/supply_chain/audits.toml:
+  │ Criteria 2
+  │ https://source2.example.com/supply_chain/audits.toml:
+  │ (URL) https://criteria2
+Error: 
+  × criteria description mismatch for criteria3
+  │ https://source1.example.com/supply_chain/audits.toml:
+  │ (URL) https://criteria3
+  │ https://source2.example.com/supply_chain/audits.toml:
+  │ (URL) https://criteria3.alt
+Error: 
+  × implied criteria mismatch for criteria3
+  │ https://source1.example.com/supply_chain/audits.toml:
+  │  - criteria2
+  │ https://source2.example.com/supply_chain/audits.toml:
+  │  - criteria1
+
diff --git a/src/tests/vet.rs b/src/tests/vet.rs
index e874a5c..3dcfe2e 100644
--- a/src/tests/vet.rs
+++ b/src/tests/vet.rs
@@ -2703,16 +2703,9 @@ fn builtin_simple_foreign_dep_criteria_fail() {
     imports.audits.insert(
         FOREIGN.to_owned(),
         AuditsFile {
-            criteria: [(
-                DEFAULT_CRIT.to_owned(),
-                CriteriaEntry {
-                    description: Some("nice".to_owned()),
-                    description_url: None,
-                    implies: Vec::new(),
-                },
-            )]
-            .into_iter()
-            .collect(),
+            criteria: [(DEFAULT_CRIT.to_owned(), criteria("nice"))]
+                .into_iter()
+                .collect(),
             audits: [(
                 "third-party1".to_owned(),
                 vec![full_audit_dep(
@@ -2757,16 +2750,9 @@ fn builtin_simple_foreign_dep_criteria_pass() {
     imports.audits.insert(
         FOREIGN.to_owned(),
         AuditsFile {
-            criteria: [(
-                DEFAULT_CRIT.to_owned(),
-                CriteriaEntry {
-                    description: Some("nice".to_owned()),
-                    description_url: None,
-                    implies: Vec::new(),
-                },
-            )]
-            .into_iter()
-            .collect(),
+            criteria: [(DEFAULT_CRIT.to_owned(), criteria("nice"))]
+                .into_iter()
+                .collect(),
             audits: [
                 (
                     "third-party1".to_owned(),