//! The FUT roster-update response — "is there a squad update to download?". //! //! ```text //! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> //! ``` //! //! # 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"\n\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 { 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 { 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"")); assert!(ROSTER_XML.ends_with(b"\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 = 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"); } }