Files
firezone/rust/gui-client/src-common/src/deep_link.rs
Thomas Eizinger 6114bb274f chore(rust): make most of the Rust code compile on MacOS (#8924)
When working on the Rust code of Firezone from a MacOS computer, it is
useful to have pretty much all of the code at least compile to ensure
detect problems early. Eventually, once we target features like a
headless MacOS client, some of these stubs will actually be filled in an
be functional.
2025-04-29 11:20:09 +00:00

174 lines
6.7 KiB
Rust

//! A module for registering, catching, and parsing deep links that are sent over to the app's already-running instance
// The IPC parts use the same primitives as the IPC service, UDS on Linux
// and named pipes on Windows, so TODO de-dupe the IPC code
use crate::auth;
use anyhow::{Context as _, Result, bail};
use secrecy::{ExposeSecret, SecretString};
use url::Url;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub(crate) const FZ_SCHEME: &str = "firezone-fd0020211111";
#[cfg(target_os = "linux")]
#[path = "deep_link/linux.rs"]
mod imp;
// Stub only
#[cfg(target_os = "macos")]
#[path = "deep_link/macos.rs"]
mod imp;
#[cfg(target_os = "windows")]
#[path = "deep_link/windows.rs"]
mod imp;
#[derive(thiserror::Error, Debug)]
#[error("named pipe server couldn't start listening, we are probably the second instance")]
pub struct CantListen;
pub use imp::{Server, open, register};
/// Parses a deep-link URL into a struct.
///
/// e.g. `firezone-fd0020211111://handle_client_sign_in_callback/?state=secret&fragment=secret&account_name=Firezone&account_slug=firezone&actor_name=Jane+Doe&identity_provider_identifier=secret`
pub(crate) fn parse_auth_callback(url_secret: &SecretString) -> Result<auth::Response> {
let url = Url::parse(url_secret.expose_secret())?;
if Some(url::Host::Domain("handle_client_sign_in_callback")) != url.host() {
bail!("URL host should be `handle_client_sign_in_callback`");
}
// Sometimes I get an empty path, might be a glitch in Firefox Linux aarch64?
match url.path() {
"/" => {}
"" => {}
_ => bail!("URL path should be `/` or empty"),
}
let mut account_slug = None;
let mut actor_name = None;
let mut fragment = None;
let mut state = None;
// There's probably a way to get serde to do this
for (key, value) in url.query_pairs() {
match key.as_ref() {
"account_slug" => {
if account_slug.is_some() {
bail!("`account_slug` should appear exactly once");
}
account_slug = Some(value.to_string());
}
"actor_name" => {
if actor_name.is_some() {
bail!("`actor_name` should appear exactly once");
}
actor_name = Some(value.to_string());
}
"fragment" => {
if fragment.is_some() {
bail!("`fragment` should appear exactly once");
}
fragment = Some(SecretString::new(value.to_string()));
}
"state" => {
if state.is_some() {
bail!("`state` should appear exactly once");
}
state = Some(SecretString::new(value.to_string()));
}
_ => {}
}
}
Ok(auth::Response {
account_slug: account_slug.context("URL should have `account_slug`")?,
actor_name: actor_name.context("URL should have `actor_name`")?,
fragment: fragment.context("URL should have `fragment`")?,
state: state.context("URL should have `state`")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::{Context, Result};
use secrecy::{ExposeSecret, SecretString};
#[test]
fn parse_auth_callback() -> Result<()> {
// Positive cases
let input = "firezone://handle_client_sign_in_callback/?account_slug=firezone&actor_name=Reactor+Scram&fragment=a_very_secret_string&state=a_less_secret_string&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input)?;
assert_eq!(actual.account_slug, "firezone");
assert_eq!(actual.actor_name, "Reactor Scram");
assert_eq!(actual.fragment.expose_secret(), "a_very_secret_string");
assert_eq!(actual.state.expose_secret(), "a_less_secret_string");
let input = "firezone-fd0020211111://handle_client_sign_in_callback?account_name=Firezone&account_slug=firezone&actor_name=Reactor+Scram&fragment=a_very_secret_string&identity_provider_identifier=1234&state=a_less_secret_string";
let actual = parse_callback_wrapper(input)?;
assert_eq!(actual.account_slug, "firezone");
assert_eq!(actual.actor_name, "Reactor Scram");
assert_eq!(actual.fragment.expose_secret(), "a_very_secret_string");
assert_eq!(actual.state.expose_secret(), "a_less_secret_string");
// Empty string "" `actor_name` is fine
let input = "firezone://handle_client_sign_in_callback/?account_slug=firezone&actor_name=&fragment=&state=&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input)?;
assert_eq!(actual.account_slug, "firezone");
assert_eq!(actual.actor_name, "");
assert_eq!(actual.fragment.expose_secret(), "");
assert_eq!(actual.state.expose_secret(), "");
// Negative cases
// URL host is wrong
let input = "firezone://not_handle_client_sign_in_callback/?account_slug=firezone&actor_name=Reactor+Scram&fragment=a_very_secret_string&state=a_less_secret_string&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input);
assert!(actual.is_err());
// `actor_name` is not just blank but totally missing
let input = "firezone://handle_client_sign_in_callback/?account_slug=firezone&fragment=&state=&identity_provider_identifier=12345";
let actual = parse_callback_wrapper(input);
assert!(actual.is_err());
// URL is nonsense
let input = "?????????";
let actual_result = parse_callback_wrapper(input);
assert!(actual_result.is_err());
Ok(())
}
fn parse_callback_wrapper(s: &str) -> Result<auth::Response> {
super::parse_auth_callback(&SecretString::new(s.to_owned()))
}
/// Tests the named pipe or Unix domain socket, doesn't test the URI scheme itself
///
/// Will fail if any other Firezone Client instance is running
/// Will fail with permission error if Firezone already ran as sudo
#[tokio::test]
async fn socket_smoke_test() -> Result<()> {
let server = super::Server::new()
.await
.context("Couldn't start Server")?;
let server_task = tokio::spawn(async move {
let bytes = server.accept().await?;
Ok::<_, anyhow::Error>(bytes)
});
let id = uuid::Uuid::new_v4().to_string();
let expected_url = url::Url::parse(&format!("bogus-test-schema://{id}"))?;
super::open(&expected_url).await?;
let bytes = server_task.await??.unwrap();
let s = std::str::from_utf8(bytes.expose_secret())?;
let url = url::Url::parse(s)?;
assert_eq!(url, expected_url);
Ok(())
}
}