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}