Skip to main content

guard_core_engine/redos/
validation_cache.rs

1//! The disk-backed cache for the pattern-safety validator's expensive
2//! layer: the reference `_validation_cache.py` (the PHP lane's
3//! `Detection/Redos/ValidationCache.php` and the TS
4//! `PatternValidationCache` port the same module).
5//!
6//! The cheap deterministic layers (dangerous constructs, compile check,
7//! structural detectors) always re-run; only the empirical cost-verdict
8//! outcome is cached, keyed by pattern and flags, so a process boot reuses
9//! prior certifications instead of re-timing every custom pattern.
10//!
11//! File shape: a JSON object mapping `sha256(pattern 0x00 flags)` hex keys
12//! to `{version, safe, reason}` entries; entries from a different engine
13//! version are dropped on load, a corrupt file starts empty (the outcome
14//! surfaces as [`LoadOutcome`] data - the engine keeps its no-logger
15//! idiom), and a write is atomic (tmp file plus rename).
16//!
17//! # Example
18//!
19//! ```
20//! use guard_core_engine::redos::validation_cache::{LoadOutcome, ValidationCache};
21//!
22//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
23//! let dir = std::env::temp_dir().join("guard-validation-cache-doctest");
24//! let _ = std::fs::remove_dir_all(&dir);
25//! std::fs::create_dir_all(&dir)?;
26//! let path = dir.join("validation-cache.json");
27//! let cache = ValidationCache::load(&path);
28//! assert!(matches!(cache.outcome(), LoadOutcome::Absent));
29//!
30//! // First sight: the empirical chain runs, the verdict persists.
31//! let (safe, _) = cache.validate_compat(r"^safe-\d+$");
32//! assert!(safe);
33//! assert!(path.is_file(), "the verdict landed on disk");
34//!
35//! // Second boot: the same pattern answers from the loaded file.
36//! let reloaded = ValidationCache::load(&path);
37//! assert!(reloaded.get(r"^safe-\d+$", true).is_some());
38//! std::fs::remove_dir_all(&dir)?;
39//! # Ok(())
40//! # }
41//! ```
42
43use std::collections::HashMap;
44use std::io::Read as _;
45use std::path::{Path, PathBuf};
46use std::sync::Mutex;
47
48use serde::{Deserialize, Serialize};
49
50/// Lowercase hex SHA-256 over `bytes` (the reference `hexdigest`).
51fn 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
63/// The cache-invalidating engine identity (the crate version): bumped
64/// with every release so verdicts from an older engine never load.
65pub fn engine_version() -> &'static str {
66    env!("CARGO_PKG_VERSION")
67}
68
69/// How the cache file loaded (the reference's log-once outcomes, surfaced
70/// as data).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum LoadOutcome {
73    /// No file yet (first boot).
74    Absent,
75    /// The file parsed; entries from other engine versions were dropped
76    /// (count named).
77    Loaded { foreign_versions_dropped: usize },
78    /// The file exists but is unreadable or corrupt: the cache starts
79    /// empty (the reference logs a warning; the data rides here).
80    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
90/// The disk-backed pattern-validation cache.
91pub 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    /// Load (or start) the cache at `path`: a missing file starts empty
108    /// ([`LoadOutcome::Absent`]), a corrupt or unreadable file starts
109    /// empty ([`LoadOutcome::Corrupt`]), and entries stamped with another
110    /// engine version drop on load.
111    #[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    /// The load outcome (the caller logs it).
153    #[must_use]
154    pub fn outcome(&self) -> &LoadOutcome {
155        &self.outcome
156    }
157
158    /// The reference `_key`: sha256 over the pattern, a `0x00` separator,
159    /// and the flags char (`i` when case-insensitive).
160    #[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    /// The cached verdict, `None` when the pattern was never certified.
169    #[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    /// Record one verdict and persist atomically (tmp file plus rename; a
178    /// failed write leaves the in-memory entry and drops the persistence,
179    /// the reference's logged-but-survivable outcome).
180    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        // An in-memory map of strings and bools always serializes.
193        let bytes = payload.expect("cache entries serialize");
194        let tmp_path = self.path.with_extension("tmp");
195        // Atomic publish: tmp write then rename. A failed rename leaves
196        // the tmp behind for the next write to overwrite (the reference's
197        // logged-but-survivable outcome).
198        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    /// The cached validation entry point (the `validate_pattern_safety`
208    /// compat shape): the cheap deterministic layers always re-run, the
209    /// empirical cost-verdict layer answers from the cache on a hit and
210    /// persists on a miss.
211    #[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    /// [`ValidationCache::validate_compat`] with the flags char explicit
217    /// (a pattern validated under different flags caches separately).
218    #[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        // The cheap layers re-run every time.
226        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        // The cache still validates and re-persists over the corruption.
310        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        // Seed the cache with a "safe" verdict for a dangerous pattern.
339        cache.put("(a+)+$", true, true, "seeded");
340        assert!(cache.get("(a+)+$", true).is_some());
341
342        // The dangerous-construct layer fires before the cache answers.
343        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        // sha256 over the documented byte shape.
355        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        // Seed a recorded-unsafe verdict for a pattern the cheap layers
366        // pass (no dangerous construct, compiles): the cache answers it.
367        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        // The cache path itself is a directory: the rename cannot land.
379        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        // The in-memory entry stays answerable; nothing panics.
383        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        // An unclosed group passes the dangerous-construct scan and
405        // fails the compile check: rejected, nothing cached.
406        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        // The second pass hits the recorded verdict without re-timing.
420        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        // The not-ignorecase arm builds the default flags and compiles.
431        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}