use std::error::Error;
use std::fmt;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub const DIRECTORY_ID: &str = "directory";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VendorEntry {
pub vendor: String,
pub display_name: String,
pub category: String,
pub hosts: Vec<String>,
#[serde(default)]
pub rulepack: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VendorDirectory {
entries: Vec<VendorEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DirectoryError {
Parse(String),
Io(String),
EmptyField {
vendor: String,
field: &'static str,
},
DuplicateHost {
host: String,
vendors: (String, String),
},
}
impl fmt::Display for DirectoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse(error) => write!(f, "invalid vendor directory JSON: {error}"),
Self::Io(error) => write!(f, "could not read vendor directory: {error}"),
Self::EmptyField { vendor, field } => {
write!(
f,
"vendor directory entry `{vendor}` has an empty `{field}`"
)
}
Self::DuplicateHost {
host,
vendors: (first, second),
} => write!(
f,
"host `{host}` is claimed by both `{first}` and `{second}` in the vendor directory"
),
}
}
}
impl Error for DirectoryError {}
impl VendorDirectory {
pub fn builtin() -> Self {
Self::from_json(crate::BUILTIN_VENDOR_DIRECTORY)
.expect("built-in vendor directory should be valid")
}
pub fn from_json(json: &str) -> Result<Self, DirectoryError> {
let directory: Self =
serde_json::from_str(json).map_err(|error| DirectoryError::Parse(error.to_string()))?;
directory.validate()?;
Ok(directory)
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, DirectoryError> {
let path = path.as_ref();
let json = fs::read_to_string(path)
.map_err(|error| DirectoryError::Io(format!("{}: {error}", path.display())))?;
Self::from_json(&json)
}
fn validate(&self) -> Result<(), DirectoryError> {
let mut claimed: Vec<(String, &str)> = Vec::new();
for entry in &self.entries {
for (field, value) in [
("vendor", &entry.vendor),
("display_name", &entry.display_name),
("category", &entry.category),
] {
if value.trim().is_empty() {
return Err(DirectoryError::EmptyField {
vendor: entry.vendor.clone(),
field,
});
}
}
if entry.hosts.is_empty() {
return Err(DirectoryError::EmptyField {
vendor: entry.vendor.clone(),
field: "hosts",
});
}
for host in &entry.hosts {
let host = host.to_ascii_lowercase();
if let Some((_, owner)) = claimed.iter().find(|(claimed, _)| claimed == &host) {
return Err(DirectoryError::DuplicateHost {
host,
vendors: ((*owner).to_string(), entry.vendor.clone()),
});
}
claimed.push((host, entry.vendor.as_str()));
}
}
Ok(())
}
pub fn entries(&self) -> &[VendorEntry] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn host_count(&self) -> usize {
self.entries.iter().map(|entry| entry.hosts.len()).sum()
}
pub fn lookup_host(&self, host: &str) -> Option<&VendorEntry> {
let host = host.to_ascii_lowercase();
self.entries.iter().find(|entry| {
entry.hosts.iter().any(|candidate| {
let candidate = candidate.to_ascii_lowercase();
host == candidate || host.ends_with(&format!(".{candidate}"))
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_DIRECTORY: &str = r#"{
"entries": [
{
"vendor": "acme",
"display_name": "Acme",
"category": "programmatic",
"hosts": ["px.acme.example", "acme-static.example"],
"rulepack": "vendor/acme"
},
{
"vendor": "globex",
"display_name": "Globex",
"category": "analytics",
"hosts": ["globex.example"]
}
]
}"#;
#[test]
fn lookup_matches_exact_hosts_and_subdomains() {
let directory = VendorDirectory::from_json(TEST_DIRECTORY).expect("parse");
assert_eq!(
directory.lookup_host("px.acme.example").map(|e| &e.vendor),
Some(&"acme".to_string())
);
assert_eq!(
directory
.lookup_host("cdn.acme-static.example")
.map(|e| &e.vendor),
Some(&"acme".to_string())
);
assert_eq!(
directory.lookup_host("GLOBEX.EXAMPLE").map(|e| &e.vendor),
Some(&"globex".to_string())
);
assert_eq!(directory.lookup_host("notglobex.example"), None);
assert_eq!(directory.lookup_host("example.com"), None);
}
#[test]
fn duplicate_hosts_are_rejected() {
let json = TEST_DIRECTORY.replace(r#""globex.example""#, r#""px.acme.example""#);
let error = VendorDirectory::from_json(&json).expect_err("duplicates are caught");
assert!(
matches!(error, DirectoryError::DuplicateHost { .. }),
"{error}"
);
}
#[test]
fn entries_need_a_vendor_and_hosts() {
let json = TEST_DIRECTORY.replace(r#""vendor": "globex""#, r#""vendor": """#);
let error = VendorDirectory::from_json(&json).expect_err("empty vendor is caught");
assert!(
matches!(error, DirectoryError::EmptyField { .. }),
"{error}"
);
let json = TEST_DIRECTORY.replace(r#""hosts": ["globex.example"]"#, r#""hosts": []"#);
let error = VendorDirectory::from_json(&json).expect_err("empty hosts are caught");
assert!(
matches!(error, DirectoryError::EmptyField { .. }),
"{error}"
);
}
#[test]
fn unknown_fields_are_rejected() {
let json =
TEST_DIRECTORY.replace(r#""vendor": "acme","#, r#""vendor": "acme", "oops": 1,"#);
let error = VendorDirectory::from_json(&json).expect_err("unknown fields are caught");
assert!(matches!(error, DirectoryError::Parse(_)), "{error}");
}
#[test]
fn the_builtin_directory_loads_and_covers_the_shipped_packs() {
let directory = VendorDirectory::builtin();
assert!(
directory.len() >= 80,
"directory shrank to {}",
directory.len()
);
assert!(directory.host_count() >= 200);
for host in [
"www.facebook.com",
"analytics.tiktok.com",
"ct.pinterest.com",
"trc.taboola.com",
"pixel.quantserve.com",
] {
assert!(
directory.lookup_host(host).is_some(),
"{host} is not in the directory"
);
}
let pack_ids: Vec<&str> = crate::BUILTIN_VENDOR_MANIFESTS
.iter()
.map(|(id, _)| *id)
.collect();
for entry in directory.entries() {
if let Some(rulepack) = &entry.rulepack {
assert!(
pack_ids.contains(&rulepack.as_str()),
"directory entry `{}` points at unknown rulepack `{rulepack}`",
entry.vendor
);
}
}
}
}