Skip to main content

lingxia_browser/
policy.rs

1//! Navigation policy classification, URL scheme extraction, and URL normalizers.
2
3use crate::types::{
4    BrowserNavigationPolicyDecision, BrowserNavigationPolicyRequest,
5    BrowserNavigationPolicyResponse,
6};
7use std::time::{Duration, Instant};
8
9pub(crate) const LINGXIA_SCHEME: &str = "lingxia";
10// `file` loads in-webview so the user can open local files from the address bar
11// (the WKWebView still gates actual reads to the granted directory).
12const BROWSER_IN_WEBVIEW_SCHEMES: &[&str] = &["http", "https", "lx", "lingxia", "file"];
13const BROWSER_NON_EXTERNAL_SCHEMES: &[&str] = &["about", "data", "blob", "javascript"];
14const TRANSIENT_USER_ACTIVATION_TTL: Duration = Duration::from_secs(10);
15
16/// Per-tab user activation that survives a short main-frame redirect chain.
17///
18/// WebKit reports a custom-scheme navigation produced by an OAuth page as
19/// `WKNavigationType::Other`, even when the chain began with a real click. Keep
20/// that activation bounded and consume it on the first external navigation.
21#[derive(Debug, Default)]
22struct BrowserTransientUserActivation {
23    expires_at: Option<Instant>,
24}
25
26impl BrowserTransientUserActivation {
27    fn apply_at(&mut self, request: &mut BrowserNavigationPolicyRequest, now: Instant) -> bool {
28        if self.expires_at.is_some_and(|expires_at| now >= expires_at) {
29            self.expires_at = None;
30        }
31
32        if !request.is_main_frame {
33            return false;
34        }
35
36        let scheme = extract_url_scheme(request.raw_url.trim());
37        let can_begin_redirect_chain = matches!(scheme.as_deref(), Some("http" | "https"));
38        let is_external = scheme.as_deref().is_some_and(|scheme| {
39            !scheme_in_list(scheme, BROWSER_IN_WEBVIEW_SCHEMES)
40                && !scheme_in_list(scheme, BROWSER_NON_EXTERNAL_SCHEMES)
41        });
42
43        if request.has_user_gesture {
44            self.expires_at =
45                can_begin_redirect_chain.then_some(now + TRANSIENT_USER_ACTIVATION_TTL);
46            return false;
47        }
48
49        if is_external
50            && self
51                .expires_at
52                .take()
53                .is_some_and(|expires_at| now < expires_at)
54        {
55            request.has_user_gesture = true;
56            return true;
57        }
58
59        false
60    }
61}
62
63pub(crate) struct BrowserNavigationPolicyEvaluation {
64    pub response: BrowserNavigationPolicyResponse,
65    pub inherited_user_activation: bool,
66}
67
68/// Stateful policy entrypoint used by each browser tab and its regression tests.
69#[derive(Debug, Default)]
70pub(crate) struct BrowserNavigationPolicySession {
71    user_activation: BrowserTransientUserActivation,
72}
73
74impl BrowserNavigationPolicySession {
75    pub(crate) fn evaluate(
76        &mut self,
77        request: BrowserNavigationPolicyRequest,
78    ) -> BrowserNavigationPolicyEvaluation {
79        self.evaluate_at(request, Instant::now())
80    }
81
82    fn evaluate_at(
83        &mut self,
84        mut request: BrowserNavigationPolicyRequest,
85        now: Instant,
86    ) -> BrowserNavigationPolicyEvaluation {
87        let inherited_user_activation = self.user_activation.apply_at(&mut request, now);
88        BrowserNavigationPolicyEvaluation {
89            response: handle_browser_navigation_policy(request),
90            inherited_user_activation,
91        }
92    }
93}
94
95/// Extract the (lowercased) scheme from a URL-like string, or `None` if the
96/// text before the first `:` is not a valid scheme.
97pub fn extract_url_scheme(raw: &str) -> Option<String> {
98    let (scheme, _) = raw.split_once(':')?;
99    if scheme.is_empty() {
100        return None;
101    }
102    let is_valid = scheme
103        .chars()
104        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
105    if !is_valid {
106        return None;
107    }
108    Some(scheme.to_ascii_lowercase())
109}
110
111/// Whether a `lingxia://` URL maps to the startup/newtab page or another internal browser page.
112///
113/// - `lingxia://newtab` (or bare `lingxia://`) → `Some(true)`
114/// - Registered `lingxia://<route>` values resolve via the browser internal-page registry.
115///
116/// Returns `None` if `url` is not a `lingxia://` URL.
117pub fn is_lingxia_startup_url(url: &str) -> Option<bool> {
118    if extract_url_scheme(url).as_deref() != Some(LINGXIA_SCHEME) {
119        return None;
120    }
121    let host = lingxia_url_host(url);
122    Some(host.is_empty() || host == "newtab")
123}
124
125pub(crate) fn lingxia_url_host(url: &str) -> String {
126    url.split_once("://")
127        .map(|x| x.1)
128        .unwrap_or("")
129        .split(['/', '?', '#'])
130        .next()
131        .unwrap_or("")
132        .to_ascii_lowercase()
133}
134
135pub(crate) fn normalize_browser_target_url(raw: &str) -> String {
136    let trimmed = raw.trim();
137    // Byte-wise prefix check: page-supplied URLs may contain multi-byte UTF-8
138    // at any position, so slicing by char count would panic.
139    if trimmed
140        .as_bytes()
141        .get(..7)
142        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"http://"))
143    {
144        // Loopback is always the same machine and browsers treat it as a secure
145        // context, so never force-upgrade it (dev or prod). Other private hosts
146        // (LAN IPs) keep plain http only inside a dev session.
147        if url_host_is_loopback(trimmed)
148            || (lxapp::is_dev_session() && preserves_plain_http_for_private_url(trimmed))
149        {
150            return trimmed.to_string();
151        }
152        // The first 7 bytes are ASCII, so byte offset 7 is a char boundary.
153        format!("https://{}", &trimmed[7..])
154    } else {
155        trimmed.to_string()
156    }
157}
158
159/// Whether the http URL's host is loopback (`localhost`, `*.localhost`,
160/// `127.0.0.0/8`, `::1`).
161fn url_host_is_loopback(trimmed: &str) -> bool {
162    let Ok(uri) = trimmed.parse::<http::Uri>() else {
163        return false;
164    };
165    uri.host().is_some_and(is_loopback_http_host)
166}
167
168fn is_loopback_http_host(host: &str) -> bool {
169    let host = host
170        .trim_matches(|ch| ch == '[' || ch == ']')
171        .trim_end_matches('.')
172        .to_ascii_lowercase();
173    if host == "localhost" || host.ends_with(".localhost") {
174        return true;
175    }
176    host.parse::<std::net::IpAddr>()
177        .is_ok_and(|ip| ip.is_loopback())
178}
179
180fn preserves_plain_http_for_private_url(trimmed: &str) -> bool {
181    let Ok(uri) = trimmed.parse::<http::Uri>() else {
182        return false;
183    };
184    if uri
185        .scheme_str()
186        .is_none_or(|scheme| !scheme.eq_ignore_ascii_case("http"))
187    {
188        return false;
189    }
190    uri.host().is_some_and(is_private_http_host)
191}
192
193fn is_private_http_host(host: &str) -> bool {
194    let host = host
195        .trim_matches(|ch| ch == '[' || ch == ']')
196        .trim_end_matches('.')
197        .to_ascii_lowercase();
198
199    let Ok(ip) = host.parse::<std::net::IpAddr>() else {
200        return false;
201    };
202    match ip {
203        std::net::IpAddr::V4(ip) => ip.is_private() || ip.is_link_local(),
204        std::net::IpAddr::V6(ip) => {
205            let first_segment = ip.segments()[0];
206            (first_segment & 0xfe00) == 0xfc00 || (first_segment & 0xffc0) == 0xfe80
207        }
208    }
209}
210
211pub fn normalize_url_for_wait_compare(raw: &str) -> String {
212    let normalized = normalize_browser_target_url(raw);
213    let trimmed = normalized.trim();
214    let Ok(uri) = trimmed.parse::<http::Uri>() else {
215        return trimmed.to_string();
216    };
217    let Some(scheme) = uri.scheme_str().map(str::to_ascii_lowercase) else {
218        return trimmed.to_string();
219    };
220    if !matches!(scheme.as_str(), "http" | "https") {
221        return trimmed.to_string();
222    }
223    let Some(host) = uri.host() else {
224        return trimmed.to_string();
225    };
226    let host = host.to_ascii_lowercase();
227    let host = if host.contains(':') && !host.starts_with('[') {
228        format!("[{host}]")
229    } else {
230        host
231    };
232    let port = uri
233        .port()
234        .map(|port| format!(":{}", port.as_str()))
235        .unwrap_or_default();
236    let path_and_query = uri
237        .path_and_query()
238        .map(|value| value.as_str())
239        .filter(|value| !value.is_empty())
240        .unwrap_or("/");
241    format!("{scheme}://{host}{port}{path_and_query}")
242}
243
244fn scheme_in_list(scheme: &str, candidates: &[&str]) -> bool {
245    candidates
246        .iter()
247        .any(|candidate| candidate.eq_ignore_ascii_case(scheme))
248}
249
250fn browser_policy_response(
251    decision: BrowserNavigationPolicyDecision,
252    reason: Option<&str>,
253) -> BrowserNavigationPolicyResponse {
254    BrowserNavigationPolicyResponse {
255        decision,
256        reason: reason.map(str::to_string),
257    }
258}
259
260/// Classify browser navigation requests into:
261/// - `in_webview`: keep loading in current webview.
262/// - `open_external`: cancel in-webview load and open externally.
263/// - `deny`: cancel navigation.
264///
265/// Security model:
266/// - `http/https/file` stay in webview; `lx/lingxia` require the main frame.
267/// - Potential external schemes require user gesture + main-frame navigation.
268/// - Non-external internal schemes (`javascript:`, `data:`, etc.) are denied.
269pub(crate) fn handle_browser_navigation_policy(
270    request: BrowserNavigationPolicyRequest,
271) -> BrowserNavigationPolicyResponse {
272    let trimmed = request.raw_url.trim();
273    if trimmed.is_empty() {
274        return browser_policy_response(BrowserNavigationPolicyDecision::Deny, Some("empty"));
275    }
276
277    if trimmed.chars().any(|c| c.is_whitespace()) {
278        return browser_policy_response(
279            BrowserNavigationPolicyDecision::Deny,
280            Some("whitespace_url"),
281        );
282    }
283
284    let Some(scheme) = extract_url_scheme(trimmed) else {
285        return browser_policy_response(
286            BrowserNavigationPolicyDecision::Deny,
287            Some("missing_scheme"),
288        );
289    };
290
291    if !request.is_main_frame && matches!(scheme.as_str(), "lx" | "lingxia") {
292        return browser_policy_response(
293            BrowserNavigationPolicyDecision::Deny,
294            Some("non_main_frame_internal"),
295        );
296    }
297
298    if scheme_in_list(&scheme, BROWSER_IN_WEBVIEW_SCHEMES) {
299        return browser_policy_response(BrowserNavigationPolicyDecision::InWebview, None);
300    }
301
302    if scheme_in_list(&scheme, BROWSER_NON_EXTERNAL_SCHEMES) {
303        return browser_policy_response(
304            BrowserNavigationPolicyDecision::Deny,
305            Some("non_external_scheme"),
306        );
307    }
308
309    if !request.is_main_frame {
310        return browser_policy_response(
311            BrowserNavigationPolicyDecision::Deny,
312            Some("non_main_frame_external"),
313        );
314    }
315
316    if !request.has_user_gesture {
317        return browser_policy_response(
318            BrowserNavigationPolicyDecision::Deny,
319            Some("gesture_required"),
320        );
321    }
322
323    browser_policy_response(BrowserNavigationPolicyDecision::OpenExternal, None)
324}
325
326pub(crate) fn handle_browser_navigation_policy_json(request_json: &str) -> Option<String> {
327    let request: BrowserNavigationPolicyRequest = serde_json::from_str(request_json).ok()?;
328    serde_json::to_string(&handle_browser_navigation_policy(request)).ok()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn normalize_browser_target_url_upgrades_http_case_insensitively() {
337        assert_eq!(
338            normalize_browser_target_url("  HTTP://Example.com/path?q=1 "),
339            "https://Example.com/path?q=1"
340        );
341        assert_eq!(
342            normalize_browser_target_url("http://example.com"),
343            "https://example.com"
344        );
345        assert_eq!(
346            normalize_browser_target_url("https://example.com"),
347            "https://example.com"
348        );
349    }
350
351    #[test]
352    fn normalize_browser_target_url_upgrades_private_ip_http_outside_dev_session() {
353        assert_eq!(
354            normalize_browser_target_url("http://192.168.1.16:8080/activate?user_code=1234"),
355            "https://192.168.1.16:8080/activate?user_code=1234"
356        );
357        assert_eq!(
358            normalize_browser_target_url("http://10.0.0.4:8080/activate"),
359            "https://10.0.0.4:8080/activate"
360        );
361        assert_eq!(
362            normalize_browser_target_url("http://172.16.0.8:8080/activate"),
363            "https://172.16.0.8:8080/activate"
364        );
365    }
366
367    #[test]
368    fn normalize_browser_target_url_preserves_loopback_http_unconditionally() {
369        // Loopback is the same machine and treated as a secure context, so plain
370        // http is kept even outside a dev session (unlike LAN private IPs above).
371        assert_eq!(
372            normalize_browser_target_url("http://127.0.0.1:8080/activate"),
373            "http://127.0.0.1:8080/activate"
374        );
375        assert_eq!(
376            normalize_browser_target_url("http://localhost:8799/"),
377            "http://localhost:8799/"
378        );
379        assert_eq!(
380            normalize_browser_target_url("http://app.localhost:3000/x"),
381            "http://app.localhost:3000/x"
382        );
383        assert_eq!(
384            normalize_browser_target_url("http://[::1]:8080/"),
385            "http://[::1]:8080/"
386        );
387        // Non-loopback still upgrades outside a dev session.
388        assert_eq!(
389            normalize_browser_target_url("http://example.com/"),
390            "https://example.com/"
391        );
392    }
393
394    #[test]
395    fn normalize_browser_target_url_handles_multibyte_input() {
396        // Multi-byte UTF-8 within the first bytes must not panic.
397        assert_eq!(normalize_browser_target_url("http🌐//x"), "http🌐//x");
398        assert_eq!(normalize_browser_target_url("中文网址"), "中文网址");
399        assert_eq!(
400            normalize_browser_target_url("http://例え.jp/路径"),
401            "https://例え.jp/路径"
402        );
403    }
404
405    #[test]
406    fn lingxia_startup_host_ignores_path_query_and_fragment_delimiters() {
407        assert_eq!(
408            lingxia_url_host("lingxia://settings#clear-browsing-data"),
409            "settings"
410        );
411        assert_eq!(lingxia_url_host("lingxia://history/?q=deepseek"), "history");
412        assert_eq!(is_lingxia_startup_url("lingxia://newtab#top"), Some(true));
413        assert_eq!(
414            is_lingxia_startup_url("lingxia://settings#clear-browsing-data"),
415            Some(false)
416        );
417    }
418
419    #[test]
420    fn normalize_url_for_wait_compare_canonicalizes_browser_urls() {
421        assert_eq!(
422            normalize_url_for_wait_compare("https://Example.com"),
423            "https://example.com/"
424        );
425        assert_eq!(
426            normalize_url_for_wait_compare("http://example.com"),
427            "https://example.com/"
428        );
429        assert_eq!(
430            normalize_url_for_wait_compare("https://[::1]:8443/path?q=1"),
431            "https://[::1]:8443/path?q=1"
432        );
433    }
434
435    #[test]
436    fn browser_nav_policy_allows_lark_with_gesture() {
437        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
438            raw_url: "lark://client/auth?code=1".to_string(),
439            has_user_gesture: true,
440            is_main_frame: true,
441        });
442
443        assert_eq!(
444            response.decision,
445            BrowserNavigationPolicyDecision::OpenExternal
446        );
447    }
448
449    #[test]
450    fn browser_nav_policy_opens_dingtalk_main_frame_with_gesture() {
451        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
452            raw_url: "dingtalk://dingtalkclient/page/link?url=https%3A%2F%2Fexample.com"
453                .to_string(),
454            has_user_gesture: true,
455            is_main_frame: true,
456        });
457
458        assert_eq!(
459            response.decision,
460            BrowserNavigationPolicyDecision::OpenExternal
461        );
462    }
463
464    #[test]
465    fn browser_activation_survives_redirect_chain_and_is_consumed_once() {
466        let now = Instant::now();
467        let mut session = BrowserNavigationPolicySession::default();
468        let clicked_https = BrowserNavigationPolicyRequest {
469            raw_url: "https://login.example/oauth/start".to_string(),
470            has_user_gesture: true,
471            is_main_frame: true,
472        };
473        let evaluation = session.evaluate_at(clicked_https, now);
474        assert!(!evaluation.inherited_user_activation);
475        assert_eq!(
476            evaluation.response.decision,
477            BrowserNavigationPolicyDecision::InWebview
478        );
479
480        let redirected_https = BrowserNavigationPolicyRequest {
481            raw_url: "https://idp.example/oauth/challenge".to_string(),
482            has_user_gesture: false,
483            is_main_frame: true,
484        };
485        let evaluation = session.evaluate_at(redirected_https, now + Duration::from_secs(3));
486        assert!(!evaluation.inherited_user_activation);
487        assert_eq!(
488            evaluation.response.decision,
489            BrowserNavigationPolicyDecision::InWebview
490        );
491
492        let dingtalk = BrowserNavigationPolicyRequest {
493            raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
494            has_user_gesture: false,
495            is_main_frame: true,
496        };
497        let evaluation = session.evaluate_at(dingtalk, now + Duration::from_secs(4));
498        assert!(evaluation.inherited_user_activation);
499        assert_eq!(
500            evaluation.response.decision,
501            BrowserNavigationPolicyDecision::OpenExternal
502        );
503
504        let replay = BrowserNavigationPolicyRequest {
505            raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
506            has_user_gesture: false,
507            is_main_frame: true,
508        };
509        let evaluation = session.evaluate_at(replay, now + Duration::from_secs(5));
510        assert!(!evaluation.inherited_user_activation);
511        assert_eq!(
512            evaluation.response.decision,
513            BrowserNavigationPolicyDecision::Deny
514        );
515        assert_eq!(
516            evaluation.response.reason.as_deref(),
517            Some("gesture_required")
518        );
519    }
520
521    #[test]
522    fn browser_activation_expires_before_external_navigation() {
523        let now = Instant::now();
524        let mut session = BrowserNavigationPolicySession::default();
525        let clicked_https = BrowserNavigationPolicyRequest {
526            raw_url: "https://login.example/oauth/start".to_string(),
527            has_user_gesture: true,
528            is_main_frame: true,
529        };
530        session.evaluate_at(clicked_https, now);
531
532        let dingtalk = BrowserNavigationPolicyRequest {
533            raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
534            has_user_gesture: false,
535            is_main_frame: true,
536        };
537        let evaluation = session.evaluate_at(dingtalk, now + TRANSIENT_USER_ACTIVATION_TTL);
538        assert!(!evaluation.inherited_user_activation);
539        assert_eq!(
540            evaluation.response.decision,
541            BrowserNavigationPolicyDecision::Deny
542        );
543    }
544
545    #[test]
546    fn browser_activation_never_promotes_subframe_navigation() {
547        let now = Instant::now();
548        let mut session = BrowserNavigationPolicySession::default();
549        let clicked_https = BrowserNavigationPolicyRequest {
550            raw_url: "https://login.example/oauth/start".to_string(),
551            has_user_gesture: true,
552            is_main_frame: true,
553        };
554        session.evaluate_at(clicked_https, now);
555
556        let subframe = BrowserNavigationPolicyRequest {
557            raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
558            has_user_gesture: false,
559            is_main_frame: false,
560        };
561        let evaluation = session.evaluate_at(subframe, now + Duration::from_secs(1));
562        assert!(!evaluation.inherited_user_activation);
563        assert_eq!(
564            evaluation.response.decision,
565            BrowserNavigationPolicyDecision::Deny
566        );
567
568        let main_frame = BrowserNavigationPolicyRequest {
569            raw_url: "dingtalk://dingtalkclient/page/link".to_string(),
570            has_user_gesture: false,
571            is_main_frame: true,
572        };
573        let evaluation = session.evaluate_at(main_frame, now + Duration::from_secs(2));
574        assert!(evaluation.inherited_user_activation);
575        assert_eq!(
576            evaluation.response.decision,
577            BrowserNavigationPolicyDecision::OpenExternal
578        );
579    }
580
581    #[test]
582    fn browser_nav_policy_denies_lark_without_gesture() {
583        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
584            raw_url: "lark://client/auth?code=1".to_string(),
585            has_user_gesture: false,
586            is_main_frame: true,
587        });
588
589        assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
590        assert_eq!(response.reason.as_deref(), Some("gesture_required"));
591    }
592
593    #[test]
594    fn browser_nav_policy_allows_unknown_custom_scheme_with_gesture() {
595        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
596            raw_url: "customxyz://hello".to_string(),
597            has_user_gesture: true,
598            is_main_frame: true,
599        });
600
601        assert_eq!(
602            response.decision,
603            BrowserNavigationPolicyDecision::OpenExternal
604        );
605    }
606
607    #[test]
608    fn browser_nav_policy_denies_non_external_scheme() {
609        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
610            raw_url: "javascript:alert(1)".to_string(),
611            has_user_gesture: true,
612            is_main_frame: true,
613        });
614
615        assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
616        assert_eq!(response.reason.as_deref(), Some("non_external_scheme"));
617    }
618
619    #[test]
620    fn browser_nav_policy_denies_external_in_subframe() {
621        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
622            raw_url: "lark://client/auth".to_string(),
623            has_user_gesture: true,
624            is_main_frame: false,
625        });
626
627        assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
628        assert_eq!(response.reason.as_deref(), Some("non_main_frame_external"));
629    }
630
631    #[test]
632    fn browser_nav_policy_allows_lingxia_in_webview() {
633        // `lingxia://` is served natively by the browser scheme handler — stay in-webview.
634        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
635            raw_url: "lingxia://settings".to_string(),
636            has_user_gesture: false,
637            is_main_frame: true,
638        });
639        assert_eq!(
640            response.decision,
641            BrowserNavigationPolicyDecision::InWebview
642        );
643    }
644
645    #[test]
646    fn browser_nav_policy_denies_internal_schemes_in_subframes() {
647        for raw_url in [
648            "lingxia://settings/history/downloads",
649            "lx://userdata/private.html",
650        ] {
651            let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
652                raw_url: raw_url.to_string(),
653                has_user_gesture: true,
654                is_main_frame: false,
655            });
656
657            assert_eq!(response.decision, BrowserNavigationPolicyDecision::Deny);
658            assert_eq!(response.reason.as_deref(), Some("non_main_frame_internal"));
659        }
660    }
661
662    #[test]
663    fn lingxia_newtab_is_startup_url() {
664        assert_eq!(is_lingxia_startup_url("lingxia://newtab"), Some(true));
665        assert_eq!(is_lingxia_startup_url("lingxia://"), Some(true));
666        assert_eq!(is_lingxia_startup_url("lingxia://downloads"), Some(false));
667        assert_eq!(is_lingxia_startup_url("https://example.com"), None);
668    }
669
670    #[test]
671    fn browser_nav_policy_allows_file_in_webview() {
672        // Local files load in-webview (file is no longer a denied non-external scheme).
673        let response = handle_browser_navigation_policy(BrowserNavigationPolicyRequest {
674            raw_url: "file:///Users/me/page.html".to_string(),
675            has_user_gesture: false,
676            is_main_frame: true,
677        });
678        assert_eq!(
679            response.decision,
680            BrowserNavigationPolicyDecision::InWebview
681        );
682    }
683}