Stock login fetches the server alert; LaunchURL behind the gate (#1040)
The stock login's Breaking News box (`WOW_GLUE_STOCK=1`): - At glue init, as `0x46a400` does, the client reads the Lua global `SERVER_ALERT_URL`, which the player's own GlueStrings or a server's patch sets. It fetches the URL once on a background thread: plain HTTP, 5 s timeouts, redirects followed. - The body builds the reference's buffer (`0x46a580`: 0x7ff cap, appended chunks). A case-insensitive `SERVERALERT:` prefix fires `SHOW_SERVER_ALERT` once from the update (`0x46b939`). Every failure is silent. - `LaunchURL` opens the browser only for URLs that pass the reference's gate (`0x5abc10`): `http://`, a strict charset and the 10-host whitelist. An over-long host is refused rather than cut. - No fetch or browser ever runs in a test, capture or unattended run. Tests run against a local fake server: prefix, 404, redirects, chunked, timeouts, caps, and the frame shown end to end. Part of #1026 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
5c7eb5fc75
commit
2af917044d
@@ -54,6 +54,7 @@ impl Plugin for LoginPlugin {
|
||||
app.init_resource::<LoginIntent>()
|
||||
.init_resource::<LoginForm>()
|
||||
.add_message::<GlueLogin>()
|
||||
.add_message::<crate::ui_script::server_alert::LaunchUrl>()
|
||||
.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",
|
||||
|
||||
@@ -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<ParkRequest>,
|
||||
/// Asks of the login engine the per-frame arms made, sent with the verbs' own.
|
||||
login_out: Vec<GlueLogin>,
|
||||
/// The server alert's fetch while it runs (`alertPending`, `[0xb41e28]`), started once per
|
||||
/// process at the glue init.
|
||||
alert_fetch: Option<crossbeam_channel::Receiver<Option<Vec<u8>>>>,
|
||||
/// The text a finished fetch left for the next frame (`alertReady`, `[0xb41e2c]`, and the
|
||||
/// buffer `0xb414f8` past its prefix).
|
||||
alert_ready: Option<String>,
|
||||
}
|
||||
|
||||
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<Option<Vec<u8>>>) {
|
||||
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<GlueLogin>) {
|
||||
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<Res<'w, crate::net::RealmChoice>>,
|
||||
abandon: Option<Res<'w, crate::net::LoginAbandon>>,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -770,6 +770,7 @@ fn stock_app() -> (
|
||||
.add_message::<DisconnectedMessage>()
|
||||
.add_message::<GlueLogin>()
|
||||
.add_message::<crate::sound::GlueMusicRequest>()
|
||||
.add_message::<crate::ui_script::server_alert::LaunchUrl>()
|
||||
.add_message::<AppExit>()
|
||||
.init_resource::<crate::portrait::GluePreview>()
|
||||
.add_systems(
|
||||
@@ -1134,6 +1135,7 @@ fn live_app_with(
|
||||
.add_message::<DisconnectedMessage>()
|
||||
.add_message::<GlueLogin>()
|
||||
.add_message::<crate::sound::GlueMusicRequest>()
|
||||
.add_message::<crate::ui_script::server_alert::LaunchUrl>()
|
||||
.add_message::<AppExit>()
|
||||
.init_resource::<crate::portrait::GluePreview>()
|
||||
.add_systems(
|
||||
@@ -1904,3 +1906,144 @@ fn a_roster_reply_after_the_dialogs_cancel_is_dropped() {
|
||||
);
|
||||
assert_eq!(vm(&app).errors(), Vec::<String>::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::<GlueMgr>().state, ScreenState::Logon);
|
||||
assert!(!shown(&app, "ServerAlertFrame"));
|
||||
|
||||
let fetch = crate::ui_script::server_alert::spawn_fetch(url);
|
||||
app.world_mut()
|
||||
.resource_mut::<GlueMgr>()
|
||||
.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::<String>("return ALERT_ARG").unwrap(), want);
|
||||
assert_eq!(abandon.load(Ordering::SeqCst), 1, "the logon was abandoned");
|
||||
assert_eq!(app.world().resource::<GlueMgr>().state, ScreenState::Idle);
|
||||
|
||||
// Once: nothing refires it.
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
assert_eq!(vm(&app).eval::<f64>("return ALERT_CALLS").unwrap(), 1.0);
|
||||
assert_eq!(vm(&app).errors(), Vec::<String>::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("<html>Not an alert.</html>\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::<GlueMgr>()
|
||||
.set_alert_fetch(fetch);
|
||||
until(&mut app, "the fetch to end", |app| {
|
||||
app.world().resource::<GlueMgr>().alert_fetch.is_none()
|
||||
});
|
||||
app.update();
|
||||
assert!(!shown(&app, "ServerAlertFrame"));
|
||||
assert_eq!(abandon.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(app.world().resource::<GlueMgr>().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<String> = app
|
||||
.world()
|
||||
.resource::<Messages<crate::ui_script::server_alert::LaunchUrl>>()
|
||||
.iter_current_update_messages()
|
||||
.map(|m| m.0.clone())
|
||||
.collect();
|
||||
assert_eq!(launched, ["http://www.worldofwarcraft.com/news"]);
|
||||
}
|
||||
|
||||
@@ -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::<super::glue_mgr::GlueMgr>() {
|
||||
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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
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<io::Error> 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<Url, FetchError> {
|
||||
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<Url, FetchError> {
|
||||
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<TcpStream, FetchError> {
|
||||
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<Option<String>, 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<String>,
|
||||
content_length: Option<u64>,
|
||||
chunked: bool,
|
||||
}
|
||||
|
||||
fn read_head(r: &mut impl BufRead) -> Result<Head, FetchError> {
|
||||
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<R> {
|
||||
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<R: BufRead> ChunkedReader<R> {
|
||||
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<R: BufRead> Read for ChunkedReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
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<io::Error>) {
|
||||
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<Vec<u8>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<crossbeam_channel::Receiver<Option<Vec<u8>>>> {
|
||||
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<LaunchUrl>) {
|
||||
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;
|
||||
@@ -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"<html>SERVERALERT:</html>"), 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<u8>),
|
||||
/// Accept and say nothing.
|
||||
Silent,
|
||||
/// Write these bytes and then say nothing, the connection open.
|
||||
Stall(Vec<u8>),
|
||||
}
|
||||
|
||||
/// 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<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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"<html>Not found, really.</html>\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<Vec<u8>>);
|
||||
|
||||
impl Write for Calls {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
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());
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user