ParadoxRuntime/BackendUrl.h
Rama main · réplica de lectura del repositorio original.
/*
* Copyright (C) 2026 MysticFox / Pranav Karande (pranav158/Mystic-Paradox)
* Licensed under the GNU Affero General Public License v3.0.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Additional terms under AGPLv3 Section 7 apply. See ADDITIONAL_TERMS.md.
*/
#pragma once
// [2026-10-13 realms S1] Pure parser for the backend address the launcher hands to the game process in the
// environment variable MYSTICPARADOX_BACKEND_URL. Header-only and free of Windows/engine types so the edge cases can
// be exercised by a throwaway console build; dllmain.cpp owns the environment read, the one-time resolution and the log.
//
// Accepted form, exactly: scheme://host[:port]
// scheme http or https (case-insensitive)
// host DNS name (labels of letters, digits and inner '-', at most 63 each, at most 253 in all, last label not
// all digits, no trailing dot), dotted-quad IPv4 (no leading zeros), or a bracketed IPv6 literal
// port 1-65535, no leading zero; absent means 80 (http) / 443 (https)
// Rejected: any space, control or non-ASCII character, user information ('@'), a path, query or fragment (even a
// lone trailing '/'), an unbracketed IPv6 literal, a zone id, an empty host or port, anything over 300 characters.
#include <cstddef>
#include <cstdint>
#include <string>
constexpr size_t kMpBackendUrlMaxChars = 300;
struct MpBackendUrl {
bool Secure = true; // https / wss
std::string Host; // lower-case DNS name or IPv4 address, or an IPv6 literal with its brackets
uint16_t Port = 443; // the explicit port, else the scheme default
};
namespace MpBackendUrlDetail {
inline bool IsDigit(char C) { return C >= '0' && C <= '9'; }
inline bool IsHex(char C) { return IsDigit(C) || (C >= 'a' && C <= 'f') || (C >= 'A' && C <= 'F'); }
inline bool IsAlnum(char C) { return IsDigit(C) || (C >= 'a' && C <= 'z') || (C >= 'A' && C <= 'Z'); }
inline std::string Lower(std::string S) {
for (char& C : S) {
if (C >= 'A' && C <= 'Z') C = static_cast<char>(C + ('a' - 'A'));
}
return S;
}
// Dotted quad: exactly four decimal parts of 1-3 digits, each at most 255, no leading zeros.
inline bool IsIPv4(const std::string& S) {
int Parts = 0;
size_t I = 0;
while (true) {
const size_t Start = I;
int Value = 0;
while (I < S.size() && IsDigit(S[I])) {
if (I - Start >= 3) return false;
Value = Value * 10 + (S[I] - '0');
++I;
}
const size_t Length = I - Start;
if (Length == 0) return false;
if (Length > 1 && S[Start] == '0') return false;
if (Value > 255) return false;
++Parts;
if (I == S.size()) break;
if (S[I] != '.') return false;
++I;
}
return Parts == 4;
}
// One side of an IPv6 literal split at "::": colon-separated groups of 1-4 hex digits; only the very last token of
// the whole address (LastSide) may be a dotted quad, which counts as two groups. An empty side has zero groups.
inline bool CountIPv6Groups(const std::string& Side, bool LastSide, int& Groups) {
Groups = 0;
if (Side.empty()) return true;
size_t Start = 0;
while (true) {
const size_t Colon = Side.find(':', Start);
const bool Last = (Colon == std::string::npos);
const std::string Token = Last ? Side.substr(Start) : Side.substr(Start, Colon - Start);
if (Token.empty()) return false;
if (Token.find('.') != std::string::npos) {
if (!(Last && LastSide) || !IsIPv4(Token)) return false;
Groups += 2;
}
else {
if (Token.size() > 4) return false;
Groups += 1;
}
if (Last) break;
Start = Colon + 1;
}
return true;
}
// RFC 4291 text form without brackets or zone id: eight groups, or at most seven around one "::".
inline bool IsIPv6(const std::string& S) {
if (S.size() < 2 || S.size() > 45) return false;
for (char C : S) {
if (!IsHex(C) && C != ':' && C != '.') return false;
}
const size_t Double = S.find("::");
int LeftGroups = 0;
int RightGroups = 0;
if (Double == std::string::npos) {
if (!CountIPv6Groups(S, true, LeftGroups)) return false;
return LeftGroups == 8;
}
if (S.find("::", Double + 1) != std::string::npos) return false; // a second "::", or ":::"
if (!CountIPv6Groups(S.substr(0, Double), false, LeftGroups)) return false;
if (!CountIPv6Groups(S.substr(Double + 2), true, RightGroups)) return false;
return LeftGroups + RightGroups <= 7;
}
inline bool IsDnsName(const std::string& S) {
if (S.empty() || S.size() > 253) return false;
size_t LabelLength = 0;
bool LabelAllDigits = true;
char Previous = '.';
for (char C : S) {
if (C == '.') {
if (LabelLength == 0 || Previous == '-') return false; // leading dot, "..", or a label ending in '-'
LabelLength = 0;
LabelAllDigits = true;
Previous = C;
continue;
}
if (!IsAlnum(C) && C != '-') return false;
if (LabelLength == 0 && C == '-') return false;
if (++LabelLength > 63) return false;
if (!IsDigit(C)) LabelAllDigits = false;
Previous = C;
}
if (LabelLength == 0 || Previous == '-') return false; // trailing dot, or a label ending in '-'
return !LabelAllDigits; // the last label is never all digits
}
} // namespace MpBackendUrlDetail
// Parses Text into Out. On failure returns false with Reason set to a static, log-safe description that never echoes
// the input (an invalid value may still have carried user information).
inline bool MpParseBackendUrl(const std::string& Text, MpBackendUrl& Out, const char*& Reason) {
using namespace MpBackendUrlDetail;
Reason = "";
if (Text.empty()) { Reason = "empty value"; return false; }
if (Text.size() > kMpBackendUrlMaxChars) { Reason = "value too long"; return false; }
for (char C : Text) {
const unsigned char U = static_cast<unsigned char>(C);
if (U <= 0x20 || U >= 0x7F) { Reason = "contains a space, control or non-ASCII character"; return false; }
}
const size_t SchemeEnd = Text.find("://");
if (SchemeEnd == std::string::npos) { Reason = "missing scheme://"; return false; }
const std::string Scheme = Lower(Text.substr(0, SchemeEnd));
bool Secure = true;
if (Scheme == "https") Secure = true;
else if (Scheme == "http") Secure = false;
else { Reason = "scheme must be http or https"; return false; }
const std::string Authority = Text.substr(SchemeEnd + 3);
if (Authority.empty()) { Reason = "missing host"; return false; }
if (Authority.find('@') != std::string::npos) { Reason = "user information is not allowed"; return false; }
if (Authority.find_first_of("/?#\\") != std::string::npos) {
Reason = "path, query and fragment are not allowed";
return false;
}
std::string Host;
std::string PortText;
bool HasPort = false;
if (Authority[0] == '[') {
const size_t Close = Authority.find(']');
if (Close == std::string::npos) { Reason = "unterminated [IPv6] literal"; return false; }
const std::string Inner = Authority.substr(1, Close - 1);
if (!IsIPv6(Inner)) { Reason = "malformed IPv6 address"; return false; }
Host = "[" + Lower(Inner) + "]";
const std::string Rest = Authority.substr(Close + 1);
if (!Rest.empty()) {
if (Rest[0] != ':') { Reason = "unexpected text after the IPv6 literal"; return false; }
PortText = Rest.substr(1);
HasPort = true;
}
}
else {
const size_t Colon = Authority.find(':');
if (Colon != std::string::npos && Authority.find(':', Colon + 1) != std::string::npos) {
Reason = "more than one ':' outside [brackets]";
return false;
}
Host = Authority.substr(0, Colon);
if (Colon != std::string::npos) {
PortText = Authority.substr(Colon + 1);
HasPort = true;
}
if (Host.empty()) { Reason = "missing host"; return false; }
if (Host.find_first_not_of("0123456789.") == std::string::npos) {
if (!IsIPv4(Host)) { Reason = "malformed IPv4 address"; return false; }
}
else if (!IsDnsName(Host)) {
Reason = "malformed host name";
return false;
}
Host = Lower(Host);
}
uint16_t Port = Secure ? 443 : 80;
if (HasPort) {
if (PortText.empty() || PortText.size() > 5 || PortText[0] == '0'
|| PortText.find_first_not_of("0123456789") != std::string::npos) {
Reason = "port must be 1-65535";
return false;
}
unsigned Value = 0;
for (char C : PortText) Value = Value * 10 + static_cast<unsigned>(C - '0');
if (Value == 0 || Value > 65535) { Reason = "port must be 1-65535"; return false; }
Port = static_cast<uint16_t>(Value);
}
Out.Secure = Secure;
Out.Host = Host;
Out.Port = Port;
return true;
}
inline uint16_t MpBackendDefaultPort(bool Secure) { return Secure ? 443 : 80; }
// The API base the HTTP redirect prepends "/__origin/<host><path>" to: scheme://host, with the port only when it is
// not the scheme default (so https://host:443 comes out as https://host, the built-in form).
inline std::wstring MpBackendApiBase(const MpBackendUrl& Url) {
std::wstring Out = Url.Secure ? L"https://" : L"http://";
Out.append(Url.Host.begin(), Url.Host.end());
if (Url.Port != MpBackendDefaultPort(Url.Secure)) {
Out += L":";
Out += std::to_wstring(static_cast<unsigned>(Url.Port));
}
return Out;
}
// The chat WebSocket address the client is given as XMPP ServerAddr: ws/wss matching http/https, the same host, no path
// and NO port. The 1.12 client built its URL as ServerAddr + ":" + ServerPort, so a path or a port in this string
// produces "...:443" after it; the port travels separately (MpBackendUrl::Port, written to ServerPort).
inline std::wstring MpBackendXmppAddress(const MpBackendUrl& Url) {
std::wstring Out = Url.Secure ? L"wss://" : L"ws://";
Out.append(Url.Host.begin(), Url.Host.end());
return Out;
}