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 }