319 lines
9.3 KiB
Rust
319 lines
9.3 KiB
Rust
//! Shared deterministic fixtures used by translated compatibility tests.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::future::Future;
|
|
use std::io;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::task::{Context, Poll, Wake, Waker};
|
|
use std::time::Duration;
|
|
|
|
/// Fails a not-yet-translated upstream test while retaining parity metadata.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Always, with the retained upstream test identity.
|
|
#[track_caller]
|
|
pub fn pending(
|
|
case_id: &str,
|
|
source: &str,
|
|
line: u64,
|
|
test: &str,
|
|
attribute: &str,
|
|
body_sha256: &str,
|
|
) -> ! {
|
|
panic!(
|
|
"pending LibreMetaverse parity case {case_id}: {test} ({attribute}) from {source}:{line}; C# body sha256={body_sha256}"
|
|
)
|
|
}
|
|
|
|
/// 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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
);
|
|
}
|
|
}
|