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 }