482 lines
15 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|