diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 0538233267e..f312d73a4a0 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -289,7 +289,17 @@ fn unix_now_secs() -> u64 { .as_secs() } +#[cfg(test)] +#[path = "relay/endpoint_tests.rs"] +mod endpoint_tests; + impl RestClient { + fn endpoint_url(&self, path: &str) -> Result { + buzz_core::relay::relay_http_endpoint(&self.base_url, path) + .map(String::from) + .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}"))) + } + /// Fetch the relay's stable signing identity from its NIP-11 document. /// /// Relay-authored workflow attribution is trusted only when the event signer @@ -302,7 +312,7 @@ impl RestClient { let mut saw_document_without_self = false; for path in ["/", "/info"] { - let url = format!("{}{path}", self.base_url); + let url = self.endpoint_url(path)?; let response = match self .http .get(&url) @@ -477,7 +487,7 @@ impl RestClient { path: &str, body_bytes: &[u8], ) -> Result { - let url = format!("{}{}", self.base_url, path); + let url = self.endpoint_url(path)?; let body_owned = body_bytes.to_vec(); let auth_tag_header = self.auth_tag_json.clone(); self.request_with_retry("POST", path, || { @@ -3851,12 +3861,39 @@ async fn send_auth_response( /// /// `ws://host:port` → `http://host:port` /// `wss://host:port` → `https://host:port` -/// Trailing slashes are stripped. +/// A root path slash is removed; non-root paths and queries are preserved. pub(crate) fn relay_ws_to_http(url: &str) -> String { - url.replace("wss://", "https://") - .replace("ws://", "http://") - .trim_end_matches('/') - .to_string() + let mut parsed = match url::Url::parse(url.trim()) { + Ok(parsed) => parsed, + Err(_) => return url.trim().to_string(), + }; + let scheme = match parsed.scheme() { + "wss" => "https".to_owned(), + "ws" => "http".to_owned(), + scheme => scheme.to_owned(), + }; + let _ = parsed.set_scheme(&scheme); + let had_root_path = parsed.path() == "/"; + if had_root_path { + parsed.set_path(""); + } else if parsed.query().is_none() && parsed.fragment().is_none() { + let path = parsed.path().trim_end_matches('/').to_owned(); + parsed.set_path(&path); + } + let mut serialized = parsed.to_string(); + if had_root_path { + let delimiter = serialized.find(['?', '#']); + match delimiter { + Some(index) if serialized.as_bytes().get(index.wrapping_sub(1)) == Some(&b'/') => { + serialized.remove(index - 1); + } + None if serialized.ends_with('/') => { + serialized.pop(); + } + _ => {} + } + } + serialized } /// Build the subscription ID for a channel: `ch-`. @@ -4594,6 +4631,26 @@ mod tests { ); } + #[test] + fn relay_ws_to_http_preserves_queries() { + assert_eq!( + relay_ws_to_http("wss://relay.example.com/?next=/"), + "https://relay.example.com?next=/" + ); + assert_eq!( + relay_ws_to_http("wss://relay.example.com/?url=wss://other.example/"), + "https://relay.example.com?url=wss://other.example/" + ); + assert_eq!( + relay_ws_to_http("wss://relay.example.com/nostr/"), + "https://relay.example.com/nostr" + ); + assert_eq!( + relay_ws_to_http("wss://relay.example.com/nostr/?next=/"), + "https://relay.example.com/nostr/?next=/" + ); + } + #[test] fn channel_sub_id_format() { let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); diff --git a/crates/buzz-acp/src/relay/endpoint_tests.rs b/crates/buzz-acp/src/relay/endpoint_tests.rs new file mode 100644 index 00000000000..5a2d3d961f6 --- /dev/null +++ b/crates/buzz-acp/src/relay/endpoint_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; + +const QUERY: &str = "token=a%2fb+%20&token=second&url=wss://other.example/&next=/"; + +#[tokio::test] +async fn endpoint_requests_preserve_path_query_and_signed_url() { + for base_path in ["", "/", "/nostr", "/nostr/"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (tx, mut rx) = mpsc::channel(8); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + // Read a complete request, including its body, before closing the socket. + let request = tokio::time::timeout(Duration::from_secs(5), async { + let mut bytes = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0, "incomplete request"); + bytes.extend_from_slice(&chunk[..n]); + assert!(bytes.len() <= 32768, "test request too large"); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8(bytes[..end].to_vec()).unwrap(); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + .unwrap_or(0); + if bytes.len() >= end + 4 + content_length { + break headers; + } + } + } + }) + .await + .unwrap(); + let first_line = request.lines().next().unwrap(); + let target = first_line.split_whitespace().nth(1).unwrap(); + let path = target.split('?').next().unwrap(); + let (status, body) = if first_line.starts_with("GET ") { + if path.ends_with("/info") { + ( + "200 OK", + serde_json::json!({"self": "ab".repeat(32)}).to_string(), + ) + } else { + ("404 Not Found", "{}".to_owned()) + } + } else { + ("200 OK", "[]".to_owned()) + }; + tx.send(request).await.unwrap(); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + socket.write_all(response.as_bytes()).await.unwrap(); + } + }); + let client = RestClient { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(), + base_url: relay_ws_to_http(&format!("ws://{address}{base_path}?{QUERY}")), + keys: Keys::generate(), + auth_tag_json: None, + }; + let filters = [nostr::Filter::new().kind(Kind::TextNote)]; + client.query(&filters).await.unwrap(); + client + .query_raw(&[serde_json::json!({"kinds": [1]})]) + .await + .unwrap(); + client.count(&filters).await.unwrap(); + let event = EventBuilder::new(Kind::TextNote, "test") + .sign_with_keys(&client.keys) + .unwrap(); + client.submit_event(&event).await.unwrap(); + assert_eq!(client.relay_self().await.unwrap(), Some("ab".repeat(32))); + + for (method, route) in [ + ("POST", "/query"), + ("POST", "/query"), + ("POST", "/count"), + ("POST", "/events"), + ("GET", "/"), + ("GET", "/info"), + ] { + let request = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + let target = request + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let (path, query) = target.split_once('?').unwrap(); + assert!(request.starts_with(&format!("{method} "))); + assert_eq!(path, format!("{}{route}", base_path.trim_end_matches('/'))); + assert_eq!(query, QUERY); + if method == "POST" { + let auth = request + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) + .unwrap() + .1 + .trim() + .strip_prefix("Nostr ") + .unwrap(); + use base64::Engine; + let event: nostr::Event = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(auth) + .unwrap(), + ) + .unwrap(); + event.verify().unwrap(); + let signed_url = event.tags.iter().find(|t| t.as_slice()[0] == "u").unwrap(); + assert_eq!( + signed_url.as_slice()[1], + format!("http://{address}{target}") + ); + } else { + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("accept: application/nostr+json"))); + } + } + assert!(rx.try_recv().is_err(), "unexpected extra request"); + server.abort(); + } +} diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index bed59b5ae04..29e2cd606e8 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -299,10 +299,9 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result Result { Ok(value) } +#[cfg(test)] +#[path = "client/endpoint_tests.rs"] +mod endpoint_tests; + impl BuzzClient { + fn endpoint_url(&self, path: &str) -> Result { + buzz_core::relay::relay_http_endpoint(&self.relay_url, path) + .map(String::from) + .map_err(|e| CliError::Usage(format!("invalid relay URL: {e}"))) + } + /// Create a new client pointing at `relay_url`. /// /// Timeout defaults are tuned for degraded WAN links and can be overridden @@ -951,7 +960,7 @@ impl BuzzClient { /// `x-auth-tag` header — the endpoint is public relay metadata, not a /// membership-scoped resource. pub async fn get_public(&self, path: &str) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; let resp = self .http .get(&url) @@ -971,7 +980,7 @@ impl BuzzClient { /// Execute a one-shot query with multiple filters via the HTTP bridge. /// Each filter is ORed by the relay (standard Nostr REQ behavior). pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result { - let url = format!("{}/query", self.relay_url); + let url = self.endpoint_url("/query")?; let body = bytes::Bytes::from( serde_json::to_vec(filters) .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?, @@ -1000,7 +1009,7 @@ impl BuzzClient { /// Returns the count as a JSON string. #[allow(dead_code)] pub async fn count(&self, filter: &serde_json::Value) -> Result { - let url = format!("{}/count", self.relay_url); + let url = self.endpoint_url("/count")?; let body = bytes::Bytes::from( serde_json::to_vec(&[filter]) .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?, @@ -1032,7 +1041,7 @@ impl BuzzClient { /// read commands, which read structured queue/audit rows rather than /// stored events. pub async fn get_authed(&self, path: &str) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; self.with_retry_body(|| { let url = url.clone(); async move { @@ -1063,7 +1072,7 @@ impl BuzzClient { path: &str, body: &serde_json::Value, ) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; let body_bytes = bytes::Bytes::from( serde_json::to_vec(body) .map_err(|e| CliError::Other(format!("request serialization failed: {e}")))?, @@ -1100,7 +1109,7 @@ impl BuzzClient { path: &str, body: &serde_json::Value, ) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?; let unknown = |detail: String| CliError::DeliveryUnknown(detail); let http = reqwest::Client::builder() @@ -1160,7 +1169,7 @@ impl BuzzClient { /// Submit a moderation command (kinds 9040–9044) with non-idempotent retry policy. async fn submit_moderation_event(&self, event: nostr::Event) -> Result { - let url = format!("{}/events", self.relay_url); + let url = self.endpoint_url("/events")?; let body = bytes::Bytes::from( serde_json::to_vec(&event) .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?, @@ -1291,7 +1300,7 @@ impl BuzzClient { /// Content-addressed uploads are exempt: same bytes ⇒ same hash, so outer /// re-run is safe regardless of the failure kind. async fn submit_stored_event(&self, event: nostr::Event) -> Result { - let url = format!("{}/events", self.relay_url); + let url = self.endpoint_url("/events")?; let body = bytes::Bytes::from( serde_json::to_vec(&event) .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?, @@ -1481,7 +1490,7 @@ impl BuzzClient { } else { Duration::from_secs(120) }; - let url = format!("{}/upload", self.relay_url); + let url = self.endpoint_url("/upload")?; let upload_body = bytes::Bytes::from(bytes); // The full upload operation — network send AND response body read — lives inside @@ -1532,7 +1541,7 @@ impl BuzzClient { Err(e) => return Err(e), } - let legacy_url = format!("{}/media/upload", self.relay_url); + let legacy_url = self.endpoint_url("/media/upload")?; self.with_retry_body(|| { let upload_body = upload_body.clone(); let legacy_url = legacy_url.clone(); @@ -1601,20 +1610,68 @@ impl BuzzClient { } } -/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash. +/// Normalize a relay URL: ws:// → http:// and wss:// → https://. /// BUZZ_RELAY_URL may be ws/wss (copied from MCP config). pub fn normalize_relay_url(url: &str) -> String { - url.replace("wss://", "https://") - .replace("ws://", "http://") - .trim_end_matches('/') - .to_string() + let mut parsed = match url::Url::parse(url.trim()) { + Ok(parsed) => parsed, + Err(_) => return url.trim().to_string(), + }; + let scheme = match parsed.scheme() { + "wss" => "https".to_owned(), + "ws" => "http".to_owned(), + scheme => scheme.to_owned(), + }; + let _ = parsed.set_scheme(&scheme); + let had_root_path = parsed.path() == "/"; + if had_root_path { + parsed.set_path(""); + } else if parsed.query().is_none() && parsed.fragment().is_none() { + let path = parsed.path().trim_end_matches('/').to_owned(); + parsed.set_path(&path); + } + serialize_relay_url(parsed, had_root_path) } /// Convert an HTTP(S) relay base URL back to a WebSocket URL for NIP-01 connections. fn to_ws_url(http_url: &str) -> String { - http_url - .replace("https://", "wss://") - .replace("http://", "ws://") + let mut parsed = match url::Url::parse(http_url.trim()) { + Ok(parsed) => parsed, + Err(_) => return http_url.trim().to_string(), + }; + let scheme = match parsed.scheme() { + "https" => "wss".to_owned(), + "http" => "ws".to_owned(), + scheme => scheme.to_owned(), + }; + let _ = parsed.set_scheme(&scheme); + // NIP-42 must name the configured path, including its trailing slash. + // Only remove a root slash that the parser inserted into a pathless URL. + let remove_root_slash = parsed.path() == "/" + && !http_url + .trim() + .split(['?', '#']) + .next() + .is_some_and(|base| base.ends_with('/')); + serialize_relay_url(parsed, remove_root_slash) +} + +fn serialize_relay_url(parsed: url::Url, had_root_path: bool) -> String { + let mut serialized = parsed.to_string(); + if !had_root_path { + return serialized; + } + let delimiter = serialized.find(['?', '#']); + match delimiter { + Some(index) if serialized.as_bytes().get(index.wrapping_sub(1)) == Some(&b'/') => { + serialized.remove(index - 1); + } + None if serialized.ends_with('/') => { + serialized.pop(); + } + _ => {} + } + serialized } /// Normalize raw event JSON array into the canonical Nostr event shape. @@ -2673,10 +2730,53 @@ mod tests { use super::{ advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, - normalize_events, BuzzClient, Credentials, Proof, + normalize_events, normalize_relay_url, to_ws_url, BuzzClient, Credentials, Proof, }; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[test] + fn relay_url_scheme_conversion_preserves_query_bytes() { + assert_eq!( + normalize_relay_url("wss://relay.example/?next=/"), + "https://relay.example?next=/" + ); + assert_eq!( + normalize_relay_url("wss://relay.example/?url=wss://other.example/"), + "https://relay.example?url=wss://other.example/" + ); + assert_eq!( + to_ws_url("https://relay.example/?next=/foo/"), + "wss://relay.example/?next=/foo/" + ); + assert_eq!( + normalize_relay_url("wss://relay.example/nostr/"), + "https://relay.example/nostr" + ); + assert_eq!( + normalize_relay_url("wss://relay.example/nostr/?next=/"), + "https://relay.example/nostr/?next=/" + ); + } + + #[test] + fn websocket_scheme_conversion_preserves_configured_path() { + for (input, expected) in [ + ("https://relay.example", "wss://relay.example"), + ("https://relay.example/", "wss://relay.example/"), + ("https://relay.example/team/", "wss://relay.example/team/"), + ( + "https://relay.example/team/?next=/", + "wss://relay.example/team/?next=/", + ), + ( + "http://relay.example?url=https://other/", + "ws://relay.example?url=https://other/", + ), + ] { + assert_eq!(to_ws_url(input), expected); + } + } + #[test] fn normalize_events_preserves_the_complete_signed_event_shape() { let signed_event = EventBuilder::new(Kind::TextNote, "signed content") diff --git a/crates/buzz-cli/src/client/endpoint_tests.rs b/crates/buzz-cli/src/client/endpoint_tests.rs new file mode 100644 index 00000000000..b1055f2b1ab --- /dev/null +++ b/crates/buzz-cli/src/client/endpoint_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use axum::body::Bytes; +use axum::http::{HeaderMap, Method, StatusCode, Uri}; +use axum::Router; +use nostr::Keys; +use tokio::sync::mpsc; + +// Include duplicates, escaping, literal '+', nested schemes and a trailing slash. +const QUERY: &str = "token=a%2fb+%20&token=second&url=wss://other.example/&next=/"; + +#[tokio::test] +async fn endpoint_requests_preserve_path_query_and_signed_url() { + for base_path in ["", "/", "/nostr", "/nostr/", "/n%2Fostr/"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let origin = format!("http://{address}"); + let (tx, mut rx) = mpsc::channel(16); + let app = Router::new().fallback( + move |method: Method, uri: Uri, headers: HeaderMap, _body: Bytes| { + let tx = tx.clone(); + async move { + tx.send((method, uri.clone(), headers)).await.unwrap(); + if uri.path().ends_with("/media/upload") { + (StatusCode::OK, r#"{"url":"http://example.test/media/blob","sha256":"abc","size":8,"type":"image/png","uploaded":0}"#) + } else if uri.path().ends_with("/upload") { + // Exercise the legacy fallback as well as the primary upload. + (StatusCode::NOT_FOUND, "{}") + } else { + (StatusCode::OK, "[]") + } + } + }, + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let base = normalize_relay_url(&format!("ws://{address}{base_path}?{QUERY}")); + let client = BuzzClient::new(base, Credentials::from_keys(Keys::generate())).unwrap(); + let prefix = base_path.trim_end_matches('/'); + let filter = serde_json::json!({"kinds": [1]}); + + // Each call below exercises a distinct production request construction site. + let mut expected = Vec::new(); + client.get_public("/info").await.unwrap(); + expected.push(("GET", "/info".to_owned(), QUERY.to_owned(), false)); + client.query(&filter).await.unwrap(); + expected.push(("POST", "/query".to_owned(), QUERY.to_owned(), true)); + client.count(&filter).await.unwrap(); + expected.push(("POST", "/count".to_owned(), QUERY.to_owned(), true)); + client + .get_authed("/moderation/reports?status=open&limit=20") + .await + .unwrap(); + expected.push(( + "GET", + "/moderation/reports".to_owned(), + format!("{QUERY}&status=open&limit=20"), + true, + )); + client + .post_json_authed("/gifs/search", &serde_json::json!({})) + .await + .unwrap(); + expected.push(("POST", "/gifs/search".to_owned(), QUERY.to_owned(), true)); + client + .post_json_once_authed("/commands?version=1", &serde_json::json!({})) + .await + .unwrap(); + expected.push(( + "POST", + "/commands".to_owned(), + format!("{QUERY}&version=1"), + true, + )); + for kind in [1, 9040] { + let event = client + .sign_event(EventBuilder::new(Kind::Custom(kind), "test")) + .await + .unwrap(); + client.submit_event(event).await.unwrap(); + expected.push(("POST", "/events".to_owned(), QUERY.to_owned(), true)); + } + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), b"\x89PNG\r\n\x1a\n").unwrap(); + client + .upload_file(file.path().to_str().unwrap()) + .await + .unwrap(); + for path in ["/upload", "/media/upload"] { + expected.push(("PUT", path.to_owned(), QUERY.to_owned(), false)); + } + let hash = "ab".repeat(32); + client.download_media(&hash).await.unwrap(); + expected.push(("GET", format!("/media/{hash}"), QUERY.to_owned(), false)); + + for (method, route, query, nip98) in expected { + let (received_method, uri, headers) = + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(received_method, method); + assert_eq!(uri.path(), format!("{prefix}{route}")); + assert_eq!(uri.query(), Some(query.as_str())); + if nip98 { + let auth = headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event: nostr::Event = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(auth) + .unwrap(), + ) + .unwrap(); + event.verify().unwrap(); + let signed_url = event.tags.iter().find(|t| t.as_slice()[0] == "u").unwrap(); + assert_eq!(signed_url.as_slice()[1], format!("{origin}{uri}")); + } + } + assert!(rx.try_recv().is_err(), "unexpected extra request"); + server.abort(); + } +} diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs index 77c74a069ad..b27940916d1 100644 --- a/crates/buzz-core/src/relay.rs +++ b/crates/buzz-core/src/relay.rs @@ -3,6 +3,35 @@ use thiserror::Error; use url::{Host, Url}; +/// Append an HTTP endpoint to a relay base path without changing its query bytes. +/// +/// `endpoint` is a relay-relative path, optionally with a query string. Endpoint +/// query parameters follow the configured relay parameters, without decoding or +/// re-encoding either query. A base fragment is omitted from the request URL. +pub fn relay_http_endpoint(base: &str, endpoint: &str) -> Result { + let mut url = Url::parse(base)?; + let (path, query) = endpoint + .split_once('?') + .map_or((endpoint, None), |(path, query)| (path, Some(query))); + url.set_path(&format!( + "{}/{}", + url.path().trim_end_matches('/'), + path.trim_start_matches('/') + )); + if let Some(query) = query { + let combined = match url.query() { + Some(base_query) if !base_query.is_empty() && !query.is_empty() => { + format!("{base_query}&{query}") + } + Some(base_query) if query.is_empty() => base_query.to_owned(), + _ => query.to_owned(), + }; + url.set_query(Some(&combined)); + } + url.set_fragment(None); + Ok(url) +} + /// Errors returned while canonicalizing a relay URL for runtime identity. #[derive(Debug, Error, PartialEq, Eq)] pub enum NormalizeRelayUrlError { @@ -71,16 +100,55 @@ pub fn normalize_relay_url(raw: &str) -> Result url.set_port(None) .map_err(|_| NormalizeRelayUrlError::InvalidScheme)?; } - if url.path() == "/" { + let had_root_path = url.path() == "/"; + if had_root_path { url.set_path(""); } - Ok(url.to_string().trim_end_matches('/').to_string()) + let mut serialized = url.to_string(); + if had_root_path { + remove_root_path_separator(&mut serialized); + } + Ok(serialized) +} + +fn remove_root_path_separator(serialized: &mut String) { + let delimiter = serialized.find(['?', '#']); + match delimiter { + Some(index) if serialized.as_bytes().get(index.wrapping_sub(1)) == Some(&b'/') => { + serialized.remove(index - 1); + } + None if serialized.ends_with('/') => { + serialized.pop(); + } + _ => {} + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn http_endpoints_preserve_base_paths_and_raw_queries() { + for (base, endpoint, expected) in [ + ("https://relay.example", "/query", "https://relay.example/query"), + ("https://relay.example/nostr/", "/query", "https://relay.example/nostr/query"), + ("https://relay.example?token=abc", "/query", "https://relay.example/query?token=abc"), + ("https://relay.example/nostr/?token=abc", "/query", "https://relay.example/nostr/query?token=abc"), + ("https://relay.example/n%2Fostr?next=/", "/info", "https://relay.example/n%2Fostr/info?next=/"), + ("https://relay.example?token=a%2fb+%20&token=2&url=wss://other/", "/reports?status=open", "https://relay.example/reports?token=a%2fb+%20&token=2&url=wss://other/&status=open"), + ("https://relay.example/nostr/?token=abc", "/", "https://relay.example/nostr/?token=abc"), + ("http://relay.example:8080?", "/query", "http://relay.example:8080/query?"), + ("https://relay.example?", "/reports?status=open", "https://relay.example/reports?status=open"), + ("https://relay.example?token=abc", "/query?", "https://relay.example/query?token=abc"), + ("https://relay.example", "/reports?status=open", "https://relay.example/reports?status=open"), + ("https://relay.example?token=abc#fragment", "/query", "https://relay.example/query?token=abc"), + ] { + assert_eq!(relay_http_endpoint(base, endpoint).unwrap().as_str(), expected); + } + assert!(relay_http_endpoint("not a URL", "/query").is_err()); + } + #[test] fn loopback_spellings_have_one_identity() { let ipv6 = normalize_relay_url("wss://[::1]/").unwrap(); @@ -103,6 +171,23 @@ mod tests { ); } + #[test] + fn preserves_queries_when_removing_root_path() { + for (input, expected) in [ + ("wss://relay.example/?next=/", "wss://relay.example?next=/"), + ( + "wss://relay.example/?next=/foo/", + "wss://relay.example?next=/foo/", + ), + ( + "wss://relay.example/?url=wss://other.example/", + "wss://relay.example?url=wss://other.example/", + ), + ] { + assert_eq!(normalize_relay_url(input).unwrap(), expected); + } + } + #[test] fn rejects_non_relay_and_ambiguous_urls() { assert_eq!(