1use std::collections::VecDeque;
20use std::sync::{Mutex, OnceLock};
21use std::time::Instant;
22
23use regex::Regex;
24
25pub struct EgressConfig {
28 forbidden: Vec<(String, Regex)>,
30 block_secrets: bool,
31 pub max_writes_per_min: Option<u32>,
33}
34
35impl Default for EgressConfig {
36 fn default() -> Self {
37 Self::off()
38 }
39}
40
41impl EgressConfig {
42 #[must_use]
44 pub fn off() -> Self {
45 Self {
46 forbidden: Vec::new(),
47 block_secrets: false,
48 max_writes_per_min: None,
49 }
50 }
51
52 #[must_use]
55 pub fn new(
56 forbidden_patterns: &[String],
57 block_secrets: bool,
58 max_writes_per_min: Option<u32>,
59 ) -> Self {
60 let forbidden = forbidden_patterns
61 .iter()
62 .filter_map(|p| Regex::new(p).ok().map(|re| (p.clone(), re)))
63 .collect();
64 Self {
65 forbidden,
66 block_secrets,
67 max_writes_per_min,
68 }
69 }
70
71 #[must_use]
73 pub fn is_active(&self) -> bool {
74 !self.forbidden.is_empty() || self.block_secrets || self.max_writes_per_min.is_some()
75 }
76
77 #[must_use]
82 pub fn check_content(&self, content: &str, redaction: &[(String, Regex)]) -> Option<String> {
83 for (source, re) in &self.forbidden {
84 if re.is_match(content) {
85 return Some(format!("forbidden-pattern:{source}"));
86 }
87 }
88 if self.block_secrets {
89 let (_, hits) = crate::core::redaction::redact_with_patterns(content, redaction);
90 if hits > 0 {
91 return Some("secret".to_string());
92 }
93 if let Some((class, _)) = crate::core::input_filters::pii::detect(content).first() {
94 return Some(format!("pii:{class}"));
95 }
96 }
97 None
98 }
99}
100
101#[must_use]
105pub fn check_rate(max_per_min: u32) -> bool {
106 let mut q = rate_state().lock().expect("egress rate state poisoned");
107 within_limit(&mut q, Instant::now(), max_per_min)
108}
109
110fn rate_state() -> &'static Mutex<VecDeque<Instant>> {
111 static STATE: OnceLock<Mutex<VecDeque<Instant>>> = OnceLock::new();
112 STATE.get_or_init(|| Mutex::new(VecDeque::new()))
113}
114
115fn within_limit(q: &mut VecDeque<Instant>, now: Instant, max_per_min: u32) -> bool {
118 while let Some(&front) = q.front() {
119 if now.duration_since(front).as_secs() >= 60 {
120 q.pop_front();
121 } else {
122 break;
123 }
124 }
125 if u32::try_from(q.len()).unwrap_or(u32::MAX) >= max_per_min {
126 return false;
127 }
128 q.push_back(now);
129 true
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use std::time::Duration;
136
137 fn cfg(patterns: &[&str], block_secrets: bool) -> EgressConfig {
138 let pats: Vec<String> = patterns.iter().map(|s| (*s).to_string()).collect();
139 EgressConfig::new(&pats, block_secrets, None)
140 }
141
142 #[test]
143 fn off_config_is_inactive() {
144 assert!(!EgressConfig::off().is_active());
145 }
146
147 #[test]
148 fn forbidden_pattern_blocks_action() {
149 let c = cfg(&[r"prod\.db\.internal"], false);
150 let reason = c.check_content("psql postgres://prod.db.internal/main", &[]);
151 assert_eq!(
152 reason.as_deref(),
153 Some("forbidden-pattern:prod\\.db\\.internal")
154 );
155 }
156
157 #[test]
158 fn clean_content_is_allowed() {
159 let c = cfg(&[r"prod\.db\.internal"], true);
160 assert!(
161 c.check_content("fn main() { println!(\"hi\"); }", &[])
162 .is_none()
163 );
164 }
165
166 #[test]
167 fn block_secrets_catches_pii() {
168 let c = cfg(&[], true);
169 let reason = c.check_content("email jane@example.com into config", &[]);
170 assert_eq!(reason.as_deref(), Some("pii:email"));
171 }
172
173 #[test]
174 fn block_secrets_catches_redaction_pattern() {
175 let c = cfg(&[], true);
176 let redaction = vec![("employee_id".to_string(), Regex::new(r"EMP-\d{4}").unwrap())];
177 let reason = c.check_content("commit by EMP-1234", &redaction);
178 assert_eq!(reason.as_deref(), Some("secret"));
179 }
180
181 #[test]
182 fn rate_limit_triggers_after_max() {
183 let mut q = VecDeque::new();
184 let now = Instant::now();
185 assert!(within_limit(&mut q, now, 2));
186 assert!(within_limit(&mut q, now, 2));
187 assert!(!within_limit(&mut q, now, 2));
189 }
190
191 #[test]
192 fn rate_limit_window_slides() {
193 let mut q = VecDeque::new();
194 let base = Instant::now();
195 assert!(within_limit(&mut q, base, 1));
196 assert!(!within_limit(&mut q, base, 1));
198 assert!(within_limit(&mut q, base + Duration::from_secs(61), 1));
200 }
201}