1#[cfg_attr(not(feature = "pac-boa"), allow(dead_code))]
8mod hostfn;
9mod policy;
10mod result;
11#[cfg_attr(not(feature = "pac-boa"), allow(dead_code))]
12mod time;
13
14#[cfg(feature = "pac-boa")]
15mod boa;
16
17#[cfg(all(windows, feature = "pac-windows-native"))]
19mod winhttp;
20
21use std::fmt;
22
23use url::Url;
24
25use crate::error::Error;
26use crate::mode::ProxyMode;
27use crate::resolve::ProxyStep;
28
29pub use self::policy::{
30 DEFAULT_PAC_LOOP_LIMIT, DEFAULT_PAC_RECURSION_LIMIT, DEFAULT_PAC_STACK_SIZE_LIMIT,
31 DEFAULT_PAC_TIMEOUT, PacPolicy,
32};
33pub use self::result::parse_find_proxy_result;
34
35#[cfg(feature = "pac-boa")]
36pub use self::boa::BoaEvaluator;
37
38#[cfg(all(windows, feature = "pac-windows-native"))]
39pub use self::winhttp::{DEFAULT_WINHTTP_PAC_TIMEOUT, WinHttpPacResolver, WinHttpPacSource};
40
41#[derive(Clone, PartialEq, Eq)]
43pub struct PacScript {
44 source: String,
45}
46
47impl fmt::Debug for PacScript {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.debug_struct("PacScript")
51 .field("len", &self.source.len())
52 .field(
53 "fnv1a",
54 &format_args!("{:016x}", crate::util::fnv1a(self.source.as_bytes())),
55 )
56 .finish()
57 }
58}
59
60impl PacScript {
61 #[must_use]
63 pub fn new(source: impl Into<String>) -> Self {
64 Self {
65 source: source.into(),
66 }
67 }
68
69 #[must_use]
71 pub fn source(&self) -> &str {
72 &self.source
73 }
74
75 #[must_use]
77 pub fn from_mode(mode: &ProxyMode) -> Option<Self> {
78 match mode {
79 ProxyMode::PacInline { script, .. } => Some(Self::new(script.clone())),
80 _ => None,
81 }
82 }
83}
84
85impl From<String> for PacScript {
86 fn from(source: String) -> Self {
87 Self::new(source)
88 }
89}
90
91impl From<&str> for PacScript {
92 fn from(source: &str) -> Self {
93 Self::new(source)
94 }
95}
96
97#[derive(Clone, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum PacRequirement<'a> {
101 NotNeeded,
103 Inline(&'a str),
105 Fetch(&'a Url),
107 Discover,
110}
111
112impl fmt::Debug for PacRequirement<'_> {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 match self {
116 Self::NotNeeded => f.write_str("NotNeeded"),
117 Self::Discover => f.write_str("Discover"),
118 Self::Inline(script) => f
119 .debug_struct("Inline")
120 .field("len", &script.len())
121 .field(
122 "fnv1a",
123 &format_args!("{:016x}", crate::util::fnv1a(script.as_bytes())),
124 )
125 .finish(),
126 Self::Fetch(url) => f
127 .debug_tuple("Fetch")
128 .field(&format_args!(
129 "{}",
130 crate::util::redact_userinfo(url.as_str())
131 ))
132 .finish(),
133 }
134 }
135}
136
137#[must_use]
147pub fn requirement(mode: &ProxyMode) -> PacRequirement<'_> {
148 match mode {
149 ProxyMode::Pac { url, .. } => PacRequirement::Fetch(url),
150 ProxyMode::PacInline { script, .. } => PacRequirement::Inline(script),
151 ProxyMode::WpadAutoDetect => PacRequirement::Discover,
152 _ => PacRequirement::NotNeeded,
153 }
154}
155
156pub trait PacEvaluator {
159 fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error>;
170}
171
172#[must_use]
186pub fn sanitize_url(url: &Url) -> Url {
187 let mut sanitized = url.clone();
188 let refused = sanitized.set_username("").is_err();
197 let _ = sanitized.set_password(None);
198 if refused && sanitized.has_authority() {
199 let mut lent = sanitized.clone();
200 if lent.set_host(Some("x")).is_ok()
201 && lent.set_username("").is_ok()
202 && lent.set_password(None).is_ok()
203 && lent.set_host(Some("")).is_ok()
204 {
205 sanitized = lent;
206 }
207 }
208 sanitized.set_fragment(None);
209 if matches!(sanitized.scheme(), "https" | "wss") {
210 sanitized.set_path("/");
211 sanitized.set_query(None);
212 }
213 sanitized
214}
215
216pub fn evaluate(
230 script: &PacScript,
231 url: &Url,
232 policy: &PacPolicy,
233) -> Result<Vec<ProxyStep>, Error> {
234 evaluate_with_host(script, url, url.host_str().unwrap_or_default(), policy)
242}
243
244pub fn evaluate_with_host(
250 script: &PacScript,
251 url: &Url,
252 host: &str,
253 policy: &PacPolicy,
254) -> Result<Vec<ProxyStep>, Error> {
255 let url = &sanitize_url(url);
256 #[cfg(feature = "pac-boa")]
260 {
261 BoaEvaluator::new(*policy).evaluate(script, url, host)
262 }
263 #[cfg(not(feature = "pac-boa"))]
264 {
265 let _ = (script, url, host, policy);
266 Err(Error::PacEngineUnavailable)
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use std::collections::HashMap;
273
274 use super::*;
275 use crate::BypassRules;
276
277 #[test]
288 fn every_requirement_debug_names_itself_and_keeps_its_fields() {
289 const SCRIPT: &str = "function FindProxyForURL(){}";
290 let url = Url::parse("https://wpad.corp/proxy.pac").unwrap();
291 let digest = format!(
292 "len: {}, fnv1a: {:016x}",
293 SCRIPT.len(),
294 crate::util::fnv1a(SCRIPT.as_bytes())
295 );
296 for (rendered, expected) in [
297 (
298 format!("{:?}", PacRequirement::NotNeeded),
299 "NotNeeded".to_owned(),
300 ),
301 (
302 format!("{:?}", PacRequirement::Discover),
303 "Discover".to_owned(),
304 ),
305 (
306 format!("{:?}", PacRequirement::Inline(SCRIPT)),
307 format!("Inline {{ {digest} }}"),
308 ),
309 (
310 format!("{:?}", PacRequirement::Fetch(&url)),
313 "Fetch(https://wpad.corp/proxy.pac)".to_owned(),
314 ),
315 (
316 format!("{:?}", PacScript::new(SCRIPT)),
317 format!("PacScript {{ {digest} }}"),
318 ),
319 ] {
320 assert_eq!(rendered, expected);
321 }
322 }
323
324 #[test]
326 fn the_documented_chromium_examples_come_out_the_same_way() {
327 for (actual, expected) in [
328 ("https://www.google.com/Foo", "https://www.google.com/"),
329 ("https://[dead::beef]/foo?bar", "https://[dead::beef]/"),
330 (
331 "https://www.example.com:8080#search",
332 "https://www.example.com:8080/",
333 ),
334 (
335 "https://username:password@www.example.com",
336 "https://www.example.com/",
337 ),
338 ] {
339 let url = Url::parse(actual).expect("valid url");
340 assert_eq!(sanitize_url(&url).as_str(), expected, "for {actual}");
341 }
342 }
343
344 #[test]
347 fn only_cryptographic_schemes_lose_their_path_and_query() {
348 let http = Url::parse("http://user:pw@example.net/deep/path?q=1#frag").unwrap();
349 assert_eq!(
350 sanitize_url(&http).as_str(),
351 "http://example.net/deep/path?q=1"
352 );
353
354 let wss = Url::parse("wss://user:pw@example.net/socket?q=1#frag").unwrap();
355 assert_eq!(sanitize_url(&wss).as_str(), "wss://example.net/");
356
357 let ws = Url::parse("ws://user:pw@example.net/socket?q=1#frag").unwrap();
358 assert_eq!(sanitize_url(&ws).as_str(), "ws://example.net/socket?q=1");
359 }
360
361 #[test]
362 fn sanitizing_is_idempotent_and_leaves_the_host_alone() {
363 for raw in [
364 "https://user:pw@example.net:8443/a?b#c",
365 "http://example.net/",
366 "ftp://user@files.corp/pub",
367 ] {
368 let url = Url::parse(raw).unwrap();
369 let once = sanitize_url(&url);
370 assert_eq!(sanitize_url(&once), once, "not idempotent for {raw}");
371 assert_eq!(once.host_str(), url.host_str(), "host changed for {raw}");
372 assert_eq!(once.port(), url.port(), "port changed for {raw}");
373 }
374 }
375
376 #[test]
379 fn cannot_be_a_base_urls_survive_untouched_except_for_the_fragment() {
380 let url = Url::parse("mailto:someone@example.net?subject=hi#frag").unwrap();
381 assert_eq!(
382 sanitize_url(&url).as_str(),
383 "mailto:someone@example.net?subject=hi"
384 );
385 }
386
387 #[test]
392 fn an_emptied_host_does_not_carry_the_credentials_through() {
393 for (input, expected) in [
394 (
395 "socks5://user:secret@example.net/p?q=1#frag",
396 "socks5:///p?q=1",
397 ),
398 (
399 "socks5://user:secret@example.net:8080/p",
400 "socks5://:8080/p",
401 ),
402 ("socks5://:secret@example.net/p", "socks5:///p"),
403 ("socks5://example.net:8080/p", "socks5://:8080/p"),
405 ] {
406 let mut url = Url::parse(input).unwrap();
407 url.set_host(Some(""))
408 .expect("a non-special scheme accepts an empty host");
409 let sanitized = sanitize_url(&url);
410 assert_eq!(sanitized.as_str(), expected, "for {input}");
411 assert_eq!(
412 sanitize_url(&sanitized),
413 sanitized,
414 "not idempotent for {input}"
415 );
416 }
417 }
418
419 #[test]
431 fn a_path_only_url_is_not_lent_an_authority_it_never_had() {
432 for (input, expected) in [
433 ("unix:/run/foo.socket", "unix:/run/foo.socket"),
434 ("git:/a/b?q=1#frag", "git:/a/b?q=1"),
436 ] {
437 let url = Url::parse(input).unwrap();
438 assert!(!url.has_authority(), "premise for {input}: {url:?}");
439 assert!(!url.cannot_be_a_base(), "premise for {input}: {url:?}");
440 assert_eq!(sanitize_url(&url).as_str(), expected, "for {input}");
441 }
442 }
443
444 #[cfg(feature = "pac-boa")]
445 #[test]
446 fn the_script_is_handed_the_sanitized_url_end_to_end() {
447 let script = PacScript::new(
448 "function FindProxyForURL(url, host) { return 'PROXY ' + url.replace(/[^a-zA-Z0-9.]/g, '-') + ':8080'; }",
449 );
450 let url = Url::parse("https://user:secret@example.net/private/doc?token=abc#x").unwrap();
451 let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
452 let seen = steps[0].endpoint().expect("a proxy step").authority();
453 assert_eq!(
454 seen, "https---example.net-:8080",
455 "the script must not see the credentials, path, query or fragment"
456 );
457 }
458
459 #[cfg(feature = "pac-boa")]
465 #[test]
466 fn a_hostless_url_reaches_the_script_with_an_empty_host() {
467 let script = PacScript::new(
468 "function FindProxyForURL(url, host) {
469 return host === '' ? 'PROXY empty.example:1' : 'PROXY host.example:2';
470 }",
471 );
472 let url = Url::parse("mailto:someone@example.net").unwrap();
473
474 let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
475 assert_eq!(
476 steps[0].endpoint().expect("a proxy step").authority(),
477 "empty.example:1",
478 "a hostless URL must reach the engine, with the host empty"
479 );
480 }
481
482 #[cfg(feature = "pac-boa")]
488 #[test]
489 fn the_host_override_is_what_the_script_sees() {
490 let script = PacScript::new(
491 "function FindProxyForURL(url, host) {
492 if (host === '[dead::beef]') { return 'PROXY bracketed.example:1'; }
493 if (host === 'dead::beef') { return 'PROXY unbracketed.example:2'; }
494 return 'PROXY neither.example:3';
495 }",
496 );
497 let url = Url::parse("https://[dead::beef]/foo").unwrap();
498
499 let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
500 assert_eq!(
501 steps[0].endpoint().expect("a proxy step").authority(),
502 "bracketed.example:1",
503 "`evaluate` must pass the spelling the URL itself carries"
504 );
505
506 let steps =
507 evaluate_with_host(&script, &url, "dead::beef", &PacPolicy::new()).expect("evaluated");
508 assert_eq!(
509 steps[0].endpoint().expect("a proxy step").authority(),
510 "unbracketed.example:2",
511 "the chosen host must reach the script instead of the URL's own"
512 );
513 }
514
515 #[test]
516 fn scripts_come_from_inline_modes_only() {
517 let inline = ProxyMode::pac_inline("body".to_owned());
518 assert_eq!(PacScript::from_mode(&inline), Some(PacScript::new("body")));
519 assert_eq!(PacScript::from_mode(&ProxyMode::Direct), None);
520 assert_eq!(
523 PacScript::from_mode(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
524 None
525 );
526
527 let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
528 assert_eq!(PacScript::from_mode(&ProxyMode::pac(url)), None);
529 }
530
531 #[test]
532 fn requirements_cover_every_mode() {
533 let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
534 assert_eq!(
535 requirement(&ProxyMode::pac(url.clone())),
536 PacRequirement::Fetch(&url)
537 );
538 assert_eq!(
539 requirement(&ProxyMode::pac_inline("b".to_owned())),
540 PacRequirement::Inline("b")
541 );
542 assert_eq!(
543 requirement(&ProxyMode::WpadAutoDetect),
544 PacRequirement::Discover
545 );
546 assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
547 assert_eq!(
552 requirement(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
553 PacRequirement::NotNeeded
554 );
555 }
556
557 #[test]
558 #[cfg(not(feature = "pac-boa"))]
559 fn without_an_engine_evaluation_reports_why() {
560 let script = PacScript::new("function FindProxyForURL(u, h) { return 'DIRECT'; }");
561 let url = Url::parse("http://example.com/").unwrap();
562 let error = evaluate(&script, &url, &PacPolicy::new()).unwrap_err();
563 assert!(matches!(error, Error::PacEngineUnavailable), "{error:?}");
564 }
565}