adapter: port the FUT roster-update response, held to the live oracle's bytes
Next component in the migration order (Roster -> LSX -> UTAS). Adapter layer
only: no host, no runtime replacement, nothing armed.
The response is shaped as much by http.server.BaseHTTPRequestHandler as by the
oracle's handler code, so it is captured over the wire rather than reasoned
about:
* HTTP/1.0 status line -- protocol_version is left at its default, so the
reply is 1.0 even though the client asks for 1.1
* send_response injects Server: and Date: BEFORE the handler's own headers
* POST answers with headers only: the handler writes the body `if method ==
"GET"`, so a POST advertises Content-Length: 67 and then sends nothing
That last one is preserved, not corrected. It looks like a bug, but "obviously a
bug" has been the wrong call before in this port, and a test now asserts it so a
future cleanup has to argue with something.
Date and Server are volatile and are MASKED in the fixture rather than dropped,
so their presence and position are still asserted. Server is additionally
recorded verbatim: it carries the container's Python version, so a drift away
from roster::ORACLE_SERVER fails a test instead of silently changing every byte
we emit.
generate_roster.py --check FAILS when it cannot reach the oracle rather than
passing, and mutation-testing the mutation harness itself caught two "surviving"
mutations that were really sed no-ops. With application verified, all four
mutations (header order, Content-Length, XML body, Connection) are killed.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
//! The FUT roster-update response — "is there a squad update to download?".
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> <rosterupdate version="0"/>
|
||||
//! ```
|
||||
//!
|
||||
//! # Why this matters more than its size suggests
|
||||
//!
|
||||
//! `checkFUTRostersFlow` downloads this before entering FUT. On success it
|
||||
//! advances to the hub; on failure it aborts with *"An error occurred
|
||||
//! downloading the FUT Squad Update"*. It is the last gate before the hub, and
|
||||
//! because it is a separate TLS connection from the redirector it is also where
|
||||
//! a certificate mismatch elsewhere in the stack first becomes visible. Three
|
||||
//! redirector gates were lost to exactly that.
|
||||
//!
|
||||
//! # What the oracle actually puts on the wire
|
||||
//!
|
||||
//! `roster_server.py` uses `http.server.BaseHTTPRequestHandler`, which shapes
|
||||
//! the response in ways the handler code does not show:
|
||||
//!
|
||||
//! * the status line is **HTTP/1.0**, because `protocol_version` is left at its
|
||||
//! default — not HTTP/1.1, despite the client asking for 1.1
|
||||
//! * `send_response` emits `Server:` and `Date:` *before* any header the
|
||||
//! handler sets, so header order is Server, Date, Content-Type,
|
||||
//! Content-Length, Connection
|
||||
//! * **POST answers with headers only.** The handler writes the body `if method
|
||||
//! == "GET"`, so a POST advertises `Content-Length: 67` and then sends
|
||||
//! nothing. That is preserved here rather than corrected: it is the behaviour
|
||||
//! the retail client was proven against, and "obviously a bug" is exactly the
|
||||
//! kind of judgement that has been wrong before in this port.
|
||||
//!
|
||||
//! Any path gets the same answer — the oracle logs `self.path` and never routes
|
||||
//! on it. A reimplementation that started 404ing unknown paths would be a
|
||||
//! behaviour change, not a fix.
|
||||
|
||||
/// The path the client requests. Recorded for diagnostics; **not** used for
|
||||
/// routing, because the oracle does not route.
|
||||
pub const REQUEST_PATH: &str = "/fifa17/fut/rosterupdate.xml";
|
||||
|
||||
/// The "no update available" body, byte-for-byte from the oracle.
|
||||
pub const ROSTER_XML: &[u8] = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rosterupdate version=\"0\"/>\n";
|
||||
|
||||
/// The `Server:` string the oracle emits.
|
||||
///
|
||||
/// Environment-derived, not protocol-derived: it is Python's version string,
|
||||
/// so it changes when the container's Python does. Kept as a default rather
|
||||
/// than hardcoded into the response builder so a host can match whatever the
|
||||
/// deployed oracle actually sends — the same reasoning that makes the
|
||||
/// advertised address configuration rather than a constant.
|
||||
pub const ORACLE_SERVER: &str = "BaseHTTP/0.6 Python/3.12.13";
|
||||
|
||||
/// Which of the oracle's three handlers a request lands in.
|
||||
///
|
||||
/// `Post` is distinct from `Head` in the oracle's *code* (it drains the request
|
||||
/// body first) but identical in its *response*, so the distinction is kept here
|
||||
/// for the host's benefit rather than the response builder's.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
Get,
|
||||
Head,
|
||||
Post,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Parse the request line's method. Unknown methods are `None` — the oracle
|
||||
/// only defines `do_GET`/`do_HEAD`/`do_POST`, and `BaseHTTPRequestHandler`
|
||||
/// answers anything else with its own 501, which is a different response
|
||||
/// this module deliberately does not claim to reproduce.
|
||||
pub fn parse(line0: &str) -> Option<Method> {
|
||||
match line0.split_whitespace().next()? {
|
||||
"GET" => Some(Method::Get),
|
||||
"HEAD" => Some(Method::Head),
|
||||
"POST" => Some(Method::Post),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only GET carries the body, per the oracle.
|
||||
pub fn carries_body(self) -> bool {
|
||||
matches!(self, Method::Get)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the response exactly as the oracle would.
|
||||
///
|
||||
/// `date` is supplied by the caller rather than read from the clock here, so
|
||||
/// this stays a pure function and the fixtures can pin it. Format is the one
|
||||
/// `BaseHTTPRequestHandler.date_time_string` produces: RFC 7231 IMF-fixdate,
|
||||
/// always GMT.
|
||||
pub fn roster_response(method: Method, server: &str, date: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(256);
|
||||
out.extend_from_slice(b"HTTP/1.0 200 OK\r\n");
|
||||
out.extend_from_slice(format!("Server: {server}\r\n").as_bytes());
|
||||
out.extend_from_slice(format!("Date: {date}\r\n").as_bytes());
|
||||
out.extend_from_slice(b"Content-Type: application/xml\r\n");
|
||||
// Always the body's length, even when no body follows. See the module note.
|
||||
out.extend_from_slice(format!("Content-Length: {}\r\n", ROSTER_XML.len()).as_bytes());
|
||||
out.extend_from_slice(b"Connection: close\r\n");
|
||||
out.extend_from_slice(b"\r\n");
|
||||
if method.carries_body() {
|
||||
out.extend_from_slice(ROSTER_XML);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `Date:` in the oracle's format, from a Unix timestamp.
|
||||
///
|
||||
/// Implemented rather than pulled from a date crate to keep this crate
|
||||
/// dependency-free, matching the protocol layer's constraint. Civil-date
|
||||
/// conversion is the standard days-from-epoch algorithm.
|
||||
pub fn http_date(unix_secs: i64) -> String {
|
||||
const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const MONTHS: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
let days = unix_secs.div_euclid(86_400);
|
||||
let secs = unix_secs.rem_euclid(86_400);
|
||||
// 1970-01-01 was a Thursday (index 3).
|
||||
let dow = DAYS[(days + 3).rem_euclid(7) as usize];
|
||||
|
||||
// days-from-civil, inverted (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!(
|
||||
"{dow}, {d:02} {mon} {y} {h:02}:{mi:02}:{s:02} GMT",
|
||||
mon = MONTHS[(m - 1) as usize],
|
||||
h = secs / 3600,
|
||||
mi = (secs % 3600) / 60,
|
||||
s = secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_body_is_the_oracles_67_bytes() {
|
||||
assert_eq!(ROSTER_XML.len(), 67);
|
||||
assert!(ROSTER_XML.starts_with(b"<?xml version=\"1.0\" encoding=\"utf-8\"?>"));
|
||||
assert!(ROSTER_XML.ends_with(b"<rosterupdate version=\"0\"/>\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_get_carries_the_body() {
|
||||
let d = "Tue, 11 Aug 2026 05:15:13 GMT";
|
||||
let get = roster_response(Method::Get, ORACLE_SERVER, d);
|
||||
let head = roster_response(Method::Head, ORACLE_SERVER, d);
|
||||
let post = roster_response(Method::Post, ORACLE_SERVER, d);
|
||||
assert_eq!(get.len(), head.len() + ROSTER_XML.len());
|
||||
assert_eq!(head, post, "the oracle answers POST exactly as HEAD");
|
||||
}
|
||||
|
||||
/// The quirk, asserted so a future "cleanup" has to argue with a test.
|
||||
#[test]
|
||||
fn a_bodiless_response_still_advertises_the_body_length() {
|
||||
let r = roster_response(Method::Head, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
|
||||
let text = String::from_utf8_lossy(&r);
|
||||
assert!(text.contains("Content-Length: 67"), "{text}");
|
||||
assert!(text.ends_with("\r\n\r\n"), "no body may follow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_order_matches_basehttprequesthandler() {
|
||||
let r = roster_response(Method::Get, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
|
||||
let text = String::from_utf8_lossy(&r);
|
||||
let order: Vec<&str> = ["Server:", "Date:", "Content-Type:", "Content-Length:", "Connection:"]
|
||||
.iter()
|
||||
.map(|h| {
|
||||
text.find(h).map(|_| *h).unwrap_or("MISSING")
|
||||
})
|
||||
.collect();
|
||||
assert!(!order.contains(&"MISSING"), "{text}");
|
||||
let positions: Vec<usize> = order.iter().map(|h| text.find(h).unwrap()).collect();
|
||||
let mut sorted = positions.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(positions, sorted, "headers out of the oracle's order:\n{text}");
|
||||
assert!(text.starts_with("HTTP/1.0 200 OK\r\n"), "must be HTTP/1.0: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_parse_and_unknown_ones_are_refused() {
|
||||
assert_eq!(Method::parse("GET /x HTTP/1.1"), Some(Method::Get));
|
||||
assert_eq!(Method::parse("HEAD /x HTTP/1.1"), Some(Method::Head));
|
||||
assert_eq!(Method::parse("POST /x HTTP/1.1"), Some(Method::Post));
|
||||
// Not reproduced on purpose: the oracle answers these with its own 501.
|
||||
assert_eq!(Method::parse("PUT /x HTTP/1.1"), None);
|
||||
assert_eq!(Method::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_date_matches_the_oracles_format() {
|
||||
// 1786425313 == Tue, 11 Aug 2026 05:15:13 GMT, the captured fixture time.
|
||||
assert_eq!(http_date(1_786_425_313), "Tue, 11 Aug 2026 05:15:13 GMT");
|
||||
assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
|
||||
// A leap day, because the civil-date maths is the only real logic here.
|
||||
assert_eq!(http_date(1_709_164_800), "Thu, 29 Feb 2024 00:00:00 GMT");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user