config.rs (13392B)
1 extern crate alloc; 2 use alloc::borrow::Cow; 3 use core::{ 4 fmt::{self, Display, Formatter}, 5 time::Duration, 6 }; 7 use rpz::file::{AbsFilePath, HttpUrl}; 8 use serde::de::{Deserialize, Deserializer, Error, MapAccess, SeqAccess, Unexpected, Visitor}; 9 use std::collections::HashSet; 10 /// Unit tests. 11 #[cfg(test)] 12 mod tests; 13 /// The TOML config file. 14 #[derive(Debug)] 15 pub(crate) struct Config { 16 /// The maximum amount of time allowed for an HTTP(S) file to be downloaded. 17 pub timeout: Option<Duration>, 18 /// The absolute file path for the [response policy zone (RPZ)](https://en.wikipedia.org/wiki/Response_policy_zone) file. 19 pub rpz: Option<AbsFilePath<false>>, 20 /// The absolute file path to the directory that contains local (un)block files. 21 pub local_dir: Option<AbsFilePath<true>>, 22 /// The unique absolute HTTP(S) URLs to [Adblock-style](https://adguard-dns.io/kb/general/dns-filtering-syntax/#adblock-style-syntax) 23 /// block lists. 24 pub adblock: HashSet<HttpUrl>, 25 /// The unique absolute HTTP(S) URLs to [domains-only](https://adguard-dns.io/kb/general/dns-filtering-syntax/#domains-only-syntax) 26 /// block lists. 27 pub domain: HashSet<HttpUrl>, 28 /// The unique absolute HTTP(S) URLs to [`hosts(5)`-style](https://adguard-dns.io/kb/general/dns-filtering-syntax/#etc-hosts-syntax) 29 /// block lists. 30 pub hosts: HashSet<HttpUrl>, 31 /// The unique absolute HTTP(S) URLs to [wildcard domain](https://pgl.yoyo.org/adservers/serverlist.php?hostformat=adblock&showintro=0&mimetype=plaintext) 32 /// block lists. 33 pub wildcard: HashSet<HttpUrl>, 34 } 35 impl Display for Config { 36 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 37 /// Helper function that writes the `Url`s in a `HashSet<HttpUrl>`. 38 fn keys(set: &HashSet<HttpUrl>, f: &mut Formatter<'_>, name: &str) -> fmt::Result { 39 write!(f, "{name}: [").and_then(|()| { 40 set.iter() 41 .try_fold((), |(), url| write!(f, "{url}, ")) 42 .and_then(|()| f.write_str("], ")) 43 }) 44 } 45 write!( 46 f, 47 "Config {{ timeout: {} rpz: {}, local_dir: {}, ", 48 self.timeout 49 .map_or_else(String::new, |dur| dur.as_secs().to_string()), 50 self.rpz 51 .as_ref() 52 .map_or_else(|| Cow::Owned(String::new()), |file| file.to_string_lossy()), 53 self.local_dir 54 .as_ref() 55 .map_or_else(|| Cow::Owned(String::new()), |dir| dir.to_string_lossy()) 56 ) 57 .and_then(|()| { 58 keys(&self.adblock, f, "adblock") 59 .and_then(|()| keys(&self.domain, f, "domain")) 60 .and_then(|()| keys(&self.hosts, f, "hosts")) 61 .and_then(|()| keys(&self.wildcard, f, "wildcard")) 62 .and_then(|()| f.write_str("}")) 63 }) 64 } 65 } 66 impl<'de> Deserialize<'de> for Config { 67 #[expect(clippy::too_many_lines, reason = "this is fine")] 68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 69 where 70 D: Deserializer<'de>, 71 { 72 /// Config file fields. 73 enum Field { 74 /// Timeout field. 75 Timeout, 76 /// RPZ field. 77 Rpz, 78 /// Local directory field. 79 LocalDir, 80 /// Adblock URLs field. 81 Adblock, 82 /// Domain-only URLs field. 83 Domain, 84 /// hosts(5) URLs field. 85 Hosts, 86 /// Wildcard URLs field. 87 Wildcard, 88 } 89 impl<'d> Deserialize<'d> for Field { 90 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 91 where 92 D: Deserializer<'d>, 93 { 94 /// `Visitor` for `Field`. 95 struct FieldVisitor; 96 impl Visitor<'_> for FieldVisitor { 97 type Value = Field; 98 fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 99 formatter.write_str( 100 "'timeout', 'rpz', 'local_dir', 'adblock', 'domain', 'hosts', or 'wildcard'", 101 ) 102 } 103 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 104 where 105 E: Error, 106 { 107 match v { 108 "timeout" => Ok(Field::Timeout), 109 "rpz" => Ok(Field::Rpz), 110 "local_dir" => Ok(Field::LocalDir), 111 "adblock" => Ok(Field::Adblock), 112 "domain" => Ok(Field::Domain), 113 "hosts" => Ok(Field::Hosts), 114 "wildcard" => Ok(Field::Wildcard), 115 _ => Err(E::unknown_field(v, &VARIANTS)), 116 } 117 } 118 } 119 deserializer.deserialize_identifier(FieldVisitor) 120 } 121 } 122 /// `Visitor` for `Config`. 123 struct ConfigVisitor; 124 impl<'d> Visitor<'d> for ConfigVisitor { 125 type Value = Config; 126 fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 127 formatter.write_str("struct Config") 128 } 129 #[expect( 130 clippy::as_conversions, 131 clippy::cast_lossless, 132 clippy::too_many_lines, 133 reason = "carefull verify use is correct" 134 )] 135 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> 136 where 137 A: MapAccess<'d>, 138 { 139 /// Verifies that the `HashSet`s are pairwise disjoint. 140 #[expect( 141 clippy::arithmetic_side_effects, 142 clippy::indexing_slicing, 143 reason = "carefully verify use is correct" 144 )] 145 fn hash_overlap<E: Error>(maps: &[&HashSet<HttpUrl>]) -> Result<(), E> { 146 /// Verifies the intersection of `left` and `right` is empty. 147 fn url_overlap<E: Error>( 148 left: &HashSet<HttpUrl>, 149 right: &HashSet<HttpUrl>, 150 ) -> Result<(), E> { 151 let (mut iter, urls) = if left.len() <= right.len() { 152 (left.iter(), right) 153 } else { 154 (right.iter(), left) 155 }; 156 iter.try_fold((), |(), url| { 157 if urls.contains(url) { 158 Err(E::invalid_type( 159 Unexpected::Other(url.to_string().as_str()), 160 &"unique URLs across the block list types", 161 )) 162 } else { 163 Ok(()) 164 } 165 }) 166 } 167 maps.iter().enumerate().try_fold((), |(), (idx, map)| { 168 maps[idx + 1..] 169 .iter() 170 .try_fold((), |(), map2| url_overlap(map, map2)) 171 }) 172 } 173 /// Wrapper around a `HashSet` that is deserializable. 174 struct Urls(HashSet<HttpUrl>); 175 impl<'de> Deserialize<'de> for Urls { 176 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 177 where 178 D: Deserializer<'de>, 179 { 180 /// `Visitor` for `Urls`. 181 struct HashVisitor; 182 impl<'d> Visitor<'d> for HashVisitor { 183 type Value = Urls; 184 fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 185 formatter.write_str("struct Urls") 186 } 187 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> 188 where 189 A: SeqAccess<'d>, 190 { 191 let mut urls = HashSet::with_capacity(seq.size_hint().unwrap_or(0)); 192 while let Some(url) = seq.next_element::<HttpUrl>()? { 193 urls.replace(url).map_or(Ok(()), |dup| { 194 Err(Error::invalid_value( 195 Unexpected::Other(dup.to_string().as_str()), 196 &"a set of unique URLs", 197 )) 198 })?; 199 } 200 Ok(Urls(urls)) 201 } 202 } 203 deserializer.deserialize_seq(HashVisitor) 204 } 205 } 206 let mut timeout = None; 207 let mut rpz = None; 208 let mut local_dir = None; 209 let mut ad = None; 210 let mut dom = None; 211 let mut hst = None; 212 let mut wc = None; 213 while let Some(key) = map.next_key()? { 214 match key { 215 Field::Timeout => { 216 if timeout.is_some() { 217 return Err(Error::duplicate_field("timeout")); 218 } 219 timeout = Some(Duration::from_secs(map.next_value::<u32>()? as u64)); 220 } 221 Field::Rpz => { 222 if rpz.is_some() { 223 return Err(Error::duplicate_field("rpz")); 224 } 225 rpz = Some(map.next_value::<AbsFilePath<false>>()?); 226 } 227 Field::LocalDir => { 228 if local_dir.is_some() { 229 return Err(Error::duplicate_field("local_dir")); 230 } 231 local_dir = Some(map.next_value::<AbsFilePath<true>>()?); 232 } 233 Field::Adblock => { 234 if ad.is_some() { 235 return Err(Error::duplicate_field("adblock")); 236 } 237 ad = Some(map.next_value::<Urls>()?); 238 } 239 Field::Domain => { 240 if dom.is_some() { 241 return Err(Error::duplicate_field("domain")); 242 } 243 dom = Some(map.next_value::<Urls>()?); 244 } 245 Field::Hosts => { 246 if hst.is_some() { 247 return Err(Error::duplicate_field("hosts")); 248 } 249 hst = Some(map.next_value::<Urls>()?); 250 } 251 Field::Wildcard => { 252 if wc.is_some() { 253 return Err(Error::duplicate_field("wildcard")); 254 } 255 wc = Some(map.next_value::<Urls>()?); 256 } 257 } 258 } 259 if local_dir.is_none() 260 && ad.as_ref().is_none_or(|urls| urls.0.is_empty()) 261 && dom.as_ref().is_none_or(|urls| urls.0.is_empty()) 262 && hst.as_ref().is_none_or(|urls| urls.0.is_empty()) 263 && wc.as_ref().is_none_or(|urls| urls.0.is_empty()) 264 { 265 Err(Error::invalid_type( 266 Unexpected::Other("no block list URLs or directory"), 267 &"at least one block list entry (i.e., 'local_dir', 'adblock', 'domain', 'hosts', or 'wildcard' must exist and not be empty)", 268 )) 269 } else { 270 let adblock = ad.map_or_else(HashSet::new, |urls| urls.0); 271 let domain = dom.map_or_else(HashSet::new, |urls| urls.0); 272 let hosts = hst.map_or_else(HashSet::new, |urls| urls.0); 273 let wildcard = wc.map_or_else(HashSet::new, |urls| urls.0); 274 hash_overlap([&adblock, &domain, &hosts, &wildcard].as_slice()).map(|()| { 275 Config { 276 timeout, 277 rpz, 278 local_dir, 279 adblock, 280 domain, 281 hosts, 282 wildcard, 283 } 284 }) 285 } 286 } 287 } 288 /// `Config` fields. 289 const VARIANTS: [&str; 7] = [ 290 "timeout", 291 "rpz", 292 "local_dir", 293 "adblock", 294 "domain", 295 "hosts", 296 "wildcard", 297 ]; 298 deserializer.deserialize_struct("Config", &VARIANTS, ConfigVisitor) 299 } 300 }