84e81f2037
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.
Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.
Split along the line the architecture already draws:
openfut-tls how to build an acceptor. Game-independent.
Knows nothing about which suites any client
offers.
adapter-fifa17::tls what FIFA 17 was OBSERVED to offer: the six
enabled suites, the two refused, the TLS 1.2
window, the EA SNI. Plain strings, so the
adapter keeps its lean dependencies -- reading
a card table should not build OpenSSL.
redirector-host joins the two. Chooses no cipher of its own.
Behaviour is unchanged, and shown to be:
* tests/fifa17_tls_profile.rs carries over every case from the deleted
module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
when gates 1-14 passed. A "pure refactor" that cannot fail is not a
claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
the retail client negotiated at 17:09 today.
Two improvements fall out of having one place to look:
* the startup banner now prints cert_sha256. The mismatch above raised no
error at startup and broke the client much later with nothing logged;
it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
is gate evidence and gets read by people.
Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.0 KiB
Rust
123 lines
4.0 KiB
Rust
//! 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"
|
|
);
|
|
}
|