1use std::collections::HashMap;
8use std::path::Path;
9
10#[derive(Debug, Clone)]
12pub struct EffectPolicy {
13 pub hosts: Vec<String>,
15 pub paths: Vec<String>,
17 pub keys: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
23pub struct ProjectConfig {
24 pub effect_policies: HashMap<String, EffectPolicy>,
26}
27
28impl ProjectConfig {
29 pub fn load_from_dir(dir: &Path) -> Result<Option<Self>, String> {
33 let path = dir.join("aver.toml");
34 let content = match std::fs::read_to_string(&path) {
35 Ok(c) => c,
36 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
37 Err(e) => return Err(format!("Failed to read {}: {}", path.display(), e)),
38 };
39 Self::parse(&content).map(Some)
40 }
41
42 pub fn parse(content: &str) -> Result<Self, String> {
44 let table: toml::Table = content
45 .parse()
46 .map_err(|e: toml::de::Error| format!("aver.toml parse error: {}", e))?;
47
48 let mut effect_policies = HashMap::new();
49
50 if let Some(toml::Value::Table(effects_table)) = table.get("effects") {
51 for (name, value) in effects_table {
52 let section = value
53 .as_table()
54 .ok_or_else(|| format!("aver.toml: [effects.{}] must be a table", name))?;
55
56 let hosts = if let Some(val) = section.get("hosts") {
57 let arr = val.as_array().ok_or_else(|| {
58 format!("aver.toml: [effects.{}].hosts must be an array", name)
59 })?;
60 arr.iter()
61 .enumerate()
62 .map(|(i, v)| {
63 v.as_str().map(|s| s.to_string()).ok_or_else(|| {
64 format!(
65 "aver.toml: [effects.{}].hosts[{}] must be a string",
66 name, i
67 )
68 })
69 })
70 .collect::<Result<Vec<_>, _>>()?
71 } else {
72 Vec::new()
73 };
74
75 let paths = if let Some(val) = section.get("paths") {
76 let arr = val.as_array().ok_or_else(|| {
77 format!("aver.toml: [effects.{}].paths must be an array", name)
78 })?;
79 arr.iter()
80 .enumerate()
81 .map(|(i, v)| {
82 v.as_str().map(|s| s.to_string()).ok_or_else(|| {
83 format!(
84 "aver.toml: [effects.{}].paths[{}] must be a string",
85 name, i
86 )
87 })
88 })
89 .collect::<Result<Vec<_>, _>>()?
90 } else {
91 Vec::new()
92 };
93
94 let keys = if let Some(val) = section.get("keys") {
95 let arr = val.as_array().ok_or_else(|| {
96 format!("aver.toml: [effects.{}].keys must be an array", name)
97 })?;
98 arr.iter()
99 .enumerate()
100 .map(|(i, v)| {
101 v.as_str().map(|s| s.to_string()).ok_or_else(|| {
102 format!(
103 "aver.toml: [effects.{}].keys[{}] must be a string",
104 name, i
105 )
106 })
107 })
108 .collect::<Result<Vec<_>, _>>()?
109 } else {
110 Vec::new()
111 };
112
113 effect_policies.insert(name.clone(), EffectPolicy { hosts, paths, keys });
114 }
115 }
116
117 Ok(ProjectConfig { effect_policies })
118 }
119
120 pub fn check_http_host(&self, method_name: &str, url_str: &str) -> Result<(), String> {
123 let namespace = method_name.split('.').next().unwrap_or(method_name);
125 let policy = self
126 .effect_policies
127 .get(method_name)
128 .or_else(|| self.effect_policies.get(namespace));
129
130 let Some(policy) = policy else {
131 return Ok(()); };
133
134 if policy.hosts.is_empty() {
135 return Ok(()); }
137
138 let parsed = url::Url::parse(url_str).map_err(|e| {
139 format!(
140 "{} denied by aver.toml: invalid URL '{}': {}",
141 method_name, url_str, e
142 )
143 })?;
144
145 let host = parsed.host_str().unwrap_or("");
146
147 for allowed in &policy.hosts {
148 if host_matches(host, allowed) {
149 return Ok(());
150 }
151 }
152
153 Err(format!(
154 "{} to '{}' denied by aver.toml policy (host '{}' not in allowed list)",
155 method_name, url_str, host
156 ))
157 }
158
159 pub fn check_disk_path(&self, method_name: &str, path_str: &str) -> Result<(), String> {
162 let namespace = method_name.split('.').next().unwrap_or(method_name);
163 let policy = self
164 .effect_policies
165 .get(method_name)
166 .or_else(|| self.effect_policies.get(namespace));
167
168 let Some(policy) = policy else {
169 return Ok(());
170 };
171
172 if policy.paths.is_empty() {
173 return Ok(());
174 }
175
176 let normalized = normalize_path(path_str);
178
179 for allowed in &policy.paths {
180 if path_matches(&normalized, allowed) {
181 return Ok(());
182 }
183 }
184
185 Err(format!(
186 "{} on '{}' denied by aver.toml policy (path not in allowed list)",
187 method_name, path_str
188 ))
189 }
190
191 pub fn check_env_key(&self, method_name: &str, key: &str) -> Result<(), String> {
194 let namespace = method_name.split('.').next().unwrap_or(method_name);
195 let policy = self
196 .effect_policies
197 .get(method_name)
198 .or_else(|| self.effect_policies.get(namespace));
199
200 let Some(policy) = policy else {
201 return Ok(());
202 };
203
204 if policy.keys.is_empty() {
205 return Ok(());
206 }
207
208 for allowed in &policy.keys {
209 if env_key_matches(key, allowed) {
210 return Ok(());
211 }
212 }
213
214 Err(format!(
215 "{} on '{}' denied by aver.toml policy (key not in allowed list)",
216 method_name, key
217 ))
218 }
219}
220
221fn host_matches(host: &str, pattern: &str) -> bool {
224 if pattern == host {
225 return true;
226 }
227 if let Some(suffix) = pattern.strip_prefix("*.") {
228 host.ends_with(suffix)
230 && host.len() > suffix.len()
231 && host.as_bytes()[host.len() - suffix.len() - 1] == b'.'
232 } else {
233 false
234 }
235}
236
237fn normalize_path(path: &str) -> String {
242 let path = Path::new(path);
243 let mut components: Vec<String> = Vec::new();
244 let mut is_absolute = false;
245
246 for comp in path.components() {
247 match comp {
248 std::path::Component::RootDir => {
249 is_absolute = true;
250 components.clear();
251 }
252 std::path::Component::CurDir => {} std::path::Component::ParentDir => {
254 if components.last().is_some_and(|c| c != "..") {
256 components.pop();
257 } else if !is_absolute {
258 components.push("..".to_string());
260 }
261 }
263 std::path::Component::Normal(s) => {
264 components.push(s.to_string_lossy().to_string());
265 }
266 std::path::Component::Prefix(p) => {
267 components.push(p.as_os_str().to_string_lossy().to_string());
268 }
269 }
270 }
271
272 let joined = components.join("/");
273 if is_absolute {
274 format!("/{}", joined)
275 } else {
276 joined
277 }
278}
279
280fn path_matches(normalized: &str, pattern: &str) -> bool {
285 let clean_pattern = if let Some(base) = pattern.strip_suffix("/**") {
286 normalize_path(base)
287 } else {
288 normalize_path(pattern)
289 };
290
291 if normalized == clean_pattern {
293 return true;
294 }
295
296 if normalized.starts_with(&clean_pattern) {
298 let rest = &normalized[clean_pattern.len()..];
299 if rest.starts_with('/') {
300 return true;
301 }
302 }
303
304 false
305}
306
307fn env_key_matches(key: &str, pattern: &str) -> bool {
310 if pattern == key {
311 return true;
312 }
313 if let Some(prefix) = pattern.strip_suffix('*') {
314 key.starts_with(prefix)
315 } else {
316 false
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_parse_empty_toml() {
326 let config = ProjectConfig::parse("").unwrap();
327 assert!(config.effect_policies.is_empty());
328 }
329
330 #[test]
331 fn test_parse_http_hosts() {
332 let toml = r#"
333[effects.Http]
334hosts = ["api.example.com", "*.internal.corp"]
335"#;
336 let config = ProjectConfig::parse(toml).unwrap();
337 let policy = config.effect_policies.get("Http").unwrap();
338 assert_eq!(policy.hosts.len(), 2);
339 assert_eq!(policy.hosts[0], "api.example.com");
340 assert_eq!(policy.hosts[1], "*.internal.corp");
341 }
342
343 #[test]
344 fn test_parse_disk_paths() {
345 let toml = r#"
346[effects.Disk]
347paths = ["./data/**"]
348"#;
349 let config = ProjectConfig::parse(toml).unwrap();
350 let policy = config.effect_policies.get("Disk").unwrap();
351 assert_eq!(policy.paths, vec!["./data/**"]);
352 }
353
354 #[test]
355 fn test_parse_env_keys() {
356 let toml = r#"
357[effects.Env]
358keys = ["APP_*", "TOKEN"]
359"#;
360 let config = ProjectConfig::parse(toml).unwrap();
361 let policy = config.effect_policies.get("Env").unwrap();
362 assert_eq!(policy.keys, vec!["APP_*", "TOKEN"]);
363 }
364
365 #[test]
366 fn test_check_http_host_allowed() {
367 let toml = r#"
368[effects.Http]
369hosts = ["api.example.com"]
370"#;
371 let config = ProjectConfig::parse(toml).unwrap();
372 assert!(
373 config
374 .check_http_host("Http.get", "https://api.example.com/data")
375 .is_ok()
376 );
377 }
378
379 #[test]
380 fn test_check_http_host_denied() {
381 let toml = r#"
382[effects.Http]
383hosts = ["api.example.com"]
384"#;
385 let config = ProjectConfig::parse(toml).unwrap();
386 let result = config.check_http_host("Http.get", "https://evil.com/data");
387 assert!(result.is_err());
388 assert!(result.unwrap_err().contains("denied by aver.toml"));
389 }
390
391 #[test]
392 fn test_check_http_host_wildcard() {
393 let toml = r#"
394[effects.Http]
395hosts = ["*.internal.corp"]
396"#;
397 let config = ProjectConfig::parse(toml).unwrap();
398 assert!(
399 config
400 .check_http_host("Http.get", "https://api.internal.corp/data")
401 .is_ok()
402 );
403 assert!(
404 config
405 .check_http_host("Http.get", "https://internal.corp/data")
406 .is_err()
407 );
408 }
409
410 #[test]
411 fn test_check_disk_path_allowed() {
412 let toml = r#"
413[effects.Disk]
414paths = ["./data/**"]
415"#;
416 let config = ProjectConfig::parse(toml).unwrap();
417 assert!(
418 config
419 .check_disk_path("Disk.readText", "data/file.txt")
420 .is_ok()
421 );
422 assert!(
423 config
424 .check_disk_path("Disk.readText", "data/sub/deep.txt")
425 .is_ok()
426 );
427 }
428
429 #[test]
430 fn test_check_disk_path_denied() {
431 let toml = r#"
432[effects.Disk]
433paths = ["./data/**"]
434"#;
435 let config = ProjectConfig::parse(toml).unwrap();
436 let result = config.check_disk_path("Disk.readText", "/etc/passwd");
437 assert!(result.is_err());
438 }
439
440 #[test]
441 fn test_check_disk_path_traversal_blocked() {
442 let toml = r#"
443[effects.Disk]
444paths = ["./data/**"]
445"#;
446 let config = ProjectConfig::parse(toml).unwrap();
447 assert!(
449 config
450 .check_disk_path("Disk.readText", "data/../etc/passwd")
451 .is_err()
452 );
453 assert!(
455 config
456 .check_disk_path("Disk.readText", "../../data/secret")
457 .is_err()
458 );
459 assert!(
461 config
462 .check_disk_path("Disk.readText", "../../../etc/passwd")
463 .is_err()
464 );
465 }
466
467 #[test]
468 fn test_no_policy_allows_all() {
469 let config = ProjectConfig::parse("").unwrap();
470 assert!(
471 config
472 .check_http_host("Http.get", "https://anything.com/data")
473 .is_ok()
474 );
475 assert!(config.check_disk_path("Disk.readText", "/any/path").is_ok());
476 assert!(config.check_env_key("Env.get", "ANY_KEY").is_ok());
477 }
478
479 #[test]
480 fn test_empty_hosts_allows_all() {
481 let toml = r#"
482[effects.Http]
483hosts = []
484"#;
485 let config = ProjectConfig::parse(toml).unwrap();
486 assert!(
487 config
488 .check_http_host("Http.get", "https://anything.com")
489 .is_ok()
490 );
491 }
492
493 #[test]
494 fn test_malformed_toml() {
495 let result = ProjectConfig::parse("invalid = [");
496 assert!(result.is_err());
497 }
498
499 #[test]
500 fn test_non_string_hosts_are_rejected() {
501 let toml = r#"
502[effects.Http]
503hosts = [42, "api.example.com"]
504"#;
505 let result = ProjectConfig::parse(toml);
506 assert!(result.is_err());
507 assert!(result.unwrap_err().contains("must be a string"));
508 }
509
510 #[test]
511 fn test_non_string_paths_are_rejected() {
512 let toml = r#"
513[effects.Disk]
514paths = [true]
515"#;
516 let result = ProjectConfig::parse(toml);
517 assert!(result.is_err());
518 assert!(result.unwrap_err().contains("must be a string"));
519 }
520
521 #[test]
522 fn test_non_string_keys_are_rejected() {
523 let toml = r#"
524[effects.Env]
525keys = [1]
526"#;
527 let result = ProjectConfig::parse(toml);
528 assert!(result.is_err());
529 assert!(result.unwrap_err().contains("must be a string"));
530 }
531
532 #[test]
533 fn test_check_env_key_allowed_exact() {
534 let toml = r#"
535[effects.Env]
536keys = ["SECRET_TOKEN"]
537"#;
538 let config = ProjectConfig::parse(toml).unwrap();
539 assert!(config.check_env_key("Env.get", "SECRET_TOKEN").is_ok());
540 assert!(config.check_env_key("Env.get", "SECRET_TOKEN_2").is_err());
541 }
542
543 #[test]
544 fn test_check_env_key_allowed_prefix_wildcard() {
545 let toml = r#"
546[effects.Env]
547keys = ["APP_*"]
548"#;
549 let config = ProjectConfig::parse(toml).unwrap();
550 assert!(config.check_env_key("Env.get", "APP_PORT").is_ok());
551 assert!(config.check_env_key("Env.set", "APP_MODE").is_ok());
552 assert!(config.check_env_key("Env.get", "HOME").is_err());
553 }
554
555 #[test]
556 fn test_check_env_key_method_specific_overrides_namespace() {
557 let toml = r#"
558[effects.Env]
559keys = ["APP_*"]
560
561[effects."Env.get"]
562keys = ["PUBLIC_*"]
563"#;
564 let config = ProjectConfig::parse(toml).unwrap();
565 assert!(config.check_env_key("Env.get", "PUBLIC_KEY").is_ok());
567 assert!(config.check_env_key("Env.get", "APP_KEY").is_err());
568 assert!(config.check_env_key("Env.set", "APP_KEY").is_ok());
570 assert!(config.check_env_key("Env.set", "PUBLIC_KEY").is_err());
571 }
572
573 #[test]
574 fn host_matches_exact() {
575 assert!(host_matches("api.example.com", "api.example.com"));
576 assert!(!host_matches("other.com", "api.example.com"));
577 }
578
579 #[test]
580 fn host_matches_wildcard() {
581 assert!(host_matches("sub.example.com", "*.example.com"));
582 assert!(host_matches("deep.sub.example.com", "*.example.com"));
583 assert!(!host_matches("example.com", "*.example.com"));
584 }
585
586 #[test]
587 fn env_key_matches_exact() {
588 assert!(env_key_matches("TOKEN", "TOKEN"));
589 assert!(!env_key_matches("TOKEN", "TOK"));
590 }
591
592 #[test]
593 fn env_key_matches_prefix_wildcard() {
594 assert!(env_key_matches("APP_PORT", "APP_*"));
595 assert!(env_key_matches("APP_", "APP_*"));
596 assert!(!env_key_matches("PORT", "APP_*"));
597 }
598}