use globset::Glob;
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use crate::envelope::{CheckResult, Decision};
#[derive(Debug, Error)]
pub enum WaiverError {
#[error("cannot read waivers file: {0}")]
Io(#[from] std::io::Error),
#[error("waivers file rejected: {0}")]
Parse(#[from] toml::de::Error),
#[error("waiver serialization failed: {0}")]
Emit(#[from] toml::ser::Error),
#[error("invalid ttl '{ttl}': use <number><s|m|h|d>, e.g. 2h")]
BadTtl { ttl: String },
#[error("invalid waiver glob '{glob}': {message}")]
BadGlob { glob: String, message: String },
#[error("timestamp error: {0}")]
Timestamp(#[from] time::error::Format),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Waiver {
pub id: String,
pub rule: String,
pub path: String,
pub reason: String,
pub author: String,
pub granted_at: String,
pub expires_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contradicts: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WaiverFile {
#[serde(default)]
waivers: Vec<Waiver>,
}
#[derive(Debug)]
pub struct GrantRequest {
pub rule: String,
pub path: String,
pub reason: String,
pub author: String,
pub ttl: Duration,
pub supersedes: Option<String>,
pub contradicts: Option<String>,
}
impl Waiver {
pub fn grant(request: GrantRequest) -> Result<Self, WaiverError> {
let now = OffsetDateTime::now_utc();
Ok(Self {
id: Uuid::new_v4().to_string(),
rule: request.rule,
path: request.path,
reason: request.reason,
author: request.author,
granted_at: now.format(&Rfc3339)?,
expires_at: (now + request.ttl).format(&Rfc3339)?,
supersedes: request.supersedes,
contradicts: request.contradicts,
})
}
}
pub fn parse_ttl(ttl: &str) -> Result<Duration, WaiverError> {
let bad = || WaiverError::BadTtl {
ttl: ttl.to_owned(),
};
let (digits, unit) = ttl.split_at(ttl.len().saturating_sub(1));
let count: i64 = digits.parse().map_err(|_| bad())?;
if count < 0 {
return Err(bad());
}
match unit {
"s" => Ok(Duration::seconds(count)),
"m" => Ok(Duration::minutes(count)),
"h" => Ok(Duration::hours(count)),
"d" => Ok(Duration::days(count)),
_ => Err(bad()),
}
}
#[derive(Debug, Default)]
pub struct WaiverSet {
entries: Vec<Waiver>,
}
impl WaiverSet {
pub fn load(path: &Path) -> Result<Self, WaiverError> {
let text = match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default())
}
other => other?,
};
let file: WaiverFile = toml::from_str(&text)?;
Ok(Self {
entries: file.waivers,
})
}
pub fn append(path: &Path, waiver: Waiver) -> Result<Waiver, WaiverError> {
Glob::new(&waiver.path).map_err(|error| WaiverError::BadGlob {
glob: waiver.path.clone(),
message: error.to_string(),
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file: WaiverFile = match std::fs::read_to_string(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => WaiverFile::default(),
other => toml::from_str(&other?)?,
};
file.waivers.push(waiver.clone());
std::fs::write(path, toml::to_string_pretty(&file)?)?;
Ok(waiver)
}
#[must_use]
pub fn entries(&self) -> &[Waiver] {
&self.entries
}
#[must_use]
pub fn suppresses(&self, rule: &str, file: &str, now: OffsetDateTime) -> bool {
let rule = crate::legacy::modern_rule_id(rule);
if rule == crate::pipeline::RULE_PROTECTED_PATH
|| rule == crate::pipeline::RULE_READ_ONLY_PATH
{
return false;
}
self.entries.iter().any(|waiver| {
crate::legacy::modern_rule_id(&waiver.rule) == rule
&& !is_expired(waiver, now)
&& !self.is_superseded(&waiver.id)
&& glob_matches(&waiver.path, file)
})
}
#[must_use]
pub fn apply_now(&self, result: CheckResult) -> CheckResult {
self.apply(result, OffsetDateTime::now_utc())
}
#[must_use]
pub fn apply(&self, mut result: CheckResult, now: OffsetDateTime) -> CheckResult {
result
.violations
.retain(|violation| !self.suppresses(&violation.rule, &violation.file, now));
if result.violations.is_empty() {
result.decision = Decision::Allow;
}
result
}
fn is_superseded(&self, id: &str) -> bool {
self.entries
.iter()
.any(|other| other.supersedes.as_deref() == Some(id))
}
#[must_use]
pub fn lint_now(&self) -> Vec<String> {
self.lint(OffsetDateTime::now_utc())
}
#[must_use]
pub fn active_now(&self) -> Vec<&Waiver> {
self.active(OffsetDateTime::now_utc())
}
#[must_use]
pub fn active(&self, now: OffsetDateTime) -> Vec<&Waiver> {
self.entries
.iter()
.filter(|waiver| !is_expired(waiver, now) && !self.is_superseded(&waiver.id))
.collect()
}
#[must_use]
pub fn lint(&self, now: OffsetDateTime) -> Vec<String> {
let mut findings = Vec::new();
for waiver in &self.entries {
if is_expired(waiver, now) {
findings.push(format!(
"waiver {} ({} on {}) is expired since {} — stale entry, prune it",
waiver.id, waiver.rule, waiver.path, waiver.expires_at
));
}
if self.is_superseded(&waiver.id) {
findings.push(format!(
"waiver {} ({} on {}) is superseded — stale entry, prune it",
waiver.id, waiver.rule, waiver.path
));
}
if let Some(target) = &waiver.contradicts {
findings.push(format!(
"waiver {} contradicts {} — resolve the pair; both remain on record",
waiver.id, target
));
}
}
findings
}
}
fn is_expired(waiver: &Waiver, now: OffsetDateTime) -> bool {
OffsetDateTime::parse(&waiver.expires_at, &Rfc3339).map_or(true, |expires| expires <= now)
}
fn glob_matches(glob: &str, file: &str) -> bool {
Glob::new(glob).is_ok_and(|g| g.compile_matcher().is_match(file))
}