//! 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(future: F) -> F::Output { struct ThreadWake(std::thread::Thread); impl Wake for ThreadWake { fn wake(self: Arc) { 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(&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, 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 { 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 { 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> { std::fs::read(test_data_path(root, relative)?) } /// A cloneable clock advanced only by the test. #[derive(Clone, Debug, Default)] pub struct ManualClock(Arc>); 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, } /// A canned deterministic HTTP-like response. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FakeResponse { pub status: u16, pub content_type: String, pub body: Vec, } /// An in-memory exact-URI/path fake with ordered request capture. #[derive(Debug, Default)] pub struct FakeNetwork { exact: Mutex>, paths: Mutex>, requests: Mutex>, } 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>) -> 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 { 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 ); } }