roster-host: transport host for the FUT roster update, lifecycle-matched
Second consumer of openfut-tls, and the reason it was extracted first.
This host contains no roster content and no cipher choice: the adapter
owns the 67 bytes and the observed TLS profile, openfut-tls owns the
acceptor, and this crate owns accept/read/drain/write/close.
Lifecycle was MEASURED, not inherited. The obvious mistake here would
have been copying the redirector's 300ms dwell because the other host has
one. A probe against the oracle says otherwise:
dwell after responding 0 ms (redirector: 300 ms)
request body drained POST answered only once it arrives
close clean FIN, never RST
keep-alive none one request per connection
The probe ran against a REPLICA of roster_server.py loaded from its own
source, not against :8081 -- http.server.HTTPServer is single-threaded
and FIFA was mid-session, so holding a connection open to measure the
close would have stalled the game's poll and could have surfaced as the
squad-update error. The replica was then confirmed byte-identical to the
live oracle under masking, the 1-byte delta being the container's Python
version in the Server header.
Differential against the live oracle, every field identical, with the
Server header compared UNMASKED:
GET 230B HEAD 163B POST 163B
drained=True reset=False answered_before_body=False
keepalive: second request accepted by the socket, never answered
Testing follows the redirector's hard-won rule: where a property is
visible both to the client and inside the host, it is asserted inside the
host via ConnOutcome. A client-side check cannot tell "drained" from "not
drained" -- it reads the buffered response either way -- and that exact
mistake let a mutation survive once already.
9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer
before draining", "hold the connection open like the redirector" and
"inherit the redirector's 300ms default".
drain_body is duplicated from the redirector deliberately. Unifying it
means editing the redirector, and the roster A/B must change exactly one
thing. Extraction is scheduled for after the roster gate closes.
Not deployed and not switched: Python still serves :8081.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
//! The roster host held against the Python oracle's measured behaviour.
|
||||
//!
|
||||
//! Response bytes are only half of it. The redirector cost two live gate
|
||||
//! attempts to a divergence no response comparison could see — a request body
|
||||
//! left unread, which turns the close into an RST — so the oracle's lifecycle
|
||||
//! was measured with a probe and every property is asserted here:
|
||||
//!
|
||||
//! ```text
|
||||
//! dwell after responding 0 ms
|
||||
//! request body drained before the response is written
|
||||
//! close immediately after, cleanly
|
||||
//! keep-alive none: one request per connection
|
||||
//! ```
|
||||
//!
|
||||
//! Where a property is observable both from the client and from inside the
|
||||
//! host, it is asserted **inside the host** via `ConnOutcome`. That distinction
|
||||
//! is not pedantry: the first attempt to test body-draining asserted that the
|
||||
//! client still received its response, and that test passed against a host that
|
||||
//! did not drain at all — the client reads the buffered response and sees
|
||||
//! `close_notify` before any reset.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use openfut_adapter_fifa17::roster;
|
||||
use openfut_roster_host::{bind, BodyRead, RosterConfig};
|
||||
|
||||
const PATH: &str = "/fifa17/fut/rosterupdate.xml";
|
||||
const BODY: &[u8] = b"probe=1&pad=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
fn cert_pair() -> (String, String) {
|
||||
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools");
|
||||
(
|
||||
format!("{base}/redir_cert.pem"),
|
||||
format!("{base}/redir_key.pem"),
|
||||
)
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
addr: std::net::SocketAddr,
|
||||
outcomes: openfut_roster_host::Outcomes,
|
||||
}
|
||||
|
||||
fn start() -> Harness {
|
||||
start_with(|_| {})
|
||||
}
|
||||
|
||||
fn start_with(tweak: impl FnOnce(&mut RosterConfig)) -> Harness {
|
||||
let (c, k) = cert_pair();
|
||||
let mut cfg = RosterConfig::for_test(&c, &k);
|
||||
tweak(&mut cfg);
|
||||
let server = bind(cfg).expect("bind");
|
||||
let addr = server.local_addr;
|
||||
let outcomes = server.outcomes();
|
||||
std::thread::spawn(move || {
|
||||
let _ = server.run();
|
||||
});
|
||||
Harness { addr, outcomes }
|
||||
}
|
||||
|
||||
/// A client that behaves like FIFA: legacy suites, no certificate checking.
|
||||
fn connect(addr: std::net::SocketAddr) -> openfut_tls::SslStream<TcpStream> {
|
||||
use openfut_tls::{SslConnector, SslMethod, SslVerifyMode};
|
||||
let mut b = SslConnector::builder(SslMethod::tls()).expect("connector");
|
||||
b.set_cipher_list(openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES)
|
||||
.expect("ciphers");
|
||||
b.set_verify(SslVerifyMode::NONE);
|
||||
let sock = TcpStream::connect(addr).expect("connect");
|
||||
sock.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let ssl = b
|
||||
.build()
|
||||
.configure()
|
||||
.and_then(|c| c.verify_hostname(false).into_ssl("roster-test"))
|
||||
.expect("ssl");
|
||||
let mut s = openfut_tls::SslStream::new(ssl, sock).expect("stream");
|
||||
s.connect().expect("handshake");
|
||||
s
|
||||
}
|
||||
|
||||
fn request(method: &str, body: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut r = format!("{method} {PATH} HTTP/1.1\r\nHost: roster-test\r\nAccept: */*\r\n");
|
||||
if let Some(b) = body {
|
||||
r.push_str(&format!("Content-Length: {}\r\n", b.len()));
|
||||
}
|
||||
r.push_str("\r\n");
|
||||
let mut out = r.into_bytes();
|
||||
if let Some(b) = body {
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_to_eof(s: &mut openfut_tls::SslStream<TcpStream>) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
match s.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => out.extend_from_slice(&chunk[..n]),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn exchange(addr: std::net::SocketAddr, method: &str, body: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut s = connect(addr);
|
||||
s.write_all(&request(method, body)).expect("write");
|
||||
s.flush().ok();
|
||||
read_to_eof(&mut s)
|
||||
}
|
||||
|
||||
fn mask(raw: &[u8]) -> String {
|
||||
String::from_utf8_lossy(raw)
|
||||
.split("\r\n")
|
||||
.map(|l| {
|
||||
if l.starts_with("Date: ") {
|
||||
"Date: <MASKED>".to_string()
|
||||
} else if l.starts_with("Server: ") {
|
||||
"Server: <MASKED>".to_string()
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n")
|
||||
}
|
||||
|
||||
/// The bytes the adapter says the oracle sends, masked the same way.
|
||||
fn expected(method: roster::Method) -> String {
|
||||
mask(&roster::roster_response(
|
||||
method,
|
||||
roster::ORACLE_SERVER,
|
||||
"IRRELEVANT",
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_returns_the_oracles_bytes() {
|
||||
let h = start();
|
||||
let got = exchange(h.addr, "GET", None);
|
||||
assert_eq!(mask(&got), expected(roster::Method::Get));
|
||||
assert!(String::from_utf8_lossy(&got).starts_with("HTTP/1.0 200 OK\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_and_post_are_headers_only_but_still_advertise_the_body() {
|
||||
let h = start();
|
||||
for (m, adapter_m) in [
|
||||
("HEAD", roster::Method::Head),
|
||||
("POST", roster::Method::Post),
|
||||
] {
|
||||
let body = if m == "POST" { Some(BODY) } else { None };
|
||||
let got = exchange(h.addr, m, body);
|
||||
assert_eq!(mask(&got), expected(adapter_m), "{m} differs");
|
||||
let text = String::from_utf8_lossy(&got);
|
||||
assert!(text.contains("Content-Length: 67"), "{m}: {text}");
|
||||
assert!(text.ends_with("\r\n\r\n"), "{m} must send no body");
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserted on the HOST's record, not on what the client received.
|
||||
///
|
||||
/// A client-side assertion cannot distinguish "drained" from "not drained":
|
||||
/// it reads the buffered response either way. This is the exact mistake that
|
||||
/// let a mutation survive on the redirector.
|
||||
#[test]
|
||||
fn the_request_body_is_drained_before_the_response_is_written() {
|
||||
let h = start();
|
||||
let _ = exchange(h.addr, "POST", Some(BODY));
|
||||
let rec = h.outcomes.lock().unwrap();
|
||||
let last = rec.last().expect("a connection was recorded");
|
||||
assert_eq!(
|
||||
last.body,
|
||||
BodyRead::Complete(BODY.len()),
|
||||
"the whole body must be read before answering, or the close becomes an RST"
|
||||
);
|
||||
assert!(last.response_bytes > 0);
|
||||
}
|
||||
|
||||
/// The oracle answers a POST only after the body arrives. Sending the head,
|
||||
/// pausing, then sending the body proves the host waits rather than replying
|
||||
/// early and leaving the socket dirty.
|
||||
#[test]
|
||||
fn a_late_body_is_waited_for_rather_than_answered_early() {
|
||||
let h = start();
|
||||
let mut s = connect(h.addr);
|
||||
let head = format!(
|
||||
"POST {PATH} HTTP/1.1\r\nHost: roster-test\r\nContent-Length: {}\r\n\r\n",
|
||||
BODY.len()
|
||||
);
|
||||
s.write_all(head.as_bytes()).unwrap();
|
||||
s.flush().ok();
|
||||
|
||||
s.get_ref()
|
||||
.set_read_timeout(Some(Duration::from_millis(600)))
|
||||
.unwrap();
|
||||
let mut early = [0u8; 64];
|
||||
let early_n = s.read(&mut early).unwrap_or(0);
|
||||
assert_eq!(
|
||||
early_n,
|
||||
0,
|
||||
"answered before the body arrived: {:?}",
|
||||
String::from_utf8_lossy(&early[..early_n])
|
||||
);
|
||||
|
||||
s.get_ref()
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
s.write_all(BODY).unwrap();
|
||||
s.flush().ok();
|
||||
let late = read_to_eof(&mut s);
|
||||
assert!(!late.is_empty(), "no response after the body arrived");
|
||||
assert_eq!(mask(&late), expected(roster::Method::Post));
|
||||
}
|
||||
|
||||
/// One request per connection: the response says `Connection: close` and the
|
||||
/// oracle closes. Measured, not assumed — a keep-alive loop would be a
|
||||
/// behaviour change invisible in any single response.
|
||||
#[test]
|
||||
fn the_connection_closes_after_one_request() {
|
||||
let h = start();
|
||||
let mut s = connect(h.addr);
|
||||
s.write_all(&request("GET", None)).unwrap();
|
||||
s.flush().ok();
|
||||
let first = read_to_eof(&mut s);
|
||||
assert!(!first.is_empty());
|
||||
// read_to_eof already ran to EOF; a second request must not be answered.
|
||||
let _ = s.write_all(&request("GET", None));
|
||||
let mut buf = [0u8; 32];
|
||||
let n = s.read(&mut buf).unwrap_or(0);
|
||||
assert_eq!(n, 0, "a second request was answered on the same connection");
|
||||
}
|
||||
|
||||
/// The oracle has no post-response sleep — unlike the redirector's 300ms.
|
||||
/// Inheriting the wrong dwell would add delay to a poll that runs every few
|
||||
/// seconds.
|
||||
#[test]
|
||||
fn the_connection_is_not_held_open_after_responding() {
|
||||
let h = start();
|
||||
let mut s = connect(h.addr);
|
||||
s.write_all(&request("GET", None)).unwrap();
|
||||
s.flush().ok();
|
||||
let start = Instant::now();
|
||||
let got = read_to_eof(&mut s);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(!got.is_empty());
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(150),
|
||||
"closed after {elapsed:?}; the roster oracle closes immediately"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dwell stays configurable so the causal experiment — set it and confirm
|
||||
/// the delay appears — can run without a rebuild. Guards against the knob
|
||||
/// being quietly ignored.
|
||||
#[test]
|
||||
fn the_dwell_is_overridable() {
|
||||
let h = start_with(|c| c.close_dwell = Duration::from_millis(400));
|
||||
let mut s = connect(h.addr);
|
||||
s.write_all(&request("GET", None)).unwrap();
|
||||
s.flush().ok();
|
||||
let start = Instant::now();
|
||||
let _ = read_to_eof(&mut s);
|
||||
assert!(
|
||||
start.elapsed() >= Duration::from_millis(350),
|
||||
"dwell override had no effect"
|
||||
);
|
||||
}
|
||||
|
||||
/// The oracle never routes on the path — it logs it and answers the same way.
|
||||
/// A host that started 404ing unknown paths would be a behaviour change.
|
||||
#[test]
|
||||
fn any_path_gets_the_same_answer() {
|
||||
let h = start();
|
||||
let mut s = connect(h.addr);
|
||||
s.write_all(b"GET /something/else HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||
.unwrap();
|
||||
s.flush().ok();
|
||||
let got = read_to_eof(&mut s);
|
||||
assert_eq!(mask(&got), expected(roster::Method::Get));
|
||||
}
|
||||
|
||||
/// Masking must not be able to hide a real difference.
|
||||
#[test]
|
||||
fn masking_does_not_hide_a_missing_header() {
|
||||
let good = roster::roster_response(roster::Method::Get, roster::ORACLE_SERVER, "X");
|
||||
let without: Vec<u8> = String::from_utf8_lossy(&good)
|
||||
.split("\r\n")
|
||||
.filter(|l| !l.starts_with("Date: "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n")
|
||||
.into_bytes();
|
||||
assert_ne!(mask(&good), mask(&without));
|
||||
}
|
||||
Reference in New Issue
Block a user