rpz

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

main.rs (20500B)


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