diff --git a/justfile b/justfile index 74d6a61..2fbee49 100644 --- a/justfile +++ b/justfile @@ -279,7 +279,7 @@ test-auth-unit: unset VCPKG_ROOT cmake --preset {{ preset }} -DBUILD_TESTING=ON > /dev/null 2>&1 \ || cmake --preset {{ preset }} -DBUILD_TESTING=ON - cmake --build --preset {{ preset }} --target test_xpid_patch --target test_parse_endpoint --target test_behavioral --target test_token_auth --target test_messages --target test_crash_recovery --target test_nevr_config --target test_service_map --target test_social_facade --target test_scenario_early_quit --target test_early_quit_lockout --target test_schannel_cred_guard --target test_hooking --target test_plugin_load_plan --target test_system_module_loader --target test_login_redirect_override --target test_websocket_frame --target test_protobuf_transport --target test_websocket_client_auth --target test_url_diagnostics --target test_winhttp_stub --target test_callback_unregistration --target test_server_context --target test_session_unregister --target test_mic_lifecycle --target test_telemetry_snapshot_store --target test_coop_ai_trace + cmake --build --preset {{ preset }} --target test_xpid_patch --target test_parse_endpoint --target test_behavioral --target test_token_auth --target test_messages --target test_crash_recovery --target test_nevr_config --target test_service_map --target test_social_facade --target test_scenario_early_quit --target test_early_quit_lockout --target test_schannel_cred_guard --target test_hooking --target test_plugin_load_plan --target test_system_module_loader --target test_login_redirect_override --target test_websocket_frame --target test_protobuf_transport --target test_websocket_client_auth --target test_url_diagnostics --target test_serverdb_uri --target test_winhttp_stub --target test_callback_unregistration --target test_server_context --target test_session_unregister --target test_mic_lifecycle --target test_telemetry_snapshot_store --target test_coop_ai_trace cmake --build --preset {{ preset }} --target test_mic_dsp cmake --build --preset {{ preset }} --target test_game_image_guard bin="build/{{ preset }}/bin/test_xpid_patch.exe" @@ -384,7 +384,7 @@ test-auth-unit: exit 1 fi wine "$bin" - for test_name in test_system_module_loader test_login_redirect_override test_websocket_frame test_protobuf_transport test_websocket_client_auth test_url_diagnostics test_winhttp_stub test_callback_unregistration test_server_context test_session_unregister test_mic_lifecycle test_telemetry_snapshot_store test_coop_ai_trace; do + for test_name in test_system_module_loader test_login_redirect_override test_websocket_frame test_protobuf_transport test_websocket_client_auth test_url_diagnostics test_serverdb_uri test_winhttp_stub test_callback_unregistration test_server_context test_session_unregister test_mic_lifecycle test_telemetry_snapshot_store test_coop_ai_trace; do bin="build/{{ preset }}/bin/${test_name}.exe" if [[ ! -f "$bin" ]]; then echo "ERROR: GTest binary not found: $bin" >&2 @@ -1350,6 +1350,27 @@ verify: echo "identity must come from the presented credential or config, never the binary." >&2 exit 1 fi + # #41: config credentials reach Nakama in a URL query from two places — the + # ServerDB registration URI (server/gameserver.cpp) and the bridge's config/ + # login connections (compat/ws_bridge.cpp). Both must go through the + # percent-encoder in server/serverdb_uri.cpp. A raw append lets a password + # with '&', '#', '%' or '+' rewrite the query, and if only one site encodes, + # the two paths send different passwords for the same account. + # Falsified 2026-10-05 against 323352b: the pattern hits gameserver.cpp:1433 + # and ws_bridge.cpp:1002-1003; on the fixed tree it exits 1. + I41_RC=0; I41_HITS=$(grep -nE '[?&]password=%s|\+= *cfgPassword|"&password="' \ + src/runtime/server/gameserver.cpp src/runtime/compat/ws_bridge.cpp) || I41_RC=$? + sensor_stage1 "#41 raw URL credential" "server/gameserver.cpp compat/ws_bridge.cpp" "$I41_RC" + if [ "$I41_RC" -eq 0 ]; then + printf '%s\n' "$I41_HITS" >&2 + echo "verify: FAIL — #41 a credential is concatenated into a URL unencoded; use ServerDbUri (server/serverdb_uri.h)." >&2 + exit 1 + fi + if ! grep -q 'ServerDbUri::BuildLegacyUri(' src/runtime/server/gameserver.cpp \ + || ! grep -q 'ServerDbUri::BuildBridgeCredentialUri(' src/runtime/compat/ws_bridge.cpp; then + echo "verify: FAIL — #41 a URL-credential site no longer calls the ServerDbUri encoder." >&2 + exit 1 + fi # N20 (owner decision, 2026-07-27): the nevr_discord_id config fallback applies # in CLIENT mode too, not only server mode. Two assertions, because either one # alone is satisfiable by the bug — the first fires if the fallback is deleted, diff --git a/src/runtime/CMakeLists.txt b/src/runtime/CMakeLists.txt index 26e5f53..4c11a5f 100644 --- a/src/runtime/CMakeLists.txt +++ b/src/runtime/CMakeLists.txt @@ -58,6 +58,7 @@ set(PATCHES_SOURCES "server/session_success_dispatch.cpp" "server/callback_unregistration.cpp" "server/session_unregister.cpp" + "server/serverdb_uri.cpp" "server/telemetry_streamer.cpp" "server/upnp.cpp" ) @@ -119,6 +120,7 @@ set(PATCHES_HEADERS "server/session_success_dispatch.h" "server/callback_unregistration.h" "server/session_unregister.h" + "server/serverdb_uri.h" "server/telemetry_streamer.h" "server/messages.h" "server/upnp.h" @@ -174,7 +176,7 @@ set_source_files_properties(ext/plugin_load_plan.cpp PROPERTIES SKIP_PRECOMPILE_ # #60: plugin_manifest.cpp builds the login's nevr_plugins array with nlohmann-json; # pure and PCH-free for the same reason as plugin_load_plan.cpp. set_source_files_properties(ext/plugin_manifest.cpp PROPERTIES SKIP_PRECOMPILE_HEADERS ON) -set_source_files_properties(log/url_diagnostics.cpp server/websocket_client.cpp server/websocket_frame.cpp server/protobuf_transport.cpp server/session_success_dispatch.cpp server/callback_unregistration.cpp server/session_unregister.cpp server/telemetry_streamer.cpp server/upnp.cpp server/gameserver.cpp server/messages.cpp PROPERTIES SKIP_PRECOMPILE_HEADERS ON) +set_source_files_properties(log/url_diagnostics.cpp server/websocket_client.cpp server/websocket_frame.cpp server/protobuf_transport.cpp server/session_success_dispatch.cpp server/callback_unregistration.cpp server/session_unregister.cpp server/serverdb_uri.cpp server/telemetry_streamer.cpp server/upnp.cpp server/gameserver.cpp server/messages.cpp PROPERTIES SKIP_PRECOMPILE_HEADERS ON) # Scenario-test control endpoint (docs/design/2026-10-01-social-scenario-harness.md). It can inject # messages into a live session, so it is OFF by default and only the mingw-scenario preset turns it @@ -333,6 +335,16 @@ if(BUILD_TESTING) target_link_libraries(test_url_diagnostics PRIVATE GTest::gtest GTest::gtest_main CURL::libcurl) gtest_discover_tests(test_url_diagnostics DISCOVERY_MODE PRE_TEST) + # Issue #41: the ServerDB URI builder (percent-encoded query) — pure, curl only. + add_executable(test_serverdb_uri + tests/test_serverdb_uri.cpp + server/serverdb_uri.cpp) + set_source_files_properties(tests/test_serverdb_uri.cpp server/serverdb_uri.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS ON) + target_include_directories(test_serverdb_uri PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(test_serverdb_uri PRIVATE GTest::gtest GTest::gtest_main CURL::libcurl) + gtest_discover_tests(test_serverdb_uri DISCOVERY_MODE PRE_TEST) + # GH #27: drives the REAL libcurl-backed IWinHttpRequest stub through its COM # vtable and IDispatch::Invoke against a loopback HTTP/1.1 listener, so the # StatusText it reports is the reason phrase the server actually sent. @@ -564,6 +576,8 @@ if(BUILD_TESTING) ext/module_loader.cpp log/url_diagnostics.cpp compat/ws_bridge.cpp + # #41: ws_bridge.cpp builds its URL credentials through the ServerDbUri encoder. + server/serverdb_uri.cpp hook/symbol_corpus.cpp # N84: plugin_loader calls HookGuard::VerifyAll after each plugin init. # Linked as production source (not a stub) so the test drives the real @@ -593,6 +607,7 @@ if(BUILD_TESTING) ext/module_loader.cpp log/url_diagnostics.cpp compat/ws_bridge.cpp + server/serverdb_uri.cpp hook/symbol_corpus.cpp patch/broadcaster_hook_stats.cpp hook/hook_liveness.cpp diff --git a/src/runtime/compat/ws_bridge.cpp b/src/runtime/compat/ws_bridge.cpp index d2c86c6..f4c3df4 100644 --- a/src/runtime/compat/ws_bridge.cpp +++ b/src/runtime/compat/ws_bridge.cpp @@ -36,6 +36,7 @@ #include "runtime/lifecycle/service_config.h" // NevrCfgGetFlat (N133 S4a: config.yaml reads) #include "runtime/log/url_diagnostics.h" #include "runtime/log/security_diagnostics.h" +#include "runtime/server/serverdb_uri.h" #include "core/logging.h" #include #include @@ -991,16 +992,23 @@ void InstallWebSocketBridge() { // password just means "no URL credentials" — we fall through to the // Bearer/JWT path and never put an empty secret on the wire (N115). // The password value is never logged. + // Issue #41: both values are percent-encoded (ServerDbUri, the same + // encoder the ServerDB registration URI uses), so a password with + // '&', '=', '#', '%', '+' or whitespace reaches Nakama byte-for-byte. { const char* cfgDiscordId = NevrCfgGetFlat("nevr_discord_id"); const char* cfgPassword = NevrCfgGetFlat("nevr_password"); - if (cfgDiscordId && cfgDiscordId[0] != '\0' && cfgPassword && cfgPassword[0] != '\0') { - char sep = (remoteUrl.find('?') != std::string::npos) ? '&' : '?'; - remoteUrl += sep; - remoteUrl += "discordid="; - remoteUrl += cfgDiscordId; - remoteUrl += "&password="; - remoteUrl += cfgPassword; + std::optional withCredentials = ServerDbUri::BuildBridgeCredentialUri( + remoteUrl, cfgDiscordId ? std::string_view(cfgDiscordId) : std::string_view(), + cfgPassword ? std::string_view(cfgPassword) : std::string_view()); + if (withCredentials) { + remoteUrl = std::move(*withCredentials); + } else { + // Allocation failure in the encoder: connect without URL credentials + // (Bearer path below) rather than put an unencoded secret on the wire. + Log(EchoVR::LogLevel::Error, + "[NEVR.WS] conn=%d could not percent-encode URL credentials; connecting without them", + connIdx); } } // conn>=2 (matchmaker): pnsradmatchmaking uses protobuf, not EchoVR diff --git a/src/runtime/server/gameserver.cpp b/src/runtime/server/gameserver.cpp index 767c7a5..7189d53 100644 --- a/src/runtime/server/gameserver.cpp +++ b/src/runtime/server/gameserver.cpp @@ -14,6 +14,7 @@ #include "auth_token_refresh.h" #include "runtime/server/constants.h" #include "runtime/server/protobuf_transport.h" +#include "runtime/server/serverdb_uri.h" #include "runtime/server/session_success_dispatch.h" #include "runtime/server/session_unregister.h" #include "runtime/server/callback_unregistration.h" @@ -1410,8 +1411,11 @@ VOID GameServerLib::RequestRegistration(INT64 serverId, CHAR*, EchoVR::SymbolId ServerFatal("Server authentication failed — no valid token for ServerDB connection"); } - thread_local static CHAR constructedUri[1024]; + // Owns the constructed URI for the rest of this call; Connect() copies it + // (websocket_client.cpp setUrl(std::string(uri))). + std::string constructedUri; if (!serverDbUri || serverDbUri[0] == '\0') { + const auto orEmpty = [](const char* value) { return value ? std::string_view(value) : std::string_view(); }; // guilds/regions are list-shaped registration metadata; read CSV so a yaml // list `[a, b]` and a scalar CSV both build the same guilds=/regions= param. const char* guilds = NevrCfgGetFlatCsv("nevr_guilds"); @@ -1425,17 +1429,17 @@ VOID GameServerLib::RequestRegistration(INT64 serverId, CHAR*, EchoVR::SymbolId // Token auth (BAC-2): identity via the Bearer JWT (sent by Connect()); discord_id/ // password dropped; guilds/regions stay as registration metadata. nevr_serverdb_uri // points at the token route that forwards the real Authorization header - // (docs/guides/token-auth-migration.md). - int written = snprintf(constructedUri, sizeof(constructedUri), "%s", tokenUri); - const char* sep = "?"; - if (guilds && guilds[0] != '\0' && written > 0 && written < (int)sizeof(constructedUri)) { - written += snprintf(constructedUri + written, sizeof(constructedUri) - written, "%sguilds=%s", sep, guilds); - sep = "&"; + // (29ce275710ad4c779d118eafc638fef613343e28:docs/guides/token-auth-migration.md). + // Issue #41: query values are percent-encoded by ServerDbUri, not snprintf. + std::optional built = + ServerDbUri::BuildTokenRouteUri(tokenUri, orEmpty(guilds), orEmpty(regions)); + if (!built) { + Log(EchoVR::LogLevel::Error, "[NEVR.GAMESERVER] could not percent-encode the token-route serverdb URI"); + ServerFatal("Could not build the ServerDB URI (token route)"); + return; } - if (regions && regions[0] != '\0' && written > 0 && written < (int)sizeof(constructedUri)) { - snprintf(constructedUri + written, sizeof(constructedUri) - written, "%sregions=%s", sep, regions); - } - serverDbUri = constructedUri; + constructedUri = std::move(*built); + serverDbUri = constructedUri.c_str(); const std::string diagnostic = LogDiagnostics::FormatRedactedUrlDiagnostic( "[NEVR.GAMESERVER] constructed serverdb URI for token auth: ", constructedUri); Log(EchoVR::LogLevel::Debug, "%s", diagnostic.c_str()); @@ -1446,24 +1450,23 @@ VOID GameServerLib::RequestRegistration(INT64 serverId, CHAR*, EchoVR::SymbolId const char* discordId = NevrCfgGetFlat("nevr_discord_id"); const char* password = NevrCfgGetFlat("nevr_password"); if (socketUri && socketUri[0] != '\0' && discordId && discordId[0] != '\0') { - int written = 0; - if (password && password[0] != '\0') { - written = snprintf(constructedUri, sizeof(constructedUri), "%s?discord_id=%s&password=%s", socketUri, discordId, password); - } else { - written = snprintf(constructedUri, sizeof(constructedUri), "%s?discord_id=%s", socketUri, discordId); - } - if (guilds && guilds[0] != '\0' && written > 0 && written < (int)sizeof(constructedUri)) { - written += snprintf(constructedUri + written, sizeof(constructedUri) - written, "&guilds=%s", guilds); - } - if (regions && regions[0] != '\0' && written > 0 && written < (int)sizeof(constructedUri)) { - snprintf(constructedUri + written, sizeof(constructedUri) - written, "®ions=%s", regions); + // Issue #41: every value is percent-encoded, so a password containing + // '&', '=', '#', '%', '+' or whitespace can no longer rewrite the query. + std::optional built = ServerDbUri::BuildLegacyUri( + socketUri, discordId, orEmpty(password), orEmpty(guilds), orEmpty(regions)); + if (!built) { + Log(EchoVR::LogLevel::Error, "[NEVR.GAMESERVER] could not percent-encode the legacy serverdb URI"); + ServerFatal("Could not build the ServerDB URI (legacy url-param auth)"); + return; } - serverDbUri = constructedUri; + constructedUri = std::move(*built); + serverDbUri = constructedUri.c_str(); // Do NOT log constructedUri here — this branch embeds the operator's - // password directly in the query string (see snprintf above). + // password in the query string. Presence only, never the value. Log(EchoVR::LogLevel::Debug, - "[NEVR.GAMESERVER] constructed serverdb URI (legacy url-param auth): discord_id=%s (password redacted)", - discordId); + "[NEVR.GAMESERVER] constructed serverdb URI (legacy url-param auth, percent-encoded): " + "discord_id=%s password=%s", + discordId, (password && password[0] != '\0') ? "present (redacted)" : "absent"); } else { serverDbUri = "ws://localhost:777/serverdb"; const std::string diagnostic = LogDiagnostics::FormatRedactedUrlDiagnostic( diff --git a/src/runtime/server/serverdb_uri.cpp b/src/runtime/server/serverdb_uri.cpp new file mode 100644 index 0000000..ab72843 --- /dev/null +++ b/src/runtime/server/serverdb_uri.cpp @@ -0,0 +1,87 @@ +#include "runtime/server/serverdb_uri.h" + +#include + +#include +#include +#include + +namespace ServerDbUri { +namespace { + +struct QueryParam { + std::string_view key; // literal, RFC 3986 unreserved; appended as-is + std::string_view value; + bool isList; // comma-separated: encode each element, keep the commas +}; + +std::optional EncodeList(std::string_view csv) { + std::string encoded; + size_t start = 0; + while (true) { + const size_t comma = csv.find(',', start); + const std::string_view element = + csv.substr(start, comma == std::string_view::npos ? std::string_view::npos : comma - start); + const std::optional encodedElement = EncodeQueryValue(element); + if (!encodedElement) return std::nullopt; + encoded += *encodedElement; + if (comma == std::string_view::npos) break; + encoded += ','; + start = comma + 1; + } + return encoded; +} + +std::optional AppendQuery(std::string_view base, std::initializer_list params) { + std::string uri(base); + for (const QueryParam& param : params) { + if (param.value.empty()) continue; + const std::optional encoded = param.isList ? EncodeList(param.value) : EncodeQueryValue(param.value); + if (!encoded) return std::nullopt; + if (uri.find('?') == std::string::npos) { + uri += '?'; + } else if (uri.back() != '?' && uri.back() != '&') { + uri += '&'; + } + uri.append(param.key); + uri += '='; + uri += *encoded; + } + return uri; +} + +} // namespace + +std::optional EncodeQueryValue(std::string_view value) { + // curl_easy_escape treats length 0 as "call strlen", which would read past a + // non-terminated string_view — so the empty case never reaches it. + if (value.empty()) return std::string(); + if (value.size() > static_cast((std::numeric_limits::max)())) return std::nullopt; + // A null handle is accepted since libcurl 7.82.0 (vcpkg ships 8.18.0). + std::unique_ptr escaped( + curl_easy_escape(nullptr, value.data(), static_cast(value.size())), &curl_free); + if (!escaped) return std::nullopt; + return std::string(escaped.get()); +} + +std::optional BuildLegacyUri(std::string_view socketUri, std::string_view discordId, + std::string_view password, std::string_view guilds, + std::string_view regions) { + return AppendQuery(socketUri, {{"discord_id", discordId, false}, + {"password", password, false}, + {"guilds", guilds, true}, + {"regions", regions, true}}); +} + +std::optional BuildTokenRouteUri(std::string_view tokenUri, std::string_view guilds, + std::string_view regions) { + return AppendQuery(tokenUri, {{"guilds", guilds, true}, {"regions", regions, true}}); +} + +std::optional BuildBridgeCredentialUri(std::string_view remoteUri, std::string_view discordId, + std::string_view password) { + if (discordId.empty() || password.empty()) return std::string(remoteUri); + return AppendQuery(remoteUri, {{"discordid", discordId, false}, {"password", password, false}}); +} + +} // namespace ServerDbUri diff --git a/src/runtime/server/serverdb_uri.h b/src/runtime/server/serverdb_uri.h new file mode 100644 index 0000000..ccb585d --- /dev/null +++ b/src/runtime/server/serverdb_uri.h @@ -0,0 +1,45 @@ +#pragma once + +// Builds the ServerDB WebSocket URI that GameServerLib::RequestRegistration +// connects to. Every query value is percent-encoded (issue #41): the legacy +// url-param auth path carries the operator's password in the query, and a value +// containing '&', '=', '#', '%', '+' or whitespace must not rewrite the query. +// +// Pure functions: no logging, no global state. The caller logs (and must never +// log the returned legacy URI — it contains the password). + +#include +#include +#include + +namespace ServerDbUri { + +// RFC 3986 percent-encoding via libcurl's curl_easy_escape: every byte except +// ALPHA / DIGIT / '-' / '.' / '_' / '~' becomes %XX (uppercase hex). Space is +// %20, '+' is %2B, so Go's url.ParseQuery decodes the original bytes exactly. +// nullopt only if libcurl cannot allocate or the value exceeds INT_MAX bytes. +std::optional EncodeQueryValue(std::string_view value); + +// Legacy url-param auth: base?discord_id=..&password=..&guilds=..®ions=.. +// guilds/regions are comma-separated lists; each element is encoded and the +// commas stay literal, because the server splits the decoded value on ','. +// Empty password/guilds/regions are omitted. If base already has a query, the +// parameters are appended with '&'. +std::optional BuildLegacyUri(std::string_view socketUri, std::string_view discordId, + std::string_view password, std::string_view guilds, + std::string_view regions); + +// Token route (nevr_serverdb_uri): base?guilds=..®ions=.., same encoding rules. +std::optional BuildTokenRouteUri(std::string_view tokenUri, std::string_view guilds, + std::string_view regions); + +// The WebSocket bridge's URL credentials (compat/ws_bridge.cpp, config and login +// connections): remoteUri?discordid=..&password=.. against the same Nakama +// handler (session_ws.go reads "discordid", then "discord_id", and "password"). +// Both credentials or neither: if either is empty, remoteUri is returned +// unchanged — an empty secret never goes on the wire (N115). The key stays +// "discordid" because ws_bridge.cpp detects URL credentials by that literal. +std::optional BuildBridgeCredentialUri(std::string_view remoteUri, std::string_view discordId, + std::string_view password); + +} // namespace ServerDbUri diff --git a/src/runtime/tests/test_serverdb_uri.cpp b/src/runtime/tests/test_serverdb_uri.cpp new file mode 100644 index 0000000..190ef44 --- /dev/null +++ b/src/runtime/tests/test_serverdb_uri.cpp @@ -0,0 +1,216 @@ +// Issue #41: the ServerDB URI used to be built with snprintf, so a password (or any +// config value) containing '&', '=', '#', '%', '+', or whitespace rewrote the query. +// These tests pin the percent-encoded output and parse it back the way the server +// does (split on '&', split on the first '=', percent-decode the value). + +#include "runtime/server/serverdb_uri.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +using QueryMap = std::multimap; + +std::string PercentDecode(const std::string& encoded) { + int decodedLength = 0; + std::unique_ptr decoded( + curl_easy_unescape(nullptr, encoded.c_str(), static_cast(encoded.size()), &decodedLength), &curl_free); + if (!decoded) { + ADD_FAILURE() << "curl_easy_unescape failed for " << encoded; + return {}; + } + return std::string(decoded.get(), static_cast(decodedLength)); +} + +// Parses the URI with libcurl's URL parser (not the builder), asserts there is no +// fragment, and decodes every query pair. +QueryMap ParseQuery(const std::string& uri) { + QueryMap pairs; + std::unique_ptr parsed(curl_url(), &curl_url_cleanup); + if (!parsed || curl_url_set(parsed.get(), CURLUPART_URL, uri.c_str(), CURLU_NON_SUPPORT_SCHEME) != CURLUE_OK) { + ADD_FAILURE() << "URI did not parse: " << uri; + return pairs; + } + char* fragment = nullptr; + EXPECT_EQ(curl_url_get(parsed.get(), CURLUPART_FRAGMENT, &fragment, 0), CURLUE_NO_FRAGMENT) << uri; + curl_free(fragment); + + char* rawQuery = nullptr; + if (curl_url_get(parsed.get(), CURLUPART_QUERY, &rawQuery, 0) != CURLUE_OK || rawQuery == nullptr) { + ADD_FAILURE() << "URI has no query: " << uri; + return pairs; + } + const std::string query(rawQuery); + curl_free(rawQuery); + + size_t start = 0; + while (start <= query.size()) { + const size_t end = std::min(query.find('&', start), query.size()); + const std::string pair = query.substr(start, end - start); + const size_t equals = pair.find('='); + if (equals == std::string::npos) { + pairs.emplace(pair, ""); + } else { + pairs.emplace(pair.substr(0, equals), PercentDecode(pair.substr(equals + 1))); + } + start = end + 1; + } + return pairs; +} + +constexpr std::string_view kHostileValue = "p&ss=w#rd%25+ ?/@&guilds=999"; + +} // namespace + +TEST(ServerDbUri, EncodeQueryValueEncodesEverythingButUnreserved) { + EXPECT_EQ(ServerDbUri::EncodeQueryValue("AZaz09-._~"), "AZaz09-._~"); + EXPECT_EQ(ServerDbUri::EncodeQueryValue("&=#%+ ?/@,"), "%26%3D%23%25%2B%20%3F%2F%40%2C"); + EXPECT_EQ(ServerDbUri::EncodeQueryValue("\xC3\xA9"), "%C3%A9"); + EXPECT_EQ(ServerDbUri::EncodeQueryValue(""), ""); +} + +TEST(ServerDbUri, EncodeQueryValueHonoursStringViewLengthNotTerminator) { + const std::string backing = "ab&cd"; + EXPECT_EQ(ServerDbUri::EncodeQueryValue(std::string_view(backing).substr(0, 3)), "ab%26"); +} + +TEST(ServerDbUri, LegacyUriPercentEncodesPasswordExactly) { + const std::optional uri = ServerDbUri::BuildLegacyUri( + "ws://db.example:777/serverdb", "123456789", kHostileValue, "111,222", "us-east"); + ASSERT_TRUE(uri.has_value()); + EXPECT_EQ(*uri, + "ws://db.example:777/serverdb?discord_id=123456789" + "&password=p%26ss%3Dw%23rd%2525%2B%20%3F%2F%40%26guilds%3D999" + "&guilds=111,222®ions=us-east"); +} + +TEST(ServerDbUri, LegacyUriRoundTripsHostileValuesWithoutFieldBleed) { + const std::optional uri = ServerDbUri::BuildLegacyUri( + "wss://db.example/serverdb", "123456789", kHostileValue, "111,222", "us-east,eu_west"); + ASSERT_TRUE(uri.has_value()); + const QueryMap pairs = ParseQuery(*uri); + const QueryMap expected = { + {"discord_id", "123456789"}, + {"password", std::string(kHostileValue)}, + {"guilds", "111,222"}, + {"regions", "us-east,eu_west"}, + }; + EXPECT_EQ(pairs, expected) << *uri; +} + +// No whitespace, so a raw-concatenated URI would still parse — this is the case +// where an unencoded password injects a second guilds= and truncates at '#'. +TEST(ServerDbUri, PasswordCannotInjectAParameterOrAFragment) { + const std::optional uri = + ServerDbUri::BuildLegacyUri("ws://h/s", "1", "a&guilds=999#tail", "111", ""); + ASSERT_TRUE(uri.has_value()); + const QueryMap pairs = ParseQuery(*uri); + EXPECT_EQ(pairs.count("guilds"), 1u) << *uri; + const QueryMap expected = {{"discord_id", "1"}, {"password", "a&guilds=999#tail"}, {"guilds", "111"}}; + EXPECT_EQ(pairs, expected) << *uri; +} + +TEST(ServerDbUri, HostileListElementsAreEncodedButCommasStayDelimiters) { + const std::optional uri = + ServerDbUri::BuildLegacyUri("ws://h/s", "1", "pw", "111,2&x=y", "a#b,c d"); + ASSERT_TRUE(uri.has_value()); + EXPECT_EQ(*uri, "ws://h/s?discord_id=1&password=pw&guilds=111,2%26x%3Dy®ions=a%23b,c%20d"); + const QueryMap pairs = ParseQuery(*uri); + const QueryMap expected = { + {"discord_id", "1"}, {"password", "pw"}, {"guilds", "111,2&x=y"}, {"regions", "a#b,c d"}}; + EXPECT_EQ(pairs, expected); +} + +// Every value the server accepts (session_ws.go discordIDPattern ^[0-9]+$, +// regionPattern ^[-A-Za-z0-9_]+$, guildPattern ^([0-9]+|any)$) is RFC 3986 +// unreserved, so the wire bytes for an already-working config do not change. +TEST(ServerDbUri, ServerAcceptedValuesProduceTheSameBytesAsBefore) { + const std::optional uri = ServerDbUri::BuildLegacyUri( + "ws://db.example:777/serverdb", "123456789", "hunter2", "111,any,222", "us-east,eu_west"); + ASSERT_TRUE(uri.has_value()); + EXPECT_EQ(*uri, + "ws://db.example:777/serverdb?discord_id=123456789&password=hunter2" + "&guilds=111,any,222®ions=us-east,eu_west"); +} + +TEST(ServerDbUri, EmptyOptionalFieldsAreOmitted) { + EXPECT_EQ(ServerDbUri::BuildLegacyUri("ws://h/s", "1", "", "", ""), "ws://h/s?discord_id=1"); + EXPECT_EQ(ServerDbUri::BuildLegacyUri("ws://h/s", "1", "", "", "r"), "ws://h/s?discord_id=1®ions=r"); + EXPECT_EQ(ServerDbUri::BuildTokenRouteUri("wss://h/nevr", "", ""), "wss://h/nevr"); +} + +TEST(ServerDbUri, BaseWithExistingQueryGetsAmpersandSeparator) { + EXPECT_EQ(ServerDbUri::BuildLegacyUri("ws://h/s?format=evr", "1", "a b", "", ""), + "ws://h/s?format=evr&discord_id=1&password=a%20b"); + EXPECT_EQ(ServerDbUri::BuildTokenRouteUri("ws://h/s?", "", "r"), "ws://h/s?regions=r"); + EXPECT_EQ(ServerDbUri::BuildTokenRouteUri("ws://h/s?x=1&", "g", ""), "ws://h/s?x=1&guilds=g"); +} + +TEST(ServerDbUri, TokenRouteUriEncodesListsAndRoundTrips) { + const std::optional uri = ServerDbUri::BuildTokenRouteUri("wss://h/nevr", "111,222", "a&b"); + ASSERT_TRUE(uri.has_value()); + EXPECT_EQ(*uri, "wss://h/nevr?guilds=111,222®ions=a%26b"); + const QueryMap pairs = ParseQuery(*uri); + const QueryMap expected = {{"guilds", "111,222"}, {"regions", "a&b"}}; + EXPECT_EQ(pairs, expected); +} + +// The old builder truncated at 1024 bytes; the new one has no fixed buffer. +TEST(ServerDbUri, LongValuesAreNotTruncated) { + const std::string longRegions(2000, 'r'); + const std::optional uri = ServerDbUri::BuildLegacyUri("ws://h/s", "1", "pw", "", longRegions); + ASSERT_TRUE(uri.has_value()); + EXPECT_EQ(*uri, "ws://h/s?discord_id=1&password=pw®ions=" + longRegions); +} + +// --- compat/ws_bridge.cpp URL credentials (config + login connections) -------- +// Same Nakama handler (session_ws.go reads "discordid" then "discord_id", and +// "password"), so the same encoding contract applies. + +TEST(ServerDbUri, BridgeCredentialsPercentEncodePasswordExactly) { + const std::optional uri = + ServerDbUri::BuildBridgeCredentialUri("wss://g.example/ws?format=evr", "123456789", kHostileValue); + ASSERT_TRUE(uri.has_value()); + EXPECT_EQ(*uri, + "wss://g.example/ws?format=evr&discordid=123456789" + "&password=p%26ss%3Dw%23rd%2525%2B%20%3F%2F%40%26guilds%3D999"); +} + +TEST(ServerDbUri, BridgeCredentialsRoundTripHostileValuesWithoutFieldBleed) { + const std::optional uri = + ServerDbUri::BuildBridgeCredentialUri("wss://g.example/ws?format=evr", "123456789", kHostileValue); + ASSERT_TRUE(uri.has_value()); + const QueryMap expected = { + {"format", "evr"}, {"discordid", "123456789"}, {"password", std::string(kHostileValue)}}; + EXPECT_EQ(ParseQuery(*uri), expected) << *uri; +} + +// The binary must not disagree with itself: both connection paths deliver the +// same decoded password for the same config value. +TEST(ServerDbUri, BridgeAndServerDbPathsSendTheSamePasswordBytes) { + const std::string password = "a+b%41;c&d#e"; + const std::optional bridge = ServerDbUri::BuildBridgeCredentialUri("ws://h/ws", "1", password); + const std::optional serverDb = ServerDbUri::BuildLegacyUri("ws://h/ws", "1", password, "", ""); + ASSERT_TRUE(bridge.has_value()); + ASSERT_TRUE(serverDb.has_value()); + const QueryMap bridgePairs = ParseQuery(*bridge); + const QueryMap serverDbPairs = ParseQuery(*serverDb); + ASSERT_EQ(bridgePairs.count("password"), 1u) << *bridge; + ASSERT_EQ(serverDbPairs.count("password"), 1u) << *serverDb; + EXPECT_EQ(bridgePairs.find("password")->second, password); + EXPECT_EQ(serverDbPairs.find("password")->second, password); +} + +TEST(ServerDbUri, BridgeCredentialsAreBothOrNeither) { + EXPECT_EQ(ServerDbUri::BuildBridgeCredentialUri("ws://h/ws?format=evr", "1", ""), "ws://h/ws?format=evr"); + EXPECT_EQ(ServerDbUri::BuildBridgeCredentialUri("ws://h/ws", "", "pw"), "ws://h/ws"); + EXPECT_EQ(ServerDbUri::BuildBridgeCredentialUri("ws://h/ws", "1", "pw"), "ws://h/ws?discordid=1&password=pw"); +}