mod base64;
mod scenario;
mod server;
use std::collections::HashMap;
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use server::Drip;
pub const SCENARIOS: &[&str] = &[
"atomic",
"partial-put",
"drop-connection",
"freeze-upload",
"sizeless",
"slow",
"foreign-marker",
"markerless",
"auth-401",
"doc-drift",
"flaky",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scenario {
Atomic,
PartialPut { first_attempt_bytes: usize },
DropConnection,
FreezeUpload,
Sizeless,
Slow {
chunk_delay_ms: u64,
chunk_size: usize,
},
ForeignMarker,
Markerless,
Auth401 { user: String, pass: String },
DocDrift,
Flaky { first_failures: u32 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Status(u16),
Reset,
PartialRead { bytes: usize },
Stalled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReqLog {
pub method: String,
pub path: String,
pub outcome: Outcome,
}
#[derive(Debug)]
pub(crate) struct Shared {
pub(crate) scenario: Scenario,
pub(crate) store: Mutex<HashMap<String, Vec<u8>>>,
pub(crate) log: Mutex<Vec<ReqLog>>,
pub(crate) first_request: Mutex<HashMap<String, u32>>,
pub(crate) first_put: Mutex<HashMap<String, u32>>,
pub(crate) drift: AtomicBool,
pub(crate) auth_b64: Option<String>,
pub(crate) drip: Option<Drip>,
pub(crate) sizeless: bool,
pub(crate) partial_first_put: Option<usize>,
pub(crate) flaky_first: Option<u32>,
}
#[derive(Debug)]
pub struct MockNexus {
shared: Arc<Shared>,
addr: SocketAddr,
stop: Arc<AtomicBool>,
}
impl MockNexus {
pub fn start(scenario: Scenario) -> std::io::Result<Self> {
Self::start_on(scenario, SocketAddr::from(([127, 0, 0, 1], 0)))
}
pub fn start_on(scenario: Scenario, addr: SocketAddr) -> std::io::Result<Self> {
let listener = TcpListener::bind(addr)?;
let bound = listener.local_addr()?;
let auth_b64 = match &scenario {
Scenario::Auth401 { user, pass } => {
Some(base64::encode(format!("{user}:{pass}").as_bytes()))
}
_ => None,
};
let drip = match &scenario {
Scenario::Slow {
chunk_delay_ms,
chunk_size,
} => Some(Drip {
delay_ms: *chunk_delay_ms,
chunk_size: *chunk_size,
}),
_ => None,
};
let sizeless = matches!(&scenario, Scenario::Sizeless);
let (partial_first_put, flaky_first) = match &scenario {
Scenario::PartialPut {
first_attempt_bytes,
} => (Some(*first_attempt_bytes), None),
Scenario::Flaky { first_failures } => (None, Some(*first_failures)),
_ => (None, None),
};
let shared = Arc::new(Shared {
scenario,
store: Mutex::new(HashMap::new()),
log: Mutex::new(Vec::new()),
first_request: Mutex::new(HashMap::new()),
first_put: Mutex::new(HashMap::new()),
drift: AtomicBool::new(false),
auth_b64,
drip,
sizeless,
partial_first_put,
flaky_first,
});
let stop = Arc::new(AtomicBool::new(false));
let worker_shared = Arc::clone(&shared);
let worker_stop = Arc::clone(&stop);
std::thread::spawn(move || accept_loop(listener, worker_shared, worker_stop));
Ok(Self {
shared,
addr: bound,
stop,
})
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub fn base_url(&self) -> String {
format!("http://{}/", self.addr)
}
pub fn store_get(&self, path: &str) -> Option<Vec<u8>> {
lock(&self.shared.store).get(path).cloned()
}
pub fn insert(&self, path: &str, bytes: &[u8]) {
lock(&self.shared.store).insert(path.to_owned(), bytes.to_vec());
}
pub fn requests(&self) -> Vec<ReqLog> {
lock(&self.shared.log).clone()
}
pub fn put_count(&self, path: &str) -> usize {
self.requests()
.iter()
.filter(|req| {
req.method == "PUT"
&& req.path == path
&& matches!(req.outcome, Outcome::Status(200..=300))
})
.count()
}
pub fn enable_drift(&self) {
self.shared.drift.store(true, Ordering::Relaxed);
}
pub fn disable_drift(&self) {
self.shared.drift.store(false, Ordering::Relaxed);
}
}
impl Drop for MockNexus {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
let _ = TcpStream::connect(self.addr);
}
}
fn accept_loop(listener: TcpListener, shared: Arc<Shared>, stop: Arc<AtomicBool>) {
loop {
let conn = match listener.accept() {
Ok((conn, _)) => conn,
Err(_) => break,
};
if stop.load(Ordering::Relaxed) {
break;
}
let conn_shared = Arc::clone(&shared);
std::thread::spawn(move || scenario::serve(&conn_shared, conn));
}
}
pub(crate) fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
use super::{MockNexus, Outcome, Scenario};
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpStream};
type Response = (u16, Vec<(String, String)>, Vec<u8>);
fn send(addr: SocketAddr, request: &[u8]) -> io::Result<TcpStream> {
let mut stream = TcpStream::connect(addr)?;
stream.write_all(request)?;
Ok(stream)
}
fn request_bytes(method: &str, path: &str, body: &[u8], extra: &[(&str, &str)]) -> Vec<u8> {
let mut head = format!("{method} {path} HTTP/1.1\r\nHost: mock\r\n");
if !body.is_empty() || method == "PUT" {
head.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
for (name, value) in extra {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("\r\n");
let mut bytes = head.into_bytes();
bytes.extend_from_slice(body);
bytes
}
fn read_response(stream: &mut TcpStream) -> io::Result<Response> {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let head_end = loop {
if let Some(i) = find(&buf, b"\r\n\r\n") {
break i;
}
let n = stream.read(&mut chunk)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated response",
));
}
buf.extend_from_slice(&chunk[..n]);
};
let head = String::from_utf8_lossy(&buf[..head_end]);
let mut lines = head.split("\r\n");
let status_line = lines.next().unwrap_or_default();
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad status line"))?;
let headers: Vec<(String, String)> = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned()))
.collect();
let content_length = headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.parse::<usize>().ok())
.unwrap_or(0);
let mut body = buf[head_end + 4..].to_vec();
while body.len() < content_length {
let n = stream.read(&mut chunk)?;
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
body.truncate(content_length);
Ok((status, headers, body))
}
fn exchange(
addr: SocketAddr,
method: &str,
path: &str,
body: &[u8],
extra: &[(&str, &str)],
) -> io::Result<Response> {
let mut stream = send(addr, &request_bytes(method, path, body, extra))?;
read_response(&mut stream)
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
#[test]
fn atomic_roundtrip_put_get_head() {
let server = MockNexus::start(Scenario::Atomic).unwrap();
assert_eq!(server.base_url(), format!("http://{}/", server.addr()));
let (status, headers, body) =
exchange(server.addr(), "PUT", "/1.14.0/sample.zip", b"PAYLOAD", &[]).unwrap();
assert_eq!(status, 201);
assert_eq!(header_value(&headers, "connection"), Some("close"));
assert!(body.is_empty());
let (status, headers, body) =
exchange(server.addr(), "GET", "/1.14.0/sample.zip", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"PAYLOAD");
assert_eq!(header_value(&headers, "content-length"), Some("7"));
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/sample.zip?x=1", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"PAYLOAD");
let (status, headers, body) =
exchange(server.addr(), "HEAD", "/1.14.0/sample.zip", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(header_value(&headers, "content-length"), Some("7"));
assert!(body.is_empty());
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/missing", b"", &[]).unwrap();
assert_eq!(status, 404);
assert_eq!(body, b"not found\n");
assert_eq!(
server.store_get("1.14.0/sample.zip").as_deref(),
Some(b"PAYLOAD".as_slice())
);
assert_eq!(server.put_count("1.14.0/sample.zip"), 1);
}
#[test]
fn partial_put_first_attempt_cut_then_stores() {
let server = MockNexus::start(Scenario::PartialPut {
first_attempt_bytes: 4,
})
.unwrap();
let mut stream = send(
server.addr(),
&request_bytes("PUT", "/1.14.0/big.zip", b"0123456789", &[]),
)
.unwrap();
let mut buf = [0u8; 64];
let probe = stream.read(&mut buf);
assert!(
matches!(probe, Err(_) | Ok(0)),
"expected closed connection, got {probe:?}"
);
assert_eq!(server.store_get("1.14.0/big.zip"), None);
let (status, _, body) =
exchange(server.addr(), "PUT", "/1.14.0/big.zip", b"0123456789", &[]).unwrap();
assert_eq!(status, 201);
assert!(body.is_empty());
assert_eq!(
server.store_get("1.14.0/big.zip").as_deref(),
Some(b"0123456789".as_slice())
);
let log = server.requests();
assert_eq!(log[0].method, "PUT");
assert_eq!(log[0].path, "1.14.0/big.zip");
assert_eq!(log[0].outcome, Outcome::PartialRead { bytes: 4 });
assert_eq!(log[1].outcome, Outcome::Status(201));
assert_eq!(server.put_count("1.14.0/big.zip"), 1);
}
#[test]
fn drop_connection_resets_first_request_per_path() {
let server = MockNexus::start(Scenario::DropConnection).unwrap();
server.insert("1.14.0/version.json", b"{}");
let mut stream = send(
server.addr(),
&request_bytes("GET", "/1.14.0/version.json", b"", &[]),
)
.unwrap();
let mut buf = [0u8; 64];
let probe = stream.read(&mut buf);
assert!(
matches!(probe, Err(_) | Ok(0)),
"expected reset, got {probe:?}"
);
assert_eq!(server.requests()[0].outcome, Outcome::Reset);
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/version.json", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"{}");
assert_eq!(server.requests().len(), 2);
}
#[test]
fn flaky_serves_503_for_first_k_requests() {
let server = MockNexus::start(Scenario::Flaky { first_failures: 2 }).unwrap();
server.insert("v1/a.bin", b"X");
for _ in 0..2 {
let (status, _, body) = exchange(server.addr(), "GET", "/v1/a.bin", b"", &[]).unwrap();
assert_eq!(status, 503);
assert_eq!(body, b"flaky\n");
}
let (status, _, body) = exchange(server.addr(), "GET", "/v1/a.bin", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"X");
let outcomes: Vec<Outcome> = server
.requests()
.into_iter()
.map(|req| req.outcome)
.collect();
assert_eq!(
outcomes,
vec![
Outcome::Status(503),
Outcome::Status(503),
Outcome::Status(200)
]
);
}
#[test]
fn foreign_marker_zeroes_stored_digest() {
let server = MockNexus::start(Scenario::ForeignMarker).unwrap();
let marker = format!("{} sample.zip\n", "a".repeat(64));
let (status, _, _) = exchange(
server.addr(),
"PUT",
"/1.14.0/sample.zip.sha256",
marker.as_bytes(),
&[],
)
.unwrap();
assert_eq!(status, 201);
assert_eq!(
server.store_get("1.14.0/sample.zip.sha256").unwrap(),
format!("{} sample.zip\n", "0".repeat(64)).into_bytes()
);
server.insert("1.14.0/real.zip.sha256", marker.as_bytes());
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/real.zip.sha256", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, marker.as_bytes());
}
#[test]
fn markerless_loses_marker_keeps_bytes() {
let server = MockNexus::start(Scenario::Markerless).unwrap();
let marker = format!("{} sample.zip\n", "b".repeat(64));
let (status, _, _) = exchange(
server.addr(),
"PUT",
"/1.14.0/sample.zip.sha256",
marker.as_bytes(),
&[],
)
.unwrap();
assert_eq!(status, 201);
assert_eq!(server.store_get("1.14.0/sample.zip.sha256"), None);
let (status, _, _) =
exchange(server.addr(), "PUT", "/1.14.0/sample.zip", b"BYTES", &[]).unwrap();
assert_eq!(status, 201);
assert_eq!(
server.store_get("1.14.0/sample.zip").as_deref(),
Some(b"BYTES".as_slice())
);
assert_eq!(server.put_count("1.14.0/sample.zip.sha256"), 1);
assert_eq!(server.put_count("1.14.0/sample.zip"), 1);
}
#[test]
fn doc_drift_synthesizes_between_enable_and_disable() {
let server = MockNexus::start(Scenario::DocDrift).unwrap();
let stored = br#"{"schema_version":1,"version":"1.14.0","artifacts":["real.zip"]}"#;
server.insert("1.14.0/version.json", stored);
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/version.json", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, stored.to_vec());
server.enable_drift();
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/version.json", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(
body,
br#"{"schema_version":1,"version":"1.14.0","artifacts":["ghost.zip"]}"#.to_vec()
);
server.disable_drift();
let (status, _, body) =
exchange(server.addr(), "GET", "/1.14.0/version.json", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(body, stored.to_vec());
}
#[test]
fn auth_401_gate() {
let server = MockNexus::start(Scenario::Auth401 {
user: "ci".to_owned(),
pass: "secret".to_owned(),
})
.unwrap();
server.insert("v1/a", b"X");
let (status, headers, body) = exchange(server.addr(), "GET", "/v1/a", b"", &[]).unwrap();
assert_eq!(status, 401);
assert_eq!(body, b"auth required\n");
assert_eq!(
header_value(&headers, "www-authenticate"),
Some("Basic realm=\"nexus\"")
);
let (status, _, _) = exchange(
server.addr(),
"GET",
"/v1/a",
b"",
&[("Authorization", "Basic Y2k6d3Jvbmc=")], )
.unwrap();
assert_eq!(status, 401);
let (status, _, body) = exchange(
server.addr(),
"GET",
"/v1/a",
b"",
&[("Authorization", "Basic Y2k6c2VjcmV0")], )
.unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"X");
}
#[test]
fn slow_get_roundtrip() {
let server = MockNexus::start(Scenario::Slow {
chunk_delay_ms: 1,
chunk_size: 3,
})
.unwrap();
let body: Vec<u8> = (0..20u32).map(|i| b'a' + (i % 26) as u8).collect();
server.insert("v/slow.bin", &body);
let (status, headers, got) =
exchange(server.addr(), "GET", "/v/slow.bin", b"", &[]).unwrap();
assert_eq!(status, 200);
assert_eq!(header_value(&headers, "content-length"), Some("20"));
assert_eq!(got, body);
}
#[test]
fn get_range_serves_suffix_body_and_rejects_out_of_bounds() {
let server = MockNexus::start(Scenario::Atomic).unwrap();
let (status, _, _) =
exchange(server.addr(), "PUT", "/1.14.0/blob.bin", b"0123456789", &[]).unwrap();
assert_eq!(status, 201);
let (status, headers, body) = exchange(
server.addr(),
"GET",
"/1.14.0/blob.bin",
b"",
&[("Range", "bytes=5-")],
)
.unwrap();
assert_eq!(status, 206);
assert_eq!(
header_value(&headers, "content-range"),
Some("bytes 5-9/10")
);
assert_eq!(header_value(&headers, "content-length"), Some("5"));
assert_eq!(body, b"56789");
assert_eq!(server.requests()[1].outcome, Outcome::Status(206));
let (status, headers, _) = exchange(
server.addr(),
"GET",
"/1.14.0/blob.bin",
b"",
&[("Range", "bytes=10-")],
)
.unwrap();
assert_eq!(status, 416);
assert_eq!(header_value(&headers, "content-range"), Some("bytes */10"));
let (status, _, body) = exchange(
server.addr(),
"GET",
"/1.14.0/missing",
b"",
&[("Range", "bytes=5-")],
)
.unwrap();
assert_eq!(status, 404);
assert_eq!(body, b"not found\n");
}
}