main.rs (15074B)
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(core_io, 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 /// Unit tests. 24 #[cfg(test)] 25 mod tests; 26 use crate::{ 27 app::Domains, 28 args::{ArgsErr, ConfigPath, Opts}, 29 config::Config, 30 }; 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 use reqwest::{Client, Error as HttpErr}; 39 use rpz::{ 40 dom::FirefoxDomainErr, 41 file::{AbsFilePath, ExtFileErr, ExternalFiles, Files, HttpUrl, LocalFiles, Summary}, 42 }; 43 #[cfg(target_os = "openbsd")] 44 use rustls::{ 45 ClientConfig, Error as TlsErr, RootCertStore, 46 pki_types::{ 47 CertificateDer, 48 pem::{Error as PkiErr, PemObject as _}, 49 }, 50 version::TLS13, 51 }; 52 use std::{ 53 collections::HashSet, 54 fs, 55 io::{self, Read as _, Write as _}, 56 sync::OnceLock, 57 }; 58 use tokio::runtime::Builder; 59 use toml::de; 60 use url as _; 61 use zfc as _; 62 /// The HTTP(S) client that is used to download all files. 63 /// It is initialized exactly once in `main` before being used. 64 static CLIENT: OnceLock<Client> = OnceLock::new(); 65 /// The output printed to `stdout` when `-h`/`--help` are passed 66 /// to the program. 67 const HELP: &str = "Response policy zone (RPZ) file generator 68 69 Usage: rpz [OPTIONS] 70 71 Options: 72 -V, --version Print version info and exit 73 -h, --help Print help 74 -q, --quiet Do not print messages 75 -v, --verbose Print summary parsing information about each file 76 -f, --file <CONFIG_FILE> Required option to pass '-' for stdin or the absolute path to the config file"; 77 /// The output printed to `stdout` when `-v`/`--version` are passed 78 /// to the program. 79 const VERSION: &str = concat!("rpz ", env!("CARGO_PKG_VERSION")); 80 /// The User-Agent header value sent to HTTP(S) servers. 81 const USER_AGENT: &str = concat!("rpz/", env!("CARGO_PKG_VERSION")); 82 /// Error returned from the program. 83 enum E { 84 /// Variant for errors due to incorrect arguments being passed. 85 Args(ArgsErr), 86 /// Variant for errors due to issues with the TOML config file. 87 Config(de::Error), 88 /// Variant for IO errors. 89 Io(io::Error), 90 /// Variant for errors due to downloading external HTTP(S) block files. 91 ExtFile(ExtFileErr), 92 /// Variant when there are no block entries to be written. 93 NoBlockEntries, 94 /// Variant when there is an issue creating the HTTP(S) client. 95 HttpClient(HttpErr), 96 /// Variant when there is a `rustls` issue. 97 #[cfg(target_os = "openbsd")] 98 Tls(TlsErr), 99 /// Variant when there is an issue parsing the root certificate store. 100 #[cfg(target_os = "openbsd")] 101 Pki(PkiErr), 102 } 103 impl fmt::Debug for E { 104 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 105 match *self { 106 Self::Args(ref e) => write!(f, "{e}.\nFor more information, try '--help'."), 107 Self::Config(_) 108 | Self::Io(_) 109 | Self::ExtFile(_) 110 | Self::NoBlockEntries 111 | Self::HttpClient(_) => <Self as Display>::fmt(self, f), 112 #[cfg(target_os = "openbsd")] 113 Self::Tls(_) | Self::Pki(_) => <Self as Display>::fmt(self, f), 114 } 115 } 116 } 117 impl Display for E { 118 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 119 match *self { 120 Self::Args(ref e) => e.fmt(f), 121 Self::Config(ref e) => e.fmt(f), 122 Self::Io(ref e) => e.fmt(f), 123 Self::ExtFile(ref e) => e.fmt(f), 124 Self::NoBlockEntries => f.write_str("there are no domains to block"), 125 Self::HttpClient(ref e) => e.fmt(f), 126 #[cfg(target_os = "openbsd")] 127 Self::Tls(ref e) => e.fmt(f), 128 #[cfg(target_os = "openbsd")] 129 Self::Pki(ref e) => e.fmt(f), 130 } 131 } 132 } 133 impl Error for E {} 134 impl From<ArgsErr> for E { 135 fn from(value: ArgsErr) -> Self { 136 Self::Args(value) 137 } 138 } 139 impl From<de::Error> for E { 140 fn from(value: de::Error) -> Self { 141 Self::Config(value) 142 } 143 } 144 impl From<io::Error> for E { 145 fn from(value: io::Error) -> Self { 146 Self::Io(value) 147 } 148 } 149 impl From<ExtFileErr> for E { 150 fn from(value: ExtFileErr) -> Self { 151 Self::ExtFile(value) 152 } 153 } 154 #[cfg(not(target_os = "openbsd"))] 155 impl From<!> for E { 156 fn from(value: !) -> Self { 157 value 158 } 159 } 160 /// Reads `Config` from `conf`. 161 fn get_config(conf: ConfigPath) -> Result<Config, E> { 162 toml::from_str::<Config>( 163 match conf { 164 ConfigPath::Stdin => { 165 let mut file = String::new(); 166 _ = io::stdin().lock().read_to_string(&mut file)?; 167 file 168 } 169 ConfigPath::Path(path) => { 170 priv_sep::unveil_read_file(path.as_path())?; 171 let file = fs::read_to_string(path.as_path())?; 172 priv_sep::unveil_none(path)?; 173 file 174 } 175 } 176 .as_str(), 177 ) 178 .map_err(E::Config) 179 } 180 /// Gets `LocalFiles` from `local_dir`. 181 fn get_local_files(local_dir: Option<AbsFilePath<true>>) -> Result<Option<LocalFiles>, E> { 182 local_dir.map_or_else( 183 || Ok(None), 184 |dir| { 185 priv_sep::unveil_read_dir(dir.as_path()) 186 .map_err(E::from) 187 .and_then(|exists| { 188 if exists { 189 LocalFiles::from_path(dir.clone()) 190 .map_err(E::Io) 191 .and_then(|files| { 192 priv_sep::unveil_none(dir).map_err(E::from).map(|()| files) 193 }) 194 } else { 195 Ok(None) 196 } 197 }) 198 }, 199 ) 200 } 201 /// Retrieves the HTTP(S) client. 202 #[cfg(target_os = "openbsd")] 203 fn https_client() -> Result<Client, E> { 204 fs::read("/etc/ssl/cert.pem") 205 .map_err(E::Io) 206 .and_then(|root_cert_file| { 207 let mut root_store = RootCertStore::empty(); 208 CertificateDer::pem_slice_iter(&root_cert_file) 209 .try_fold((), |(), cert| { 210 cert.map_err(E::Pki) 211 .and_then(|c| root_store.add(c).map_err(E::Tls)) 212 }) 213 .and_then(|()| { 214 let mut conf = 215 ClientConfig::builder_with_protocol_versions([&TLS13].as_slice()) 216 .with_root_certificates(root_store) 217 .with_no_client_auth(); 218 conf.alpn_protocols = vec![vec![b'h', b'2']]; 219 Client::builder() 220 .user_agent(USER_AGENT) 221 .tls_backend_preconfigured(conf) 222 .build() 223 .map_err(E::HttpClient) 224 }) 225 }) 226 } 227 /// Retrieves the HTTP(S) client. 228 #[cfg(not(target_os = "openbsd"))] 229 fn https_client() -> Result<Client, E> { 230 Client::builder() 231 .user_agent(USER_AGENT) 232 .tls_backend_rustls() 233 .build() 234 .map_err(E::HttpClient) 235 } 236 /// Downloads block files from HTTP(S) servers. 237 #[expect(clippy::unreachable, reason = "there is a bug and we want to crash")] 238 fn get_external_files( 239 timeout: Duration, 240 adblock: HashSet<HttpUrl>, 241 domain: HashSet<HttpUrl>, 242 hosts: HashSet<HttpUrl>, 243 wildcard: HashSet<HttpUrl>, 244 ) -> Result<Files, E> { 245 Builder::new_current_thread() 246 .enable_all() 247 .build() 248 .map_or_else( 249 |e| Err(E::Io(e)), 250 |runtime| { 251 runtime.block_on(async { 252 match https_client() { 253 Ok(c) => { 254 CLIENT.set(c).unwrap_or_else(|_e| { 255 unreachable!("there is a bug in OnceLock::set") 256 }); 257 let client = CLIENT 258 .get() 259 .unwrap_or_else(|| unreachable!("there is a bug in OnceLock::get")); 260 let mut files = ExternalFiles::new(); 261 files.add_adblock(client, adblock); 262 files.add_domain(client, domain); 263 files.add_hosts(client, hosts); 264 files.add_wildcard(client, wildcard); 265 Files::from_external(files, timeout) 266 .await 267 .map_err(E::ExtFile) 268 } 269 Err(e) => Err(e), 270 } 271 }) 272 }, 273 ) 274 } 275 /// Verbosity of what is written to `stdout`. 276 #[derive(Clone, Copy)] 277 enum Verbosity { 278 /// Suppress all summary info. 279 None, 280 /// Write normal amount of info. 281 Normal, 282 /// Write verbose information. 283 High, 284 } 285 /// Writes to `stdout` the summary information in the event the quiet 286 /// option was not passed. 287 fn write_summary( 288 summaries: Vec<Summary<'_, FirefoxDomainErr>>, 289 verbose: bool, 290 unblock_count: usize, 291 block_count: usize, 292 ) -> Result<(), io::Error> { 293 let mut stdout = io::stdout().lock(); 294 let mut domain_count = 0usize; 295 let mut comment_count = 0usize; 296 let mut blank_count = 0usize; 297 let mut error_count = 0usize; 298 if verbose { 299 summaries.into_iter().try_fold((), |(), summary| { 300 domain_count = domain_count.saturating_add(summary.domain_count); 301 comment_count = comment_count.saturating_add(summary.comment_count); 302 blank_count = blank_count.saturating_add(summary.blank_count); 303 error_count = error_count.saturating_add(summary.errors.values().sum()); 304 writeln!(&mut stdout, "{summary}") 305 })?; 306 } else { 307 summaries.into_iter().fold((), |(), summary| { 308 domain_count = domain_count.saturating_add(summary.domain_count); 309 comment_count = comment_count.saturating_add(summary.comment_count); 310 blank_count = blank_count.saturating_add(summary.blank_count); 311 error_count = error_count.saturating_add(summary.errors.values().sum()); 312 }); 313 } 314 writeln!( 315 stdout, 316 "unblock count written: {}\nblock count written: {}\ntotal lines written: {}\ndomains parsed: {}\ncomments parsed: {}\nblanks parsed: {}\nparsing errors: {}", 317 unblock_count, 318 block_count, 319 unblock_count.saturating_add(block_count), 320 domain_count, 321 comment_count, 322 blank_count, 323 error_count, 324 ) 325 } 326 #[expect( 327 clippy::arithmetic_side_effects, 328 clippy::unreachable, 329 reason = "math is correct and we want to crash if there is a bug" 330 )] 331 fn main() -> Result<(), E> { 332 let mut promises = priv_sep::pledge_init()?; 333 priv_sep::veil_all()?; 334 let (conf, verbosity) = match Opts::from_args()? { 335 Opts::Help => return writeln!(io::stdout().lock(), "{HELP}").map_err(E::Io), 336 Opts::Version => return writeln!(io::stdout().lock(), "{VERSION}").map_err(E::Io), 337 Opts::Config(path) => (path, Verbosity::Normal), 338 Opts::ConfigQuiet(path) => (path, Verbosity::None), 339 Opts::ConfigVerbose(path) => (path, Verbosity::High), 340 Opts::Verbose | Opts::Quiet => return Err(E::Args(ArgsErr::ConfigPathNotPassed)), 341 Opts::None => return Err(E::Args(ArgsErr::NoArgs)), 342 }; 343 let config = get_config(conf)?; 344 // We use a temp file to write to that way we can avoid overwriting 345 // a file just for the process to error. Once the temp file is written to, 346 // we rename it to the desired name. 347 let tmp_rpz = config.rpz.as_ref().map_or_else( 348 || { 349 priv_sep::pledge_away_create_write(&mut promises) 350 .map_err(E::from) 351 .map(|()| None) 352 }, 353 |file| { 354 priv_sep::unveil_create(file.as_path()) 355 .and_then(|()| { 356 let mut rpz = file.clone(); 357 _ = rpz.append("tmp"); 358 priv_sep::unveil_create_read_write(rpz.as_path()).map(|()| Some(rpz)) 359 }) 360 .map_err(E::from) 361 }, 362 )?; 363 let local_files = get_local_files(config.local_dir)?; 364 let (mut domains, mut summaries) = if let Some(files) = local_files.as_ref() { 365 Domains::new_with(files) 366 } else { 367 ( 368 Domains::new(), 369 Vec::with_capacity( 370 config.adblock.len() 371 + config.domain.len() 372 + config.hosts.len() 373 + config.wildcard.len(), 374 ), 375 ) 376 }; 377 let ext_files = if config.adblock.is_empty() 378 && config.domain.is_empty() 379 && config.hosts.is_empty() 380 && config.wildcard.is_empty() 381 { 382 if domains.block().is_empty() { 383 return Err(E::NoBlockEntries); 384 } 385 priv_sep::pledge_away_net(&mut promises) 386 .and_then(|()| priv_sep::pledge_away_unveil(&mut promises).map(|()| Files::new())) 387 .map_err(E::from) 388 } else { 389 priv_sep::unveil_https().map_err(E::from).and_then(|()| { 390 priv_sep::pledge_away_unveil(&mut promises) 391 .map_err(E::from) 392 .and_then(|()| { 393 get_external_files( 394 config.timeout.unwrap_or(Duration::from_hours(1)), 395 config.adblock, 396 config.domain, 397 config.hosts, 398 config.wildcard, 399 ) 400 .and_then(|files| { 401 priv_sep::pledge_away_net(&mut promises) 402 .map_err(E::from) 403 .map(|()| files) 404 }) 405 }) 406 }) 407 }?; 408 domains.add_block_files(&ext_files, &mut summaries); 409 if domains.block().is_empty() { 410 return Err(E::NoBlockEntries); 411 } 412 let (unblock_count, block_count) = domains.write(config.rpz.map(|file| { 413 ( 414 file, 415 tmp_rpz.unwrap_or_else(|| unreachable!("there is a bug in main")), 416 ) 417 }))?; 418 if matches!(verbosity, Verbosity::None) { 419 Ok(()) 420 } else { 421 priv_sep::pledge_away_all_but_stdio(&mut promises)?; 422 write_summary( 423 summaries, 424 matches!(verbosity, Verbosity::High), 425 unblock_count, 426 block_count, 427 ) 428 .map_err(E::Io) 429 } 430 }