use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct RiskAcceptRule {
pub package: String,
pub version_range: String,
#[allow(dead_code)]
pub justification: String,
#[allow(dead_code)]
pub proposed_at: Option<DateTime<Utc>>,
#[allow(dead_code)]
pub expires_at: Option<DateTime<Utc>>,
}
impl RiskAcceptRule {
pub fn matches(&self, package: &str, version: &str) -> bool {
if self.package != package {
return false;
}
version_range_matches(&self.version_range, version)
}
}
pub fn load_rules(path: &Path) -> Result<Vec<RiskAcceptRule>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("read risk-accept rules: {}", path.display()))?;
parse_rules(&content, Utc::now())
}
pub fn parse_rules(content: &str, now: DateTime<Utc>) -> Result<Vec<RiskAcceptRule>> {
let mut rules: Vec<RiskAcceptRule> = Vec::new();
let mut in_block = false;
let mut cur = PartialRule::default();
for line in content.lines() {
let trimmed = line.trim();
if !in_block {
if trimmed == "risk_accepted:" {
in_block = true;
}
continue;
}
if !line.starts_with(' ') && !line.starts_with('\t') && trimmed.ends_with(':') {
flush_rule(&mut cur, &mut rules, now);
in_block = false;
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("- ") {
flush_rule(&mut cur, &mut rules, now);
parse_kv(rest, &mut cur);
continue;
}
parse_kv(trimmed, &mut cur);
}
flush_rule(&mut cur, &mut rules, now);
Ok(rules)
}
#[derive(Default, Debug)]
struct PartialRule {
package: Option<String>,
version_range: Option<String>,
justification: Option<String>,
proposed_at: Option<String>,
expires_at: Option<String>,
}
fn flush_rule(cur: &mut PartialRule, out: &mut Vec<RiskAcceptRule>, now: DateTime<Utc>) {
let taken = std::mem::take(cur);
let (Some(package), Some(version_range)) = (taken.package, taken.version_range) else {
return;
};
let expires_at = taken.expires_at.as_deref().and_then(parse_iso);
if let Some(exp) = expires_at {
if exp < now {
return;
}
}
out.push(RiskAcceptRule {
package,
version_range,
justification: taken.justification.unwrap_or_default(),
proposed_at: taken.proposed_at.as_deref().and_then(parse_iso),
expires_at,
});
}
fn parse_iso(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn parse_kv(line: &str, cur: &mut PartialRule) {
let Some((key, rest)) = line.split_once(':') else {
return;
};
let key = key.trim();
let value = unquote(rest.trim());
match key {
"package" => cur.package = Some(value),
"version_range" => cur.version_range = Some(value),
"justification" => cur.justification = Some(value),
"proposed_at" => cur.proposed_at = Some(value),
"expires_at" => cur.expires_at = Some(value),
_ => {}
}
}
fn unquote(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
pub fn version_range_matches(range: &str, version: &str) -> bool {
let range = range.trim();
if range == "*" || range.is_empty() {
return true;
}
if range.contains(',') {
return range
.split(',')
.all(|clause| version_range_matches(clause.trim(), version));
}
if let Some(rest) = range.strip_prefix("<=") {
return compare_semver(version, rest.trim()) != std::cmp::Ordering::Greater;
}
if let Some(rest) = range.strip_prefix(">=") {
return compare_semver(version, rest.trim()) != std::cmp::Ordering::Less;
}
if let Some(rest) = range.strip_prefix('<') {
return compare_semver(version, rest.trim()) == std::cmp::Ordering::Less;
}
if let Some(rest) = range.strip_prefix('>') {
return compare_semver(version, rest.trim()) == std::cmp::Ordering::Greater;
}
if let Some(rest) = range.strip_prefix('=') {
return version == rest.trim();
}
version == range
}
fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering {
let ap: Vec<&str> = a.split('.').collect();
let bp: Vec<&str> = b.split('.').collect();
for i in 0..std::cmp::max(ap.len(), bp.len()) {
let ai = ap.get(i).copied().unwrap_or("0");
let bi = bp.get(i).copied().unwrap_or("0");
let (an, at) = split_numeric_prefix(ai);
let (bn, bt) = split_numeric_prefix(bi);
match (an, bn) {
(Some(x), Some(y)) => match x.cmp(&y) {
std::cmp::Ordering::Equal => match at.cmp(bt) {
std::cmp::Ordering::Equal => continue,
other => return other,
},
other => return other,
},
_ => match ai.cmp(bi) {
std::cmp::Ordering::Equal => continue,
other => return other,
},
}
}
std::cmp::Ordering::Equal
}
fn split_numeric_prefix(s: &str) -> (Option<u64>, &str) {
let end = s
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(i, _)| i)
.unwrap_or(s.len());
if end == 0 {
return (None, s);
}
let (num, tail) = s.split_at(end);
(num.parse().ok(), tail)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn now_fixed() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 30, 0, 0, 0).unwrap()
}
#[test]
fn parses_minimal_rule() {
let yaml = "risk_accepted:\n - package: cors\n version_range: \"<2.8.5\"\n justification: \"waiver\"\n";
let rules = parse_rules(yaml, now_fixed()).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].package, "cors");
assert_eq!(rules[0].version_range, "<2.8.5");
}
#[test]
fn parses_multiple_rules() {
let yaml = r#"risk_accepted:
- package: cors
version_range: "<2.8.5"
justification: "a"
- package: lodash
version_range: "*"
justification: "b"
"#;
let rules = parse_rules(yaml, now_fixed()).unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[1].package, "lodash");
}
#[test]
fn drops_expired_rules() {
let yaml = r#"risk_accepted:
- package: cors
version_range: "*"
justification: "expired"
expires_at: "2026-01-01T00:00:00Z"
- package: lodash
version_range: "*"
justification: "live"
expires_at: "2026-12-31T00:00:00Z"
"#;
let rules = parse_rules(yaml, now_fixed()).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].package, "lodash");
}
#[test]
fn version_range_operators() {
assert!(version_range_matches("<2.8.5", "2.8.4"));
assert!(!version_range_matches("<2.8.5", "2.8.5"));
assert!(version_range_matches("<=2.8.5", "2.8.5"));
assert!(version_range_matches(">=2.0.0", "2.8.4"));
assert!(version_range_matches(">1.0.0", "2.0.0"));
assert!(!version_range_matches(">1.0.0", "1.0.0"));
assert!(version_range_matches("=2.8.4", "2.8.4"));
assert!(version_range_matches("2.8.4", "2.8.4"));
assert!(!version_range_matches("2.8.4", "2.8.5"));
assert!(version_range_matches("*", "0.0.1"));
}
#[test]
fn compound_range_all_must_match() {
assert!(version_range_matches(">=1.0.0,<2.0.0", "1.5.0"));
assert!(!version_range_matches(">=1.0.0,<2.0.0", "2.0.0"));
assert!(!version_range_matches(">=1.0.0,<2.0.0", "0.9.0"));
}
#[test]
fn rule_matches_predicate() {
let r = RiskAcceptRule {
package: "cors".to_string(),
version_range: "<2.8.5".to_string(),
justification: "x".to_string(),
proposed_at: None,
expires_at: None,
};
assert!(r.matches("cors", "2.8.4"));
assert!(!r.matches("cors", "2.8.5"));
assert!(!r.matches("lodash", "2.8.4"));
}
}