Implement native login and session initialization (#56)
All checks were successful
Native code generation / deterministic (push) Successful in 11m53s
Imaging and meshing gate / native (push) Successful in 4m3s
JPEG 2000 feature / linux (push) Successful in 2m33s
Native Rust workspace compile / compile (push) Successful in 4m3s
Skia feature / linux (push) Successful in 31m24s

This commit is contained in:
2026-08-09 18:18:28 +00:00
parent aa269f1785
commit 3951bdb809
15 changed files with 3992 additions and 1355 deletions

View File

@@ -7,10 +7,16 @@ use libremetaverse::packets::{
RegionHandshakePacket, RegionHandshakeReplyPacket, StartPingCheckPacket, UseCircuitCodePacket,
};
use libremetaverse::{
CapsEventDictionary, CapsEventQueueCallback, GridClient, Helpers, NetworkManager,
NetworkManagerDisconnectType, PacketEventDictionary, Simulator,
CapsEventDictionary, CapsEventQueueCallback, GridClient, Helpers, HttpCapsClient, LoginState,
LoginStatus, NetworkManager, NetworkManagerDisconnectType, NetworkManagerLoginResponseCallback,
PacketEventDictionary, Simulator,
};
use libremetaverse_types::compat::{EventHandler, Uri};
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{
CancellationTokenSource, EventHandler, HttpMessageHandler, HttpRequest, HttpResponse, Uri,
};
use libremetaverse_types::{UUID, Utils};
use std::collections::{BTreeMap, HashMap};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
@@ -204,6 +210,35 @@ fn synchronous_specific_dispatch_suppresses_default_async_chain_like_reference()
);
}
#[test]
fn abort_and_callback_unregistration_preserve_reference_noop_behavior() {
let client = GridClient::new().unwrap();
let manager = client.network();
let progress = Arc::new(Mutex::new(Vec::new()));
let observed = Arc::clone(&progress);
let _guard = manager.subscribe_login_progress(Arc::new(move |args| {
observed.lock().unwrap().push(args);
}));
let callback = NetworkManagerLoginResponseCallback::from_handler(|_, _, _, _, _| {});
manager
.register_login_response_callback_with_login_response_callback(callback.clone())
.unwrap();
manager
.unregister_login_response_callback(callback.clone())
.unwrap();
manager
.unregister_login_response_callback(callback)
.unwrap();
manager.abort_login().unwrap();
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_message(), "Abort Requested");
let progress = progress.lock().unwrap();
assert_eq!(progress.len(), 1);
assert_eq!(progress[0].status(), LoginStatus::Failed);
assert_eq!(progress[0].message(), "Abort Requested");
}
struct TestCapsMessage;
impl IMessage for TestCapsMessage {}
@@ -319,6 +354,7 @@ fn sim_connecting_can_cancel_without_leaking_collection_state() {
enum ServerCommand {
Ping,
Disable,
ExpectEconomy,
Stop,
}
@@ -327,6 +363,7 @@ enum ServerReport {
Ack(u32),
Circuit(u32),
HandshakeReply(u32),
Economy,
Ping(u8),
}
@@ -472,6 +509,13 @@ fn spawn_fake_server() -> FakeServer {
bytes[0] &= !(Helpers::MSG_RELIABLE | Helpers::MSG_ZEROCODED);
socket.send_to(&bytes, client_endpoint).unwrap();
}
ServerCommand::ExpectEconomy => loop {
let (length, _) = socket.recv_from(&mut buffer).expect("EconomyDataRequest");
if packet_type(&buffer[..length]) == Some(PacketType::EconomyDataRequest) {
report_sender.send(ServerReport::Economy).unwrap();
break;
}
},
ServerCommand::Stop => break,
}
}
@@ -620,7 +664,6 @@ fn fake_server_drives_circuit_handshake_ping_disable_and_disconnect_reasons() {
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::HandshakeReply(0x1 | 0x2 | 0x4)
);
assert_reuse_preserves_connected_override(&mut manager, &server, &sim);
assert_ping_exchange(&server, &packet_receiver);
@@ -897,3 +940,396 @@ fn handshake_timeout_preserves_reference_connect_result_and_shutdown_tears_down_
assert_eq!(sim_disconnects.load(Ordering::Relaxed), 1);
drop(guard);
}
fn login_http_response(body: OSD) -> HttpResponse {
let body = OSDParser::serialize_llsd_xml_bytes(body).expect("serialize LLSD");
HttpResponse {
status_code: 200,
headers: BTreeMap::from([("Content-Length".into(), body.len().to_string())]),
content_type: Some("application/llsd+xml".into()),
body,
}
}
fn successful_login_response(endpoint: SocketAddr, seed: &str) -> OSD {
OSD::Map(HashMap::from([
("login".into(), OSD::Boolean(true)),
("message".into(), OSD::String("Welcome".into())),
(
"agent_id".into(),
OSD::UUID(
UUID::new_with_string("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into()).unwrap(),
),
),
(
"session_id".into(),
OSD::UUID(
UUID::new_with_string("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".into()).unwrap(),
),
),
(
"secure_session_id".into(),
OSD::UUID(
UUID::new_with_string("cccccccc-cccc-cccc-cccc-cccccccccccc".into()).unwrap(),
),
),
("circuit_code".into(), OSD::Integer(0x1234)),
("region_x".into(), OSD::Integer(1000)),
("region_y".into(), OSD::Integer(2000)),
("region_size_x".into(), OSD::Integer(256)),
("region_size_y".into(), OSD::Integer(256)),
("sim_ip".into(), OSD::String(endpoint.ip().to_string())),
("sim_port".into(), OSD::Integer(i32::from(endpoint.port()))),
("seed_capability".into(), OSD::String(seed.into())),
(
"account_level_benefits".into(),
OSD::Map(HashMap::from([(
"group_membership_limit".into(),
OSD::Integer(70),
)])),
),
]))
}
#[tokio::test(flavor = "current_thread")]
async fn login_hashes_credentials_parses_response_and_hands_off_session_to_udp_and_caps() {
let server = spawn_fake_server();
let requests = Arc::new(Mutex::new(Vec::<HttpRequest>::new()));
let recorded = Arc::clone(&requests);
let endpoint = server.endpoint;
let handler = HttpMessageHandler::new(move |request, _| {
let recorded = Arc::clone(&recorded);
async move {
let response = if request.uri.0.contains("/login") {
login_http_response(successful_login_response(
endpoint,
"http://caps.test/seed?token=secret",
))
} else {
login_http_response(OSD::Map(HashMap::new()))
};
recorded.lock().unwrap().push(request);
response
}
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 2_000;
client.settings().connection_mut().mfa_enabled = true;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let callback_calls = Arc::new(AtomicUsize::new(0));
let observed_calls = Arc::clone(&callback_calls);
let callback = NetworkManagerLoginResponseCallback::from_handler(
move |success, redirect, message, reason, response| {
assert!(success);
assert!(!redirect);
assert_eq!(message, "Welcome");
assert!(reason.is_empty());
assert!(response.is_some_and(|value| value.success()));
observed_calls.fetch_add(1, Ordering::Relaxed);
},
);
manager
.register_login_response_callback_with_login_response_callback_string_array(
callback,
Some(vec!["custom-login-option".into()]),
)
.unwrap();
let progress = Arc::new(Mutex::new(Vec::new()));
let observed = Arc::clone(&progress);
let _progress_guard = manager.subscribe_login_progress(Arc::new(move |args| {
observed.lock().unwrap().push(args.status());
}));
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
params.token = "123456".into();
params.mfa_hash = "saved-mfa-hash".into();
let response = manager
.login_with_response_with_login_params_cancellation_token(params, None)
.await
.unwrap()
.expect("successful response");
assert_eq!(response.login(), LoginState::True);
assert_eq!(manager.login_status_code(), LoginStatus::Success);
assert_eq!(manager.login_message(), "Welcome");
assert_eq!(manager.max_agent_groups(), 70);
assert_eq!(callback_calls.load(Ordering::Relaxed), 1);
assert!(manager.connected());
assert!(manager.current_sim().is_some());
assert_eq!(
manager.login_seed_capability(),
Some(Uri("http://caps.test/seed?token=secret".into()))
);
assert_eq!(
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::Circuit(0x1234)
);
assert_eq!(
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::HandshakeReply(0x1 | 0x2 | 0x4)
);
server.commands.send(ServerCommand::ExpectEconomy).unwrap();
assert_eq!(
server.reports.recv_timeout(Duration::from_secs(1)).unwrap(),
ServerReport::Economy
);
let login_request = requests
.lock()
.unwrap()
.iter()
.find(|request| request.uri.0.contains("/login"))
.cloned()
.expect("login request");
let request = OSDParser::deserialize_with_bytes(login_request.body).unwrap();
let OSD::Map(request) = request else {
panic!("login request must be an LLSD map");
};
assert_eq!(
request["passwd"].as_string().unwrap(),
Utils::md5_with_string("plain-secret".into()).unwrap()
);
assert_eq!(request["token"].as_string().unwrap(), "123456");
assert_eq!(request["mfa_hash"].as_string().unwrap(), "saved-mfa-hash");
assert_eq!(request["start"].as_string().unwrap(), "last");
assert_eq!(request["channel"].as_string().unwrap(), "MetaCrate");
assert_eq!(request["version"].as_string().unwrap(), "1.0");
assert_eq!(
request["platform"].as_string().unwrap(),
NetworkManager::get_platform().unwrap()
);
assert!(!request["platform_version"].as_string().unwrap().is_empty());
assert_eq!(
request["mac"].as_string().unwrap(),
request["id0"].as_string().unwrap()
);
let OSD::Array(options) = &request["options"] else {
panic!("login options must be an LLSD array");
};
assert!(
options
.iter()
.any(|option| option.as_string().as_deref() == Ok("custom-login-option"))
);
assert_eq!(
*progress.lock().unwrap(),
[
LoginStatus::ConnectingToLogin,
LoginStatus::ReadingResponse,
LoginStatus::ConnectingToSim,
LoginStatus::Success,
]
);
manager
.shutdown_with_disconnect_type(NetworkManagerDisconnectType::ClientInitiated)
.unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn redirect_reuses_hashed_credentials_and_failure_is_typed_without_partial_session() {
let requests = Arc::new(Mutex::new(Vec::<HttpRequest>::new()));
let recorded = Arc::clone(&requests);
let calls = Arc::new(AtomicUsize::new(0));
let handler_calls = Arc::clone(&calls);
let handler = HttpMessageHandler::new(move |request, _| {
let recorded = Arc::clone(&recorded);
let index = handler_calls.fetch_add(1, Ordering::Relaxed);
async move {
recorded.lock().unwrap().push(request);
if index == 0 {
login_http_response(OSD::Map(HashMap::from([
("login".into(), OSD::String("indeterminate".into())),
(
"next_url".into(),
OSD::String("http://login.test/redirected".into()),
),
("next_duration".into(), OSD::Integer(0)),
("message".into(), OSD::String("Redirecting".into())),
])))
} else {
login_http_response(OSD::Map(HashMap::from([
("login".into(), OSD::Boolean(false)),
("reason".into(), OSD::String("key".into())),
("message".into(), OSD::String("Denied".into())),
])))
}
}
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 1_000;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let callback_calls = Arc::new(AtomicUsize::new(0));
let observed_calls = Arc::clone(&callback_calls);
manager
.register_login_response_callback_with_login_response_callback(
NetworkManagerLoginResponseCallback::from_handler(move |_, _, _, _, _| {
observed_calls.fetch_add(1, Ordering::Relaxed);
}),
)
.unwrap();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
assert!(
!manager
.login_with_login_params_cancellation_token(params, None)
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_error_key(), "key");
assert_eq!(manager.login_message(), "Denied");
assert!(!manager.connected());
assert!(manager.current_sim().is_none());
assert_eq!(
callback_calls.load(Ordering::Relaxed),
0,
"the C# callback only runs for a successful login"
);
let passwords: Vec<_> = requests
.lock()
.unwrap()
.iter()
.map(|request| {
let OSD::Map(map) = OSDParser::deserialize_with_bytes(request.body.clone()).unwrap()
else {
panic!("request must be map");
};
map["passwd"].as_string().unwrap()
})
.collect();
assert_eq!(passwords.len(), 2);
assert_eq!(passwords[0], passwords[1]);
assert_ne!(passwords[0], "plain-secret");
}
#[tokio::test(flavor = "current_thread")]
async fn successful_authentication_with_invalid_simulator_clears_partial_session() {
let handler = HttpMessageHandler::new(|_, _| async move {
let mut response = successful_login_response(
"127.0.0.1:1".parse().unwrap(),
"http://caps.test/seed?token=must-not-survive",
);
let OSD::Map(ref mut fields) = response else {
unreachable!();
};
fields.insert("sim_port".into(), OSD::Integer(0));
login_http_response(response)
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 1_000;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
assert!(
!manager
.login_with_login_params_cancellation_token(params, None)
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert!(manager.current_sim().is_none());
assert!(manager.simulators.is_empty());
assert!(!manager.connected());
assert!(manager.login_seed_capability().is_none());
let response = manager.login_response_data.as_ref().unwrap();
assert_eq!(response.session_id(), UUID::zero());
assert_eq!(response.secure_session_id(), UUID::zero());
assert!(response.seed_capability().is_empty());
assert!(response.account_level_benefits().is_none());
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn login_timeout_is_typed_and_leaves_no_partial_session() {
let handler =
HttpMessageHandler::new(|_, _| async move { std::future::pending::<HttpResponse>().await });
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 25;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
assert!(
!manager
.login_with_login_params_cancellation_token(params, None)
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_error_key(), "timeout");
assert_eq!(manager.login_message(), "Login timed out");
assert!(manager.current_sim().is_none());
assert!(manager.login_seed_capability().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn canceled_login_finishes_failed_without_retaining_simulator_work() {
let handler = HttpMessageHandler::new(|_, cancellation| async move {
cancellation.cancelled().await;
login_http_response(OSD::Map(HashMap::new()))
});
let mut client = GridClient::new().unwrap();
client.settings().timing().login_timeout = 1_000;
client.set_http_caps_client(HttpCapsClient::new(handler).unwrap());
let manager = client.network();
let mut params = manager
.default_login_params(
"Ada".into(),
"Lovelace".into(),
"plain-secret".into(),
"MetaCrate".into(),
"1.0".into(),
)
.unwrap();
params.uri = "http://login.test/login".into();
let cancellation = CancellationTokenSource::new();
cancellation.cancel();
assert!(
!manager
.login_with_login_params_cancellation_token(params, Some(cancellation.token()))
.await
.unwrap()
);
assert_eq!(manager.login_status_code(), LoginStatus::Failed);
assert_eq!(manager.login_error_key(), "canceled");
assert!(!manager.connected());
assert!(manager.simulators.is_empty());
}