1#[cfg_attr(not(pac_quickjs), allow(dead_code))]
8mod hostfn;
9mod policy;
10mod result;
11#[cfg_attr(not(pac_quickjs), allow(dead_code))]
12mod time;
13
14#[cfg(any(pac_quickjs, feature = "pac-subprocess"))]
15mod budget;
16
17mod dispatch;
18
19#[cfg(pac_quickjs)]
20mod quickjs;
21
22#[cfg(feature = "pac-subprocess")]
23mod subprocess;
24
25#[cfg(all(windows, feature = "pac-windows-native"))]
27mod winhttp;
28
29#[cfg(all(target_os = "android", feature = "pac-android-native"))]
30mod android;
31
32#[cfg(any(pac_cfnetwork, test))]
34mod cfnetwork;
35
36use std::fmt;
37
38use url::Url;
39
40use crate::error::Error;
41use crate::mode::ProxyMode;
42use crate::resolve::ProxyStep;
43
44pub use self::dispatch::PacResolver;
45pub use self::policy::{DEFAULT_PAC_TIMEOUT, PacPolicy};
46pub use self::result::parse_find_proxy_result;
47
48#[cfg(pac_quickjs)]
49pub use self::quickjs::QuickJsEvaluator;
50
51pub const QUICKJS_AVAILABLE: bool = cfg!(pac_quickjs);
55
56#[cfg(feature = "pac-subprocess")]
57pub use self::subprocess::SubprocessEvaluator;
58#[cfg(all(feature = "pac-subprocess", pac_quickjs))]
59pub use self::subprocess::serve_worker;
60
61#[cfg(all(windows, feature = "pac-windows-native"))]
62pub use self::winhttp::{
63 AutoProxyReset, DEFAULT_WINHTTP_PAC_TIMEOUT, WinHttpPacResolver, WinHttpPacSource,
64};
65
66#[cfg(all(target_os = "android", feature = "pac-android-native"))]
67pub use self::android::AndroidPacResolver;
68
69#[cfg(all(windows, feature = "pac-windows-native"))]
73pub type NativePacResolver = WinHttpPacResolver;
74#[cfg(pac_cfnetwork)]
76pub type NativePacResolver = CfNetworkPacResolver;
77#[cfg(all(target_os = "android", feature = "pac-android-native"))]
79pub type NativePacResolver = AndroidPacResolver;
80
81#[cfg(pac_cfnetwork)]
82pub use self::cfnetwork::{
83 CfNetworkPacEvaluator, CfNetworkPacResolver, DEFAULT_CFNETWORK_PAC_TIMEOUT,
84};
85
86#[derive(Clone, PartialEq, Eq)]
88pub struct PacScript {
89 source: String,
90}
91
92impl fmt::Debug for PacScript {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.debug_struct("PacScript")
96 .field("len", &self.source.len())
97 .field(
98 "fnv1a",
99 &format_args!("{:016x}", crate::util::fnv1a(self.source.as_bytes())),
100 )
101 .finish()
102 }
103}
104
105impl PacScript {
106 #[must_use]
108 pub fn new(source: impl Into<String>) -> Self {
109 Self {
110 source: source.into(),
111 }
112 }
113
114 #[must_use]
116 pub fn source(&self) -> &str {
117 &self.source
118 }
119
120 #[must_use]
122 pub fn from_mode(mode: &ProxyMode) -> Option<Self> {
123 match mode {
124 ProxyMode::PacInline { script, .. } => Some(Self::new(script.clone())),
125 _ => None,
126 }
127 }
128}
129
130impl From<String> for PacScript {
131 fn from(source: String) -> Self {
132 Self::new(source)
133 }
134}
135
136impl From<&str> for PacScript {
137 fn from(source: &str) -> Self {
138 Self::new(source)
139 }
140}
141
142#[derive(Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum PacRequirement<'a> {
146 NotNeeded,
148 Inline(&'a str),
150 Fetch(&'a Url),
152 Discover,
156}
157
158impl fmt::Debug for PacRequirement<'_> {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 match self {
162 Self::NotNeeded => f.write_str("NotNeeded"),
163 Self::Discover => f.write_str("Discover"),
164 Self::Inline(script) => f
165 .debug_struct("Inline")
166 .field("len", &script.len())
167 .field(
168 "fnv1a",
169 &format_args!("{:016x}", crate::util::fnv1a(script.as_bytes())),
170 )
171 .finish(),
172 Self::Fetch(url) => f
173 .debug_tuple("Fetch")
174 .field(&format_args!(
175 "{}",
176 crate::util::redact_userinfo(url.as_str())
177 ))
178 .finish(),
179 }
180 }
181}
182
183#[must_use]
193pub fn requirement(mode: &ProxyMode) -> PacRequirement<'_> {
194 match mode {
195 ProxyMode::Pac { url, .. } => PacRequirement::Fetch(url),
196 ProxyMode::PacInline { script, .. } => PacRequirement::Inline(script),
197 ProxyMode::WpadAutoDetect => PacRequirement::Discover,
198 _ => PacRequirement::NotNeeded,
199 }
200}
201
202pub trait PacEvaluator {
209 fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error>;
220}
221
222#[must_use]
237pub fn sanitize_url(url: &Url) -> Url {
238 let mut sanitized = url.clone();
239 let refused = sanitized.set_username("").is_err();
248 let _ = sanitized.set_password(None);
249 if refused && sanitized.has_authority() {
250 let mut lent = sanitized.clone();
251 if lent.set_host(Some("x")).is_ok()
252 && lent.set_username("").is_ok()
253 && lent.set_password(None).is_ok()
254 && lent.set_host(Some("")).is_ok()
255 {
256 sanitized = lent;
257 }
258 }
259 sanitized.set_fragment(None);
260 if matches!(sanitized.scheme(), "https" | "wss") {
261 sanitized.set_path("/");
262 sanitized.set_query(None);
263 }
264 sanitized
265}
266
267pub fn evaluate(
281 script: &PacScript,
282 url: &Url,
283 policy: &PacPolicy,
284) -> Result<Vec<ProxyStep>, Error> {
285 evaluate_with_host(script, url, url.host_str().unwrap_or_default(), policy)
293}
294
295pub fn evaluate_with_host(
301 script: &PacScript,
302 url: &Url,
303 host: &str,
304 policy: &PacPolicy,
305) -> Result<Vec<ProxyStep>, Error> {
306 let url = &sanitize_url(url);
307 #[cfg(pac_quickjs)]
310 {
311 QuickJsEvaluator::new(*policy).evaluate(script, url, host)
312 }
313 #[cfg(not(pac_quickjs))]
314 {
315 let _ = (script, url, host, policy);
316 Err(Error::PacEngineUnavailable)
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use std::collections::HashMap;
323
324 use super::*;
325 use crate::BypassRules;
326
327 #[test]
337 fn every_requirement_debug_names_itself_and_keeps_its_fields() {
338 const SCRIPT: &str = "function FindProxyForURL(){}";
339 let url = Url::parse("https://wpad.corp/proxy.pac").unwrap();
340 let digest = format!(
341 "len: {}, fnv1a: {:016x}",
342 SCRIPT.len(),
343 crate::util::fnv1a(SCRIPT.as_bytes())
344 );
345 for (rendered, expected) in [
346 (
347 format!("{:?}", PacRequirement::NotNeeded),
348 "NotNeeded".to_owned(),
349 ),
350 (
351 format!("{:?}", PacRequirement::Discover),
352 "Discover".to_owned(),
353 ),
354 (
355 format!("{:?}", PacRequirement::Inline(SCRIPT)),
356 format!("Inline {{ {digest} }}"),
357 ),
358 (
359 format!("{:?}", PacRequirement::Fetch(&url)),
362 "Fetch(https://wpad.corp/proxy.pac)".to_owned(),
363 ),
364 (
365 format!("{:?}", PacScript::new(SCRIPT)),
366 format!("PacScript {{ {digest} }}"),
367 ),
368 ] {
369 assert_eq!(rendered, expected);
370 }
371 }
372
373 #[test]
375 fn the_documented_chromium_examples_come_out_the_same_way() {
376 for (actual, expected) in [
377 ("https://www.google.com/Foo", "https://www.google.com/"),
378 ("https://[dead::beef]/foo?bar", "https://[dead::beef]/"),
379 (
380 "https://www.example.com:8080#search",
381 "https://www.example.com:8080/",
382 ),
383 (
384 "https://username:password@www.example.com",
385 "https://www.example.com/",
386 ),
387 ] {
388 let url = Url::parse(actual).expect("valid url");
389 assert_eq!(sanitize_url(&url).as_str(), expected, "for {actual}");
390 }
391 }
392
393 #[test]
396 fn only_cryptographic_schemes_lose_their_path_and_query() {
397 let http = Url::parse("http://user:pw@example.net/deep/path?q=1#frag").unwrap();
398 assert_eq!(
399 sanitize_url(&http).as_str(),
400 "http://example.net/deep/path?q=1"
401 );
402
403 let wss = Url::parse("wss://user:pw@example.net/socket?q=1#frag").unwrap();
404 assert_eq!(sanitize_url(&wss).as_str(), "wss://example.net/");
405
406 let ws = Url::parse("ws://user:pw@example.net/socket?q=1#frag").unwrap();
407 assert_eq!(sanitize_url(&ws).as_str(), "ws://example.net/socket?q=1");
408 }
409
410 #[test]
411 fn sanitizing_is_idempotent_and_leaves_the_host_alone() {
412 for raw in [
413 "https://user:pw@example.net:8443/a?b#c",
414 "http://example.net/",
415 "ftp://user@files.corp/pub",
416 ] {
417 let url = Url::parse(raw).unwrap();
418 let once = sanitize_url(&url);
419 assert_eq!(sanitize_url(&once), once, "not idempotent for {raw}");
420 assert_eq!(once.host_str(), url.host_str(), "host changed for {raw}");
421 assert_eq!(once.port(), url.port(), "port changed for {raw}");
422 }
423 }
424
425 #[test]
428 fn cannot_be_a_base_urls_survive_untouched_except_for_the_fragment() {
429 let url = Url::parse("mailto:someone@example.net?subject=hi#frag").unwrap();
430 assert_eq!(
431 sanitize_url(&url).as_str(),
432 "mailto:someone@example.net?subject=hi"
433 );
434 }
435
436 #[test]
441 fn an_emptied_host_does_not_carry_the_credentials_through() {
442 for (input, expected) in [
443 (
444 "socks5://user:secret@example.net/p?q=1#frag",
445 "socks5:///p?q=1",
446 ),
447 (
448 "socks5://user:secret@example.net:8080/p",
449 "socks5://:8080/p",
450 ),
451 ("socks5://:secret@example.net/p", "socks5:///p"),
452 ("socks5://example.net:8080/p", "socks5://:8080/p"),
454 ] {
455 let mut url = Url::parse(input).unwrap();
456 url.set_host(Some(""))
457 .expect("a non-special scheme accepts an empty host");
458 let sanitized = sanitize_url(&url);
459 assert_eq!(sanitized.as_str(), expected, "for {input}");
460 assert_eq!(
461 sanitize_url(&sanitized),
462 sanitized,
463 "not idempotent for {input}"
464 );
465 }
466 }
467
468 #[test]
480 fn a_path_only_url_is_not_lent_an_authority_it_never_had() {
481 for (input, expected) in [
482 ("unix:/run/foo.socket", "unix:/run/foo.socket"),
483 ("git:/a/b?q=1#frag", "git:/a/b?q=1"),
485 ] {
486 let url = Url::parse(input).unwrap();
487 assert!(!url.has_authority(), "premise for {input}: {url:?}");
488 assert!(!url.cannot_be_a_base(), "premise for {input}: {url:?}");
489 assert_eq!(sanitize_url(&url).as_str(), expected, "for {input}");
490 }
491 }
492
493 #[cfg(pac_quickjs)]
494 #[test]
495 fn the_script_is_handed_the_sanitized_url_end_to_end() {
496 let script = PacScript::new(
497 "function FindProxyForURL(url, host) { return 'PROXY ' + url.replace(/[^a-zA-Z0-9.]/g, '-') + ':8080'; }",
498 );
499 let url = Url::parse("https://user:secret@example.net/private/doc?token=abc#x").unwrap();
500 let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
501 let seen = steps[0].endpoint().expect("a proxy step").authority();
502 assert_eq!(
503 seen, "https---example.net-:8080",
504 "the script must not see the credentials, path, query or fragment"
505 );
506 }
507
508 #[cfg(pac_quickjs)]
514 #[test]
515 fn a_hostless_url_reaches_the_script_with_an_empty_host() {
516 let script = PacScript::new(
517 "function FindProxyForURL(url, host) {
518 return host === '' ? 'PROXY empty.example:1' : 'PROXY host.example:2';
519 }",
520 );
521 let url = Url::parse("mailto:someone@example.net").unwrap();
522
523 let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
524 assert_eq!(
525 steps[0].endpoint().expect("a proxy step").authority(),
526 "empty.example:1",
527 "a hostless URL must reach the engine, with the host empty"
528 );
529 }
530
531 #[cfg(pac_quickjs)]
537 #[test]
538 fn the_host_override_is_what_the_script_sees() {
539 let script = PacScript::new(
540 "function FindProxyForURL(url, host) {
541 if (host === '[dead::beef]') { return 'PROXY bracketed.example:1'; }
542 if (host === 'dead::beef') { return 'PROXY unbracketed.example:2'; }
543 return 'PROXY neither.example:3';
544 }",
545 );
546 let url = Url::parse("https://[dead::beef]/foo").unwrap();
547
548 let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
549 assert_eq!(
550 steps[0].endpoint().expect("a proxy step").authority(),
551 "bracketed.example:1",
552 "`evaluate` must pass the spelling the URL itself carries"
553 );
554
555 let steps =
556 evaluate_with_host(&script, &url, "dead::beef", &PacPolicy::new()).expect("evaluated");
557 assert_eq!(
558 steps[0].endpoint().expect("a proxy step").authority(),
559 "unbracketed.example:2",
560 "the chosen host must reach the script instead of the URL's own"
561 );
562 }
563
564 #[test]
565 fn scripts_come_from_inline_modes_only() {
566 let inline = ProxyMode::pac_inline("body".to_owned());
567 assert_eq!(PacScript::from_mode(&inline), Some(PacScript::new("body")));
568 assert_eq!(PacScript::from_mode(&ProxyMode::Direct), None);
569 assert_eq!(
572 PacScript::from_mode(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
573 None
574 );
575
576 let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
577 assert_eq!(PacScript::from_mode(&ProxyMode::pac(url)), None);
578 }
579
580 #[test]
581 fn requirements_cover_every_mode() {
582 let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
583 assert_eq!(
584 requirement(&ProxyMode::pac(url.clone())),
585 PacRequirement::Fetch(&url)
586 );
587 assert_eq!(
588 requirement(&ProxyMode::pac_inline("b".to_owned())),
589 PacRequirement::Inline("b")
590 );
591 assert_eq!(
592 requirement(&ProxyMode::WpadAutoDetect),
593 PacRequirement::Discover
594 );
595 assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
596 assert_eq!(
601 requirement(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
602 PacRequirement::NotNeeded
603 );
604 }
605
606 #[test]
607 #[cfg(not(pac_quickjs))]
608 fn without_an_engine_evaluation_reports_why() {
609 let script = PacScript::new("function FindProxyForURL(u, h) { return 'DIRECT'; }");
610 let url = Url::parse("http://example.com/").unwrap();
611 let error = evaluate(&script, &url, &PacPolicy::new()).unwrap_err();
612 assert!(matches!(error, Error::PacEngineUnavailable), "{error:?}");
613 }
614}