rpz

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

main.rs (20244B)


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