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