Skip to main content

proxy_watch/pac/
mod.rs

1//! PAC evaluation (`pac` feature, off by default): script → `Vec<ProxyStep>` like
2//! [`resolve`](crate::resolve()).
3//!
4//! No in-crate fetch: pass body to [`evaluate`]. Untrusted code: [`PacPolicy`] defaults
5//! block DNS, fake local IP, 5 s budget. `pac-quickjs` to run. Bypass lists do not apply.
6
7#[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// WinHTTP path: target+feature gated so the flag stays additive/portable.
26#[cfg(all(windows, feature = "pac-windows-native"))]
27mod winhttp;
28
29#[cfg(all(target_os = "android", feature = "pac-android-native"))]
30mod android;
31
32// CFNetwork path: gated like WinHTTP; the dictionary-to-step mapping also builds under `test`.
33#[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
51/// Whether this build carries QuickJS, so that [`evaluate`] runs a script rather than
52/// answering [`Error::PacEngineUnavailable`]: `pac-quickjs` on a target other than Android
53/// and iOS. Lets a caller pick an engine before calling, without its own `cfg`.
54pub 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/// The native PAC resolver this target has: [`WinHttpPacResolver`] on Windows,
70/// `CfNetworkPacResolver` on macOS and iOS, `AndroidPacResolver` on Android, whichever
71/// the matching `pac-*-native` feature (or `pac-native`) compiled in.
72#[cfg(all(windows, feature = "pac-windows-native"))]
73pub type NativePacResolver = WinHttpPacResolver;
74/// The native PAC resolver this target has: `CfNetworkPacResolver`.
75#[cfg(pac_cfnetwork)]
76pub type NativePacResolver = CfNetworkPacResolver;
77/// The native PAC resolver this target has: `AndroidPacResolver`.
78#[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/// PAC script body (newtype so "this is JS that will run" stays visible in signatures).
87#[derive(Clone, PartialEq, Eq)]
88pub struct PacScript {
89    source: String,
90}
91
92// Same masking as [`ProxyMode::PacInline`]: length + FNV-1a, never the source.
93impl 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    /// Wrap a script body.
107    #[must_use]
108    pub fn new(source: impl Into<String>) -> Self {
109        Self {
110            source: source.into(),
111        }
112    }
113
114    /// The JavaScript source.
115    #[must_use]
116    pub fn source(&self) -> &str {
117        &self.source
118    }
119
120    /// [`ProxyMode::PacInline`]'s body, or `None` (including for [`ProxyMode::Pac`]).
121    #[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/// What [`requirement`] says a [`ProxyMode`] needs before routing.
143#[derive(Clone, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum PacRequirement<'a> {
146    /// No auto-config; [`resolve`](crate::resolve()) already answers.
147    NotNeeded,
148    /// Body already in the snapshot ([`ProxyMode::PacInline`]).
149    Inline(&'a str),
150    /// Fetch this URL and pass the body ([`ProxyMode::Pac`]).
151    Fetch(&'a Url),
152    /// WPAD on; this crate runs no DHCP 252 / DNS `wpad.` discovery (collision risk).
153    /// Windows / macOS / iOS: the native resolver built `with_wpad(true)` hands it to the OS.
154    /// Else: inline or fetched script.
155    Discover,
156}
157
158// Mirror [`ProxyMode`]'s Debug: `Inline` → len+digest, `Fetch` → redacted URL.
159impl 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/// What `mode` needs before [`evaluate`] can be called for it.
184///
185/// ```
186/// # use proxy_watch::pac::{requirement, PacRequirement};
187/// # use proxy_watch::ProxyMode;
188/// assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
189/// let inline = ProxyMode::pac_inline("…".to_owned());
190/// assert_eq!(requirement(&inline), PacRequirement::Inline("…"));
191/// ```
192#[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
202/// A replaceable engine that evaluates a PAC script for one URL.
203///
204/// `pac-quickjs` implements it in process, `pac-subprocess` in a worker process, and on
205/// macOS and iOS `pac-macos-native` / `pac-ios-native` over CFNetwork
206/// (`CfNetworkPacEvaluator`). Whether an implementation honours [`PacPolicy`] is stated on
207/// the implementation.
208pub trait PacEvaluator {
209    /// Run `FindProxyForURL(url, host)` and parse the result.
210    ///
211    /// Call [`sanitize_url`] if you bypass [`evaluate_with_host`].
212    ///
213    /// # Errors
214    ///
215    /// [`Error::PacEvaluation`], [`Error::PacTimeout`], [`Error::PacSaturated`],
216    /// [`Error::PacInvalidResult`], and [`Error::Io`] for an OS resource the evaluator
217    /// needs but cannot get: with a [`PacPolicy::timeout`] set, `QuickJsEvaluator` spawns
218    /// the thread it enforces the budget on, and that spawn can fail.
219    fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error>;
220}
221
222/// Chromium `SanitizeUrl`: strip userinfo and fragment always, and path and query only for
223/// `https`/`wss`.
224/// Not a [`PacPolicy`] knob. Custom [`PacEvaluator`] impls should still sanitize.
225///
226/// ```
227/// use proxy_watch::Url;
228/// use proxy_watch::pac::sanitize_url;
229///
230/// let url = Url::parse("http://user:secret@example.net/a/b?q=1#frag").unwrap();
231/// assert_eq!(sanitize_url(&url).as_str(), "http://example.net/a/b?q=1");
232///
233/// let url = Url::parse("https://user:secret@example.net/a/b?q=1#frag").unwrap();
234/// assert_eq!(sanitize_url(&url).as_str(), "https://example.net/");
235/// ```
236#[must_use]
237pub fn sanitize_url(url: &Url) -> Url {
238    let mut sanitized = url.clone();
239    // Both setters refuse a URL `has_host()` calls hostless. A cannot-be-a-base URL reaches
240    // that refusal with no userinfo to lose, but a non-special scheme whose host was
241    // emptied is hostless *and* still holds `user:pass@`, because `set_host(Some(""))`
242    // accepts on `socks5:` what it rejects on `http:` with `EmptyHost`. Lend such a URL a
243    // host so the setters engage, then put the empty host back. All or nothing: a
244    // half-applied round trip would move the host instead of the credentials. The authority
245    // is what separates the two refusals; `Url::password` is not, because it panics on
246    // these URLs (`trace::render::MaskedUrl` has the same note).
247    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
267/// Evaluate `script` for `url` under `policy` (`sanitize_url` first).
268///
269/// This function uses `pac-quickjs`. Enabling `pac-windows-native` adds
270/// `WinHttpPacResolver`, which is separate from this function's engine. [`PacResolver`]
271/// routes configurations between that resolver and this function.
272///
273/// A URL with no host (`data:`, `mailto:`) is still evaluated, with `host` empty:
274/// `resolve_with_pac` and the native resolvers' `resolve_config` answer Direct for those
275/// without running anything. This is the engine door, not a routing entry point.
276///
277/// # Errors
278///
279/// [`Error::PacEngineUnavailable`] with no engine, else [`PacEvaluator::evaluate`].
280pub fn evaluate(
281    script: &PacScript,
282    url: &Url,
283    policy: &PacPolicy,
284) -> Result<Vec<ProxyStep>, Error> {
285    // `host_str` keeps the brackets on an IPv6 literal, so the script sees `[::1]`. The two
286    // references disagree here: Gecko passes `nsIURI::GetAsciiHost`, whose IPv6 segment is
287    // bracketed, while Chromium passes `GURL::HostNoBrackets()`, so neither spelling can be
288    // called the right one. Following Gecko keeps the string the URL itself carries; the
289    // host functions accept both spellings (`unbracket_ipv6` in `dns_resolve` /
290    // `is_resolvable`, the colon test in `is_plain_host_name`), and `evaluate_with_host` is
291    // the way out for a caller who wants Chromium's.
292    evaluate_with_host(script, url, url.host_str().unwrap_or_default(), policy)
293}
294
295/// Like [`evaluate`], but the caller chooses `host` (`url` is still sanitized).
296///
297/// # Errors
298///
299/// Same as [`evaluate`].
300pub 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    // `pac-windows-native` exports [`WinHttpPacResolver`] for the caller to drive itself
308    // rather than entering here.
309    #[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    // Callers ask for this type to read its answer, and the arms with no field print as a
328    // bare word from a hand-written impl, so this test is the only thing comparing the word
329    // to the variant. `Discover` rendered as `NotNeeded` reads as its own opposite: "WPAD
330    // is on and the discovery cannot be run here" against "routing is already answered".
331    // Deciding whether a machine needs a PAC engine is read off this line.
332    //
333    // `PacScript` shares the digest rendering with `Inline`, so it is held here too. Its
334    // `len` label is in `debug_masking`'s registry, which asks the rendering to *contain*
335    // the word; the exact string is what also refuses `length`.
336    #[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                // No credentials, so the row pins the framing and not the masking, which
360                // `debug_masking`'s registry owns for this arm.
361                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    // Every example `net/docs/proxy.md` gives for `FindProxyForURL`'s first argument.
374    #[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    // The asymmetry is the part most likely to be "tidied up" by a later reader, so it
394    // is pinned: plain HTTP keeps its path and query on purpose.
395    #[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    // A URL with no authority has no userinfo to strip; the setters refuse, and that
426    // must not be mistaken for a failure to sanitize.
427    #[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    // ...and the refusal above is not the only one. `set_host(Some(""))` is accepted on a
437    // non-special scheme and rejected on a special one, so this is the one shape that reaches
438    // the same refusal with credentials still attached. `Url::parse` cannot build it; a caller
439    // holding a `Url` can, and every evaluator hands `as_str()` straight to the script.
440    #[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            // Nothing to strip, and the shape `Url::password` cannot be asked about.
453            ("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    // The other half of the guard on the lend-a-host branch, and the half `mailto:` above
469    // cannot show. That one is refused twice over: no authority *and* cannot-be-a-base, so
470    // `set_host` refuses too and the round trip collapses on its own. A non-special scheme
471    // with a rootless path is refused only once: `unix:/run/foo.socket` has no authority to
472    // hold userinfo, but it *can* be a base, so every setter in the chain succeeds and the
473    // URL comes back with an empty authority it never had.
474    //
475    // No other test checks `has_authority()` in the guard. Without it,
476    // `unix:/run/foo.socket` comes back as `unix:///run/foo.socket`, and nothing else in
477    // the tree objects. Every evaluator hands `as_str()` to the script, so that is a
478    // different string in `FindProxyForURL`'s first argument.
479    #[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            // The fragment still goes, so this is not "the function declined to run".
484            ("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    // `evaluate` is the engine door, not a routing entry point: unlike `resolve_with_pac`
509    // and `WinHttpPacResolver::resolve_config` it does not answer Direct for a hostless
510    // URL, it runs the script with the host empty. Adopting the routing convention here
511    // would change what a caller who reached the engine directly gets back, and no other
512    // test looks at a URL without a host.
513    #[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    // The escape hatch `evaluate`'s comment points at. `evaluate` passes `host_str`, so a
532    // script sees Gecko's bracketed IPv6 spelling; `evaluate_with_host` exists so a caller
533    // who wants Chromium's `HostNoBrackets()` can have it. Nothing inside this crate ever
534    // passes a host other than the URL's own, so without this the override could stop
535    // reaching the engine and no other test would notice.
536    #[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        // `Manual` for the same reason it appears in `requirements_cover_every_mode`: it is
570        // the fifth variant, and `Direct` alone leaves the `_` arm free to grow a case for it.
571        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        // The name says *every* mode, and `Direct` alone does not make that true: `Manual`
597        // is the other variant the `_` arm answers for, and it is the one with something to
598        // lose. A case growing in front of that arm sends a machine with static proxies off
599        // to do WPAD discovery, and with only `Direct` here nothing goes red.
600        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}