Proxy: one copy of the rules, none of it per request

Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
This commit is contained in:
zhykowoandWorkBuddy AI committed 2026-10-26 21:17:28 +08:00
1 parent a9453b7c93
commit da54f988d4
6 files changed
+312 -217

No files matched your search

+222 -54
View File
@@ -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<Option<String>> = 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<Option<HashMap<String, ureq::Agent>>> = 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<String>) {
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<String> {
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<String> {
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::Proxy>) -> 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<ureq::Proxy> {
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<String> {
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<T>(m: &Mutex<T>) -> 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);
}
}
+2 -2
View File
@@ -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`.
+12 -6
View File
@@ -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.
+1 -1
View File
@@ -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 {
+9 -4
View File
@@ -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);
}
+66 -150
View File
@@ -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<Option<Option<String>>> = 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<ureq::Proxy> {
let scheme = scheme_of(url)?;
let host = host_of(url)?;
if local(host) || excluded(host, &no_proxy()) {
pub fn spec_for(url: &str) -> Option<String> {
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<ureq::Proxy> {
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<String> {
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<String> {
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<String>) {
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<String> {
/// 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<String> {
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<String> {
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<String> {
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<Option<String>>> {
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:[email protected]: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]