use std::{collections::VecDeque, sync::Mutex}; use serde::Serialize; use tauri::{Emitter, Manager, State}; use url::Url; use crate::nostr_bind; #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PendingCommunityDeepLink { id: String, kind: String, relay_url: String, code: Option, policy_receipt: Option, name: Option, } #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); impl PendingCommunityDeepLinks { fn enqueue(&self, pending: PendingCommunityDeepLink) { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); if queue.iter().any(|item| { item.kind == pending.kind && item.relay_url == pending.relay_url && item.code == pending.code && item.policy_receipt == pending.policy_receipt && item.name == pending.name }) { return; } queue.push_back(pending); } fn first(&self) -> Option { self.0 .lock() .expect("pending deep-link queue poisoned") .front() .cloned() } fn acknowledge(&self, id: &str) -> bool { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); if queue.front().is_some_and(|item| item.id == id) { queue.pop_front(); true } else { false } } } #[tauri::command] pub(crate) fn take_pending_community_deep_link( pending: State<'_, PendingCommunityDeepLinks>, ) -> Option { pending.first() } #[tauri::command] pub(crate) fn acknowledge_pending_community_deep_link( id: String, pending: State<'_, PendingCommunityDeepLinks>, ) -> bool { pending.acknowledge(&id) } fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, relay_url: String, code: Option, policy_receipt: Option, name: Option, ) { app.state::() .enqueue(PendingCommunityDeepLink { id: uuid::Uuid::new_v4().to_string(), kind: kind.to_owned(), relay_url, code, policy_receipt, name, }); } fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; }; if let Err(error) = window.unminimize() { eprintln!("buzz-desktop: failed to unminimize main window for deep link: {error}"); } if let Err(error) = window.show() { eprintln!("buzz-desktop: failed to show main window for deep link: {error}"); } if let Err(error) = window.set_focus() { eprintln!("buzz-desktop: failed to focus main window for deep link: {error}"); } } /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation /// policy of the `connect` arm so the frontend never sees a half-formed /// payload (e.g. `channelId: ""` from `channel=&id=foo`). /// /// Pulled out of `handle_deep_link_url` so it can be unit-tested without /// a live `tauri::AppHandle`. fn parse_message_deep_link(url: &Url) -> Option { let mut channel: Option = None; let mut message_id: Option = None; let mut thread: Option = None; for (k, v) in url.query_pairs() { let v = v.into_owned(); if v.is_empty() { continue; } match k.as_ref() { "channel" => channel = Some(v), "id" => message_id = Some(v), "thread" => thread = Some(v), _ => {} } } let (channel_id, message_id) = (channel?, message_id?); Some(serde_json::json!({ "channelId": channel_id, "messageId": message_id, "threadRootId": thread, })) } /// Parse the query string of a `buzz://join?…` URL into the JSON payload /// emitted on `deep-link-join`. Requires a ws(s) `relay` URL and a non-empty /// `code`; returns `None` otherwise so the frontend never sees a half-formed /// payload. fn parse_join_deep_link(url: &Url) -> Option { let mut code: Option = None; let mut policy_receipt: Option = None; for (k, v) in url.query_pairs() { let v = v.into_owned(); if v.is_empty() { continue; } match k.as_ref() { "code" => code = Some(v), "policy_receipt" => policy_receipt = Some(v), _ => {} } } let code = code?; let relay_url = parse_websocket_relay_param(url)?; Some(serde_json::json!({ "relayUrl": relay_url, "code": code, "policyReceipt": policy_receipt, })) } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AddCommunityDeepLinkPayload { relay_url: String, name: Option, } fn parse_websocket_relay_param(url: &Url) -> Option { let relay_url = url .query_pairs() .find(|(key, _)| key == "relay") .map(|(_, value)| value.into_owned()) .filter(|value| !value.is_empty())?; let parsed = Url::parse(&relay_url).ok()?; if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() { return None; } Some(relay_url) } fn parse_add_community_deep_link(url: &Url) -> Option { Some(AddCommunityDeepLinkPayload { relay_url: parse_websocket_relay_param(url)?, name: optional_non_empty_param(url, "name"), }) } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct NostrBindDeepLinkPayload { challenge_id: String, nonce: String, verification_code: String, audience: String, action: String, protocol: String, version: String, origin: String, expires_at: String, return_mode: String, callback_url: Option, } fn non_empty_param(url: &Url, name: &str) -> Result { url.query_pairs() .find(|(key, _)| key == name) .map(|(_, value)| value.into_owned()) .filter(|value| !value.is_empty()) .ok_or_else(|| format!("missing {name}")) } fn optional_non_empty_param(url: &Url, name: &str) -> Option { url.query_pairs() .find(|(key, _)| key == name) .map(|(_, value)| value.into_owned()) .filter(|value| !value.is_empty()) } fn validate_nostr_bind_callback_url(callback_url: &str, origin: &str) -> Result<(), String> { let callback = Url::parse(callback_url).map_err(|error| format!("invalid callback_url: {error}"))?; let origin = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?; if callback.scheme() != "https" { return Err("callback_url must use https".into()); } if callback.host_str().is_none() { return Err("callback_url missing host".into()); } if !callback.username().is_empty() || callback.password().is_some() { return Err("callback_url must not include credentials".into()); } if callback.scheme() != origin.scheme() || callback.host_str() != origin.host_str() || callback.port_or_known_default() != origin.port_or_known_default() { return Err("callback_url must match origin".into()); } Ok(()) } fn parse_nostr_bind_deep_link(url: &Url) -> Result { let challenge_id = non_empty_param(url, "challenge_id")?; let nonce = non_empty_param(url, "nonce")?; let verification_code = non_empty_param(url, "verification_code")?; let audience = non_empty_param(url, "audience")?; let action = non_empty_param(url, "action")?; let protocol = non_empty_param(url, "protocol")?; let version = non_empty_param(url, "version")?; let origin = non_empty_param(url, "origin")?; let expires_at = non_empty_param(url, "expires_at")?; let return_mode = non_empty_param(url, "return")?; let callback_url = optional_non_empty_param(url, "callback_url"); nostr_bind::validate_challenge_id(&challenge_id)?; nostr_bind::validate_nonce(&nonce)?; nostr_bind::validate_verification_code(&verification_code)?; nostr_bind::validate_protocol_fields(&audience, &action, &protocol, &version)?; nostr_bind::validate_origin(&origin)?; // Expired links still reach the consent surface so the user gets an explicit // failure instead of a silent stderr-only rejection from a launched app. nostr_bind::validate_expires_at_format(&expires_at)?; match return_mode.as_str() { nostr_bind::RETURN_MODE_CLIPBOARD => {} nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 if callback_url.is_some() => {} nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 => { return Err("browser_fragment_v1 requires callback_url".into()); } _ => return Err("unsupported return mode".into()), } if let Some(callback_url) = callback_url.as_deref() { validate_nostr_bind_callback_url(callback_url, &origin)?; } Ok(NostrBindDeepLinkPayload { challenge_id, nonce, verification_code, audience, action, protocol, version, origin, expires_at, return_mode, callback_url, }) } /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: /// - `buzz://connect?relay=` — emits `deep-link-connect` to the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, Err(e) => { eprintln!("buzz-desktop: invalid deep link URL {url_str:?}: {e}"); return; } }; if url.scheme() != "buzz" { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } match url.host_str() { Some("connect") => { let Some(relay_url) = parse_websocket_relay_param(&url) else { eprintln!("buzz-desktop: connect deep link missing/invalid relay: {url_str}"); return; }; activate_main_window(app); queue_community_deep_link(app, "connect", relay_url.clone(), None, None, None); let _ = app.emit("deep-link-connect", relay_url); } Some("join") => { // `buzz://join?relay=&code=` — fired by // the relay's /invite/ landing page. The frontend claims the // invite against the relay's HTTP API, then adds the workspace. let Some(payload) = parse_join_deep_link(&url) else { eprintln!("buzz-desktop: join deep link missing/invalid relay or code: {url_str}"); return; }; activate_main_window(app); let relay_url = payload["relayUrl"].as_str().unwrap_or_default().to_owned(); let code = payload["code"].as_str().map(str::to_owned); let policy_receipt = payload["policyReceipt"].as_str().map(str::to_owned); queue_community_deep_link(app, "join", relay_url, code, policy_receipt, None); let _ = app.emit("deep-link-join", payload); } Some("add-community") => { let Some(payload) = parse_add_community_deep_link(&url) else { eprintln!("buzz-desktop: add-community deep link missing/invalid relay: {url_str}"); return; }; activate_main_window(app); queue_community_deep_link( app, "add-community", payload.relay_url.clone(), None, None, payload.name.clone(), ); let _ = app.emit("deep-link-add-community", payload); } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // // Validation policy mirrors the `connect` arm: parse what we // need, refuse to emit anything if a required param is missing // so the frontend never sees a half-formed payload. The // frontend listener mirrors `parseMessageLink` in TS — we keep // structure on this side (serde JSON) and let the TS code own // any further normalisation. let Some(payload) = parse_message_deep_link(&url) else { eprintln!("buzz-desktop: message deep link missing channel or id: {url_str}"); return; }; activate_main_window(app); let _ = app.emit("deep-link-message", payload); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); let _ = app.emit("deep-link-nostr-bind", payload); } Err(error) => { eprintln!("buzz-desktop: rejecting nostr-bind deep link: {error}: {url_str}"); } }, Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } None => { eprintln!("buzz-desktop: deep link missing action: {url_str}"); } } } #[cfg(test)] mod tests { use url::Url; use super::{ parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, }; fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { PendingCommunityDeepLink { id: id.to_owned(), kind: if code.is_some() { "join" } else { "connect" }.to_owned(), relay_url: relay_url.to_owned(), code: code.map(str::to_owned), policy_receipt: None, name: None, } } #[test] fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { let mut link = pending("join", "wss://relay.example", Some("invite")); link.policy_receipt = Some("relay-signed-receipt".to_owned()); let payload = serde_json::to_value(link).unwrap(); assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); } #[test] fn pending_community_links_are_fifo_and_acknowledged_in_order() { let queue = PendingCommunityDeepLinks::default(); queue.enqueue(pending("first", "wss://one.example", Some("one"))); queue.enqueue(pending("second", "wss://two.example", Some("two"))); assert_eq!(queue.first().unwrap().id, "first"); assert!(!queue.acknowledge("second")); assert!(queue.acknowledge("first")); assert_eq!(queue.first().unwrap().id, "second"); } #[test] fn pending_community_links_dedupe_exact_intents() { let queue = PendingCommunityDeepLinks::default(); queue.enqueue(pending("first", "wss://one.example", Some("one"))); queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); assert!(queue.acknowledge("first")); assert!(queue.first().is_none()); } fn valid_nostr_bind_url() -> Url { Url::parse( "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", ) .unwrap() } #[test] fn parse_add_community_deep_link_extracts_relay_and_name() { let url = Url::parse( "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", ) .unwrap(); let payload = parse_add_community_deep_link(&url).unwrap(); assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); assert_eq!(payload.name.as_deref(), Some("Acme Team")); } #[test] fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { for raw in [ "buzz://add-community?relay=wss%3A%2F%2Facme.example", "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", ] { assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) .unwrap() .name .is_none()); } } #[test] fn parse_add_community_deep_link_rejects_invalid_relays() { for raw in [ "buzz://add-community", "buzz://add-community?relay=", "buzz://add-community?relay=not-a-url", "buzz://add-community?relay=https%3A%2F%2Facme.example", "buzz://add-community?relay=wss%3A%2F%2F", ] { assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); } } #[test] fn parse_message_deep_link_extracts_required_params() { let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert_eq!(payload["channelId"], "abc"); assert_eq!(payload["messageId"], "xyz"); assert!(payload["threadRootId"].is_null()); } #[test] fn parse_message_deep_link_accepts_buzz_scheme() { let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert_eq!(payload["channelId"], "abc"); assert_eq!(payload["messageId"], "xyz"); } #[test] fn parse_message_deep_link_includes_thread_root() { let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert_eq!(payload["threadRootId"], "root1"); } #[test] fn parse_message_deep_link_rejects_missing_id() { let url = Url::parse("buzz://message?channel=abc").unwrap(); assert!(parse_message_deep_link(&url).is_none()); } #[test] fn parse_message_deep_link_rejects_empty_channel() { // Regression: `channel=&id=foo` previously produced channelId: "". let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); assert!(parse_message_deep_link(&url).is_none()); } #[test] fn parse_message_deep_link_rejects_empty_id() { let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); assert!(parse_message_deep_link(&url).is_none()); } #[test] fn parse_message_deep_link_treats_empty_thread_as_absent() { let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); let payload = parse_message_deep_link(&url).expect("required params present"); assert!(payload["threadRootId"].is_null()); } #[test] fn parse_join_deep_link_extracts_relay_and_code() { let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); let payload = parse_join_deep_link(&url).expect("required params present"); assert_eq!(payload["relayUrl"], "wss://relay.example"); assert_eq!(payload["code"], "abc.def"); assert!(payload["policyReceipt"].is_null()); } #[test] fn parse_join_deep_link_extracts_policy_receipt() { let url = Url::parse( "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", ) .unwrap(); let payload = parse_join_deep_link(&url).expect("required params present"); assert_eq!(payload["policyReceipt"], "receipt.value"); } #[test] fn parse_join_deep_link_rejects_missing_code() { let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_join_deep_link_rejects_empty_code() { let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_join_deep_link_rejects_missing_relay() { let url = Url::parse("buzz://join?code=abc.def").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_join_deep_link_rejects_non_websocket_relay() { let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); assert!(parse_join_deep_link(&url).is_none()); } #[test] fn parse_nostr_bind_deep_link_accepts_valid_url() { let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); assert_eq!(payload.verification_code, "123456"); assert_eq!(payload.audience, "buzz:nostr-identity"); assert_eq!(payload.action, "bind_nostr_identity"); assert_eq!(payload.protocol, "buzz-nostr-identity"); assert_eq!(payload.version, "1"); assert_eq!(payload.origin, "https://example.com"); assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); assert_eq!(payload.return_mode, "clipboard"); assert_eq!(payload.callback_url, None); } #[test] fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); let payload = parse_nostr_bind_deep_link(&url).unwrap(); assert_eq!( payload.callback_url.as_deref(), Some("https://example.com/buzz?mockSession=1") ); } #[test] fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); let payload = parse_nostr_bind_deep_link(&url).unwrap(); assert_eq!(payload.return_mode, "browser_fragment_v1"); assert_eq!( payload.callback_url.as_deref(), Some("https://example.com/buzz") ); } #[test] fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); assert_eq!( parse_nostr_bind_deep_link(&url).unwrap_err(), "browser_fragment_v1 requires callback_url" ); } #[test] fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_http_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_empty_nonce() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_short_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_long_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_wrong_action() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_wrong_audience() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_non_https_origin() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_origin_with_path() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); assert!(parse_nostr_bind_deep_link(&url).is_err()); } #[test] fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); let payload = parse_nostr_bind_deep_link(&url).unwrap(); assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); } }