fix(fifa17): route the client's lowercase usermassinfo to the Rust handler

The retail client sends BOTH casings. Observed twice on staging, each time
inside a genuine client sequence:

  16:56:56  route=squad-active 200
  16:56:56  GET /ut/game/fifa17/usermassinfo -> passthrough -> 502
  16:56:56  route=userMassInfo 200

classify matched the exact literal `userMassInfo`, so the lowercase request
fell through to the Python upstream. Today that is a harmless 502 because the
upstream is dead and the client immediately retries with the canonical casing -
but on a deployment with Python ALIVE that request would be ANSWERED there,
silently splitting authority away from Rust for a route Core owns. That is the
real defect, not the wasted round trip.

Fixed with the smallest possible alias: this one tail is matched
case-insensitively, the rest of the table stays exact since no other route has
ever shown a casing variant. Paths are NOT globally lowercased.

Tests cover the canonical casing, lowercase, uppercase, two adjacent tails that
must NOT be swept up by the alias (`usermassinfox`, `usermass`), and method
semantics (PUT/POST still passthrough). With the alias reverted the test fails.
This commit is contained in:
funman300
2026-08-24 22:00:08 +00:00
parent 96f24e799a
commit e49c1f211c
2 changed files with 41 additions and 1 deletions
+9 -1
View File
@@ -237,7 +237,15 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("squad/list") if get => Route::SquadList,
Some("squad/active") if get => Route::SquadActive,
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
Some("userMassInfo") if get => Route::UserMassInfo,
// The retail client sends BOTH casings: a lowercase `usermassinfo`
// followed immediately by the canonical `userMassInfo`. Matching the
// literal only meant the lowercase one fell through to the Python
// upstream — a 502 here, but on a deployment with Python alive it would
// be ANSWERED there, silently splitting authority away from Rust for a
// route Core owns. Matched case-insensitively for this tail only; the
// rest of the table stays exact, since no other route has shown a
// casing variant.
Some(t) if get && t.eq_ignore_ascii_case("usermassinfo") => Route::UserMassInfo,
Some("user/club") if put || post => Route::ClubRename,
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
+32
View File
@@ -1079,6 +1079,38 @@ fn classify_squad_and_usermassinfo_routes() {
classify("GET", "/ut/game/fifa17/userMassInfo"),
Route::UserMassInfo
);
// The retail client sends the lowercase tail too, immediately before the
// canonical one. It must reach the SAME Rust-owned handler: falling through
// to Python is a 502 here and, with Python alive, an authority split.
assert_eq!(
classify("GET", "/ut/game/fifa17/usermassinfo"),
Route::UserMassInfo,
"lowercase usermassinfo is a real retail request, observed twice"
);
assert_eq!(
classify("GET", "/ut/game/fifa17/USERMASSINFO"),
Route::UserMassInfo
);
// Adjacent tails must NOT be swept up by the case-insensitive arm.
assert_eq!(
classify("GET", "/ut/game/fifa17/usermassinfox"),
Route::Passthrough,
"the alias must match the whole tail, not a prefix"
);
assert_eq!(
classify("GET", "/ut/game/fifa17/usermass"),
Route::Passthrough
);
// Method semantics are unchanged: only GET is this route.
assert_eq!(
classify("PUT", "/ut/game/fifa17/usermassinfo"),
Route::Passthrough
);
assert_eq!(
classify("POST", "/ut/game/fifa17/userMassInfo"),
Route::Passthrough
);
// GET /squad/active and every numeric GET are the one Core-backed current
// squad, matching the oracle's single-squad response regardless of URL id.
assert_eq!(