1use crate::glob::glob_match;
2use crate::model::Entry;
3use crate::vendor::vendor_for;
4use serde::Deserialize;
5use std::path::Path;
6
7#[derive(Debug, Default, Deserialize)]
8pub struct Config {
9 #[serde(default)]
10 pub ownership: Vec<OwnershipRule>,
11 #[serde(default)]
12 pub required_headers: Vec<RequiredHeaderRule>,
13 #[serde(default)]
14 pub rules: Vec<Rule>,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct RequiredHeaderRule {
19 pub host: String,
21 #[serde(default)]
23 pub headers: Vec<String>,
24}
25
26#[derive(Debug, Clone, Default, Deserialize)]
27pub struct Rule {
28 pub name: String,
30 #[serde(default)]
32 pub host: Option<String>,
33 #[serde(default)]
35 pub path: Option<String>,
36 #[serde(default)]
38 pub method: Option<String>,
39 #[serde(default)]
41 pub status: Option<String>,
42 #[serde(default)]
44 pub require_headers: Vec<String>,
45 #[serde(default)]
47 pub max_latency_ms: Option<f64>,
48 #[serde(default)]
50 pub forbid: bool,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct OwnershipRule {
55 pub name: String,
56 #[serde(default)]
57 pub host: Option<String>,
58 #[serde(default)]
59 pub path: Option<String>,
60 #[serde(default)]
61 pub owner: Option<String>,
62 #[serde(default)]
63 pub criticality: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub struct Subsystem {
68 pub name: String,
69 pub owner: Option<String>,
70 pub criticality: Option<String>,
71}
72
73#[derive(Debug, thiserror::Error)]
74pub enum ConfigError {
75 #[error("failed to read config file")]
76 Io(#[source] std::io::Error),
77 #[error("failed to parse config YAML")]
78 Parse(#[source] yaml_serde::Error),
79}
80
81impl Config {
82 pub fn load(explicit: Option<&Path>) -> Result<Config, ConfigError> {
85 match explicit {
86 Some(p) => {
87 let text = std::fs::read_to_string(p).map_err(ConfigError::Io)?;
88 Config::from_yaml_str(&text)
89 }
90 None => {
91 let default = Path::new("wiretrail.yaml");
92 if default.is_file() {
93 let text = std::fs::read_to_string(default).map_err(ConfigError::Io)?;
94 Config::from_yaml_str(&text)
95 } else {
96 Ok(Config::default())
97 }
98 }
99 }
100 }
101
102 pub fn from_yaml_str(s: &str) -> Result<Config, ConfigError> {
103 yaml_serde::from_str(s).map_err(ConfigError::Parse)
104 }
105
106 pub fn subsystem_for(&self, e: &Entry) -> Subsystem {
109 for rule in &self.ownership {
110 if rule_matches(rule, e) {
111 return Subsystem {
112 name: rule.name.clone(),
113 owner: rule.owner.clone(),
114 criticality: rule.criticality.clone(),
115 };
116 }
117 }
118 if let Some(v) = vendor_for(&e.host) {
119 return Subsystem {
120 name: v.to_string(),
121 owner: None,
122 criticality: None,
123 };
124 }
125 let name = if e.host.is_empty() {
126 "(unknown)".to_string()
127 } else {
128 e.host.clone()
129 };
130 Subsystem {
131 name,
132 owner: None,
133 criticality: None,
134 }
135 }
136}
137
138fn rule_matches(rule: &OwnershipRule, e: &Entry) -> bool {
139 if rule.host.is_none() && rule.path.is_none() {
141 return false;
142 }
143 if let Some(h) = &rule.host
144 && !glob_match(h, &e.host)
145 {
146 return false;
147 }
148 if let Some(p) = &rule.path
149 && !glob_match(p, &e.path)
150 {
151 return false;
152 }
153 true
154}
155
156#[cfg(test)]
157mod tests {
158 use super::Config;
159 use crate::model::sample_entry;
160
161 #[test]
162 fn parses_ownership_rules_from_yaml() {
163 let yaml = r#"
164ownership:
165 - name: Torii Addon
166 host: "torii.*"
167 owner: Addons
168 criticality: high
169 - name: GitHub Releases
170 host: "api.github.com"
171 path: "/repos/*"
172"#;
173 let cfg = Config::from_yaml_str(yaml).unwrap();
174 assert_eq!(cfg.ownership.len(), 2);
175 }
176
177 #[test]
178 fn rule_match_wins_over_vendor() {
179 let cfg =
180 Config::from_yaml_str("ownership:\n - name: Torii Addon\n host: \"torii.*\"\n")
181 .unwrap();
182 let e = sample_entry(0, "torii.nexioapp.org", "GET", "/manifest.json", 308);
183 let s = cfg.subsystem_for(&e);
184 assert_eq!(s.name, "Torii Addon");
185 }
186
187 #[test]
188 fn falls_back_to_vendor_then_host() {
189 let cfg = Config::default();
190 let gh = sample_entry(0, "api.github.com", "GET", "/x", 200);
191 assert_eq!(cfg.subsystem_for(&gh).name, "GitHub");
192 let unknown = sample_entry(1, "torii.nexioapp.org", "GET", "/x", 200);
193 assert_eq!(cfg.subsystem_for(&unknown).name, "torii.nexioapp.org");
194 }
195
196 #[test]
197 fn path_rule_requires_path_match() {
198 let cfg = Config::from_yaml_str(
199 "ownership:\n - name: Repos\n host: \"api.github.com\"\n path: \"/repos/*\"\n",
200 )
201 .unwrap();
202 let hit = sample_entry(0, "api.github.com", "GET", "/repos/foo/bar", 200);
203 let miss = sample_entry(1, "api.github.com", "GET", "/users/foo", 200);
204 assert_eq!(cfg.subsystem_for(&hit).name, "Repos");
205 assert_eq!(cfg.subsystem_for(&miss).name, "GitHub");
207 }
208
209 #[test]
210 fn parses_required_headers() {
211 let yaml = r#"
212required_headers:
213 - host: "api.company.com"
214 headers: ["Authorization", "X-App-Version"]
215"#;
216 let cfg = Config::from_yaml_str(yaml).unwrap();
217 assert_eq!(cfg.required_headers.len(), 1);
218 assert_eq!(cfg.required_headers[0].host, "api.company.com");
219 assert_eq!(
220 cfg.required_headers[0].headers,
221 vec!["Authorization", "X-App-Version"]
222 );
223 }
224
225 #[test]
226 fn required_headers_defaults_empty() {
227 let cfg = Config::from_yaml_str("ownership: []").unwrap();
228 assert!(cfg.required_headers.is_empty());
229 }
230
231 #[test]
232 fn parses_rules_from_yaml() {
233 let yaml = r#"
234rules:
235 - name: "API needs auth"
236 host: "api.*"
237 require_headers: ["Authorization"]
238 max_latency_ms: 2000
239 - name: "no internal hosts"
240 host: "*.internal"
241 forbid: true
242"#;
243 let cfg = Config::from_yaml_str(yaml).unwrap();
244 assert_eq!(cfg.rules.len(), 2);
245 assert_eq!(cfg.rules[0].require_headers, vec!["Authorization"]);
246 assert_eq!(cfg.rules[0].max_latency_ms, Some(2000.0));
247 assert!(cfg.rules[1].forbid);
248 }
249}