Files
MetaCrate/tests/compat/src/lib.rs
Chili Palmer adf5165033
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled
fix: harden OpenSim session readiness
2026-08-21 20:37:16 +02:00

482 lines
15 KiB
Rust

//! Shared deterministic fixtures used by translated compatibility tests.
pub mod rlv_support;
use std::collections::BTreeMap;
use std::future::Future;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::task::{Context, Poll, Wake, Waker};
use std::time::Duration;
const OPENSIM_LOGIN_OPTIONS: [&str; 6] = [
"avatar_picker_url",
"classified_fee",
"currency",
"destination_guide_url",
"profile-server-url",
"search",
];
static LIVE_GRID_ACCOUNT: Mutex<()> = Mutex::new(());
/// Runs one test future without choosing an async runtime for the library.
pub fn block_on<F: Future>(future: F) -> F::Output {
struct ThreadWake(std::thread::Thread);
impl Wake for ThreadWake {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(ThreadWake(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::park(),
}
}
}
/// Tokio runtime kept alive for the complete lifetime of a live-grid fixture.
///
/// Network login starts UDP, capability, timer, and cancellation tasks. Keeping
/// this multi-thread runtime in the fixture lets those tasks continue running
/// while a synchronous compatibility assertion observes grid state.
pub struct LiveTestRuntime {
runtime: tokio::runtime::Runtime,
_account: MutexGuard<'static, ()>,
}
impl LiveTestRuntime {
/// Creates a live-I/O runtime with all Tokio drivers enabled.
///
/// # Panics
///
/// Panics if worker threads or the runtime's I/O driver cannot be created.
#[must_use]
pub fn new() -> Self {
let account = LIVE_GRID_ACCOUNT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self {
runtime: tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("create live-grid Tokio runtime"),
_account: account,
}
}
/// Runs one live-grid future while retaining the runtime for background I/O.
pub fn block_on<F: Future>(&self, future: F) -> F::Output {
self.runtime.block_on(future)
}
}
impl Default for LiveTestRuntime {
fn default() -> Self {
Self::new()
}
}
/// Authenticates a test client against the exact `OpenSim` endpoint in `.env`.
///
/// The helper adds the `OpenSim` response options used by the dedicated smoke
/// program and retries only the server's explicit stale-session response. It
/// does not substitute a Second Life endpoint, region, or start location.
///
/// # Panics
///
/// Panics if parameters cannot be constructed or the dedicated account cannot
/// authenticate within 90 seconds.
pub fn login_live_grid(
runtime: &LiveTestRuntime,
client: &mut libremetaverse::GridClient,
channel: &str,
version: &str,
) -> libremetaverse::NetworkManager {
client.settings().timing().login_timeout = 30_000;
client.settings().timing().logout_timeout = 5_000;
// OpenSim sends inventory bootstrap and initial scene packets as part of
// login. These lazily constructed services must install their consumers
// before credentials cross the wire or that one-shot state is lost.
let _ = client.self_();
let _ = client.objects();
let _ = client.inventory();
let _ = client.assets();
let _ = client.appearance();
let _ = client.avatars();
let _ = client.grid();
let _ = client.estate();
let network = client.network();
let (first, last, password, login_url) = live_grid_credentials();
let deadline = std::time::Instant::now() + Duration::from_secs(90);
let mut start = "last";
loop {
let mut login = network
.default_login_params(
first.clone(),
last.clone(),
password.clone(),
channel.to_owned(),
version.to_owned(),
)
.expect("NetworkManager DefaultLoginParams");
login.uri.clone_from(&login_url);
start.clone_into(&mut login.start);
for option in OPENSIM_LOGIN_OPTIONS {
if !login.options.iter().any(|existing| existing == option) {
login.options.push(option.to_owned());
}
}
let logged_in = runtime
.block_on(network.login_with_login_params_cancellation_token(login, None))
.expect("NetworkManager LoginAsync");
if logged_in {
assert!(network.connected(), "client is not connected to the grid");
let simulator = network
.current_sim()
.expect("CurrentSim is null after successful OpenSim login");
client
.self_()
.complete_agent_movement(simulator)
.expect("complete OpenSim agent movement after login");
return network;
}
let message = network.login_message();
let message_lower = message.to_ascii_lowercase();
let stale_session = message_lower.contains("already logged in")
|| message_lower.contains("failed to verify user presence");
if start == "last"
&& (message_lower.contains("failed to verify user presence")
|| message_lower.contains("access denied to region"))
{
start = "home";
continue;
}
assert!(
stale_session && std::time::Instant::now() < deadline,
"OpenSim login failed: {message}"
);
std::thread::sleep(Duration::from_secs(5));
}
}
/// Performs the protocol logout while the live runtime is still active.
pub fn logout_live_grid(runtime: &LiveTestRuntime, client: &mut libremetaverse::GridClient) {
let network = client.network();
let _ = runtime.block_on(network.logout_with_cancellation_token(None));
let _ = client.dispose_with_method();
}
/// Loads the live-grid identity from the process environment or workspace `.env`.
///
/// # Panics
///
/// Panics when one of `GRID_USER`, `GRID_PASSWORD`, or `GRID_LOGIN_URL` is absent.
#[must_use]
pub fn live_grid_credentials() -> (String, String, String, String) {
fn credential(name: &str) -> String {
if let Ok(value) = std::env::var(name)
&& !value.trim().is_empty()
{
return value;
}
let dotenv = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(".env");
let contents = std::fs::read_to_string(dotenv).expect("read workspace .env");
contents
.lines()
.find_map(|line| {
let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
let (key, value) = line.split_once('=')?;
(key.trim() == name).then(|| value.trim().trim_matches(['\'', '"']).to_owned())
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| panic!("live test requires {name}"))
}
let full_name = credential("GRID_USER");
let password = credential("GRID_PASSWORD");
let login_url = credential("GRID_LOGIN_URL");
let mut names = full_name.split_whitespace();
let first = names.next().expect("live first name").to_owned();
let last = names.next().expect("live last name").to_owned();
(first, last, password, login_url)
}
/// Asserts equality within the exact absolute tolerance carried by an upstream test.
///
/// # Panics
///
/// Panics when the tolerance is invalid or the values differ beyond it.
#[track_caller]
pub fn assert_close(actual: f64, expected: f64, tolerance: f64) {
assert!(
tolerance.is_finite() && tolerance >= 0.0,
"tolerance must be finite and non-negative"
);
assert!(
(actual - expected).abs() <= tolerance,
"expected {expected:?} +/- {tolerance:?}, got {actual:?}"
);
}
/// Asserts byte equality and reports the first differing offset.
///
/// # Panics
///
/// Panics when the byte slices differ.
#[track_caller]
pub fn assert_bytes_eq(actual: &[u8], expected: &[u8]) {
if actual == expected {
return;
}
let offset = actual
.iter()
.zip(expected)
.position(|(actual, expected)| actual != expected)
.unwrap_or_else(|| actual.len().min(expected.len()));
panic!(
"byte fixtures differ at offset {offset}: expected {} bytes, got {} bytes",
expected.len(),
actual.len()
);
}
/// Decodes a whitespace-separated or contiguous hexadecimal byte fixture.
///
/// # Errors
///
/// Returns an error for an odd digit count or a non-hexadecimal digit.
pub fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
let digits: Vec<_> = input
.bytes()
.filter(|byte| !byte.is_ascii_whitespace())
.collect();
if digits.len() % 2 != 0 {
return Err("hex fixture has an odd number of digits".into());
}
digits
.chunks_exact(2)
.enumerate()
.map(|(index, pair)| {
let high = hex_digit(pair[0]);
let low = hex_digit(pair[1]);
high.zip(low)
.map(|(high, low)| high << 4 | low)
.ok_or_else(|| format!("invalid hex byte at digit {}", index * 2))
})
.collect()
}
fn hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
/// Resolves test data below a fixture root without permitting path traversal.
///
/// # Errors
///
/// Returns [`io::ErrorKind::InvalidInput`] for absolute or parent-relative paths.
pub fn test_data_path(root: &Path, relative: &Path) -> io::Result<PathBuf> {
if relative.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"test data path must stay below its fixture root",
));
}
Ok(root.join(relative))
}
/// Loads a test-data file through [`test_data_path`].
///
/// # Errors
///
/// Returns path validation and file-read errors.
pub fn load_test_data(root: &Path, relative: &Path) -> io::Result<Vec<u8>> {
std::fs::read(test_data_path(root, relative)?)
}
/// A cloneable clock advanced only by the test.
#[derive(Clone, Debug, Default)]
pub struct ManualClock(Arc<Mutex<Duration>>);
impl ManualClock {
/// Returns the deterministic elapsed time.
#[must_use]
pub fn now(&self) -> Duration {
*self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Advances the clock and returns its new value.
///
/// # Panics
///
/// Panics if the resulting duration exceeds [`Duration::MAX`].
#[must_use]
pub fn advance(&self, duration: Duration) -> Duration {
let mut now = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*now += duration;
*now
}
}
/// A recorded deterministic HTTP-like request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecordedRequest {
pub method: String,
pub uri: String,
pub body: Vec<u8>,
}
/// A canned deterministic HTTP-like response.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FakeResponse {
pub status: u16,
pub content_type: String,
pub body: Vec<u8>,
}
/// An in-memory exact-URI/path fake with ordered request capture.
#[derive(Debug, Default)]
pub struct FakeNetwork {
exact: Mutex<BTreeMap<(String, String), FakeResponse>>,
paths: Mutex<BTreeMap<(String, String), FakeResponse>>,
requests: Mutex<Vec<RecordedRequest>>,
}
impl FakeNetwork {
/// Adds an exact method/URI response.
pub fn add_response(&self, method: &str, uri: &str, response: FakeResponse) {
self.exact
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert((method.to_owned(), uri.to_owned()), response);
}
/// Adds a response matched after removing the request query string.
pub fn add_path_response(&self, method: &str, uri_without_query: &str, response: FakeResponse) {
self.paths
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert((method.to_owned(), uri_without_query.to_owned()), response);
}
/// Records a request and returns its configured response, or a deterministic 404.
pub fn send(&self, method: &str, uri: &str, body: impl Into<Vec<u8>>) -> FakeResponse {
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(RecordedRequest {
method: method.to_owned(),
uri: uri.to_owned(),
body: body.into(),
});
let key = (method.to_owned(), uri.to_owned());
if let Some(response) = self
.exact
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
{
return response.clone();
}
let path_key = (
method.to_owned(),
uri.split('?').next().unwrap_or(uri).to_owned(),
);
self.paths
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&path_key)
.cloned()
.unwrap_or_else(|| FakeResponse {
status: 404,
content_type: "application/octet-stream".into(),
body: Vec::new(),
})
}
/// Returns all captured requests in send order.
#[must_use]
pub fn requests(&self) -> Vec<RecordedRequest> {
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_harness_covers_clock_network_and_bytes() {
assert_eq!(block_on(std::future::ready(7)), 7);
assert_close(1.001, 1.0, 0.01);
assert_bytes_eq(&decode_hex("00 ff 2A").unwrap(), &[0, 255, 42]);
assert!(decode_hex("é").is_err());
let clock = ManualClock::default();
assert_eq!(
clock.advance(Duration::from_millis(25)),
Duration::from_millis(25)
);
assert_eq!(clock.now(), Duration::from_millis(25));
let network = FakeNetwork::default();
network.add_path_response(
"GET",
"https://example.test/cap",
FakeResponse {
status: 200,
content_type: "application/llsd+xml".into(),
body: b"fixture".to_vec(),
},
);
assert_eq!(
network.send("GET", "https://example.test/cap?tid=1", []),
FakeResponse {
status: 200,
content_type: "application/llsd+xml".into(),
body: b"fixture".to_vec(),
}
);
assert_eq!(network.requests()[0].uri, "https://example.test/cap?tid=1");
}
#[test]
fn test_data_rejects_parent_traversal() {
assert_eq!(
test_data_path(Path::new("fixtures"), Path::new("../secret"))
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
}