1use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct WebCategory {
42 pub name: String,
44 pub description: String,
46 pub allowlist: Vec<String>,
48 pub blocklist: Vec<String>,
50 pub require_permission: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
59#[non_exhaustive]
60pub struct WebAccessPolicy {
61 pub enabled: bool,
63 pub mode: String,
65 pub rate_limit_per_minute: u32,
67 pub max_concurrent_fetches: u32,
69 pub max_result_size_bytes: usize,
71 pub categories: Vec<WebCategory>,
73}
74
75impl WebAccessPolicy {
76 pub fn disabled() -> Self {
81 Self {
82 enabled: false,
83 ..Self::default()
84 }
85 }
86
87 pub fn is_strict(&self) -> bool {
89 self.mode.eq_ignore_ascii_case("strict")
90 }
91
92 pub fn classify(&self, domain: &str) -> &WebCategory {
95 let domain_lower = domain.to_lowercase();
96 for cat in &self.categories {
97 if cat
98 .blocklist
99 .iter()
100 .any(|d| domain_lower.contains(d.as_str()))
101 {
102 return cat;
103 }
104 if cat
105 .allowlist
106 .iter()
107 .any(|d| domain_lower.contains(d.as_str()))
108 {
109 return cat;
110 }
111 }
112 self.categories
114 .last()
115 .expect("WebAccessPolicy categories must not be empty")
116 }
117
118 pub fn is_allowed(&self, domain: &str) -> (bool, String) {
123 if !self.enabled {
124 return (true, "policy_disabled".to_string());
125 }
126 let domain_lower = domain.to_lowercase();
127 let cat = self.classify(domain);
128
129 if cat
131 .blocklist
132 .iter()
133 .any(|d| domain_lower.contains(d.as_str()))
134 {
135 return (false, format!("blocked by {} category", cat.name));
136 }
137
138 if self.is_strict()
140 && !cat
141 .allowlist
142 .iter()
143 .any(|d| domain_lower.contains(d.as_str()))
144 {
145 return (false, format!("not allowlisted (category: {})", cat.name));
146 }
147
148 if cat.require_permission {
150 return (
151 false,
152 format!("permission required (category: {})", cat.name),
153 );
154 }
155
156 (true, cat.name.clone())
157 }
158}
159
160impl Default for WebAccessPolicy {
161 fn default() -> Self {
162 Self {
163 enabled: true,
164 mode: "permissive".to_string(),
165 rate_limit_per_minute: 30,
166 max_concurrent_fetches: 5,
167 max_result_size_bytes: 1_048_576,
168 categories: default_categories(),
169 }
170 }
171}
172
173pub fn default_categories() -> Vec<WebCategory> {
176 vec![
177 WebCategory {
178 name: "news".to_string(),
179 description: "News sites and media outlets".to_string(),
180 allowlist: vec![
181 "nrk.no".to_string(),
182 "vg.no".to_string(),
183 "bbc.com".to_string(),
184 "cnn.com".to_string(),
185 "reuters.com".to_string(),
186 "apnews.com".to_string(),
187 "theguardian.com".to_string(),
188 "nytimes.com".to_string(),
189 "dw.com".to_string(),
190 ],
191 blocklist: vec![],
192 require_permission: false,
193 },
194 WebCategory {
195 name: "search".to_string(),
196 description: "Search engines".to_string(),
197 allowlist: vec![
198 "google.com".to_string(),
199 "bing.com".to_string(),
200 "duckduckgo.com".to_string(),
201 "search.brave.com".to_string(),
202 "kagi.com".to_string(),
203 ],
204 blocklist: vec![],
205 require_permission: false,
206 },
207 WebCategory {
208 name: "code_repos".to_string(),
209 description: "Code repositories".to_string(),
210 allowlist: vec![
211 "github.com".to_string(),
212 "gitlab.com".to_string(),
213 "bitbucket.org".to_string(),
214 "codeberg.org".to_string(),
215 "gitea.com".to_string(),
216 ],
217 blocklist: vec![],
218 require_permission: false,
219 },
220 WebCategory {
221 name: "documentation".to_string(),
222 description: "Technical docs and references".to_string(),
223 allowlist: vec![
224 "docs.rs".to_string(),
225 "crates.io".to_string(),
226 "pypi.org".to_string(),
227 "npmjs.com".to_string(),
228 "developer.mozilla.org".to_string(),
229 "w3.org".to_string(),
230 "stackoverflow.com".to_string(),
231 "wikipedia.org".to_string(),
232 "arxiv.org".to_string(),
233 "kubernetes.io".to_string(),
234 "helm.sh".to_string(),
235 ],
236 blocklist: vec![],
237 require_permission: false,
238 },
239 WebCategory {
240 name: "social_media".to_string(),
241 description: "Social media platforms".to_string(),
242 allowlist: vec![],
243 blocklist: vec![
244 "facebook.com".to_string(),
245 "instagram.com".to_string(),
246 "twitter.com".to_string(),
247 "tiktok.com".to_string(),
248 "snapchat.com".to_string(),
249 "reddit.com".to_string(),
250 ],
251 require_permission: true,
252 },
253 WebCategory {
254 name: "uncategorized".to_string(),
255 description: "All other domains".to_string(),
256 allowlist: vec![],
257 blocklist: vec![],
258 require_permission: true,
259 },
260 ]
261}
262
263pub fn extract_domain(param_str: &str) -> String {
268 let s = param_str.trim().trim_matches('"');
269 if let Some(start) = s.find("://") {
270 let after = &s[start + 3..];
271 let end = after.find('/').unwrap_or(after.len());
272 return after[..end].to_string();
273 }
274 let end = s.find('/').unwrap_or(s.len());
275 s[..end].to_string()
276}
277
278#[allow(dead_code)]
281#[derive(Debug, Clone)]
282pub struct RateLimiter {
283 buckets: HashMap<String, (std::time::Instant, u32)>,
284 max_per_minute: u32,
285}
286
287#[allow(dead_code)]
288impl RateLimiter {
289 pub fn new(max_per_minute: u32) -> Self {
291 Self {
292 buckets: HashMap::new(),
293 max_per_minute,
294 }
295 }
296
297 pub fn check(&mut self, key: &str) -> bool {
300 let now = std::time::Instant::now();
301 let entry = self.buckets.entry(key.to_string()).or_insert((now, 0));
302 if now.duration_since(entry.0).as_secs() > 60 {
303 *entry = (now, 1);
304 return true;
305 }
306 if entry.1 >= self.max_per_minute {
307 return false;
308 }
309 entry.1 += 1;
310 true
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_default_policy_enabled_permissive() {
320 let p = WebAccessPolicy::default();
321 assert!(p.enabled);
322 assert!(!p.is_strict());
323 assert!(!p.categories.is_empty());
324 }
325
326 #[test]
327 fn test_allowlisted_domain_passes() {
328 let p = WebAccessPolicy::default();
329 let (allowed, reason) = p.is_allowed("github.com");
330 assert!(allowed);
331 assert_eq!(reason, "code_repos");
332 }
333
334 #[test]
335 fn test_blocklisted_domain_denied() {
336 let p = WebAccessPolicy::default();
337 let (allowed, reason) = p.is_allowed("facebook.com");
338 assert!(!allowed);
339 assert!(reason.contains("blocked"));
340 }
341
342 #[test]
343 fn test_uncategorized_requires_permission() {
344 let p = WebAccessPolicy::default();
345 let (allowed, reason) = p.is_allowed("some-random-domain.example");
346 assert!(!allowed);
347 assert!(reason.contains("permission required"));
348 }
349
350 #[test]
351 fn test_disabled_policy_allows_all() {
352 let p = WebAccessPolicy {
353 enabled: false,
354 ..WebAccessPolicy::default()
355 };
356 let (allowed, reason) = p.is_allowed("facebook.com");
357 assert!(allowed);
358 assert_eq!(reason, "policy_disabled");
359 }
360
361 #[test]
362 fn test_strict_mode_denies_unallowlisted() {
363 let p = WebAccessPolicy {
364 mode: "strict".to_string(),
365 ..WebAccessPolicy::default()
366 };
367 assert!(p.is_strict());
368 assert!(p.is_allowed("github.com").0);
370 let (allowed, reason) = p.is_allowed("not-in-any-list.example");
372 assert!(!allowed);
373 assert!(reason.contains("not allowlisted"));
374 }
375
376 #[test]
377 fn test_classify_case_insensitive() {
378 let p = WebAccessPolicy::default();
379 assert_eq!(p.classify("GitHub.COM").name, "code_repos");
380 assert_eq!(p.classify("github.com").name, "code_repos");
381 }
382
383 #[test]
384 fn test_extract_domain_from_url() {
385 assert_eq!(extract_domain("https://docs.rs/ravenclaws"), "docs.rs");
386 assert_eq!(extract_domain("http://github.com/egkristi"), "github.com");
387 assert_eq!(
388 extract_domain("\"https://example.com/path\""),
389 "example.com"
390 );
391 }
392
393 #[test]
394 fn test_extract_domain_from_query() {
395 assert_eq!(extract_domain("rust documentation"), "rust documentation");
396 assert_eq!(extract_domain("github.com"), "github.com");
397 }
398
399 #[test]
400 fn test_rate_limiter_allows_within_limit() {
401 let mut rl = RateLimiter::new(2);
402 assert!(rl.check("docs.rs"));
403 assert!(rl.check("docs.rs"));
404 assert!(!rl.check("docs.rs")); }
406
407 #[test]
408 fn test_rate_limiter_independent_keys() {
409 let mut rl = RateLimiter::new(1);
410 assert!(rl.check("a.example"));
411 assert!(rl.check("b.example")); assert!(!rl.check("a.example"));
413 }
414}