feat(fifa17): own non-economy static + security-question routes in Rust
Migrate 5 non-economy UTAS route families from the Python oracle proxy to
Rust host ownership: user/accountinfo, settings, leaderboards/options,
match/reset, and phishing/{trusteddevice,question,validate}.
- adapter fut::non_economy: pure IO-free shapers matching the observed prod
oracle bodies + a verbatim port of security_question_route (stateless ack;
answer never stored/compared; trusted-device is an invariant constant).
- host: Route variants + classify() arms + owner=RUST dispatch; the
security-question X-UT-SID gate reuses SessionStore::session_known.
- tests: 9 adapter unit tests (contract) + host non_economy_route_ownership
(classify + classify_economy negatives).
This commit is contained in:
@@ -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<String> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user