diff --git a/crates/horizon-port-pack/src/net.rs b/crates/horizon-port-pack/src/net.rs index d457ae804..d829d1357 100644 --- a/crates/horizon-port-pack/src/net.rs +++ b/crates/horizon-port-pack/src/net.rs @@ -2,65 +2,142 @@ //! places as everything else, so they take the same proxy — and leave the same hosts alone. //! //! The launcher hands its own setting over (`set_proxy`, from `proxy.rs`); a run of the -//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead. Either -//! way a host in `NO_PROXY`, or on this computer, is reached directly: a host that only answers -//! without the proxy would otherwise fail here while the launcher's own downloads worked. +//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead (`env_for`), +//! taking the one that matches the address's scheme the way the launcher does. Either way a host in +//! `NO_PROXY`, or on this computer, is reached directly: a host that only answers without the proxy +//! would otherwise fail here while the launcher's own downloads worked. //! -//! The rules are `launcher/src/proxy.rs`'s, which the launcher applies to its own traffic. The two -//! crates share no code (the launcher depends on this one, not the other way round), so they are -//! kept in step by hand. -use std::sync::Mutex; +//! Which hosts those are is decided here and nowhere else. The launcher asks `direct`, `env_for`, +//! `host_of`, `scheme_of`, `socks` and `parse` for its own traffic, and keeps its agents here too +//! (`keep`), so the two agree whatever the player set — and there is one copy of the rules to +//! change when they need it. +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; /// What the launcher set, when it did. static PROXY: Mutex> = Mutex::new(None); +/// Agents kept for reuse, named by the part that asked and the proxy they use: a fresh `Agent` +/// starts a new connection pool, so building one per request costs a handshake per file. +static KEPT: Mutex>> = Mutex::new(None); + /// The proxy to use from now on: the launcher's setting, or None to fall back to the environment. +/// The agents built so far are dropped when it changes, since they hold the address it replaces. pub fn set_proxy(spec: Option) { let spec = spec.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); - if let Ok(mut proxy) = PROXY.lock() { - *proxy = spec; + let mut proxy = lock(&PROXY); + if *proxy == spec { + return; } + *proxy = spec; + drop(proxy); + *lock(&KEPT) = None; } -/// The proxy address to use: the launcher's, else the environment's. -fn spec() -> Option { - let set = PROXY.lock().ok().and_then(|proxy| proxy.clone()); - set.or_else(|| { - [ - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", - ] - .iter() - .find_map(|name| { - std::env::var(name) - .ok() - .map(|v| v.trim().to_owned()) - .filter(|v| !v.is_empty()) - }) - }) +/// The proxy address `url` is fetched through: what the launcher set, else the environment's for +/// its scheme, unless `url` is a host that must be reached directly. +fn spec_for(url: &str) -> Option { + if direct(url) { + return None; + } + lock(&PROXY) + .clone() + .or_else(|| env_for(scheme_of(url).unwrap_or("https"))) } /// An agent for downloading `url`: through the proxy when one is set, unless `url` is a host that /// must be reached directly. pub fn agent(url: &str) -> ureq::Agent { - let builder = ureq::AgentBuilder::new(); - if direct(url, &no_proxy()) { - return builder.build(); + keep("port-pack", spec_for(url).as_deref(), |proxy| { + let builder = ureq::AgentBuilder::new(); + match proxy { + Some(proxy) => builder.proxy(proxy).build(), + None => builder.build(), + } + }) +} + +/// The agent `url` is fetched with, built by `build` and then kept for the next request that names +/// the same `slot` and proxy: a fresh `Agent` starts a new connection pool, so building one per +/// request is a handshake per file, and a modpack's jars are fetched one after another. +pub fn keep( + slot: &str, + spec: Option<&str>, + build: impl FnOnce(Option) -> ureq::Agent, +) -> ureq::Agent { + let proxy = spec.and_then(parse); + // An address that isn't a proxy is used as none, and remembered as none. + let key = format!( + "{slot} {}", + if proxy.is_some() { + spec.unwrap_or_default() + } else { + "" + } + ); + if let Some(agent) = lock(&KEPT).as_ref().and_then(|kept| kept.get(&key)) { + return agent.clone(); } - match spec().and_then(|s| ureq::Proxy::new(s).ok()) { - Some(proxy) => builder.proxy(proxy).build(), - None => builder.build(), + let agent = build(proxy); + lock(&KEPT) + .get_or_insert_with(HashMap::new) + .insert(key, agent.clone()); + agent +} + +/// `spec` as a proxy, when it is one. An address that can't be used is said once: a download left +/// to go direct would otherwise fail with nothing pointing at the proxy. +pub fn parse(spec: &str) -> Option { + if socks(spec) { + say(&format!( + "{spec} is a SOCKS proxy; downloads go without one. Write the http:// address your \ + proxy app listens on as well." + )); + return None; + } + match ureq::Proxy::new(spec) { + Ok(proxy) => Some(proxy), + Err(e) => { + say(&format!( + "The proxy address {spec} can't be used ({e}); downloads go without one." + )); + None + } } } -/// Whether `url` is reached without the proxy, given a `NO_PROXY` list: a local address, or a host -/// the list names. -fn direct(url: &str, no_proxy: &str) -> bool { - host_of(url).is_some_and(|host| local(host) || excluded(host, no_proxy)) +/// Whether `spec` names a SOCKS proxy, which these downloads don't go through: `ureq` reads a +/// SOCKS address happily and only fails when it connects through it. +pub fn socks(spec: &str) -> bool { + matches!( + spec.split_once("://") + .map(|(s, _)| s.to_ascii_lowercase()) + .as_deref(), + Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h") + ) +} + +/// Whether `url` is reached without the proxy: a local address, or a host `NO_PROXY` names. The +/// launcher asks this of its own requests, so both reach the same hosts directly. +pub fn direct(url: &str) -> bool { + host_of(url).is_some_and(|host| local(host) || excluded(host, &no_proxy())) +} + +/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling +/// first and the lower-case one after, as the tools that write them do. +pub fn env_for(scheme: &str) -> Option { + let names: &[&str] = if scheme.eq_ignore_ascii_case("http") { + &["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"] + } else { + &["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] + }; + names.iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + }) } /// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is. @@ -72,7 +149,7 @@ fn no_proxy() -> String { } /// The host part of a URL (`https://user@host:port/path` is `host`). -fn host_of(url: &str) -> Option<&str> { +pub fn host_of(url: &str) -> Option<&str> { let rest = url.split_once("://").map_or(url, |(_, rest)| rest); let rest = rest.split(['/', '?', '#']).next()?; let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host); @@ -83,7 +160,12 @@ fn host_of(url: &str) -> Option<&str> { }) } -/// Whether `host` is this computer. +/// `https` in `https://…`, or None for anything that isn't an absolute address. +pub fn scheme_of(url: &str) -> Option<&str> { + url.split_once("://").map(|(scheme, _)| scheme) +} + +/// Whether `host` is this computer: the game's own API and a server on the same machine. fn local(host: &str) -> bool { let host = bare(host); host == "localhost" @@ -119,12 +201,35 @@ fn bare(host: &str) -> String { host.trim().trim_matches(['[', ']']).to_ascii_lowercase() } +/// Said once, however many downloads follow: a wrong address is one thing to report, not one +/// report per file. +fn say(what: &str) { + static SAID: AtomicBool = AtomicBool::new(false); + if !SAID.swap(true, Ordering::Relaxed) { + eprintln!("{what}"); + } +} + +/// The lock, even after a thread panicked while holding it: an address lost to a panic is worse +/// than an old one kept. +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + #[cfg(test)] mod tests { use super::*; + /// What the tests below share, since the address and the agents are one of each for the whole + /// process: they take it in turn. + fn alone() -> MutexGuard<'static, ()> { + static TURN: Mutex<()> = Mutex::new(()); + lock(&TURN) + } + #[test] fn a_proxy_the_launcher_sets_is_trimmed_and_kept() { + let _turn = alone(); set_proxy(Some(" http://127.0.0.1:7890 ".into())); assert_eq!( PROXY.lock().unwrap().as_deref(), @@ -134,16 +239,13 @@ mod tests { assert!(PROXY.lock().unwrap().is_none()); } - /// A host that must be reached directly takes no proxy, however one is set: what an import - /// with `NO_PROXY=modrinth.com` needs, since the launcher's own requests to it go direct. + /// An address on this computer is reached directly whatever `NO_PROXY` says: what an import + /// needs when the launcher's own requests to it go direct. #[test] - fn a_listed_host_is_reached_directly() { - let list = "modrinth.com"; - assert!(direct("https://cdn.modrinth.com/data/a.jar", list)); - assert!(direct("https://api.modrinth.com/v2/version", list)); - assert!(!direct("https://edge.forgecdn.net/files/1.jar", list)); - assert!(!direct("https://example.com/a.jar", list)); - assert!(direct("http://127.0.0.1:8130/api/v1/status", "")); + fn a_local_address_is_reached_directly() { + assert!(direct("http://127.0.0.1:8130/api/v1/status")); + assert!(direct("http://localhost/api/v1/status")); + assert!(direct("https://[::1]:25565/")); } #[test] @@ -153,32 +255,98 @@ mod tests { Some("cdn.modrinth.com") ); assert_eq!( - host_of("https://user:pass@host.example:8443/a"), - Some("host.example") + host_of("https://user:pass@piston-meta.mojang.com:8443/a"), + Some("piston-meta.mojang.com") + ); + assert_eq!( + host_of("http://127.0.0.1:8130/api/v1/status"), + Some("127.0.0.1") ); assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]")); + assert_eq!(host_of("https://example.com?x=1"), Some("example.com")); + assert_eq!(host_of("https://example.com#top"), Some("example.com")); + } + + #[test] + fn only_absolute_addresses_have_a_scheme() { + assert_eq!(scheme_of("https://a.b"), Some("https")); + assert_eq!(scheme_of("http://a.b"), Some("http")); + assert_eq!(scheme_of("a.b/c"), None); } #[test] fn this_computer_is_never_proxied() { - for host in ["localhost", "[::1]", "127.0.0.1", "127.1.2.3"] { + for host in [ + "localhost", + "LOCALHOST", + "[::1]", + "::1", + "127.0.0.1", + "127.1.2.3", + ] { assert!(local(host), "{host}"); } - for host in ["cdn.modrinth.com", "10.0.0.1"] { + for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] { assert!(!local(host), "{host}"); } } + /// What an import with `NO_PROXY=modrinth.com` needs: the launcher's own requests to it go + /// direct, so the jars fetched for it must too. #[test] fn no_proxy_lists_hosts_and_their_subdomains() { assert!(excluded("anything.at.all", "*")); assert!(excluded("cdn.modrinth.com", "modrinth.com")); + assert!(excluded("api.modrinth.com", "modrinth.com")); + assert!(excluded("piston-meta.mojang.com", "mojang.com")); assert!(excluded( "wiki.corp.example", " .corp.example , other.host " )); assert!(excluded("github.com", "github.com:443")); assert!(!excluded("notmodrinth.com", "modrinth.com")); + assert!(!excluded("modrinth.com", " .corp.example , mojang.com ")); + assert!(!excluded("edge.forgecdn.net", "modrinth.com")); assert!(!excluded("modrinth.com", "")); } + + #[test] + fn an_address_that_cant_be_used_becomes_no_proxy() { + assert!(parse("http://127.0.0.1:7890").is_some()); + assert!(parse("127.0.0.1:7890").is_some()); + // `ureq` reads a SOCKS address and only fails once it connects through it. + assert!(socks("socks5://127.0.0.1:7891")); + assert!(parse("socks5://127.0.0.1:7891").is_none()); + assert!(!socks("http://127.0.0.1:7890")); + } + + /// The agent a second request gets is the one the first built: one connection pool, not one + /// handshake per jar. + #[test] + fn an_agent_is_kept_for_the_next_request() { + let _turn = alone(); + set_proxy(None); + let mut built = 0; + for _ in 0..3 { + keep("kept", None, |_| { + built += 1; + ureq::AgentBuilder::new().build() + }); + } + assert_eq!(built, 1, "three requests, one agent"); + // Another address is another agent, and a new setting drops the ones before it. + keep("kept", Some("http://127.0.0.1:7890"), |proxy| { + assert!(proxy.is_some()); + built += 1; + ureq::AgentBuilder::new().build() + }); + assert_eq!(built, 2, "a second address needs a second agent"); + set_proxy(Some("http://127.0.0.1:7891".into())); + keep("kept", Some("http://127.0.0.1:7891"), |_| { + built += 1; + ureq::AgentBuilder::new().build() + }); + assert_eq!(built, 3, "a new setting drops the agents kept before it"); + set_proxy(None); + } } diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 7c9562441..9e315165d 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -132,11 +132,11 @@ MCP: `account_offline`. ## `horizon-launcher settings` -Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off. +Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it). horizon-launcher settings [KEY] [VALUE] -- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation) +- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy) - `VALUE`: The new value (with KEY) MCP: `settings`. diff --git a/launcher/src/browser/download.rs b/launcher/src/browser/download.rs index 580cc5d28..f370234f8 100644 --- a/launcher/src/browser/download.rs +++ b/launcher/src/browser/download.rs @@ -19,18 +19,24 @@ const MAX: u64 = 1 << 30; /// The HTTP agent the sources share, reaching `url` through the player's proxy when one is set /// (`proxy`). Modrinth asks for a user agent that names the project. +/// +/// It is kept between downloads: a fresh agent starts a new connection pool, so one per file would +/// be a handshake per file, and a modpack brings hundreds. pub(super) fn agent(url: &str) -> ureq::Agent { - crate::proxy::agent( - ureq::AgentBuilder::new() + let spec = crate::proxy::spec_for(url); + horizon_port_pack::net::keep("browser", spec.as_deref(), |proxy| { + let builder = ureq::AgentBuilder::new() .user_agent(&format!( "ludovic111/horizon horizon-launcher/{} (github.com/ludovic111/horizon)", crate::backend::VERSION )) .timeout_connect(std::time::Duration::from_secs(15)) - .timeout_read(std::time::Duration::from_secs(60)), - url, - ) - .build() + .timeout_read(std::time::Duration::from_secs(60)); + match proxy { + Some(proxy) => builder.proxy(proxy).build(), + None => builder.build(), + } + }) } /// Why a file can't be downloaded at all, before any byte is read. diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index f7c934e59..8f9f4001d 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -731,7 +731,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> { let value = value.ok_or("settings KEY VALUE: the value is missing")?; set_setting(&mut settings, &key, &value)?; settings.save(); - horizon_launcher::proxy::announce(); + horizon_launcher::proxy::announce(settings.proxy.clone()); } let all = settings_json(&settings); if as_json { diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 5abb732c3..b9a7665d1 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -2721,7 +2721,9 @@ impl Launcher { } _ if paste => { if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) { - this.proxy_input.push_str(text.trim()); + // A copied line, not a copied page: the field takes one address. + let line = text.lines().next().unwrap_or(&text); + this.proxy_input.push_str(line.trim()); } } _ if !k.modifiers.control && !k.modifiers.platform => { @@ -2731,7 +2733,10 @@ impl Launcher { } _ => return, } - this.proxy_input.truncate(256); + // `truncate` counts bytes, and a cut inside a character panics: count characters. + if this.proxy_input.chars().count() > 256 { + this.proxy_input = this.proxy_input.chars().take(256).collect(); + } this.save_proxy(); cx.stop_propagation(); cx.notify(); @@ -2756,7 +2761,7 @@ impl Launcher { let value = self.proxy_input.trim().to_owned(); self.settings.proxy = (!value.is_empty()).then_some(value); self.settings.save(); - horizon_launcher::proxy::announce(); + horizon_launcher::proxy::announce(self.settings.proxy.clone()); } /// Settings' Microsoft accounts: who is signed in and plays, the sign-in with its code. The @@ -3672,7 +3677,7 @@ fn main() { backend::exe_path(); // What the launcher downloads goes through the player's proxy, if they set one: the parts // that build their own agents (`horizon-port-pack`) are told before anything starts. - horizon_launcher::proxy::announce(); + horizon_launcher::proxy::announce(horizon_launcher::backend::Settings::load().proxy); if let Some(code) = cli::run() { std::process::exit(code); } diff --git a/launcher/src/proxy.rs b/launcher/src/proxy.rs index 730f1b4b7..8d6493456 100644 --- a/launcher/src/proxy.rs +++ b/launcher/src/proxy.rs @@ -9,23 +9,35 @@ //! Servers page's calls to a game server's API (`servers.rs`) build their agent without it: they //! are on loopback and asked every second. //! -//! The jars an import downloads are fetched by `horizon-port-pack`, which builds its own agents and -//! keeps these same `NO_PROXY` rules (`crates/horizon-port-pack/src/net.rs`). The two crates share -//! no code, so a change to them here belongs there too. +//! Which hosts are reached directly, and how an address is read, are `horizon-port-pack`'s rules +//! (`crates/horizon-port-pack/src/net.rs`): the jars an import fetches are downloaded there, and +//! the same hosts have to be left alone on both sides. This module adds what is the launcher's +//! own — the setting in `settings.json`, kept in memory so a request doesn't read the file — and +//! hands the address to the parts that build their agents. use crate::backend::Settings; +use std::sync::Mutex; -/// The proxy to reach `url` through: the one set for the launcher or in the environment, unless +/// The launcher's setting, as `announce` last read it, or None before it has: a request shouldn't +/// read `settings.json` every time, and an import fetches hundreds of jars. +static SET: Mutex>> = Mutex::new(None); + +/// The proxy address to reach `url` through: the launcher's setting, else the environment's, unless /// `url` is a local address or `NO_PROXY` names its host. -pub fn for_url(url: &str) -> Option { - let scheme = scheme_of(url)?; - let host = host_of(url)?; - if local(host) || excluded(host, &no_proxy()) { +pub fn spec_for(url: &str) -> Option { + let scheme = horizon_port_pack::net::scheme_of(url)?; + if horizon_port_pack::net::direct(url) { return None; } - ureq::Proxy::new(written(scheme)?).ok() + setting().or_else(|| horizon_port_pack::net::env_for(scheme)) } -/// `builder`, reaching `url` through the proxy when one is set. +/// The proxy to reach `url` through: `spec_for`'s address, read as one. +pub fn for_url(url: &str) -> Option { + spec_for(url).and_then(|spec| horizon_port_pack::net::parse(&spec)) +} + +/// `builder`, reaching `url` through the proxy when one is set. For the parts that ask for a few +/// addresses; the ones that fetch file after file keep their agent instead (`keep`). pub fn agent(builder: ureq::AgentBuilder, url: &str) -> ureq::AgentBuilder { match for_url(url) { Some(proxy) => builder.proxy(proxy), @@ -39,19 +51,17 @@ pub fn refuse(spec: &str) -> Option { if spec.is_empty() { return None; } - let scheme = spec.split_once("://").map(|(s, _)| s.to_ascii_lowercase()); - if matches!( - scheme.as_deref(), - Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h") - ) { + if horizon_port_pack::net::socks(spec) { return Some(format!( "{spec} is a SOCKS proxy; Horizon downloads through an HTTP proxy, such as the \ http:// address your proxy app also listens on." )); } - // `Proxy::new` takes `http://` without a host, which would leave a download failing with - // nothing said about the proxy. - if ureq::Proxy::new(spec).is_err() || host_of(spec).is_none_or(str::is_empty) { + // `ureq` takes `http://` without a host, which would leave a download failing with nothing + // said about the proxy. + if horizon_port_pack::net::parse(spec).is_none() + || horizon_port_pack::net::host_of(spec).is_none_or(str::is_empty) + { return Some(format!( "{spec} isn't a proxy address. Write it as http://host:port." )); @@ -59,97 +69,46 @@ pub fn refuse(spec: &str) -> Option { None } -/// Tells the parts that build their own agents (`horizon-port-pack`) which proxy to use. Called at -/// start and whenever the player changes the setting; the hosts `NO_PROXY` names stay direct there -/// too, since it reads the same list. -pub fn announce() { - horizon_port_pack::net::set_proxy(written("https")); +/// Tells the parts that build their own agents (`horizon-port-pack`) which proxy to use, and keeps +/// the setting for the launcher's own requests. Called with what `settings.json` holds: at start, +/// and whenever the player changes the setting. +/// +/// The environment's is left to them: they read it per address (`HTTPS_PROXY` for an https jar, +/// `HTTP_PROXY` for an http one), the way the launcher does for its own. +pub fn announce(setting: Option) { + let spec = setting + .map(|p| p.trim().to_owned()) + .filter(|p| !p.is_empty()); + *held() = Some(spec.clone()); + horizon_port_pack::net::set_proxy(spec); } -/// The proxy as it is written down: the launcher's, else the environment's for `scheme`. -fn written(scheme: &str) -> Option { +/// The launcher's setting: read from `settings.json` the first time it is needed and kept after +/// that, so a download doesn't read the file once per file. +fn setting() -> Option { + let mut set = held(); + match set.clone() { + Some(kept) => kept, + None => { + let read = written(); + *set = Some(read.clone()); + read + } + } +} + +/// The launcher's setting as it is written down, or None when there is none. +fn written() -> Option { Settings::load() .proxy .map(|p| p.trim().to_owned()) .filter(|p| !p.is_empty()) - .or_else(|| from_env(scheme)) } -/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling -/// first and the lower-case one after, as the tools that write them do. -fn from_env(scheme: &str) -> Option { - let names: &[&str] = if scheme.eq_ignore_ascii_case("http") { - &["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"] - } else { - &["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] - }; - names.iter().find_map(|name| { - std::env::var(name) - .ok() - .map(|v| v.trim().to_owned()) - .filter(|v| !v.is_empty()) - }) -} - -/// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is. -fn no_proxy() -> String { - ["NO_PROXY", "no_proxy"] - .iter() - .find_map(|name| std::env::var(name).ok()) - .unwrap_or_default() -} - -/// The host part of a URL (`https://user@host:port/path` is `host`). -fn host_of(url: &str) -> Option<&str> { - let rest = url.split_once("://").map_or(url, |(_, rest)| rest); - let rest = rest.split(['/', '?', '#']).next()?; - let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host); - Some(match rest.rfind(':') { - // A port follows the last colon; an IPv6 literal keeps the colons inside its brackets. - Some(at) if !rest[at..].contains(']') => &rest[..at], - _ => rest, - }) -} - -/// `https` in `https://…`, or None for anything that isn't an absolute address. -fn scheme_of(url: &str) -> Option<&str> { - url.split_once("://").map(|(scheme, _)| scheme) -} - -/// Whether `host` is this computer: the game's own API and a server on the same machine. -fn local(host: &str) -> bool { - let host = bare(host); - host == "localhost" - || host.ends_with(".localhost") - || host == "::1" - || host == "0.0.0.0" - || host.starts_with("127.") -} - -/// Whether `host` is in `NO_PROXY`: `*`, `host`, `.domain`, `*.domain` and `host:port` entries. -fn excluded(host: &str, no_proxy: &str) -> bool { - let host = bare(host); - no_proxy - .split(',') - .map(str::trim) - .filter(|e| !e.is_empty()) - .any(|entry| { - if entry == "*" { - return true; - } - let entry = entry.trim_start_matches('*').trim_start_matches('.'); - let entry = match entry.rfind(':') { - Some(at) if !entry[at..].contains(']') => &entry[..at], - _ => entry, - }; - let entry = bare(entry); - !entry.is_empty() && (host == entry || host.ends_with(&format!(".{entry}"))) - }) -} - -/// A host without the brackets an IPv6 literal wears, lower case. -fn bare(host: &str) -> String { - host.trim().trim_matches(['[', ']']).to_ascii_lowercase() +/// The setting's lock, even after a thread panicked while holding it: an address lost to a panic +/// is worse than an old one kept. +fn held() -> std::sync::MutexGuard<'static, Option>> { + SET.lock().unwrap_or_else(|e| e.into_inner()) } #[cfg(test)] @@ -157,58 +116,15 @@ mod tests { use super::*; #[test] - fn a_host_is_read_out_of_the_address() { - assert_eq!( - host_of("https://cdn.modrinth.com/v2/x.jar"), - Some("cdn.modrinth.com") - ); - assert_eq!( - host_of("https://user:pass@piston-meta.mojang.com:8443/a"), - Some("piston-meta.mojang.com") - ); - assert_eq!( - host_of("http://127.0.0.1:8130/api/v1/status"), - Some("127.0.0.1") - ); - assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]")); - assert_eq!(host_of("https://example.com?x=1"), Some("example.com")); - assert_eq!(host_of("https://example.com#top"), Some("example.com")); + fn a_local_address_takes_no_proxy() { + assert_eq!(spec_for("http://127.0.0.1:8130/api/v1/status"), None); + assert_eq!(spec_for("http://localhost/api/v1/status"), None); } + /// An address the launcher knows nothing about is one nothing is written for either. #[test] - fn only_absolute_addresses_have_a_scheme() { - assert_eq!(scheme_of("https://a.b"), Some("https")); - assert_eq!(scheme_of("http://a.b"), Some("http")); - assert_eq!(scheme_of("a.b/c"), None); - } - - #[test] - fn this_computer_is_never_proxied() { - for host in [ - "localhost", - "LOCALHOST", - "[::1]", - "::1", - "127.0.0.1", - "127.1.2.3", - ] { - assert!(local(host), "{host}"); - } - for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] { - assert!(!local(host), "{host}"); - } - } - - #[test] - fn no_proxy_lists_hosts_and_their_subdomains() { - let list = "*.internal, .corp.example, mojang.com, github.com:443, *"; - assert!(excluded("anything.at.all", list)); - assert!(excluded("wiki.corp.example", "corp.example")); - assert!(excluded("piston-meta.mojang.com", "mojang.com")); - assert!(excluded("github.com", "github.com:443")); - assert!(!excluded("notmojang.com", "mojang.com")); - assert!(!excluded("modrinth.com", " .corp.example , mojang.com ")); - assert!(!excluded("modrinth.com", "")); + fn a_relative_address_has_no_scheme_to_read_a_proxy_for() { + assert_eq!(spec_for("data/a.jar"), None); } #[test]