//! 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 + '_ { 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>(iter: T) -> Self { let mut attrs = Attrs::new(); for (k, v) in iter { attrs.insert(k, v); } attrs } } /// One parsed `` 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']*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus /// `RECIP_RE = r']*\brecipient="([^"]*)"'`, both as `re.search`. /// /// Transcription notes, all of them observable behaviour rather than style: /// * `[^>]*` cannot cross a `>`, so `id` must live inside the `` /// 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> { const TAG: &str = "') { 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 `` 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 = "').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( ®ion[eq - name_len..eq], ®ion[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 `` 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(" 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#"<{body}/>"#) } /// 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 `` whose `sender` attribute does not /// byte-equal the `recipient` the client put on the matching `` (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: // // 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#""#, r#""#, r#""#, r#""#, r#""# ), 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 { let map: HashMap = 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#""#; 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#""#; 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#""#; 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 = "\n \n"; 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#""#, r#""#, // no id r#""#, // id not digits r#""#, // \b fails r#""#, // 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#""#; 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#""#; assert_eq!(parse_request(xml).unwrap().id, "2"); } #[test] fn the_self_closing_slash_does_not_become_an_attribute() { let xml = r#""#; 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#""# ); } #[test] fn auth_code_carries_the_value_attribute() { let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]); assert_eq!( r, concat!( r#""# ) ); } #[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#""#), "{r}"); } #[test] fn query_entitlements_owns_the_retail_offer() { assert_eq!( reply("QueryEntitlements", &[]), concat!( r#""#, r#""#, r#""# ) ); } #[test] fn profile_carries_the_identity_and_the_verbatim_template() { assert_eq!( reply("GetProfile", &[]), concat!( r#""# ) ); } #[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"/>"#), "{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#""# ); } #[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#""#), "{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#""#), "{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#""#, ); assert_eq!( safe, concat!( r#""# ) ); } #[test] fn redacts_the_auth_code_element_generically() { let safe = safe_xml_for_log(r#""#); 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#""#, ); 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#""#); assert_eq!(status, r#""#); // The element name alone must not trip the blanket pass. assert_eq!( safe_xml_for_log(r#""#), r#""# ); } #[test] fn redaction_is_case_insensitive_and_keeps_the_written_case() { assert_eq!( safe_xml_for_log(r#""#), r#""# ); } #[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#""#), r#""# ); } #[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(), "{}"); } }