Skip to main content

rto_exec/adapter/
cargo_audit.rs

1//! `cargo-audit` — `RustSec` advisories against a resolved `Cargo.lock`.
2//!
3//! This is the dependency-vulnerability half of the coverage matrix (ADR-0018),
4//! and it covers **Rust only**: `cargo audit` reads `Cargo.lock` and nothing
5//! else. Python, Java and Node dependency vulnerabilities are a different tool
6//! and a separate change; nothing here should be read as covering them.
7//!
8//! # Why this analyzer is the one that makes staleness real
9//!
10//! Semgrep's answer depends on the rules and the source. `cargo audit`'s answer
11//! depends on an **advisory database that changes without the source changing** —
12//! the exact case ADR-0012 built [`AdvisoryDb`] for. Its report states which
13//! database it consulted (`last-commit`) and when that database was published
14//! (`last-updated`), and both are carried onto the run so a result can be
15//! labelled *possibly stale* rather than *current*.
16//!
17//! # Severity is a mapping, and the tool's own evidence is kept
18//!
19//! `RustSec` does not publish a qualitative severity level. It publishes a CVSS
20//! **vector** on some advisories and an `informational` kind on others. This
21//! adapter maps the kind onto [`Severity`] and preserves the raw CVSS vector,
22//! aliases and categories verbatim in `meta`. Computing a CVSS base score from
23//! the vector — which is what `cargo audit`'s own terminal output does — is
24//! deliberately not done here: it is a scoring algorithm with its own versions,
25//! and inventing a number that disagreed with the tool's would be worse than
26//! carrying the vector unchanged.
27//!
28//! @rto:0012
29//! @rto:0018
30
31use serde::Deserialize;
32
33use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext};
34use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
35use crate::runner::ExecError;
36use rto_graph::{AdvisoryDb, Severity};
37
38/// The analyzer id, and the first component of every finding key it produces.
39pub const ANALYZER: &str = "cargo-audit";
40
41/// Asset id of the pinned `RustSec` advisory database.
42pub const ADVISORY_DB_ASSET: &str = "rustsec-advisory-db";
43
44/// Stands in for the lockfile blob in a finding's identity when the caller could
45/// not determine one.
46///
47/// A `cargo-audit` finding is a claim about *a package version in a particular
48/// lockfile*, so the lockfile blob is part of its identity. When it is unknown —
49/// a report ingested outside a checkout — the identity stays well-formed and
50/// says so, rather than silently keying on an empty component.
51pub const UNKNOWN_LOCKFILE: &str = "unknown-lockfile";
52
53/// The adapter.
54#[derive(Debug, Clone, Copy)]
55pub struct CargoAudit;
56
57impl Adapter for CargoAudit {
58    fn analyzer(&self) -> &'static str {
59        ANALYZER
60    }
61
62    fn summary(&self) -> &'static str {
63        "RustSec advisories against Cargo.lock (Rust dependencies only)"
64    }
65
66    fn languages(&self) -> &'static [&'static str] {
67        &["rust"]
68    }
69
70    fn asset_ids(&self) -> &'static [&'static str] {
71        &[ADVISORY_DB_ASSET]
72    }
73
74    fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
75        Invocation {
76            program: "cargo".to_owned(),
77            args: vec![
78                "audit".to_owned(),
79                "--json".to_owned(),
80                // Egress configured off: never refresh the database mid-run. The
81                // database is provisioned and pinned, so a run's answer is a
82                // function of inputs that were fixed before it started.
83                "--no-fetch".to_owned(),
84                "--db".to_owned(),
85                assets.arg(ADVISORY_DB_ASSET),
86            ],
87            // 0 = clean, 1 = vulnerabilities found.
88            success_statuses: vec![0, 1],
89        }
90    }
91
92    fn normalize(
93        &self,
94        native: &[u8],
95        ctx: &NativeContext<'_>,
96    ) -> Result<NormalizedReport, ExecError> {
97        let output: AuditOutput = serde_json::from_slice(native)?;
98        let Some(vulnerabilities) = output.vulnerabilities else {
99            return Err(ExecError::MalformedReport(
100                "not a cargo-audit report: no `vulnerabilities` object".to_owned(),
101            ));
102        };
103
104        let lockfile = ctx
105            .source
106            .lockfile_blob
107            .as_deref()
108            .filter(|b| !b.trim().is_empty())
109            .unwrap_or(UNKNOWN_LOCKFILE);
110
111        let mut findings = Vec::new();
112        for entry in &vulnerabilities.list {
113            findings.push(convert(entry, "vulnerability", lockfile)?);
114        }
115        // `warnings` is keyed by kind (`unmaintained`, `unsound`, `yanked`, …),
116        // and the set of kinds grows: iterating the map rather than naming the
117        // kinds means a new one is reported instead of silently dropped.
118        let mut kinds: Vec<&String> = output.warnings.keys().collect();
119        kinds.sort();
120        for kind in kinds {
121            for entry in &output.warnings[kind] {
122                findings.push(convert(entry, kind, lockfile)?);
123            }
124        }
125
126        Ok(NormalizedReport {
127            schema: REPORT_SCHEMA.to_owned(),
128            analyzer: ANALYZER.to_owned(),
129            // `cargo audit --json` carries no version field of its own, so a
130            // report ingested from CI records "unknown" unless the caller
131            // learned the version another way (a subprocess run asks the binary).
132            analyzer_version: ctx.version_or(None),
133            started_at: ctx.started_at.clone(),
134            ended_at: ctx.ended_at.clone(),
135            exit_status: ctx.exit_status,
136            // Rules are not a thing for cargo-audit; the advisory database is.
137            rules_digest: None,
138            image_digest: None,
139            // The report's own account of the database wins where it has one;
140            // otherwise the caller's provisioning record stands in. `cargo audit`
141            // reports nothing here whenever `--db` was passed, so in practice the
142            // fallback is what carries the staleness evidence.
143            advisory_db: output
144                .database
145                .and_then(advisory_db)
146                .or_else(|| ctx.advisory_db.clone()),
147            source: ctx.source.clone(),
148            findings,
149        })
150    }
151}
152
153/// The advisory database evidence, or `None` when the report named no commit —
154/// an unidentifiable database is not evidence, and a blank digest would read as
155/// one.
156fn advisory_db(database: Database) -> Option<AdvisoryDb> {
157    let digest = database.last_commit?;
158    if digest.trim().is_empty() {
159        return None;
160    }
161    Some(AdvisoryDb {
162        digest,
163        published_at: database.last_updated.filter(|s| !s.trim().is_empty()),
164    })
165}
166
167/// One vulnerability or warning entry → one normalized finding.
168fn convert(entry: &Entry, kind: &str, lockfile: &str) -> Result<ReportFinding, ExecError> {
169    let package = entry.package.as_ref().ok_or_else(|| {
170        ExecError::MalformedReport(format!("a cargo-audit {kind} entry has no `package`"))
171    })?;
172    if package.name.trim().is_empty() {
173        return Err(ExecError::MalformedReport(format!(
174            "a cargo-audit {kind} entry has an unnamed package"
175        )));
176    }
177
178    // A yanked crate has no advisory, so the warning kind takes the advisory
179    // slot in the identity. Every component stays non-empty and the recipe's
180    // shape — what fired, on what package, at what version, in which lockfile —
181    // is the same either way.
182    let advisory = entry.advisory.as_ref();
183    let rule = advisory
184        .map(|a| a.id.clone())
185        .filter(|id| !id.trim().is_empty())
186        .unwrap_or_else(|| kind.to_owned());
187    let version = if package.version.trim().is_empty() {
188        "unknown-version".to_owned()
189    } else {
190        package.version.clone()
191    };
192
193    let title = advisory
194        .map(|a| a.title.trim())
195        .filter(|t| !t.is_empty())
196        .map_or_else(
197            || format!("{} {version} is {kind}", package.name),
198            str::to_owned,
199        );
200
201    Ok(ReportFinding {
202        // ADR-0012's recipe: advisory, package, version, lockfile blob.
203        identity: vec![
204            rule.clone(),
205            package.name.clone(),
206            version.clone(),
207            lockfile.to_owned(),
208        ],
209        rule,
210        severity: severity(kind, advisory),
211        title,
212        message: advisory
213            .map(|a| a.description.trim().to_owned())
214            .unwrap_or_default(),
215        // The claim is about a resolved dependency, not a location in the
216        // source; `Cargo.lock` is the file that decides it.
217        path: Some("Cargo.lock".to_owned()),
218        span: None,
219        meta: serde_json::json!({
220            "kind": kind,
221            "package": package.name,
222            "version": version,
223            "patched": entry.versions.as_ref().map(|v| v.patched.clone()).unwrap_or_default(),
224            "cvss": advisory.and_then(|a| a.cvss.clone()),
225            "aliases": advisory.map(|a| a.aliases.clone()).unwrap_or_default(),
226            // Real advisories often carry the CVE under `related` rather than
227            // `aliases` — RUSTSEC-2020-0159 lists CVE-2020-26235 there — so
228            // dropping it would lose the identifier most people search by.
229            "related": advisory.map(|a| a.related.clone()).unwrap_or_default(),
230            "categories": advisory.map(|a| a.categories.clone()).unwrap_or_default(),
231            "url": advisory.and_then(|a| a.url.clone()),
232            "advisory_date": advisory.and_then(|a| a.date.clone()),
233        }),
234    })
235}
236
237/// Map a `RustSec` entry onto [`Severity`].
238///
239/// `RustSec` publishes no qualitative level, so this is Roteiro's mapping, not the
240/// tool's judgement, and the advisory's own CVSS vector is preserved in `meta`
241/// unchanged. A vulnerability is `high` because `RustSec`'s bar for one is a
242/// security defect with a known impact; the informational kinds are graded below
243/// it. An unrecognised kind is kept verbatim rather than being flattened into a
244/// level nobody assigned.
245fn severity(kind: &str, advisory: Option<&Advisory>) -> Severity {
246    // An advisory's own `informational` field is more specific than the bucket
247    // it happened to be listed under.
248    let kind = advisory
249        .and_then(|a| a.informational.as_deref())
250        .unwrap_or(kind);
251    match kind {
252        "vulnerability" => Severity::High,
253        "unsound" => Severity::Medium,
254        "unmaintained" | "yanked" => Severity::Low,
255        "notice" => Severity::Info,
256        other => Severity::from_token(other),
257    }
258}
259
260/// The shape of `cargo audit --json`, narrowed to what is needed. Unknown fields
261/// are ignored so a `cargo-audit` upgrade that adds keys does not break ingest.
262#[derive(Debug, Deserialize)]
263struct AuditOutput {
264    #[serde(default)]
265    database: Option<Database>,
266    /// Absent rather than empty distinguishes "not a cargo-audit report" from
267    /// "a clean audit".
268    #[serde(default)]
269    vulnerabilities: Option<Vulnerabilities>,
270    #[serde(default)]
271    warnings: std::collections::BTreeMap<String, Vec<Entry>>,
272}
273
274#[derive(Debug, Deserialize)]
275struct Database {
276    #[serde(default, rename = "last-commit")]
277    last_commit: Option<String>,
278    #[serde(default, rename = "last-updated")]
279    last_updated: Option<String>,
280}
281
282#[derive(Debug, Deserialize)]
283struct Vulnerabilities {
284    #[serde(default)]
285    list: Vec<Entry>,
286}
287
288#[derive(Debug, Deserialize)]
289struct Entry {
290    #[serde(default)]
291    advisory: Option<Advisory>,
292    #[serde(default)]
293    package: Option<Package>,
294    #[serde(default)]
295    versions: Option<Versions>,
296}
297
298#[derive(Debug, Deserialize)]
299struct Advisory {
300    #[serde(default)]
301    id: String,
302    #[serde(default)]
303    title: String,
304    #[serde(default)]
305    description: String,
306    #[serde(default)]
307    date: Option<String>,
308    #[serde(default)]
309    url: Option<String>,
310    #[serde(default)]
311    cvss: Option<String>,
312    #[serde(default)]
313    informational: Option<String>,
314    #[serde(default)]
315    aliases: Vec<String>,
316    #[serde(default)]
317    related: Vec<String>,
318    #[serde(default)]
319    categories: Vec<String>,
320}
321
322#[derive(Debug, Deserialize)]
323struct Package {
324    #[serde(default)]
325    name: String,
326    #[serde(default)]
327    version: String,
328}
329
330#[derive(Debug, Deserialize)]
331struct Versions {
332    #[serde(default)]
333    patched: Vec<String>,
334}
335
336#[cfg(test)]
337mod tests {
338    use super::{ADVISORY_DB_ASSET, ANALYZER, CargoAudit, UNKNOWN_LOCKFILE};
339    use crate::adapter::{Adapter, AssetPaths, NativeContext};
340    use crate::runner::ExecError;
341    use rto_graph::{Severity, SourceIdentity};
342
343    static SOURCE_WITH_LOCK: std::sync::LazyLock<SourceIdentity> =
344        std::sync::LazyLock::new(|| SourceIdentity {
345            lockfile_blob: Some("lock123".to_owned()),
346            ..SourceIdentity::default()
347        });
348    static SOURCE_BARE: std::sync::LazyLock<SourceIdentity> =
349        std::sync::LazyLock::new(SourceIdentity::default);
350
351    fn ctx(source: &'static SourceIdentity) -> NativeContext<'static> {
352        NativeContext {
353            started_at: "2026-08-15T09:00:00Z".to_owned(),
354            ended_at: "2026-08-15T09:00:02Z".to_owned(),
355            analyzer_version: Some("0.21.2".to_owned()),
356            exit_status: 1,
357            source,
358            rules_digest: None,
359            advisory_db: None,
360            worktree: None,
361            snippets: &crate::snippet::NoSnippets,
362        }
363    }
364
365    const NATIVE: &str = r#"{
366      "database": {
367        "advisory-count": 742,
368        "last-commit": "9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90",
369        "last-updated": "2026-06-01T04:12:00Z"
370      },
371      "lockfile": {"dependency-count": 412},
372      "vulnerabilities": {
373        "found": true,
374        "count": 1,
375        "list": [
376          {
377            "advisory": {
378              "id": "RUSTSEC-2026-0031",
379              "package": "openssl",
380              "title": "openssl `X509` use-after-free",
381              "description": "A crafted certificate chain can free memory still in use.",
382              "date": "2026-05-20",
383              "url": "https://rustsec.org/advisories/RUSTSEC-2026-0031",
384              "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
385              "aliases": ["CVE-2026-1234"],
386              "categories": ["memory-corruption"]
387            },
388            "versions": {"patched": [">=0.10.66"], "unaffected": []},
389            "package": {"name": "openssl", "version": "0.10.5"}
390          }
391        ]
392      },
393      "warnings": {
394        "unmaintained": [
395          {
396            "kind": "unmaintained",
397            "advisory": {
398              "id": "RUSTSEC-2024-0436",
399              "title": "paste is unmaintained",
400              "description": "The author has archived the repository.",
401              "informational": "unmaintained"
402            },
403            "versions": {"patched": []},
404            "package": {"name": "paste", "version": "1.0.15"}
405          }
406        ],
407        "yanked": [
408          {
409            "kind": "yanked",
410            "package": {"name": "half-baked", "version": "0.3.1"}
411          }
412        ]
413      }
414    }"#;
415
416    #[test]
417    fn normalizes_vulnerabilities_and_warnings_together() {
418        let report = CargoAudit
419            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
420            .expect("parse");
421        assert_eq!(report.analyzer, ANALYZER);
422        assert_eq!(report.analyzer_version, "0.21.2");
423        assert_eq!(report.findings.len(), 3, "one vuln, two warnings");
424        assert!(
425            report.rules_digest.is_none(),
426            "rules are not a cargo-audit thing"
427        );
428    }
429
430    /// The advisory database is the whole reason this analyzer needs a staleness
431    /// story: the same lockfile at the same commit legitimately yields a
432    /// different answer against a newer database.
433    #[test]
434    fn carries_the_advisory_database_identity_and_publication_date() {
435        let report = CargoAudit
436            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
437            .expect("parse");
438        let db = report.advisory_db.expect("an advisory database");
439        assert_eq!(db.digest, "9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90");
440        assert_eq!(db.published_at.as_deref(), Some("2026-06-01T04:12:00Z"));
441    }
442
443    /// A database with no commit id cannot be identified, so it is recorded as
444    /// absent rather than as a blank digest that would read like evidence.
445    #[test]
446    fn an_unidentifiable_database_is_recorded_as_none() {
447        let native = NATIVE.replace("\"9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90\"", "\"  \"");
448        let report = CargoAudit
449            .normalize(native.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
450            .expect("parse");
451        assert!(report.advisory_db.is_none());
452    }
453
454    /// `cargo audit` reports `last-commit: null` whenever it is pointed at a
455    /// database with `--db` rather than resolving one itself — which is every
456    /// pinned, reproducible, offline run. Without the caller's provisioning
457    /// record standing in, the *pinned* configuration would be the one with no
458    /// staleness evidence, which is exactly backwards.
459    #[test]
460    fn falls_back_to_the_callers_pinned_database_when_the_report_names_none() {
461        let native = r#"{"database":{"advisory-count":1216,"last-commit":null,
462            "last-updated":null},"vulnerabilities":{"list":[]},"warnings":{}}"#;
463        let mut ctx = ctx(&SOURCE_WITH_LOCK);
464        ctx.advisory_db = Some(rto_graph::AdvisoryDb {
465            digest: "ec5f7ef066dd".to_owned(),
466            published_at: Some("2026-08-12T10:42:29Z".to_owned()),
467        });
468        let report = CargoAudit
469            .normalize(native.as_bytes(), &ctx)
470            .expect("parse");
471        let db = report
472            .advisory_db
473            .expect("the pinned database must stand in");
474        assert_eq!(db.digest, "ec5f7ef066dd");
475        assert_eq!(db.published_at.as_deref(), Some("2026-08-12T10:42:29Z"));
476    }
477
478    /// It is a fallback, not an override: when the tool does report a database,
479    /// the tool's own account is the evidence.
480    #[test]
481    fn the_reports_own_database_wins_over_the_callers() {
482        let mut ctx = ctx(&SOURCE_WITH_LOCK);
483        ctx.advisory_db = Some(rto_graph::AdvisoryDb {
484            digest: "from-the-cache".to_owned(),
485            published_at: None,
486        });
487        let report = CargoAudit
488            .normalize(NATIVE.as_bytes(), &ctx)
489            .expect("parse");
490        assert_eq!(
491            report.advisory_db.expect("db").digest,
492            "9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90"
493        );
494    }
495
496    #[test]
497    fn uses_the_advisory_package_version_lockfile_identity() {
498        let report = CargoAudit
499            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
500            .expect("parse");
501        let vuln = report
502            .findings
503            .iter()
504            .find(|f| f.rule == "RUSTSEC-2026-0031")
505            .expect("the vulnerability");
506        assert_eq!(
507            vuln.identity,
508            vec!["RUSTSEC-2026-0031", "openssl", "0.10.5", "lock123"]
509        );
510        assert_eq!(vuln.severity, Severity::High);
511        assert_eq!(vuln.path.as_deref(), Some("Cargo.lock"));
512        assert_eq!(
513            vuln.meta["cvss"],
514            "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
515        );
516        assert_eq!(vuln.meta["aliases"][0], "CVE-2026-1234");
517    }
518
519    /// A yanked crate has no advisory at all. Its identity must still be
520    /// well-formed, so the warning kind takes the advisory slot.
521    #[test]
522    fn a_warning_with_no_advisory_is_keyed_by_its_kind() {
523        let report = CargoAudit
524            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
525            .expect("parse");
526        let yanked = report
527            .findings
528            .iter()
529            .find(|f| f.rule == "yanked")
530            .expect("the yanked warning");
531        assert_eq!(
532            yanked.identity,
533            vec!["yanked", "half-baked", "0.3.1", "lock123"]
534        );
535        assert_eq!(yanked.severity, Severity::Low);
536        assert_eq!(yanked.title, "half-baked 0.3.1 is yanked");
537    }
538
539    #[test]
540    fn an_informational_advisory_is_graded_below_a_vulnerability() {
541        let report = CargoAudit
542            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
543            .expect("parse");
544        let unmaintained = report
545            .findings
546            .iter()
547            .find(|f| f.rule == "RUSTSEC-2024-0436")
548            .expect("the unmaintained warning");
549        assert_eq!(unmaintained.severity, Severity::Low);
550    }
551
552    /// Outside a checkout the lockfile blob is unknown. The identity has to stay
553    /// well-formed and say so, rather than key on an empty component.
554    #[test]
555    fn an_unknown_lockfile_is_named_not_blank() {
556        let report = CargoAudit
557            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_BARE))
558            .expect("parse");
559        assert!(
560            report
561                .findings
562                .iter()
563                .all(|f| f.identity[3] == UNKNOWN_LOCKFILE)
564        );
565    }
566
567    /// The lockfile is part of the identity precisely so a finding does not
568    /// silently survive a dependency bump under the same key.
569    #[test]
570    fn a_different_lockfile_is_a_different_finding() {
571        let with_lock = CargoAudit
572            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
573            .expect("a");
574        let bare = CargoAudit
575            .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_BARE))
576            .expect("b");
577        assert_ne!(with_lock.findings[0].identity, bare.findings[0].identity);
578    }
579
580    #[test]
581    fn a_clean_audit_is_a_valid_empty_report() {
582        let clean = br#"{"database":{"last-commit":"abc"},
583            "vulnerabilities":{"found":false,"count":0,"list":[]},"warnings":{}}"#;
584        let report = CargoAudit
585            .normalize(clean, &ctx(&SOURCE_WITH_LOCK))
586            .expect("parse");
587        assert!(report.findings.is_empty());
588        assert_eq!(report.advisory_db.expect("db").digest, "abc");
589    }
590
591    #[test]
592    fn refuses_output_that_is_not_a_cargo_audit_report() {
593        let err = CargoAudit
594            .normalize(br#"{"database":{}}"#, &ctx(&SOURCE_WITH_LOCK))
595            .expect_err("must be refused");
596        assert!(matches!(err, ExecError::MalformedReport(_)));
597        assert!(
598            err.to_string().contains("no `vulnerabilities` object"),
599            "{err}"
600        );
601
602        assert!(matches!(
603            CargoAudit.normalize(b"<html>", &ctx(&SOURCE_WITH_LOCK)),
604            Err(ExecError::Json(_))
605        ));
606    }
607
608    #[test]
609    fn refuses_an_entry_with_no_package() {
610        let native = r#"{"vulnerabilities":{"list":[{"advisory":{"id":"R-1"}}]},"warnings":{}}"#;
611        assert!(matches!(
612            CargoAudit.normalize(native.as_bytes(), &ctx(&SOURCE_WITH_LOCK)),
613            Err(ExecError::MalformedReport(_))
614        ));
615    }
616
617    /// A warning kind this build has never heard of must be reported, not
618    /// dropped: the set of `RustSec` informational kinds grows over time.
619    #[test]
620    fn an_unknown_warning_kind_is_reported_verbatim() {
621        let native = r#"{"vulnerabilities":{"list":[]},"warnings":{
622            "future-hazard":[{"package":{"name":"x","version":"1.0.0"}}]}}"#;
623        let report = CargoAudit
624            .normalize(native.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
625            .expect("parse");
626        assert_eq!(report.findings.len(), 1);
627        assert_eq!(report.findings[0].rule, "future-hazard");
628        assert_eq!(
629            report.findings[0].severity,
630            Severity::Other("future-hazard".to_owned())
631        );
632    }
633
634    #[test]
635    fn the_invocation_pins_the_database_and_refuses_to_refresh_it() {
636        let entries = [(ADVISORY_DB_ASSET, std::path::PathBuf::from("/cache/db"))];
637        let invocation = CargoAudit.command(&AssetPaths::new(&entries));
638        assert_eq!(invocation.program, "cargo");
639        assert_eq!(invocation.args[0], "audit");
640        assert!(invocation.args.contains(&"--no-fetch".to_owned()));
641        let db = invocation
642            .args
643            .iter()
644            .position(|a| a == "--db")
645            .map(|i| invocation.args[i + 1].clone())
646            .expect("a --db argument");
647        assert_eq!(db, "/cache/db");
648        assert_eq!(invocation.success_statuses, vec![0, 1]);
649    }
650
651    #[test]
652    fn covers_rust_dependencies_and_says_nothing_more() {
653        assert_eq!(CargoAudit.languages(), &["rust"]);
654        assert_eq!(CargoAudit.asset_ids(), &[ADVISORY_DB_ASSET]);
655    }
656}