1use globset::Glob;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use thiserror::Error;
12use time::format_description::well_known::Rfc3339;
13use time::{Duration, OffsetDateTime};
14use uuid::Uuid;
15
16use crate::envelope::{CheckResult, Decision};
17
18#[derive(Debug, Error)]
19pub enum WaiverError {
20 #[error("cannot read waivers file: {0}")]
21 Io(#[from] std::io::Error),
22 #[error("waivers file rejected: {0}")]
23 Parse(#[from] toml::de::Error),
24 #[error("waiver serialization failed: {0}")]
25 Emit(#[from] toml::ser::Error),
26 #[error("invalid ttl '{ttl}': use <number><s|m|h|d>, e.g. 2h")]
27 BadTtl { ttl: String },
28 #[error("invalid waiver glob '{glob}': {message}")]
29 BadGlob { glob: String, message: String },
30 #[error("timestamp error: {0}")]
31 Timestamp(#[from] time::error::Format),
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct Waiver {
39 pub id: String,
40 pub rule: String,
41 pub path: String,
42 pub reason: String,
43 pub author: String,
44 pub granted_at: String,
46 pub expires_at: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub supersedes: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub contradicts: Option<String>,
51}
52
53#[derive(Debug, Default, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct WaiverFile {
56 #[serde(default)]
57 waivers: Vec<Waiver>,
58}
59
60#[derive(Debug)]
63pub struct GrantRequest {
64 pub rule: String,
65 pub path: String,
66 pub reason: String,
67 pub author: String,
68 pub ttl: Duration,
69 pub supersedes: Option<String>,
70 pub contradicts: Option<String>,
71}
72
73impl Waiver {
74 pub fn grant(request: GrantRequest) -> Result<Self, WaiverError> {
80 let now = OffsetDateTime::now_utc();
81 Ok(Self {
82 id: Uuid::new_v4().to_string(),
83 rule: request.rule,
84 path: request.path,
85 reason: request.reason,
86 author: request.author,
87 granted_at: now.format(&Rfc3339)?,
88 expires_at: (now + request.ttl).format(&Rfc3339)?,
89 supersedes: request.supersedes,
90 contradicts: request.contradicts,
91 })
92 }
93}
94
95pub fn parse_ttl(ttl: &str) -> Result<Duration, WaiverError> {
100 let bad = || WaiverError::BadTtl {
101 ttl: ttl.to_owned(),
102 };
103 let (digits, unit) = ttl.split_at(ttl.len().saturating_sub(1));
104 let count: i64 = digits.parse().map_err(|_| bad())?;
105 if count < 0 {
106 return Err(bad());
107 }
108 match unit {
109 "s" => Ok(Duration::seconds(count)),
110 "m" => Ok(Duration::minutes(count)),
111 "h" => Ok(Duration::hours(count)),
112 "d" => Ok(Duration::days(count)),
113 _ => Err(bad()),
114 }
115}
116
117#[derive(Debug, Default)]
119pub struct WaiverSet {
120 entries: Vec<Waiver>,
121}
122
123impl WaiverSet {
124 pub fn load(path: &Path) -> Result<Self, WaiverError> {
130 let text = match std::fs::read_to_string(path) {
131 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
132 return Ok(Self::default())
133 }
134 other => other?,
135 };
136 let file: WaiverFile = toml::from_str(&text)?;
137 Ok(Self {
138 entries: file.waivers,
139 })
140 }
141
142 pub fn append(path: &Path, waiver: Waiver) -> Result<Waiver, WaiverError> {
148 Glob::new(&waiver.path).map_err(|error| WaiverError::BadGlob {
151 glob: waiver.path.clone(),
152 message: error.to_string(),
153 })?;
154 if let Some(parent) = path.parent() {
155 std::fs::create_dir_all(parent)?;
156 }
157 let mut file: WaiverFile = match std::fs::read_to_string(path) {
158 Err(error) if error.kind() == std::io::ErrorKind::NotFound => WaiverFile::default(),
159 other => toml::from_str(&other?)?,
160 };
161 file.waivers.push(waiver.clone());
162 std::fs::write(path, toml::to_string_pretty(&file)?)?;
163 Ok(waiver)
164 }
165
166 #[must_use]
167 pub fn entries(&self) -> &[Waiver] {
168 &self.entries
169 }
170
171 #[must_use]
177 pub fn suppresses(&self, rule: &str, file: &str, now: OffsetDateTime) -> bool {
178 let rule = crate::legacy::modern_rule_id(rule);
179 if rule == crate::pipeline::RULE_PROTECTED_PATH
180 || rule == crate::pipeline::RULE_READ_ONLY_PATH
181 {
182 return false;
183 }
184 self.entries.iter().any(|waiver| {
185 crate::legacy::modern_rule_id(&waiver.rule) == rule
186 && !is_expired(waiver, now)
187 && !self.is_superseded(&waiver.id)
188 && glob_matches(&waiver.path, file)
189 })
190 }
191
192 #[must_use]
194 pub fn apply_now(&self, result: CheckResult) -> CheckResult {
195 self.apply(result, OffsetDateTime::now_utc())
196 }
197
198 #[must_use]
200 pub fn apply(&self, mut result: CheckResult, now: OffsetDateTime) -> CheckResult {
201 result
202 .violations
203 .retain(|violation| !self.suppresses(&violation.rule, &violation.file, now));
204 if result.violations.is_empty() {
205 result.decision = Decision::Allow;
206 }
207 result
208 }
209
210 fn is_superseded(&self, id: &str) -> bool {
211 self.entries
212 .iter()
213 .any(|other| other.supersedes.as_deref() == Some(id))
214 }
215
216 #[must_use]
218 pub fn lint_now(&self) -> Vec<String> {
219 self.lint(OffsetDateTime::now_utc())
220 }
221
222 #[must_use]
224 pub fn active_now(&self) -> Vec<&Waiver> {
225 self.active(OffsetDateTime::now_utc())
226 }
227
228 #[must_use]
230 pub fn active(&self, now: OffsetDateTime) -> Vec<&Waiver> {
231 self.entries
232 .iter()
233 .filter(|waiver| !is_expired(waiver, now) && !self.is_superseded(&waiver.id))
234 .collect()
235 }
236
237 #[must_use]
241 pub fn lint(&self, now: OffsetDateTime) -> Vec<String> {
242 let mut findings = Vec::new();
243 for waiver in &self.entries {
244 if is_expired(waiver, now) {
245 findings.push(format!(
246 "waiver {} ({} on {}) is expired since {} — stale entry, prune it",
247 waiver.id, waiver.rule, waiver.path, waiver.expires_at
248 ));
249 }
250 if self.is_superseded(&waiver.id) {
251 findings.push(format!(
252 "waiver {} ({} on {}) is superseded — stale entry, prune it",
253 waiver.id, waiver.rule, waiver.path
254 ));
255 }
256 if let Some(target) = &waiver.contradicts {
257 findings.push(format!(
258 "waiver {} contradicts {} — resolve the pair; both remain on record",
259 waiver.id, target
260 ));
261 }
262 }
263 findings
264 }
265}
266
267fn is_expired(waiver: &Waiver, now: OffsetDateTime) -> bool {
268 OffsetDateTime::parse(&waiver.expires_at, &Rfc3339).map_or(true, |expires| expires <= now)
270}
271
272fn glob_matches(glob: &str, file: &str) -> bool {
273 Glob::new(glob).is_ok_and(|g| g.compile_matcher().is_match(file))
274}