Skip to main content

pixellint_core/
directory.rs

1//! Vendor endpoint directory.
2//!
3//! The directory answers one question a rulepack cannot: whose pixel is this?
4//! It maps hosts to vendors and nothing else. Entries carry no parameter
5//! contracts and no rule text, so a directory hit never asserts that an
6//! artifact is right or wrong; it says which vendor owns the endpoint and
7//! whether Pixellint has a rulepack for it.
8//!
9//! That split is deliberate. Rules require a citable contract, and most vendors
10//! never publish one. Attribution is a weaker claim that can be made honestly
11//! for the long tail.
12
13use std::error::Error;
14use std::fmt;
15use std::fs;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20/// Pseudo-rulepack id used to include or exclude the directory in
21/// [`ValidationOptions`](crate::ValidationOptions).
22pub const DIRECTORY_ID: &str = "directory";
23
24/// One vendor and the endpoints it serves.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct VendorEntry {
28    /// Stable vendor slug, reported as `detected_vendor`.
29    pub vendor: String,
30    pub display_name: String,
31    /// Broad function of the endpoint: social, programmatic, analytics, and so on.
32    pub category: String,
33    /// Hosts the vendor serves. A host also matches its subdomains.
34    pub hosts: Vec<String>,
35    /// First-party rulepack that covers some of this vendor's endpoints, if one
36    /// exists.
37    #[serde(default)]
38    pub rulepack: Option<String>,
39}
40
41/// A set of vendor entries.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct VendorDirectory {
45    entries: Vec<VendorEntry>,
46}
47
48/// Why a directory could not be loaded.
49#[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    /// The directory compiled into the crate.
89    ///
90    /// Parsing is proven by tests, so a malformed built-in directory is a build
91    /// failure rather than a runtime surprise.
92    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    /// Total hosts across every entry.
165    pub fn host_count(&self) -> usize {
166        self.entries.iter().map(|entry| entry.hosts.len()).sum()
167    }
168
169    /// Finds the vendor that serves a host. A directory host also matches its
170    /// subdomains, so `sc-static.net` covers `cdn.sc-static.net`.
171    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        // Every rulepack an entry points at has to exist.
287        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}