proxy-watch 0.3.1

Read and watch the operating system's proxy settings, layered with *_proxy, and the route each URL takes under them
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
//! PAC evaluation (`pac` feature, off by default): script → `Vec<ProxyStep>` like
//! [`resolve`](crate::resolve()).
//!
//! No in-crate fetch: pass body to [`evaluate`]. Untrusted code: [`PacPolicy`] defaults
//! block DNS, fake local IP, 5 s budget. `pac-quickjs` to run. Bypass lists do not apply.

#[cfg_attr(not(pac_quickjs), allow(dead_code))]
mod hostfn;
mod policy;
pub(crate) mod result;
#[cfg_attr(not(pac_quickjs), allow(dead_code))]
mod time;

#[cfg(any(pac_quickjs, feature = "pac-subprocess"))]
mod budget;

mod dispatch;

#[cfg(pac_quickjs)]
mod quickjs;

#[cfg(feature = "pac-subprocess")]
mod subprocess;

// WinHTTP path: target+feature gated so the flag stays additive/portable.
#[cfg(all(windows, feature = "pac-windows-native"))]
mod winhttp;

#[cfg(all(target_os = "android", feature = "pac-android-native"))]
mod android;

// CFNetwork path: gated like WinHTTP; the dictionary-to-step mapping also builds under `test`.
#[cfg(any(pac_cfnetwork, test))]
mod cfnetwork;

use std::fmt;

use url::Url;

use crate::error::Error;
use crate::mode::ProxyMode;
use crate::resolve::ProxyStep;

pub use self::dispatch::PacResolver;
pub use self::policy::{DEFAULT_PAC_TIMEOUT, PacPolicy};
pub use self::result::parse_find_proxy_result;

#[cfg(pac_quickjs)]
pub use self::quickjs::QuickJsEvaluator;

/// Whether this build carries QuickJS, so that [`evaluate`] runs a script rather than
/// answering [`Error::PacEngineUnavailable`]: `pac-quickjs` on a target other than Android
/// and iOS. Lets a caller pick an engine before calling, without its own `cfg`.
pub const QUICKJS_AVAILABLE: bool = cfg!(pac_quickjs);

#[cfg(feature = "pac-subprocess")]
pub use self::subprocess::SubprocessEvaluator;
#[cfg(all(feature = "pac-subprocess", pac_quickjs))]
pub use self::subprocess::serve_worker;

#[cfg(all(windows, feature = "pac-windows-native"))]
pub use self::winhttp::{
    AutoProxyReset, DEFAULT_WINHTTP_PAC_TIMEOUT, WinHttpPacResolver, WinHttpPacSource,
};

#[cfg(all(target_os = "android", feature = "pac-android-native"))]
pub use self::android::AndroidPacResolver;

/// The native PAC resolver this target has: [`WinHttpPacResolver`] on Windows,
/// `CfNetworkPacResolver` on macOS and iOS, `AndroidPacResolver` on Android, whichever
/// the matching `pac-*-native` feature (or `pac-native`) compiled in.
#[cfg(all(windows, feature = "pac-windows-native"))]
pub type NativePacResolver = WinHttpPacResolver;
/// The native PAC resolver this target has: `CfNetworkPacResolver`.
#[cfg(pac_cfnetwork)]
pub type NativePacResolver = CfNetworkPacResolver;
/// The native PAC resolver this target has: `AndroidPacResolver`.
#[cfg(all(target_os = "android", feature = "pac-android-native"))]
pub type NativePacResolver = AndroidPacResolver;

#[cfg(pac_cfnetwork)]
pub use self::cfnetwork::{
    CfNetworkPacEvaluator, CfNetworkPacResolver, DEFAULT_CFNETWORK_PAC_TIMEOUT,
};

/// PAC script body (newtype so "this is JS that will run" stays visible in signatures).
#[derive(Clone, PartialEq, Eq)]
pub struct PacScript {
    source: String,
}

// Same masking as [`ProxyMode::PacInline`]: length + FNV-1a, never the source.
impl fmt::Debug for PacScript {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PacScript")
            .field("len", &self.source.len())
            .field(
                "fnv1a",
                &format_args!("{:016x}", crate::util::fnv1a(self.source.as_bytes())),
            )
            .finish()
    }
}

impl PacScript {
    /// Wrap a script body.
    #[must_use]
    pub fn new(source: impl Into<String>) -> Self {
        Self {
            source: source.into(),
        }
    }

    /// The JavaScript source.
    #[must_use]
    pub fn source(&self) -> &str {
        &self.source
    }

    /// [`ProxyMode::PacInline`]'s body, or `None` (including for [`ProxyMode::Pac`]).
    #[must_use]
    pub fn from_mode(mode: &ProxyMode) -> Option<Self> {
        match mode {
            ProxyMode::PacInline { script, .. } => Some(Self::new(script.clone())),
            _ => None,
        }
    }
}

impl From<String> for PacScript {
    fn from(source: String) -> Self {
        Self::new(source)
    }
}

impl From<&str> for PacScript {
    fn from(source: &str) -> Self {
        Self::new(source)
    }
}

/// What [`requirement`] says a [`ProxyMode`] needs before routing.
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PacRequirement<'a> {
    /// No auto-config; [`resolve`](crate::resolve()) already answers.
    NotNeeded,
    /// Body already in the snapshot ([`ProxyMode::PacInline`]).
    Inline(&'a str),
    /// Fetch this URL and pass the body ([`ProxyMode::Pac`]).
    Fetch(&'a Url),
    /// WPAD on; this crate runs no DHCP 252 / DNS `wpad.` discovery (collision risk).
    /// Windows / macOS / iOS: the native resolver built `with_wpad(true)` hands it to the OS.
    /// Else: inline or fetched script.
    Discover,
}

// Mirror [`ProxyMode`]'s Debug: `Inline` → len+digest, `Fetch` → redacted URL.
impl fmt::Debug for PacRequirement<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NotNeeded => f.write_str("NotNeeded"),
            Self::Discover => f.write_str("Discover"),
            Self::Inline(script) => f
                .debug_struct("Inline")
                .field("len", &script.len())
                .field(
                    "fnv1a",
                    &format_args!("{:016x}", crate::util::fnv1a(script.as_bytes())),
                )
                .finish(),
            Self::Fetch(url) => f
                .debug_tuple("Fetch")
                .field(&format_args!(
                    "{}",
                    crate::util::redact_userinfo(url.as_str())
                ))
                .finish(),
        }
    }
}

/// What `mode` needs before [`evaluate`] can be called for it.
///
/// ```
/// # use proxy_watch::pac::{requirement, PacRequirement};
/// # use proxy_watch::ProxyMode;
/// assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
/// let inline = ProxyMode::pac_inline("…".to_owned());
/// assert_eq!(requirement(&inline), PacRequirement::Inline("…"));
/// ```
#[must_use]
pub fn requirement(mode: &ProxyMode) -> PacRequirement<'_> {
    match mode {
        ProxyMode::Pac { url, .. } => PacRequirement::Fetch(url),
        ProxyMode::PacInline { script, .. } => PacRequirement::Inline(script),
        ProxyMode::WpadAutoDetect => PacRequirement::Discover,
        _ => PacRequirement::NotNeeded,
    }
}

/// A replaceable engine that evaluates a PAC script for one URL.
///
/// `pac-quickjs` implements it in process, `pac-subprocess` in a worker process, and on
/// macOS and iOS `pac-macos-native` / `pac-ios-native` over CFNetwork
/// (`CfNetworkPacEvaluator`). Whether an implementation honours [`PacPolicy`] is stated on
/// the implementation.
pub trait PacEvaluator {
    /// Run `FindProxyForURL(url, host)` and parse the result.
    ///
    /// Call [`sanitize_url`] if you bypass [`evaluate_with_host`].
    ///
    /// # Errors
    ///
    /// [`Error::PacEvaluation`], [`Error::PacTimeout`], [`Error::PacSaturated`],
    /// [`Error::PacInvalidResult`], and [`Error::Io`] for an OS resource the evaluator
    /// needs but cannot get: with a [`PacPolicy::timeout`] set, `QuickJsEvaluator` spawns
    /// the thread it enforces the budget on, and that spawn can fail.
    fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error>;
}

/// Chromium `SanitizeUrl`: strip userinfo and fragment always, and path and query only for
/// `https`/`wss`.
/// Not a [`PacPolicy`] knob. Custom [`PacEvaluator`] impls should still sanitize.
///
/// ```
/// use proxy_watch::Url;
/// use proxy_watch::pac::sanitize_url;
///
/// let url = Url::parse("http://user:secret@example.net/a/b?q=1#frag").unwrap();
/// assert_eq!(sanitize_url(&url).as_str(), "http://example.net/a/b?q=1");
///
/// let url = Url::parse("https://user:secret@example.net/a/b?q=1#frag").unwrap();
/// assert_eq!(sanitize_url(&url).as_str(), "https://example.net/");
/// ```
#[must_use]
pub fn sanitize_url(url: &Url) -> Url {
    let mut sanitized = url.clone();
    // Both setters refuse a URL `has_host()` calls hostless. A cannot-be-a-base URL reaches
    // that refusal with no userinfo to lose, but a non-special scheme whose host was
    // emptied is hostless *and* still holds `user:pass@`, because `set_host(Some(""))`
    // accepts on `socks5:` what it rejects on `http:` with `EmptyHost`. Lend such a URL a
    // host so the setters engage, then put the empty host back. All or nothing: a
    // half-applied round trip would move the host instead of the credentials. The authority
    // is what separates the two refusals; `Url::password` is not, because it panics on
    // these URLs (`trace::render::MaskedUrl` has the same note).
    let refused = sanitized.set_username("").is_err();
    let _ = sanitized.set_password(None);
    if refused && sanitized.has_authority() {
        let mut lent = sanitized.clone();
        if lent.set_host(Some("x")).is_ok()
            && lent.set_username("").is_ok()
            && lent.set_password(None).is_ok()
            && lent.set_host(Some("")).is_ok()
        {
            sanitized = lent;
        }
    }
    sanitized.set_fragment(None);
    if matches!(sanitized.scheme(), "https" | "wss") {
        sanitized.set_path("/");
        sanitized.set_query(None);
    }
    sanitized
}

/// Evaluate `script` for `url` under `policy` (`sanitize_url` first).
///
/// This function uses `pac-quickjs`. Enabling `pac-windows-native` adds
/// `WinHttpPacResolver`, which is separate from this function's engine. [`PacResolver`]
/// routes configurations between that resolver and this function.
///
/// A URL with no host (`data:`, `mailto:`) is still evaluated, with `host` empty:
/// `resolve_with_pac` and the native resolvers' `resolve_config` answer Direct for those
/// without running anything. This is the engine door, not a routing entry point.
///
/// # Errors
///
/// [`Error::PacEngineUnavailable`] with no engine, else [`PacEvaluator::evaluate`].
pub fn evaluate(
    script: &PacScript,
    url: &Url,
    policy: &PacPolicy,
) -> Result<Vec<ProxyStep>, Error> {
    // `host_str` keeps the brackets on an IPv6 literal, so the script sees `[::1]`. The two
    // references disagree here: Gecko passes `nsIURI::GetAsciiHost`, whose IPv6 segment is
    // bracketed, while Chromium passes `GURL::HostNoBrackets()`, so neither spelling can be
    // called the right one. Following Gecko keeps the string the URL itself carries; the
    // host functions accept both spellings (`unbracket_ipv6` in `dns_resolve` /
    // `is_resolvable`, the colon test in `is_plain_host_name`), and `evaluate_with_host` is
    // the way out for a caller who wants Chromium's.
    evaluate_with_host(script, url, url.host_str().unwrap_or_default(), policy)
}

/// Like [`evaluate`], but the caller chooses `host` (`url` is still sanitized).
///
/// # Errors
///
/// Same as [`evaluate`].
pub fn evaluate_with_host(
    script: &PacScript,
    url: &Url,
    host: &str,
    policy: &PacPolicy,
) -> Result<Vec<ProxyStep>, Error> {
    let url = &sanitize_url(url);
    // `pac-windows-native` exports [`WinHttpPacResolver`] for the caller to drive itself
    // rather than entering here.
    #[cfg(pac_quickjs)]
    {
        QuickJsEvaluator::new(*policy).evaluate(script, url, host)
    }
    #[cfg(not(pac_quickjs))]
    {
        let _ = (script, url, host, policy);
        Err(Error::PacEngineUnavailable)
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;
    use crate::BypassRules;

    // Callers ask for this type to read its answer, and the arms with no field print as a
    // bare word from a hand-written impl, so this test is the only thing comparing the word
    // to the variant. `Discover` rendered as `NotNeeded` reads as its own opposite: "WPAD
    // is on and the discovery cannot be run here" against "routing is already answered".
    // Deciding whether a machine needs a PAC engine is read off this line.
    //
    // `PacScript` shares the digest rendering with `Inline`, so it is held here too. Its
    // `len` label is in `debug_masking`'s registry, which asks the rendering to *contain*
    // the word; the exact string is what also refuses `length`.
    #[test]
    fn every_requirement_debug_names_itself_and_keeps_its_fields() {
        const SCRIPT: &str = "function FindProxyForURL(){}";
        let url = Url::parse("https://wpad.corp/proxy.pac").unwrap();
        let digest = format!(
            "len: {}, fnv1a: {:016x}",
            SCRIPT.len(),
            crate::util::fnv1a(SCRIPT.as_bytes())
        );
        for (rendered, expected) in [
            (
                format!("{:?}", PacRequirement::NotNeeded),
                "NotNeeded".to_owned(),
            ),
            (
                format!("{:?}", PacRequirement::Discover),
                "Discover".to_owned(),
            ),
            (
                format!("{:?}", PacRequirement::Inline(SCRIPT)),
                format!("Inline {{ {digest} }}"),
            ),
            (
                // No credentials, so the row pins the framing and not the masking, which
                // `debug_masking`'s registry owns for this arm.
                format!("{:?}", PacRequirement::Fetch(&url)),
                "Fetch(https://wpad.corp/proxy.pac)".to_owned(),
            ),
            (
                format!("{:?}", PacScript::new(SCRIPT)),
                format!("PacScript {{ {digest} }}"),
            ),
        ] {
            assert_eq!(rendered, expected);
        }
    }

    // Every example `net/docs/proxy.md` gives for `FindProxyForURL`'s first argument.
    #[test]
    fn the_documented_chromium_examples_come_out_the_same_way() {
        for (actual, expected) in [
            ("https://www.google.com/Foo", "https://www.google.com/"),
            ("https://[dead::beef]/foo?bar", "https://[dead::beef]/"),
            (
                "https://www.example.com:8080#search",
                "https://www.example.com:8080/",
            ),
            (
                "https://username:password@www.example.com",
                "https://www.example.com/",
            ),
        ] {
            let url = Url::parse(actual).expect("valid url");
            assert_eq!(sanitize_url(&url).as_str(), expected, "for {actual}");
        }
    }

    // The asymmetry is the part most likely to be "tidied up" by a later reader, so it
    // is pinned: plain HTTP keeps its path and query on purpose.
    #[test]
    fn only_cryptographic_schemes_lose_their_path_and_query() {
        let http = Url::parse("http://user:pw@example.net/deep/path?q=1#frag").unwrap();
        assert_eq!(
            sanitize_url(&http).as_str(),
            "http://example.net/deep/path?q=1"
        );

        let wss = Url::parse("wss://user:pw@example.net/socket?q=1#frag").unwrap();
        assert_eq!(sanitize_url(&wss).as_str(), "wss://example.net/");

        let ws = Url::parse("ws://user:pw@example.net/socket?q=1#frag").unwrap();
        assert_eq!(sanitize_url(&ws).as_str(), "ws://example.net/socket?q=1");
    }

    #[test]
    fn sanitizing_is_idempotent_and_leaves_the_host_alone() {
        for raw in [
            "https://user:pw@example.net:8443/a?b#c",
            "http://example.net/",
            "ftp://user@files.corp/pub",
        ] {
            let url = Url::parse(raw).unwrap();
            let once = sanitize_url(&url);
            assert_eq!(sanitize_url(&once), once, "not idempotent for {raw}");
            assert_eq!(once.host_str(), url.host_str(), "host changed for {raw}");
            assert_eq!(once.port(), url.port(), "port changed for {raw}");
        }
    }

    // A URL with no authority has no userinfo to strip; the setters refuse, and that
    // must not be mistaken for a failure to sanitize.
    #[test]
    fn cannot_be_a_base_urls_survive_untouched_except_for_the_fragment() {
        let url = Url::parse("mailto:someone@example.net?subject=hi#frag").unwrap();
        assert_eq!(
            sanitize_url(&url).as_str(),
            "mailto:someone@example.net?subject=hi"
        );
    }

    // ...and the refusal above is not the only one. `set_host(Some(""))` is accepted on a
    // non-special scheme and rejected on a special one, so this is the one shape that reaches
    // the same refusal with credentials still attached. `Url::parse` cannot build it; a caller
    // holding a `Url` can, and every evaluator hands `as_str()` straight to the script.
    #[test]
    fn an_emptied_host_does_not_carry_the_credentials_through() {
        for (input, expected) in [
            (
                "socks5://user:secret@example.net/p?q=1#frag",
                "socks5:///p?q=1",
            ),
            (
                "socks5://user:secret@example.net:8080/p",
                "socks5://:8080/p",
            ),
            ("socks5://:secret@example.net/p", "socks5:///p"),
            // Nothing to strip, and the shape `Url::password` cannot be asked about.
            ("socks5://example.net:8080/p", "socks5://:8080/p"),
        ] {
            let mut url = Url::parse(input).unwrap();
            url.set_host(Some(""))
                .expect("a non-special scheme accepts an empty host");
            let sanitized = sanitize_url(&url);
            assert_eq!(sanitized.as_str(), expected, "for {input}");
            assert_eq!(
                sanitize_url(&sanitized),
                sanitized,
                "not idempotent for {input}"
            );
        }
    }

    // The other half of the guard on the lend-a-host branch, and the half `mailto:` above
    // cannot show. That one is refused twice over: no authority *and* cannot-be-a-base, so
    // `set_host` refuses too and the round trip collapses on its own. A non-special scheme
    // with a rootless path is refused only once: `unix:/run/foo.socket` has no authority to
    // hold userinfo, but it *can* be a base, so every setter in the chain succeeds and the
    // URL comes back with an empty authority it never had.
    //
    // No other test checks `has_authority()` in the guard. Without it,
    // `unix:/run/foo.socket` comes back as `unix:///run/foo.socket`, and nothing else in
    // the tree objects. Every evaluator hands `as_str()` to the script, so that is a
    // different string in `FindProxyForURL`'s first argument.
    #[test]
    fn a_path_only_url_is_not_lent_an_authority_it_never_had() {
        for (input, expected) in [
            ("unix:/run/foo.socket", "unix:/run/foo.socket"),
            // The fragment still goes, so this is not "the function declined to run".
            ("git:/a/b?q=1#frag", "git:/a/b?q=1"),
        ] {
            let url = Url::parse(input).unwrap();
            assert!(!url.has_authority(), "premise for {input}: {url:?}");
            assert!(!url.cannot_be_a_base(), "premise for {input}: {url:?}");
            assert_eq!(sanitize_url(&url).as_str(), expected, "for {input}");
        }
    }

    #[cfg(pac_quickjs)]
    #[test]
    fn the_script_is_handed_the_sanitized_url_end_to_end() {
        let script = PacScript::new(
            "function FindProxyForURL(url, host) { return 'PROXY ' + url.replace(/[^a-zA-Z0-9.]/g, '-') + ':8080'; }",
        );
        let url = Url::parse("https://user:secret@example.net/private/doc?token=abc#x").unwrap();
        let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
        let seen = steps[0].endpoint().expect("a proxy step").authority();
        assert_eq!(
            seen, "https---example.net-:8080",
            "the script must not see the credentials, path, query or fragment"
        );
    }

    // `evaluate` is the engine door, not a routing entry point: unlike `resolve_with_pac`
    // and `WinHttpPacResolver::resolve_config` it does not answer Direct for a hostless
    // URL, it runs the script with the host empty. Adopting the routing convention here
    // would change what a caller who reached the engine directly gets back, and no other
    // test looks at a URL without a host.
    #[cfg(pac_quickjs)]
    #[test]
    fn a_hostless_url_reaches_the_script_with_an_empty_host() {
        let script = PacScript::new(
            "function FindProxyForURL(url, host) {
                 return host === '' ? 'PROXY empty.example:1' : 'PROXY host.example:2';
             }",
        );
        let url = Url::parse("mailto:someone@example.net").unwrap();

        let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
        assert_eq!(
            steps[0].endpoint().expect("a proxy step").authority(),
            "empty.example:1",
            "a hostless URL must reach the engine, with the host empty"
        );
    }

    // The escape hatch `evaluate`'s comment points at. `evaluate` passes `host_str`, so a
    // script sees Gecko's bracketed IPv6 spelling; `evaluate_with_host` exists so a caller
    // who wants Chromium's `HostNoBrackets()` can have it. Nothing inside this crate ever
    // passes a host other than the URL's own, so without this the override could stop
    // reaching the engine and no other test would notice.
    #[cfg(pac_quickjs)]
    #[test]
    fn the_host_override_is_what_the_script_sees() {
        let script = PacScript::new(
            "function FindProxyForURL(url, host) {
                 if (host === '[dead::beef]') { return 'PROXY bracketed.example:1'; }
                 if (host === 'dead::beef') { return 'PROXY unbracketed.example:2'; }
                 return 'PROXY neither.example:3';
             }",
        );
        let url = Url::parse("https://[dead::beef]/foo").unwrap();

        let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
        assert_eq!(
            steps[0].endpoint().expect("a proxy step").authority(),
            "bracketed.example:1",
            "`evaluate` must pass the spelling the URL itself carries"
        );

        let steps =
            evaluate_with_host(&script, &url, "dead::beef", &PacPolicy::new()).expect("evaluated");
        assert_eq!(
            steps[0].endpoint().expect("a proxy step").authority(),
            "unbracketed.example:2",
            "the chosen host must reach the script instead of the URL's own"
        );
    }

    #[test]
    fn scripts_come_from_inline_modes_only() {
        let inline = ProxyMode::pac_inline("body".to_owned());
        assert_eq!(PacScript::from_mode(&inline), Some(PacScript::new("body")));
        assert_eq!(PacScript::from_mode(&ProxyMode::Direct), None);
        // `Manual` for the same reason it appears in `requirements_cover_every_mode`: it is
        // the fifth variant, and `Direct` alone leaves the `_` arm free to grow a case for it.
        assert_eq!(
            PacScript::from_mode(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
            None
        );

        let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
        assert_eq!(PacScript::from_mode(&ProxyMode::pac(url)), None);
    }

    #[test]
    fn requirements_cover_every_mode() {
        let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
        assert_eq!(
            requirement(&ProxyMode::pac(url.clone())),
            PacRequirement::Fetch(&url)
        );
        assert_eq!(
            requirement(&ProxyMode::pac_inline("b".to_owned())),
            PacRequirement::Inline("b")
        );
        assert_eq!(
            requirement(&ProxyMode::WpadAutoDetect),
            PacRequirement::Discover
        );
        assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
        // The name says *every* mode, and `Direct` alone does not make that true: `Manual`
        // is the other variant the `_` arm answers for, and it is the one with something to
        // lose. A case growing in front of that arm sends a machine with static proxies off
        // to do WPAD discovery, and with only `Direct` here nothing goes red.
        assert_eq!(
            requirement(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
            PacRequirement::NotNeeded
        );
    }

    #[test]
    #[cfg(not(pac_quickjs))]
    fn without_an_engine_evaluation_reports_why() {
        let script = PacScript::new("function FindProxyForURL(u, h) { return 'DIRECT'; }");
        let url = Url::parse("http://example.com/").unwrap();
        let error = evaluate(&script, &url, &PacPolicy::new()).unwrap_err();
        assert!(matches!(error, Error::PacEngineUnavailable), "{error:?}");
    }
}