diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index 976b8169..ec49dd3b 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -54,6 +54,7 @@ impl Plugin for LoginPlugin { app.init_resource::() .init_resource::() .add_message::() + .add_message::() .add_systems( OnEnter(ClientState::Login), ( @@ -85,6 +86,10 @@ impl Plugin for LoginPlugin { crate::ui_script::glue_scene::feed_stock_glue_preview .run_if(crate::ui_script::glue::stock_glue) .in_set(crate::portrait::GluePreviewFeed), + crate::ui_script::server_alert::open_launched + // Reads only its message, so no state orders it but the sets. + .after(benilla_world::schedule::WorldStage::Stream) + .run_if(crate::ui_script::glue::stock_glue), answer_glue, drive_policy, to_select_on_roster, @@ -366,9 +371,8 @@ fn world_refusal_text(strings: &GlueStrings, code: u8) -> &str { m::AUTH_ALREADY_LOGGING_IN => ("AUTH_ALREADY_LOGGING_IN", "Already Logging In"), m::AUTH_LOGIN_SERVER_NOT_FOUND => ("AUTH_LOGIN_SERVER_NOT_FOUND", "Invalid Login Server"), // All but `AUTH_ALREADY_ONLINE` below are the rows of the client's `OKAY_WITH_URL` table - // (`0x803740`). Deviation: no URL button, because `LaunchURL` is a shell-out benilla does - // not make and its whitelist (`0x85cc34`) names only Blizzard domains that no longer - // serve these pages. + // (`0x803740`). Deviation: no URL button on the native screen, because `LaunchURL`'s + // whitelist (`0x85cc34`) names only Blizzard domains that no longer serve these pages. m::AUTH_BANNED => ( "AUTH_BANNED", "This account has been banned for violating the Terms of Use Agreement", diff --git a/crates/benilla-app/src/ui_script/glue_mgr.rs b/crates/benilla-app/src/ui_script/glue_mgr.rs index 7eb3f4d9..bf91a2d6 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr.rs @@ -411,6 +411,8 @@ enum Outgoing { Teardown, /// The roster load (`0x4724d0`) over the roster the last reply left. Roster, + /// `SHOW_SERVER_ALERT(text)` (event 21, `0x46b964`): the alert's text after its prefix. + ServerAlert(String), } /// The logon's notification cells: the latest `(state, result)` (`0x836b28`, `0x836b24`) and the @@ -482,6 +484,12 @@ pub(crate) struct GlueMgr { park_out: Vec, /// Asks of the login engine the per-frame arms made, sent with the verbs' own. login_out: Vec, + /// The server alert's fetch while it runs (`alertPending`, `[0xb41e28]`), started once per + /// process at the glue init. + alert_fetch: Option>>>, + /// The text a finished fetch left for the next frame (`alertReady`, `[0xb41e2c]`, and the + /// buffer `0xb414f8` past its prefix). + alert_ready: Option, } impl GlueMgr { @@ -491,6 +499,37 @@ impl GlueMgr { self.roster.iter().find(|c| c.guid == guid) } + /// The alert fetch the glue init began (`0x46a564`). + pub(super) fn set_alert_fetch(&mut self, fetch: crossbeam_channel::Receiver>>) { + self.alert_fetch = Some(fetch); + } + + /// A finished fetch's alert becomes ready (`0x46a5f1`); one that found none, or died, ends the + /// wait (`0x46a5fb`). + fn poll_alert(&mut self) { + let Some(fetch) = &self.alert_fetch else { + return; + }; + match fetch.try_recv() { + Ok(alert) => { + self.alert_fetch = None; + self.alert_ready = alert.map(|text| String::from_utf8_lossy(&text).into_owned()); + } + Err(crossbeam_channel::TryRecvError::Empty) => {} + Err(crossbeam_channel::TryRecvError::Disconnected) => self.alert_fetch = None, + } + } + + /// The top of `CGlueMgr::Update` (`0x46b939`-`0x46b96c`): a ready alert first runs the status + /// dialog's button (`0x46b947` → `0x46b720`, the screen state's cancel), fires + /// `SHOW_SERVER_ALERT` with the text, and is spent. + fn show_alert(&mut self, asks: &mut Vec) { + if let Some(text) = self.alert_ready.take() { + self.status_dialog_click(asks); + self.outbox.push(Outgoing::ServerAlert(text)); + } + } + /// The agreements as read at the startup (`0x46a400`). pub(super) fn set_notices(&mut self, notices: [NoticeState; 4]) { self.notices = Some(notices); @@ -1093,6 +1132,7 @@ pub(crate) struct LoginNews<'w, 's> { pub(crate) struct GlueAsks<'w> { login: MessageWriter<'w, GlueLogin>, music: MessageWriter<'w, crate::sound::GlueMusicRequest>, + launch: MessageWriter<'w, super::server_alert::LaunchUrl>, realm: Option>, abandon: Option>, } @@ -1192,8 +1232,14 @@ pub(crate) fn drive_glue_mgr( .and_then(|s| s.cvar(crate::realm_select::CVAR_REALM_NAME)) .unwrap_or_default(); - // The verbs the last tick's Lua called. + // The alert a finished fetch left, shown before anything else this frame (`0x46b939`). let mut logins = Vec::new(); + mgr.poll_alert(); + if glue_up { + mgr.show_alert(&mut logins); + } + + // The verbs the last tick's Lua called. if let Some(s) = script.as_deref_mut() { mgr.screen = s.glue_screen(); for request in s.take_glue_requests() { @@ -1227,6 +1273,10 @@ pub(crate) fn drive_glue_mgr( logins.push(GlueLogin::Disconnect); } GlueRequest::QuitGame => logins.push(GlueLogin::Quit), + // `0x5abd00`, past the whitelist the verb applied (`0x5abc10`). + GlueRequest::LaunchUrl(url) => { + asks.launch.write(super::server_alert::LaunchUrl(url)); + } GlueRequest::RequestRealmList { dialog } => mgr.request_realm_list(dialog), GlueRequest::CancelRealmListQuery => mgr.cancel_realm_list_query(), // `CLoginMgr::Cancel` (`0x5b3320`): nothing else moves, so no dialog shows. @@ -1494,6 +1544,9 @@ fn flush_one( mgr.realm_cells = script.glue_realm_cells(); } Outgoing::Teardown => script.teardown_glue_roster(), + Outgoing::ServerAlert(text) => { + script.fire_event("SHOW_SERVER_ALERT", vec![ScriptValue::Str(text)]); + } Outgoing::Roster => { let roster = mgr .roster diff --git a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs index 182d33bc..da28e921 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs @@ -770,6 +770,7 @@ fn stock_app() -> ( .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .init_resource::() .add_systems( @@ -1134,6 +1135,7 @@ fn live_app_with( .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .init_resource::() .add_systems( @@ -1904,3 +1906,144 @@ fn a_roster_reply_after_the_dialogs_cancel_is_dropped() { ); assert_eq!(vm(&app).errors(), Vec::::new()); } + +// ── The server alert ── + +/// A body of the shape the 2007 alert had: the prefix, a dated heading, paragraphs split by blank +/// lines, a list of realm names one to a line, and a final newline; plain text, about 1 KB. +fn alert_body() -> String { + let mut body = String::from( + "SERVERALERT:\n[January 17, 2007 - 5:00PM PST]\n\n\ + In order to address player queues on highly populated realms, we are offering free \ + character transfer moves to players on the realms listed below.\n\n\ + Please note: if the populations of these realms are not reduced by free character \ + moves, we will be forced to perform realm splits to reduce their population to \ + acceptable levels.\n\n\ + For full details, please check the latest forum sticky posts.\n\n", + ); + for realm in [ + "Alexstrasza", + "Aggramar", + "Argent Dawn", + "Azjol-Nerub", + "Blackwater Raiders", + "Bloodhoof", + "Bronzebeard", + "Cenarius", + "Doomhammer", + "Thunderhorn", + "Whisperwind", + "Zul'jin", + ] { + body.push_str(realm); + body.push('\n'); + } + body +} + +/// A server that answers the next request with `body` and closes; its URL. +fn serve_body(body: &str) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut raw = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes(); + raw.extend_from_slice(body.as_bytes()); + std::thread::spawn(move || { + use std::io::{Read, Write}; + if let Ok((mut conn, _)) = listener.accept() { + // The whole request head first, then the answer. + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") && conn.read(&mut byte).unwrap_or(0) == 1 { + head.push(byte[0]); + } + let _ = conn.write_all(&raw); + } + }); + format!("http://127.0.0.1:{port}/alert") +} + +/// A fetched alert reaches the stock login screen: the status dialog's button runs first +/// (`0x46b947`), so a logon in flight is abandoned, `SHOW_SERVER_ALERT` fires once with the text +/// after the prefix (`0x46b957`), and the stock handler shows `ServerAlertFrame` with it. +#[test] +fn a_fetched_alert_cancels_the_logon_and_shows_the_alert_frame() { + let _ = benilla_formats::wow_data_or_skip!(); + let body = alert_body(); + let url = serve_body(&body); + let (mut app, rx, abandon) = stock_app(); + vm(&app) + .run( + "probe = CreateFrame('Frame') probe:RegisterEvent('SHOW_SERVER_ALERT') \ + probe:SetScript('OnEvent', function() ALERT_ARG = arg1 \ + ALERT_CALLS = (ALERT_CALLS or 0) + 1 end)", + ) + .unwrap(); + vm(&app).run("AcceptEULA() AcceptTOS()").unwrap(); + app.update(); + type_and_login(&mut app); + assert!(rx.try_recv().is_ok()); + assert_eq!(app.world().resource::().state, ScreenState::Logon); + assert!(!shown(&app, "ServerAlertFrame")); + + let fetch = crate::ui_script::server_alert::spawn_fetch(url); + app.world_mut() + .resource_mut::() + .set_alert_fetch(fetch); + until(&mut app, "the alert frame", |app| { + shown(app, "ServerAlertFrame") + }); + + // After the prefix, short of the last byte (`SStrCopy` of the whole body, `0x46a59f`). + let want = &body[b"SERVERALERT:".len()..body.len() - 1]; + assert_eq!(vm(&app).eval::("return ALERT_ARG").unwrap(), want); + assert_eq!(abandon.load(Ordering::SeqCst), 1, "the logon was abandoned"); + assert_eq!(app.world().resource::().state, ScreenState::Idle); + + // Once: nothing refires it. + for _ in 0..5 { + app.update(); + } + assert_eq!(vm(&app).eval::("return ALERT_CALLS").unwrap(), 1.0); + assert_eq!(vm(&app).errors(), Vec::::new()); +} + +/// A body without the prefix, or a fetch that finds none, shows nothing and leaves the logon be. +#[test] +fn a_body_without_the_prefix_shows_no_alert() { + let _ = benilla_formats::wow_data_or_skip!(); + let url = serve_body("Not an alert.\n"); + let (mut app, rx, abandon) = stock_app(); + vm(&app).run("AcceptEULA() AcceptTOS()").unwrap(); + app.update(); + type_and_login(&mut app); + assert!(rx.try_recv().is_ok()); + let fetch = crate::ui_script::server_alert::spawn_fetch(url); + app.world_mut() + .resource_mut::() + .set_alert_fetch(fetch); + until(&mut app, "the fetch to end", |app| { + app.world().resource::().alert_fetch.is_none() + }); + app.update(); + assert!(!shown(&app, "ServerAlertFrame")); + assert_eq!(abandon.load(Ordering::SeqCst), 0); + assert_eq!(app.world().resource::().state, ScreenState::Logon); +} + +/// `LaunchURL` reaches the host as a launch message, for a whitelisted URL only. +#[test] +fn launch_url_reaches_the_host_for_a_whitelisted_url() { + let _ = benilla_formats::wow_data_or_skip!(); + let (mut app, _rx, _abandon) = stock_app(); + vm(&app) + .run("LaunchURL('http://example.com/') LaunchURL('http://www.worldofwarcraft.com/news')") + .unwrap(); + app.update(); + let launched: Vec = app + .world() + .resource::>() + .iter_current_update_messages() + .map(|m| m.0.clone()) + .collect(); + assert_eq!(launched, ["http://www.worldofwarcraft.com/news"]); +} diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index ecd34454..91a0c377 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -77,6 +77,7 @@ fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool { let start = std::time::Instant::now(); let failures = super::glue::load_glue(&mut script); super::glue::finish_glue_load_log(&mut script); + start_server_alert(world, &script); super::glue::set_glue_screen(&mut script, screen); info!( "ui_script: stock glue up in {:.0} ms on \"{}\" ({} load failure(s))", @@ -95,6 +96,19 @@ fn install_glue_vm(world: &mut World, screen: super::glue::GlueScreen) -> bool { charselect } +/// Start the server alert's fetch, as the glue manager's init does after the GlueXML load and +/// before the first screen (`0x46a541`-`0x46a564`, `0x402b36`): the global `SERVER_ALERT_URL` as +/// the glue files left it, so `GlueLocalization` wins over `GlueStrings`, fetched once per +/// process ([`super::server_alert::begin_once`]). +fn start_server_alert(world: &mut World, script: &UiScript) { + let url = script.global_text("SERVER_ALERT_URL"); + if let Some(fetch) = super::server_alert::begin_once(&url) { + if let Some(mut mgr) = world.get_resource_mut::() { + mgr.set_alert_fetch(fetch); + } + } +} + /// Read the four agreement CVars into the glue VM and the manager before any screen is set, as /// the reference does (`0x46a400` at `0x402b36`, then `SET_GLUE_SCREEN` at `0x402b65`): the login /// screen's `OnShow` asks them at once, and nothing asks again. diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index c004b07c..fdb1daa6 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -22,6 +22,8 @@ pub(crate) mod glue; pub(crate) mod glue_mgr; pub(crate) mod glue_scene; mod input; +/// The server alert's fetch and `LaunchURL`'s browser, for the stock glue screens. +pub(crate) mod server_alert; #[cfg(test)] pub(crate) use input::feed_keys; #[cfg(test)] diff --git a/crates/benilla-app/src/ui_script/server_alert.rs b/crates/benilla-app/src/ui_script/server_alert.rs new file mode 100644 index 00000000..a0eb0062 --- /dev/null +++ b/crates/benilla-app/src/ui_script/server_alert.rs @@ -0,0 +1,570 @@ +//! The server alert (`SHOW_SERVER_ALERT`): the one background fetch of the text the login screen +//! shows over its boxes, and the verb that opens a link in it. +//! +//! The glue manager's init (`0x46a400`) reads the Lua global `SERVER_ALERT_URL` by name (`0x46a541` +//! → `0x703bf0`, after the GlueXML load at `0x46a505`, so `GlueLocalization` overrides the stock +//! `GlueStrings` value), sets `alertPending` and clears `alertReady` and the buffer +//! (`0xb41e28`, `0xb41e2c`, `0xb414f8`), and starts the download once (`0x46a564` → `0x7a6ee0`): +//! `GET`, agent `Blizzard Web Client` (`0x87d3b0`), a 5000 ms connect and receive timeout +//! (`0x7a6ee6`), redirects followed. Only a 2xx status reads the body (`0x7a6a0e`-`0x7a6a21`); each +//! read of up to 0x4000 bytes (`0x7a6a56`) reaches the callback (`0x46a580`) with its size, and the +//! callback appends it to the 0x800-byte buffer ([`AlertBuffer`]). When the connection handle +//! closes (`[esi+4]` at `0x7a6be3`, the `InternetConnect` handle of `0x7a6ddf`), the callback runs +//! once more with the done flag (`0x7a6c4b`), and a buffer that begins `SERVERALERT:` case-folded +//! (`0x836ed8`) sets `alertReady` (`0x46a5f1`). Every failure is silent, and nothing refetches. +//! +//! An `https://` URL shows nothing in the reference either: `InternetConnect` asks for the HTTP +//! service (`0x7a6dcf`) and `HttpOpenRequest` passes `0x84000200` (`0x7a6e34`), without +//! `INTERNET_FLAG_SECURE`, so it speaks plain HTTP to the URL's port (443 for `https://`) and gets +//! no 2xx. benilla does not dial an `https://` URL, with the same result, and logs one line. The +//! client sets neither redirect flag (`0x4000`, `0x8000`); what WinINet does with an +//! http-to-https redirect is the OS's, not in the client's bytes, and benilla does not follow one. +//! +//! Deviation: the body is read with 0x4000-byte reads filled to the end of the body, so what a +//! read returns does not depend on how the network cut the packets; and the reading stops once +//! the buffer is full, since no later byte can change it. +//! Deviation: the connection is direct. The reference opens its session with access type 0 +//! (`InternetOpenA`, `0x7a6d97`), the system's proxy settings, which benilla does not read. + +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use bevy::prelude::*; + +/// What `0x46b957` skips to find the text: the prefix `0x836ed8` names (`0x836b3c` points at it). +const PREFIX: &[u8] = b"SERVERALERT:"; +/// The buffer `0xb414f8` and the stack copy in the callback, both 0x800 bytes with their NUL. +const BUFFER: usize = 0x800; +/// One read (`0x7a6a56`). +const CHUNK: usize = 0x4000; +/// The user agent (`0x87d3b0`). +const AGENT: &str = "Blizzard Web Client"; +/// Redirects followed before the fetch gives up. +const MAX_REDIRECTS: usize = 5; +/// A status line or header line past this is not an HTTP response. +const MAX_LINE: usize = 8192; +const MAX_HEADERS: usize = 100; + +/// The connect and receive timeouts, `0x1388` ms each (`0x7a6ee6`). +#[derive(Clone, Copy, Debug)] +pub(super) struct Timeouts { + pub connect: Duration, + pub read: Duration, +} + +impl Timeouts { + pub(super) const REFERENCE: Timeouts = Timeouts { + connect: Duration::from_millis(5000), + read: Duration::from_millis(5000), + }; +} + +/// The callback's buffer (`0xb414f8`): what the chunks of a response add up to. +#[derive(Debug, Default)] +pub(super) struct AlertBuffer { + buf: Vec, +} + +impl AlertBuffer { + /// One read's bytes (`0x46a580`). An empty read adds nothing. Otherwise the callback copies the + /// chunk into a 0x800-byte stack string with `SStrCopy` (`0x64a5a0`, size `min(len, 0x800)`), + /// which stops at a NUL and leaves room for its own, so the chunk's last byte is lost; then it + /// appends that to the buffer with `SStrPack` (`0x64a760`, size 0x800), so chunks add up to + /// 0x7ff bytes and no more. + pub(super) fn push(&mut self, chunk: &[u8]) { + if chunk.is_empty() { + return; + } + let piece = &chunk[..chunk.len().min(BUFFER) - 1]; + let piece = &piece[..piece.iter().position(|&b| b == 0).unwrap_or(piece.len())]; + let room = (BUFFER - 1).saturating_sub(self.buf.len()); + self.buf.extend_from_slice(&piece[..piece.len().min(room)]); + } + + /// Nothing a later chunk adds can change the buffer. + pub(super) fn is_full(&self) -> bool { + self.buf.len() >= BUFFER - 1 + } + + /// The done call (`0x46a5c0`): the text after the prefix when the buffer begins with it, any + /// case (`SStrCmpI` over the prefix's length, `0x46a5e8`); a shorter or different buffer is no + /// alert. + pub(super) fn finish(self) -> Option> { + let head = self.buf.get(..PREFIX.len())?; + head.eq_ignore_ascii_case(PREFIX) + .then(|| self.buf[PREFIX.len()..].to_vec()) + } +} + +/// Why a download ended without its body. +#[derive(Debug)] +pub(super) enum FetchError { + /// Not an `http://` or `https://` URL, or one with no host. + BadUrl, + /// An `https://` URL, the deviation above. + Https, + /// A status that is neither 2xx nor a redirect with a `Location`. + Status(u16), + TooManyRedirects, + /// Not an HTTP response. + Protocol, + Io(io::Error), +} + +impl std::fmt::Display for FetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FetchError::BadUrl => f.write_str("not an http URL"), + FetchError::Https => f.write_str("https"), + FetchError::Status(status) => write!(f, "status {status}"), + FetchError::TooManyRedirects => f.write_str("too many redirects"), + FetchError::Protocol => f.write_str("not an HTTP response"), + FetchError::Io(e) => write!(f, "{e}"), + } + } +} + +impl From for FetchError { + fn from(e: io::Error) -> Self { + FetchError::Io(e) + } +} + +/// A parsed `http://` URL. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Url { + host: String, + port: u16, + /// The request target: the path and query, `/` at least. + target: String, +} + +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + s.get(..prefix.len()) + .filter(|head| head.eq_ignore_ascii_case(prefix)) + .map(|_| &s[prefix.len()..]) +} + +fn parse_url(url: &str) -> Result { + let rest = match strip_prefix_ci(url, "http://") { + Some(rest) => rest, + None if strip_prefix_ci(url, "https://").is_some() => return Err(FetchError::Https), + None => return Err(FetchError::BadUrl), + }; + let rest = rest.split('#').next().unwrap_or_default(); + let end = rest.find(['/', '?']).unwrap_or(rest.len()); + let (authority, tail) = rest.split_at(end); + // User information is not sent: the request names the host alone. + let authority = authority.rsplit('@').next().unwrap_or_default(); + let (host, port) = match authority.strip_prefix('[') { + Some(v6) => { + let (host, after) = v6.split_once(']').ok_or(FetchError::BadUrl)?; + (host, after.strip_prefix(':')) + } + None => match authority.rsplit_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }, + }; + let port = match port { + None | Some("") => 80, + Some(p) => p.parse().map_err(|_| FetchError::BadUrl)?, + }; + if host.is_empty() { + return Err(FetchError::BadUrl); + } + let target = if tail.is_empty() || tail.starts_with('?') { + format!("/{tail}") + } else { + tail.to_string() + }; + Ok(Url { + host: host.to_string(), + port, + target, + }) +} + +/// Where a `Location` points from `base`: a full URL, a scheme-less `//host/..`, an absolute path +/// or a path relative to the request's directory. +fn resolve(base: &Url, location: &str) -> Result { + let location = location.trim(); + if strip_prefix_ci(location, "http://").is_some() + || strip_prefix_ci(location, "https://").is_some() + { + return parse_url(location); + } + if let Some(rest) = location.strip_prefix("//") { + return parse_url(&format!("http://{rest}")); + } + let target = if location.starts_with('/') { + location.to_string() + } else { + let path = base.target.split('?').next().unwrap_or("/"); + let dir = &path[..path.rfind('/').map_or(0, |i| i + 1)]; + format!("{dir}{location}") + }; + Ok(Url { + host: base.host.clone(), + port: base.port, + target, + }) +} + +fn connect(url: &Url, timeouts: Timeouts) -> Result { + let mut last = None; + for addr in (url.host.as_str(), url.port).to_socket_addrs()? { + match TcpStream::connect_timeout(&addr, timeouts.connect) { + Ok(stream) => { + stream.set_read_timeout(Some(timeouts.read))?; + stream.set_write_timeout(Some(timeouts.read))?; + return Ok(stream); + } + Err(e) => last = Some(e), + } + } + Err(last + .unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no address")) + .into()) +} + +/// One line without its terminator, `None` at the end of the stream. +fn read_line(r: &mut impl BufRead) -> Result, FetchError> { + let mut line = Vec::new(); + r.by_ref() + .take(MAX_LINE as u64) + .read_until(b'\n', &mut line)?; + if line.is_empty() { + return Ok(None); + } + if line.last() != Some(&b'\n') { + return Err(FetchError::Protocol); + } + while matches!(line.last(), Some(b'\n' | b'\r')) { + line.pop(); + } + Ok(Some(String::from_utf8_lossy(&line).into_owned())) +} + +/// A response head: the status and the headers the body needs. +#[derive(Debug, Default)] +struct Head { + status: u16, + location: Option, + content_length: Option, + chunked: bool, +} + +fn read_head(r: &mut impl BufRead) -> Result { + let status = read_line(r)?.ok_or(FetchError::Protocol)?; + let mut parts = status.split_whitespace(); + if !parts.next().is_some_and(|v| v.starts_with("HTTP/")) { + return Err(FetchError::Protocol); + } + let mut head = Head { + status: parts + .next() + .and_then(|s| s.parse().ok()) + .ok_or(FetchError::Protocol)?, + ..Head::default() + }; + for _ in 0..MAX_HEADERS { + let line = read_line(r)?.ok_or(FetchError::Protocol)?; + if line.is_empty() { + return Ok(head); + } + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match name.trim().to_ascii_lowercase().as_str() { + "location" => head.location = Some(value.to_string()), + "content-length" => head.content_length = value.parse().ok(), + "transfer-encoding" => { + head.chunked = value + .split(',') + .any(|t| t.trim().eq_ignore_ascii_case("chunked")); + } + _ => {} + } + } + Err(FetchError::Protocol) +} + +/// `Transfer-Encoding: chunked`, decoded. +struct ChunkedReader { + inner: R, + /// Bytes left in the current chunk. + left: u64, + /// The previous chunk's trailing CRLF is still to be read. + crlf: bool, + done: bool, +} + +impl ChunkedReader { + fn new(inner: R) -> Self { + ChunkedReader { + inner, + left: 0, + crlf: false, + done: false, + } + } + + fn next_chunk(&mut self) -> io::Result<()> { + let bad = |what| io::Error::new(io::ErrorKind::InvalidData, what); + if self.crlf { + read_line(&mut self.inner).map_err(|_| bad("chunk end"))?; + self.crlf = false; + } + let line = read_line(&mut self.inner) + .map_err(|_| bad("chunk size"))? + .ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + let size = line.split(';').next().unwrap_or_default().trim(); + self.left = u64::from_str_radix(size, 16).map_err(|_| bad("chunk size"))?; + if self.left == 0 { + // The trailers, up to the blank line. + let mut ended = false; + for _ in 0..MAX_HEADERS { + if read_line(&mut self.inner) + .map_err(|_| bad("trailer"))? + .is_none_or(|l| l.is_empty()) + { + ended = true; + break; + } + } + if !ended { + return Err(bad("trailer")); + } + self.done = true; + } else { + self.crlf = true; + } + Ok(()) + } +} + +impl Read for ChunkedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + while self.left == 0 { + if self.done { + return Ok(0); + } + self.next_chunk()?; + } + let want = buf + .len() + .min(usize::try_from(self.left).unwrap_or(usize::MAX)); + let n = self.inner.read(&mut buf[..want])?; + if n == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + self.left -= n as u64; + Ok(n) + } +} + +/// Read until `buf` is full or the body ends. A read error after some bytes returns them with the +/// error. +fn fill(body: &mut impl Read, buf: &mut [u8]) -> (usize, Option) { + let mut n = 0; + while n < buf.len() { + match body.read(&mut buf[n..]) { + Ok(0) => break, + Ok(k) => n += k, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return (n, Some(e)), + } + } + (n, None) +} + +/// The body in reads of up to [`CHUNK`] bytes, each handed to `on_chunk`, which answers whether to +/// read on. +fn stream_body( + body: &mut impl Read, + on_chunk: &mut dyn FnMut(&[u8]) -> bool, +) -> Result<(), FetchError> { + let mut chunk = vec![0u8; CHUNK]; + loop { + let (n, error) = fill(body, &mut chunk); + if n > 0 && !on_chunk(&chunk[..n]) { + return Ok(()); + } + if let Some(e) = error { + return Err(e.into()); + } + if n < CHUNK { + return Ok(()); + } + } +} + +/// The request, in one write so the head leaves in one segment. An IPv6 literal is bracketed in +/// the Host header. +fn send_request(w: &mut impl Write, url: &Url) -> io::Result<()> { + let name = if url.host.contains(':') { + format!("[{}]", url.host) + } else { + url.host.clone() + }; + let host = if url.port == 80 { + name + } else { + format!("{name}:{}", url.port) + }; + let request = format!( + "GET {} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {AGENT}\r\nAccept: */*\r\n\ + Cache-Control: no-cache\r\nPragma: no-cache\r\nConnection: close\r\n\r\n", + url.target + ); + w.write_all(request.as_bytes()) +} + +/// `GET url`, following up to [`MAX_REDIRECTS`] redirects, and the 2xx body to `on_chunk` in reads +/// of up to 0x4000 bytes. A body that ends in an error has handed over what came before it. +pub(super) fn download( + url: &str, + timeouts: Timeouts, + on_chunk: &mut dyn FnMut(&[u8]) -> bool, +) -> Result<(), FetchError> { + let mut url = parse_url(url)?; + for _ in 0..=MAX_REDIRECTS { + let mut stream = connect(&url, timeouts)?; + send_request(&mut stream, &url)?; + let mut reader = BufReader::with_capacity(CHUNK, stream); + let head = read_head(&mut reader)?; + match head.status { + // `0x7a6a11`-`0x7a6a21`: 200 up to 299 reads the body. + 200..=299 => { + if head.status == 204 { + return Ok(()); + } + return if head.chunked { + stream_body(&mut ChunkedReader::new(reader), on_chunk) + } else if let Some(length) = head.content_length { + stream_body(&mut reader.take(length), on_chunk) + } else { + stream_body(&mut reader, on_chunk) + }; + } + 301 | 302 | 303 | 307 | 308 => { + let location = head.location.ok_or(FetchError::Status(head.status))?; + url = resolve(&url, &location)?; + } + status => return Err(FetchError::Status(status)), + } + } + Err(FetchError::TooManyRedirects) +} + +/// The alert `url` serves: the text after `SERVERALERT:`, or `None` for every other outcome. +pub(super) fn fetch_alert(url: &str, timeouts: Timeouts) -> Option> { + let mut buffer = AlertBuffer::default(); + let result = download(url, timeouts, &mut |chunk| { + buffer.push(chunk); + !buffer.is_full() + }); + match result { + Err(FetchError::Https) => { + info!("server alert: {url} is https; the reference shows nothing for it, nor does benilla"); + } + Err(e) => debug!("server alert: {url}: {e}"), + Ok(()) => {} + } + buffer.finish() +} + +/// The fetch on a thread of its own: the channel holds the alert once, or closes without one. +pub(super) fn spawn_fetch(url: String) -> crossbeam_channel::Receiver>> { + let (tx, rx) = crossbeam_channel::bounded(1); + let started = std::thread::Builder::new() + .name("server-alert".into()) + .spawn(move || { + let _ = tx.send(fetch_alert(&url, Timeouts::REFERENCE)); + }); + if let Err(e) = started { + debug!("server alert: no thread: {e}"); + } + rx +} + +/// Whether this run may touch the network or the desktop for the alert: not a test, and not a +/// capture or any other unattended run. +pub(super) fn allowed() -> bool { + !cfg!(test) && !crate::run_mode::unattended() +} + +/// Whether the first glue VM of the process has been seen: the reference initialises its glue +/// manager once (`0x46a400`), so the fetch happens once however many glue VMs follow. +static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// The fetch for the glue manager's init (`0x46a564`): started once per process, and only for a +/// URL that is not empty, when the run [`allowed`] it. +pub(super) fn begin_once(url: &str) -> Option>>> { + if STARTED.swap(true, std::sync::atomic::Ordering::SeqCst) || !allowed() || url.is_empty() { + return None; + } + Some(spawn_fetch(url.to_string())) +} + +/// `LaunchURL` reaching the host (`0x5abd00`): a URL that passed the verb's whitelist. +#[derive(Message, Clone, Debug, PartialEq, Eq)] +pub(crate) struct LaunchUrl(pub(crate) String); + +/// Open each launched URL in the player's browser (`0x429ff0`'s `ShellExecute "open"`), never in +/// a test or an unattended run. +pub(crate) fn open_launched(mut urls: MessageReader) { + for LaunchUrl(url) in urls.read() { + // The verb gated it, and the opener does not take the message's word for it. + if !benilla_ui::script::launch_url_allowed(url) { + warn!("LaunchURL: {url:?} refused"); + continue; + } + if !allowed() { + debug!("LaunchURL: {url} not opened in this run"); + continue; + } + open_in_browser(url); + } +} + +/// The platform's opener, detached. The URL is `http://` and letters, digits, `.`, `-` and `/` +/// ([`benilla_ui::script::GlueRequest::LaunchUrl`]), so it carries nothing a shell would read. +fn open_in_browser(url: &str) { + let mut command = if cfg!(target_os = "macos") { + std::process::Command::new("/usr/bin/open") + } else if cfg!(target_os = "windows") { + // By its full path, never a search of the working directory or the PATH. + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); + let mut c = std::process::Command::new( + std::path::Path::new(&root) + .join("System32") + .join("rundll32.exe"), + ); + c.arg("url.dll,FileProtocolHandler"); + c + } else { + std::process::Command::new("xdg-open") + }; + command + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + match command.spawn() { + Ok(mut child) => { + // Reaped off the main thread, so the opener never lingers as a zombie. + std::thread::spawn(move || { + let _ = child.wait(); + }); + } + Err(e) => warn!("LaunchURL: no browser opener: {e}"), + } +} + +#[cfg(test)] +#[path = "server_alert_tests.rs"] +mod tests; diff --git a/crates/benilla-app/src/ui_script/server_alert_tests.rs b/crates/benilla-app/src/ui_script/server_alert_tests.rs new file mode 100644 index 00000000..c4a2f3ed --- /dev/null +++ b/crates/benilla-app/src/ui_script/server_alert_tests.rs @@ -0,0 +1,419 @@ +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use super::*; + +/// Short enough that the timeout test is quick. +const FAST: Timeouts = Timeouts { + connect: Duration::from_millis(500), + read: Duration::from_millis(300), +}; + +// ── The buffer, chunk by chunk ── + +#[test] +fn a_chunk_loses_its_last_byte_and_stops_at_a_nul() { + let mut b = AlertBuffer::default(); + b.push(b"SERVERALERT:abX"); + assert_eq!(b.buf, b"SERVERALERT:ab"); + // One byte leaves nothing (`SStrCopy` of size 1), and an empty read adds nothing. + let mut b = AlertBuffer::default(); + b.push(b"X"); + b.push(b""); + assert!(b.buf.is_empty()); + let mut b = AlertBuffer::default(); + b.push(b"ab\0cdX"); + assert_eq!(b.buf, b"ab"); +} + +/// The callback appends (`SStrPack`), so a body read in pieces is the pieces joined, each short +/// of its last byte. +#[test] +fn chunks_add_up_to_the_buffer_and_stop_at_its_cap() { + let mut b = AlertBuffer::default(); + b.push(b"SERVERALERT:abX"); + b.push(b"cdY"); + assert_eq!(b.finish().as_deref(), Some(&b"abcd"[..])); + + let mut b = AlertBuffer::default(); + b.push(b"SERVERALERT:"); + b.push(&vec![b'a'; 0x4000]); + assert_eq!(b.buf.len(), 0x7ff, "0x800 with the terminator"); + assert!(b.is_full()); + b.push(b"more"); + assert_eq!(b.buf.len(), 0x7ff); + // A chunk of exactly 0x7ff or 0x800 bytes fills it the same way. + for len in [0x7ff, 0x800, 0x801] { + let mut b = AlertBuffer::default(); + b.push(&vec![b'a'; len]); + assert_eq!(b.buf.len(), (len.min(0x800) - 1), "{len}"); + } +} + +#[test] +fn the_prefix_is_case_folded_and_must_be_all_there() { + let alert = |body: &[u8]| { + let mut b = AlertBuffer::default(); + b.push(body); + b.finish() + }; + assert_eq!(alert(b"serverAlert:hi\n"), Some(b"hi".to_vec())); + assert_eq!(alert(b"SERVERALERT:\n"), Some(Vec::new())); + assert_eq!(alert(b"SERVERALERT\n"), None, "no colon"); + assert_eq!(alert(b"SERVERALERT"), None, "a prefix cut short"); + assert_eq!(alert(b" SERVERALERT:hi\n"), None, "not at the start"); + assert_eq!(alert(b"SERVERALERT:"), None); + assert_eq!(alert(b""), None); +} + +// ── The URL ── + +#[test] +fn urls_split_into_host_port_and_target() { + let url = |s| parse_url(s).unwrap(); + assert_eq!( + url("http://launcher.worldofwarcraft.com/alert"), + Url { + host: "launcher.worldofwarcraft.com".into(), + port: 80, + target: "/alert".into() + } + ); + assert_eq!(url("HTTP://h:8080").target, "/"); + assert_eq!(url("http://h:8080?x=1#frag").target, "/?x=1"); + assert_eq!(url("http://h:8080?x=1").port, 8080); + assert_eq!(url("http://u:p@h/a/b?c#d").host, "h"); + let v6 = url("http://[::1]:81/p"); + assert_eq!((v6.host.as_str(), v6.port), ("::1", 81)); + assert!(matches!(parse_url("https://h/"), Err(FetchError::Https))); + assert!(matches!(parse_url("HTTPS://h/"), Err(FetchError::Https))); + for bad in [ + "", + "ftp://h/", + "h/alert", + "http://", + "http://:80/", + "http://h:x/", + ] { + assert!(matches!(parse_url(bad), Err(FetchError::BadUrl)), "{bad}"); + } +} + +#[test] +fn a_location_resolves_against_the_request() { + let base = parse_url("http://h:81/a/b?q").unwrap(); + let go = |l| resolve(&base, l).unwrap(); + assert_eq!(go("/c").target, "/c"); + assert_eq!(go("c").target, "/a/c"); + assert_eq!(go("http://other/x").host, "other"); + assert_eq!(go("//other:9/x").port, 9); + assert_eq!((go("/c").host.as_str(), go("/c").port), ("h", 81)); + assert!(matches!( + resolve(&base, "https://h/"), + Err(FetchError::Https) + )); +} + +// ── The fetch, against a fake server ── + +/// What a fake server does with a connection. +enum Reply { + /// Write these bytes and close. + Raw(Vec), + /// Accept and say nothing. + Silent, + /// Write these bytes and then say nothing, the connection open. + Stall(Vec), +} + +/// A server on a loopback port: each connection's request head goes to `handler` with the +/// connection's number, and the heads are kept. +struct Fake { + port: u16, + heads: Arc>>, +} + +impl Fake { + fn start(handler: impl Fn(&str, usize, u16) -> Reply + Send + 'static) -> Fake { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let heads = Arc::new(Mutex::new(Vec::new())); + let seen = heads.clone(); + std::thread::spawn(move || { + for (n, conn) in listener.incoming().enumerate() { + let Ok(mut conn) = conn else { return }; + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + if conn.read(&mut byte).unwrap_or(0) == 0 { + break; + } + head.push(byte[0]); + } + let head = String::from_utf8_lossy(&head).into_owned(); + let reply = handler(&head, n, port); + seen.lock().unwrap().push(head); + match reply { + Reply::Raw(bytes) => { + let _ = conn.write_all(&bytes); + } + Reply::Silent => std::thread::sleep(Duration::from_secs(2)), + Reply::Stall(bytes) => { + let _ = conn.write_all(&bytes); + std::thread::sleep(Duration::from_secs(2)); + } + } + } + }); + Fake { port, heads } + } + + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } + + fn requests(&self) -> usize { + self.heads.lock().unwrap().len() + } +} + +fn ok(body: &[u8]) -> Reply { + let mut raw = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes(); + raw.extend_from_slice(body); + Reply::Raw(raw) +} + +fn alert(url: &str) -> Option { + fetch_alert(url, FAST).map(|t| String::from_utf8_lossy(&t).into_owned()) +} + +#[test] +fn a_200_with_the_prefix_is_an_alert_and_the_request_is_the_references() { + let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:\nRealms are down.\n")); + assert_eq!( + alert(&fake.url("/alert?x=1")).as_deref(), + // The last byte, the final newline, is the callback's to lose. + Some("\nRealms are down.") + ); + let head = fake.heads.lock().unwrap()[0].clone(); + assert!(head.starts_with("GET /alert?x=1 HTTP/1.1\r\n"), "{head}"); + assert!(head.contains(&format!("Host: 127.0.0.1:{}\r\n", fake.port))); + assert!(head.contains("User-Agent: Blizzard Web Client\r\n")); +} + +#[test] +fn a_200_without_the_prefix_is_no_alert() { + let fake = Fake::start(|_, _, _| ok(b"Not found, really.\n")); + assert_eq!(alert(&fake.url("/")), None); +} + +#[test] +fn a_404_is_no_alert_even_with_the_prefix_in_its_body() { + let body = b"SERVERALERT:\nnot for you\n"; + let fake = Fake::start(move |_, _, _| { + let mut raw = format!( + "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + raw.extend_from_slice(body); + Reply::Raw(raw) + }); + assert_eq!(alert(&fake.url("/")), None); +} + +#[test] +fn redirects_are_followed_up_to_the_limit() { + let fake = Fake::start(|head, _, port| { + let to = |loc: String| { + Reply::Raw( + format!("HTTP/1.1 302 Found\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n") + .into_bytes(), + ) + }; + if head.starts_with("GET /a ") { + to("/b".into()) + } else if head.starts_with("GET /b ") { + to(format!("http://127.0.0.1:{port}/c")) + } else if head.starts_with("GET /loop ") { + to("/loop".into()) + } else { + ok(b"SERVERALERT:moved\n") + } + }); + assert_eq!(alert(&fake.url("/a")).as_deref(), Some("moved")); + assert_eq!(fake.requests(), 3); + let before = fake.requests(); + assert_eq!(alert(&fake.url("/loop")), None); + assert_eq!(fake.requests() - before, MAX_REDIRECTS + 1); +} + +#[test] +fn a_redirect_to_https_is_not_followed() { + let fake = Fake::start(|_, _, _| { + Reply::Raw( + b"HTTP/1.1 301 Moved\r\nLocation: https://127.0.0.1:1/alert\r\nContent-Length: 0\r\n\r\n" + .to_vec(), + ) + }); + assert!(matches!( + download(&fake.url("/"), FAST, &mut |_| true), + Err(FetchError::Https) + )); + assert_eq!(alert(&fake.url("/")), None); +} + +#[test] +fn a_chunked_body_is_decoded() { + let fake = Fake::start(|_, _, _| { + Reply::Raw( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\ + 7\r\nSERVERA\r\n8;ext=1\r\nLERT:hi!\r\n3\r\n\nyo\r\n0\r\nTrailer: x\r\n\r\n" + .to_vec(), + ) + }); + // One read of the whole body: the decoded bytes, short of the last. + assert_eq!(alert(&fake.url("/")).as_deref(), Some("hi!\ny")); +} + +#[test] +fn a_body_with_no_length_is_read_to_the_close() { + let fake = Fake::start(|_, _, _| { + Reply::Raw(b"HTTP/1.0 200 OK\r\nServer: x\r\n\r\nSERVERALERT:to the end\n".to_vec()) + }); + assert_eq!(alert(&fake.url("/")).as_deref(), Some("to the end")); +} + +#[test] +fn a_server_that_says_nothing_times_out() { + let fake = Fake::start(|_, _, _| Reply::Silent); + let start = Instant::now(); + assert_eq!(alert(&fake.url("/")), None); + assert!(start.elapsed() < Duration::from_millis(1500)); +} + +/// The status and the first bytes, then silence past the read timeout: the done call +/// (`0x7a6c4b`) runs whatever ended the reads, and finds the prefix. +#[test] +fn a_body_that_stalls_keeps_what_came_before_it() { + let fake = Fake::start(|_, _, _| { + Reply::Stall( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nSERVERALERT:partial\nmore".to_vec(), + ) + }); + let start = Instant::now(); + assert_eq!(alert(&fake.url("/")).as_deref(), Some("partial\nmor")); + assert!(start.elapsed() < Duration::from_millis(1500)); +} + +#[test] +fn a_3_kb_body_fills_the_buffer_and_no_more() { + let mut body = b"SERVERALERT:".to_vec(); + body.extend((0..3000).map(|i| b'a' + (i % 26) as u8)); + let fake = Fake::start(move |_, _, _| ok(&body.clone())); + let text = fetch_alert(&fake.url("/"), FAST).unwrap(); + assert_eq!(text.len(), 0x7ff - PREFIX.len()); + assert!(text.starts_with(b"abcdefghij")); +} + +#[test] +fn an_https_url_is_never_dialled() { + let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:x")); + let https = fake.url("/").replacen("http://", "https://", 1); + assert!(matches!( + download(&https, FAST, &mut |_| true), + Err(FetchError::Https) + )); + assert_eq!(alert(&https), None); + assert_eq!(fake.requests(), 0); +} + +#[test] +fn a_refused_connection_is_silent() { + let port = { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + assert_eq!(alert(&format!("http://127.0.0.1:{port}/")), None); + assert!(TcpStream::connect(("127.0.0.1", port)).is_err()); +} + +/// The fetch runs on a thread of its own, so the caller never waits on it. +#[test] +fn the_background_fetch_hands_over_its_alert_through_a_channel() { + let fake = Fake::start(|_, _, _| ok(b"SERVERALERT:\nhello\n")); + let rx = spawn_fetch(fake.url("/")); + let got = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(got.as_deref(), Some(&b"\nhello"[..])); +} + +/// A test run starts no fetch and opens no browser, whatever the URL. +#[test] +fn a_test_run_neither_fetches_nor_opens_a_browser() { + assert!(!allowed()); + assert!(begin_once("http://127.0.0.1:1/").is_none()); +} + +/// A writer that records each call, to see how the request leaves. +#[derive(Default)] +struct Calls(Vec>); + +impl Write for Calls { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.push(buf.to_vec()); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn the_request_head_leaves_in_one_write() { + let mut calls = Calls::default(); + send_request(&mut calls, &parse_url("http://h:81/a?b").unwrap()).unwrap(); + assert_eq!(calls.0.len(), 1); + let head = String::from_utf8(calls.0.remove(0)).unwrap(); + assert!( + head.starts_with("GET /a?b HTTP/1.1\r\nHost: h:81\r\n"), + "{head}" + ); + assert!(head.ends_with("\r\n\r\n")); +} + +#[test] +fn an_ipv6_host_is_bracketed_in_the_host_header() { + let mut calls = Calls::default(); + send_request(&mut calls, &parse_url("http://[::1]/").unwrap()).unwrap(); + let head = String::from_utf8(calls.0.concat()).unwrap(); + assert!(head.contains("\r\nHost: [::1]\r\n"), "{head}"); + calls.0.clear(); + send_request(&mut calls, &parse_url("http://[::1]:8080/").unwrap()).unwrap(); + assert!(String::from_utf8(calls.0.concat()) + .unwrap() + .contains("\r\nHost: [::1]:8080\r\n")); +} + +/// A chunked body whose trailers never end is refused, not read forever. +#[test] +fn endless_chunked_trailers_are_refused() { + let mut raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n".to_vec(); + for _ in 0..MAX_HEADERS + 5 { + raw.extend_from_slice(b"X-Trailer: y\r\n"); + } + raw.extend_from_slice(b"\r\n"); + let fake = Fake::start(move |_, _, _| Reply::Raw(raw.clone())); + assert!(matches!( + download(&fake.url("/"), FAST, &mut |_| true), + Err(FetchError::Io(_)) + )); + // The ordinary trailer still ends it. + let fake = Fake::start(|_, _, _| { + Reply::Raw( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX: y\r\n\r\n".to_vec(), + ) + }); + assert!(download(&fake.url("/"), FAST, &mut |_| true).is_ok()); +} diff --git a/crates/benilla-ui/src/script/glue_core.rs b/crates/benilla-ui/src/script/glue_core.rs index 348898c6..6ecf4d70 100644 --- a/crates/benilla-ui/src/script/glue_core.rs +++ b/crates/benilla-ui/src/script/glue_core.rs @@ -122,6 +122,69 @@ pub enum GlueRequest { /// cleared in the VM; the host takes screen state 3, the `CHAR_LIST_RETRIEVING` dialog and /// the `CMSG_CHAR_ENUM`. GetCharacterList, + /// `LaunchURL(url)` (`0x46cf60` → `0x5abd00`) for a URL that passed `launch_url_allowed` + /// (`0x5abd00` tests `0x5abc10` first): the player's browser opens it (`0x429ff0`). + LaunchUrl(String), +} + +/// The hosts `LaunchURL` opens, the 10-entry table at `0x85cc34`: a leading `*` stands for one +/// label, so `*.blizzard.com` takes `forums.blizzard.com` and not `a.b.blizzard.com`. +const LAUNCH_HOSTS: [&str; 10] = [ + "*.blizzard.com", + "*.blizzcon.com", + "www.battle.net", + "*.worldofwarcraft.com", + "www.battlenet.co.kr", + "*.worldofwarcraft.co.kr", + "*.blizzard-europe.com", + "*.wow-europe.com", + "*.wowchina.com", + "*.wowtaiwan.com.tw", +]; + +/// The gate in front of the browser: `0x5abd00` tests `0x5abc10` and `0x429ff0` takes the URL. +/// The URL begins `http://` (`0x85d6bc`, compared case-folded), every byte after it is a letter, +/// a digit, `.`, `-` or `/` (so no query, port, escape or `_`), and its host, the text up to the +/// first `/`, is one of [`LAUNCH_HOSTS`], compared case-folded. `0x429ff0` refuses a URL of 0x400 +/// bytes or more (`0x42a01c`). The reference works on a C string, so the verb cuts its argument +/// at a NUL before asking; a URL with a NUL in it is refused here. +/// +/// Deviation: a host of 0x80 bytes or more is refused. The reference copies the host into a 0x80-byte +/// buffer (`0x5abc7c`-`0x5abc9c`) and writes its terminator one byte past the end, and the cut +/// host could end in a whitelisted suffix the real one does not; this takes the safe side. +pub fn launch_url_allowed(url: &str) -> bool { + if url.len() >= 0x400 || url.contains('\0') { + return false; + } + let url = url.as_bytes(); + const SCHEME: &[u8] = b"http://"; + let Some(rest) = url + .get(..SCHEME.len()) + .filter(|head| head.eq_ignore_ascii_case(SCHEME)) + .map(|_| &url[SCHEME.len()..]) + else { + return false; + }; + if !rest + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'/')) + { + return false; + } + let host = rest.split(|&b| b == b'/').next().unwrap_or_default(); + if host.len() >= 0x80 { + return false; + } + LAUNCH_HOSTS + .iter() + .any(|entry| match entry.strip_prefix('*') { + // `SStrChr(host, '.')`, then the rest against the entry past its `*`. + Some(suffix) => host + .iter() + .position(|&b| b == b'.') + .is_some_and(|dot| host[dot..].eq_ignore_ascii_case(suffix.as_bytes())), + None => host.eq_ignore_ascii_case(entry.as_bytes()), + }) } /// The realm `GetServerName` (`0x46d280`) answers: the `realmName` CVar's text, and the @@ -432,6 +495,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; + // A string argument (`0x46cf68`) or `Usage: LaunchURL("URL")` (`0x837ca4`), then `0x5abd00`, + // which opens the browser for a URL `0x5abc10` passes and does nothing for any other. The + // in-game state has no such verb. + g.set( + "LaunchURL", + lua.create_function(|lua, url: Value| { + let url = string_arg(lua, url, "Usage: LaunchURL(\"URL\")")?; + // The C string ends at a NUL (`0x6f3690`), for the gate and for the shell alike. + let url = url.split('\0').next().unwrap_or_default(); + if launch_url_allowed(url) { + queue(lua, GlueRequest::LaunchUrl(url.to_string())); + } + Ok(()) + })?, + )?; g.set( "StopGlueMusic", lua.create_function(|lua, _: MultiValue| { @@ -575,6 +653,80 @@ mod tests { ); } + /// `0x5abc10`: the scheme, the character set past it and the host table. + #[test] + fn launch_url_opens_only_the_whitelisted_hosts() { + let ok = [ + "http://www.worldofwarcraft.com/", + "HTTP://WWW.WorldOfWarcraft.com/index.html", + "http://forums.blizzard.com", + "http://us.blizzard.com/en-us/alert", + "http://www.battle.net/x", + "http://www.wow-europe.com/en/info/theburningcrusade.html", + "http://status.wow-europe.com/en/alert", + ]; + for url in ok { + assert!(launch_url_allowed(url), "{url}"); + } + let refused = [ + "https://www.blizzard.com/", + "ftp://www.blizzard.com/", + "www.blizzard.com", + "http://blizzard.com/", + "http://a.b.blizzard.com/", + "http://example.com/", + "http://example.com/www.blizzard.com", + "http://www.blizzard.com.evil.net/", + "http://battle.net/", + "http://www.blizzard.com/?q=1", + "http://www.blizzard.com:80/", + "http://www.blizzard.com/a_b", + "http://www.blizzard.com/a b", + "http://www.blizzard.com\n/", + "", + ]; + for url in refused { + assert!(!launch_url_allowed(url), "{url:?}"); + } + // The gate takes a C string: a NUL inside one is refused outright. + assert!(!launch_url_allowed("http://www.blizzard.com/\0?q=1")); + // 0x400 bytes or more is refused (`0x42a01c`); one byte under is not. + let path = |n: usize| format!("http://www.blizzard.com/{}", "a".repeat(n - 24)); + assert!(launch_url_allowed(&path(0x3ff))); + assert!(!launch_url_allowed(&path(0x400))); + // A host of 0x80 bytes or more is refused rather than cut to a whitelisted tail. + let host = |n: usize| format!("http://{}.blizzard.com/", "a".repeat(n - 13)); + assert!(launch_url_allowed(&host(0x7f))); + assert!(!launch_url_allowed(&host(0x80))); + let cut = format!("http://{}.blizzard.com.evil.net/", "a".repeat(0x80 - 13)); + assert!(!launch_url_allowed(&cut)); + } + + #[test] + fn launch_url_queues_a_passing_url_and_raises_its_usage() { + let mut s = UiScript::new_glue().unwrap(); + s.run("LaunchURL('http://www.worldofwarcraft.com/')") + .unwrap(); + s.run("LaunchURL('http://example.com/')").unwrap(); + // The text before a NUL is the argument, for the gate and for the opener. + s.run("LaunchURL('http://www.blizzard.com/a\\0?q=1')") + .unwrap(); + s.run("LaunchURL('http://example.com/\\0http://www.blizzard.com/')") + .unwrap(); + let queued = s.take_glue_requests(); + assert!( + matches!( + queued.as_slice(), + [GlueRequest::LaunchUrl(a), GlueRequest::LaunchUrl(b)] + if a == "http://www.worldofwarcraft.com/" && b == "http://www.blizzard.com/a" + ), + "{queued:?}" + ); + let err = s.run("LaunchURL()").unwrap_err().to_string(); + assert!(err.contains("Usage: LaunchURL(\"URL\")"), "{err}"); + assert!(s.take_glue_requests().is_empty()); + } + #[test] fn the_glue_build_info_is_five_strings() { let s = UiScript::new_glue().unwrap(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 43b4d913..af350131 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -69,7 +69,9 @@ mod glue_core; mod glue_realm; mod glue_select; pub use glue::{VmKind, GLUE_EVENTS}; -pub use glue_core::{GlueCredentials, GlueNotice, GlueRequest, GlueServer, NoticeState}; +pub use glue_core::{ + launch_url_allowed, GlueCredentials, GlueNotice, GlueRequest, GlueServer, NoticeState, +}; pub use glue_realm::{ realm_load_classify, realm_load_stats, GlueRealm, GlueRealmCategory, GlueRealmCells, GlueRealmType, RealmSortKey, diff --git a/crates/benilla-ui/src/script/tests/glue_surface.rs b/crates/benilla-ui/src/script/tests/glue_surface.rs index 5652b7e8..4c4cbe30 100644 --- a/crates/benilla-ui/src/script/tests/glue_surface.rs +++ b/crates/benilla-ui/src/script/tests/glue_surface.rs @@ -140,7 +140,7 @@ const NOT_YET: &[(&str, &[&str])] = &[ ), ( "the glue core (0x46ddf0, table 0x8373b8): the addon list, the movie's setter, the patch and \ - survey, the billing, the URLs and the credits music; each a binding of its own, the \ + survey, the billing, the addon URL and the credits music; each a binding of its own, the \ ones with an in-game namesake included", &[ "DisableAddOn", @@ -158,7 +158,6 @@ const NOT_YET: &[(&str, &[&str])] = &[ "GetScriptMemory", "IsAddonVersionCheckEnabled", "LaunchAddOnURL", - "LaunchURL", "PINEntered", "PatchDownloadApply", "PatchDownloadCancel", diff --git a/crates/benilla-ui/src/script/tests/simplehtml.rs b/crates/benilla-ui/src/script/tests/simplehtml.rs index 03868a11..3b7c70b5 100644 --- a/crates/benilla-ui/src/script/tests/simplehtml.rs +++ b/crates/benilla-ui/src/script/tests/simplehtml.rs @@ -290,6 +290,21 @@ fn the_three_fallback_routes_each_land_on_one_raw_block() { } } +/// The login screen's server alert (`SHOW_SERVER_ALERT`'s text, `ServerAlertText:SetText(arg1)`) +/// is plain text with blank lines, brackets, an apostrophe and a final newline: one `P` block of +/// the raw string, no collapse (`0x78a501`). +#[test] +fn a_plain_text_server_alert_is_one_raw_p_block_with_its_newlines() { + let s = script(); + page(&s); + let body = "[January 17, 2007 - 5:00PM PST]\n\nPlease note: free moves.\n\n\ + Aggramar\nArgent Dawn\nZul'jin\n"; + s.run(&format!("Page:SetText({})", lua_str(body))).unwrap(); + assert_eq!(texts(&s, "Page"), [body]); + assert_eq!(blocks(&s, "Page")[0].justify_h, "LEFT"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + #[test] fn malformed_markup_falls_back_rather_than_erroring() { let s = script();