blob: 62ca3506bd3b3daed4d24762dcdb8cbf41d0e7f4 [file] [log] [blame]
use cargo_deny::{
advisories::{self, cfg},
field_eq, func_name,
test_utils::{self as tu},
Krates,
};
struct TestCtx {
dbs: advisories::DbSet,
krates: Krates,
}
fn load() -> TestCtx {
static ONCE: parking_lot::Once = parking_lot::Once::new();
ONCE.call_once(|| {
let mut cargo = std::process::Command::new("cargo");
cargo.args([
"fetch",
"--manifest-path",
"examples/06_advisories/Cargo.toml",
]);
assert!(
cargo.status().expect("failed to run cargo fetch").success(),
"failed to fetch crates"
);
});
let md: krates::cm::Metadata = serde_json::from_str(
&std::fs::read_to_string("tests/test_data/advisories/06_advisories.json").unwrap(),
)
.unwrap();
let krates: Krates = krates::Builder::new()
.build_with_metadata(md, krates::NoneFilter)
.unwrap();
let db = {
advisories::DbSet::load(
"tests/advisory-db".into(),
vec![],
advisories::Fetch::Disallow(time::Duration::days(100)),
)
.unwrap()
};
TestCtx { dbs: db, krates }
}
fn iter_notes(diag: &serde_json::Value) -> Option<impl Iterator<Item = &str>> {
diag.pointer("/fields/notes")
.and_then(|notes| notes.as_array())
.map(|array| array.iter().filter_map(|s| s.as_str()))
}
fn find_by_code<'a>(diags: &'a [serde_json::Value], code: &str) -> Option<&'a serde_json::Value> {
diags.iter().find(|v| match iter_notes(v) {
Some(mut notes) => notes.any(|note| note.contains(code)),
None => false,
})
}
/// Validates we emit diagnostics when a vulnerability advisory is detected
#[test]
fn detects_vulnerabilities() {
let TestCtx { dbs, krates } = load();
let cfg = tu::Config::new("vulnerability = 'deny'");
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
None,
tx,
);
});
let diag = find_by_code(&diags, "RUSTSEC-2019-0001").unwrap();
insta::assert_json_snapshot!(diag);
}
/// Validates we emit diagnostics when an unmaintained advisory is detected
#[test]
fn detects_unmaintained() {
let TestCtx { dbs, krates } = load();
let cfg = tu::Config::new("unmaintained = 'warn'");
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
None,
tx,
);
});
let unmaintained_diag = find_by_code(&diags, "RUSTSEC-2016-0004").unwrap();
insta::assert_json_snapshot!(unmaintained_diag);
}
/// Validates we emit diagnostics when an unsound advisory is detected
#[test]
fn detects_unsound() {
let TestCtx { dbs, krates } = load();
let cfg = tu::Config::new("unsound = 'warn'");
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
None,
tx,
);
});
let unsound_diag = find_by_code(&diags, "RUSTSEC-2019-0036").unwrap();
insta::assert_json_snapshot!(unsound_diag);
}
/// Validates that advisories that are ignored still have diagnostics emitted for
/// them, but with 'note' severity
#[test]
fn downgrades_lint_levels() {
let TestCtx { dbs, krates } = load();
let cfg = tu::Config::new(
r#"
unmaintained = "warn"
ignore = [
"RUSTSEC-2016-0004",
{ id = "RUSTSEC-2019-0001", reason = "this is a test" },
]
"#,
);
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
None,
tx,
);
});
let downgraded = [
find_by_code(&diags, "RUSTSEC-2016-0004").unwrap(),
find_by_code(&diags, "RUSTSEC-2019-0001").unwrap(),
];
insta::assert_json_snapshot!(downgraded);
let ignored: Vec<_> = diags
.into_iter()
.filter(|v| {
v.pointer("/fields/code")
.and_then(|s| s.as_str())
.map_or(false, |s| s == "advisory-ignored")
})
.collect();
insta::assert_json_snapshot!(ignored);
}
/// Validates we can detect yanked crates from sparse, git, and
/// non crates.io registries
#[test]
fn detects_yanked() {
// This crate has really light dependencies that _should_ still exercise
// the yank checking without taking more than a couple of seconds to download
// even though we always do it in a fresh temporary directory
let td = temp_dir();
let cargo_home = td.path();
let mut cmd = krates::Cmd::new();
// Note we need to set the current directory as cargo has a bug/design flaw
// where .cargo/config.toml is only searched from the current working directory
// not the location of the root manifest. which is....really annoying
cmd.current_dir("examples/12_yank_check")
.lock_opts(krates::LockOptions {
frozen: false,
locked: true,
offline: false,
});
let mut cmd: krates::cm::MetadataCommand = cmd.into();
cmd.env("CARGO_HOME", cargo_home);
let krates: Krates = krates::Builder::new()
.build(cmd, krates::NoneFilter)
.unwrap();
let indices = advisories::Indices::load(&krates, cargo_home.to_owned().try_into().unwrap());
let dbs = advisories::DbSet { dbs: Vec::new() };
{
let cfg =
tu::Config::new("yanked = 'deny'\nunmaintained = 'allow'\nvulnerability = 'allow'");
let indices = advisories::Indices {
indices: Vec::new(),
cache: indices.cache.clone(),
};
let diags = tu::gather_diagnostics::<cfg::Config, _, _>(
&krates,
func_name!(),
cfg,
|ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
Some(indices),
tx,
);
},
);
let diags: Vec<_> = diags
.into_iter()
.filter(|v| {
v.pointer("/fields/message")
.and_then(|v| v.as_str())
.map_or(false, |v| v.starts_with("detected yanked crate"))
})
.collect();
insta::assert_json_snapshot!(diags);
}
{
let cfg = tu::Config::new(
r#"
yanked = "deny"
ignore = [
# This crate is in the graph, but we're ignoring it
{ crate = "[email protected]", reason = "a new version has not been released yet" },
# This crate is not in the graph, so we should get a warning about it
"boop",
]
unmaintained = "allow"
vulnerability = "allow"
"#,
);
let diags = tu::gather_diagnostics::<cfg::Config, _, _>(
&krates,
func_name!(),
cfg,
|ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
Some(indices),
tx,
);
},
);
let diags: Vec<_> = diags
.into_iter()
.filter(|v| {
v.pointer("/fields/message")
.and_then(|v| v.as_str())
.map_or(false, |v| {
v.starts_with("detected yanked crate") || v.starts_with("yanked crate")
})
})
.collect();
insta::assert_json_snapshot!(diags);
}
}
/// Validates that if we fail to load 1 or more indices, all the crates sourced
/// to that index will emit an diagnostic that they can't be checked
#[test]
fn warns_on_index_failures() {
let TestCtx { dbs, krates } = load();
let cfg = tu::Config::new("yanked = 'deny'\nunmaintained = 'allow'\nvulnerability = 'allow'");
let source = cargo_deny::Source::crates_io(false);
let mut cache = std::collections::BTreeMap::new();
for krate in krates.krates() {
cache.insert(
(krate.name.as_str(), &source),
advisories::Entry::Error("this path is valid but we pretend it is non-utf8".into()),
);
}
let indices = advisories::Indices {
indices: vec![(
&source,
Err(tame_index::Error::NonUtf8Path(
"this path is valid but we pretend it is non-utf8".into(),
)),
)],
cache,
};
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
Some(indices),
tx,
);
});
// This is the number of crates sourced to crates.io
assert_eq!(
diags
.into_iter()
.filter(|v| { field_eq!(v, "/fields/message", "unable to check for yanked crates") })
.count(),
193
);
}
/// Validates that we emit a warning if a crate in the graph _does_ match an
/// advisory, however that advisory has been withdrawn <https://github.com/rustsec/advisory-db/pull/942>
#[test]
fn warns_on_ignored_and_withdrawn() {
let TestCtx { dbs, krates } = load();
let cfg = tu::Config::new(
"yanked = 'deny'\nunmaintained = 'deny'\nvulnerability = 'deny'\nignore = ['RUSTSEC-2020-0053']",
);
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
None,
tx,
);
});
insta::assert_json_snapshot!(diags
.iter()
.find(|diag| field_eq!(diag, "/fields/code", "advisory-not-detected"))
.unwrap());
}
#[inline]
fn temp_dir() -> tempfile::TempDir {
tempfile::tempdir_in(env!("CARGO_TARGET_TMPDIR")).unwrap()
}
#[inline]
fn to_path(td: &tempfile::TempDir) -> Option<&cargo_deny::Path> {
Some(cargo_deny::Path::from_path(td.path()).unwrap())
}
/// Validates that stale advisory databases result in an error
#[test]
fn fails_on_stale_advisory_database() {
assert!(advisories::DbSet::load(
"tests/advisory-db".into(),
vec![],
advisories::Fetch::Disallow(time::Duration::seconds(0)),
)
.unwrap_err()
.to_string()
.contains("repository is stale"));
}
use advisories::Fetch;
const TEST_DB_URL: &str = "https://github.com/EmbarkStudios/test-advisory-db";
const TEST_DB_PATH: &str = "tests/advisory-db/github.com-c373669cccc50ac0";
const GIT_PATH: &str = "github.com-c373669cccc50ac0/.git";
const GIT_SUB_PATH: &str = ".git/modules/tests/advisory-db/github.com-c373669cccc50ac0";
/// Expected HEAD without fetch
const EXPECTED_ONE: &str = "1f44d565d81692a44b8c7af8a80f587e19757f8c";
const EXPECTED_ONE_ID: &str = "BOOP-2023-0001";
const EXPECTED_ONE_DATE: &str = "2023-06-30";
/// Expected remote HEAD for <https://github.com/EmbarkStudios/test-advisory-db>
const EXPECTED_TWO: &str = "c84d73b086cc762f6a2b8ed794d47171a52781a3";
const EXPECTED_TWO_ID: &str = "BOOP-2023-0002";
const EXPECTED_TWO_DATE: &str = "2023-07-10";
fn do_open(td: &tempfile::TempDir, f: Fetch) -> advisories::AdvisoryDb {
let mut db_set = advisories::DbSet::load(
to_path(td).unwrap().to_owned(),
vec![TEST_DB_URL.parse().unwrap()],
f,
)
.unwrap();
db_set.dbs.pop().unwrap()
}
fn validate(adb: &advisories::AdvisoryDb, rev: &str, ids: &[(&str, &str)]) {
let repo = gix::open(&adb.path).expect("failed to open repo");
assert_eq!(repo.head_commit().unwrap().id.to_hex().to_string(), rev);
for (id, date) in ids {
let adv = adb.db.get(&id.parse().unwrap()).expect("unable to find id");
assert_eq!(adv.date().as_str(), *date);
}
assert!(
(time::OffsetDateTime::now_utc() - adb.fetch_time) < std::time::Duration::from_secs(60)
);
}
/// Validates we can clone an advisory database with gix
#[test]
fn clones_with_gix() {
let td = temp_dir();
let db = do_open(&td, Fetch::Allow);
validate(
&db,
EXPECTED_TWO,
&[
(EXPECTED_ONE_ID, EXPECTED_ONE_DATE),
(EXPECTED_TWO_ID, EXPECTED_TWO_DATE),
],
);
}
/// Validates we can clone an advisory database with git
#[test]
fn clones_with_git() {
let td = temp_dir();
let db = do_open(&td, Fetch::AllowWithGitCli);
validate(
&db,
EXPECTED_TWO,
&[
(EXPECTED_ONE_ID, EXPECTED_ONE_DATE),
(EXPECTED_TWO_ID, EXPECTED_TWO_DATE),
],
);
}
fn validate_fetch(fetch: Fetch) {
let td = temp_dir();
fs_extra::copy_items(
&[TEST_DB_PATH],
td.path(),
&fs_extra::dir::CopyOptions::default(),
)
.expect("failed to copy");
let git_path = td.path().join(GIT_PATH);
std::fs::remove_file(&git_path).expect("unable to remove .git file");
fs_extra::copy_items(
&[GIT_SUB_PATH],
&git_path,
&fs_extra::dir::CopyOptions {
copy_inside: true,
..Default::default()
},
)
.expect("failed to copy");
// We need to overwrite the config file in the git directory, otherwise
// mutations will actually affect the working tree rather than the actual
// temp location we've copied the submodule into
std::fs::write(
git_path.join("config"),
r#"
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = https://github.com/EmbarkStudios/test-advisory-db
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
merge = refs/heads/main
"#,
)
.expect("failed to write config");
let db = do_open(&td, Fetch::Disallow(time::Duration::days(10000)));
validate(&db, EXPECTED_ONE, &[(EXPECTED_ONE_ID, EXPECTED_ONE_DATE)]);
let db = do_open(&td, fetch);
validate(
&db,
EXPECTED_TWO,
&[
(EXPECTED_ONE_ID, EXPECTED_ONE_DATE),
(EXPECTED_TWO_ID, EXPECTED_TWO_DATE),
],
);
}
/// Validates we can fetch advisory db updates with gix
#[test]
fn fetches_with_gix() {
if std::env::var_os("CI").is_some() && cfg!(target_os = "macos") {
println!("consistently times out, so tired");
return;
}
validate_fetch(Fetch::Allow);
}
/// Validates we can fetch advisory db updates with git
#[test]
fn fetches_with_git() {
if std::env::var_os("CI").is_some() && cfg!(target_os = "macos") {
println!("consistently times out, so tired");
return;
}
validate_fetch(Fetch::AllowWithGitCli);
}
/// Validates that we can detect source replacement and can still perform yank
/// checking
#[test]
fn crates_io_source_replacement() {
use rayon::prelude::*;
// Create a local registry in a temp dir that we use a source replacement
// for crates.io
let lrd = temp_dir();
{
use tame_index::{external::reqwest, index::local};
let sparse = tame_index::index::RemoteSparseIndex::new(
tame_index::SparseIndex::new(tame_index::IndexLocation::new(
tame_index::IndexUrl::CratesIoSparse,
))
.unwrap(),
reqwest::blocking::Client::new(),
);
// Use a separate even more temporary cargo home for the gathering of the
// crates we want to write to the temp local registry, so that we don't
// pollute the cargo home used in the actual test
let temp_cargo_home = temp_dir();
let mut cmd = krates::Cmd::new();
// Note we need to set the current directory as cargo has a bug/design flaw
// where .cargo/config.toml is only searched from the current working directory
// not the location of the root manifest. which is....really annoying
cmd.current_dir("examples/12_yank_check")
.lock_opts(krates::LockOptions {
frozen: false,
locked: true,
offline: false,
});
let mut cmd: krates::cm::MetadataCommand = cmd.into();
cmd.env("CARGO_HOME", temp_cargo_home.path());
let krates: Krates = krates::Builder::new()
.build(cmd, krates::NoneFilter)
.unwrap();
struct IndexPkg {
ik: tame_index::IndexKrate,
version: semver::Version,
}
let lock = &tame_index::utils::flock::FileLock::unlocked();
let index_krates: Vec<_> = krates
.krates()
.filter_map(|k| {
if k.source.as_ref().map_or(true, |s| !s.is_crates_io()) {
return None;
}
Some(IndexPkg {
ik: sparse
.cached_krate(k.name.as_str().try_into().unwrap(), lock)
.unwrap()
.unwrap(),
version: k.version.clone(),
})
})
.collect();
let client =
local::builder::Client::build(reqwest::blocking::ClientBuilder::new()).unwrap();
let lrb = local::LocalRegistryBuilder::create(to_path(&lrd).unwrap().to_owned()).unwrap();
let config = sparse.index.index_config().unwrap();
index_krates.into_par_iter().for_each(|ip| {
let iv = ip
.ik
.versions
.iter()
.find(|iv| iv.version.parse::<cargo_deny::Version>().unwrap() == ip.version)
.unwrap();
let vk = local::ValidKrate::download(&client, &config, iv).unwrap();
lrb.insert(&ip.ik, &[vk]).unwrap();
});
let _lr = lrb.finalize(true).unwrap();
}
// Copy the package to a new temp dir so that we can mutate the config.toml
// to use our new local registry
let pkg_dir = temp_dir();
{
fs_extra::copy_items(
&["examples/12_yank_check"],
pkg_dir.path(),
&Default::default(),
)
.expect("failed to copy");
let config_path = pkg_dir.path().join("12_yank_check/.cargo/config.toml");
let mut cfg =
std::fs::read_to_string(&config_path).expect("failed to read .cargo/config.toml");
cfg.push_str("\n[source.temp-local-registry]\n");
use std::fmt::Write;
writeln!(&mut cfg, "local-registry = \"{}\"", to_path(&lrd).unwrap()).unwrap();
cfg.push_str("\n[source.crates-io]\n");
cfg.push_str("replace-with = \"temp-local-registry\"");
std::fs::write(config_path, cfg).expect("failed to write .cargo/config.toml");
}
// This crate has really light dependencies that _should_ still exercise
// the yank checking without taking more than a couple of seconds to download
// even though we always do it in a fresh temporary directory
let td = temp_dir();
let cargo_home = td.path();
let mut cmd = krates::Cmd::new();
// Note we need to set the current directory as cargo has a bug/design flaw
// where .cargo/config.toml is only searched from the current working directory
// not the location of the root manifest. which is....really annoying
cmd.current_dir(pkg_dir.path().join("12_yank_check"))
.lock_opts(krates::LockOptions {
frozen: false,
locked: true,
offline: false,
});
let mut cmd: krates::cm::MetadataCommand = cmd.into();
cmd.env("CARGO_HOME", cargo_home);
let cargo_home: camino::Utf8PathBuf = cargo_home.to_owned().try_into().unwrap();
let mut kb = krates::Builder::new();
cargo_deny::krates_with_index(
&mut kb,
Some(to_path(&pkg_dir).unwrap().join("12_yank_check")),
Some(cargo_home.clone()),
)
.unwrap();
let krates: Krates = kb.build(cmd, krates::NoneFilter).unwrap();
let indices = advisories::Indices::load(&krates, cargo_home.clone());
let cfg = tu::Config::new("yanked = 'deny'\nunmaintained = 'allow'\nvulnerability = 'allow'");
let dbs = advisories::DbSet { dbs: Vec::new() };
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
Some(indices),
tx,
);
});
let diags: Vec<_> = diags
.into_iter()
.filter(|v| {
v.pointer("/fields/message")
.and_then(|v| v.as_str())
.map_or(false, |v| v.starts_with("detected yanked crate"))
})
.collect();
insta::assert_json_snapshot!(diags);
// Now that we've verified we can perform the yank checks against valid indices, go
// in and corrupt/remove the index entries and perform the check again to ensure we
// give good error messages to the user
{
let index = tame_index::index::ComboIndexCache::new(
tame_index::IndexLocation::new(
tame_index::IndexUrl::crates_io(
Some(to_path(&pkg_dir).unwrap().join("12_yank_check")),
Some(&cargo_home),
None,
)
.unwrap(),
)
.with_root(Some(cargo_home.clone())),
)
.unwrap();
// Nuke spdx entirely
let spath = index.cache_path("spdx".try_into().unwrap());
std::fs::remove_file(spath).unwrap();
// Note we also need to nuke the spdx crate file otherwise the local registry
// validation will fail, we don't care about this, we are screwing it up on purpose
std::fs::remove_file(lrd.path().join("spdx-0.3.1.crate")).unwrap();
// Remove the specific version of smallvec pinned by the lockfile
{
let spath = index.cache_path("smallvec".try_into().unwrap());
let json = std::fs::read_to_string(&spath).unwrap();
let mut file = std::fs::File::create(spath).unwrap();
for line in json.lines() {
use std::io::Write as _;
if line.contains(r#","vers":"1.6.1","#) {
continue;
}
assert_eq!(
file.write_vectored(&[
std::io::IoSlice::new(line.as_bytes()),
std::io::IoSlice::new(b"\n"),
])
.unwrap(),
line.len() + 1
);
}
std::fs::remove_file(lrd.path().join("smallvec-1.6.1.crate")).unwrap();
}
}
// Change the version of the cache entry to a too old version that tame-index doesn't support
{
let index = tame_index::index::ComboIndexCache::new(
tame_index::IndexLocation::new(
"https://github.com/EmbarkStudios/cargo-test-index".into(),
)
.with_root(Some(cargo_home.clone())),
)
.unwrap();
let spath = index.cache_path("crate-two".try_into().unwrap());
let mut sc = std::fs::read(&spath).unwrap();
sc[0] = tame_index::index::cache::CURRENT_CACHE_VERSION /* 3 */ - 2;
std::fs::write(spath, sc).unwrap();
}
// Corrupt the entry by making the etag string invalid utf8
{
let index = tame_index::index::ComboIndexCache::new(
tame_index::IndexLocation::new(
"sparse+https://cargo.cloudsmith.io/embark/deny/".into(),
)
.with_root(Some(cargo_home.clone())),
)
.unwrap();
let spath = index.cache_path("crate-one".try_into().unwrap());
let mut sc = std::fs::read(&spath).unwrap();
sc[11] = 0xc0;
sc[12] = 0x80;
std::fs::write(spath, sc).unwrap();
}
let indices = advisories::Indices::load(&krates, cargo_home.clone());
let cfg = tu::Config::new("yanked = 'deny'\nunmaintained = 'allow'\nvulnerability = 'allow'");
let diags =
tu::gather_diagnostics::<cfg::Config, _, _>(&krates, func_name!(), cfg, |ctx, _, tx, _| {
advisories::check(
ctx,
&dbs,
Option::<advisories::NoneReporter>::None,
Some(indices),
tx,
);
});
let diags: Vec<_> = diags
.into_iter()
.filter_map(|v| {
v.pointer("/fields/notes/0")
.and_then(|v| v.as_str())
.map(|n| {
(
v.pointer("/fields/graphs/0/Krate/name")
.and_then(|v| v.as_str())
.unwrap()
.to_owned(),
n.replace(cargo_home.as_str(), "$TEMP_LOCAL"),
)
})
})
.collect();
insta::assert_json_snapshot!(diags);
}