Skip to main content

mollify_core/
supplychain.rs

1//! Supply-chain analysis: cross-reference **pinned/locked dependency versions**
2//! against a local **advisory database** and flag versions that fall in a known
3//! vulnerable range (`vulnerable-dependency`).
4//!
5//! Determinism is preserved by design: the advisory DB is an *input file*, never
6//! a live network call. Same `(lockfile, advisory-db)` → byte-identical output.
7//! Refresh the DB out-of-band with `scripts/fetch-advisories.py` (which pulls
8//! from OSV / safety-db). Mollify itself never reaches the network.
9
10use crate::fingerprint::fingerprint;
11use crate::installed::Installed;
12use crate::known::normalize_dist;
13use crate::version::{matches_spec, specs_intersect};
14use camino::Utf8Path;
15use mollify_types::{Action, Category, Confidence, Finding, Location, Severity};
16use serde::{Deserialize, Serialize};
17
18/// One advisory in the normalized `mollify-advisories/1` schema.
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct Advisory {
21    pub id: String,
22    pub package: String,
23    /// Affected version specs; ANY matching spec ⇒ vulnerable (OR).
24    #[serde(default)]
25    pub specs: Vec<String>,
26    #[serde(default)]
27    pub summary: String,
28    #[serde(default)]
29    pub aliases: Vec<String>,
30    #[serde(default)]
31    pub severity: Option<String>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35struct AdvisoryDb {
36    #[serde(default)]
37    advisories: Vec<Advisory>,
38}
39
40/// A concrete, resolved dependency version found in a lockfile / pin.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PinnedDep {
43    pub name: String,
44    pub version: String,
45    pub source: camino::Utf8PathBuf,
46    pub line: u32,
47}
48
49/// Load and index the advisory DB at `db_path` (or `None` if unreadable/invalid).
50pub fn load_db(db_path: &Utf8Path) -> Option<Vec<Advisory>> {
51    let text = std::fs::read_to_string(db_path).ok()?;
52    let db: AdvisoryDb = serde_json::from_str(&text).ok()?;
53    Some(db.advisories)
54}
55
56/// A declared dependency *constraint* (range), e.g. `requests>=2.0,<3` — as
57/// opposed to a concrete pin. Empty `spec` means an unconstrained dependency.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DeclaredRange {
60    pub name: String,
61    pub spec: String,
62    pub source: camino::Utf8PathBuf,
63    pub line: u32,
64}
65
66/// Analyze a project: match pinned versions, installed versions, and declared
67/// ranges against `advisories`.
68pub fn analyze(root: &Utf8Path, advisories: &[Advisory]) -> Vec<Finding> {
69    let pins = collect_pins(root);
70    let pinned: rustc_hash::FxHashSet<String> = pins.iter().map(|p| p.name.clone()).collect();
71    let ranges = collect_declared_ranges(root);
72    let installed = crate::installed::discover(root);
73
74    let mut findings = analyze_pins(&pins, advisories);
75    findings.extend(analyze_declared(
76        &ranges,
77        advisories,
78        installed.as_ref(),
79        &pinned,
80    ));
81    findings.sort_by(|a, b| {
82        a.location
83            .path
84            .cmp(&b.location.path)
85            .then(a.location.line.cmp(&b.location.line))
86            .then(a.reason.cmp(&b.reason))
87    });
88    findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
89    findings
90}
91
92/// Match declared *ranges* against advisories. A range is resolved precisely
93/// when the package is installed (concrete version → `Certain`); otherwise we
94/// flag when the declared range **intersects** a vulnerable range (`Uncertain`:
95/// the range permits a vulnerable version, though it may resolve to a safe one).
96pub fn analyze_declared(
97    ranges: &[DeclaredRange],
98    advisories: &[Advisory],
99    installed: Option<&Installed>,
100    pinned: &rustc_hash::FxHashSet<String>,
101) -> Vec<Finding> {
102    let mut findings = Vec::new();
103    let mut seen: rustc_hash::FxHashSet<(String, String, String)> =
104        rustc_hash::FxHashSet::default();
105    for dep in ranges {
106        if pinned.contains(&dep.name) {
107            continue; // an exact pin already covers this package, precisely
108        }
109        let installed_ver = installed.and_then(|i| i.versions.get(&dep.name).cloned());
110        for adv in advisories {
111            if normalize_dist(&adv.package) != dep.name {
112                continue;
113            }
114            let alias = adv
115                .aliases
116                .iter()
117                .find(|a| a.starts_with("CVE-"))
118                .cloned()
119                .unwrap_or_else(|| adv.id.clone());
120            let summary = if adv.summary.is_empty() {
121                String::new()
122            } else {
123                format!(" — {}", adv.summary)
124            };
125
126            let (matched, version_key, confidence, reason) = if let Some(ver) = &installed_ver {
127                // Resolve the range to the concrete installed version.
128                let hit = adv.specs.is_empty() || adv.specs.iter().any(|s| matches_spec(ver, s));
129                (
130                    hit,
131                    ver.clone(),
132                    Confidence::Certain,
133                    format!(
134                        "`{}` {ver} (installed, declared `{}`) is affected by {alias}{summary}",
135                        dep.name, dep.spec
136                    ),
137                )
138            } else if dep.spec.is_empty() {
139                (false, String::new(), Confidence::Uncertain, String::new())
140            } else {
141                // No concrete version — does the declared range permit a vulnerable one?
142                let hit = adv.specs.iter().any(|s| specs_intersect(&dep.spec, s));
143                (
144                    hit,
145                    dep.spec.clone(),
146                    Confidence::Uncertain,
147                    format!(
148                        "declared range `{} {}` permits a version affected by {alias}{summary}; pin or constrain above the fix",
149                        dep.name, dep.spec
150                    ),
151                )
152            };
153            if !matched {
154                continue;
155            }
156            if !seen.insert((dep.name.clone(), version_key.clone(), alias.clone())) {
157                continue;
158            }
159            let rule = "vulnerable-dependency";
160            findings.push(Finding {
161                fingerprint: fingerprint(rule, &[&dep.name, &version_key, &adv.id]),
162                rule: rule.into(),
163                category: Category::Security,
164                severity: Severity::Warn,
165                confidence,
166                attribution: None,
167                reason,
168                location: Location {
169                    path: dep.source.clone(),
170                    line: dep.line,
171                    column: 0,
172                    end_line: None,
173                },
174                actions: vec![Action {
175                    kind: "upgrade-dependency".into(),
176                    description: format!(
177                        "Constrain `{}` out of the affected range for {} ({alias}).",
178                        dep.name, adv.id
179                    ),
180                    auto_fixable: false,
181                    suppression_comment: Some("# mollify: ignore[vulnerable-dependency]".into()),
182                }],
183            });
184        }
185    }
186    findings
187}
188
189/// Match a set of pinned deps against advisories (pure; testable).
190pub fn analyze_pins(pins: &[PinnedDep], advisories: &[Advisory]) -> Vec<Finding> {
191    let mut findings = Vec::new();
192    // The same CVE is often published under several advisory ids (GHSA + PYSEC);
193    // collapse them so each (package, version, CVE) is reported once.
194    let mut seen: rustc_hash::FxHashSet<(String, String, String)> =
195        rustc_hash::FxHashSet::default();
196    for pin in pins {
197        for adv in advisories {
198            if normalize_dist(&adv.package) != pin.name {
199                continue;
200            }
201            // ANY affected spec matching the pinned version ⇒ vulnerable.
202            // An advisory with no specs is treated as "all versions affected".
203            let hit =
204                adv.specs.is_empty() || adv.specs.iter().any(|s| matches_spec(&pin.version, s));
205            if !hit {
206                continue;
207            }
208            let rule = "vulnerable-dependency";
209            let alias = adv
210                .aliases
211                .iter()
212                .find(|a| a.starts_with("CVE-"))
213                .cloned()
214                .unwrap_or_else(|| adv.id.clone());
215            if !seen.insert((pin.name.clone(), pin.version.clone(), alias.clone())) {
216                continue; // same CVE already reported for this pin
217            }
218            let summary = if adv.summary.is_empty() {
219                String::new()
220            } else {
221                format!(" — {}", adv.summary)
222            };
223            findings.push(Finding {
224                fingerprint: fingerprint(rule, &[&pin.name, &pin.version, &adv.id]),
225                rule: rule.into(),
226                category: Category::Security,
227                severity: Severity::Warn,
228                confidence: Confidence::Certain,
229                attribution: None,
230                reason: format!(
231                    "`{}` {} is affected by {alias}{summary}",
232                    pin.name, pin.version
233                ),
234                location: Location {
235                    path: pin.source.clone(),
236                    line: pin.line,
237                    column: 0,
238                    end_line: None,
239                },
240                actions: vec![Action {
241                    kind: "upgrade-dependency".into(),
242                    description: format!(
243                        "Upgrade `{}` out of the affected range for {} ({alias}).",
244                        pin.name, adv.id
245                    ),
246                    auto_fixable: false,
247                    suppression_comment: Some("# mollify: ignore[vulnerable-dependency]".into()),
248                }],
249            });
250        }
251    }
252    findings.sort_by(|a, b| {
253        a.location
254            .path
255            .cmp(&b.location.path)
256            .then(a.reason.cmp(&b.reason))
257    });
258    findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
259    findings
260}
261
262/// Collect concrete (name, version) pins from common lock/pin files.
263pub fn collect_pins(root: &Utf8Path) -> Vec<PinnedDep> {
264    let mut pins = Vec::new();
265    // requirements*.txt — `name==version` lines.
266    for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
267        let name = entry.file_name();
268        let name = name.to_string_lossy();
269        if name.starts_with("requirements") && name.ends_with(".txt") {
270            if let Ok(p) = camino::Utf8PathBuf::from_path_buf(entry.path()) {
271                parse_requirements(&p, &mut pins);
272            }
273        }
274    }
275    // poetry.lock / uv.lock — TOML [[package]] tables.
276    for lock in ["poetry.lock", "uv.lock"] {
277        let p = root.join(lock);
278        if p.exists() {
279            parse_toml_lock(&p, &mut pins);
280        }
281    }
282    pins.sort_by(|a, b| a.name.cmp(&b.name).then(a.version.cmp(&b.version)));
283    pins.dedup();
284    pins
285}
286
287fn parse_requirements(path: &Utf8Path, out: &mut Vec<PinnedDep>) {
288    let Ok(text) = std::fs::read_to_string(path) else {
289        return;
290    };
291    for (i, raw) in text.lines().enumerate() {
292        let line = raw.split('#').next().unwrap_or("").trim();
293        if line.is_empty() || line.starts_with('-') {
294            continue;
295        }
296        // Only exact pins are unambiguous: `name==1.2.3` (drop extras/markers).
297        let Some((name_part, rest)) = line.split_once("==") else {
298            continue;
299        };
300        let name = normalize_dist(name_part.split('[').next().unwrap_or(name_part).trim());
301        let version = rest
302            .split([';', ' ', ','])
303            .next()
304            .unwrap_or("")
305            .trim()
306            .to_string();
307        if !name.is_empty() && !version.is_empty() {
308            out.push(PinnedDep {
309                name,
310                version,
311                source: path.to_owned(),
312                line: i as u32 + 1,
313            });
314        }
315    }
316}
317
318fn parse_toml_lock(path: &Utf8Path, out: &mut Vec<PinnedDep>) {
319    let Ok(text) = std::fs::read_to_string(path) else {
320        return;
321    };
322    let Ok(table) = text.parse::<toml::Table>() else {
323        return;
324    };
325    let Some(pkgs) = table.get("package").and_then(|p| p.as_array()) else {
326        return;
327    };
328    for pkg in pkgs {
329        let (Some(name), Some(version)) = (
330            pkg.get("name").and_then(|v| v.as_str()),
331            pkg.get("version").and_then(|v| v.as_str()),
332        ) else {
333            continue;
334        };
335        out.push(PinnedDep {
336            name: normalize_dist(name),
337            version: version.to_string(),
338            source: path.to_owned(),
339            line: 1,
340        });
341    }
342}
343
344/// Collect declared dependency *constraints* (ranges) from `requirements*.txt`
345/// and `pyproject.toml` (PEP 621 `[project].dependencies` + Poetry).
346pub fn collect_declared_ranges(root: &Utf8Path) -> Vec<DeclaredRange> {
347    let mut out = Vec::new();
348    for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
349        let name = entry.file_name();
350        let name = name.to_string_lossy();
351        if name.starts_with("requirements") && name.ends_with(".txt") {
352            if let Ok(p) = camino::Utf8PathBuf::from_path_buf(entry.path()) {
353                if let Ok(text) = std::fs::read_to_string(&p) {
354                    for (i, raw) in text.lines().enumerate() {
355                        let line = raw.split('#').next().unwrap_or("").trim();
356                        if line.is_empty() || line.starts_with('-') {
357                            continue;
358                        }
359                        if let Some((name, spec)) = split_requirement(line) {
360                            out.push(DeclaredRange {
361                                name,
362                                spec,
363                                source: p.clone(),
364                                line: i as u32 + 1,
365                            });
366                        }
367                    }
368                }
369            }
370        }
371    }
372    let pp = root.join("pyproject.toml");
373    if pp.exists() {
374        parse_pyproject_ranges(&pp, &mut out);
375    }
376    out.sort_by(|a, b| a.name.cmp(&b.name).then(a.spec.cmp(&b.spec)));
377    out.dedup();
378    out
379}
380
381/// Split a requirement string into `(normalized_name, pep440_spec)`. Markers
382/// and extras are dropped; a bare dependency yields an empty spec.
383fn split_requirement(line: &str) -> Option<(String, String)> {
384    let line = line.split(';').next().unwrap_or("").trim();
385    if line.is_empty() {
386        return None;
387    }
388    match line.find(['<', '>', '=', '!', '~']) {
389        Some(pos) => {
390            let name = line[..pos].split('[').next().unwrap_or("").trim();
391            let spec = line[pos..].trim().to_string();
392            if name.is_empty() {
393                None
394            } else {
395                Some((normalize_dist(name), spec))
396            }
397        }
398        None => {
399            let name = line.split('[').next().unwrap_or("").trim();
400            if name.is_empty() {
401                None
402            } else {
403                Some((normalize_dist(name), String::new()))
404            }
405        }
406    }
407}
408
409fn parse_pyproject_ranges(path: &Utf8Path, out: &mut Vec<DeclaredRange>) {
410    let Ok(text) = std::fs::read_to_string(path) else {
411        return;
412    };
413    let Ok(table) = text.parse::<toml::Table>() else {
414        return;
415    };
416    // PEP 621: [project].dependencies = ["name>=1.0", ...]
417    if let Some(deps) = table
418        .get("project")
419        .and_then(|p| p.get("dependencies"))
420        .and_then(|d| d.as_array())
421    {
422        for d in deps {
423            if let Some(s) = d.as_str() {
424                if let Some((name, spec)) = split_requirement(s) {
425                    out.push(DeclaredRange {
426                        name,
427                        spec,
428                        source: path.to_owned(),
429                        line: 1,
430                    });
431                }
432            }
433        }
434    }
435    // Poetry: [tool.poetry.dependencies] name = "^1.2" / ">=1.0" / { version = ".." }
436    if let Some(deps) = table
437        .get("tool")
438        .and_then(|t| t.get("poetry"))
439        .and_then(|p| p.get("dependencies"))
440        .and_then(|d| d.as_table())
441    {
442        for (name, val) in deps {
443            if name.eq_ignore_ascii_case("python") {
444                continue;
445            }
446            let raw = val.as_str().map(|s| s.to_string()).or_else(|| {
447                val.get("version")
448                    .and_then(|v| v.as_str())
449                    .map(String::from)
450            });
451            if let Some(raw) = raw {
452                out.push(DeclaredRange {
453                    name: normalize_dist(name),
454                    spec: poetry_to_pep440(&raw),
455                    source: path.to_owned(),
456                    line: 1,
457                });
458            }
459        }
460    }
461}
462
463/// Convert a Poetry caret/tilde constraint to a PEP 440 range. Plain PEP 440
464/// specs pass through; `*` / unrecognized → empty (any).
465fn poetry_to_pep440(spec: &str) -> String {
466    let s = spec.trim();
467    if s == "*" || s.is_empty() {
468        return String::new();
469    }
470    if let Some(rest) = s.strip_prefix('^') {
471        // ^X.Y.Z → >=X.Y.Z,<next-significant. Leading zeros tighten the bound.
472        let parts: Vec<u64> = rest
473            .split('.')
474            .map(|p| p.parse::<u64>().unwrap_or(0))
475            .collect();
476        if parts.is_empty() {
477            return String::new();
478        }
479        let upper = caret_upper(&parts);
480        return format!(">={rest},<{upper}");
481    }
482    if let Some(rest) = s.strip_prefix('~') {
483        // ~X.Y → >=X.Y,<X.(Y+1); ~X → >=X,<(X+1)
484        let parts: Vec<u64> = rest
485            .split('.')
486            .map(|p| p.parse::<u64>().unwrap_or(0))
487            .collect();
488        let upper = match parts.len() {
489            0 => return String::new(),
490            1 => format!("{}", parts[0] + 1),
491            _ => format!("{}.{}", parts[0], parts[1] + 1),
492        };
493        return format!(">={rest},<{upper}");
494    }
495    // Already a PEP 440 specifier (or bare version).
496    s.to_string()
497}
498
499/// Caret upper bound: bump the first non-zero component (Poetry/SemVer rule).
500fn caret_upper(parts: &[u64]) -> String {
501    for (i, &p) in parts.iter().enumerate() {
502        if p != 0 {
503            let mut bumped = parts[..=i].to_vec();
504            bumped[i] += 1;
505            for b in bumped.iter_mut().skip(i + 1) {
506                *b = 0;
507            }
508            return bumped
509                .iter()
510                .map(|n| n.to_string())
511                .collect::<Vec<_>>()
512                .join(".");
513        }
514    }
515    // All zeros (`^0.0.0`) → next patch.
516    let mut v = parts.to_vec();
517    if let Some(last) = v.last_mut() {
518        *last += 1;
519    }
520    v.iter()
521        .map(|n| n.to_string())
522        .collect::<Vec<_>>()
523        .join(".")
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use camino::Utf8PathBuf;
530
531    fn temp(tag: &str) -> Utf8PathBuf {
532        let base =
533            std::env::temp_dir().join(format!("mollify-core-sc-{}-{tag}", std::process::id()));
534        let _ = std::fs::remove_dir_all(&base);
535        std::fs::create_dir_all(&base).unwrap();
536        Utf8PathBuf::from_path_buf(base).unwrap()
537    }
538
539    fn adv(id: &str, pkg: &str, specs: &[&str]) -> Advisory {
540        Advisory {
541            id: id.into(),
542            package: pkg.into(),
543            specs: specs.iter().map(|s| s.to_string()).collect(),
544            summary: "test advisory".into(),
545            aliases: vec!["CVE-2020-00000".into()],
546            severity: Some("high".into()),
547        }
548    }
549
550    #[test]
551    fn flags_pinned_vulnerable_version() {
552        let pins = vec![
553            PinnedDep {
554                name: "jinja2".into(),
555                version: "2.4.1".into(),
556                source: "requirements.txt".into(),
557                line: 3,
558            },
559            PinnedDep {
560                name: "jinja2".into(),
561                version: "3.1.5".into(),
562                source: "requirements.txt".into(),
563                line: 4,
564            },
565        ];
566        let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
567        let f = analyze_pins(&pins, &advisories);
568        assert_eq!(f.len(), 1, "got {f:?}");
569        assert!(f[0].reason.contains("2.4.1"));
570        assert!(f[0].reason.contains("CVE-2020-00000"));
571    }
572
573    #[test]
574    fn parses_requirements_and_lock() {
575        let d = temp("pins");
576        std::fs::write(
577            d.join("requirements.txt"),
578            "# comment\nDjango==3.2.0\nrequests>=2.0  # range, skipped\nflask==2.0.1 ; python_version>='3.7'\n",
579        )
580        .unwrap();
581        std::fs::write(
582            d.join("poetry.lock"),
583            "[[package]]\nname = \"urllib3\"\nversion = \"1.26.4\"\n",
584        )
585        .unwrap();
586        let pins = collect_pins(&d);
587        assert!(pins
588            .iter()
589            .any(|p| p.name == "django" && p.version == "3.2.0"));
590        assert!(pins
591            .iter()
592            .any(|p| p.name == "flask" && p.version == "2.0.1"));
593        assert!(pins
594            .iter()
595            .any(|p| p.name == "urllib3" && p.version == "1.26.4"));
596        assert!(
597            !pins.iter().any(|p| p.name == "requests"),
598            "ranges not pinned"
599        );
600        std::fs::remove_dir_all(&d).ok();
601    }
602
603    #[test]
604    fn loads_db_from_disk() {
605        let d = temp("db");
606        let db = d.join("adv.json");
607        std::fs::write(
608            &db,
609            r#"{"schema":"mollify-advisories/1","advisories":[{"id":"PYSEC-9","package":"flask","specs":["<2.0.0"],"summary":"x","aliases":["CVE-1"]}]}"#,
610        )
611        .unwrap();
612        let advisories = load_db(&db).unwrap();
613        assert_eq!(advisories.len(), 1);
614        assert_eq!(advisories[0].package, "flask");
615        std::fs::remove_dir_all(&d).ok();
616    }
617
618    #[test]
619    fn flags_declared_range_that_permits_vulnerable() {
620        let ranges = vec![DeclaredRange {
621            name: "jinja2".into(),
622            spec: ">=2.0".into(),
623            source: "requirements.txt".into(),
624            line: 2,
625        }];
626        let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
627        let f = analyze_declared(&ranges, &advisories, None, &Default::default());
628        assert_eq!(f.len(), 1, "got {f:?}");
629        assert!(matches!(f[0].confidence, Confidence::Uncertain));
630        assert!(f[0].reason.contains("permits"), "{}", f[0].reason);
631    }
632
633    #[test]
634    fn declared_range_above_fix_is_clean() {
635        let ranges = vec![DeclaredRange {
636            name: "jinja2".into(),
637            spec: ">=2.11.3".into(),
638            source: "requirements.txt".into(),
639            line: 2,
640        }];
641        let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
642        let f = analyze_declared(&ranges, &advisories, None, &Default::default());
643        assert!(
644            f.is_empty(),
645            "range entirely above the fix should be clean: {f:?}"
646        );
647    }
648
649    #[test]
650    fn installed_version_resolves_range_precisely() {
651        let mut versions = rustc_hash::FxHashMap::default();
652        versions.insert("jinja2".to_string(), "2.4.1".to_string());
653        let inst = Installed {
654            versions,
655            ..Default::default()
656        };
657        let ranges = vec![DeclaredRange {
658            name: "jinja2".into(),
659            spec: ">=2.0".into(),
660            source: "pyproject.toml".into(),
661            line: 1,
662        }];
663        let advisories = vec![adv("PYSEC-1", "Jinja2", &["<2.11.3"])];
664        let f = analyze_declared(&ranges, &advisories, Some(&inst), &Default::default());
665        assert_eq!(f.len(), 1, "got {f:?}");
666        assert!(matches!(f[0].confidence, Confidence::Certain));
667        assert!(f[0].reason.contains("2.4.1") && f[0].reason.contains("installed"));
668    }
669
670    #[test]
671    fn collects_declared_ranges_from_requirements_and_pyproject() {
672        let d = temp("ranges");
673        std::fs::write(d.join("requirements.txt"), "requests>=2.0,<3\nflask\n").unwrap();
674        std::fs::write(
675            d.join("pyproject.toml"),
676            "[project]\ndependencies = [\"urllib3>=1.0\"]\n[tool.poetry.dependencies]\npython = \"^3.9\"\nclick = \"^8.1\"\n",
677        )
678        .unwrap();
679        let r = collect_declared_ranges(&d);
680        assert!(r
681            .iter()
682            .any(|x| x.name == "requests" && x.spec == ">=2.0,<3"));
683        assert!(r.iter().any(|x| x.name == "flask" && x.spec.is_empty()));
684        assert!(r.iter().any(|x| x.name == "urllib3" && x.spec == ">=1.0"));
685        // Poetry caret converted to a PEP 440 range; `python` excluded.
686        assert!(r.iter().any(|x| x.name == "click" && x.spec == ">=8.1,<9"));
687        assert!(!r.iter().any(|x| x.name == "python"));
688        std::fs::remove_dir_all(&d).ok();
689    }
690}