1use std::error::Error;
14use std::fmt;
15use std::fs;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20pub const DIRECTORY_ID: &str = "directory";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct VendorEntry {
28 pub vendor: String,
30 pub display_name: String,
31 pub category: String,
33 pub hosts: Vec<String>,
35 #[serde(default)]
38 pub rulepack: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct VendorDirectory {
45 entries: Vec<VendorEntry>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum DirectoryError {
51 Parse(String),
52 Io(String),
53 EmptyField {
54 vendor: String,
55 field: &'static str,
56 },
57 DuplicateHost {
58 host: String,
59 vendors: (String, String),
60 },
61}
62
63impl fmt::Display for DirectoryError {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 Self::Parse(error) => write!(f, "invalid vendor directory JSON: {error}"),
67 Self::Io(error) => write!(f, "could not read vendor directory: {error}"),
68 Self::EmptyField { vendor, field } => {
69 write!(
70 f,
71 "vendor directory entry `{vendor}` has an empty `{field}`"
72 )
73 }
74 Self::DuplicateHost {
75 host,
76 vendors: (first, second),
77 } => write!(
78 f,
79 "host `{host}` is claimed by both `{first}` and `{second}` in the vendor directory"
80 ),
81 }
82 }
83}
84
85impl Error for DirectoryError {}
86
87impl VendorDirectory {
88 pub fn builtin() -> Self {
93 Self::from_json(crate::BUILTIN_VENDOR_DIRECTORY)
94 .expect("built-in vendor directory should be valid")
95 }
96
97 pub fn from_json(json: &str) -> Result<Self, DirectoryError> {
98 let directory: Self =
99 serde_json::from_str(json).map_err(|error| DirectoryError::Parse(error.to_string()))?;
100 directory.validate()?;
101 Ok(directory)
102 }
103
104 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, DirectoryError> {
105 let path = path.as_ref();
106 let json = fs::read_to_string(path)
107 .map_err(|error| DirectoryError::Io(format!("{}: {error}", path.display())))?;
108 Self::from_json(&json)
109 }
110
111 fn validate(&self) -> Result<(), DirectoryError> {
112 let mut claimed: Vec<(String, &str)> = Vec::new();
113
114 for entry in &self.entries {
115 for (field, value) in [
116 ("vendor", &entry.vendor),
117 ("display_name", &entry.display_name),
118 ("category", &entry.category),
119 ] {
120 if value.trim().is_empty() {
121 return Err(DirectoryError::EmptyField {
122 vendor: entry.vendor.clone(),
123 field,
124 });
125 }
126 }
127
128 if entry.hosts.is_empty() {
129 return Err(DirectoryError::EmptyField {
130 vendor: entry.vendor.clone(),
131 field: "hosts",
132 });
133 }
134
135 for host in &entry.hosts {
136 let host = host.to_ascii_lowercase();
137
138 if let Some((_, owner)) = claimed.iter().find(|(claimed, _)| claimed == &host) {
139 return Err(DirectoryError::DuplicateHost {
140 host,
141 vendors: ((*owner).to_string(), entry.vendor.clone()),
142 });
143 }
144
145 claimed.push((host, entry.vendor.as_str()));
146 }
147 }
148
149 Ok(())
150 }
151
152 pub fn entries(&self) -> &[VendorEntry] {
153 &self.entries
154 }
155
156 pub fn len(&self) -> usize {
157 self.entries.len()
158 }
159
160 pub fn is_empty(&self) -> bool {
161 self.entries.is_empty()
162 }
163
164 pub fn host_count(&self) -> usize {
166 self.entries.iter().map(|entry| entry.hosts.len()).sum()
167 }
168
169 pub fn lookup_host(&self, host: &str) -> Option<&VendorEntry> {
172 let host = host.to_ascii_lowercase();
173
174 self.entries.iter().find(|entry| {
175 entry.hosts.iter().any(|candidate| {
176 let candidate = candidate.to_ascii_lowercase();
177 host == candidate || host.ends_with(&format!(".{candidate}"))
178 })
179 })
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 const TEST_DIRECTORY: &str = r#"{
188 "entries": [
189 {
190 "vendor": "acme",
191 "display_name": "Acme",
192 "category": "programmatic",
193 "hosts": ["px.acme.example", "acme-static.example"],
194 "rulepack": "vendor/acme"
195 },
196 {
197 "vendor": "globex",
198 "display_name": "Globex",
199 "category": "analytics",
200 "hosts": ["globex.example"]
201 }
202 ]
203 }"#;
204
205 #[test]
206 fn lookup_matches_exact_hosts_and_subdomains() {
207 let directory = VendorDirectory::from_json(TEST_DIRECTORY).expect("parse");
208
209 assert_eq!(
210 directory.lookup_host("px.acme.example").map(|e| &e.vendor),
211 Some(&"acme".to_string())
212 );
213 assert_eq!(
214 directory
215 .lookup_host("cdn.acme-static.example")
216 .map(|e| &e.vendor),
217 Some(&"acme".to_string())
218 );
219 assert_eq!(
220 directory.lookup_host("GLOBEX.EXAMPLE").map(|e| &e.vendor),
221 Some(&"globex".to_string())
222 );
223 assert_eq!(directory.lookup_host("notglobex.example"), None);
224 assert_eq!(directory.lookup_host("example.com"), None);
225 }
226
227 #[test]
228 fn duplicate_hosts_are_rejected() {
229 let json = TEST_DIRECTORY.replace(r#""globex.example""#, r#""px.acme.example""#);
230 let error = VendorDirectory::from_json(&json).expect_err("duplicates are caught");
231 assert!(
232 matches!(error, DirectoryError::DuplicateHost { .. }),
233 "{error}"
234 );
235 }
236
237 #[test]
238 fn entries_need_a_vendor_and_hosts() {
239 let json = TEST_DIRECTORY.replace(r#""vendor": "globex""#, r#""vendor": """#);
240 let error = VendorDirectory::from_json(&json).expect_err("empty vendor is caught");
241 assert!(
242 matches!(error, DirectoryError::EmptyField { .. }),
243 "{error}"
244 );
245
246 let json = TEST_DIRECTORY.replace(r#""hosts": ["globex.example"]"#, r#""hosts": []"#);
247 let error = VendorDirectory::from_json(&json).expect_err("empty hosts are caught");
248 assert!(
249 matches!(error, DirectoryError::EmptyField { .. }),
250 "{error}"
251 );
252 }
253
254 #[test]
255 fn unknown_fields_are_rejected() {
256 let json =
257 TEST_DIRECTORY.replace(r#""vendor": "acme","#, r#""vendor": "acme", "oops": 1,"#);
258 let error = VendorDirectory::from_json(&json).expect_err("unknown fields are caught");
259 assert!(matches!(error, DirectoryError::Parse(_)), "{error}");
260 }
261
262 #[test]
263 fn the_builtin_directory_loads_and_covers_the_shipped_packs() {
264 let directory = VendorDirectory::builtin();
265
266 assert!(
267 directory.len() >= 80,
268 "directory shrank to {}",
269 directory.len()
270 );
271 assert!(directory.host_count() >= 200);
272
273 for host in [
274 "www.facebook.com",
275 "analytics.tiktok.com",
276 "ct.pinterest.com",
277 "trc.taboola.com",
278 "pixel.quantserve.com",
279 ] {
280 assert!(
281 directory.lookup_host(host).is_some(),
282 "{host} is not in the directory"
283 );
284 }
285
286 let pack_ids: Vec<&str> = crate::BUILTIN_VENDOR_MANIFESTS
288 .iter()
289 .map(|(id, _)| *id)
290 .collect();
291 for entry in directory.entries() {
292 if let Some(rulepack) = &entry.rulepack {
293 assert!(
294 pack_ids.contains(&rulepack.as_str()),
295 "directory entry `{}` points at unknown rulepack `{rulepack}`",
296 entry.vendor
297 );
298 }
299 }
300 }
301}