rpz

Response policy zone (RPZ) file generator.
git clone https://git.philomathiclife.com/repos/rpz
Log | Files | Refs | README

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 }