1use std::collections::HashMap;
44use std::io::Read as _;
45use std::path::{Path, PathBuf};
46use std::sync::Mutex;
47
48use serde::{Deserialize, Serialize};
49
50fn sha256_hex(bytes: &[u8]) -> String {
52 use sha2::{Digest, Sha256};
53 let digest = Sha256::digest(bytes);
54 digest
55 .iter()
56 .fold(String::with_capacity(64), |mut out, byte| {
57 use core::fmt::Write as _;
58 let _ = write!(out, "{byte:02x}");
59 out
60 })
61}
62
63pub fn engine_version() -> &'static str {
66 env!("CARGO_PKG_VERSION")
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum LoadOutcome {
73 Absent,
75 Loaded { foreign_versions_dropped: usize },
78 Corrupt { detail: String },
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84struct CachedEntry {
85 version: String,
86 safe: bool,
87 reason: String,
88}
89
90pub struct ValidationCache {
92 path: PathBuf,
93 entries: Mutex<HashMap<String, CachedEntry>>,
94 outcome: LoadOutcome,
95}
96
97impl std::fmt::Debug for ValidationCache {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.debug_struct("ValidationCache")
100 .field("path", &self.path)
101 .field("outcome", &self.outcome)
102 .finish_non_exhaustive()
103 }
104}
105
106impl ValidationCache {
107 #[must_use]
112 pub fn load(path: &Path) -> Self {
113 let mut outcome = LoadOutcome::Absent;
114 let mut entries = HashMap::new();
115 match std::fs::File::open(path) {
116 Err(_) => {}
117 Ok(mut file) => {
118 let mut raw = String::new();
119 let read = file.read_to_string(&mut raw);
120 match read.and_then(|_| {
121 serde_json::from_str::<HashMap<String, CachedEntry>>(&raw)
122 .map_err(|error| std::io::Error::other(error.to_string()))
123 }) {
124 Ok(file) => {
125 let mut foreign = 0usize;
126 for (key, entry) in file {
127 if entry.version == engine_version() {
128 entries.insert(key, entry);
129 } else {
130 foreign += 1;
131 }
132 }
133 outcome = LoadOutcome::Loaded {
134 foreign_versions_dropped: foreign,
135 };
136 }
137 Err(error) => {
138 outcome = LoadOutcome::Corrupt {
139 detail: error.to_string(),
140 };
141 }
142 }
143 }
144 }
145 Self {
146 path: path.to_path_buf(),
147 entries: Mutex::new(entries),
148 outcome,
149 }
150 }
151
152 #[must_use]
154 pub fn outcome(&self) -> &LoadOutcome {
155 &self.outcome
156 }
157
158 #[must_use]
161 pub fn key(pattern: &str, ignore_case: bool) -> String {
162 let mut keyed = pattern.as_bytes().to_vec();
163 keyed.push(0u8);
164 keyed.extend_from_slice(if ignore_case { b"i" } else { b"" });
165 sha256_hex(&keyed)
166 }
167
168 #[must_use]
170 pub fn get(&self, pattern: &str, ignore_case: bool) -> Option<(bool, String)> {
171 let entries = self.entries.lock().expect("validation cache");
172 entries
173 .get(&Self::key(pattern, ignore_case))
174 .map(|entry| (entry.safe, entry.reason.clone()))
175 }
176
177 pub fn put(&self, pattern: &str, ignore_case: bool, safe: bool, reason: &str) {
181 let key = Self::key(pattern, ignore_case);
182 let entry = CachedEntry {
183 version: engine_version().to_owned(),
184 safe,
185 reason: reason.to_owned(),
186 };
187 let payload = {
188 let mut entries = self.entries.lock().expect("validation cache");
189 entries.insert(key, entry);
190 serde_json::to_vec(&*entries)
191 };
192 let bytes = payload.expect("cache entries serialize");
194 let tmp_path = self.path.with_extension("tmp");
195 let written = std::fs::write(&tmp_path, bytes).is_ok();
199 if written && std::fs::rename(&tmp_path, &self.path).is_ok() {
200 return;
201 }
202 if written {
203 let _ = std::fs::remove_file(&tmp_path);
204 }
205 }
206
207 #[must_use]
212 pub fn validate_compat(&self, pattern: &str) -> (bool, String) {
213 self.validate_with_flags(pattern, super::safety::default_flags().ignorecase)
214 }
215
216 #[must_use]
219 pub fn validate_with_flags(&self, pattern: &str, ignore_case: bool) -> (bool, String) {
220 let flags = if ignore_case {
221 super::safety::default_flags()
222 } else {
223 super::ast::Flags::default()
224 };
225 if let Some(construct) = super::prefilters::dangerous_construct_violation(pattern) {
227 return (
228 false,
229 super::safety::SafetyReason::DangerousConstruct(construct).to_string(),
230 );
231 }
232 if let Err(error) = super::safety::compile_failed(pattern, flags) {
233 return (false, error);
234 }
235 if let Some((safe, reason)) = self.get(pattern, ignore_case) {
236 return (safe, reason);
237 }
238 let verdict = super::safety::validate_pattern_safety(
239 pattern,
240 &super::safety::SafetyMode::CostVerdict {
241 max_content_length: None,
242 },
243 );
244 let (safe, reason) = (verdict.safe, verdict.reason.to_string());
245 self.put(pattern, ignore_case, safe, &reason);
246 (safe, reason)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn tempdir() -> (std::path::PathBuf, PathBuf) {
255 static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
256 let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
257 let dir = std::env::temp_dir().join(format!(
258 "guard-validation-cache-{}-{seq}",
259 std::process::id()
260 ));
261 let _ = std::fs::remove_dir_all(&dir);
262 let _ = std::fs::create_dir_all(&dir);
263 let path = dir.join("validation-cache.json");
264 (dir, path)
265 }
266
267 fn cleanup(dir: &Path) {
268 let _ = std::fs::remove_dir_all(dir);
269 }
270
271 #[test]
272 fn a_missing_file_starts_absent_and_empty() {
273 let (dir, path) = tempdir();
274 let cache = ValidationCache::load(&path);
275 assert_eq!(cache.outcome(), &LoadOutcome::Absent);
276 assert!(cache.get("anything", true).is_none());
277 cleanup(&dir);
278 }
279
280 #[test]
281 fn the_first_verdict_persists_and_a_second_boot_reuses_it() {
282 let (dir, path) = tempdir();
283 let cache = ValidationCache::load(&path);
284 let (safe, _) = cache.validate_compat(r"^safe-\d+$");
285 assert!(safe);
286 assert!(path.is_file());
287
288 let reloaded = ValidationCache::load(&path);
289 assert_eq!(
290 reloaded.outcome(),
291 &LoadOutcome::Loaded {
292 foreign_versions_dropped: 0
293 }
294 );
295 let (safe, reason) = reloaded.get(r"^safe-\d+$", true).expect("cached");
296 assert!(safe);
297 assert!(!reason.is_empty());
298 cleanup(&dir);
299 }
300
301 #[test]
302 fn a_corrupt_file_starts_empty_with_the_outcome_named() {
303 let (dir, path) = tempdir();
304 std::fs::write(&path, "{not json").expect("corrupt file");
305 let cache = ValidationCache::load(&path);
306 assert!(matches!(cache.outcome(), LoadOutcome::Corrupt { .. }));
307 assert!(cache.get("anything", false).is_none());
308
309 let (safe, _) = cache.validate_compat(r"^safe-\d+$");
311 assert!(safe);
312 let reloaded = ValidationCache::load(&path);
313 assert!(reloaded.get(r"^safe-\d+$", true).is_some());
314 cleanup(&dir);
315 }
316
317 #[test]
318 fn foreign_version_entries_drop_on_load() {
319 let (dir, path) = tempdir();
320 let key = ValidationCache::key("some-pattern", true);
321 let foreign = format!(r#"{{"{key}":{{"version":"0.0.1","safe":false,"reason":"stale"}}}}"#);
322 std::fs::write(&path, foreign).expect("foreign cache");
323 let cache = ValidationCache::load(&path);
324 assert_eq!(
325 cache.outcome(),
326 &LoadOutcome::Loaded {
327 foreign_versions_dropped: 1
328 }
329 );
330 assert!(cache.get("some-pattern", true).is_none());
331 cleanup(&dir);
332 }
333
334 #[test]
335 fn the_cheap_layers_re_run_even_when_cached() {
336 let (dir, path) = tempdir();
337 let cache = ValidationCache::load(&path);
338 cache.put("(a+)+$", true, true, "seeded");
340 assert!(cache.get("(a+)+$", true).is_some());
341
342 let (safe, reason) = cache.validate_compat("(a+)+$");
344 assert!(!safe);
345 assert!(reason.contains("dangerous") || !reason.is_empty());
346 cleanup(&dir);
347 }
348
349 #[test]
350 fn the_flags_char_splits_the_cache_keys() {
351 let key_case = ValidationCache::key("pattern", true);
352 let key_plain = ValidationCache::key("pattern", false);
353 assert_ne!(key_case, key_plain);
354 let mut keyed = b"pattern".to_vec();
356 keyed.push(0u8);
357 keyed.extend_from_slice(b"i");
358 assert_eq!(key_case, sha256_hex(&keyed));
359 }
360
361 #[test]
362 fn an_unsafe_verdict_caches_too() {
363 let (dir, path) = tempdir();
364 let cache = ValidationCache::load(&path);
365 cache.put("^(?:a|a?)+$", true, false, "over budget");
368 let reloaded = ValidationCache::load(&path);
369 let (safe, reason) = reloaded.get("^(?:a|a?)+$", true).expect("cached");
370 assert!(!safe);
371 assert_eq!(reason, "over budget");
372 cleanup(&dir);
373 }
374
375 #[test]
376 fn an_unpublishable_cache_keeps_the_memory_entry() {
377 let (dir, path) = tempdir();
378 std::fs::create_dir_all(&path).expect("cache path as dir");
380 let cache = ValidationCache::load(&path);
381 cache.put("some-pattern", true, true, "reason");
382 assert_eq!(
384 cache.get("some-pattern", true),
385 Some((true, "reason".to_owned()))
386 );
387 cleanup(&dir);
388 }
389
390 #[test]
391 fn the_debug_shape_names_the_path_and_outcome() {
392 let (dir, path) = tempdir();
393 let cache = ValidationCache::load(&path);
394 let rendered = format!("{cache:?}");
395 assert!(rendered.contains("validation-cache.json"));
396 assert!(rendered.contains("Absent"));
397 cleanup(&dir);
398 }
399
400 #[test]
401 fn an_uncompilable_pattern_is_rejected_before_the_cache() {
402 let (dir, path) = tempdir();
403 let cache = ValidationCache::load(&path);
404 let (safe, reason) = cache.validate_with_flags("(unclosed", true);
407 assert!(!safe);
408 assert!(!reason.is_empty());
409 assert!(cache.get("(unclosed", true).is_none());
410 cleanup(&dir);
411 }
412
413 #[test]
414 fn a_repeat_validation_answers_from_the_cache() {
415 let (dir, path) = tempdir();
416 let cache = ValidationCache::load(&path);
417 let (safe, _) = cache.validate_compat(r"^safe-\d+$");
418 assert!(safe);
419 let (safe, reason) = cache.validate_compat(r"^safe-\d+$");
421 assert!(safe);
422 assert!(!reason.is_empty());
423 cleanup(&dir);
424 }
425
426 #[test]
427 fn case_sensitive_validation_uses_the_plain_flags_arm() {
428 let (dir, path) = tempdir();
429 let cache = ValidationCache::load(&path);
430 let (safe, _) = cache.validate_with_flags(r"^safe-[a-z]+$", false);
432 assert!(safe);
433 assert!(cache.get(r"^safe-[a-z]+$", false).is_some());
434 cleanup(&dir);
435 }
436}