use rust_xmpp::{ http_service::serve_router, http_transport::{ConnectionHandler, bosh_router, websocket_router}, wire::Framer, }; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, }; fn handler(count: Arc) -> ConnectionHandler { Arc::new(move |mut stream| { let count = count.clone(); Box::pin(async move { let mut f = Framer::default(); while let Ok(xml) = f.next(&mut stream).await { if xml.starts_with("PLAIN").await.unwrap(); } else if xml == "" { break; } else { count.fetch_add(1, Ordering::SeqCst); stream.write_all(xml.as_bytes()).await.unwrap(); } } }) }) } async fn http(addr: std::net::SocketAddr, body: &str) -> String { let mut stream = TcpStream::connect(addr).await.unwrap(); let req = format!( "POST /http-bind HTTP/1.1\r\nHost: example.test\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); stream.write_all(req.as_bytes()).await.unwrap(); let mut bytes = vec![]; stream.read_to_end(&mut bytes).await.unwrap(); String::from_utf8(bytes) .unwrap() .split_once("\r\n\r\n") .unwrap() .1 .into() } #[tokio::test] async fn bosh_session_retries_restart_termination() { let count = Arc::new(AtomicUsize::new(0)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let app = bosh_router(handler(count.clone())); let task = tokio::spawn(serve_router(listener, None, app)); let initial=http(addr,"").await; let doc = roxmltree::Document::parse(&initial).unwrap(); let sid = doc.root_element().attribute("sid").unwrap().to_string(); assert!(initial.contains("mechanisms")); let msg = format!( "hello" ); let response = http(addr, &msg).await; assert!(response.contains("hello")); let replay = http(addr, &msg).await; assert_eq!(replay, response); assert_eq!(count.load(Ordering::SeqCst), 1); let restart=http(addr,&format!("")).await; assert!(restart.contains("mechanisms")); let end=http(addr,&format!("")).await; assert!(end.contains("type='terminate'")); task.abort(); } async fn ws_send(s: &mut TcpStream, text: &str) { let bytes = text.as_bytes(); let mut frame = vec![0x81]; if bytes.len() < 126 { frame.push(0x80 | bytes.len() as u8) } else { frame.push(0x80 | 126); frame.extend_from_slice(&(bytes.len() as u16).to_be_bytes()); } frame.extend_from_slice(&[1, 2, 3, 4]); frame.extend( bytes .iter() .enumerate() .map(|(i, b)| b ^ [1, 2, 3, 4][i % 4]), ); s.write_all(&frame).await.unwrap(); } async fn ws_read(s: &mut TcpStream) -> String { let mut h = [0; 2]; s.read_exact(&mut h).await.unwrap(); assert_eq!(h[0], 0x81); let n = if h[1] == 126 { s.read_u16().await.unwrap() as usize } else { h[1] as usize }; let mut b = vec![0; n]; s.read_exact(&mut b).await.unwrap(); String::from_utf8(b).unwrap() } #[tokio::test] async fn websocket_frames_are_independent_xml() { let count = Arc::new(AtomicUsize::new(0)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let app = websocket_router(handler(count.clone())); let task = tokio::spawn(serve_router(listener, None, app)); let mut s = TcpStream::connect(addr).await.unwrap(); s.write_all(b"GET /xmpp-websocket HTTP/1.1\r\nHost: example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: xmpp\r\n\r\n").await.unwrap(); let mut header = vec![]; loop { header.push(s.read_u8().await.unwrap()); if header.ends_with(b"\r\n\r\n") { break; } } assert!( String::from_utf8(header) .unwrap() .starts_with("HTTP/1.1 101") ); ws_send( &mut s, "", ) .await; let open = ws_read(&mut s).await; assert!(open.contains("hi", ) .await; let message = ws_read(&mut s).await; assert!(message.contains("hi")); assert_eq!(count.load(Ordering::SeqCst), 1); drop(s); task.abort(); } #[tokio::test] async fn configured_origin_is_enforced() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let router = rust_xmpp::http_transport::with_origins( bosh_router(handler(Arc::new(AtomicUsize::new(0)))), vec!["https://allowed.test".into()], ); let task = tokio::spawn(serve_router(listener, None, router)); for (origin, status) in [ ("https://evil.test", "403"), ("https://allowed.test", "200"), ] { let mut stream = TcpStream::connect(addr).await.unwrap(); let request = format!( "OPTIONS /http-bind HTTP/1.1\r\nHost: example.test\r\nOrigin: {origin}\r\nConnection: close\r\n\r\n" ); stream.write_all(request.as_bytes()).await.unwrap(); let mut result = String::new(); stream.read_to_string(&mut result).await.unwrap(); assert!( result.starts_with(&format!("HTTP/1.1 {status}")), "{result}" ); if status == "200" { assert!(result.contains("access-control-allow-origin: https://allowed.test")); } } task.abort(); } #[tokio::test] async fn bosh_reorders_two_requests_without_replaying_stanzas() { let count = Arc::new(AtomicUsize::new(0)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let task = tokio::spawn(serve_router( listener, None, bosh_router(handler(count.clone())), )); let first = http( addr, "", ) .await; let doc = roxmltree::Document::parse(&first).unwrap(); let sid = doc.root_element().attribute("sid").unwrap(); let newer = format!( "three" ); let older = format!( "two" ); let a = tokio::spawn(async move { http(addr, &newer).await }); tokio::time::sleep(std::time::Duration::from_millis(30)).await; let b = http(addr, &older).await; let a = a.await.unwrap(); assert!( !a.contains("terminate") && !b.contains("terminate"), "{a} {b}" ); assert_eq!(count.load(Ordering::SeqCst), 2); assert!(format!("{b}{a}").contains("three")); task.abort(); } #[tokio::test] async fn bosh_preserves_namespace_inherited_from_body() { let count = Arc::new(AtomicUsize::new(0)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let task = tokio::spawn(serve_router( listener, None, bosh_router(handler(count.clone())), )); let first = http( addr, "", ) .await; let doc = roxmltree::Document::parse(&first).unwrap(); let sid = doc.root_element().attribute("sid").unwrap(); let reply=http(addr,&format!("namespaced")).await; let parsed = roxmltree::Document::parse(&reply).unwrap(); assert!( parsed .descendants() .any(|n| n.tag_name().name() == "message" && n.tag_name().namespace() == Some("jabber:client")), "{reply}" ); assert_eq!(count.load(Ordering::SeqCst), 1); task.abort(); } async fn raw_http(addr: std::net::SocketAddr, request: &str) -> String { let mut stream = TcpStream::connect(addr).await.unwrap(); stream.write_all(request.as_bytes()).await.unwrap(); let mut result = String::new(); stream.read_to_string(&mut result).await.unwrap(); result } #[tokio::test] async fn transport_paths_and_cors_preflight_match_prosody() { use rust_xmpp::http_transport::with_origins; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let h = handler(Arc::new(AtomicUsize::new(0))); let app = with_origins( bosh_router(h.clone()).merge(websocket_router(h)), vec!["https://wthome.ru".into()], ); let task = tokio::spawn(serve_router(listener, None, app)); for path in [ "/http-bind", "/http-bind/", "/xmpp-websocket", "/xmpp-websocket/", ] { let result = raw_http(addr, &format!( "OPTIONS {path} HTTP/1.1\r\nHost: example.test\r\nOrigin: https://wthome.ru\r\nAccess-Control-Request-Method: {}\r\nAccess-Control-Request-Headers: Content-Type\r\nConnection: close\r\n\r\n", if path.starts_with("/http-bind") { "POST" } else { "GET" } )).await; assert!(result.starts_with("HTTP/1.1 200"), "{path}: {result}"); assert!( result.contains("access-control-allow-origin: https://wthome.ru"), "{result}" ); assert!( result .to_ascii_lowercase() .contains("access-control-allow-headers: content-type"), "{result}" ); } let body = ""; let result = raw_http(addr, &format!( "POST /http-bind/ HTTP/1.1\r\nHost: example.test\r\nOrigin: https://wthome.ru\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() )).await; assert!( result.starts_with("HTTP/1.1 200") && result.contains("mechanisms"), "{result}" ); task.abort(); } #[tokio::test] async fn bosh_retry_survives_aborted_http_request_without_replaying_stanzas() { use tokio::sync::Notify; let seen = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); let count = Arc::new(AtomicUsize::new(0)); let h: ConnectionHandler = { let seen = seen.clone(); let release = release.clone(); let count = count.clone(); Arc::new(move |mut stream| { let seen = seen.clone(); let release = release.clone(); let count = count.clone(); Box::pin(async move { let mut f = Framer::default(); while let Ok(xml) = f.next(&mut stream).await { if xml.starts_with("").await.unwrap(); } else { count.fetch_add(1, Ordering::SeqCst); seen.notify_one(); release.notified().await; stream.write_all(xml.as_bytes()).await.unwrap(); } } }) }) }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let task = tokio::spawn(serve_router(listener, None, bosh_router(h))); let initial = http( addr, "", ) .await; let doc = roxmltree::Document::parse(&initial).unwrap(); let sid = doc.root_element().attribute("sid").unwrap(); let body = format!( "once" ); let mut stream = TcpStream::connect(addr).await.unwrap(); stream.write_all(format!("POST /http-bind HTTP/1.1\r\nHost: example.test\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); tokio::time::timeout(std::time::Duration::from_secs(2), seen.notified()) .await .unwrap(); socket2::SockRef::from(&stream) .set_linger(Some(std::time::Duration::ZERO)) .unwrap(); drop(stream); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let retry_body = body.clone(); let retry = tokio::spawn(async move { http(addr, &retry_body).await }); tokio::time::sleep(std::time::Duration::from_millis(20)).await; release.notify_one(); let result = tokio::time::timeout(std::time::Duration::from_secs(2), retry) .await .expect("retry must receive the original response even after HTTP disconnect") .unwrap(); assert!( result.contains("once") && !result.contains("terminate"), "{result}" ); assert_eq!(count.load(Ordering::SeqCst), 1); assert_eq!(http(addr, &body).await, result); task.abort(); } #[tokio::test] async fn bosh_termination_waits_for_graceful_cleanup_before_reconnect() { let cleaned = Arc::new(AtomicUsize::new(0)); let h: ConnectionHandler = { let cleaned = cleaned.clone(); Arc::new(move |mut stream| { let cleaned = cleaned.clone(); Box::pin(async move { let mut framer = Framer::default(); while let Ok(xml) = framer.next(&mut stream).await { if xml.starts_with("").await.unwrap(); } else if xml == "" { stream.write_all(b"").await.unwrap(); // The stream response can arrive before backend cleanup ends. tokio::time::sleep(std::time::Duration::from_millis(20)).await; cleaned.fetch_add(1, Ordering::SeqCst); break; } } }) }) }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let task = tokio::spawn(serve_router(listener, None, bosh_router(h))); let initial = http( addr, "", ) .await; let doc = roxmltree::Document::parse(&initial).unwrap(); let sid = doc.root_element().attribute("sid").unwrap(); let end = http(addr, &format!("")).await; assert!(end.contains("type='terminate'"), "{end}"); assert_eq!( cleaned.load(Ordering::SeqCst), 1, "termination must finish graceful backend cleanup before acknowledging the client" ); let reconnected = http( addr, "", ) .await; assert!(reconnected.contains("sid="), "{reconnected}"); task.abort(); } async fn websocket_cleanup(explicit_close: bool) { let cleaned = Arc::new(tokio::sync::Notify::new()); let outcome = Arc::new(AtomicUsize::new(0)); let h: ConnectionHandler = { let cleaned = cleaned.clone(); let outcome = outcome.clone(); Arc::new(move |mut stream| { let cleaned = cleaned.clone(); let outcome = outcome.clone(); Box::pin(async move { let mut graceful = false; let mut f = Framer::default(); while let Ok(xml) = f.next(&mut stream).await { if xml.starts_with("").await.unwrap(); } else if xml == "" { graceful = true; break; } } // Simulate cleanup that yields after receiving close or EOF. tokio::time::sleep(std::time::Duration::from_millis(20)).await; outcome.store(if graceful { 1 } else { 2 }, Ordering::SeqCst); cleaned.notify_one(); }) }) }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let task = tokio::spawn(serve_router(listener, None, websocket_router(h))); let mut stream = TcpStream::connect(addr).await.unwrap(); stream.write_all(b"GET /xmpp-websocket HTTP/1.1\r\nHost: example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: xmpp\r\n\r\n").await.unwrap(); let mut header = Vec::new(); while !header.ends_with(b"\r\n\r\n") { header.push(stream.read_u8().await.unwrap()); } ws_send( &mut stream, "", ) .await; ws_read(&mut stream).await; ws_read(&mut stream).await; if explicit_close { ws_send( &mut stream, "", ) .await; // Follow the XMPP close immediately with a masked WebSocket close frame. stream.write_all(&[0x88, 0x80, 0, 0, 0, 0]).await.unwrap(); } drop(stream); tokio::time::timeout(std::time::Duration::from_secs(2), cleaned.notified()) .await .expect("transport must let the engine finish cleanup"); assert_eq!( outcome.load(Ordering::SeqCst), if explicit_close { 1 } else { 2 } ); task.abort(); } #[tokio::test] async fn websocket_close_allows_graceful_engine_cleanup() { websocket_cleanup(true).await; } #[tokio::test] async fn websocket_disconnect_delivers_eof_without_graceful_close() { websocket_cleanup(false).await; }