//! 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");
}
}