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}