rpz

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

main.rs (21250B)


      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(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 use crate::{
     24     app::Domains,
     25     args::{ArgsErr, ConfigPath, Opts},
     26     config::Config,
     27 };
     28 use ascii_domain as _;
     29 use core::{
     30     error::Error,
     31     fmt::{self, Display, Formatter},
     32     time::Duration,
     33 };
     34 use num_bigint as _;
     35 use reqwest::{Client, Error as HttpErr};
     36 use rpz::{
     37     dom::FirefoxDomainErr,
     38     file::{AbsFilePath, ExtFileErr, ExternalFiles, Files, HttpUrl, LocalFiles, Summary},
     39 };
     40 #[cfg(target_os = "openbsd")]
     41 use rustls::{
     42     ClientConfig, Error as TlsErr, RootCertStore,
     43     pki_types::{
     44         CertificateDer,
     45         pem::{Error as PkiErr, PemObject as _},
     46     },
     47     version::TLS13,
     48 };
     49 use std::{
     50     collections::HashSet,
     51     fs,
     52     io::{self, Read as _, Write as _},
     53     sync::OnceLock,
     54 };
     55 use tokio::runtime::Builder;
     56 use toml::de;
     57 use url as _;
     58 use zfc as _;
     59 /// The HTTP(S) client that is used to download all files.
     60 /// It is initialized exactly once in `main` before being used.
     61 static CLIENT: OnceLock<Client> = OnceLock::new();
     62 /// The output printed to `stdout` when `-h`/`--help` are passed
     63 /// to the program.
     64 const HELP: &str = "Response policy zone (RPZ) file generator
     65 
     66 Usage: rpz [OPTIONS]
     67 
     68 Options:
     69   -V, --version               Print version info and exit
     70   -h, --help                  Print help
     71   -q, --quiet                 Do not print messages
     72   -v, --verbose               Print summary parsing information about each file
     73   -f, --file <CONFIG_FILE>    Required option to pass '-' for stdin or the absolute path to the config file";
     74 /// The output printed to `stdout` when `-v`/`--version` are passed
     75 /// to the program.
     76 const VERSION: &str = concat!("rpz ", env!("CARGO_PKG_VERSION"));
     77 /// The User-Agent header value sent to HTTP(S) servers.
     78 const USER_AGENT: &str = concat!("rpz/", env!("CARGO_PKG_VERSION"));
     79 /// Error returned from the program.
     80 enum E {
     81     /// Variant for errors due to incorrect arguments being passed.
     82     Args(ArgsErr),
     83     /// Variant for errors due to issues with the TOML config file.
     84     Config(de::Error),
     85     /// Variant for IO errors.
     86     Io(io::Error),
     87     /// Variant for errors due to downloading external HTTP(S) block files.
     88     ExtFile(ExtFileErr),
     89     /// Variant when there are no block entries to be written.
     90     NoBlockEntries,
     91     /// Variant when there is an issue creating the HTTP(S) client.
     92     HttpClient(HttpErr),
     93     /// Variant when there is a `rustls` issue.
     94     #[cfg(target_os = "openbsd")]
     95     Tls(TlsErr),
     96     /// Variant when there is an issue parsing the root certificate store.
     97     #[cfg(target_os = "openbsd")]
     98     Pki(PkiErr),
     99 }
    100 impl fmt::Debug for E {
    101     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    102         match *self {
    103             Self::Args(ref e) => write!(f, "{e}.\nFor more information, try '--help'."),
    104             Self::Config(_)
    105             | Self::Io(_)
    106             | Self::ExtFile(_)
    107             | Self::NoBlockEntries
    108             | Self::HttpClient(_) => <Self as Display>::fmt(self, f),
    109             #[cfg(target_os = "openbsd")]
    110             Self::Tls(_) | Self::Pki(_) => <Self as Display>::fmt(self, f),
    111         }
    112     }
    113 }
    114 impl Display for E {
    115     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    116         match *self {
    117             Self::Args(ref e) => e.fmt(f),
    118             Self::Config(ref e) => e.fmt(f),
    119             Self::Io(ref e) => e.fmt(f),
    120             Self::ExtFile(ref e) => e.fmt(f),
    121             Self::NoBlockEntries => f.write_str("there are no domains to block"),
    122             Self::HttpClient(ref e) => e.fmt(f),
    123             #[cfg(target_os = "openbsd")]
    124             Self::Tls(ref e) => e.fmt(f),
    125             #[cfg(target_os = "openbsd")]
    126             Self::Pki(ref e) => e.fmt(f),
    127         }
    128     }
    129 }
    130 impl Error for E {}
    131 impl From<ArgsErr> for E {
    132     fn from(value: ArgsErr) -> Self {
    133         Self::Args(value)
    134     }
    135 }
    136 impl From<de::Error> for E {
    137     fn from(value: de::Error) -> Self {
    138         Self::Config(value)
    139     }
    140 }
    141 impl From<io::Error> for E {
    142     fn from(value: io::Error) -> Self {
    143         Self::Io(value)
    144     }
    145 }
    146 impl From<ExtFileErr> for E {
    147     fn from(value: ExtFileErr) -> Self {
    148         Self::ExtFile(value)
    149     }
    150 }
    151 #[cfg(not(target_os = "openbsd"))]
    152 impl From<!> for E {
    153     fn from(value: !) -> Self {
    154         value
    155     }
    156 }
    157 /// Reads `Config` from `conf`.
    158 fn get_config(conf: ConfigPath) -> Result<Config, E> {
    159     toml::from_str::<Config>(
    160         match conf {
    161             ConfigPath::Stdin => {
    162                 let mut file = String::new();
    163                 _ = io::stdin().lock().read_to_string(&mut file)?;
    164                 file
    165             }
    166             ConfigPath::Path(path) => {
    167                 priv_sep::unveil_read_file(path.as_path())?;
    168                 let file = fs::read_to_string(path.as_path())?;
    169                 priv_sep::unveil_none(path)?;
    170                 file
    171             }
    172         }
    173         .as_str(),
    174     )
    175     .map_err(E::Config)
    176 }
    177 /// Gets `LocalFiles` from `local_dir`.
    178 fn get_local_files(local_dir: Option<AbsFilePath<true>>) -> Result<Option<LocalFiles>, E> {
    179     local_dir.map_or_else(
    180         || Ok(None),
    181         |dir| {
    182             priv_sep::unveil_read_dir(dir.as_path())
    183                 .map_err(E::from)
    184                 .and_then(|exists| {
    185                     if exists {
    186                         LocalFiles::from_path(dir.clone())
    187                             .map_err(E::Io)
    188                             .and_then(|files| {
    189                                 priv_sep::unveil_none(dir).map_err(E::from).map(|()| files)
    190                             })
    191                     } else {
    192                         Ok(None)
    193                     }
    194                 })
    195         },
    196     )
    197 }
    198 /// Retrieves the HTTP(S) client.
    199 #[cfg(target_os = "openbsd")]
    200 fn https_client() -> Result<Client, E> {
    201     fs::read("/etc/ssl/cert.pem")
    202         .map_err(E::Io)
    203         .and_then(|root_cert_file| {
    204             let mut root_store = RootCertStore::empty();
    205             CertificateDer::pem_slice_iter(&root_cert_file)
    206                 .try_fold((), |(), cert| {
    207                     cert.map_err(E::Pki)
    208                         .and_then(|c| root_store.add(c).map_err(E::Tls))
    209                 })
    210                 .and_then(|()| {
    211                     let mut conf =
    212                         ClientConfig::builder_with_protocol_versions([&TLS13].as_slice())
    213                             .with_root_certificates(root_store)
    214                             .with_no_client_auth();
    215                     conf.alpn_protocols = vec![vec![b'h', b'2']];
    216                     Client::builder()
    217                         .user_agent(USER_AGENT)
    218                         .tls_backend_preconfigured(conf)
    219                         .build()
    220                         .map_err(E::HttpClient)
    221                 })
    222         })
    223 }
    224 /// Retrieves the HTTP(S) client.
    225 #[cfg(not(target_os = "openbsd"))]
    226 fn https_client() -> Result<Client, E> {
    227     Client::builder()
    228         .user_agent(USER_AGENT)
    229         .tls_backend_rustls()
    230         .build()
    231         .map_err(E::HttpClient)
    232 }
    233 /// Downloads block files from HTTP(S) servers.
    234 #[expect(clippy::unreachable, reason = "there is a bug and we want to crash")]
    235 fn get_external_files(
    236     timeout: Duration,
    237     adblock: HashSet<HttpUrl>,
    238     domain: HashSet<HttpUrl>,
    239     hosts: HashSet<HttpUrl>,
    240     wildcard: HashSet<HttpUrl>,
    241 ) -> Result<Files, E> {
    242     Builder::new_current_thread()
    243         .enable_all()
    244         .build()
    245         .map_or_else(
    246             |e| Err(E::Io(e)),
    247             |runtime| {
    248                 runtime.block_on(async {
    249                     match https_client() {
    250                         Ok(c) => {
    251                             CLIENT.set(c).unwrap_or_else(|_e| {
    252                                 unreachable!("there is a bug in OnceLock::set")
    253                             });
    254                             let client = CLIENT
    255                                 .get()
    256                                 .unwrap_or_else(|| unreachable!("there is a bug in OnceLock::get"));
    257                             let mut files = ExternalFiles::new();
    258                             files.add_adblock(client, adblock);
    259                             files.add_domain(client, domain);
    260                             files.add_hosts(client, hosts);
    261                             files.add_wildcard(client, wildcard);
    262                             Files::from_external(files, timeout)
    263                                 .await
    264                                 .map_err(E::ExtFile)
    265                         }
    266                         Err(e) => Err(e),
    267                     }
    268                 })
    269             },
    270         )
    271 }
    272 /// Verbosity of what is written to `stdout`.
    273 #[derive(Clone, Copy)]
    274 enum Verbosity {
    275     /// Suppress all summary info.
    276     None,
    277     /// Write normal amount of info.
    278     Normal,
    279     /// Write verbose information.
    280     High,
    281 }
    282 /// Writes to `stdout` the summary information in the event the quiet
    283 /// option was not passed.
    284 fn write_summary(
    285     summaries: Vec<Summary<'_, FirefoxDomainErr>>,
    286     verbose: bool,
    287     unblock_count: usize,
    288     block_count: usize,
    289 ) -> Result<(), io::Error> {
    290     let mut stdout = io::stdout().lock();
    291     let mut domain_count = 0usize;
    292     let mut comment_count = 0usize;
    293     let mut blank_count = 0usize;
    294     let mut error_count = 0usize;
    295     if verbose {
    296         summaries.into_iter().try_fold((), |(), summary| {
    297             domain_count = domain_count.saturating_add(summary.domain_count);
    298             comment_count = comment_count.saturating_add(summary.comment_count);
    299             blank_count = blank_count.saturating_add(summary.blank_count);
    300             error_count = error_count.saturating_add(summary.errors.values().sum());
    301             writeln!(&mut stdout, "{summary}")
    302         })?;
    303     } else {
    304         summaries.into_iter().fold((), |(), summary| {
    305             domain_count = domain_count.saturating_add(summary.domain_count);
    306             comment_count = comment_count.saturating_add(summary.comment_count);
    307             blank_count = blank_count.saturating_add(summary.blank_count);
    308             error_count = error_count.saturating_add(summary.errors.values().sum());
    309         });
    310     }
    311     writeln!(
    312         stdout,
    313         "unblock count written: {}\nblock count written: {}\ntotal lines written: {}\ndomains parsed: {}\ncomments parsed: {}\nblanks parsed: {}\nparsing errors: {}",
    314         unblock_count,
    315         block_count,
    316         unblock_count.saturating_add(block_count),
    317         domain_count,
    318         comment_count,
    319         blank_count,
    320         error_count,
    321     )
    322 }
    323 #[expect(
    324     clippy::arithmetic_side_effects,
    325     clippy::unreachable,
    326     reason = "math is correct and we want to crash if there is a bug"
    327 )]
    328 fn main() -> Result<(), E> {
    329     let mut promises = priv_sep::pledge_init()?;
    330     priv_sep::veil_all()?;
    331     let (conf, verbosity) = match Opts::from_args()? {
    332         Opts::Help => return writeln!(io::stdout().lock(), "{HELP}").map_err(E::Io),
    333         Opts::Version => return writeln!(io::stdout().lock(), "{VERSION}").map_err(E::Io),
    334         Opts::Config(path) => (path, Verbosity::Normal),
    335         Opts::ConfigQuiet(path) => (path, Verbosity::None),
    336         Opts::ConfigVerbose(path) => (path, Verbosity::High),
    337         Opts::Verbose | Opts::Quiet => return Err(E::Args(ArgsErr::ConfigPathNotPassed)),
    338         Opts::None => return Err(E::Args(ArgsErr::NoArgs)),
    339     };
    340     let config = get_config(conf)?;
    341     // We use a temp file to write to that way we can avoid overwriting
    342     // a file just for the process to error. Once the temp file is written to,
    343     // we rename it to the desired name.
    344     let tmp_rpz = config.rpz.as_ref().map_or_else(
    345         || {
    346             priv_sep::pledge_away_create_write(&mut promises)
    347                 .map_err(E::from)
    348                 .map(|()| None)
    349         },
    350         |file| {
    351             priv_sep::unveil_create(file.as_path())
    352                 .and_then(|()| {
    353                     let mut rpz = file.clone();
    354                     _ = rpz.append("tmp");
    355                     priv_sep::unveil_create_read_write(rpz.as_path()).map(|()| Some(rpz))
    356                 })
    357                 .map_err(E::from)
    358         },
    359     )?;
    360     let local_files = get_local_files(config.local_dir)?;
    361     let (mut domains, mut summaries) = if let Some(files) = local_files.as_ref() {
    362         Domains::new_with(files)
    363     } else {
    364         (
    365             Domains::new(),
    366             Vec::with_capacity(
    367                 config.adblock.len()
    368                     + config.domain.len()
    369                     + config.hosts.len()
    370                     + config.wildcard.len(),
    371             ),
    372         )
    373     };
    374     let ext_files = if config.adblock.is_empty()
    375         && config.domain.is_empty()
    376         && config.hosts.is_empty()
    377         && config.wildcard.is_empty()
    378     {
    379         if domains.block().is_empty() {
    380             return Err(E::NoBlockEntries);
    381         }
    382         priv_sep::pledge_away_net(&mut promises)
    383             .and_then(|()| priv_sep::pledge_away_unveil(&mut promises).map(|()| Files::new()))
    384             .map_err(E::from)
    385     } else {
    386         priv_sep::unveil_https().map_err(E::from).and_then(|()| {
    387             priv_sep::pledge_away_unveil(&mut promises)
    388                 .map_err(E::from)
    389                 .and_then(|()| {
    390                     get_external_files(
    391                         config.timeout.unwrap_or(Duration::from_hours(1)),
    392                         config.adblock,
    393                         config.domain,
    394                         config.hosts,
    395                         config.wildcard,
    396                     )
    397                     .and_then(|files| {
    398                         priv_sep::pledge_away_net(&mut promises)
    399                             .map_err(E::from)
    400                             .map(|()| files)
    401                     })
    402                 })
    403         })
    404     }?;
    405     domains.add_block_files(&ext_files, &mut summaries);
    406     if domains.block().is_empty() {
    407         return Err(E::NoBlockEntries);
    408     }
    409     let (unblock_count, block_count) = domains.write(config.rpz.map(|file| {
    410         (
    411             file,
    412             tmp_rpz.unwrap_or_else(|| unreachable!("there is a bug in main")),
    413         )
    414     }))?;
    415     if matches!(verbosity, Verbosity::None) {
    416         Ok(())
    417     } else {
    418         priv_sep::pledge_away_all_but_stdio(&mut promises)?;
    419         write_summary(
    420             summaries,
    421             matches!(verbosity, Verbosity::High),
    422             unblock_count,
    423             block_count,
    424         )
    425         .map_err(E::Io)
    426     }
    427 }
    428 #[cfg(test)]
    429 pub(crate) mod test_prog {
    430     use std::{fs, process::Command};
    431     /// The path to the program dir with no block subdirectories.
    432     pub(crate) const PROG_DIR_NO_SUB: &str = "/home/zack/projects/rpz/target/";
    433     /// The path to the program dir with block subdirectories.
    434     pub(crate) const PROG_DIR: &str = "/home/zack/projects/rpz/target/release/";
    435     /// The path to the program.
    436     const PROG: &str = "/home/zack/projects/rpz/target/release/rpz";
    437     /// Message to append to the appropriate variable above if an issue occurs.
    438     pub(crate) const ERR_MSG: &str = " does not exist, so program testing cannot occur";
    439     /// Verify the correct directories and files exist.
    440     #[expect(clippy::panic, reason = "OK in tests")]
    441     pub(crate) fn verify_files() {
    442         if !fs::metadata(PROG)
    443             .unwrap_or_else(|_| panic!("{PROG}{ERR_MSG}"))
    444             .is_file()
    445         {
    446             panic!("{PROG} is not an executable file")
    447         } else if !fs::metadata(PROG_DIR)
    448             .unwrap_or_else(|_| panic!("{PROG_DIR}{ERR_MSG}"))
    449             .is_dir()
    450         {
    451             panic!("{PROG_DIR} is not a directory")
    452         } else if !fs::metadata(PROG_DIR_NO_SUB)
    453             .unwrap_or_else(|_| panic!("{PROG_DIR_NO_SUB}{ERR_MSG}"))
    454             .is_dir()
    455         {
    456             panic!("{PROG_DIR_NO_SUB} is not a directory")
    457         } else {
    458             let mut files = String::from(PROG_DIR);
    459             let len = files.len();
    460             files.push_str("block/adblock/foo");
    461             if fs::metadata(files.as_str())
    462                 .unwrap_or_else(|_| panic!("{files}{ERR_MSG}. it must only contain '||bar.com'"))
    463                 .is_file()
    464             {
    465                 files.truncate(len);
    466                 files.push_str("block/domain/foo");
    467                 if fs::metadata(files.as_str())
    468                     .unwrap_or_else(|_| {
    469                         panic!("{files}{ERR_MSG}. it must only contain 'www.example.com'")
    470                     })
    471                     .is_file()
    472                 {
    473                     files.truncate(len);
    474                     files.push_str("unblock/hosts/foo");
    475                     if fs::metadata(files.as_str())
    476                         .unwrap_or_else(|_| {
    477                             panic!("{files}{ERR_MSG}. it must only contain '0.0.0.0 www.bar.com'")
    478                         })
    479                         .is_file()
    480                     {
    481                         files.truncate(len);
    482                         files.push_str("unblock/wildcard/foo");
    483                         if !fs::metadata(files.as_str())
    484                             .unwrap_or_else(|_| {
    485                                 panic!("{files}{ERR_MSG}. it must only contain '*.foo.com'")
    486                             })
    487                             .is_file()
    488                         {
    489                             panic!("{files} is not a file");
    490                         }
    491                     } else {
    492                         panic!("{files} is not a file");
    493                     }
    494                 } else {
    495                     panic!("{files} is not a file");
    496                 }
    497             } else {
    498                 panic!("{files} is not a file");
    499             }
    500         }
    501     }
    502     pub(crate) fn get_command() -> Command {
    503         Command::new(PROG)
    504     }
    505 }
    506 #[cfg(test)]
    507 mod tests {
    508     use crate::{E, test_prog};
    509     use std::{io::Write as _, process::Stdio, thread};
    510     #[test]
    511     #[ignore = "performs I/O"]
    512     fn app() {
    513         test_prog::verify_files();
    514         assert!(
    515             test_prog::get_command()
    516                 .args(["-f", "-"])
    517                 .stderr(Stdio::piped())
    518                 .stdin(Stdio::piped())
    519                 .stdout(Stdio::null())
    520                 .spawn()
    521                 .is_ok_and(|mut cmd| {
    522                     cmd.stdin.take().is_some_and(|mut stdin| {
    523                         thread::spawn(move || {
    524                             stdin
    525                                 .write_all(
    526                                     format!("local_dir=\"{}\"", test_prog::PROG_DIR_NO_SUB)
    527                                         .as_bytes(),
    528                                 )
    529                                 .is_ok()
    530                         })
    531                         .join()
    532                         .is_ok_and(|v| v)
    533                     }) && cmd.wait_with_output().is_ok_and(|output| {
    534                         !output.status.success()
    535                             && output.stderr
    536                                 == format!("Error: {:?}\n", E::NoBlockEntries).into_bytes()
    537                     })
    538                 })
    539         );
    540         assert!(test_prog::get_command()
    541             .args(["-vf", "-"])
    542             .stderr(Stdio::null())
    543             .stdin(Stdio::piped())
    544             .stdout(Stdio::piped())
    545             .spawn()
    546             .is_ok_and(|mut cmd| {
    547                 cmd.stdin.take().is_some_and(|mut stdin| {
    548                     thread::spawn(move || {
    549                         stdin
    550                             .write_all(format!("local_dir=\"{}\"", test_prog::PROG_DIR).as_bytes())
    551                             .is_ok()
    552                     })
    553                     .join()
    554                     .is_ok_and(|v| v)
    555                 }) && cmd.wait_with_output().is_ok_and(|output| {
    556                     output.status.success()
    557                         && output.stdout
    558                             == b"www.bar.com CNAME rpz-passthru.
    559 bar.com CNAME .
    560 *.bar.com CNAME .
    561 www.example.com CNAME .
    562 (Hosts) /home/zack/projects/rpz/target/release/unblock/hosts/foo - domains parsed: 1, comments parsed: 0, blanks parsed: 0, parsing errors: 0
    563 (Wildcard) /home/zack/projects/rpz/target/release/unblock/wildcard/foo - domains parsed: 1, comments parsed: 0, blanks parsed: 0, parsing errors: 0
    564 (Adblock) /home/zack/projects/rpz/target/release/block/adblock/foo - domains parsed: 1, comments parsed: 0, blanks parsed: 0, parsing errors: 0
    565 (Domain-only) /home/zack/projects/rpz/target/release/block/domain/foo - domains parsed: 1, comments parsed: 0, blanks parsed: 0, parsing errors: 0
    566 unblock count written: 1
    567 block count written: 3
    568 total lines written: 4
    569 domains parsed: 4
    570 comments parsed: 0
    571 blanks parsed: 0
    572 parsing errors: 0\n"
    573                 })
    574             }));
    575     }
    576 }