Skip to main content

proxy_watch/
mode.rs

1//! The unified proxy mode.
2
3use std::collections::HashMap;
4use std::fmt;
5
6use url::Url;
7
8use crate::bypass::BypassRules;
9use crate::diagnostic::RejectedValue;
10use crate::endpoint::{ProxyEndpoint, ProxyEntry, Scheme};
11
12/// What a single configuration source says about proxying.
13///
14/// Hand-written [`Debug`], because [`ProxyConfig`](crate::ProxyConfig)'s is derived and would
15/// otherwise print whatever this one does. What each variant withholds is not the same thing:
16/// a [`Pac`](ProxyMode::Pac) URL is printed, minus its userinfo — the location is what makes a
17/// report actionable, and only the credentials are the secret — while a
18/// [`PacInline`](ProxyMode::PacInline) body is withheld whole and stands in as length plus
19/// hash, because a script is not a locator and any part of it may be one.
20/// [`Manual`](ProxyMode::Manual) delegates — [`ProxyEntry`] masks auth.
21#[derive(Clone, Default, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum ProxyMode {
24    /// No proxy at all (Windows `ProxyEnable=0`, GNOME `mode=none`, macOS all
25    /// `*Enable` keys zero). This is the [`Default`].
26    #[default]
27    Direct,
28    /// Static per-scheme proxies plus bypass rules.
29    ///
30    /// Sealed, like the other variants that carry fields: build it with
31    /// [`ProxyMode::manual`], which is what keeps the [`rejected`](ProxyMode::Manual) list
32    /// and the `per_scheme` entries mirroring it in step — the constructor reads the list
33    /// back out of the map, so the two cannot be handed in disagreeing. The enum's own
34    /// `#[non_exhaustive]` does not seal a variant — it only forces a `_` arm in a match —
35    /// so without this a caller could assemble a `Manual` whose two halves disagree, and
36    /// `resolve` answers from the mirror. (Not linked: `resolve` is behind its own feature,
37    /// and this variant is not.)
38    #[non_exhaustive]
39    Manual {
40        /// Per-scheme proxy entries.
41        per_scheme: HashMap<Scheme, ProxyEntry>,
42        /// Bypass rules.
43        bypass: BypassRules,
44        /// Fail-open drops (redacted); opposite of [`BypassRules::rejected`].
45        rejected: Vec<RejectedValue>,
46    },
47    /// PAC script at a URL (fetch/evaluate via `pac` / `resolve_with_pac`).
48    ///
49    /// Sealed; build it with [`ProxyMode::pac`].
50    #[non_exhaustive]
51    Pac {
52        /// Script URL.
53        url: Url,
54        /// Fail-open drops (redacted), as in [`Manual`](ProxyMode::Manual) — a source can
55        /// reach a PAC answer having already lost an unrelated key on the way to it. There
56        /// is no `per_scheme` to mirror them into here, so they are reachable only through
57        /// [`ProxyMode::rejected`].
58        rejected: Vec<RejectedValue>,
59    },
60    /// Inline PAC script body.
61    ///
62    /// Sealed; build it with [`ProxyMode::pac_inline`].
63    #[non_exhaustive]
64    PacInline {
65        /// JavaScript source.
66        script: String,
67        /// Fail-open drops (redacted); see [`Pac`](ProxyMode::Pac)'s field of the same name.
68        rejected: Vec<RejectedValue>,
69    },
70    /// WPAD enabled (no DHCP/DNS probe in this crate).
71    ///
72    /// Not sealed, and neither is [`Direct`](ProxyMode::Direct): a sealed unit variant
73    /// cannot be *named* by another crate, not merely constructed by one, so sealing these
74    /// two would cost every caller `mode == ProxyMode::Direct`. `Direct` could not be
75    /// sealed in any case — it is this enum's [`Default`], and a default variant must be
76    /// exhaustive. Neither carries fields, and neither ever will: a backend that dropped a
77    /// setting answers [`Manual`](ProxyMode::Manual) so the record has somewhere to go
78    /// (`proxy_dict.rs`' "reject-only stays `Manual`"), which is why the pair that cannot
79    /// be sealed is also the pair with nothing to add.
80    WpadAutoDetect,
81}
82
83// Both lookups' precedence rule, in the one place, so that adding a step to either cannot
84// put them out of step: the first entry that is an *answer* wins, and a drop is not one.
85// A later step that answers is a proxy the platform did configure — macOS' SOCKS fallback
86// and Windows' `socks=` fill are exactly that — so preferring the record over it would turn
87// a resolvable request into an error. With nothing to answer anywhere the earliest record
88// is returned, because that is the slot whose loss took the answer away; walking the chain
89// is what keeps that order the *lookup's* order rather than a second one maintained by hand.
90fn first_answer<'a>(chain: impl IntoIterator<Item = &'a ProxyEntry>) -> Option<&'a ProxyEntry> {
91    let mut dropped = None;
92    for entry in chain {
93        match entry {
94            ProxyEntry::Unusable(_) => dropped = dropped.or(Some(entry)),
95            _ => return Some(entry),
96        }
97    }
98    dropped
99}
100
101impl ProxyMode {
102    /// Build a [`ProxyMode::Manual`] value, taking the
103    /// [`rejected`](ProxyMode::Manual) list from whatever drops `per_scheme` already holds.
104    ///
105    /// Empty for the map a backend builds, which records its drops separately and attaches
106    /// them with `with_rejected` (private) once the map is complete. It is a caller filtering
107    /// or merging a *parsed* `Manual` that hands one back already carrying
108    /// [`ProxyEntry::Unusable`] entries, because that is where `with_rejected` put them, and
109    /// the list has to be recovered from there rather than left empty — an empty list beside
110    /// a lookup that answers `Unusable` says nothing was lost about a request that cannot be
111    /// routed. Read in [`Scheme::ALL`]'s order, not the map's, which is seeded per instance.
112    /// A record that named no scheme was never in the map and cannot come back this way.
113    #[must_use]
114    pub fn manual(per_scheme: HashMap<Scheme, ProxyEntry>, bypass: BypassRules) -> Self {
115        let rejected = Scheme::ALL
116            .iter()
117            .filter_map(|scheme| match per_scheme.get(scheme) {
118                Some(ProxyEntry::Unusable(value)) => Some(value.clone()),
119                _ => None,
120            })
121            .collect();
122        ProxyMode::Manual {
123            per_scheme,
124            bypass,
125            rejected,
126        }
127    }
128
129    /// Build a [`ProxyMode::Pac`] value with an empty `rejected` list.
130    #[must_use]
131    pub fn pac(url: Url) -> Self {
132        ProxyMode::Pac {
133            url,
134            rejected: Vec::new(),
135        }
136    }
137
138    /// Build a [`ProxyMode::PacInline`] value with an empty `rejected` list.
139    #[must_use]
140    pub fn pac_inline(script: String) -> Self {
141        ProxyMode::PacInline {
142            script,
143            rejected: Vec::new(),
144        }
145    }
146
147    /// Set the [`rejected`](ProxyMode::Manual) list of a [`ProxyMode::Manual`] value,
148    /// *replacing* whatever it held. Every caller builds the list first and hands it over
149    /// once; a second call replaces the list but leaves behind the `per_scheme` entries the
150    /// first one derived (below), so a lookup could still reach a record that
151    /// [`ProxyMode::rejected`] no longer lists. That is why the `kioslaverc`
152    /// `ProxyType = 4` path merges the environment's list and its own skipped slots into
153    /// one vector before calling, rather than calling twice.
154    ///
155    /// A no-op on [`ProxyMode::Direct`] and [`ProxyMode::WpadAutoDetect`], which have
156    /// nowhere to put the list. No backend needs one there: a reader that dropped a setting
157    /// answers [`Manual`](ProxyMode::Manual) rather than `Direct` precisely so the record
158    /// has somewhere to go, and the two WPAD returns are reached only from a flag that read
159    /// cleanly, so nothing can have been recorded by the time either is taken.
160    ///
161    /// Only [`Manual`](ProxyMode::Manual) mirrors the list into `per_scheme`; the PAC
162    /// variants have no map, and a lookup against them answers `None` for every scheme
163    /// anyway. The rest of this describes that mirror.
164    ///
165    /// Every record that names a scheme is also written into `per_scheme` as
166    /// [`ProxyEntry::Unusable`], which is what makes a drop reachable from a lookup instead
167    /// of only from a second list the lookup would have to be kept in step with. Doing it
168    /// here rather than in each backend is the point: spread across the backends, five
169    /// readers express "keep this scheme out of the map so the record answers instead" in
170    /// five dialects, and the two orderings — the lookup's and the record walk's — have to
171    /// agree by hand.
172    ///
173    /// Two rules:
174    ///
175    /// - An occupied slot stands, including one holding [`ProxyEntry::Disabled`] — that is
176    ///   an answer the platform gave, and a backend that recorded a drop and wrote the slot
177    ///   anyway (macOS' SOCKS fallback, GNOME's `use-same-proxy`) meant the write. A backend
178    ///   that wants the record to answer leaves the slot empty. A record naming
179    ///   [`Scheme::All`] is no exception here: a *live* catch-all does not reach past a
180    ///   `Disabled` either ([`entry_for`](Self::entry_for)'s first rule), so losing one took
181    ///   nothing from that scheme — clearing it would turn `http_proxy=` beside an
182    ///   unparseable `all_proxy=` into an error. The one reader whose catch-all *overwrites*
183    ///   `Disabled` rather than filling around it is macOS', and it empties those slots
184    ///   itself before calling this.
185    /// - The first record for a slot wins, which is what
186    ///   [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable) promises.
187    ///
188    /// A record naming no scheme stays in the list alone: there is no slot to put it in, and
189    /// it never took any one request's answer away.
190    #[must_use]
191    pub(crate) fn with_rejected(mut self, rejected: Vec<RejectedValue>) -> Self {
192        match &mut self {
193            ProxyMode::Manual {
194                per_scheme,
195                rejected: slot,
196                ..
197            } => {
198                for value in &rejected {
199                    let Some(scheme) = value.affected_scheme() else {
200                        continue;
201                    };
202                    per_scheme
203                        .entry(scheme)
204                        .or_insert_with(|| ProxyEntry::Unusable(value.clone()));
205                }
206                *slot = rejected;
207            }
208            ProxyMode::Pac { rejected: slot, .. } | ProxyMode::PacInline { rejected: slot, .. } => {
209                *slot = rejected
210            }
211            ProxyMode::Direct | ProxyMode::WpadAutoDetect => {}
212        }
213        self
214    }
215
216    /// Whether the mode means "connect directly".
217    #[must_use]
218    pub fn is_direct(&self) -> bool {
219        matches!(self, ProxyMode::Direct)
220    }
221
222    /// The bypass rules, when the mode has any.
223    #[must_use]
224    pub fn bypass(&self) -> Option<&BypassRules> {
225        match self {
226            ProxyMode::Manual { bypass, .. } => Some(bypass),
227            _ => None,
228        }
229    }
230
231    /// The redacted text of every scheme-endpoint entry the source dropped, when the
232    /// mode has any. See [`ProxyMode::Manual`]'s `rejected` field for what this
233    /// records and why.
234    #[must_use]
235    pub fn rejected(&self) -> Option<&[RejectedValue]> {
236        match self {
237            ProxyMode::Manual { rejected, .. }
238            | ProxyMode::Pac { rejected, .. }
239            | ProxyMode::PacInline { rejected, .. } => Some(rejected),
240            ProxyMode::Direct | ProxyMode::WpadAutoDetect => None,
241        }
242    }
243
244    /// Entry for `scheme`. Concrete schemes beat [`Scheme::All`]; `All` has no fallback.
245    /// `None` outside Manual or with no entry; `Some(Disabled)` suppresses `All`.
246    /// [`Unusable`](ProxyEntry::Unusable) does not: a drop is the last resort, so a live
247    /// `All` still answers and the record surfaces only when nothing else covers `scheme`.
248    /// Backends may substitute before the map is built (macOS SOCKS fallback, Windows
249    /// `socks=` fill) — this applies to the finished map.
250    #[must_use]
251    pub fn entry_for(&self, scheme: Scheme) -> Option<&ProxyEntry> {
252        let ProxyMode::Manual { per_scheme, .. } = self else {
253            return None;
254        };
255        // No `scheme == Scheme::All` guard: reaching the fallback means the first lookup
256        // missed, and for `All` the fallback *is* that same lookup, so it can only miss
257        // again. A guard would be a branch no input can tell apart from its absence.
258        first_answer(
259            [scheme, Scheme::All]
260                .into_iter()
261                .filter_map(|scheme| per_scheme.get(&scheme)),
262        )
263    }
264
265    /// The proxy endpoint that applies to `scheme`, or `None`.
266    ///
267    /// `None` is "no endpoint to hand out". Inside [`Manual`](ProxyMode::Manual) that is
268    /// direct access when nothing covers the scheme and when what does is
269    /// [`Disabled`](ProxyEntry::Disabled) — but not when it is
270    /// [`Unusable`](ProxyEntry::Unusable), a setting that was lost rather than an answer,
271    /// which `resolve` reports as
272    /// [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable). A caller that has to
273    /// tell those two apart asks [`entry_for`](Self::entry_for), which is the same lookup
274    /// with the entry left intact. Every other mode returns `None` for every scheme,
275    /// including the PAC and WPAD ones that keep their answer in a script this method cannot
276    /// run; that is [`Error::PacNotSupported`](crate::Error::PacNotSupported), not direct.
277    ///
278    /// ```
279    /// # use proxy_watch::{parse, ProxyMode, Scheme, BypassRules};
280    /// let per_scheme = parse::proxy_server("http=a:8080;https=b:8443");
281    /// let mode = ProxyMode::manual(per_scheme, BypassRules::new());
282    /// assert_eq!(mode.endpoint_for(Scheme::Http).unwrap().port, 8080);
283    /// // No `ftp=` and no catch-all entry: nothing applies.
284    /// assert!(mode.endpoint_for(Scheme::Ftp).is_none());
285    /// ```
286    #[must_use]
287    pub fn endpoint_for(&self, scheme: Scheme) -> Option<&ProxyEndpoint> {
288        self.entry_for(scheme).and_then(ProxyEntry::endpoint)
289    }
290
291    // Only the first three steps come from the references (Chromium's
292    // `GetProxyListForWebSocketScheme`). `all` is a fourth that neither has and Chromium
293    // structurally cannot reach — there a catch-all and a per-scheme entry never coexist —
294    // so it goes last, for the reason it does everywhere else here: a named entry beats
295    // [`Scheme::All`]. `§4.1.3` is Chromium's way of citing it; in the RFC the note is item 3
296    // of the numbered list in §4.1, not a section of that number.
297    /// Entry for `ws`/`wss`: socks → https → http (RFC 6455 §4.1.3, as Chromium reads it),
298    /// then `all` — a step this crate adds.
299    ///
300    /// Unlike [`entry_for`](Self::entry_for), intermediate `Disabled` entries do not stop
301    /// the chain — only terminal [`Scheme::All`] is returned as-is (e.g. empty `all_proxy=`).
302    /// An intermediate [`Unusable`](ProxyEntry::Unusable) does not stop it either, but is
303    /// remembered, and the earliest one is the answer when no step has a real one.
304    ///
305    /// ```
306    /// # use proxy_watch::{parse, ProxyMode, BypassRules};
307    /// // Only `socks=` and `http=`: the WebSocket chain prefers the SOCKS proxy.
308    /// let per_scheme = parse::proxy_server("http=h:80;socks=s:1080");
309    /// let mode = ProxyMode::manual(per_scheme, BypassRules::new());
310    /// assert_eq!(mode.websocket_endpoint().unwrap().authority(), "s:1080");
311    ///
312    /// // `https=` explicitly disabled: the chain keeps going to `http=` rather than
313    /// // stopping the way `entry_for(Scheme::Https)` would for an actual `https`
314    /// // request.
315    /// let per_scheme = parse::proxy_server("https=;http=h:80");
316    /// let mode = ProxyMode::manual(per_scheme, BypassRules::new());
317    /// assert_eq!(mode.websocket_endpoint().unwrap().authority(), "h:80");
318    /// ```
319    #[must_use]
320    pub fn websocket_entry(&self) -> Option<&ProxyEntry> {
321        let ProxyMode::Manual { per_scheme, .. } = self else {
322            return None;
323        };
324        let configured = |scheme: Scheme| match per_scheme.get(&scheme) {
325            entry @ Some(ProxyEntry::Use(_) | ProxyEntry::Unusable(_)) => entry,
326            _ => None,
327        };
328        first_answer(
329            [Scheme::Socks, Scheme::Https, Scheme::Http]
330                .into_iter()
331                .filter_map(configured)
332                .chain(per_scheme.get(&Scheme::All)),
333        )
334    }
335
336    /// The proxy endpoint for a `ws://`/`wss://` request; see
337    /// [`websocket_entry`](Self::websocket_entry) for the resolution order.
338    #[must_use]
339    pub fn websocket_endpoint(&self) -> Option<&ProxyEndpoint> {
340        self.websocket_entry().and_then(ProxyEntry::endpoint)
341    }
342}
343
344impl fmt::Debug for ProxyMode {
345    // Not a derive, for the reason [`ProxyMode`]'s own doc gives.
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        match self {
348            ProxyMode::Direct => write!(f, "Direct"),
349            ProxyMode::Manual {
350                per_scheme,
351                bypass,
352                rejected,
353            } => f
354                .debug_struct("Manual")
355                // Sorted, which a `HashMap` is not: its iteration order is seeded per
356                // instance, so the same configuration would print differently on each
357                // run and a diff between two logged configurations would report changes
358                // nobody made. `Scheme`'s `Ord` is the declaration order, which is
359                // [`Scheme::ALL`]'s documented one. Every map this crate's `Debug` prints
360                // is ordered for that reason — `ProxyEnv`'s is the other public one.
361                .field(
362                    "per_scheme",
363                    &per_scheme
364                        .iter()
365                        .collect::<std::collections::BTreeMap<_, _>>(),
366                )
367                .field("bypass", bypass)
368                .field("rejected", rejected)
369                .finish(),
370            ProxyMode::Pac { url, rejected } => f
371                .debug_struct("Pac")
372                .field(
373                    "url",
374                    &format_args!("{}", crate::util::redact_userinfo(url.as_str())),
375                )
376                .field("rejected", rejected)
377                .finish(),
378            ProxyMode::PacInline { script, rejected } => f
379                .debug_struct("PacInline")
380                .field("len", &script.len())
381                .field(
382                    "fnv1a",
383                    &format_args!("{:016x}", crate::util::fnv1a(script.as_bytes())),
384                )
385                .field("rejected", rejected)
386                .finish(),
387            ProxyMode::WpadAutoDetect => write!(f, "WpadAutoDetect"),
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn rejection(input: &str) -> RejectedValue {
397        RejectedValue::new(
398            crate::RejectionKind::InvalidProxyEndpoint,
399            crate::RejectionSource::ProxyServer,
400            input,
401        )
402    }
403
404    const SECRET: &str = "hunter2";
405
406    #[test]
407    fn manual_debug_delegates_to_proxy_entrys_own_masking() {
408        use crate::auth::ProxyAuth;
409        use crate::endpoint::ProxyEndpoint;
410
411        let mut per_scheme = HashMap::new();
412        per_scheme.insert(
413            Scheme::Http,
414            ProxyEntry::Use(
415                ProxyEndpoint::new(url::Host::Domain("proxy.corp".to_owned()), 8080)
416                    .with_auth(ProxyAuth::new("alice", Some(SECRET))),
417            ),
418        );
419        let mode = ProxyMode::manual(per_scheme, BypassRules::new());
420        let debug = format!("{mode:?}");
421        assert!(!debug.contains(SECRET), "{debug}");
422        assert!(debug.contains("Manual"), "{debug}");
423        assert!(debug.contains("proxy.corp"), "{debug}");
424    }
425
426    // Two `Debug` renderings of the same configuration must be the same text. A
427    // `HashMap` gives no such promise: its iteration order is seeded per instance, so
428    // the field would come out in a different order on each run and a diff between two
429    // logged configurations would report changes nobody made.
430    #[test]
431    fn manual_debug_prints_the_schemes_in_a_fixed_order() {
432        use crate::parse;
433
434        let mode = ProxyMode::manual(
435            parse::proxy_server("all=e:5;socks=d:4;ftp=c:3;https=b:2;http=a:1"),
436            BypassRules::new(),
437        );
438        let debug = format!("{mode:?}");
439
440        let mut at = 0;
441        for scheme in Scheme::ALL {
442            // With the `:` the map's separator prints, so `Http` does not match inside
443            // `Https`.
444            let name = format!("{scheme:?}:");
445            let found = debug[at..]
446                .find(&name)
447                .unwrap_or_else(|| panic!("{name} out of order or missing in {debug}"));
448            at += found + name.len();
449        }
450    }
451
452    // The impl is hand-written for the whole enum, and this test is the only thing holding
453    // four of its renderings. `WpadAutoDetect` printed as `Direct` makes "discovery is
454    // running" and "no proxy at all" the same line in a log — and `is_direct` is false for
455    // one of them, so a reader comparing the two would be told the line is wrong. `Pac`'s
456    // `rejected` is where a PAC configuration parks what it could not read, and
457    // `PacInline`'s `len` is named here for the same reason.
458    //
459    // Exact strings, so that a label, a field order or a variant name cannot change unseen.
460    // The `RejectedValue` and the hash keep their own renderings, which this impl does not
461    // own, so the expectations defer to them rather than copying them out.
462    #[test]
463    fn every_mode_debug_names_itself_and_keeps_its_fields() {
464        const SCRIPT: &str = "function FindProxyForURL(){}";
465        let dropped = rejection("h:99999");
466        for (mode, expected) in [
467            (ProxyMode::Direct, "Direct".to_owned()),
468            (ProxyMode::WpadAutoDetect, "WpadAutoDetect".to_owned()),
469            (
470                // No credentials, so the row pins the framing and not the masking, which
471                // `debug_masking`'s registry owns for this variant.
472                ProxyMode::pac(url::Url::parse("https://wpad.corp/proxy.pac").unwrap())
473                    .with_rejected(vec![dropped.clone()]),
474                format!("Pac {{ url: https://wpad.corp/proxy.pac, rejected: [{dropped:?}] }}"),
475            ),
476            (
477                ProxyMode::pac_inline(SCRIPT.to_owned()),
478                format!(
479                    "PacInline {{ len: {}, fnv1a: {:016x}, rejected: [] }}",
480                    SCRIPT.len(),
481                    crate::util::fnv1a(SCRIPT.as_bytes())
482                ),
483            ),
484        ] {
485            assert_eq!(format!("{mode:?}"), expected);
486        }
487    }
488
489    fn authority(mode: &ProxyMode, want: Option<&str>) {
490        assert_eq!(
491            mode.websocket_endpoint()
492                .map(ProxyEndpoint::authority)
493                .as_deref(),
494            want
495        );
496    }
497
498    #[test]
499    fn websocket_entry_prefers_socks_then_https_then_http() {
500        use crate::parse;
501
502        let mode = ProxyMode::manual(
503            parse::proxy_server("http=h:80;https=s:443;socks=k:1080"),
504            BypassRules::new(),
505        );
506        authority(&mode, Some("k:1080"));
507
508        let mode = ProxyMode::manual(
509            parse::proxy_server("http=h:80;https=s:443"),
510            BypassRules::new(),
511        );
512        authority(&mode, Some("s:443"));
513
514        let mode = ProxyMode::manual(parse::proxy_server("http=h:80"), BypassRules::new());
515        authority(&mode, Some("h:80"));
516    }
517
518    #[test]
519    fn websocket_entry_falls_back_to_the_bare_catch_all_last() {
520        use crate::parse;
521
522        let mode = ProxyMode::manual(parse::proxy_server("bare:9000"), BypassRules::new());
523        authority(&mode, Some("bare:9000"));
524
525        // "Last" means after `http=`, not merely "used when it is the only entry". This is
526        // the step the references do not have: in `net/proxy_resolution/proxy_config.h`,
527        // Chromium's `ProxyConfig::ProxyRules::type` is `PROXY_LIST` (the catch-all, in
528        // `single_proxies`) or `PROXY_LIST_PER_SCHEME` (`proxies_for_http` and its
529        // siblings) and never both at once, so the input above is not a shape it can hold.
530        let mode = ProxyMode::manual(parse::proxy_server("all=a:1;http=h:80"), BypassRules::new());
531        authority(&mode, Some("h:80"));
532
533        // Nothing configured at all: no entry, same as a direct connection.
534        let mode = ProxyMode::manual(HashMap::new(), BypassRules::new());
535        authority(&mode, None);
536    }
537
538    // Unlike [`ProxyMode::entry_for`], a disabled entry along the chain does not stop
539    // it — [`ProxyMode::websocket_entry`]'s doc states that difference, and it is why
540    // the chain cannot be three `entry_for` calls.
541    #[test]
542    fn websocket_entry_treats_a_disabled_tier_as_absent_not_as_a_stop_signal() {
543        use crate::parse;
544
545        // `https=` explicitly off: the chain still reaches `http=` instead of jumping
546        // straight to "no entry".
547        let mode = ProxyMode::manual(parse::proxy_server("https=;http=h:80"), BypassRules::new());
548        authority(&mode, Some("h:80"));
549
550        // Confirm the asymmetry: `entry_for(Https)` itself *does* stop at `Disabled`
551        // for an actual `https` request.
552        assert_eq!(mode.entry_for(Scheme::Https), Some(&ProxyEntry::Disabled));
553
554        // The same at the head of the chain rather than in its middle. Without this the
555        // claim is "a `Disabled` at the `https` tier is skipped", which is one position,
556        // not the rule the doc states.
557        let mode = ProxyMode::manual(
558            parse::proxy_server("socks=;https=s:443"),
559            BypassRules::new(),
560        );
561        authority(&mode, Some("s:443"));
562    }
563
564    // The other half of the same doc sentence: the three tiers above drop a `Disabled`,
565    // but the terminal [`Scheme::All`] is returned as-is. [`authority`] cannot see the
566    // difference — it reads through [`ProxyMode::websocket_endpoint`], which turns both
567    // `Some(Disabled)` and `None` into no endpoint — so the entry is asserted directly.
568    #[test]
569    fn a_disabled_terminal_catch_all_is_returned_rather_than_dropped() {
570        use crate::parse;
571
572        let mode = ProxyMode::manual(parse::proxy_server("all="), BypassRules::new());
573        assert_eq!(mode.websocket_entry(), Some(&ProxyEntry::Disabled));
574        authority(&mode, None);
575    }
576
577    #[test]
578    fn websocket_entry_is_none_for_non_manual_modes() {
579        assert!(ProxyMode::Direct.websocket_entry().is_none());
580        assert!(ProxyMode::WpadAutoDetect.websocket_entry().is_none());
581    }
582
583    #[test]
584    fn manual_defaults_to_an_empty_rejected_list() {
585        let mode = ProxyMode::manual(HashMap::new(), BypassRules::new());
586        assert_eq!(mode.rejected(), Some(&[][..]));
587    }
588
589    // Empty is the answer for a map with nothing lost in it, and the row above is the whole
590    // of that case. A caller filtering or merging a parsed `Manual` reaches for its map, and
591    // the map is where `with_rejected` put the drops — so handing one back to the only public
592    // constructor there is must not turn them into a list that says nothing was lost while a
593    // lookup still answers `Unusable`. That is the shape of a drop nobody can name: `resolve`
594    // errors, and the report written from `rejected()` has no line for it.
595    #[test]
596    fn manual_recovers_the_drops_the_map_it_was_handed_already_carries() {
597        let lost = rejection("http=not a host").for_scheme(Some(Scheme::Http));
598        let inherited = lost.clone().for_scheme(Some(Scheme::Https));
599        let mode = ProxyMode::manual(
600            HashMap::from([
601                (Scheme::Http, ProxyEntry::Unusable(lost.clone())),
602                (Scheme::Https, ProxyEntry::Unusable(inherited.clone())),
603                (Scheme::Ftp, ProxyEntry::Disabled),
604            ]),
605            BypassRules::new(),
606        );
607        // In `Scheme::ALL`'s order, not the map's, which is seeded per instance — the same
608        // reason the `Debug` above sorts.
609        assert_eq!(mode.rejected(), Some(&[lost, inherited][..]));
610    }
611
612    #[test]
613    fn with_rejected_attaches_the_list_to_a_manual_mode() {
614        let mode = ProxyMode::manual(HashMap::new(), BypassRules::new())
615            .with_rejected(vec![rejection("http=not a host")]);
616        assert_eq!(
617            mode.rejected().unwrap()[0].redacted_input(),
618            "http=not a host"
619        );
620        let debug = format!("{mode:?}");
621        assert!(debug.contains("not a host"), "{debug}");
622    }
623
624    #[test]
625    fn with_rejected_attaches_the_list_to_the_pac_modes_too() {
626        let url = Url::parse("http://wpad.corp/proxy.pac").expect("the fixture URL parses");
627        for mode in [
628            ProxyMode::pac(url),
629            ProxyMode::pac_inline("function FindProxyForURL(u, h) {}".to_owned()),
630        ] {
631            let mode = mode.with_rejected(vec![rejection("ProxyAutoDiscoveryEnable=yes")]);
632            assert_eq!(
633                mode.rejected().unwrap()[0].redacted_input(),
634                "ProxyAutoDiscoveryEnable=yes",
635                "{mode:?}"
636            );
637        }
638    }
639
640    // `Direct` and `WpadAutoDetect` have nowhere to put a list, which is also why no backend
641    // hands them one: a reader that dropped something answers `Manual` instead of `Direct`,
642    // and every WPAD return is reached only from a flag that read cleanly.
643    #[test]
644    fn the_field_less_modes_have_no_list_and_swallow_one() {
645        for mode in [ProxyMode::Direct, ProxyMode::WpadAutoDetect] {
646            assert!(mode.rejected().is_none(), "{mode:?}");
647            let after = mode.clone().with_rejected(vec![rejection("ignored")]);
648            assert_eq!(after, mode);
649            assert!(after.rejected().is_none(), "{after:?}");
650        }
651    }
652
653    // Two [`ProxyMode::Manual`] values built from identical input —
654    // including identical `rejected` text — must compare equal so
655    // [`ProxyConfig`](crate::ProxyConfig)'s duplicate-notification suppression keeps
656    // working now that `rejected` is part of the derived [`PartialEq`].
657    #[test]
658    fn manual_modes_with_equal_rejected_lists_are_equal() {
659        let a = ProxyMode::manual(HashMap::new(), BypassRules::new())
660            .with_rejected(vec![rejection("http=garbage")]);
661        let b = ProxyMode::manual(HashMap::new(), BypassRules::new())
662            .with_rejected(vec![rejection("http=garbage")]);
663        assert_eq!(a, b);
664
665        let c = ProxyMode::manual(HashMap::new(), BypassRules::new())
666            .with_rejected(vec![rejection("http=other-garbage")]);
667        assert_ne!(a, c);
668    }
669}