//! Blaze RPC dispatch: inbound frame → outbound frames. //! //! Three behaviours here are load-bearing and none of them are obvious from the //! individual response shapes: //! //! * **Login answers with four frames, in order**: the reply first, then //! `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`. //! * **An unimplemented RPC still gets an empty reply.** Silence makes the //! client wait for a timeout; an empty reply lets every field fall back to a //! client-side default and the boot continues. //! * **Non-request message types get nothing at all** — answering a reply or a //! notification would desynchronise the client's own correlation. //! //! No error replies are emitted. `msgType` 3 exists, but the error-code //! placement is UNRESOLVED — three clean-room sources disagree between //! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so //! emitting one would be a guess on the wire. Do not add one without a capture. use openfut_protocol_blaze::fire2::{Frame, Header, MsgType}; use openfut_protocol_blaze::heat2::{self, Struct, Value}; use super::config::AdapterConfig; use super::ids::{association_lists, auth, census_data, component, user_sessions, util}; use super::responses as r; use super::session::Session; /// Everything needed to answer one RPC. pub struct Adapter { pub config: AdapterConfig, } impl Adapter { pub fn new(config: AdapterConfig) -> Adapter { Adapter { config } } /// Answer one inbound frame. /// /// `now` is passed in rather than read from the clock so responses are /// reproducible: several bodies stamp a timestamp, and a hidden clock read /// would make every fixture unrepeatable. pub fn dispatch( &self, header: &Header, body: &Struct, session: &mut Session, now: i64, ) -> Vec { // Transport-level ping, whatever the component/command. if header.msg_type == MsgType::Ping { return vec![reply(header, Vec::new(), MsgType::PingReply)]; } // Only requests are answered. if header.msg_type != MsgType::Message { return Vec::new(); } let cfg = &self.config; match (header.component, header.command) { // ------------------------------------------------------- Util (component::UTIL, util::PRE_AUTH) => { // preAuth is where the session learns who it is talking to: // the service name is echoed back, and the locale is captured // for every later ALOC field. session.service_name = service_name_of(body); if let Some(loc) = find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC")) { session.account_locale = loc; } let svc = session.service_name.clone(); reply_tdf(header, &r::preauth_response(&svc, cfg)) } (component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)), (component::UTIL, util::FETCH_CLIENT_CONFIG) => { let cfid = get_str(body, "CFID"); reply_tdf(header, &r::fetch_config_response(&cfid, cfg)) } (component::UTIL, util::POST_AUTH) => { reply_tdf(header, &r::post_auth_response(session, cfg)) } (component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)), (component::UTIL, util::USER_SETTINGS_LOAD) => { reply_tdf(header, &r::user_settings_response()) } // Accepted and discarded; the client only needs the ack. (component::UTIL, util::USER_SETTINGS_SAVE) | (component::UTIL, util::SET_CLIENT_STATE) | (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header), // --------------------------------------------- Authentication (component::AUTHENTICATION, auth::LOGIN) => { session.auth_code = get_str(body, "AUTH"); session.logged_in = true; session.login_time = now; self.login_burst(header, session, now) } // Same forged session; the request fields differ and are ignored. (component::AUTHENTICATION, auth::TRUSTED_LOGIN) | (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => { session.logged_in = true; session.login_time = now; self.login_burst(header, session, now) } // Receiving logout is NORMAL, not a failure: the OSDK state table // orders Connect -> Logout -> VersionCheck -> PCLogin, so this is // the routine "drop any stale session" step before login. It is // only a symptom if login never follows. (component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header), (component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2) | (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS) | (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2) | (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => { reply_tdf(header, &r::entitlements_response(cfg)) } (component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => { reply_tdf(header, &r::get_auth_token_response(session)) } (component::AUTHENTICATION, auth::GET_ACCOUNT) => { reply_tdf(header, &r::account_info(now, cfg)) } (component::AUTHENTICATION, auth::GET_PERSONA) => { reply_tdf(header, &r::get_persona_response(now, cfg)) } (component::AUTHENTICATION, auth::LIST_PERSONAS) => { reply_tdf(header, &r::list_personas_response(now, cfg)) } // ---------------------------------------------- UserSessions (component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => { // Ack, then re-push the extended data so the client's cached // copy reflects the network info it just reported. vec![ reply(header, Vec::new(), MsgType::Reply), notify( component::USER_SESSIONS, user_sessions::notify::EXTENDED_DATA_UPDATE, &r::user_session_extended_data_update(cfg), ), ] } // ------------------------------------------ AssociationLists (component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => { reply_tdf(header, &r::get_lists_response()) } // ----------------------------------------------- CensusData (component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => { reply_tdf(header, &r::census_subscribe_response()) } // An empty reply, never silence: see the module docs. _ => empty_reply(header), } } /// Login reply followed by the three UserSessions pushes, in order. /// /// The order is the oracle's ("pamplona" order: reply first). The /// alternative ("grid-blaze": notifications first) is also reported to /// work, but only this one is proven against our client, so it is the one /// reproduced. fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec { let cfg = &self.config; vec![ reply( header, heat2::encode(&r::login_response(session, now, cfg)), MsgType::Reply, ), notify( component::USER_SESSIONS, user_sessions::notify::USER_AUTHENTICATED, &r::user_session_login_info(session, now, cfg), ), notify( component::USER_SESSIONS, user_sessions::notify::EXTENDED_DATA_UPDATE, &r::user_session_extended_data_update(cfg), ), notify( component::USER_SESSIONS, user_sessions::notify::USER_ADDED, &r::user_data(session, cfg), ), ] } } // ------------------------------------------------------------------ helpers fn reply(request: &Header, payload: Vec, msg_type: MsgType) -> Frame { let mut frame = Frame::new( request.component, request.command, request.msg_num, msg_type, payload, ); // A reply echoes routing verbatim and changes only the msgType bits. frame.header.user_index = request.user_index; frame } fn reply_tdf(request: &Header, body: &Struct) -> Vec { vec![reply(request, heat2::encode(body), MsgType::Reply)] } fn empty_reply(request: &Header) -> Vec { vec![reply(request, Vec::new(), MsgType::Reply)] } fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame { Frame::notification(component, notify_id, heat2::encode(body)) } /// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`. fn service_name_of(body: &Struct) -> String { body.get("CDAT") .and_then(Value::as_struct) .and_then(|c| c.get("SVCN")) .and_then(Value::as_str) .filter(|s| !s.is_empty()) .unwrap_or(super::session::DEFAULT_SERVICE_NAME) .to_string() } /// Depth-first search for an INT member anywhere in a decoded body. /// /// The client has moved which struct carries `LANG`/`LOC` between builds, so /// the oracle searches rather than addressing a fixed path. fn find_nested_int(body: &Struct, tag: &str) -> Option { for (t, v) in body.iter() { if t.to_label() == tag { if let Value::Int(n) = v { return Some(*n); } } if let Value::Struct(inner) = v { if let Some(found) = find_nested_int(inner, tag) { return Some(found); } } } None } fn get_str(body: &Struct, tag: &str) -> String { body.get(tag) .and_then(Value::as_str) .unwrap_or("") .to_string() } #[cfg(test)] mod tests { use super::*; use openfut_protocol_blaze::heat2::Struct as S; fn adapter() -> Adapter { Adapter::new(AdapterConfig::loopback()) } fn req(component: u16, command: u16) -> Header { Header::new(component, command, 7, MsgType::Message) } #[test] fn login_answers_with_reply_then_three_pushes_in_order() { let a = adapter(); let mut sess = Session::new("k", 0); let out = a.dispatch( &req(component::AUTHENTICATION, auth::LOGIN), &S::new(), &mut sess, 1, ); assert_eq!(out.len(), 4); assert_eq!(out[0].header.msg_type, MsgType::Reply); let ids: Vec = out[1..].iter().map(|f| f.header.command).collect(); assert_eq!( ids, vec![ user_sessions::notify::USER_AUTHENTICATED, user_sessions::notify::EXTENDED_DATA_UPDATE, user_sessions::notify::USER_ADDED, ] ); for f in &out[1..] { assert_eq!(f.header.msg_type, MsgType::Notification); assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated"); } } #[test] fn unimplemented_rpcs_get_an_empty_reply_not_silence() { let a = adapter(); let mut sess = Session::new("k", 0); let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1); assert_eq!(out.len(), 1); assert_eq!(out[0].header.msg_type, MsgType::Reply); assert!(out[0].payload.is_empty()); } #[test] fn non_requests_are_ignored_entirely() { let a = adapter(); let mut sess = Session::new("k", 0); for mt in [ MsgType::Reply, MsgType::Notification, MsgType::ErrorReply, MsgType::PingReply, ] { let h = Header::new(component::UTIL, util::PING, 1, mt); assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}"); } } #[test] fn transport_ping_gets_an_empty_ping_reply() { let a = adapter(); let mut sess = Session::new("k", 0); let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping); let out = a.dispatch(&h, &S::new(), &mut sess, 1); assert_eq!(out.len(), 1); assert_eq!(out[0].header.msg_type, MsgType::PingReply); assert!(out[0].payload.is_empty()); } #[test] fn replies_echo_routing_including_user_index() { let a = adapter(); let mut sess = Session::new("k", 0); let mut h = req(component::UTIL, util::PING); h.user_index = 7; h.msg_num = 0x4242; let out = a.dispatch(&h, &S::new(), &mut sess, 1); assert_eq!(out[0].header.user_index, 7); assert_eq!(out[0].header.msg_num, 0x4242); assert_eq!(out[0].header.component, component::UTIL); assert_eq!(out[0].header.command, util::PING); } #[test] fn preauth_captures_locale_and_service_name() { let a = adapter(); let mut sess = Session::new("k", 0x656E5553); let body = S::new().with( "CDAT", Value::Struct( S::new() .with("LANG", Value::Int(0x64654445)) .with("SVCN", Value::String("fifa-2017-pc-de".into())), ), ); a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1); assert_eq!(sess.account_locale, 0x64654445); assert_eq!(sess.service_name, "fifa-2017-pc-de"); } #[test] fn preauth_without_svcn_falls_back_to_the_default() { let a = adapter(); let mut sess = Session::new("k", 0); a.dispatch( &req(component::UTIL, util::PRE_AUTH), &S::new(), &mut sess, 1, ); assert_eq!( sess.service_name, super::super::session::DEFAULT_SERVICE_NAME ); } #[test] fn login_records_the_auth_code_for_later_get_auth_token() { let a = adapter(); let mut sess = Session::new("k", 0); let body = S::new().with("AUTH", Value::String("CODE-123".into())); a.dispatch( &req(component::AUTHENTICATION, auth::LOGIN), &body, &mut sess, 1, ); let out = a.dispatch( &req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN), &S::new(), &mut sess, 1, ); let decoded = heat2::decode(&out[0].payload).unwrap(); assert_eq!( decoded.get("AUTH").and_then(Value::as_str), Some("CODE-123") ); } #[test] fn update_network_info_acks_then_pushes() { let a = adapter(); let mut sess = Session::new("k", 0); let out = a.dispatch( &req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO), &S::new(), &mut sess, 1, ); assert_eq!(out.len(), 2); assert!(out[0].payload.is_empty()); assert_eq!(out[1].header.msg_type, MsgType::Notification); } #[test] fn all_four_entitlement_aliases_agree() { let a = adapter(); let mut sess = Session::new("k", 0); let bodies: Vec> = [ auth::LIST_USER_ENTITLEMENTS2, auth::LIST_ENTITLEMENTS, auth::LIST_PERSONA_ENTITLEMENTS2, auth::GRANT_ENTITLEMENT2, ] .iter() .map(|&cmd| { a.dispatch( &req(component::AUTHENTICATION, cmd), &S::new(), &mut sess, 1, )[0] .payload .clone() }) .collect(); assert!(bodies.windows(2).all(|w| w[0] == w[1])); } #[test] fn finds_a_nested_int_at_any_depth() { let body = S::new().with( "A", Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))), ); assert_eq!(find_nested_int(&body, "LANG"), Some(42)); assert_eq!(find_nested_int(&body, "NOPE"), None); } }