rpz

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

main.rs (15074B)


      1 //! # `rpz`
      2 //!
      3 //! Consult [`README.md`](https://crates.io/crates/rpz).
      4 #![expect(
      5     unstable_features,
      6     reason = "we already require nightly, so we elect to use never"
      7 )]
      8 #![expect(
      9     clippy::multiple_crate_versions,
     10     reason = "dependencies haven't updated to newest crates"
     11 )]
     12 #![feature(core_io, never_type)]
     13 /// Contains a wrapper of block and unblock `RpzDomain`s
     14 /// which can be used to write to a `File` or `stdout`.
     15 mod app;
     16 /// Module for reading and parsing passed arguments.
     17 mod args;
     18 /// Module for the TOML config file.
     19 mod config;
     20 /// Contains functions for `pledge(2)` and `unveil(2)` on OpenBSD platforms when compiled
     21 /// with the `priv_sep` feature; otherwise almost all functions are no-ops.
     22 mod priv_sep;
     23 /// Unit tests.
     24 #[cfg(test)]
     25 mod tests;
     26 use crate::{
     27     app::Domains,
     28     args::{ArgsErr, ConfigPath, Opts},
     29     config::Config,
     30 };
     31 use ascii_domain as _;
     32 use core::{
     33     error::Error,
     34     fmt::{self, Display, Formatter},
     35     time::Duration,
     36 };
     37 use num_bigint as _;
     38 use reqwest::{Client, Error as HttpErr};
     39 use rpz::{
     40     dom::FirefoxDomainErr,
     41     file::{AbsFilePath, ExtFileErr, ExternalFiles, Files, HttpUrl, LocalFiles, Summary},
     42 };
     43 #[cfg(target_os = "openbsd")]
     44 use rustls::{
     45     ClientConfig, Error as TlsErr, RootCertStore,
     46     pki_types::{
     47         CertificateDer,
     48         pem::{Error as PkiErr, PemObject as _},
     49     },
     50     version::TLS13,
     51 };
     52 use std::{
     53     collections::HashSet,
     54     fs,
     55     io::{self, Read as _, Write as _},
     56     sync::OnceLock,
     57 };
     58 use tokio::runtime::Builder;
     59 use toml::de;
     60 use url as _;
     61 use zfc as _;
     62 /// The HTTP(S) client that is used to download all files.
     63 /// It is initialized exactly once in `main` before being used.
     64 static CLIENT: OnceLock<Client> = OnceLock::new();
     65 /// The output printed to `stdout` when `-h`/`--help` are passed
     66 /// to the program.
     67 const HELP: &str = "Response policy zone (RPZ) file generator
     68 
     69 Usage: rpz [OPTIONS]
     70 
     71 Options:
     72   -V, --version               Print version info and exit
     73   -h, --help                  Print help
     74   -q, --quiet                 Do not print messages
     75   -v, --verbose               Print summary parsing information about each file
     76   -f, --file <CONFIG_FILE>    Required option to pass '-' for stdin or the absolute path to the config file";
     77 /// The output printed to `stdout` when `-v`/`--version` are passed
     78 /// to the program.
     79 const VERSION: &str = concat!("rpz ", env!("CARGO_PKG_VERSION"));
     80 /// The User-Agent header value sent to HTTP(S) servers.
     81 const USER_AGENT: &str = concat!("rpz/", env!("CARGO_PKG_VERSION"));
     82 /// Error returned from the program.
     83 enum E {
     84     /// Variant for errors due to incorrect arguments being passed.
     85     Args(ArgsErr),
     86     /// Variant for errors due to issues with the TOML config file.
     87     Config(de::Error),
     88     /// Variant for IO errors.
     89     Io(io::Error),
     90     /// Variant for errors due to downloading external HTTP(S) block files.
     91     ExtFile(ExtFileErr),
     92     /// Variant when there are no block entries to be written.
     93     NoBlockEntries,
     94     /// Variant when there is an issue creating the HTTP(S) client.
     95     HttpClient(HttpErr),
     96     /// Variant when there is a `rustls` issue.
     97     #[cfg(target_os = "openbsd")]
     98     Tls(TlsErr),
     99     /// Variant when there is an issue parsing the root certificate store.
    100     #[cfg(target_os = "openbsd")]
    101     Pki(PkiErr),
    102 }
    103 impl fmt::Debug for E {
    104     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    105         match *self {
    106             Self::Args(ref e) => write!(f, "{e}.\nFor more information, try '--help'."),
    107             Self::Config(_)
    108             | Self::Io(_)
    109             | Self::ExtFile(_)
    110             | Self::NoBlockEntries
    111             | Self::HttpClient(_) => <Self as Display>::fmt(self, f),
    112             #[cfg(target_os = "openbsd")]
    113             Self::Tls(_) | Self::Pki(_) => <Self as Display>::fmt(self, f),
    114         }
    115     }
    116 }
    117 impl Display for E {
    118     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    119         match *self {
    120             Self::Args(ref e) => e.fmt(f),
    121             Self::Config(ref e) => e.fmt(f),
    122             Self::Io(ref e) => e.fmt(f),
    123             Self::ExtFile(ref e) => e.fmt(f),
    124             Self::NoBlockEntries => f.write_str("there are no domains to block"),
    125             Self::HttpClient(ref e) => e.fmt(f),
    126             #[cfg(target_os = "openbsd")]
    127             Self::Tls(ref e) => e.fmt(f),
    128             #[cfg(target_os = "openbsd")]
    129             Self::Pki(ref e) => e.fmt(f),
    130         }
    131     }
    132 }
    133 impl Error for E {}
    134 impl From<ArgsErr> for E {
    135     fn from(value: ArgsErr) -> Self {
    136         Self::Args(value)
    137     }
    138 }
    139 impl From<de::Error> for E {
    140     fn from(value: de::Error) -> Self {
    141         Self::Config(value)
    142     }
    143 }
    144 impl From<io::Error> for E {
    145     fn from(value: io::Error) -> Self {
    146         Self::Io(value)
    147     }
    148 }
    149 impl From<ExtFileErr> for E {
    150     fn from(value: ExtFileErr) -> Self {
    151         Self::ExtFile(value)
    152     }
    153 }
    154 #[cfg(not(target_os = "openbsd"))]
    155 impl From<!> for E {
    156     fn from(value: !) -> Self {
    157         value
    158     }
    159 }
    160 /// Reads `Config` from `conf`.
    161 fn get_config(conf: ConfigPath) -> Result<Config, E> {
    162     toml::from_str::<Config>(
    163         match conf {
    164             ConfigPath::Stdin => {
    165                 let mut file = String::new();
    166                 _ = io::stdin().lock().read_to_string(&mut file)?;
    167                 file
    168             }
    169             ConfigPath::Path(path) => {
    170                 priv_sep::unveil_read_file(path.as_path())?;
    171                 let file = fs::read_to_string(path.as_path())?;
    172                 priv_sep::unveil_none(path)?;
    173                 file
    174             }
    175         }
    176         .as_str(),
    177     )
    178     .map_err(E::Config)
    179 }
    180 /// Gets `LocalFiles` from `local_dir`.
    181 fn get_local_files(local_dir: Option<AbsFilePath<true>>) -> Result<Option<LocalFiles>, E> {
    182     local_dir.map_or_else(
    183         || Ok(None),
    184         |dir| {
    185             priv_sep::unveil_read_dir(dir.as_path())
    186                 .map_err(E::from)
    187                 .and_then(|exists| {
    188                     if exists {
    189                         LocalFiles::from_path(dir.clone())
    190                             .map_err(E::Io)
    191                             .and_then(|files| {
    192                                 priv_sep::unveil_none(dir).map_err(E::from).map(|()| files)
    193                             })
    194                     } else {
    195                         Ok(None)
    196                     }
    197                 })
    198         },
    199     )
    200 }
    201 /// Retrieves the HTTP(S) client.
    202 #[cfg(target_os = "openbsd")]
    203 fn https_client() -> Result<Client, E> {
    204     fs::read("/etc/ssl/cert.pem")
    205         .map_err(E::Io)
    206         .and_then(|root_cert_file| {
    207             let mut root_store = RootCertStore::empty();
    208             CertificateDer::pem_slice_iter(&root_cert_file)
    209                 .try_fold((), |(), cert| {
    210                     cert.map_err(E::Pki)
    211                         .and_then(|c| root_store.add(c).map_err(E::Tls))
    212                 })
    213                 .and_then(|()| {
    214                     let mut conf =
    215                         ClientConfig::builder_with_protocol_versions([&TLS13].as_slice())
    216                             .with_root_certificates(root_store)
    217                             .with_no_client_auth();
    218                     conf.alpn_protocols = vec![vec![b'h', b'2']];
    219                     Client::builder()
    220                         .user_agent(USER_AGENT)
    221                         .tls_backend_preconfigured(conf)
    222                         .build()
    223                         .map_err(E::HttpClient)
    224                 })
    225         })
    226 }
    227 /// Retrieves the HTTP(S) client.
    228 #[cfg(not(target_os = "openbsd"))]
    229 fn https_client() -> Result<Client, E> {
    230     Client::builder()
    231         .user_agent(USER_AGENT)
    232         .tls_backend_rustls()
    233         .build()
    234         .map_err(E::HttpClient)
    235 }
    236 /// Downloads block files from HTTP(S) servers.
    237 #[expect(clippy::unreachable, reason = "there is a bug and we want to crash")]
    238 fn get_external_files(
    239     timeout: Duration,
    240     adblock: HashSet<HttpUrl>,
    241     domain: HashSet<HttpUrl>,
    242     hosts: HashSet<HttpUrl>,
    243     wildcard: HashSet<HttpUrl>,
    244 ) -> Result<Files, E> {
    245     Builder::new_current_thread()
    246         .enable_all()
    247         .build()
    248         .map_or_else(
    249             |e| Err(E::Io(e)),
    250             |runtime| {
    251                 runtime.block_on(async {
    252                     match https_client() {
    253                         Ok(c) => {
    254                             CLIENT.set(c).unwrap_or_else(|_e| {
    255                                 unreachable!("there is a bug in OnceLock::set")
    256                             });
    257                             let client = CLIENT
    258                                 .get()
    259                                 .unwrap_or_else(|| unreachable!("there is a bug in OnceLock::get"));
    260                             let mut files = ExternalFiles::new();
    261                             files.add_adblock(client, adblock);
    262                             files.add_domain(client, domain);
    263                             files.add_hosts(client, hosts);
    264                             files.add_wildcard(client, wildcard);
    265                             Files::from_external(files, timeout)
    266                                 .await
    267                                 .map_err(E::ExtFile)
    268                         }
    269                         Err(e) => Err(e),
    270                     }
    271                 })
    272             },
    273         )
    274 }
    275 /// Verbosity of what is written to `stdout`.
    276 #[derive(Clone, Copy)]
    277 enum Verbosity {
    278     /// Suppress all summary info.
    279     None,
    280     /// Write normal amount of info.
    281     Normal,
    282     /// Write verbose information.
    283     High,
    284 }
    285 /// Writes to `stdout` the summary information in the event the quiet
    286 /// option was not passed.
    287 fn write_summary(
    288     summaries: Vec<Summary<'_, FirefoxDomainErr>>,
    289     verbose: bool,
    290     unblock_count: usize,
    291     block_count: usize,
    292 ) -> Result<(), io::Error> {
    293     let mut stdout = io::stdout().lock();
    294     let mut domain_count = 0usize;
    295     let mut comment_count = 0usize;
    296     let mut blank_count = 0usize;
    297     let mut error_count = 0usize;
    298     if verbose {
    299         summaries.into_iter().try_fold((), |(), summary| {
    300             domain_count = domain_count.saturating_add(summary.domain_count);
    301             comment_count = comment_count.saturating_add(summary.comment_count);
    302             blank_count = blank_count.saturating_add(summary.blank_count);
    303             error_count = error_count.saturating_add(summary.errors.values().sum());
    304             writeln!(&mut stdout, "{summary}")
    305         })?;
    306     } else {
    307         summaries.into_iter().fold((), |(), summary| {
    308             domain_count = domain_count.saturating_add(summary.domain_count);
    309             comment_count = comment_count.saturating_add(summary.comment_count);
    310             blank_count = blank_count.saturating_add(summary.blank_count);
    311             error_count = error_count.saturating_add(summary.errors.values().sum());
    312         });
    313     }
    314     writeln!(
    315         stdout,
    316         "unblock count written: {}\nblock count written: {}\ntotal lines written: {}\ndomains parsed: {}\ncomments parsed: {}\nblanks parsed: {}\nparsing errors: {}",
    317         unblock_count,
    318         block_count,
    319         unblock_count.saturating_add(block_count),
    320         domain_count,
    321         comment_count,
    322         blank_count,
    323         error_count,
    324     )
    325 }
    326 #[expect(
    327     clippy::arithmetic_side_effects,
    328     clippy::unreachable,
    329     reason = "math is correct and we want to crash if there is a bug"
    330 )]
    331 fn main() -> Result<(), E> {
    332     let mut promises = priv_sep::pledge_init()?;
    333     priv_sep::veil_all()?;
    334     let (conf, verbosity) = match Opts::from_args()? {
    335         Opts::Help => return writeln!(io::stdout().lock(), "{HELP}").map_err(E::Io),
    336         Opts::Version => return writeln!(io::stdout().lock(), "{VERSION}").map_err(E::Io),
    337         Opts::Config(path) => (path, Verbosity::Normal),
    338         Opts::ConfigQuiet(path) => (path, Verbosity::None),
    339         Opts::ConfigVerbose(path) => (path, Verbosity::High),
    340         Opts::Verbose | Opts::Quiet => return Err(E::Args(ArgsErr::ConfigPathNotPassed)),
    341         Opts::None => return Err(E::Args(ArgsErr::NoArgs)),
    342     };
    343     let config = get_config(conf)?;
    344     // We use a temp file to write to that way we can avoid overwriting
    345     // a file just for the process to error. Once the temp file is written to,
    346     // we rename it to the desired name.
    347     let tmp_rpz = config.rpz.as_ref().map_or_else(
    348         || {
    349             priv_sep::pledge_away_create_write(&mut promises)
    350                 .map_err(E::from)
    351                 .map(|()| None)
    352         },
    353         |file| {
    354             priv_sep::unveil_create(file.as_path())
    355                 .and_then(|()| {
    356                     let mut rpz = file.clone();
    357                     _ = rpz.append("tmp");
    358                     priv_sep::unveil_create_read_write(rpz.as_path()).map(|()| Some(rpz))
    359                 })
    360                 .map_err(E::from)
    361         },
    362     )?;
    363     let local_files = get_local_files(config.local_dir)?;
    364     let (mut domains, mut summaries) = if let Some(files) = local_files.as_ref() {
    365         Domains::new_with(files)
    366     } else {
    367         (
    368             Domains::new(),
    369             Vec::with_capacity(
    370                 config.adblock.len()
    371                     + config.domain.len()
    372                     + config.hosts.len()
    373                     + config.wildcard.len(),
    374             ),
    375         )
    376     };
    377     let ext_files = if config.adblock.is_empty()
    378         && config.domain.is_empty()
    379         && config.hosts.is_empty()
    380         && config.wildcard.is_empty()
    381     {
    382         if domains.block().is_empty() {
    383             return Err(E::NoBlockEntries);
    384         }
    385         priv_sep::pledge_away_net(&mut promises)
    386             .and_then(|()| priv_sep::pledge_away_unveil(&mut promises).map(|()| Files::new()))
    387             .map_err(E::from)
    388     } else {
    389         priv_sep::unveil_https().map_err(E::from).and_then(|()| {
    390             priv_sep::pledge_away_unveil(&mut promises)
    391                 .map_err(E::from)
    392                 .and_then(|()| {
    393                     get_external_files(
    394                         config.timeout.unwrap_or(Duration::from_hours(1)),
    395                         config.adblock,
    396                         config.domain,
    397                         config.hosts,
    398                         config.wildcard,
    399                     )
    400                     .and_then(|files| {
    401                         priv_sep::pledge_away_net(&mut promises)
    402                             .map_err(E::from)
    403                             .map(|()| files)
    404                     })
    405                 })
    406         })
    407     }?;
    408     domains.add_block_files(&ext_files, &mut summaries);
    409     if domains.block().is_empty() {
    410         return Err(E::NoBlockEntries);
    411     }
    412     let (unblock_count, block_count) = domains.write(config.rpz.map(|file| {
    413         (
    414             file,
    415             tmp_rpz.unwrap_or_else(|| unreachable!("there is a bug in main")),
    416         )
    417     }))?;
    418     if matches!(verbosity, Verbosity::None) {
    419         Ok(())
    420     } else {
    421         priv_sep::pledge_away_all_but_stdio(&mut promises)?;
    422         write_summary(
    423             summaries,
    424             matches!(verbosity, Verbosity::High),
    425             unblock_count,
    426             block_count,
    427         )
    428         .map_err(E::Io)
    429     }
    430 }