Skip to main content

aver/
config.rs

1/// Project configuration from `aver.toml`.
2///
3/// Currently supports runtime effect policies:
4///   [effects.Http]   hosts = ["api.example.com", "*.internal.corp"]
5///   [effects.Disk]   paths = ["./data/**"]
6///   [effects.Env]    keys  = ["APP_*", "TOKEN"]
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Runtime policy for a single effect namespace.
11#[derive(Debug, Clone)]
12pub struct EffectPolicy {
13    /// Allowed HTTP hosts (exact or wildcard `*.domain`).
14    pub hosts: Vec<String>,
15    /// Allowed filesystem paths (exact or recursive `/**`).
16    pub paths: Vec<String>,
17    /// Allowed environment variable keys (exact or wildcard `PREFIX_*`).
18    pub keys: Vec<String>,
19}
20
21/// Project-level configuration loaded from `aver.toml`.
22#[derive(Debug, Clone)]
23pub struct ProjectConfig {
24    /// Effect namespace → policy.  Absence of a key means "allow all".
25    pub effect_policies: HashMap<String, EffectPolicy>,
26}
27
28impl ProjectConfig {
29    /// Try to load `aver.toml` from the given directory.
30    /// Returns `Ok(None)` if the file does not exist.
31    /// Returns `Err` if the file exists but is malformed (parse errors, bad types).
32    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    /// Parse the TOML content into a ProjectConfig.
43    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    /// Check whether an HTTP call to `url_str` is allowed by the policy.
121    /// Returns Ok(()) if allowed, Err(message) if denied.
122    pub fn check_http_host(&self, method_name: &str, url_str: &str) -> Result<(), String> {
123        // Find the most specific matching policy: first try "Http.get", then "Http"
124        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(()); // No policy = allow all
132        };
133
134        if policy.hosts.is_empty() {
135            return Ok(()); // Empty hosts list = allow all
136        }
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    /// Check whether a Disk operation on `path_str` is allowed by the policy.
160    /// Returns Ok(()) if allowed, Err(message) if denied.
161    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        // Normalize the path to prevent ../ traversal
177        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    /// Check whether an Env operation on `key` is allowed by the policy.
192    /// Returns Ok(()) if allowed, Err(message) if denied.
193    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
221/// Check if a hostname matches an allowed pattern.
222/// Supports exact match and wildcard prefix `*.domain`.
223fn host_matches(host: &str, pattern: &str) -> bool {
224    if pattern == host {
225        return true;
226    }
227    if let Some(suffix) = pattern.strip_prefix("*.") {
228        // *.example.com matches sub.example.com but not example.com itself
229        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
237/// Normalize a filesystem path for matching.
238/// Resolves `.` and `..` components without touching the filesystem.
239/// Leading `..` components are preserved (not silently dropped) so that
240/// `../../etc/passwd` does NOT normalize to `etc/passwd`.
241fn 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 => {} // skip .
253            std::path::Component::ParentDir => {
254                // Only pop if the last component is a normal segment (not "..")
255                if components.last().is_some_and(|c| c != "..") {
256                    components.pop();
257                } else if !is_absolute {
258                    // Preserve leading ".." for relative paths — never silently drop them
259                    components.push("..".to_string());
260                }
261                // For absolute paths, extra ".." at root is a no-op (stays at /)
262            }
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
280/// Check if a normalized path matches an allowed pattern.
281/// Supports:
282///   - Exact prefix match: "./data" matches "./data" and "./data/file.txt"
283///   - Recursive glob: "./data/**" matches everything under ./data/
284fn 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    // The path must start with the allowed base
292    if normalized == clean_pattern {
293        return true;
294    }
295
296    // Check if it's under the allowed directory
297    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
307/// Check if an env key matches an allowed pattern.
308/// Supports exact match and suffix wildcard `PREFIX_*`.
309fn 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        // data/../etc/passwd normalizes to etc/passwd — not under data/
448        assert!(
449            config
450                .check_disk_path("Disk.readText", "data/../etc/passwd")
451                .is_err()
452        );
453        // Leading ../ must NOT be silently dropped — ../../data/x must NOT match data/**
454        assert!(
455            config
456                .check_disk_path("Disk.readText", "../../data/secret")
457                .is_err()
458        );
459        // More leading dotdots
460        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        // Env.get uses method-specific key list
566        assert!(config.check_env_key("Env.get", "PUBLIC_KEY").is_ok());
567        assert!(config.check_env_key("Env.get", "APP_KEY").is_err());
568        // Env.set falls back to namespace key list
569        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}