f451406058
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
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::loopback()
|
|
};
|
|
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::loopback();
|
|
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::loopback();
|
|
a.endpoints.advertise = "10.0.0.5".into();
|
|
let mut b = AdapterConfig::loopback();
|
|
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"
|
|
);
|
|
}
|