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