Files
OpenFUT/openfut-lsx/src/protocol.rs
T
funman300 750d6c2e18 feat(companions): port the launcher's two Python services to Rust
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).

openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.

openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').

The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.

VERIFICATION — the ports are checked against the Python, not against themselves:

* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
  constants, which proves consistency, not parity, and the Python cannot run here
  (pycryptodome absent) with the client host unreachable. So the LCG and key derivation
  were transcribed from the Python and run as plain arithmetic, and every AES value came
  from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
  954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
  key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
  payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
  netns, so the smoke test runs under `unshare -n` — the real binary on the port the
  client actually dials, with no port-override hack and no risk to production. A
  hand-written client read the unprompted <Challenge>, completed the handshake, and
  decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
  derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
  candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
  the exact Python message, dual stdout+logfile output, and a missing value rejected
  with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
  every constant by executing the Python module and drove the binary against a synthetic
  client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
  confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
  own parser fixture, so backend capability registration still works.

Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
2026-08-18 05:31:00 +00:00

915 lines
35 KiB
Rust

//! Request parsing, verb dispatch and log redaction.
//!
//! The dispatch is request-DRIVEN (the Steampunks stub was a blind fixed script),
//! and the frame grammar is the Python's three regexes, transcribed by hand so the
//! crate needs no regex engine. Where the regexes are tolerant, this is tolerant
//! in the same way -- see [`parse_request`].
use std::fmt::Write as _;
use std::fs;
use crate::events::Conn;
use crate::identity::{Identity, CONTENT_ID, ENTITLEMENT_TAG};
use crate::{env_flag, log, Env, AUTHCODE_FILE, CLIENTID_FILE};
/// Attributes of the request's child element, in the order they appeared.
///
/// Python builds `dict(ATTR_RE.findall(rest))`: a repeated attribute keeps its
/// FIRST position but takes its LAST value, and that dict is echoed into the
/// `<< id=...` log line, so the ordering is observable.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Attrs<'a>(Vec<(&'a str, &'a str)>);
impl<'a> Attrs<'a> {
pub fn new() -> Self {
Self(Vec::new())
}
pub fn insert(&mut self, key: &'a str, value: &'a str) {
match self.0.iter_mut().find(|(k, _)| *k == key) {
Some(slot) => slot.1 = value,
None => self.0.push((key, value)),
}
}
pub fn get(&self, key: &str) -> Option<&'a str> {
self.0.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
/// `attrs.get(key, "")`.
pub fn get_or_empty(&self, key: &str) -> &'a str {
self.get(key).unwrap_or("")
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
self.0.iter().copied()
}
/// `repr(dict)`, because the request log line embeds it verbatim.
pub fn py_repr(&self) -> String {
let mut s = String::from("{");
for (i, (k, v)) in self.0.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
let _ = write!(s, "{}: {}", py_repr(k), py_repr(v));
}
s.push('}');
s
}
}
impl<'a> FromIterator<(&'a str, &'a str)> for Attrs<'a> {
fn from_iter<T: IntoIterator<Item = (&'a str, &'a str)>>(iter: T) -> Self {
let mut attrs = Attrs::new();
for (k, v) in iter {
attrs.insert(k, v);
}
attrs
}
}
/// One parsed `<Request>` frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request<'a> {
/// The digits of `id="N"`, kept as text: it is echoed, never arithmetic.
pub id: &'a str,
pub name: &'a str,
pub attrs: Attrs<'a>,
/// The response `sender` must byte-equal this (matcher 0x1471189b0). Captured
/// separately from the rest of the frame, and defaulting to "", so a frame
/// that ever lacks `recipient` still gets answered fast instead of a 15s
/// stall.
pub recipient: &'a str,
}
/// `REQ_RE = r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus
/// `RECIP_RE = r'<Request[^>]*\brecipient="([^"]*)"'`, both as `re.search`.
///
/// Transcription notes, all of them observable behaviour rather than style:
/// * `[^>]*` cannot cross a `>`, so `id` must live inside the `<Request ...>`
/// tag itself; the tag must be closed for the pattern to match at all.
/// * that leading `[^>]*` is greedy, so with more than one `id="N"` in the tag
/// the RIGHTMOST one wins.
/// * the trailing `([^>]*)/?>` is greedy too, so the captured attribute region
/// keeps a self-closing `/` -- harmless, `ATTR_RE` skips it.
/// * `recipient` is searched over the whole frame independently of the element
/// match, and its value may legally contain `>`.
pub fn parse_request(xml: &str) -> Option<Request<'_>> {
const TAG: &str = "<Request";
let mut from = 0;
while let Some(off) = xml[from..].find(TAG) {
let start = from + off;
from = start + TAG.len();
let tag_end = match xml[from..].find('>') {
Some(i) => from + i,
None => continue, // `[^>]*>` needs the tag to close
};
let Some(id) = rightmost_id(xml, from, tag_end) else {
continue;
};
// `>\s*<`
let child = xml[tag_end + 1..].trim_start_matches(char::is_whitespace);
let Some(child) = child.strip_prefix('<') else {
continue;
};
let name_len = child
.as_bytes()
.iter()
.take_while(|c| c.is_ascii_alphabetic())
.count();
if name_len == 0 {
continue;
}
let (name, tail) = child.split_at(name_len);
let Some(gt) = tail.find('>') else {
continue;
};
return Some(Request {
id,
name,
attrs: parse_attrs(&tail[..gt]),
recipient: find_recipient(xml).unwrap_or(""),
});
}
None
}
/// The rightmost `\bid="(\d+)"` inside `xml[from..tag_end]`.
fn rightmost_id(xml: &str, from: usize, tag_end: usize) -> Option<&str> {
let tag = &xml[from..tag_end];
let mut search_end = tag.len();
while let Some(at) = tag[..search_end].rfind("id=\"") {
search_end = at;
// `\b`: the character before `id` must not be a word character. Look at
// the whole frame, so `<Requestid="1">` correctly fails.
if !word_boundary_before(xml, from + at) {
continue;
}
let value = &tag[at + 4..];
let digits = value.as_bytes().iter().take_while(|c| c.is_ascii_digit()).count();
if digits > 0 && value.as_bytes().get(digits) == Some(&b'"') {
return Some(&value[..digits]);
}
}
None
}
fn find_recipient(xml: &str) -> Option<&str> {
const TAG: &str = "<Request";
let mut from = 0;
while let Some(off) = xml[from..].find(TAG) {
from += off + TAG.len();
// The literal must start before the tag closes; the VALUE may run past it.
let limit = xml[from..].find('>').map_or(xml.len(), |i| from + i);
let mut search_end = limit;
while let Some(at) = xml[from..search_end].rfind("recipient=\"") {
let at = from + at;
search_end = at;
if !word_boundary_before(xml, at) {
continue;
}
let value_start = at + "recipient=\"".len();
if let Some(len) = xml[value_start..].find('"') {
return Some(&xml[value_start..value_start + len]);
}
}
}
None
}
/// `ATTR_RE.findall` -- `(\w+)="([^"]*)"`, non-overlapping, left to right.
fn parse_attrs(region: &str) -> Attrs<'_> {
let mut attrs = Attrs::new();
let mut pos = 0;
while let Some(off) = region[pos..].find("=\"") {
let eq = pos + off;
// Greedy `\w+` immediately before `="`.
let name_len: usize = region[..eq]
.chars()
.rev()
.take_while(|c| is_word(*c))
.map(char::len_utf8)
.sum();
let value_start = eq + 2;
let Some(value_len) = region[value_start..].find('"') else {
break; // an unterminated value ends the scan, as the regex does
};
if name_len > 0 {
attrs.insert(
&region[eq - name_len..eq],
&region[value_start..value_start + value_len],
);
}
pos = value_start + value_len + 1;
}
attrs
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn word_boundary_before(s: &str, at: usize) -> bool {
at == 0 || !s[..at].chars().next_back().is_some_and(is_word)
}
// ------------------------------------------------------------------ redaction
const SECRET_ATTRS: [&str; 5] = ["AuthCode", "AuthToken", "SessionKey", "Token", "Sid"];
const AUTH_CODE_ATTRS: [&str; 3] = ["value", "Code", "Return"];
const CHALLENGE_ATTRS: [&str; 1] = ["response"];
/// Redact credential-bearing LSX attributes from ordinary diagnostics.
///
/// The blanket pass only catches `Name="..."` pairs; the auth code and the
/// challenge response hide behind generic attribute names (`value`, `Code`,
/// `Return`, `response`), so those two elements get a second, element-scoped pass
/// -- otherwise `<ErrorSuccess Code="0">` would be redacted too and the ordinary
/// status lines would stop being readable.
pub fn safe_xml_for_log(xml: &str) -> String {
let safe = redact_attrs(xml, &SECRET_ATTRS);
let safe = if safe.contains("<AuthCode ") {
redact_attrs(&safe, &AUTH_CODE_ATTRS)
} else {
safe
};
if safe.contains("<ChallengeAccepted ") {
redact_attrs(&safe, &CHALLENGE_ATTRS)
} else {
safe
}
}
/// `re.sub(r'(?i)\b(a|b|c)="[^"]*"', r'\1="[REDACTED]"', xml)`; the attribute name
/// keeps the case it was written in.
fn redact_attrs(input: &str, names: &[&str]) -> String {
let mut out = String::with_capacity(input.len());
let mut last = 0;
let mut i = 0;
while i < input.len() {
if !input.is_char_boundary(i) || !word_boundary_before(input, i) {
i += 1;
continue;
}
let rest = &input[i..];
let hit = names.iter().find_map(|name| {
let after = rest.get(..name.len())?;
if !after.eq_ignore_ascii_case(name) {
return None;
}
let value = rest[name.len()..].strip_prefix("=\"")?;
let len = value.find('"')?;
Some((name.len(), name.len() + 2 + len + 1))
});
match hit {
Some((name_len, match_len)) => {
out.push_str(&input[last..i]);
out.push_str(&rest[..name_len]);
out.push_str("=\"[REDACTED]\"");
i += match_len;
last = i;
}
None => i += 1,
}
}
out.push_str(&input[last..]);
out
}
/// `repr()` of a Python string, for the log lines that embed one.
pub fn py_repr(s: &str) -> String {
let quote = if s.contains('\'') && !s.contains('"') {
'"'
} else {
'\''
};
let mut out = String::with_capacity(s.len() + 2);
out.push(quote);
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c == quote => {
out.push('\\');
out.push(c);
}
// Python renders the remaining ASCII controls as \xNN; anything
// printable (including non-ASCII) goes through verbatim.
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
let _ = write!(out, "\\x{:02x}", c as u32);
}
c => out.push(c),
}
}
out.push(quote);
out
}
// ------------------------------------------------------------------ responses
pub fn resp(mid: &str, body: &str, sender: &str) -> String {
format!(r#"<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>"#)
}
/// Knobs that shape individual replies.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtocolConfig {
/// A/B-control integrity: v1 (`lsx_responder.py`) answered GetGameInfo
/// FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
/// silently changed it to "true", which meant `OPENFUT_LSX_EVENTS=0` was NOT a
/// byte-identical control any more. Keep it OFF by default so events-off ==
/// v1 exactly; flip `OPENFUT_LSX_FULLGAME=1` to run the FULLGAME="true"
/// experiment on its own.
pub fullgame_purchased: bool,
/// `OPENFUT_AUTHCODE`.
pub auth_code: String,
}
impl Default for ProtocolConfig {
fn default() -> Self {
Self {
fullgame_purchased: false,
auth_code: format!("OPENFUT-{}", "0".repeat(24)),
}
}
}
impl ProtocolConfig {
pub fn from_env() -> Self {
Self::from_vars(&crate::os_env)
}
pub fn from_vars(env: Env<'_>) -> Self {
Self {
fullgame_purchased: env_flag(env, "OPENFUT_LSX_FULLGAME", false),
auth_code: env("OPENFUT_AUTHCODE").unwrap_or_else(|| Self::default().auth_code),
}
}
}
/// The verb dispatcher.
pub struct Responder {
pub identity: Identity,
pub config: ProtocolConfig,
}
impl Responder {
pub fn new(identity: Identity, config: ProtocolConfig) -> Self {
Self { identity, config }
}
/// Request-DRIVEN dispatch.
///
/// CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
/// 0x1471189b0 rejects any `<Response>` whose `sender` attribute does not
/// byte-equal the `recipient` the client put on the matching `<Request>` (it
/// reads `serviceNames[facility]`; with our empty GetConfigResponse all 34
/// names are "" so recipient="" for every verb after GetConfig, which itself
/// uses the hard-coded literal "EbisuSDK"). We were answering GetProfile/
/// GetAuthCode/QueryEntitlements with sender="EbisuSDK" -> silently discarded
/// -> GetProfile (the SOLE writer of OriginSDK+0x3a0 default-user) never took
/// -> the whole online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO
/// the request's recipient back as the response sender, which is what every
/// `reply` below does.
///
/// `conn` is `None` in the selftest; a `None` connection must not touch the
/// run's success-signal files.
pub fn build_reply(
&self,
mid: &str,
req_name: &str,
attrs: &Attrs<'_>,
conn: Option<&Conn>,
recipient: &str,
) -> String {
let reply = |body: &str| resp(mid, body, recipient);
match req_name {
// FLAG (1): "internet is reachable". The stub hardcoded
// connected="0" -> "log in to Origin". This is NOT the logged-in
// flag; see the crate docs.
"GetInternetConnectedState" => reply(r#"InternetConnectedState connected="1""#),
// Request shape is built at 0x14713b8d0:
// <GetAuthCode ClientId="..." Scope="..."/>
// Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
//
// THE ATTRIBUTE NAME IS "value" -- verified, not guessed: the
// "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
// -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
// attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
// @0x1436c7768, concat at 0x14712d130) and does exactly ONE
// get-attribute-as-string call 0x14713fe50(node, "value", &dest).
// dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands
// at +0xc8 -- which is what OriginRequestAuthCodeSync's impl
// 0x1470e67f0 reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as
// *out_len. Code=/Return= are NEVER read; with them alone the parsed
// string is empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the
// OSDK classifier 0x14717d5d0 falls into its `test rbp,rbp / je` arm
// and reports OSDK_UNDERAGE_ERROR (a mislabelled "no auth code"
// fallback). Code=/Return= are kept only as harmless padding.
"GetAuthCode" => {
let client_id = attrs.get_or_empty("ClientId");
let scope = attrs.get_or_empty("Scope");
let code = &self.config.auth_code;
// Only touch the run's success-signal files on a REAL request. The
// selftest passes conn=None; if it wrote these files it would
// pre-satisfy watch-step "authcode.txt becomes non-empty" and make
// a non-event read as success on the next live run.
if let Some(conn) = conn {
for (path, val) in [(AUTHCODE_FILE, code.as_str()), (CLIENTID_FILE, client_id)]
{
if let Err(e) = fs::write(path, val) {
log!("could not write {path}: {e}");
}
}
// GetAuthCode has fired: stop the heartbeat so we do not keep
// re-pushing Login/OnlineStatus events during Blaze login.
conn.stop_events();
}
log!("*** GetAuthCode ISSUED ***");
log!(
" ClientId={} Scope={}",
py_repr(client_id),
py_repr(scope)
);
log!(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)");
reply(&format!(
r#"AuthCode value="{code}" Code="{code}" Return="{code}""#
))
}
// The only reply with a child element, so it cannot use the
// single-element `resp` helper.
"QueryEntitlements" => format!(
concat!(
r#"<LSX><Response id="{mid}" sender="{recipient}">"#,
r#"<QueryEntitlementsResponse>"#,
r#"<OriginItem ItemId="{tag}" EntitlementId="1" "#,
r#"ResourceId="{content}" OfferId="{content}" "#,
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
r#"</QueryEntitlementsResponse>"#,
r#"</Response></LSX>"#
),
mid = mid,
recipient = recipient,
tag = ENTITLEMENT_TAG,
content = CONTENT_ID
),
// This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
// (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
// @0x1470da680 are bare reads of those fields, written only by
// OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
// ONLY PersonaId/UserId/Persona come from the identity; the rest of
// this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
// AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
// and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
"GetProfile" => reply(&format!(
concat!(
r#"GetProfileResponse IsSubscriber="true" PersonaId="{persona}" "#,
r#"AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" "#,
r#"UserId="{user}" Persona="{name}" IsUnderAge="false" "#,
r#"CommerceCurrency="USD""#
),
persona = self.identity.persona_id,
user = self.identity.user_id(),
name = self.identity.persona_name
)),
"GetGameInfo" => match attrs.get("GameInfoId") {
Some("LANGUAGES") => reply(concat!(
r#"GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,"#,
r#"en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,"#,
r#"pt_PT,ru_RU,sv_SE,tr_TR,zh_TW""#
)),
// MUST be true or the client shows "Your title version is
// outdated" and blocks all online features.
Some("UPTODATE") => reply(r#"GetGameInfoResponse GameInfo="true""#),
// OFF by default: v1 answered "false" here (fell through to the
// default), and keeping this gated makes OPENFUT_LSX_EVENTS=0
// byte-identical to v1.
Some("FULLGAME_PURCHASED") if self.config.fullgame_purchased => {
reply(r#"GetGameInfoResponse GameInfo="true""#)
}
// FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a
// trial; matches v1 exactly)
_ => reply(r#"GetGameInfoResponse GameInfo="false""#),
},
"GetSetting" => {
// The client asks in UPPERCASE.
match attrs.get_or_empty("SettingId").to_uppercase().as_str() {
"ENVIRONMENT" | "ENVIRONMENTNAME" => {
reply(r#"GetSettingResponse Setting="production""#)
}
"LANGUAGE" => reply(&format!(
r#"GetSettingResponse Setting="{}""#,
self.identity.locale
)),
_ => reply(r#"GetSettingResponse Setting="false""#),
}
}
"GetConfig" => reply(r#"GetConfigResponse Config="false""#),
"IsProgressiveInstallationAvailable" => reply(concat!(
r#"IsProgressiveInstallationAvailableResponse ItemId="" "#,
r#"Available="false""#
)),
_ => reply(r#"ErrorSuccess Code="0" Description="""#),
}
}
}
/// Trigger points: push right after answering these verbs. GetProfile is the
/// earliest safe moment -- by then the SDK has built its handler set and has a
/// default user, so a Login event has somewhere to land.
///
/// For GetGameInfo the caller only fires on UPTODATE, otherwise we would push
/// three times per boot for FREETRIAL/LANGUAGES too.
pub fn push_after(req_name: &str) -> Option<&'static str> {
match req_name {
"GetProfile" => Some("after GetProfile"),
"GetInternetConnectedState" => Some("after GetInternetConnectedState"),
"GetGameInfo" => Some("after GetGameInfo UPTODATE"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |k: &str| map.get(k).cloned()
}
fn responder() -> Responder {
Responder::new(Identity::default(), ProtocolConfig::default())
}
fn reply(verb: &str, attrs: &[(&str, &str)]) -> String {
responder().build_reply("7", verb, &attrs.iter().copied().collect(), None, "")
}
// ---------------------------------------------------------------- parsing
#[test]
fn parses_a_request_with_a_recipient() {
let xml = r#"<LSX><Request id="12" recipient="EbisuSDK"><GetConfig Locale="en_US" Env="prod"/></Request></LSX>"#;
let req = parse_request(xml).unwrap();
assert_eq!(req.id, "12");
assert_eq!(req.name, "GetConfig");
assert_eq!(req.recipient, "EbisuSDK");
assert_eq!(req.attrs.get("Locale"), Some("en_US"));
assert_eq!(req.attrs.get("Env"), Some("prod"));
assert_eq!(req.attrs.len(), 2);
}
#[test]
fn parses_a_request_without_a_recipient() {
// Must still be answered -- fast -- rather than stalling ~15s.
let xml = r#"<LSX><Request id="3"><GetProfile/></Request></LSX>"#;
let req = parse_request(xml).unwrap();
assert_eq!((req.id, req.name, req.recipient), ("3", "GetProfile", ""));
assert!(req.attrs.is_empty());
}
#[test]
fn parses_an_empty_recipient() {
let xml = r#"<LSX><Request id="9" recipient=""><GetSetting SettingId="LANGUAGE"/></Request></LSX>"#;
let req = parse_request(xml).unwrap();
assert_eq!(req.recipient, "");
assert_eq!(req.attrs.get("SettingId"), Some("LANGUAGE"));
}
#[test]
fn tolerates_whitespace_between_the_request_and_its_element() {
let xml = "<LSX><Request id=\"4\" recipient=\"X\">\n <GetAuthCode ClientId=\"c\" Scope=\"s\" />\n</Request></LSX>";
let req = parse_request(xml).unwrap();
assert_eq!((req.id, req.name, req.recipient), ("4", "GetAuthCode", "X"));
assert_eq!(req.attrs.get("ClientId"), Some("c"));
assert_eq!(req.attrs.get("Scope"), Some("s"));
}
#[test]
fn rejects_frames_that_are_not_requests() {
for xml in [
"",
r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
r#"<LSX><Request recipient="X"><GetConfig/></Request></LSX>"#, // no id
r#"<LSX><Request id="abc"><GetConfig/></Request></LSX>"#, // id not digits
r#"<LSX><Requestid="1"><GetConfig/></Request></LSX>"#, // \b fails
r#"<LSX><Request id="1">"#, // no element
] {
assert!(parse_request(xml).is_none(), "{xml}");
}
}
#[test]
fn a_repeated_attribute_keeps_its_place_and_takes_the_last_value() {
let xml = r#"<LSX><Request id="1"><V a="1" b="2" a="3"/></Request></LSX>"#;
let req = parse_request(xml).unwrap();
assert_eq!(req.attrs.py_repr(), "{'a': '3', 'b': '2'}");
}
#[test]
fn greedy_id_takes_the_rightmost_one() {
let xml = r#"<LSX><Request id="1" other-id="2"><GetConfig/></Request></LSX>"#;
assert_eq!(parse_request(xml).unwrap().id, "2");
}
#[test]
fn the_self_closing_slash_does_not_become_an_attribute() {
let xml = r#"<LSX><Request id="1"><GetGameInfo GameInfoId="UPTODATE"/></Request></LSX>"#;
let req = parse_request(xml).unwrap();
assert_eq!(req.attrs.len(), 1);
assert_eq!(req.attrs.get("GameInfoId"), Some("UPTODATE"));
}
// ------------------------------------------------------------------ verbs
#[test]
fn internet_connected_state_is_one() {
assert_eq!(
reply("GetInternetConnectedState", &[]),
r#"<LSX><Response id="7" sender=""><InternetConnectedState connected="1"/></Response></LSX>"#
);
}
#[test]
fn auth_code_carries_the_value_attribute() {
let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]);
assert_eq!(
r,
concat!(
r#"<LSX><Response id="7" sender=""><AuthCode "#,
r#"value="OPENFUT-000000000000000000000000" "#,
r#"Code="OPENFUT-000000000000000000000000" "#,
r#"Return="OPENFUT-000000000000000000000000"/></Response></LSX>"#
)
);
}
#[test]
fn auth_code_honours_the_env_override() {
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_AUTHCODE", "ABC")]));
let r = Responder::new(Identity::default(), cfg).build_reply(
"1",
"GetAuthCode",
&Attrs::new(),
None,
"",
);
assert!(r.contains(r#"<AuthCode value="ABC" Code="ABC" Return="ABC"/>"#), "{r}");
}
#[test]
fn query_entitlements_owns_the_retail_offer() {
assert_eq!(
reply("QueryEntitlements", &[]),
concat!(
r#"<LSX><Response id="7" sender=""><QueryEntitlementsResponse>"#,
r#"<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" "#,
r#"ResourceId="1027460" OfferId="1027460" "#,
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
r#"</QueryEntitlementsResponse></Response></LSX>"#
)
);
}
#[test]
fn profile_carries_the_identity_and_the_verbatim_template() {
assert_eq!(
reply("GetProfile", &[]),
concat!(
r#"<LSX><Response id="7" sender=""><GetProfileResponse IsSubscriber="true" "#,
r#"PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" "#,
r#"GeoCountry="US" UserId="33068179" Persona="CAGE" IsUnderAge="false" "#,
r#"CommerceCurrency="USD"/></Response></LSX>"#
)
);
}
#[test]
fn profile_echoes_an_overridden_persona() {
let ident = Identity::from_vars(&env_of(&[
("FUT_PERSONA_ID", "42"),
("FUT_PERSONA_NAME", "ZED"),
]))
.unwrap();
let r = Responder::new(ident, ProtocolConfig::default())
.build_reply("1", "GetProfile", &Attrs::new(), None, "");
assert!(r.contains(r#"PersonaId="42""#), "{r}");
assert!(r.contains(r#"UserId="42""#), "{r}");
assert!(r.contains(r#"Persona="ZED""#), "{r}");
}
#[test]
fn game_info_languages_and_uptodate() {
let r = reply("GetGameInfo", &[("GameInfoId", "LANGUAGES")]);
assert!(r.contains(r#"GameInfo="ar_SA,cs_CZ,"#), "{r}");
assert!(r.ends_with(r#"tr_TR,zh_TW"/></Response></LSX>"#), "{r}");
assert!(reply("GetGameInfo", &[("GameInfoId", "UPTODATE")])
.contains(r#"GetGameInfoResponse GameInfo="true""#));
}
#[test]
fn fullgame_purchased_is_false_unless_the_knob_is_set() {
// A/B-control integrity: events-off must stay byte-identical to v1.
for id in ["FULLGAME_PURCHASED", "FREETRIAL", "ANYTHING_ELSE"] {
assert!(
reply("GetGameInfo", &[("GameInfoId", id)])
.contains(r#"GetGameInfoResponse GameInfo="false""#),
"{id}"
);
}
// ... and a GetGameInfo with no GameInfoId at all.
assert!(reply("GetGameInfo", &[]).contains(r#"GameInfo="false""#));
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", "1")]));
let on = Responder::new(Identity::default(), cfg);
assert!(on
.build_reply(
"7",
"GetGameInfo",
&[("GameInfoId", "FULLGAME_PURCHASED")].into_iter().collect(),
None,
""
)
.contains(r#"GameInfo="true""#));
// Still only that one id flips.
assert!(on
.build_reply(
"7",
"GetGameInfo",
&[("GameInfoId", "FREETRIAL")].into_iter().collect(),
None,
""
)
.contains(r#"GameInfo="false""#));
}
#[test]
fn fullgame_knob_follows_the_not_zero_rule() {
for (value, on) in [("0", false), ("1", true), ("", true), ("false", true)] {
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", value)]));
assert_eq!(cfg.fullgame_purchased, on, "OPENFUT_LSX_FULLGAME={value:?}");
}
}
#[test]
fn settings() {
for id in ["ENVIRONMENT", "environment", "EnvironmentName"] {
assert!(
reply("GetSetting", &[("SettingId", id)])
.contains(r#"GetSettingResponse Setting="production""#),
"{id}"
);
}
assert!(reply("GetSetting", &[("SettingId", "LANGUAGE")])
.contains(r#"GetSettingResponse Setting="en_US""#));
assert!(reply("GetSetting", &[("SettingId", "WHATEVER")])
.contains(r#"GetSettingResponse Setting="false""#));
assert!(reply("GetSetting", &[]).contains(r#"Setting="false""#));
let ident = Identity::from_vars(&env_of(&[("FUT_LOCALE", "fr_FR")])).unwrap();
assert!(Responder::new(ident, ProtocolConfig::default())
.build_reply(
"7",
"GetSetting",
&[("SettingId", "LANGUAGE")].into_iter().collect(),
None,
""
)
.contains(r#"Setting="fr_FR""#));
}
#[test]
fn config_is_empty_which_is_why_every_recipient_is_the_empty_string() {
assert!(reply("GetConfig", &[]).contains(r#"GetConfigResponse Config="false""#));
}
#[test]
fn progressive_installation_is_unavailable() {
assert!(reply("IsProgressiveInstallationAvailable", &[]).contains(
r#"IsProgressiveInstallationAvailableResponse ItemId="" Available="false""#
));
}
#[test]
fn unknown_verbs_fall_through_to_error_success() {
assert_eq!(
reply("GetSomethingWeHaveNeverSeen", &[]),
r#"<LSX><Response id="7" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#
);
}
#[test]
fn the_response_sender_byte_equals_the_request_recipient() {
let r = responder().build_reply("5", "GetProfile", &Attrs::new(), None, "EbisuSDK");
assert!(r.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{r}");
// Including for the one verb built without the `resp` helper.
let q = responder().build_reply("5", "QueryEntitlements", &Attrs::new(), None, "EbisuSDK");
assert!(q.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{q}");
}
#[test]
fn push_triggers() {
assert_eq!(push_after("GetProfile"), Some("after GetProfile"));
assert_eq!(
push_after("GetInternetConnectedState"),
Some("after GetInternetConnectedState")
);
assert_eq!(push_after("GetGameInfo"), Some("after GetGameInfo UPTODATE"));
assert_eq!(push_after("GetConfig"), None);
assert_eq!(push_after("GetAuthCode"), None);
}
// -------------------------------------------------------------- redaction
#[test]
fn redacts_credential_attributes() {
let safe = safe_xml_for_log(
r#"<Thing AuthCode="a" AuthToken="b" SessionKey="c" Token="d" Sid="e" Keep="f"/>"#,
);
assert_eq!(
safe,
concat!(
r#"<Thing AuthCode="[REDACTED]" AuthToken="[REDACTED]" "#,
r#"SessionKey="[REDACTED]" Token="[REDACTED]" Sid="[REDACTED]" Keep="f"/>"#
)
);
}
#[test]
fn redacts_the_auth_code_element_generically() {
let safe =
safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
assert!(!safe.contains("secret"), "{safe}");
assert_eq!(safe.matches("[REDACTED]").count(), 3, "{safe}");
}
#[test]
fn redacts_the_challenge_response() {
let safe = safe_xml_for_log(
r#"<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="e4f5"/></Response></LSX>"#,
);
assert!(!safe.contains("e4f5"), "{safe}");
assert!(safe.contains(r#"response="[REDACTED]""#), "{safe}");
assert!(safe.contains(r#"id="1""#), "{safe}");
}
#[test]
fn leaves_ordinary_status_lines_readable() {
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
assert_eq!(status, r#"<ErrorSuccess Code="0" Description=""/>"#);
// The element name alone must not trip the blanket pass.
assert_eq!(
safe_xml_for_log(r#"<GetConfigResponse Config="false"/>"#),
r#"<GetConfigResponse Config="false"/>"#
);
}
#[test]
fn redaction_is_case_insensitive_and_keeps_the_written_case() {
assert_eq!(
safe_xml_for_log(r#"<X authtoken="q" SID="r"/>"#),
r#"<X authtoken="[REDACTED]" SID="[REDACTED]"/>"#
);
}
#[test]
fn redaction_respects_word_boundaries() {
// `MySid="x"` has no boundary before `Sid`, so it is left alone.
assert_eq!(
safe_xml_for_log(r#"<X MySid="x"/>"#),
r#"<X MySid="x"/>"#
);
}
#[test]
fn python_repr_of_strings() {
assert_eq!(py_repr(""), "''");
assert_eq!(py_repr("X"), "'X'");
assert_eq!(py_repr("it's"), "\"it's\"");
assert_eq!(py_repr("a\"b"), "'a\"b'");
assert_eq!(py_repr("a\nb"), r"'a\nb'");
assert_eq!(py_repr("a\\b"), r"'a\\b'");
assert_eq!(Attrs::new().py_repr(), "{}");
}
}