Skip to main content

rustinel_core/
advisory.rs

1//! RustSec advisory integration.
2//!
3//! Security & networking model:
4//! - This module reads advisories from a *local* directory in the RustSec
5//!   advisory-db format — both the v4 Markdown layout (`RUSTSEC-*.md` with a
6//!   fenced TOML front-matter) and plain `.toml` files. It performs no network
7//!   I/O itself.
8//! - Online refresh of the database lives in the CLI (`advisory update`, which
9//!   shells out to `git`); the core never spawns processes or touches the
10//!   network. When nothing is cached we degrade gracefully to an empty database
11//!   rather than crashing — satisfying `--offline` cleanly.
12//! - Advisory matching is purely metadata-based: locked version vs the
13//!   advisory's `patched`/`unaffected` semver requirements.
14
15use crate::errors::RustinelError;
16use crate::lockfile::LockfileModel;
17use crate::signals::{Evidence, RiskSignal, Severity};
18use semver::{BuildMetadata, Comparator, Op, Prerelease, Version, VersionReq};
19use serde::Deserialize;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone)]
23pub struct Advisory {
24    pub id: String,
25    pub package: String,
26    pub title: String,
27    pub informational: Option<String>,
28    pub cvss_score: Option<f32>,
29    pub patched: Vec<String>,
30    pub unaffected: Vec<String>,
31}
32
33#[derive(Debug, Default)]
34pub struct AdvisoryDb {
35    advisories: Vec<Advisory>,
36    /// Set when the requested DB path was absent; used to emit a soft warning
37    /// rather than failing the run (important for `--offline`).
38    pub missing: bool,
39}
40
41#[derive(Debug, Deserialize)]
42struct RawAdvisoryFile {
43    advisory: RawAdvisory,
44    versions: Option<RawVersions>,
45}
46
47#[derive(Debug, Deserialize)]
48struct RawAdvisory {
49    id: String,
50    package: String,
51    #[serde(default)]
52    title: String,
53    #[serde(default)]
54    informational: Option<String>,
55    #[serde(default)]
56    cvss: Option<String>,
57    /// Date the advisory was retracted, if any. A withdrawn advisory is no
58    /// longer in effect (e.g. issued in error, or the situation it described
59    /// has been resolved); cargo-audit suppresses these by default and so do we.
60    #[serde(default)]
61    withdrawn: Option<String>,
62}
63
64#[derive(Debug, Deserialize)]
65struct RawVersions {
66    #[serde(default)]
67    patched: Vec<String>,
68    #[serde(default)]
69    unaffected: Vec<String>,
70}
71
72impl AdvisoryDb {
73    pub fn empty() -> Self {
74        Self {
75            advisories: Vec::new(),
76            missing: false,
77        }
78    }
79
80    pub fn len(&self) -> usize {
81        self.advisories.len()
82    }
83
84    pub fn is_empty(&self) -> bool {
85        self.advisories.is_empty()
86    }
87
88    /// Load every `*.toml` advisory found recursively under `dir`.
89    ///
90    /// If `dir` does not exist, returns an empty DB flagged as `missing` (no
91    /// error) so that offline runs without a cached DB continue to work.
92    pub fn load_from_dir(dir: &Path) -> Result<Self, RustinelError> {
93        if !dir.exists() {
94            return Ok(Self {
95                advisories: Vec::new(),
96                missing: true,
97            });
98        }
99        let mut advisories: Vec<Advisory> = Vec::new();
100        // (dir, depth). Symlinks are never followed; depth and total entries are
101        // bounded so a hostile or symlink-looped advisory tree cannot hang the run.
102        let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
103        let mut visited = 0usize;
104        while let Some((d, depth)) = stack.pop() {
105            let entries = std::fs::read_dir(&d).map_err(|e| RustinelError::AdvisoryDb {
106                path: d.clone(),
107                message: e.to_string(),
108            })?;
109            for entry in entries.flatten() {
110                if visited >= crate::safety::MAX_DIR_ENTRIES {
111                    advisories.sort_by(|a, b| a.id.cmp(&b.id));
112                    return Ok(Self {
113                        advisories,
114                        missing: false,
115                    });
116                }
117                visited += 1;
118                let Ok(ft) = entry.file_type() else { continue };
119                if ft.is_symlink() {
120                    continue;
121                }
122                let path = entry.path();
123                if ft.is_dir() {
124                    // Skip VCS metadata (the advisory-db is a git checkout).
125                    if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
126                        continue;
127                    }
128                    if depth < crate::safety::MAX_DIR_DEPTH {
129                        stack.push((path, depth + 1));
130                    }
131                } else if ft.is_file() {
132                    // RustSec advisory-db v4 stores advisories as Markdown files
133                    // with a fenced TOML front-matter (`RUSTSEC-*.md`); older /
134                    // alternate layouts use plain `.toml`. Handle both.
135                    match path.extension().and_then(|e| e.to_str()) {
136                        Some("toml") | Some("md") => {
137                            if let Some(adv) = parse_advisory_file(&path)? {
138                                advisories.push(adv);
139                            }
140                        }
141                        _ => {}
142                    }
143                }
144            }
145        }
146        advisories.sort_by(|a, b| a.id.cmp(&b.id));
147        Ok(Self {
148            advisories,
149            missing: false,
150        })
151    }
152
153    /// Resolve the default advisory cache directory (`~/.cargo/advisory-db` if
154    /// present, else a rustinel-specific cache path).
155    pub fn default_cache_dir() -> Option<PathBuf> {
156        let home = std::env::var_os("CARGO_HOME")
157            .map(PathBuf::from)
158            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))?;
159        Some(home.join("advisory-db"))
160    }
161
162    /// Produce risk signals for any locked package matched by an advisory.
163    pub fn match_lockfile(&self, lock: &LockfileModel) -> Vec<RiskSignal> {
164        let mut signals = Vec::new();
165        for package in lock.registry_packages() {
166            // RustSec advisories are crates.io-scoped. A git, path, or
167            // alternate-registry crate that happens to share a name with an
168            // advised crate must not be flagged (matches cargo-audit).
169            if !package.id.is_crates_io() {
170                continue;
171            }
172            let Ok(version) = Version::parse(&package.id.version) else {
173                continue;
174            };
175            for advisory in &self.advisories {
176                if advisory.package != package.id.name {
177                    continue;
178                }
179                if advisory.affects(&version) {
180                    signals.push(advisory.to_signal(&package.id.to_string()));
181                }
182            }
183        }
184        signals
185    }
186}
187
188impl Advisory {
189    /// A version is affected when it is matched by none of the `patched`
190    /// requirements and none of the `unaffected` requirements.
191    pub fn affects(&self, version: &Version) -> bool {
192        if matches_any(&self.patched, version) {
193            return false;
194        }
195        if matches_any(&self.unaffected, version) {
196            return false;
197        }
198        true
199    }
200
201    pub fn severity(&self) -> Severity {
202        if let Some(kind) = &self.informational {
203            // Unmaintained/unsound/notice advisories are lower urgency.
204            return match kind.as_str() {
205                "unsound" => Severity::Medium,
206                _ => Severity::Low,
207            };
208        }
209        match self.cvss_score {
210            Some(s) if s >= 9.0 => Severity::Critical,
211            Some(s) if s >= 7.0 => Severity::High,
212            Some(s) if s >= 4.0 => Severity::Medium,
213            Some(_) => Severity::Low,
214            // A vulnerability advisory with no CVSS still defaults to High.
215            None => Severity::High,
216        }
217    }
218
219    fn weight(&self) -> u8 {
220        match self.severity() {
221            Severity::Critical => 60,
222            Severity::High => 30,
223            Severity::Medium => 15,
224            Severity::Low => 6,
225            Severity::Info => 0,
226        }
227    }
228
229    fn to_signal(&self, package: &str) -> RiskSignal {
230        let summary = if self.title.is_empty() {
231            format!("{} advisory affects this version", self.id)
232        } else {
233            format!("{}: {}", self.id, self.title)
234        };
235        let recommendation = if self.patched.is_empty() {
236            "No patched version is published. Evaluate removing or replacing this dependency."
237                .into()
238        } else {
239            format!("Update to a patched version: {}", self.patched.join(", "))
240        };
241        RiskSignal {
242            id: format!("advisory_{}", self.id),
243            package: package.to_string(),
244            severity: self.severity(),
245            weight: self.weight(),
246            confidence: 1.0,
247            evidence: vec![Evidence::new("advisory", summary)],
248            recommendation,
249        }
250    }
251}
252
253fn matches_any(reqs: &[String], version: &Version) -> bool {
254    reqs.iter().any(|raw| req_matches(raw, version))
255}
256
257/// Does `version` satisfy the requirement string, using the same semantics as
258/// cargo-audit / rustsec (OSV-style bare-version ordering that participates
259/// prereleases) rather than `semver`'s default prerelease-exclusion rule?
260///
261/// `semver::VersionReq::matches` deliberately refuses to match a prerelease
262/// (`1.0.0-rc.1`) against a comparator that lacks a same-`x.y.z` prerelease, so
263/// a bound like `< 1.0.0` would *fail* to exclude `1.0.0-rc.1` — over-reporting
264/// it as affected. rustsec instead orders prereleases normally. We match that:
265/// release versions keep the standard (identical) semantics; prerelease versions
266/// are evaluated comparator-by-comparator with bare ordering.
267fn req_matches(raw: &str, version: &Version) -> bool {
268    let Ok(req) = VersionReq::parse(raw) else {
269        return false;
270    };
271    if version.pre.is_empty() {
272        return req.matches(version);
273    }
274    req.comparators
275        .iter()
276        .all(|c| comparator_matches_bare(c, version))
277}
278
279/// Evaluate a single comparator against a version using bare `Version` ordering
280/// (no prerelease-exclusion), matching rustsec's OSV range comparison.
281fn comparator_matches_bare(c: &Comparator, v: &Version) -> bool {
282    let base = Version {
283        major: c.major,
284        minor: c.minor.unwrap_or(0),
285        patch: c.patch.unwrap_or(0),
286        pre: c.pre.clone(),
287        build: BuildMetadata::EMPTY,
288    };
289    match c.op {
290        Op::Greater => *v > base,
291        Op::GreaterEq => *v >= base,
292        Op::Less => *v < base,
293        Op::LessEq => *v <= base,
294        // Exact comparators are partial-aware: `=1.2` means any `1.2.x`, `=1`
295        // means any `1.x.y` — matching semver's own `=` family semantics, here
296        // applied with bare prerelease ordering like the other arms. (Naive
297        // `*v == base` would zero-fill `=1.2` to `1.2.0` and miss `1.2.5-rc.1`.)
298        Op::Exact => {
299            if v.major != c.major {
300                return false;
301            }
302            let Some(minor) = c.minor else {
303                return true;
304            };
305            if v.minor != minor {
306                return false;
307            }
308            let Some(patch) = c.patch else {
309                return true;
310            };
311            v.patch == patch && v.pre == c.pre
312        }
313        // Caret (`^0.6.4`) appears in backport-patched ranges; expand to its
314        // `[base, upper)` interval and compare with bare ordering.
315        Op::Caret => *v >= base && *v < caret_upper(c),
316        // Tilde / wildcard do not appear in RustSec advisory bounds; defer to the
317        // standard check (correct for releases, conservative for prereleases).
318        _ => VersionReq {
319            comparators: vec![c.clone()],
320        }
321        .matches(v),
322    }
323}
324
325/// Upper (exclusive) bound of a caret comparator, per Cargo's caret rules.
326///
327/// The bound carries a `-0` prerelease so that prereleases of the next-breaking
328/// version are *excluded* from the caret range: `^0.6.4` covers `[0.6.4, 0.7.0-0)`,
329/// so `0.7.0-rc.1` is NOT in `^0.6.4` (it belongs to the 0.7.0 line). This matches
330/// cargo-audit / rustsec, which treats a caret-patched range as ending before any
331/// prerelease of the breaking release.
332fn caret_upper(c: &Comparator) -> Version {
333    let (major, minor, patch);
334    if c.major > 0 {
335        (major, minor, patch) = (c.major + 1, 0, 0);
336    } else if c.minor.unwrap_or(0) > 0 {
337        (major, minor, patch) = (0, c.minor.unwrap_or(0) + 1, 0);
338    } else if c.minor.is_some() && c.patch.is_some() {
339        (major, minor, patch) = (0, 0, c.patch.unwrap_or(0) + 1);
340    } else if c.minor.is_some() {
341        (major, minor, patch) = (0, 1, 0);
342    } else {
343        (major, minor, patch) = (1, 0, 0);
344    }
345    Version {
346        major,
347        minor,
348        patch,
349        // `"0"` is always a valid prerelease; the fallback never triggers.
350        pre: Prerelease::new("0").unwrap_or(Prerelease::EMPTY),
351        build: BuildMetadata::EMPTY,
352    }
353}
354
355fn parse_advisory_file(path: &Path) -> Result<Option<Advisory>, RustinelError> {
356    // Size-capped read; oversized or non-regular files are skipped, never fatal.
357    let content =
358        match crate::safety::read_file_capped(path, crate::safety::MAX_ADVISORY_FILE_BYTES) {
359            Some(c) => c,
360            None => return Ok(None),
361        };
362    // `.md` advisories embed the TOML in a fenced front-matter; `.toml` files are
363    // pure TOML. `extract_toml` returns the TOML body for either form.
364    let toml_src = match extract_toml(&content) {
365        Some(src) => src,
366        None => return Ok(None),
367    };
368    let raw: RawAdvisoryFile = match toml::from_str(&toml_src) {
369        Ok(r) => r,
370        // A non-advisory document in the tree is skipped rather than fatal.
371        Err(_) => return Ok(None),
372    };
373    // Withdrawn advisories are retracted and must not produce findings (matches
374    // cargo-audit's default). Drop them at load so they never match a package.
375    if raw.advisory.withdrawn.is_some() {
376        return Ok(None);
377    }
378    let versions = raw.versions.unwrap_or(RawVersions {
379        patched: vec![],
380        unaffected: vec![],
381    });
382    // RustSec `.md` advisories carry their human title as the first Markdown
383    // heading, not in the TOML. Fall back to that when the TOML title is empty.
384    let title = if raw.advisory.title.is_empty() {
385        extract_md_title(&content).unwrap_or_default()
386    } else {
387        raw.advisory.title
388    };
389    Ok(Some(Advisory {
390        id: raw.advisory.id,
391        package: raw.advisory.package,
392        title,
393        informational: raw.advisory.informational,
394        cvss_score: raw.advisory.cvss.as_deref().and_then(parse_cvss_base_score),
395        patched: versions.patched,
396        unaffected: versions.unaffected,
397    }))
398}
399
400/// Return the TOML body of an advisory document.
401///
402/// - Pure `.toml` content is returned as-is (no fence present).
403/// - `.md` advisories (RustSec v4) wrap the TOML in a fenced block:
404///   ```` ```toml … ``` ```` or, in older layouts, a `+++ … +++` front-matter.
405pub(crate) fn extract_toml(content: &str) -> Option<String> {
406    let trimmed = content.trim_start();
407
408    // Fenced ```toml ... ``` block (anywhere near the top).
409    if let Some(start) = content.find("```toml") {
410        let after = &content[start + "```toml".len()..];
411        if let Some(end) = after.find("```") {
412            return Some(after[..end].trim().to_string());
413        }
414    }
415
416    // `+++` TOML front-matter.
417    if let Some(rest) = trimmed.strip_prefix("+++") {
418        if let Some(end) = rest.find("+++") {
419            return Some(rest[..end].trim().to_string());
420        }
421    }
422
423    // Bare advisory TOML (no fence, has an [advisory] table).
424    if content.contains("[advisory]") {
425        return Some(content.to_string());
426    }
427
428    None
429}
430
431/// Pull the first Markdown H1 (`# Title`) from an advisory document, used as the
432/// human-readable title for `.md` advisories.
433fn extract_md_title(content: &str) -> Option<String> {
434    // Skip the fenced TOML block first so we don't pick up a `#` comment inside it.
435    let body = match content.find("```toml").and_then(|s| {
436        content[s + 7..]
437            .find("```")
438            .map(|e| &content[s + 7 + e + 3..])
439    }) {
440        Some(after_fence) => after_fence,
441        None => content,
442    };
443    for line in body.lines() {
444        let line = line.trim();
445        if let Some(title) = line.strip_prefix("# ") {
446            return Some(title.trim().to_string());
447        }
448    }
449    None
450}
451
452/// Extract the numeric base score if the CVSS field is a bare number. Full CVSS
453/// vector parsing is intentionally not implemented; vectors fall back to the
454/// severity heuristics in [`Advisory::severity`].
455fn parse_cvss_base_score(cvss: &str) -> Option<f32> {
456    cvss.trim().parse::<f32>().ok()
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn adv(patched: &[&str], unaffected: &[&str]) -> Advisory {
464        Advisory {
465            id: "RUSTSEC-2099-0001".into(),
466            package: "vuln".into(),
467            title: "test".into(),
468            informational: None,
469            cvss_score: Some(7.5),
470            patched: patched.iter().map(|s| s.to_string()).collect(),
471            unaffected: unaffected.iter().map(|s| s.to_string()).collect(),
472        }
473    }
474
475    #[test]
476    fn extract_toml_from_markdown_fence() {
477        let md = "```toml\n[advisory]\nid = \"RUSTSEC-2020-0105\"\npackage = \"abi_stable\"\ncvss = \"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H\"\n\n[versions]\npatched = [\">= 0.9.1\"]\n```\n\n# Title\n\nDescription text.\n";
478        let toml_src = extract_toml(md).expect("toml extracted");
479        let raw: RawAdvisoryFile = toml::from_str(&toml_src).unwrap();
480        assert_eq!(raw.advisory.id, "RUSTSEC-2020-0105");
481        assert_eq!(raw.advisory.package, "abi_stable");
482    }
483
484    #[test]
485    fn extract_toml_from_bare_toml() {
486        let src = "[advisory]\nid = \"X\"\npackage = \"p\"\n";
487        assert!(extract_toml(src).is_some());
488    }
489
490    #[test]
491    fn extract_toml_rejects_plain_markdown() {
492        assert!(extract_toml("# Just a readme\n\nNo advisory here.\n").is_none());
493    }
494
495    #[test]
496    fn affected_below_patch() {
497        let a = adv(&[">= 1.2.4"], &[]);
498        assert!(a.affects(&Version::parse("1.2.3").unwrap()));
499        assert!(!a.affects(&Version::parse("1.2.4").unwrap()));
500        assert!(!a.affects(&Version::parse("2.0.0").unwrap()));
501    }
502
503    #[test]
504    fn unaffected_range_excluded() {
505        let a = adv(&[">= 1.2.4"], &["< 1.0.0"]);
506        assert!(!a.affects(&Version::parse("0.9.0").unwrap()));
507        assert!(a.affects(&Version::parse("1.1.0").unwrap()));
508    }
509
510    #[test]
511    fn severity_from_cvss() {
512        let mut a = adv(&[], &[]);
513        a.cvss_score = Some(9.5);
514        assert_eq!(a.severity(), Severity::Critical);
515        a.cvss_score = Some(5.0);
516        assert_eq!(a.severity(), Severity::Medium);
517        a.cvss_score = None;
518        assert_eq!(a.severity(), Severity::High);
519    }
520
521    #[test]
522    fn informational_is_lower_severity() {
523        let mut a = adv(&[], &[]);
524        a.informational = Some("unmaintained".into());
525        assert_eq!(a.severity(), Severity::Low);
526        a.informational = Some("unsound".into());
527        assert_eq!(a.severity(), Severity::Medium);
528    }
529
530    #[test]
531    fn missing_dir_is_not_an_error() {
532        let db = AdvisoryDb::load_from_dir(Path::new("/nonexistent/rustinel/db")).unwrap();
533        assert!(db.missing);
534        assert!(db.is_empty());
535    }
536
537    #[test]
538    fn withdrawn_advisories_are_suppressed() {
539        // A retracted advisory must never be loaded or produce a finding, matching
540        // cargo-audit's default. Regression guard for the `withdrawn` field.
541        let dir = std::env::temp_dir().join("rustinel_withdrawn_db_test");
542        let _ = std::fs::remove_dir_all(&dir);
543        std::fs::create_dir_all(&dir).unwrap();
544        let withdrawn = "[advisory]\nid = \"RUSTSEC-2025-0007\"\npackage = \"ring\"\n\
545            informational = \"unmaintained\"\nwithdrawn = \"2025-02-22\"\n";
546        let active = "[advisory]\nid = \"RUSTSEC-2099-0001\"\npackage = \"vuln-crate\"\n\
547            cvss = \"7.5\"\n\n[versions]\npatched = [\">= 1.0.2\"]\n";
548        std::fs::write(dir.join("withdrawn.toml"), withdrawn).unwrap();
549        std::fs::write(dir.join("active.toml"), active).unwrap();
550
551        let db = AdvisoryDb::load_from_dir(&dir).unwrap();
552        let _ = std::fs::remove_dir_all(&dir);
553
554        // Only the active advisory survives the load.
555        assert_eq!(db.len(), 1, "withdrawn advisory must be dropped at load");
556        assert!(
557            db.advisories.iter().all(|a| a.id != "RUSTSEC-2025-0007"),
558            "withdrawn advisory must not be present"
559        );
560        assert!(db.advisories.iter().any(|a| a.id == "RUSTSEC-2099-0001"));
561    }
562
563    #[test]
564    fn multi_range_patched_real_shape() {
565        // Real shape from RUSTSEC-2026-0098 (rustls-webpki): two patched ranges
566        // with a gap. A version below the first patch is affected; a version in
567        // either patched range is not.
568        let a = adv(
569            &[">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"],
570            &[],
571        );
572        assert!(
573            a.affects(&Version::parse("0.103.10").unwrap()),
574            "below patch"
575        );
576        assert!(
577            !a.affects(&Version::parse("0.103.12").unwrap()),
578            "first patch"
579        );
580        assert!(
581            !a.affects(&Version::parse("0.104.0-alpha.6").unwrap()),
582            "second patch"
583        );
584    }
585
586    #[test]
587    fn prerelease_in_gap_is_affected() {
588        // A prerelease that falls between two patched ranges is still affected.
589        let a = adv(
590            &[">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"],
591            &[],
592        );
593        assert!(a.affects(&Version::parse("0.104.0-alpha.3").unwrap()));
594    }
595
596    #[test]
597    fn no_version_ranges_affects_all() {
598        // An advisory with no patched/unaffected (e.g. unmaintained) applies to
599        // every version of the crate.
600        let a = adv(&[], &[]);
601        assert!(a.affects(&Version::parse("0.1.0").unwrap()));
602        assert!(a.affects(&Version::parse("99.0.0").unwrap()));
603    }
604
605    #[test]
606    fn malformed_version_req_does_not_panic() {
607        // A malformed range string must be ignored (treated as non-matching),
608        // never panic — robustness against a corrupt advisory entry.
609        let a = adv(&["not a semver req"], &["also <<>> bad"]);
610        // Neither bound parses, so the version is not excluded -> affected.
611        assert!(a.affects(&Version::parse("1.0.0").unwrap()));
612    }
613
614    #[test]
615    fn prerelease_excluded_by_unaffected_bound() {
616        // Real divergence fixed: cargo-audit/OSV orders prereleases normally, so
617        // `1.0.0-rc.1` is `< 1.0.0` and thus unaffected. semver's VersionReq would
618        // refuse to match `< 1.0.0` against the prerelease and over-report it.
619        let a = adv(&[], &["< 1.0.0"]);
620        assert!(!a.affects(&Version::parse("1.0.0-rc.1").unwrap()));
621        // Same shape as the `time 0.2.23-rc.1` / unaffected `< 0.3.6` case.
622        let b = adv(&[">= 0.3.6"], &["< 0.3.6"]);
623        assert!(!b.affects(&Version::parse("0.2.23-rc.1").unwrap()));
624    }
625
626    #[test]
627    fn partial_exact_bound_covers_prereleases() {
628        // `=1.2` means "any 1.2.x". Before the fix it zero-filled to `1.2.0`, so a
629        // prerelease of that line was wrongly reported as affected (false positive).
630        let a = adv(&[], &["= 1.2"]);
631        assert!(
632            !a.affects(&Version::parse("1.2.5-rc.1").unwrap()),
633            "=1.2 must cover 1.2.5-rc.1"
634        );
635        assert!(
636            a.affects(&Version::parse("1.3.0-rc.1").unwrap()),
637            "=1.2 must not cover 1.3.0-rc.1"
638        );
639        // `=1` means "any 1.x.y".
640        let b = adv(&[], &["= 1"]);
641        assert!(!b.affects(&Version::parse("1.7.0-rc.1").unwrap()));
642        assert!(b.affects(&Version::parse("2.0.0-rc.1").unwrap()));
643        // A full `=1.2.3` still excludes the prerelease of that exact version.
644        let c = adv(&[], &["= 1.2.3"]);
645        assert!(c.affects(&Version::parse("1.2.3-rc.1").unwrap()));
646    }
647
648    #[test]
649    fn prerelease_in_affected_range_still_flags() {
650        // No false negative: a prerelease genuinely inside the affected window
651        // (unaffected `< 0.1.0`, patched `>= 0.3.0`) is still reported.
652        let a = adv(&[">= 0.3.0"], &["< 0.1.0"]);
653        assert!(a.affects(&Version::parse("0.2.0-rc.1").unwrap()));
654    }
655
656    #[test]
657    fn prerelease_against_caret_patched_bound() {
658        // Caret backport-patch range with a prerelease version, via bare ordering:
659        // `^0.6.4` == `[0.6.4, 0.7.0)`. `0.6.5-rc.1` is inside -> patched -> not
660        // affected; `0.6.4-rc.1` is below 0.6.4 -> not patched.
661        let a = adv(&["^0.6.4", ">= 0.7.1"], &[]);
662        assert!(!a.affects(&Version::parse("0.6.5-rc.1").unwrap()));
663        assert!(a.affects(&Version::parse("0.6.4-rc.1").unwrap()));
664        // A prerelease of the next breaking release (0.7.0-rc.1) is NOT covered by
665        // `^0.6.4` and is below `>= 0.7.1`, so it is affected — matches cargo-audit
666        // on the real string-interner RUSTSEC-2019-0023 case.
667        assert!(a.affects(&Version::parse("0.7.0-rc.1").unwrap()));
668    }
669
670    #[test]
671    fn advisories_only_match_crates_io_source() {
672        // A git/path/alternate-registry crate sharing a name with an advised
673        // crates.io crate must NOT be flagged (matches cargo-audit).
674        use crate::lockfile::{LockfileModel, Package, PackageId};
675
676        let mk = |source: Option<&str>| Package {
677            id: PackageId {
678                name: "vuln".into(),
679                version: "1.0.0".into(),
680                source: source.map(|s| s.to_string()),
681            },
682            checksum: None,
683            dependencies: vec![],
684        };
685        let lock = LockfileModel {
686            path: "Cargo.lock".into(),
687            version: Some(3),
688            packages: vec![
689                mk(Some(crate::lockfile::CRATES_IO_REGISTRY)),
690                mk(Some("git+https://github.com/attacker/notvuln#abc")),
691                mk(Some("registry+https://internal.corp/private-index")),
692            ],
693        };
694
695        let mut db = AdvisoryDb::empty();
696        db.advisories.push(adv(&[], &[])); // affects all versions of `vuln`
697
698        let signals = db.match_lockfile(&lock);
699        // Only the crates.io-sourced package is matched.
700        assert_eq!(signals.len(), 1, "only crates.io source should match");
701    }
702}