1use std::sync::mpsc::{self, RecvTimeoutError};
11use std::thread;
12use std::time::Duration;
13
14use boa_engine::{Context, JsResult, JsValue, NativeFunction, Source, js_string};
15use url::Url;
16
17use crate::error::Error;
18use crate::resolve::ProxyStep;
19
20use super::hostfn;
21use super::policy::PacPolicy;
22use super::result::parse_find_proxy_result;
23use super::{PacEvaluator, PacScript};
24
25#[derive(Debug, Clone)]
46pub struct BoaEvaluator {
47 policy: PacPolicy,
48}
49
50impl BoaEvaluator {
51 #[must_use]
53 pub fn new(policy: PacPolicy) -> Self {
54 Self { policy }
55 }
56
57 #[must_use]
59 pub fn policy(&self) -> &PacPolicy {
60 &self.policy
61 }
62}
63
64impl PacEvaluator for BoaEvaluator {
65 fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error> {
66 let url = &crate::pac::sanitize_url(url);
69 match self.policy.timeout() {
70 Some(timeout) => run_with_timeout(
71 script.source().to_owned(),
72 url.as_str().to_owned(),
73 host.to_owned(),
74 self.policy,
75 timeout,
76 ),
77 None => run(script.source(), url.as_str(), host, self.policy),
78 }
79 }
80}
81
82fn run_with_timeout(
104 source: String,
105 url: String,
106 host: String,
107 policy: PacPolicy,
108 timeout: Duration,
109) -> Result<Vec<ProxyStep>, Error> {
110 if timeout.is_zero() {
111 return Err(Error::PacTimeout { timeout });
112 }
113
114 let (sender, receiver) = mpsc::sync_channel(1);
115 thread::Builder::new()
116 .name("proxy-watch-pac".to_owned())
117 .spawn(move || {
118 let _ = sender.send(run(&source, &url, &host, policy));
121 })
122 .map_err(|source| Error::io("spawning the PAC evaluation thread", source))?;
123
124 match receiver.recv_timeout(timeout) {
125 Ok(result) => result,
126 Err(RecvTimeoutError::Timeout) => Err(Error::PacTimeout { timeout }),
127 Err(RecvTimeoutError::Disconnected) => Err(Error::pac_evaluation(
128 "the PAC evaluation thread ended without producing a result",
129 )),
130 }
131}
132
133fn apply_runtime_limits(context: &mut Context, policy: PacPolicy) {
135 let limits = context.runtime_limits_mut();
136 limits.set_loop_iteration_limit(policy.max_loop_iterations());
137 limits.set_recursion_limit(policy.recursion_limit());
138 limits.set_stack_size_limit(policy.stack_size_limit());
139}
140
141fn run(source: &str, url: &str, host: &str, policy: PacPolicy) -> Result<Vec<ProxyStep>, Error> {
143 let mut context = Context::default();
144 apply_runtime_limits(&mut context, policy);
145
146 register_host_functions(&mut context, policy).map_err(|error| {
147 Error::pac_evaluation(format!("could not install the PAC host functions: {error}"))
148 })?;
149
150 context.eval(Source::from_bytes(source)).map_err(|error| {
151 Error::pac_evaluation(format!("the PAC script failed to load: {error}"))
152 })?;
153
154 let global = context.global_object();
158 let mut callee = None;
159 for name in [
160 js_string!("FindProxyForURL"),
161 js_string!("FindProxyForURLEx"),
162 ] {
163 let value = global.get(name, &mut context).map_err(|error| {
169 Error::pac_evaluation(format!("reading the PAC entry point failed: {error}"))
170 })?;
171 if let Some(function) = value.as_callable() {
172 callee = Some(function);
173 break;
174 }
175 }
176 let Some(callee) = callee else {
177 return Err(Error::pac_evaluation(
178 "the PAC script defines no FindProxyForURL function",
179 ));
180 };
181
182 let args = [
183 JsValue::from(js_string!(url)),
184 JsValue::from(js_string!(host)),
185 ];
186 let returned = callee
187 .call(&JsValue::undefined(), &args, &mut context)
188 .map_err(|error| Error::pac_evaluation(format!("FindProxyForURL failed: {error}")))?;
189
190 let text = returned
205 .to_string(&mut context)
206 .map_err(|error| {
207 Error::pac_evaluation(format!(
208 "FindProxyForURL returned a value that is not a string: {error}"
209 ))
210 })?
211 .to_std_string_escaped();
212
213 parse_find_proxy_result(&text)
214}
215
216fn arg_string(args: &[JsValue], index: usize, context: &mut Context) -> JsResult<String> {
218 match args.get(index) {
219 Some(value) => Ok(value.to_string(context)?.to_std_string_escaped()),
220 None => Ok(String::new()),
221 }
222}
223
224fn arg_strings(args: &[JsValue], context: &mut Context) -> JsResult<Vec<String>> {
226 let mut out = Vec::with_capacity(args.len());
227 for value in args {
228 out.push(value.to_string(context)?.to_std_string_escaped());
229 }
230 Ok(out)
231}
232
233fn register_host_functions(context: &mut Context, policy: PacPolicy) -> JsResult<()> {
238 context.register_global_callable(
239 js_string!("isPlainHostName"),
240 1,
241 NativeFunction::from_copy_closure(|_this, args, context| {
242 let host = arg_string(args, 0, context)?;
243 Ok(JsValue::from(hostfn::is_plain_host_name(&host)))
244 }),
245 )?;
246
247 context.register_global_callable(
248 js_string!("dnsDomainIs"),
249 2,
250 NativeFunction::from_copy_closure(|_this, args, context| {
251 let host = arg_string(args, 0, context)?;
252 let domain = arg_string(args, 1, context)?;
253 Ok(JsValue::from(hostfn::dns_domain_is(&host, &domain)))
254 }),
255 )?;
256
257 context.register_global_callable(
258 js_string!("localHostOrDomainIs"),
259 2,
260 NativeFunction::from_copy_closure(|_this, args, context| {
261 let host = arg_string(args, 0, context)?;
262 let hostdom = arg_string(args, 1, context)?;
263 Ok(JsValue::from(hostfn::local_host_or_domain_is(
264 &host, &hostdom,
265 )))
266 }),
267 )?;
268
269 context.register_global_callable(
270 js_string!("isResolvable"),
271 1,
272 NativeFunction::from_copy_closure(move |_this, args, context| {
273 let host = arg_string(args, 0, context)?;
274 Ok(JsValue::from(hostfn::is_resolvable(&host, &policy)))
275 }),
276 )?;
277
278 context.register_global_callable(
279 js_string!("isInNet"),
280 3,
281 NativeFunction::from_copy_closure(move |_this, args, context| {
282 let host = arg_string(args, 0, context)?;
283 let pattern = arg_string(args, 1, context)?;
284 let mask = arg_string(args, 2, context)?;
285 Ok(JsValue::from(hostfn::is_in_net(
286 &host, &pattern, &mask, &policy,
287 )))
288 }),
289 )?;
290
291 context.register_global_callable(
292 js_string!("dnsResolve"),
293 1,
294 NativeFunction::from_copy_closure(move |_this, args, context| {
295 let host = arg_string(args, 0, context)?;
296 Ok(match hostfn::dns_resolve(&host, &policy) {
297 Some(address) => JsValue::from(js_string!(address.to_string())),
298 None => JsValue::null(),
301 })
302 }),
303 )?;
304
305 context.register_global_callable(
306 js_string!("myIpAddress"),
307 0,
308 NativeFunction::from_copy_closure(move |_this, _args, _context| {
309 Ok(JsValue::from(js_string!(
310 hostfn::my_ip_address(&policy).to_string()
311 )))
312 }),
313 )?;
314
315 context.register_global_callable(
316 js_string!("dnsDomainLevels"),
317 1,
318 NativeFunction::from_copy_closure(|_this, args, context| {
319 let host = arg_string(args, 0, context)?;
320 Ok(JsValue::from(hostfn::dns_domain_levels(&host) as f64))
321 }),
322 )?;
323
324 context.register_global_callable(
325 js_string!("shExpMatch"),
326 2,
327 NativeFunction::from_copy_closure(|_this, args, context| {
328 let text = arg_string(args, 0, context)?;
329 let pattern = arg_string(args, 1, context)?;
330 Ok(JsValue::from(hostfn::sh_exp_match(&text, &pattern)))
331 }),
332 )?;
333
334 context.register_global_callable(
335 js_string!("weekdayRange"),
336 2,
337 NativeFunction::from_copy_closure(move |_this, args, context| {
338 let args = arg_strings(args, context)?;
339 Ok(JsValue::from(hostfn::weekday_range(&args, &policy)))
340 }),
341 )?;
342
343 context.register_global_callable(
344 js_string!("dateRange"),
345 6,
346 NativeFunction::from_copy_closure(move |_this, args, context| {
347 let args = arg_strings(args, context)?;
348 Ok(JsValue::from(hostfn::date_range(&args, &policy)))
349 }),
350 )?;
351
352 context.register_global_callable(
353 js_string!("timeRange"),
354 6,
355 NativeFunction::from_copy_closure(move |_this, args, context| {
356 let args = arg_strings(args, context)?;
357 Ok(JsValue::from(hostfn::time_range(&args, &policy)))
358 }),
359 )?;
360
361 context.register_global_callable(
362 js_string!("alert"),
363 1,
364 NativeFunction::from_copy_closure(|_this, args, context| {
365 let message = arg_string(args, 0, context)?;
366 hostfn::alert(&message);
367 Ok(JsValue::undefined())
368 }),
369 )?;
370
371 context.register_global_callable(
372 js_string!("convert_addr"),
373 1,
374 NativeFunction::from_copy_closure(|_this, args, context| {
375 let address = arg_string(args, 0, context)?;
376 Ok(JsValue::from(f64::from(hostfn::convert_addr(&address))))
377 }),
378 )?;
379
380 Ok(())
381}
382
383#[cfg(test)]
384mod tests {
385 use std::time::{Duration, UNIX_EPOCH};
386
387 use super::*;
388
389 fn steps(source: &str, url: &str, policy: PacPolicy) -> Result<Vec<ProxyStep>, Error> {
390 let url = Url::parse(url).unwrap();
391 let host = url.host_str().unwrap_or_default().to_owned();
392 BoaEvaluator::new(policy).evaluate(&PacScript::new(source), &url, &host)
393 }
394
395 #[test]
415 fn a_zero_timeout_is_a_budget_of_nothing_not_the_absence_of_one() {
416 let error = steps(
417 "function FindProxyForURL(url, host) { return 'DIRECT'; }",
418 "http://example.com/",
419 PacPolicy::new().with_timeout(Some(Duration::ZERO)),
420 )
421 .unwrap_err();
422 assert!(
423 matches!(error, Error::PacTimeout { timeout } if timeout.is_zero()),
424 "{error:?}"
425 );
426 }
427
428 #[test]
429 fn a_constant_script_returns_direct() {
430 let result = steps(
431 "function FindProxyForURL(url, host) { return 'DIRECT'; }",
432 "http://example.com/",
433 PacPolicy::new(),
434 )
435 .unwrap();
436 assert_eq!(result, vec![ProxyStep::Direct]);
437 }
438
439 #[test]
440 fn the_url_and_host_arguments_reach_the_script() {
441 let result = steps(
442 "function FindProxyForURL(url, host) {
443 if (url.indexOf('/secret') != -1 && host == 'example.com') {
444 return 'PROXY hit:1';
445 }
446 return 'PROXY miss:1';
447 }",
448 "http://example.com/secret",
449 PacPolicy::new(),
450 )
451 .unwrap();
452 assert_eq!(result[0].endpoint().unwrap().authority(), "hit:1");
453 }
454
455 #[test]
461 fn the_script_is_handed_a_sanitized_url() {
462 let source = "function FindProxyForURL(url, host) {
463 if (url === 'http://example.net/a/b?q=1') { return 'DIRECT'; }
464 if (url.indexOf('hunter2') != -1) { return 'PROXY password:1'; }
465 if (url.indexOf('alice') != -1) { return 'PROXY username:1'; }
466 if (url.indexOf('#frag') != -1) { return 'PROXY fragment:1'; }
467 return 'PROXY unexpected:1';
468 }";
469 let result = steps(
470 source,
471 "http://alice:hunter2@example.net/a/b?q=1#frag",
472 PacPolicy::new(),
473 )
474 .unwrap();
475 assert_eq!(
476 result,
477 vec![ProxyStep::Direct],
478 "the endpoint names what the script was still able to read"
479 );
480 }
481
482 #[test]
483 fn the_plain_entry_point_wins_when_both_exist() {
484 let source = "function FindProxyForURL(url, host) { return 'PROXY plain:1'; }
485 function FindProxyForURLEx(url, host) { return 'PROXY ex:1'; }";
486 let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
487 assert_eq!(result[0].endpoint().unwrap().authority(), "plain:1");
488 }
489
490 #[test]
491 fn the_ex_entry_point_still_runs_when_it_is_the_only_one() {
492 let source = "function FindProxyForURLEx(url, host) { return 'PROXY ex:1'; }";
493 let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
494 assert_eq!(result[0].endpoint().unwrap().authority(), "ex:1");
495 }
496
497 #[test]
498 fn a_realistic_corporate_script() {
499 let source = "function FindProxyForURL(url, host) {
501 return 'PROXY edge:8080; PROXY backup:8080; DIRECT';
502 }";
503 let chain = steps(source, "https://example.net/", PacPolicy::new()).unwrap();
504 assert_eq!(chain.len(), 3);
505 assert_eq!(chain[0].endpoint().unwrap().authority(), "edge:8080");
506 assert_eq!(chain[1].endpoint().unwrap().authority(), "backup:8080");
507 assert!(chain[2].is_direct());
508 }
509
510 #[test]
511 fn every_host_function_is_bound() {
512 let source = "function FindProxyForURL(url, host) {
514 alert('hello');
515 var used = [
516 isPlainHostName(host),
517 dnsDomainIs(host, '.example'),
518 localHostOrDomainIs(host, 'www.example'),
519 isResolvable(host),
520 isInNet(host, '10.0.0.0', '255.0.0.0'),
521 dnsResolve(host),
522 myIpAddress(),
523 dnsDomainLevels(host),
524 shExpMatch(url, 'http:*'),
525 weekdayRange('MON', 'FRI'),
526 dateRange('JAN', 'DEC'),
527 timeRange(0, 23),
528 convert_addr('127.0.0.1')
529 ];
530 return 'PROXY ok:' + used.length;
531 }";
532 let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
533 assert_eq!(result[0].endpoint().unwrap().port, 13);
534 }
535
536 #[test]
550 fn an_argument_the_script_leaves_out_is_read_as_the_empty_string() {
551 let source = "function FindProxyForURL(url, host) {
552 return 'PROXY ' + [
553 isPlainHostName(),
554 dnsDomainIs(host),
555 localHostOrDomainIs(host),
556 shExpMatch(url),
557 isInNet(host, '10.0.0.0'),
558 dnsResolve(),
559 dnsDomainLevels(),
560 weekdayRange(),
561 dateRange(),
562 timeRange()
563 ].map(String).join('-') + ':1';
564 }";
565 let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
566 assert_eq!(
567 result[0].endpoint().unwrap().authority(),
568 "true-true-false-false-false-null-0-false-false-false:1"
569 );
570 }
571
572 #[test]
598 fn each_host_function_receives_its_arguments_in_the_documented_order() {
599 let source = "function FindProxyForURL(url, host) {
600 return 'PROXY ' + [
601 dnsDomainIs('www.corp.example', '.corp.example'),
602 localHostOrDomainIs('www', 'www.corp.example'),
603 isInNet('10.0.1.5', '10.0.0.0', '255.255.0.0'),
604 isInNet('10.0.1.5', '10.0.0.0', '255.255.255.0'),
605 shExpMatch('http://www.corp.example/x', 'http://*.corp.example/*')
606 ].map(String).join('-') + ':1';
607 }";
608 let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
609 assert_eq!(
610 result[0].endpoint().unwrap().authority(),
611 "true-true-true-false-true:1"
612 );
613 }
614
615 #[test]
616 fn the_default_policy_makes_dns_resolve_null() {
617 let source = "function FindProxyForURL(url, host) {
618 return dnsResolve(host) == null ? 'DIRECT' : 'PROXY leaked:1';
619 }";
620 assert!(steps(source, "http://example.com/", PacPolicy::new()).unwrap()[0].is_direct());
621 }
622
623 #[test]
624 fn my_ip_address_is_loopback_unless_configured() {
625 let source =
626 "function FindProxyForURL(url, host) { return 'PROXY ' + myIpAddress() + ':1'; }";
627 let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
628 assert_eq!(result[0].endpoint().unwrap().authority(), "127.0.0.1:1");
629
630 let policy = PacPolicy::new().with_my_ip_address("192.0.2.7".parse().unwrap());
631 let result = steps(source, "http://example.com/", policy).unwrap();
632 assert_eq!(result[0].endpoint().unwrap().authority(), "192.0.2.7:1");
633 }
634
635 #[test]
636 fn the_time_functions_see_the_pinned_clock() {
637 let policy = PacPolicy::new().with_now(UNIX_EPOCH + Duration::from_secs(1_709_214_307));
639 let source = "function FindProxyForURL(url, host) {
640 if (weekdayRange('MON', 'FRI') && timeRange(9, 17) && dateRange('FEB')) {
641 return 'PROXY office:8080';
642 }
643 return 'DIRECT';
644 }";
645 let result = steps(source, "http://example.com/", policy).unwrap();
646 assert_eq!(result[0].endpoint().unwrap().authority(), "office:8080");
647 }
648
649 #[test]
650 fn a_throwing_script_is_an_error() {
651 let error = steps(
652 "function FindProxyForURL(url, host) { throw new Error('nope'); }",
653 "http://a/",
654 PacPolicy::new(),
655 )
656 .unwrap_err();
657 assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
658 }
659
660 #[test]
667 fn a_thrown_string_is_masked_and_sanitized_end_to_end() {
668 let error = steps(
669 "function FindProxyForURL(url, host) { \
670 throw 'leaked http://alice:hunter2@proxy.corp/x.pac\\nWARN forged line'; \
671 }",
672 "http://a/",
673 PacPolicy::new(),
674 )
675 .unwrap_err();
676
677 let display = error.to_string();
678 let debug = format!("{error:?}");
679 assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
680 assert!(!display.contains("hunter2"), "{display}");
681 assert!(!debug.contains("hunter2"), "{debug}");
682 assert!(!display.contains('\n'), "{display}");
683 assert!(!debug.contains('\n'), "{debug}");
684 assert!(display.contains("proxy.corp"), "{display}");
686 }
687
688 #[test]
689 fn a_script_without_the_entry_point_is_an_error() {
690 let error = steps("var x = 1;", "http://a/", PacPolicy::new()).unwrap_err();
691 assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
692 }
693
694 #[test]
698 fn a_throwing_entry_point_getter_is_not_reported_as_a_missing_entry_point() {
699 let error = steps(
700 "Object.defineProperty(globalThis, 'FindProxyForURL', {
701 get: function () { throw new Error('tripwire'); }
702 });",
703 "http://a/",
704 PacPolicy::new(),
705 )
706 .unwrap_err();
707 let display = error.to_string();
708 assert!(
709 !display.contains("defines no FindProxyForURL"),
710 "the getter threw, so the name is defined: {display}"
711 );
712 assert!(display.contains("tripwire"), "{display}");
713 }
714
715 #[test]
716 fn a_nonsense_return_value_is_an_error() {
717 let error = steps(
718 "function FindProxyForURL(url, host) { return 'GOPHER g:70'; }",
719 "http://a/",
720 PacPolicy::new(),
721 )
722 .unwrap_err();
723 assert!(matches!(error, Error::PacInvalidResult { .. }), "{error:?}");
724 }
725
726 #[test]
727 fn an_infinite_loop_hits_the_wall_clock_timeout() {
728 let policy = PacPolicy::new()
749 .with_max_loop_iterations(20_000_000)
750 .with_timeout(Some(Duration::from_millis(250)));
751 let start = std::time::Instant::now();
752 let error = steps(
753 "function FindProxyForURL(url, host) { while (true) {} }",
754 "http://a/",
755 policy,
756 )
757 .unwrap_err();
758 assert!(matches!(error, Error::PacTimeout { .. }), "{error:?}");
759 assert!(start.elapsed() < Duration::from_secs(5));
760 }
761
762 #[test]
763 fn an_infinite_loop_hits_the_iteration_cap() {
764 let policy = PacPolicy::new()
766 .with_max_loop_iterations(10_000)
767 .with_timeout(None);
768 let error = steps(
769 "function FindProxyForURL(url, host) { while (true) {} }",
770 "http://a/",
771 policy,
772 )
773 .unwrap_err();
774 assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
775 }
776
777 #[test]
784 fn iteration_inside_a_builtin_is_not_charged_to_the_loop_cap() {
785 let policy = PacPolicy::new()
786 .with_max_loop_iterations(10_000)
787 .with_timeout(None);
788 let result = steps(
789 "function FindProxyForURL(url, host) {
790 var filler = 'a'.repeat(1000000);
791 return filler.length === 1000000 ? 'DIRECT' : 'PROXY p:1';
792 }",
793 "http://a/",
794 policy,
795 )
796 .unwrap();
797 assert_eq!(result, vec![ProxyStep::Direct]);
798 }
799
800 #[test]
801 fn unbounded_recursion_does_not_blow_the_stack() {
802 let error = steps(
803 "function boom(n) { return boom(n + 1); }
804 function FindProxyForURL(url, host) { return boom(0); }",
805 "http://a/",
806 PacPolicy::new(),
807 )
808 .unwrap_err();
809 assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
810 }
811
812 #[test]
815 fn a_tight_recursion_limit_rejects_deep_recursion() {
816 let policy = PacPolicy::new().with_recursion_limit(10);
817 let error = steps(
818 "function depth(n) { return n <= 0 ? 0 : 1 + depth(n - 1); }
819 function FindProxyForURL(url, host) { return 'PROXY d:' + depth(100); }",
820 "http://a/",
821 policy,
822 )
823 .unwrap_err();
824 assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
825 }
826
827 #[test]
832 fn the_default_recursion_limit_still_runs_an_ordinary_recursive_script() {
833 let result = steps(
834 "function depth(n) { return n <= 0 ? 0 : 1 + depth(n - 1); }
835 function FindProxyForURL(url, host) { return 'PROXY d:' + depth(100); }",
836 "http://a/",
837 PacPolicy::new(),
838 )
839 .unwrap();
840 assert_eq!(result[0].endpoint().unwrap().port, 100);
841 }
842
843 #[test]
846 fn recursion_and_stack_size_limits_reach_the_engine() {
847 let policy = PacPolicy::new()
848 .with_recursion_limit(7)
849 .with_stack_size_limit(42);
850 let mut context = Context::default();
851 apply_runtime_limits(&mut context, policy);
852 assert_eq!(context.runtime_limits().recursion_limit(), 7);
853 assert_eq!(context.runtime_limits().stack_size_limit(), 42);
854 }
855
856 #[test]
864 fn the_two_limits_that_claim_to_be_boas_still_are() {
865 let boa = Context::default().runtime_limits();
866 assert_eq!(
867 boa.recursion_limit(),
868 crate::pac::DEFAULT_PAC_RECURSION_LIMIT
869 );
870 assert_eq!(
871 boa.stack_size_limit(),
872 crate::pac::DEFAULT_PAC_STACK_SIZE_LIMIT
873 );
874 assert_eq!(boa.loop_iteration_limit(), u64::MAX);
878 assert_ne!(crate::pac::DEFAULT_PAC_LOOP_LIMIT, u64::MAX);
886 }
887
888 #[test]
889 fn the_runtime_offers_no_way_out() {
890 for escape in [
893 "fetch('http://evil/')",
894 "require('fs')",
895 "XMLHttpRequest",
896 "process.exit(1)",
897 "globalThis.WebAssembly.compile",
898 ] {
899 let source =
900 format!("function FindProxyForURL(url, host) {{ {escape}; return 'DIRECT'; }}");
901 let error = steps(&source, "http://a/", PacPolicy::new()).unwrap_err();
902 assert!(
903 matches!(error, Error::PacEvaluation { .. }),
904 "{escape} gave {error:?}"
905 );
906 }
907 }
908}