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