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 }