//! Where a URL should open: here, another app, or the real browser. //! //! Pure. No I/O, no Tauri types, no webviews — which is what makes the rules //! testable, and they are the rules the whole app is judged on. use serde::{Deserialize, Serialize}; use url::Url; /// Hosts that always stay inside the app, whichever app is showing. /// /// A "Sign in with Google" button is a user click to a foreign host. Without /// this list the ordinary rule hands it to the external browser, and the login /// completes over there — in the one place whose cookies this app cannot see. const IDENTITY_PROVIDERS: &[&str] = &[ "accounts.google.com", "accounts.youtube.com", "login.microsoftonline.com", "login.microsoft.com", "login.live.com", "login.windows.net", "sts.windows.net", "device.login.microsoftonline.com", "okta.com", "oktapreview.com", "auth0.com", "duosecurity.com", "appleid.apple.com", "signin.aws.amazon.com", "accounts.zoho.com", "id.atlassian.com", "auth.atlassian.com", "slack.com", "onelogin.com", "pingidentity.com", "authsvc.teams.microsoft.com", ]; /// The scope of one configured app, as routing needs it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppScope { pub id: String, /// Hosts this app owns. A URL matches on an exact host or a subdomain. pub scope: Vec, } /// What to do with a URL a webview is about to open. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Decision { /// Let the current webview have it. Stay, /// Another configured app owns this host: switch to it and navigate there. Switch { app_id: String, url: String }, /// Not ours. Hand it to the default browser. External { url: String }, } /// True when `host` is `scope` or a subdomain of it. /// /// Written out rather than a suffix test, because `evilgoogle.com` ends with /// `google.com` and must not match it. pub fn host_matches(host: &str, scope: &str) -> bool { let host = host.trim_start_matches("www.").to_ascii_lowercase(); let scope = scope.trim_start_matches("www.").to_ascii_lowercase(); host == scope || host.ends_with(&format!(".{scope}")) } /// The sign-in hosts, for the cookie import — a session for a tool is no use /// without the identity provider's cookie that vouches for it. pub fn identity_providers() -> &'static [&'static str] { IDENTITY_PROVIDERS } fn is_identity_provider(host: &str) -> bool { IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p)) } /// The app whose scope matches `host` most specifically. /// /// Longest match wins, so an app scoped to `mail.google.com` beats one scoped /// to `google.com` for a Gmail URL rather than the answer depending on order. fn best_match<'a>(host: &str, apps: &'a [AppScope]) -> Option<&'a AppScope> { apps.iter() .filter_map(|a| { a.scope .iter() .filter(|s| host_matches(host, s)) .map(|s| s.len()) .max() .map(|len| (len, a)) }) .max_by_key(|(len, _)| *len) .map(|(_, a)| a) } /// Decide where `url` opens, given which app is showing. /// /// `current` is the id of the app the click came from; `None` means the /// decision is being made outside any app. pub fn decide(url: &str, current: Option<&str>, apps: &[AppScope]) -> Decision { let Ok(parsed) = Url::parse(url) else { return Decision::External { url: url.to_string() }; }; // mailto:, tel:, zoommtg: and friends are for the OS to place, not us. if !matches!(parsed.scheme(), "http" | "https") { return Decision::External { url: url.to_string() }; } let Some(host) = parsed.host_str() else { return Decision::External { url: url.to_string() }; }; // The app showing right now gets first refusal, so an app whose scope // overlaps another's never steals its own internal navigation. if let Some(id) = current { if let Some(app) = apps.iter().find(|a| a.id == id) { if app.scope.iter().any(|s| host_matches(host, s)) { return Decision::Stay; } } } if is_identity_provider(host) { return Decision::Stay; } match best_match(host, apps) { Some(app) => Decision::Switch { app_id: app.id.clone(), url: url.to_string(), }, None => Decision::External { url: url.to_string() }, } } #[cfg(test)] mod tests { use super::*; fn apps() -> Vec { vec![ AppScope { id: "gmail".into(), scope: vec!["mail.google.com".into()] }, AppScope { id: "drive".into(), scope: vec!["drive.google.com".into()] }, AppScope { id: "google".into(), scope: vec!["google.com".into()] }, AppScope { id: "gh".into(), scope: vec!["github.com".into()] }, ] } #[test] fn host_matches_exact_and_subdomain() { assert!(host_matches("github.com", "github.com")); assert!(host_matches("gist.github.com", "github.com")); assert!(host_matches("www.github.com", "github.com")); } #[test] fn host_matches_rejects_suffix_lookalikes() { assert!(!host_matches("evilgithub.com", "github.com")); assert!(!host_matches("github.com.evil.net", "github.com")); } #[test] fn stays_within_the_current_app() { let d = decide("https://mail.google.com/mail/u/0/#inbox", Some("gmail"), &apps()); assert_eq!(d, Decision::Stay); } #[test] fn switches_to_the_app_that_owns_the_host() { let d = decide("https://drive.google.com/file/d/123", Some("gmail"), &apps()); assert_eq!( d, Decision::Switch { app_id: "drive".into(), url: "https://drive.google.com/file/d/123".into() } ); } #[test] fn longest_scope_wins_over_a_broader_one() { // Both `google.com` and `mail.google.com` match; the specific app must win. let d = decide("https://mail.google.com/", Some("gh"), &apps()); assert!(matches!(d, Decision::Switch { ref app_id, .. } if app_id == "gmail")); } #[test] fn unknown_hosts_leave_for_the_browser() { let d = decide("https://news.ycombinator.com/", Some("gh"), &apps()); assert_eq!(d, Decision::External { url: "https://news.ycombinator.com/".into() }); } #[test] fn identity_providers_stay_inside() { // Otherwise every SSO login completes in a browser this app cannot read. let d = decide("https://accounts.google.com/o/oauth2/auth?x=1", Some("gh"), &apps()); assert_eq!(d, Decision::Stay); } #[test] fn non_web_schemes_go_to_the_os() { let d = decide("mailto:someone@example.com", Some("gmail"), &apps()); assert!(matches!(d, Decision::External { .. })); } #[test] fn garbage_urls_do_not_panic() { assert!(matches!(decide("not a url", None, &apps()), Decision::External { .. })); } #[test] fn current_app_wins_even_when_another_scope_is_longer() { // `google` is showing and clicks a google.com link: it keeps it, rather // than the more specific gmail app stealing an internal navigation. let d = decide("https://google.com/search?q=x", Some("google"), &apps()); assert_eq!(d, Decision::Stay); } }