8aab2c0d41
REDIRECTOR. The first hop's <serverinstanceinfo> XML, byte-for-byte against the oracle across three advertised addresses. Owns the response only; TLS and HTTP transport belong to a host, exactly as the Blaze adapter owns dispatch while the sidecar owns the socket. The <secure>0</secure> field is the client being told the second hop is plaintext -- independent corroboration of the plaintext Blaze finding, now expressed in code. NUCLEUS IS NOT PORTED, and that is a finding rather than an omission. Instrumented across every live session: listener bound YES 0.0.0.0:42131 since 00:21:32 handler logs on connect YES unconditional, before any parsing client received the URL YES OSDK_NUCLEUS fetched 10+ times client connected NO zero requests, including 4 full FUT flows So the long-standing nucleusConnect=0.0.0.0 anomaly is explained: FIFA never follows that URL on this path. The invalid address has never mattered because nothing dials it. Porting the stub would add an untested component for no parity gain. TLS CONSTRAINT RECORDED, NOT RESOLVED. All 9 observed handshakes negotiated AES256-GCM-SHA384 = TLS 1.2 with STATIC RSA key exchange. rustls supports only forward-secret (EC)DHE suites and cannot serve that. Whether the client also OFFERS ECDHE is unknown -- OpenSSL follows client preference by default, so preferring static RSA does not prove it is the only option. This must be instrumented from a real ClientHello before a TLS stack is chosen; the module docs say so rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
403 lines
15 KiB
Rust
403 lines
15 KiB
Rust
//! Differential tests: the Rust adapter against the Python Blaze responder.
|
|
//!
|
|
//! `openfut-protocol-blaze` proves the *codec* matches. This proves the layer
|
|
//! that decides **what to say**: for each inbound frame, the exact frames that
|
|
//! go back and their order.
|
|
//!
|
|
//! Every vector in `fixtures/blaze_transactions.jsonl` was produced by calling
|
|
//! the real `blaze_responder_v3b.dispatch()`. Transactions replay in file order
|
|
//! against a shared session per connection, so ordering-dependent behaviour is
|
|
//! exercised rather than assumed — preAuth captures the locale that later ALOC
|
|
//! fields echo, and login sets the auth code getAuthToken returns afterwards.
|
|
//!
|
|
//! Comparison is byte-for-byte, including frame count and order. A missing
|
|
//! post-login notification or a reply where the oracle stays silent is a
|
|
//! failure here, which is the whole point.
|
|
//!
|
|
//! Regenerate after any oracle change: python3 fixtures/generate.py
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Endpoints, Identity, Session};
|
|
use openfut_protocol_blaze::fire2::Frame;
|
|
use openfut_protocol_blaze::heat2;
|
|
use serde_json::Value as J;
|
|
|
|
fn records() -> Vec<J> {
|
|
let path = format!(
|
|
"{}/fixtures/blaze_transactions.jsonl",
|
|
env!("CARGO_MANIFEST_DIR")
|
|
);
|
|
let text = std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
|
|
text.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
|
|
.collect()
|
|
}
|
|
|
|
fn unhex(s: &str) -> Vec<u8> {
|
|
(0..s.len())
|
|
.step_by(2)
|
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
|
|
.collect()
|
|
}
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
}
|
|
|
|
fn st(j: &J, k: &str) -> String {
|
|
j[k].as_str()
|
|
.unwrap_or_else(|| panic!("{k} is a string"))
|
|
.to_string()
|
|
}
|
|
|
|
fn n(j: &J, k: &str) -> i64 {
|
|
j[k].as_i64().unwrap_or_else(|| panic!("{k} is a number"))
|
|
}
|
|
|
|
/// Rebuild the exact configuration the fixtures were generated under.
|
|
///
|
|
/// The generator uses deliberately non-loopback addresses, so an adapter that
|
|
/// hardcoded one instead of reading its config fails loudly here rather than
|
|
/// coincidentally matching a default.
|
|
fn config_from(record: &J) -> (AdapterConfig, i64) {
|
|
let id = &record["identity"];
|
|
let identity = Identity {
|
|
persona_id: n(id, "persona_id"),
|
|
persona_name: st(id, "persona_name"),
|
|
user_id: n(id, "user_id"),
|
|
ext_id: n(id, "ext_id"),
|
|
email: st(id, "email"),
|
|
namespace: st(id, "namespace"),
|
|
client_platform: n(id, "client_platform"),
|
|
persona_status: n(id, "persona_status"),
|
|
user_session_type: n(id, "user_session_type"),
|
|
account_locale: n(id, "account_locale_int"),
|
|
locale: st(id, "locale"),
|
|
content_id: st(id, "content_id"),
|
|
entitlement_tag: st(id, "entitlement_tag"),
|
|
entitlement_group: st(id, "entitlement_group"),
|
|
title_id: st(id, "title_id"),
|
|
client_id: st(id, "client_id"),
|
|
platform: st(id, "platform"),
|
|
};
|
|
let endpoints = Endpoints {
|
|
advertise: st(record, "advertise"),
|
|
bind: st(record, "bind"),
|
|
pow_content_host: st(record, "pow_content_host"),
|
|
pow_host: st(record, "pow_host"),
|
|
..Endpoints::default()
|
|
};
|
|
let cfg = AdapterConfig {
|
|
identity,
|
|
endpoints,
|
|
server_version: st(&record["identity"], "server_version"),
|
|
};
|
|
(cfg, n(record, "now"))
|
|
}
|
|
|
|
struct Replay {
|
|
adapter: Adapter,
|
|
now: i64,
|
|
sessions: HashMap<String, Session>,
|
|
records: Vec<J>,
|
|
}
|
|
|
|
fn setup() -> Replay {
|
|
let records = records();
|
|
let cfg_rec = records
|
|
.iter()
|
|
.find(|r| r["kind"] == "config")
|
|
.expect("fixture carries a config record")
|
|
.clone();
|
|
let (cfg, now) = config_from(&cfg_rec);
|
|
|
|
let mut sessions = HashMap::new();
|
|
for r in &records {
|
|
if r["kind"] == "session" {
|
|
// The session key is injected, not generated: it appears verbatim
|
|
// in three responses, so a self-minted one could never match.
|
|
sessions.insert(
|
|
st(r, "id"),
|
|
Session::new(st(r, "session_key"), n(r, "account_locale")),
|
|
);
|
|
}
|
|
}
|
|
|
|
Replay {
|
|
adapter: Adapter::new(cfg),
|
|
now,
|
|
sessions,
|
|
records,
|
|
}
|
|
}
|
|
|
|
/// The headline test: replay every transaction and require identical frames.
|
|
#[test]
|
|
fn dispatch_matches_python_oracle_byte_for_byte() {
|
|
let mut rp = setup();
|
|
let records = rp.records.clone();
|
|
let mut checked = 0usize;
|
|
|
|
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
|
let name = st(rec, "name");
|
|
let sid = st(rec, "session");
|
|
let request = unhex(&st(rec, "request_hex"));
|
|
let expected: Vec<String> = rec["responses"]
|
|
.as_array()
|
|
.expect("responses array")
|
|
.iter()
|
|
.map(|f| f.as_str().unwrap().to_string())
|
|
.collect();
|
|
|
|
let (frame, used) = Frame::parse(&request)
|
|
.unwrap_or_else(|e| panic!("{name}: fixture request does not parse: {e}"));
|
|
assert_eq!(used, request.len(), "{name}: trailing bytes in request");
|
|
|
|
let body = if frame.payload.is_empty() {
|
|
heat2::Struct::new()
|
|
} else {
|
|
heat2::decode(&frame.payload)
|
|
.unwrap_or_else(|e| panic!("{name}: request body is not valid TDF: {e}"))
|
|
};
|
|
|
|
let session = rp.sessions.get_mut(&sid).expect("session declared");
|
|
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
|
|
|
assert_eq!(
|
|
out.len(),
|
|
expected.len(),
|
|
"\n{name}: produced {} frame(s), oracle produced {}",
|
|
out.len(),
|
|
expected.len()
|
|
);
|
|
for (idx, (got, want)) in out.iter().zip(expected.iter()).enumerate() {
|
|
let got_hex = hex(&got.encode());
|
|
if &got_hex != want {
|
|
// Narrow the failure to header vs body before dumping bytes.
|
|
let want_bytes = unhex(want);
|
|
let got_bytes = got.encode();
|
|
assert_eq!(
|
|
hex(&got_bytes[..16.min(got_bytes.len())]),
|
|
hex(&want_bytes[..16.min(want_bytes.len())]),
|
|
"\n{name} frame {idx}: HEADER differs"
|
|
);
|
|
panic!(
|
|
"\n{name} frame {idx}: BODY differs\n got {} bytes\n want {} bytes",
|
|
got_bytes.len().saturating_sub(16),
|
|
want_bytes.len().saturating_sub(16)
|
|
);
|
|
}
|
|
}
|
|
checked += 1;
|
|
}
|
|
|
|
assert!(
|
|
checked >= 40,
|
|
"expected the full script, replayed {checked}"
|
|
);
|
|
}
|
|
|
|
/// Frame counts and ordering are part of the contract, so assert them
|
|
/// separately from bytes — a rewrite that answered correctly but dropped a
|
|
/// notification would otherwise fail with an unhelpful byte diff.
|
|
#[test]
|
|
fn frame_counts_and_ordering_match() {
|
|
let mut rp = setup();
|
|
let records = rp.records.clone();
|
|
|
|
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
|
let name = st(rec, "name");
|
|
let request = unhex(&st(rec, "request_hex"));
|
|
let expected = rec["responses"].as_array().unwrap();
|
|
|
|
let (frame, _) = Frame::parse(&request).unwrap();
|
|
let body = if frame.payload.is_empty() {
|
|
heat2::Struct::new()
|
|
} else {
|
|
heat2::decode(&frame.payload).unwrap()
|
|
};
|
|
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
|
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
|
|
|
assert_eq!(out.len(), expected.len(), "{name}: frame count");
|
|
|
|
for (got, want_hex) in out.iter().zip(expected.iter()) {
|
|
let want = Frame::parse(&unhex(want_hex.as_str().unwrap())).unwrap().0;
|
|
assert_eq!(
|
|
got.header.component, want.header.component,
|
|
"{name}: component"
|
|
);
|
|
assert_eq!(got.header.command, want.header.command, "{name}: command");
|
|
assert_eq!(got.header.msg_type, want.header.msg_type, "{name}: msgType");
|
|
assert_eq!(got.header.msg_num, want.header.msg_num, "{name}: msgNum");
|
|
assert_eq!(
|
|
got.header.user_index, want.header.user_index,
|
|
"{name}: userIndex"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The login burst is the sequence most likely to be silently wrong, so pin it
|
|
/// explicitly rather than relying on it being buried in the byte comparison.
|
|
#[test]
|
|
fn login_emits_reply_then_exactly_three_pushes() {
|
|
let rp = setup();
|
|
let login = rp
|
|
.records
|
|
.iter()
|
|
.find(|r| r["kind"] == "tx" && r["name"] == "login")
|
|
.expect("login transaction present");
|
|
|
|
let frames: Vec<Frame> = login["responses"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|h| Frame::parse(&unhex(h.as_str().unwrap())).unwrap().0)
|
|
.collect();
|
|
|
|
assert_eq!(frames.len(), 4, "reply + three UserSessions pushes");
|
|
assert_eq!(
|
|
frames[0].header.msg_type,
|
|
openfut_protocol_blaze::fire2::MsgType::Reply
|
|
);
|
|
let notify_ids: Vec<u16> = frames[1..].iter().map(|f| f.header.command).collect();
|
|
assert_eq!(notify_ids, vec![0x0008, 0x0001, 0x0002]);
|
|
}
|
|
|
|
/// The generator uses non-loopback addresses, so any loopback literal left in a
|
|
/// response means the adapter hardcoded something it should have read from
|
|
/// config — the exact regression the client/server split was meant to prevent.
|
|
///
|
|
/// Two keys are genuine literals in the oracle, not substitution failures.
|
|
/// Both are OAuth redirect targets the client never actually dials (the flow is
|
|
/// forged), so the loopback is inert; they are allowlisted by key rather than
|
|
/// by pattern so a third one cannot slip in unnoticed.
|
|
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
|
|
|
|
#[test]
|
|
fn no_response_hardcodes_a_loopback_address() {
|
|
let mut rp = setup();
|
|
let records = rp.records.clone();
|
|
let advertise = "198.51.100.7";
|
|
|
|
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
|
let name = st(rec, "name");
|
|
let request = unhex(&st(rec, "request_hex"));
|
|
let (frame, _) = Frame::parse(&request).unwrap();
|
|
let body = if frame.payload.is_empty() {
|
|
heat2::Struct::new()
|
|
} else {
|
|
heat2::decode(&frame.payload).unwrap()
|
|
};
|
|
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
|
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
|
|
|
for f in &out {
|
|
let text = String::from_utf8_lossy(&f.payload);
|
|
for (at, _) in text.match_indices("127.0.0.1") {
|
|
// TDF strings are length-prefixed and NUL-terminated, so the
|
|
// owning key sits shortly before the value. Look back far
|
|
// enough to name it, and require it to be allowlisted.
|
|
let start = at.saturating_sub(80);
|
|
let context = &text[start..text.len().min(at + 64)];
|
|
assert!(
|
|
ALLOWED_LOOPBACK_KEYS.iter().any(|k| context.contains(k)),
|
|
"\n{name}: unexpected loopback literal, context {context:?}"
|
|
);
|
|
}
|
|
}
|
|
// The advertised address must actually appear somewhere in the config
|
|
// responses, or substitution silently did nothing.
|
|
if name.starts_with("fetch_config") || name == "preauth" {
|
|
let text = String::from_utf8_lossy(&out[0].payload);
|
|
assert!(
|
|
text.contains(advertise),
|
|
"{name}: advertised address missing from the config payload"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session state must survive across RPCs on one connection, and must NOT leak
|
|
/// between connections.
|
|
#[test]
|
|
fn session_state_is_per_connection() {
|
|
let mut rp = setup();
|
|
let records = rp.records.clone();
|
|
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
|
let request = unhex(&st(rec, "request_hex"));
|
|
let (frame, _) = Frame::parse(&request).unwrap();
|
|
let body = if frame.payload.is_empty() {
|
|
heat2::Struct::new()
|
|
} else {
|
|
heat2::decode(&frame.payload).unwrap()
|
|
};
|
|
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
|
rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
|
}
|
|
|
|
// "main" logged in with an auth code and an enUS preAuth.
|
|
let main = &rp.sessions["main"];
|
|
assert!(main.logged_in);
|
|
assert_eq!(main.auth_code, "OPENFUT-TEST-AUTHCODE");
|
|
assert_eq!(main.account_locale, 0x656E_5553);
|
|
|
|
// "locale" ran a deDE preAuth and a login carrying no AUTH member.
|
|
let loc = &rp.sessions["locale"];
|
|
assert_eq!(loc.account_locale, 0x6465_4445, "deDE locale captured");
|
|
assert_eq!(loc.service_name, "fifa-2017-pc-de");
|
|
assert!(loc.auth_code.is_empty());
|
|
|
|
// "fallbacks" never logged in.
|
|
assert!(!rp.sessions["fallbacks"].logged_in);
|
|
}
|
|
|
|
// ────────────────────────────── redirector ──────────────────────────────
|
|
//
|
|
// The first hop. A different protocol from Blaze — HTTPS with an XML body —
|
|
// but the same rule: byte-for-byte against the oracle.
|
|
|
|
#[test]
|
|
fn redirect_response_matches_python_oracle_byte_for_byte() {
|
|
use openfut_adapter_fifa17::redirector;
|
|
|
|
let path = format!("{}/fixtures/redirector.json", env!("CARGO_MANIFEST_DIR"));
|
|
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
|
|
let table: serde_json::Map<String, J> = serde_json::from_str(&text).expect("valid JSON");
|
|
assert!(table.len() >= 3, "expected several advertised addresses");
|
|
|
|
for (advertise, want_hex) in &table {
|
|
let mut cfg = AdapterConfig::default();
|
|
cfg.endpoints.advertise = advertise.clone();
|
|
|
|
let got = redirector::redirect_response(&cfg);
|
|
assert_eq!(
|
|
hex(&got),
|
|
want_hex.as_str().unwrap(),
|
|
"\nredirector response differs for advertise={advertise}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The advertised Blaze endpoint must follow config, and the fixtures use
|
|
/// deliberately different addresses so a hardcoded one cannot pass.
|
|
#[test]
|
|
fn redirect_response_is_configurable_not_baked() {
|
|
use openfut_adapter_fifa17::redirector;
|
|
|
|
let mut a = AdapterConfig::default();
|
|
a.endpoints.advertise = "10.0.0.5".into();
|
|
let mut b = AdapterConfig::default();
|
|
b.endpoints.advertise = "10.0.0.6".into();
|
|
|
|
assert_ne!(
|
|
redirector::redirect_response(&a),
|
|
redirector::redirect_response(&b),
|
|
"the advertised address must reach the wire"
|
|
);
|
|
}
|