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,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: <MASKED>\r\n", raw)
|
||||
masked = SERVER_RE.sub(b"Server: <MASKED>\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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,13 @@
|
||||
//!
|
||||
//! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented, runtime validated.
|
||||
//! * [`redirector`] — the first hop's `<serverinstanceinfo>` 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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 = "<MASKED>";
|
||||
|
||||
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<u8> {
|
||||
// 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<u8> {
|
||||
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::<Vec<_>>()
|
||||
.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<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_date),
|
||||
"masking collapsed a missing Date into a match"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user