diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 98be336..c6956e9 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -10,6 +10,7 @@ pub mod economy; pub mod economy_policy; pub mod entities; pub mod item; +pub mod non_economy; pub mod owned_query; pub mod pack_content; pub mod squad; diff --git a/openfut-adapter-fifa17/src/fut/non_economy.rs b/openfut-adapter-fifa17/src/fut/non_economy.rs new file mode 100644 index 0000000..10575e7 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/non_economy.rs @@ -0,0 +1,306 @@ +//! FIFA17 non-economy presentation routes, migrated from the Python oracle. +//! +//! Pure, IO-free shapers that reproduce the **observed production** oracle +//! contract (`fifa17-recon/tools/utas_server.py`) for the non-economy UTAS +//! routes a Rust host can own without any Core or account state: +//! +//! * `GET …/user/accountinfo` → `{}` (FUT_ACCOUNTINFO off) +//! * `GET …/settings` → `{"configs":[]}` (FUT_SETTINGS off) +//! * `GET …/leaderboards/options` → `{}` (FUT_MODES off) +//! * `PUT …/match/reset` → `{}` (Tier-B no-op ack) +//! * `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired +//! FUT security-question service, a stateless acknowledgement. +//! +//! These match the bodies the live prod oracle actually returns under the +//! production environment (`FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1`, none +//! of the feature flags set). They carry no persisted state: the security +//! record the oracle seeds (`{version:1,verified:true}`) is log-only — the +//! response is invariant — so a faithful Rust owner needs no persistence. + +use serde_json::{json, Value}; + +/// `GET …/user/accountinfo` — production oracle returns an empty object. +pub fn accountinfo_body() -> Value { + json!({}) +} + +/// `GET …/settings` — production oracle returns an empty config list. +pub fn settings_body() -> Value { + json!({ "configs": [] }) +} + +/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an +/// empty object; the retail client ignores the body. +pub fn leaderboard_options_body() -> Value { + json!({}) +} + +/// `PUT …/match/reset` — Tier-B no-op acknowledgement. +pub fn match_reset_body() -> Value { + json!({}) +} + +/// The phishing/security-question action, parsed from the URL tail. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityAction { + TrustedDevice, + Question, + Validate, + Unknown, +} + +/// The `phishing/` selector from a `…/phishing/` path tail. +pub fn parse_security_action(tail: &str) -> SecurityAction { + let last = tail.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + match last { + "trusteddevice" => SecurityAction::TrustedDevice, + "question" => SecurityAction::Question, + "validate" => SecurityAction::Validate, + _ => SecurityAction::Unknown, + } +} + +/// A well-formed FUT phishing token is an opaque 32-char lowercase/uppercase hex +/// value (`_PHISHING_HEX32.fullmatch` in the oracle). +fn is_hex32(s: &str) -> bool { + s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Reproduce `utas_server.py::security_question_route` verbatim. +/// +/// The retired FUT security-question service is a stateless acknowledgement: it +/// validates request shape (session, 32-hex device id, 32-hex answer, numeric +/// question) and returns fixed bodies. The answer is a client-transformed opaque +/// value that is **never stored or compared**; the trusted-device response is an +/// invariant `verified/trusted` constant. +/// +/// * `session_known` — whether `X-UT-SID` maps to an open Rust session. +/// * `device_id` / `question` / `answer` — decoded query parameters (`""`/`None` +/// when absent). +/// +/// Returns `(http_status, body)`. +pub fn security_question_response( + method: &str, + action: SecurityAction, + session_known: bool, + device_id: &str, + question: Option<&str>, + answer: Option<&str>, +) -> (u16, Value) { + let is = |m: &str| method.eq_ignore_ascii_case(m); + if !session_known { + return (400, json!({ "reason": "invalid_session" })); + } + if !is_hex32(device_id) { + return (400, json!({ "reason": "malformed_request" })); + } + match action { + SecurityAction::TrustedDevice => { + if !is("GET") { + return (405, json!({ "reason": "method_not_allowed" })); + } + ( + 200, + json!({ "changed": false, "exists": true, "locked": false, "trusted": true }), + ) + } + SecurityAction::Question if is("GET") => ( + 200, + json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 }), + ), + SecurityAction::Question if is("POST") || is("PUT") => { + let q = question.unwrap_or(""); + let a = answer.unwrap_or(""); + if q.is_empty() || !q.bytes().all(|b| b.is_ascii_digit()) || !is_hex32(a) { + return (400, json!({ "reason": "malformed_request" })); + } + (200, json!({})) + } + SecurityAction::Validate if is("POST") => { + let a = answer.unwrap_or(""); + if !is_hex32(a) { + return (400, json!({ "reason": "malformed_request" })); + } + (200, json!({})) + } + _ => (405, json!({ "reason": "method_not_allowed" })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEV: &str = "6236375476659cd0f6c780e728774b71"; // 32-hex (live deviceId) + const ANS: &str = "0123456789abcdef0123456789abcdef"; + + #[test] + fn static_bodies_match_oracle() { + assert_eq!(accountinfo_body(), json!({})); + assert_eq!(settings_body(), json!({ "configs": [] })); + assert_eq!(leaderboard_options_body(), json!({})); + assert_eq!(match_reset_body(), json!({})); + } + + #[test] + fn action_parse() { + assert_eq!( + parse_security_action("phishing/trusteddevice"), + SecurityAction::TrustedDevice + ); + assert_eq!( + parse_security_action("phishing/question/"), + SecurityAction::Question + ); + assert_eq!( + parse_security_action("phishing/validate"), + SecurityAction::Validate + ); + assert_eq!( + parse_security_action("phishing/other"), + SecurityAction::Unknown + ); + } + + #[test] + fn trusted_device_verified_constant() { + let (s, b) = + security_question_response("GET", SecurityAction::TrustedDevice, true, DEV, None, None); + assert_eq!(s, 200); + assert_eq!( + b, + json!({ "changed": false, "exists": true, "locked": false, "trusted": true }) + ); + } + + #[test] + fn no_session_is_400_invalid_session() { + let (s, b) = security_question_response( + "GET", + SecurityAction::TrustedDevice, + false, + DEV, + None, + None, + ); + assert_eq!(s, 400); + assert_eq!(b, json!({ "reason": "invalid_session" })); + } + + #[test] + fn bad_device_id_is_malformed() { + for bad in [ + "", + "xyz", + "6236375476659cd0f6c780e728774b7", + "not-hex-not-hex-not-hex-not-hexx", + ] { + let (s, b) = security_question_response( + "GET", + SecurityAction::TrustedDevice, + true, + bad, + None, + None, + ); + assert_eq!(s, 400, "device {bad:?}"); + assert_eq!(b, json!({ "reason": "malformed_request" })); + } + } + + #[test] + fn trusted_device_wrong_method_405() { + let (s, _) = security_question_response( + "POST", + SecurityAction::TrustedDevice, + true, + DEV, + None, + None, + ); + assert_eq!(s, 405); + } + + #[test] + fn question_get_returns_prompt() { + let (s, b) = + security_question_response("GET", SecurityAction::Question, true, DEV, None, None); + assert_eq!(s, 200); + assert_eq!( + b, + json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 }) + ); + } + + #[test] + fn question_setup_validates_shape() { + // valid numeric question + 32-hex answer + let (s, b) = security_question_response( + "POST", + SecurityAction::Question, + true, + DEV, + Some("0"), + Some(ANS), + ); + assert_eq!(s, 200); + assert_eq!(b, json!({})); + // empty question -> malformed + let (s, _) = security_question_response( + "POST", + SecurityAction::Question, + true, + DEV, + Some(""), + Some(ANS), + ); + assert_eq!(s, 400); + // non-digit question -> malformed + let (s, _) = security_question_response( + "PUT", + SecurityAction::Question, + true, + DEV, + Some("x"), + Some(ANS), + ); + assert_eq!(s, 400); + // bad answer -> malformed + let (s, _) = security_question_response( + "POST", + SecurityAction::Question, + true, + DEV, + Some("0"), + Some("short"), + ); + assert_eq!(s, 400); + } + + #[test] + fn validate_checks_answer() { + let (s, b) = security_question_response( + "POST", + SecurityAction::Validate, + true, + DEV, + None, + Some(ANS), + ); + assert_eq!(s, 200); + assert_eq!(b, json!({})); + let (s, _) = security_question_response( + "POST", + SecurityAction::Validate, + true, + DEV, + None, + Some("nope"), + ); + assert_eq!(s, 400); + // wrong method for validate + let (s, _) = + security_question_response("GET", SecurityAction::Validate, true, DEV, None, Some(ANS)); + assert_eq!(s, 405); + } +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 4340f05..9ea4f58 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -54,6 +54,7 @@ use openfut_adapter_fifa17::fut::economy_policy::{ match_reward_total, result_from_end_reason, MatchResult, }; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; +use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::owned_query::{ is_special_rareflag, map_to_core, parse_club_query, MapError, }; @@ -101,6 +102,17 @@ pub enum Route { /// economy body, with the empty-My-Packs topology overlaid from the Rust /// session mode (the 65534 sentinel is stripped for a verified clean-v1 SID). StorePurchaseGroup, + /// `GET …/user/accountinfo` — Rust-owned static `{}` (production oracle body). + AccountInfo, + /// `GET …/settings` — Rust-owned static `{"configs":[]}`. + Settings, + /// `GET …/leaderboards/options` — Rust-owned static `{}`. + LeaderboardOptions, + /// `PUT …/match/reset` — Rust-owned no-op ack `{}`. + MatchReset, + /// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired + /// FUT security-question service, owned in Rust as a stateless ack. + SecurityQuestion, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } @@ -135,6 +147,11 @@ pub fn classify(method: &str, path: &str) -> Route { Some("userMassInfo") if get => Route::UserMassInfo, Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup, Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace, + Some("user/accountinfo") if get => Route::AccountInfo, + Some("settings") if get => Route::Settings, + Some("leaderboards/options") if get => Route::LeaderboardOptions, + Some("match/reset") if put => Route::MatchReset, + Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion, _ => Route::Passthrough, } } @@ -2223,6 +2240,23 @@ impl Server { Route::StorePurchaseGroup => { self.handle_store_purchasegroup(method, target, headers, body, client_ip) } + Route::AccountInfo => { + eprintln!("utas-host owner=RUST route=accountinfo status=200"); + json_status(200, &non_economy::accountinfo_body()) + } + Route::Settings => { + eprintln!("utas-host owner=RUST route=settings status=200"); + json_status(200, &non_economy::settings_body()) + } + Route::LeaderboardOptions => { + eprintln!("utas-host owner=RUST route=leaderboards-options status=200"); + json_status(200, &non_economy::leaderboard_options_body()) + } + Route::MatchReset => { + eprintln!("utas-host owner=RUST route=match-reset status=200"); + json_status(200, &non_economy::match_reset_body()) + } + Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, @@ -2361,6 +2395,51 @@ impl Server { resp } + /// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired + /// FUT security-question service, owned entirely in Rust (no proxy, no Core). + /// Stateless: the client-transformed answer is never stored or compared, and + /// the trusted-device response is an invariant verified/trusted constant. The + /// `X-UT-SID` session gate mirrors the oracle — an unknown session is a 400 + /// `invalid_session`; a malformed 32-hex device id/answer is `malformed_request`. + fn handle_security_question( + &self, + method: &str, + target: &str, + headers: &[(String, String)], + ) -> WireResponse { + fn query_param(query: &str, key: &str) -> Option { + query.split('&').find_map(|kv| { + let (k, v) = kv.split_once('=')?; + (k == key).then(|| v.to_string()) + }) + } + let path = target.split('?').next().unwrap_or(target); + let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); + let tail = ut_tail(path).unwrap_or(""); + let action = non_economy::parse_security_action(tail); + let sid = header(headers, "x-ut-sid").unwrap_or(""); + let known = self.sessions.lock().unwrap().session_known(sid); + let device_id = query_param(query, "deviceId").unwrap_or_default(); + let question = query_param(query, "question"); + let answer = query_param(query, "answer"); + let (status, body) = non_economy::security_question_response( + method, + action, + known, + &device_id, + question.as_deref(), + answer.as_deref(), + ); + eprintln!( + "utas-host owner=RUST route=security-question status={} action={:?} sid={} known={}", + status, + action, + fifa17_sidlog(sid), + known + ); + json_status(status, &body) + } + /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive). pub fn serve(&self, addr: &str) -> std::io::Result<()> { let listener = TcpListener::bind(addr)?; @@ -3358,4 +3437,60 @@ mod tests { ); } } + + #[test] + fn non_economy_route_ownership() { + // The non-economy routes newly owned by Rust must classify to their arm, + // and lookalikes must stay Passthrough (proxied to Python). + let owned: &[(&str, &str, Route)] = &[ + ( + "GET", + "/ut/game/fifa17/user/accountinfo", + Route::AccountInfo, + ), + ("GET", "/ut/game/fifa17/settings", Route::Settings), + ( + "GET", + "/ut/game/fifa17/leaderboards/options", + Route::LeaderboardOptions, + ), + ("PUT", "/ut/game/fifa17/match/reset", Route::MatchReset), + ( + "GET", + "/ut/game/fifa17/phishing/trusteddevice", + Route::SecurityQuestion, + ), + ( + "POST", + "/ut/game/fifa17/phishing/question", + Route::SecurityQuestion, + ), + ( + "POST", + "/ut/game/fifa17/phishing/validate", + Route::SecurityQuestion, + ), + ]; + for (m, p, want) in owned { + assert_eq!(classify(m, p), *want, "OWN: {m} {p}"); + } + // Still Python (not yet migrated) / lookalikes / wrong method. + let proxied: &[(&str, &str)] = &[ + ("GET", "/ut/game/fifa17/hub"), + ("GET", "/ut/game/fifa17/club/stats/year"), + ("PUT", "/ut/game/fifa17/clientdata/userHubData"), + ("POST", "/openfut/account/sync"), + ("GET", "/ut/game/fifa17/settingsfoo"), + ("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only + ("GET", "/ut/game/fifa17/match/reset"), + ("PUT", "/ut/game/fifa17/user/accountinfo"), + ]; + for (m, p) in proxied { + assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}"); + } + // These non-economy routes must NOT be economy-classified. + for (m, p, _) in owned { + assert_eq!(classify_economy(m, p), None, "NON-ECON: {m} {p}"); + } + } }