diff --git a/openfut-adapter-fifa17/fixtures/generate_roster.py b/openfut-adapter-fifa17/fixtures/generate_roster.py new file mode 100644 index 0000000..ae03b26 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/generate_roster.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Capture the roster oracle's responses byte-for-byte. + + generate_roster.py [--check] [host:port] + +Unlike `generate.py`, which imports the Blaze responder and calls its pure +functions, this captures over the wire. The roster response is shaped as much by +`http.server.BaseHTTPRequestHandler` as by the handler code -- HTTP/1.0 status +line, `Server:`/`Date:` injected ahead of the handler's own headers, POST +answered without a body -- and only the real socket shows all of that. + +Two fields are volatile and are MASKED rather than recorded: + + Date: changes every second + Server: carries the container's Python version + +They are masked, not dropped, so their presence and position are still asserted. +The Server string is additionally recorded verbatim under `observed_server`, so +a drift between the container's Python and the adapter's `ORACLE_SERVER` +constant is visible rather than silent. + +`--check` re-captures and compares. If the oracle is unreachable it FAILS rather +than passing: a check that cannot check must not report success. +""" +import json +import os +import re +import socket +import ssl +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "roster.json") +PATH = "/fifa17/fut/rosterupdate.xml" + +DATE_RE = re.compile(rb"^Date: .+?\r\n", re.M) +SERVER_RE = re.compile(rb"^Server: (.+?)\r\n", re.M) + + +def fetch(host, port, method, body=None): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_ciphers("ALL:@SECLEVEL=0") + s = ctx.wrap_socket(socket.create_connection((host, port), timeout=8), + server_hostname="fixture") + req = "%s %s HTTP/1.1\r\nHost: %s:%d\r\nAccept: */*\r\n" % (method, PATH, host, port) + if body is not None: + req += "Content-Length: %d\r\n" % len(body) + req += "\r\n" + s.sendall(req.encode() + (body or b"")) + out = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + out += chunk + s.close() + return out + + +def capture(host, port): + result = {"path": PATH, "responses": {}} + servers = set() + for method, body in (("GET", None), ("HEAD", None), ("POST", b"probe=1")): + raw = fetch(host, port, method, body) + m = SERVER_RE.search(raw) + if m: + servers.add(m.group(1).decode()) + masked = DATE_RE.sub(b"Date: \r\n", raw) + masked = SERVER_RE.sub(b"Server: \r\n", masked) + result["responses"][method] = masked.hex() + if len(servers) != 1: + raise SystemExit("oracle returned inconsistent Server headers: %r" % servers) + result["observed_server"] = servers.pop() + return result + + +def main(): + check = "--check" in sys.argv + args = [a for a in sys.argv[1:] if not a.startswith("--")] + host, port = (args[0].split(":") if args else ("127.0.0.1", "8081"))[0], \ + int((args[0].split(":")[1] if args and ":" in args[0] else "8081")) + + try: + fresh = capture(host, port) + except Exception as e: + # Explicitly a failure. A --check that silently passes when it could not + # reach the oracle is exactly the class of self-confirming tooling this + # project has been bitten by repeatedly. + raise SystemExit("cannot reach the roster oracle at %s:%d (%s). " + "Refusing to report success." % (host, port, e)) + + if check: + if not os.path.exists(OUT): + raise SystemExit("no fixture at %s -- run without --check first" % OUT) + with open(OUT) as f: + stored = json.load(f) + if stored.get("responses") != fresh["responses"]: + for m in sorted(set(stored.get("responses", {})) | set(fresh["responses"])): + a = stored.get("responses", {}).get(m) + b = fresh["responses"].get(m) + if a != b: + print("MISMATCH %s\n stored: %s\n live : %s" % (m, a, b)) + raise SystemExit("roster fixtures differ from the live oracle") + if stored.get("observed_server") != fresh["observed_server"]: + raise SystemExit( + "the oracle's Server header changed: %r -> %r.\n" + "Update roster::ORACLE_SERVER and regenerate." + % (stored.get("observed_server"), fresh["observed_server"])) + print("roster fixtures match the live oracle (%d responses, server=%r)" + % (len(fresh["responses"]), fresh["observed_server"])) + return + + with open(OUT, "w") as f: + json.dump(fresh, f, indent=2, sort_keys=True) + f.write("\n") + print("wrote %s (%d responses, server=%r)" + % (OUT, len(fresh["responses"]), fresh["observed_server"])) + + +if __name__ == "__main__": + main() diff --git a/openfut-adapter-fifa17/fixtures/roster.json b/openfut-adapter-fifa17/fixtures/roster.json new file mode 100644 index 0000000..85b22b0 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/roster.json @@ -0,0 +1,9 @@ +{ + "observed_server": "BaseHTTP/0.6 Python/3.12.13", + "path": "/fifa17/fut/rosterupdate.xml", + "responses": { + "GET": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d227574662d38223f3e0a3c726f737465727570646174652076657273696f6e3d2230222f3e0a", + "HEAD": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a", + "POST": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a" + } +} diff --git a/openfut-adapter-fifa17/src/lib.rs b/openfut-adapter-fifa17/src/lib.rs index 524420c..4f80a2e 100644 --- a/openfut-adapter-fifa17/src/lib.rs +++ b/openfut-adapter-fifa17/src/lib.rs @@ -22,10 +22,13 @@ //! //! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented, runtime validated. //! * [`redirector`] — the first hop's `` response. -//! Implemented; TLS transport is still the host's problem and unresolved. +//! Implemented; runtime validated against the retail client. +//! * [`roster`] — the FUT roster-update response, the last gate before the hub. +//! Implemented; not yet runtime validated. //! -//! Still served only by the Python backend: LSX/Origin (`:4216`), roster XML -//! (`:8081`), UTAS/RS4 (`:8099`) and POW/EASFC (`:8094`). +//! Still served only by the Python backend: LSX/Origin (`:4216`), UTAS/RS4 +//! (`:8099`) and POW/EASFC (`:8094`). Roster XML (`:8081`) has an adapter here +//! but no Rust host yet. //! //! **Nucleus (`:42131`) is deliberately not ported.** Instrumentation across //! every live session showed the client never dials it: the listener is bound, @@ -66,5 +69,6 @@ pub mod blaze; pub mod redirector; +pub mod roster; pub use blaze::{Adapter, AdapterConfig, Session}; diff --git a/openfut-adapter-fifa17/src/roster/mod.rs b/openfut-adapter-fifa17/src/roster/mod.rs new file mode 100644 index 0000000..188adf7 --- /dev/null +++ b/openfut-adapter-fifa17/src/roster/mod.rs @@ -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──> +//! ``` +//! +//! # 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"); + } +} diff --git a/openfut-adapter-fifa17/tests/roster_parity.rs b/openfut-adapter-fifa17/tests/roster_parity.rs new file mode 100644 index 0000000..81cb81a --- /dev/null +++ b/openfut-adapter-fifa17/tests/roster_parity.rs @@ -0,0 +1,121 @@ +//! The roster response, held against bytes captured from the live oracle. +//! +//! The fixture masks the two volatile fields (`Date:`, `Server:`) and records +//! the observed `Server` string separately, so this can assert the full byte +//! layout while still failing loudly if the oracle's Python version drifts away +//! from the adapter's `ORACLE_SERVER` constant. + +use openfut_adapter_fifa17::roster::{self, Method}; + +const MASK: &str = ""; + +fn fixture() -> String { + let path = format!("{}/fixtures/roster.json", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("roster fixtures missing at {path}: {e}")) +} + +/// Minimal extraction: the fixture is written by our own generator and is a +/// flat object, so a JSON dependency would be overkill in a crate that has none. +fn field(text: &str, key: &str) -> String { + let needle = format!("\"{key}\""); + let at = text + .find(&needle) + .unwrap_or_else(|| panic!("fixture has no {key}")); + let rest = &text[at + needle.len()..]; + let colon = rest.find(':').expect("key: value"); + let open = rest[colon..].find('"').expect("value opens") + colon; + let close = rest[open + 1..].find('"').expect("value closes"); + rest[open + 1..open + 1 + close].to_string() +} + +fn expected(method: &str) -> Vec { + // Scope the search to the responses object so a key never matches elsewhere. + let text = fixture(); + let at = text.find("\"responses\"").expect("responses"); + let hex = field(&text[at..], method); + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex")) + .collect() +} + +/// Re-apply the generator's masking so the comparison is like-for-like. +fn mask(raw: &[u8]) -> Vec { + let text = String::from_utf8_lossy(raw); + let masked: String = text + .split("\r\n") + .map(|line| { + if line.starts_with("Date: ") { + format!("Date: {MASK}") + } else if line.starts_with("Server: ") { + format!("Server: {MASK}") + } else { + line.to_string() + } + }) + .collect::>() + .join("\r\n"); + masked.into_bytes() +} + +fn check(method: Method, name: &str) { + let got = roster::roster_response( + method, + roster::ORACLE_SERVER, + "Tue, 11 Aug 2026 05:15:13 GMT", + ); + assert_eq!( + String::from_utf8_lossy(&mask(&got)), + String::from_utf8_lossy(&expected(name)), + "{name} differs from the oracle" + ); +} + +#[test] +fn get_matches_the_oracle() { + check(Method::Get, "GET"); +} + +#[test] +fn head_matches_the_oracle() { + check(Method::Head, "HEAD"); +} + +/// Including the quirk: headers advertising a body that never arrives. +#[test] +fn post_matches_the_oracle() { + check(Method::Post, "POST"); +} + +/// If the container's Python changes, `ORACLE_SERVER` is stale and every +/// response this adapter builds is wrong in a byte the oracle would have got +/// right. The fixture records what was actually observed so that drift is a +/// test failure rather than a silent divergence. +#[test] +fn the_server_constant_still_matches_the_observed_oracle() { + let observed = field(&fixture(), "observed_server"); + assert_eq!( + roster::ORACLE_SERVER, observed, + "roster::ORACLE_SERVER is stale — the oracle now sends {observed:?}. \ + Regenerate fixtures and update the constant." + ); +} + +/// The masking must not be able to hide a real difference. If `Date:` were +/// dropped rather than masked, a response missing it entirely would still pass. +#[test] +fn masking_does_not_hide_a_missing_header() { + let good = roster::roster_response(Method::Get, roster::ORACLE_SERVER, "X"); + let without_date: Vec = String::from_utf8_lossy(&good) + .split("\r\n") + .filter(|l| !l.starts_with("Date: ")) + .collect::>() + .join("\r\n") + .into_bytes(); + assert_ne!( + mask(&good), + mask(&without_date), + "masking collapsed a missing Date into a match" + ); +}