use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{json, Value};
#[derive(Clone, Debug)]
pub struct Component {
pub name: String,
pub version: String,
pub license: String,
}
#[derive(Clone)]
pub struct Vuln {
pub crate_name: String,
pub version: String,
pub ids: Vec<String>,
pub summary: String,
}
pub struct SecurityReport {
pub repo: String,
pub components: Vec<Component>,
pub vulns: Vec<Vuln>,
}
fn purl(name: &str, version: &str) -> String {
format!("pkg:cargo/{name}@{version}")
}
impl SecurityReport {
pub fn license_tally(&self) -> Vec<(String, usize)> {
let mut m: BTreeMap<String, usize> = BTreeMap::new();
for c in &self.components {
*m.entry(c.license.clone()).or_default() += 1;
}
let mut v: Vec<_> = m.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v
}
pub fn vuln_count(&self) -> usize {
self.vulns.iter().map(|v| v.ids.len()).sum()
}
pub fn to_cyclonedx(&self) -> Value {
json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": { "component": { "type": "application", "name": self.repo } },
"components": self.components.iter().map(|c| json!({
"type": "library",
"name": c.name,
"version": c.version,
"purl": purl(&c.name, &c.version),
"licenses": [{ "license": { "name": c.license } }],
})).collect::<Vec<_>>(),
"vulnerabilities": self.vulns.iter().flat_map(|v| {
let (name, ver) = (v.crate_name.clone(), v.version.clone());
let summary = v.summary.clone();
v.ids.iter().map(move |id| json!({
"id": id,
"source": { "name": "OSV", "url": format!("https://osv.dev/vulnerability/{id}") },
"affects": [{ "ref": purl(&name, &ver) }],
"description": summary,
})).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
})
}
}
pub fn components(repo: &Path) -> Result<(String, Vec<Component>)> {
let repo_name = repo.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
if !repo.is_dir() {
anyhow::bail!(
"no checkout for `{repo_name}` at {} — is the repo name/case right and the workspace synced?",
repo.display()
);
}
let meta_err = match cargo_metadata_components(repo) {
Ok(c) => return Ok((repo_name, c)),
Err(e) => e,
};
eprintln!("nornir-security: cargo metadata unavailable ({meta_err:#}); using Cargo.lock");
let lock = repo.join("Cargo.lock");
if lock.is_file() {
return Ok((repo_name, cargo_lock_components(repo)?));
}
if cargo_available() {
let gen = Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(repo)
.output()
.context("spawn cargo generate-lockfile")?;
if gen.status.success() && lock.is_file() {
return Ok((repo_name, cargo_lock_components(repo)?));
}
anyhow::bail!(
"could not resolve dependencies for `{repo_name}`: cargo metadata failed ({meta_err}) \
and `cargo generate-lockfile` could not produce a lockfile:\n{}",
String::from_utf8_lossy(&gen.stderr)
);
}
anyhow::bail!(
"no SBOM source for `{repo_name}`: `cargo` is not on PATH (so cargo metadata / \
generate-lockfile can't run) and no Cargo.lock is committed (library crates gitignore it). \
Install a cargo toolchain for the scan host, or commit a Cargo.lock."
)
}
fn cargo_available() -> bool {
Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn cargo_metadata_components(repo: &Path) -> Result<Vec<Component>> {
let out = Command::new("cargo")
.args(["metadata", "--format-version", "1"])
.current_dir(repo)
.output()
.context("spawn cargo metadata")?;
if !out.status.success() {
anyhow::bail!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr));
}
let md: Value = serde_json::from_slice(&out.stdout).context("parse cargo metadata")?;
Ok(md["packages"]
.as_array()
.map(|a| {
a.iter()
.map(|p| Component {
name: p["name"].as_str().unwrap_or_default().to_string(),
version: p["version"].as_str().unwrap_or_default().to_string(),
license: p["license"].as_str().unwrap_or("NOASSERTION").to_string(),
})
.collect()
})
.unwrap_or_default())
}
fn cargo_lock_components(repo: &Path) -> Result<Vec<Component>> {
let path = repo.join("Cargo.lock");
let text = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.lock")?;
Ok(doc
.get("package")
.and_then(|p| p.as_array())
.map(|pkgs| {
pkgs.iter()
.filter_map(|p| {
Some(Component {
name: p.get("name")?.as_str()?.to_string(),
version: p.get("version")?.as_str()?.to_string(),
license: "NOASSERTION".to_string(),
})
})
.collect()
})
.unwrap_or_default())
}
pub fn components_warehouse_first(
wh: &crate::warehouse::iceberg::IcebergWarehouse,
repo: &Path,
) -> Result<(String, Vec<Component>)> {
let repo_name = repo.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
match wh.query_sbom_components(&repo_name) {
Ok(Some(rows)) if !rows.is_empty() => {
let comps = rows
.into_iter()
.map(|r| Component { name: r.name, version: r.version, license: r.license })
.collect();
Ok((repo_name, comps))
}
_ => components(repo),
}
}
pub fn warm(
wh: &crate::warehouse::iceberg::IcebergWarehouse,
repo: &Path,
scan_root: Option<&Path>,
) -> Result<(String, Vec<Component>)> {
if let Some(root) = scan_root {
let (cloned, linked) = prepare_path_deps(repo, root, &|_| None);
if cloned + linked > 0 {
eprintln!(
"nornir-security: prepared path-deps for {} ({cloned} cloned, {linked} linked)",
repo.display()
);
}
}
let (repo_name, comps) = components(repo)?;
let rows: Vec<crate::warehouse::iceberg::SbomComponentRow> = comps
.iter()
.map(|c| crate::warehouse::iceberg::SbomComponentRow {
name: c.name.clone(),
version: c.version.clone(),
license: c.license.clone(),
})
.collect();
wh.append_sbom_components(&repo_name, uuid::Uuid::new_v4(), &rows)
.with_context(|| format!("persist sbom components for {repo_name}"))?;
Ok((repo_name, comps))
}
pub fn materialize_path_dep_siblings(repo: &Path, scan_root: &Path) -> Result<usize> {
let manifest = repo.join("Cargo.toml");
let text = match std::fs::read_to_string(&manifest) {
Ok(t) => t,
Err(_) => return Ok(0), };
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
let mut linked = 0usize;
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else { continue };
for (dep_name, spec) in deps {
let Some(path) = spec.as_table().and_then(|t| t.get("path")).and_then(|p| p.as_str())
else {
continue;
};
let target = repo.join(path);
if target.join("Cargo.toml").exists() {
continue; }
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
let candidate = [leaf.as_str(), dep_name.as_str()]
.into_iter()
.map(|n| scan_root.join(n))
.find(|c| c.join("Cargo.toml").exists());
let Some(src) = candidate else { continue };
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
if symlink_dir(&src, &target).is_ok() {
linked += 1;
}
}
}
Ok(linked)
}
pub fn unresolved_path_dep_siblings(repo: &Path, scan_root: &Path) -> Vec<(String, String)> {
let Ok(text) = std::fs::read_to_string(repo.join("Cargo.toml")) else { return Vec::new() };
let Ok(doc) = toml::from_str::<toml::Value>(&text) else { return Vec::new() };
let mut unresolved = Vec::new();
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else { continue };
for (dep_name, spec) in deps {
let Some(path) = spec.as_table().and_then(|t| t.get("path")).and_then(|p| p.as_str())
else {
continue;
};
if repo.join(path).join("Cargo.toml").exists() {
continue;
}
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
let present = [leaf.as_str(), dep_name.as_str()]
.into_iter()
.any(|n| scan_root.join(n).join("Cargo.toml").exists());
if !present {
unresolved.push((dep_name.clone(), path.to_string()));
}
}
}
unresolved
}
pub fn dep_clone_base() -> String {
std::env::var("NORNIR_DEP_CLONE_BASE")
.unwrap_or_else(|_| "https://codeberg.org/nordisk".to_string())
}
fn sibling_repo_name(path: &str) -> Option<String> {
Path::new(path).components().find_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
}
#[cfg(feature = "net-scan")]
pub fn clone_missing_path_dep_siblings(
repo: &Path,
scan_root: &Path,
url_for: &dyn Fn(&str) -> Option<String>,
) -> Result<usize> {
let manifest = repo.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&manifest) else { return Ok(0) };
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
let mut cloned = 0usize;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else { continue };
for (_dep_name, spec) in deps {
let Some(path) = spec.as_table().and_then(|t| t.get("path")).and_then(|p| p.as_str())
else {
continue;
};
if repo.join(path).join("Cargo.toml").exists() {
continue;
}
let Some(repo_name) = sibling_repo_name(path) else { continue };
if !seen.insert(repo_name.clone()) {
continue; }
let dest = scan_root.join(&repo_name);
if dest.join("Cargo.toml").exists() {
continue;
}
let url =
url_for(&repo_name).unwrap_or_else(|| format!("{}/{}", dep_clone_base(), repo_name));
match crate::gitio::clone_or_fetch(
&url,
&dest,
crate::gitio::nornir_ssh_key_path().as_deref(),
) {
Ok(sha) => {
eprintln!(
"nornir-security: cloned path-dep sibling `{repo_name}` from {url} @ {sha}"
);
cloned += 1;
}
Err(e) => eprintln!(
"nornir-security: clone path-dep sibling `{repo_name}` from {url} failed \
(scan will degrade to Cargo.lock): {e:#}"
),
}
}
}
Ok(cloned)
}
#[cfg(not(feature = "net-scan"))]
pub fn clone_missing_path_dep_siblings(
_repo: &Path,
_scan_root: &Path,
_url_for: &dyn Fn(&str) -> Option<String>,
) -> Result<usize> {
Ok(0)
}
pub fn prepare_path_deps(
repo: &Path,
scan_root: &Path,
url_for: &dyn Fn(&str) -> Option<String>,
) -> (usize, usize) {
let cloned = clone_missing_path_dep_siblings(repo, scan_root, url_for).unwrap_or_else(|e| {
eprintln!("nornir-security: clone path-dep siblings for {} skipped: {e:#}", repo.display());
0
});
let linked = materialize_path_dep_siblings(repo, scan_root).unwrap_or_else(|e| {
eprintln!("nornir-security: link path-dep siblings for {} skipped: {e:#}", repo.display());
0
});
(cloned, linked)
}
#[cfg(unix)]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
if link.exists() {
return Ok(());
}
std::os::unix::fs::symlink(src, link)
}
#[cfg(not(unix))]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
if link.exists() {
return Ok(());
}
std::os::windows::fs::symlink_dir(src, link)
}
pub fn scan(repo: &Path) -> Result<SecurityReport> {
let (repo_name, components) = components(repo)?;
let vulns = query_vulns(&components)?;
Ok(SecurityReport { repo: repo_name, components, vulns })
}
pub fn query_vulns(components: &[Component]) -> Result<Vec<Vuln>> {
if components.is_empty() {
return Ok(Vec::new());
}
match advisory_db_path() {
Some(db) => advisory_db_query(components, &db),
None => osv_query(components),
}
}
pub fn advisory_db_path() -> Option<PathBuf> {
let p = std::env::var_os("NORNIR_ADVISORY_DB")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/opt/nornir/advisory-db"));
p.join("crates").is_dir().then_some(p)
}
pub fn update_advisory_db() -> Result<PathBuf> {
let dest = std::env::var_os("NORNIR_ADVISORY_DB")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/opt/nornir/advisory-db"));
let url = std::env::var("NORNIR_ADVISORY_DB_URL")
.unwrap_or_else(|_| "https://github.com/rustsec/advisory-db".to_string());
crate::gitio::clone_or_fetch(&url, &dest, None)
.with_context(|| format!("clone/fetch advisory-db from {url}"))?;
Ok(dest)
}
fn req_matches(reqs: &[String], v: &semver::Version) -> bool {
let mut bare = v.clone();
bare.pre = semver::Prerelease::EMPTY;
reqs.iter().any(|r| {
semver::VersionReq::parse(r).map(|req| req.matches(v) || req.matches(&bare)).unwrap_or(false)
})
}
fn extract_toml_front_matter(text: &str) -> Option<&str> {
let after = &text[text.find("```toml")? + "```toml".len()..];
let end = after.find("```")?;
Some(after[..end].trim_matches('\n'))
}
fn string_list(v: Option<&toml::Value>) -> Vec<String> {
v.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default()
}
pub fn advisory_db_query(components: &[Component], db: &Path) -> Result<Vec<Vuln>> {
let crates = db.join("crates");
let mut out = Vec::new();
for c in components {
let dir = crates.join(&c.name);
if !dir.is_dir() {
continue;
}
let Ok(version) = semver::Version::parse(&c.version) else { continue };
let mut ids = Vec::new();
let mut summary = String::new();
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let Some(front) = extract_toml_front_matter(&text) else { continue };
let Ok(doc) = toml::from_str::<toml::Value>(front) else { continue };
let adv = doc.get("advisory");
let versions = doc.get("versions");
let patched = string_list(versions.and_then(|v| v.get("patched")));
let unaffected = string_list(versions.and_then(|v| v.get("unaffected")));
if !req_matches(&patched, &version) && !req_matches(&unaffected, &version) {
if summary.is_empty() {
summary = adv
.and_then(|a| a.get("title"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
}
if let Some(id) = adv.and_then(|a| a.get("id")).and_then(|v| v.as_str()) {
ids.push(id.to_string());
}
}
}
if !ids.is_empty() {
out.push(Vuln { crate_name: c.name.clone(), version: c.version.clone(), ids, summary });
}
}
Ok(out)
}
pub fn osv_query(components: &[Component]) -> Result<Vec<Vuln>> {
if components.is_empty() {
return Ok(Vec::new());
}
let queries: Vec<Value> = components
.iter()
.map(|c| json!({ "package": { "ecosystem": "crates.io", "name": c.name }, "version": c.version }))
.collect();
let body = serde_json::to_string(&json!({ "queries": queries }))?;
let resp = match ureq::post("https://api.osv.dev/v1/querybatch")
.set("Content-Type", "application/json")
.send_string(&body)
{
Ok(r) => r,
Err(e) => {
eprintln!("nornir-security: OSV.dev unreachable ({e}); skipping vuln lookup (seed NORNIR_ADVISORY_DB for offline)");
return Ok(Vec::new());
}
};
let resp_str = resp.into_string().context("read OSV response")?;
let resp: Value = serde_json::from_str(&resp_str).context("parse OSV response")?;
let mut vulns = Vec::new();
if let Some(results) = resp["results"].as_array() {
for (i, r) in results.iter().enumerate() {
let Some(vs) = r["vulns"].as_array() else { continue };
if vs.is_empty() || i >= components.len() {
continue;
}
let c = &components[i];
vulns.push(Vuln {
crate_name: c.name.clone(),
version: c.version.clone(),
ids: vs.iter().filter_map(|v| v["id"].as_str().map(String::from)).collect(),
summary: vs.first().and_then(|v| v["summary"].as_str()).unwrap_or_default().to_string(),
});
}
}
Ok(vulns)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_toml_front_matter_pulls_the_advisory_block() {
let md = "```toml\n[advisory]\nid = \"RUSTSEC-2024-0001\"\n[versions]\npatched = [\">= 1.0.0\"]\n```\n\n# Title\n\nbody";
let front = extract_toml_front_matter(md).expect("front matter");
let doc: toml::Value = toml::from_str(front).unwrap();
assert_eq!(doc["advisory"]["id"].as_str(), Some("RUSTSEC-2024-0001"));
assert_eq!(string_list(doc.get("versions").and_then(|v| v.get("patched"))), vec!["\
>= 1.0.0".trim_start()]);
}
#[test]
fn req_matches_handles_prerelease_versions() {
let rc = semver::Version::parse("0.11.0-rc.4").unwrap();
assert!(req_matches(&[">= 0.10.0".into()], &rc), "0.11.0-rc.4 should satisfy >= 0.10.0");
let old = semver::Version::parse("0.9.0").unwrap();
assert!(!req_matches(&[">= 0.10.0".into()], &old), "0.9.0 must NOT satisfy >= 0.10.0");
}
#[test]
fn components_warehouse_first_uses_capture_then_falls_back() {
use crate::warehouse::iceberg::{IcebergWarehouse, SbomComponentRow};
let whdir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(whdir.path()).unwrap();
let repodir = tempfile::tempdir().unwrap();
let repo = repodir.path().join("myrepo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
repo.join("Cargo.lock"),
"[[package]]\nname = \"fallbackdep\"\nversion = \"9.9.9\"\n",
)
.unwrap();
let (name, comps) = components_warehouse_first(&wh, &repo).unwrap();
assert_eq!(name, "myrepo");
assert!(comps.iter().any(|c| c.name == "fallbackdep" && c.version == "9.9.9"));
wh.append_sbom_components(
"myrepo",
uuid::Uuid::new_v4(),
&[SbomComponentRow { name: "cached".into(), version: "1.2.3".into(), license: "MIT".into() }],
)
.unwrap();
let (name, comps) = components_warehouse_first(&wh, &repo).unwrap();
assert_eq!(name, "myrepo");
assert_eq!(comps.len(), 1);
assert_eq!(comps[0].name, "cached");
assert_eq!(comps[0].license, "MIT");
}
#[test]
fn materialize_path_dep_siblings_links_missing_sibling() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let znippy = root.join("znippy");
std::fs::create_dir_all(&znippy).unwrap();
std::fs::write(
znippy.join("Cargo.toml"),
"[package]\nname=\"znippy\"\nversion=\"0.1.0\"\n\n[dependencies]\nzoomies = { path = \"../zoomies\" }\n",
)
.unwrap();
std::fs::write(
znippy.join("Cargo.toml"),
"[package]\nname=\"znippy\"\nversion=\"0.1.0\"\n\n[dependencies]\nzoomies = { path = \"vendor/zoomies\" }\n",
)
.unwrap();
let zoomies = root.join("zoomies");
std::fs::create_dir_all(&zoomies).unwrap();
std::fs::write(zoomies.join("Cargo.toml"), "[package]\nname=\"zoomies\"\nversion=\"0.1.0\"\n").unwrap();
assert!(!znippy.join("vendor/zoomies/Cargo.toml").exists());
let n = materialize_path_dep_siblings(&znippy, root).unwrap();
assert_eq!(n, 1, "one sibling link created");
assert!(znippy.join("vendor/zoomies/Cargo.toml").exists());
let n2 = materialize_path_dep_siblings(&znippy, root).unwrap();
assert_eq!(n2, 0, "already-linked sibling is not re-linked");
}
#[test]
fn materialize_is_noop_when_path_dep_already_resolves() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let a = root.join("a");
std::fs::create_dir_all(&a).unwrap();
let b = root.join("b");
std::fs::create_dir_all(&b).unwrap();
std::fs::write(b.join("Cargo.toml"), "[package]\nname=\"b\"\nversion=\"0.1.0\"\n").unwrap();
std::fs::write(
a.join("Cargo.toml"),
"[package]\nname=\"a\"\nversion=\"0.1.0\"\n\n[dependencies]\nb = { path = \"../b\" }\n",
)
.unwrap();
let n = materialize_path_dep_siblings(&a, root).unwrap();
assert_eq!(n, 0);
}
#[cfg(feature = "net-scan")]
#[test]
fn clone_missing_sibling_makes_path_dep_resolve() {
let tmp = tempfile::tempdir().unwrap();
let remote = tmp.path().join("remote");
let skade_src = remote.join("skade");
std::fs::create_dir_all(skade_src.join("skade")).unwrap();
std::fs::write(
skade_src.join("Cargo.toml"),
"[package]\nname=\"skade-katalog\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
)
.unwrap();
std::fs::write(
skade_src.join("skade").join("Cargo.toml"),
"[package]\nname=\"skade\"\nversion=\"0.4.0\"\nedition=\"2021\"\n",
)
.unwrap();
crate::gitio::init(&skade_src).unwrap();
crate::gitio::commit_all(&skade_src, "seed skade").unwrap();
let scan_root = tmp.path().join("git");
let nornir = scan_root.join("nornir");
std::fs::create_dir_all(&nornir).unwrap();
std::fs::write(
nornir.join("Cargo.toml"),
"[package]\nname=\"nornir\"\nversion=\"0.4.0\"\nedition=\"2021\"\n\n\
[dependencies]\n\
skade = { path = \"../skade/skade\" }\n\
skade-katalog = { path = \"../skade\" }\n",
)
.unwrap();
assert!(!nornir.join("../skade/skade/Cargo.toml").exists());
let remote_str = remote.to_string_lossy().into_owned();
let url_for = move |name: &str| Some(format!("file://{remote_str}/{name}"));
let cloned = clone_missing_path_dep_siblings(&nornir, &scan_root, &url_for).unwrap();
assert_eq!(cloned, 1, "the single missing `skade` repo is cloned once");
assert!(
nornir.join("../skade/skade").join("Cargo.toml").exists(),
"`../skade/skade` (the skade crate) resolves after clone"
);
assert!(
nornir.join("../skade").join("Cargo.toml").exists(),
"`../skade` (the skade-katalog root crate) resolves after clone"
);
let again = clone_missing_path_dep_siblings(&nornir, &scan_root, &url_for).unwrap();
assert_eq!(again, 0, "already-present sibling is not re-cloned");
}
#[test]
fn affected_logic_unpatched_between_ranges() {
let v = semver::Version::parse("0.20.0").unwrap();
let patched = vec![">= 0.39.0".to_string()];
let unaffected = vec!["< 0.15.0".to_string()];
let is_affected = !req_matches(&patched, &v) && !req_matches(&unaffected, &v);
assert!(is_affected);
let v2 = semver::Version::parse("0.40.0").unwrap();
assert!(req_matches(&patched, &v2));
}
}