use crate::{
diag::{Diagnostic, FileId, Label},
LintLevel, PathBuf, Spanned,
};
use rustsec::advisory;
use serde::Deserialize;
use url::Url;
#[allow(clippy::reversed_empty_ranges)]
const fn yanked() -> Spanned<LintLevel> {
Spanned::new(LintLevel::Warn, 0..0)
}
#[allow(clippy::reversed_empty_ranges)]
fn ninety_days() -> Spanned<String> {
Spanned::new("P90D".to_owned(), 0..0)
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Config {
pub db_path: Option<PathBuf>,
#[serde(default)]
pub db_urls: Vec<Spanned<String>>,
#[serde(default = "crate::lint_deny")]
pub vulnerability: LintLevel,
#[serde(default = "crate::lint_warn")]
pub unmaintained: LintLevel,
#[serde(default = "crate::lint_warn")]
pub unsound: LintLevel,
#[serde(default = "yanked")]
pub yanked: Spanned<LintLevel>,
#[serde(default = "crate::lint_warn")]
pub notice: LintLevel,
#[serde(default)]
pub ignore: Vec<Spanned<advisory::Id>>,
pub severity_threshold: Option<advisory::Severity>,
pub git_fetch_with_cli: Option<bool>,
#[serde(default)]
pub disable_yank_checking: bool,
#[serde(default = "ninety_days")]
pub maximum_db_staleness: Spanned<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
db_path: None,
db_urls: Vec::new(),
ignore: Vec::new(),
vulnerability: LintLevel::Deny,
unmaintained: LintLevel::Warn,
unsound: LintLevel::Warn,
yanked: yanked(),
notice: LintLevel::Warn,
severity_threshold: None,
git_fetch_with_cli: None,
disable_yank_checking: false,
maximum_db_staleness: ninety_days(),
}
}
}
impl crate::cfg::UnvalidatedConfig for Config {
type ValidCfg = ValidConfig;
fn validate(
self,
cfg_file: FileId,
_files: &mut crate::diag::Files,
diags: &mut Vec<Diagnostic>,
) -> Self::ValidCfg {
let mut ignored: Vec<_> = self.ignore.into_iter().map(AdvisoryId::from).collect();
ignored.sort();
let mut db_urls: Vec<_> = self
.db_urls
.into_iter()
.filter_map(|dburl| match crate::cfg::parse_url(cfg_file, dburl) {
Ok(u) => Some(u),
Err(diag) => {
diags.push(diag);
None
}
})
.collect();
db_urls.sort();
if db_urls.len() > 1 {
for window in db_urls.windows(2) {
if window[0] == window[1] {
diags.push(
Diagnostic::warning()
.with_message("duplicate advisory database url detected")
.with_labels(vec![
Label::secondary(cfg_file, window[0].span.clone()),
Label::secondary(cfg_file, window[1].span.clone()),
]),
);
}
}
}
db_urls.dedup();
for url in &db_urls {
if url.value.domain().is_none() {
diags.push(
Diagnostic::error()
.with_message("advisory database url doesn't have a domain name")
.with_labels(vec![Label::secondary(cfg_file, url.span.clone())]),
);
}
}
let maximum_db_staleness = match parse_rfc3339_duration(&self.maximum_db_staleness.value) {
Ok(mds) => mds,
Err(err) => {
diags.push(
Diagnostic::error()
.with_message("failed to parse RFC3339 duration")
.with_labels(vec![Label::secondary(
cfg_file,
self.maximum_db_staleness.span.clone(),
)])
.with_notes(vec![err.to_string()]),
);
time::Duration::seconds_f64(90. * 24. * 60. * 60. * 60.)
}
};
ValidConfig {
file_id: cfg_file,
db_path: self.db_path,
db_urls,
ignore: ignored,
vulnerability: self.vulnerability,
unmaintained: self.unmaintained,
unsound: self.unsound,
yanked: self.yanked,
notice: self.notice,
severity_threshold: self.severity_threshold,
git_fetch_with_cli: self.git_fetch_with_cli.unwrap_or_default(),
disable_yank_checking: self.disable_yank_checking,
maximum_db_staleness,
}
}
}
pub(crate) type AdvisoryId = Spanned<advisory::Id>;
pub struct ValidConfig {
pub file_id: FileId,
pub db_path: Option<PathBuf>,
pub db_urls: Vec<Spanned<Url>>,
pub(crate) ignore: Vec<AdvisoryId>,
pub vulnerability: LintLevel,
pub unmaintained: LintLevel,
pub unsound: LintLevel,
pub yanked: Spanned<LintLevel>,
pub notice: LintLevel,
pub severity_threshold: Option<advisory::Severity>,
pub git_fetch_with_cli: bool,
pub disable_yank_checking: bool,
pub maximum_db_staleness: time::Duration,
}
fn parse_rfc3339_duration(value: &str) -> anyhow::Result<time::Duration> {
use anyhow::Context as _;
let mut value = value
.strip_prefix('P')
.context("duration requires 'P' prefix")?;
const UNITS: &[(char, f64)] = &[
('D', 24. * 60. * 60.),
('M', 30.43 * 24. * 60. * 60.),
('Y', 365. * 24. * 60. * 60.),
('W', 7. * 24. * 60. * 60.),
('H', 60. * 60.),
('M', 60.),
('S', 1.),
('W', 7. * 24. * 60. * 60.),
];
for c in value.chars() {
if c == ',' {
anyhow::bail!("'{c}' is valid in the RFC-3339 duration format but not supported by this implementation, use '.' instead");
}
if c != '.' && c != 'T' && !c.is_ascii_digit() && !UNITS.iter().any(|(uc, _)| c == *uc) {
anyhow::bail!("'{c}' is not valid in the RFC-3339 duration format");
}
}
#[derive(Copy, Clone, PartialEq, PartialOrd)]
enum Unit {
Empty,
Year,
Month,
Day,
Time,
Hour,
Minute,
Second,
Week,
}
impl Unit {
#[inline]
fn from(c: char, is_time: bool) -> Self {
match c {
'D' => Self::Day,
'T' => Self::Time,
'H' => Self::Hour,
'M' => {
if is_time {
Self::Minute
} else {
Self::Month
}
}
'S' => Self::Second,
'Y' => Self::Year,
'W' => Self::Week,
other => unreachable!("'{other}' should be impossible"),
}
}
}
let mut duration = time::Duration::new(0, 0);
let mut last_unit = Unit::Empty;
let mut last_unitc = '_';
let mut supplied_units = 0;
let mut is_time = false;
while !value.is_empty() {
let unit_index = value
.find(|c: char| c.is_ascii_uppercase())
.context("unit not specified")?;
let unitc = value.as_bytes()[unit_index] as char;
let unit = Unit::from(unitc, is_time);
anyhow::ensure!(
unit > last_unit,
"unit '{unitc}' cannot follow '{last_unitc}'"
);
if unit == Unit::Time {
anyhow::ensure!(
unit_index == 0,
"unit not specified for value '{}'",
&value[..unit_index]
);
is_time = true;
} else {
anyhow::ensure!(unit_index != 0, "value not specified for '{unitc}'");
let uvs = &value[..unit_index];
let unit_value: f64 = uvs
.parse()
.with_context(|| "failed to parse value '{uvs}' for unit '{unit}'")?;
supplied_units += 1;
anyhow::ensure!(
!matches!(unit, Unit::Hour | Unit::Minute | Unit::Second) || is_time,
"'{unitc}' must be preceded with 'T'"
);
let block = if is_time { &UNITS[4..] } else { &UNITS[..4] };
let unit_to_seconds = block
.iter()
.find_map(|(c, uts)| (*c == unitc).then_some(*uts))
.unwrap();
duration += time::Duration::checked_seconds_f64(unit_value * unit_to_seconds)
.with_context(|| format!("value '{unit_value}' for '{unitc}' is out of range"))?;
}
last_unitc = unitc;
last_unit = unit;
value = &value[unit_index + 1..];
}
anyhow::ensure!(supplied_units > 0, "must supply at least one time unit");
Ok(duration)
}
#[cfg(test)]
mod test {
use super::{parse_rfc3339_duration as dur_parse, *};
use crate::cfg::{test::*, Fake, UnvalidatedConfig};
#[test]
fn deserializes_advisories_cfg() {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Advisories {
advisories: Config,
}
let mut cd: ConfigData<Advisories> = load("tests/cfg/advisories.toml");
let mut diags = Vec::new();
let validated = cd
.config
.advisories
.validate(cd.id, &mut cd.files, &mut diags);
assert!(
!diags
.iter()
.any(|d| d.severity >= crate::diag::Severity::Error),
"{diags:#?}"
);
assert_eq!(validated.file_id, cd.id);
assert!(validated
.db_path
.iter()
.map(|dp| dp.as_str())
.eq(vec!["~/.cargo/advisory-dbs"]));
assert!(validated.db_urls.iter().eq(vec![&Url::parse(
"https://github.com/RustSec/advisory-db"
)
.unwrap()
.fake()]));
assert_eq!(validated.vulnerability, LintLevel::Deny);
assert_eq!(validated.unmaintained, LintLevel::Warn);
assert_eq!(validated.unsound, LintLevel::Warn);
assert_eq!(validated.yanked, LintLevel::Warn);
assert_eq!(validated.notice, LintLevel::Warn);
assert_eq!(
validated.ignore,
vec!["RUSTSEC-0000-0000"
.parse::<rustsec::advisory::Id>()
.unwrap()]
);
assert_eq!(
validated.severity_threshold,
Some(rustsec::advisory::Severity::Medium)
);
}
#[test]
fn rejects_invalid_durations() {
const FAILURES: &[&str] = &[
"no-P", "P", "PT", "P1H3", "P2TH3", "PT1HM", "PT1M3H", "P1M3Y", "P2W1Y", "PT2W1H", "P5H", "P5S", "PT1,5S",
];
let failures: String = FAILURES.iter().fold(String::new(), |mut acc, bad| {
use std::fmt::Write;
writeln!(&mut acc, "{:?}", dur_parse(bad)).unwrap();
acc
});
insta::assert_snapshot!(failures);
}
#[test]
fn parses_valid_durations() {
const DAY: f64 = 24. * 60. * 60.;
const MONTH: f64 = 30.43 * DAY;
const TABLE: &[(&str, f64)] = &[
("P1Y", 365. * DAY),
("P1.5Y", 365. * 1.5 * DAY),
("P1M", MONTH),
("P2W", 7. * 2. * DAY),
("P3D", 3. * DAY),
("PT4H", 4. * 60. * 60.),
("PT2M", 2. * 60.),
("PT8S", 8.),
("PT8.5S", 8.5),
("P1Y3M", 365. * DAY + 3. * MONTH),
("P1Y5D", 365. * DAY + 5. * DAY),
("P1Y4M3D", 365. * DAY + 4. * MONTH + 3. * DAY),
(
"P1Y3M2DT3H2M1S",
365. * DAY + 3. * MONTH + 2. * DAY + 3. * 60. * 60. + 2. * 60. + 1.,
),
("P2DT4H", 2. * DAY + 4. * 60. * 60.),
("P2MT0.5M", 2. * MONTH + 0.5 * 60.),
("P5DT1.6M", 5. * DAY + 60. * 1.6),
("P1.5W", 7. * 1.5 * DAY),
("P3D1.5W", 3. * DAY + 7. * 1.5 * DAY),
("P2DT3.002S", 2. * DAY + 3.002),
("P2DT3.02003S", 2. * DAY + 3.02003),
("P2DT4H3M2.6S", 2. * DAY + 4. * 60. * 60. + 3. * 60. + 2.6),
("PT3H2M1.1S", 3. * 60. * 60. + 2. * 60. + 1.1),
];
for (dur, secs) in TABLE {
match dur_parse(dur) {
Ok(parsed) => {
assert_eq!(
parsed,
time::Duration::seconds_f64(*secs),
"unexpected duration for '{dur}'"
);
}
Err(err) => {
panic!("failed to parse '{dur}': {err:#}");
}
}
}
}
}