Files
ReSkate/Extension/Music/Test/music_artwork_tests.cpp
T
ReSkate localandClaude Sonnet 5.5 702e5c74d2 Music: serve mod cover artwork over HTTP/2 cleartext
The game fetches the artwork URL with HTTP/2 cleartext (h2c, prior knowledge), and only from 127.0.0.1. The loopback server spoke HTTP/1.1, so every cover request was answered with an HTTP/1.1 404 and mod playlist/song covers never appeared.

Speak h2c: complete the SETTINGS handshake, read the request header block (HEADERS and CONTINUATION frames), decode :path with a minimal HPACK decoder (static table and Huffman code from RFC 7541 in hpack_tables.h), and serve the matching image as HEADERS plus DATA frames with flow control. Keep the HTTP/1.1 path for other clients. Serve from one stable origin (http://127.0.0.1:47823/music-art/<mod>/<relative>) so the game's HTTP cache is reused, send Cache-Control and an ETag so it stops re-fetching, close gracefully (closing with unread frames queued makes Windows send RST and the client drops the tail), and use a non-blocking listener since several pollers share it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:09:51 +01:00

142 lines
7.8 KiB
C++

#include <winsock2.h>
#include <Windows.h>
#include <winhttp.h>
#include "Extension/Music/music_artwork.h"
#include <array>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
namespace {
int failures = 0;
void check(bool ok, const char* message) {
if (!ok) { ++failures; std::cerr << "FAIL: " << message << '\n'; }
}
struct Handle {
HINTERNET value{};
~Handle() { if (value) WinHttpCloseHandle(value); }
};
std::pair<DWORD, std::string> fetch(const std::string& url) {
const std::wstring wide(url.begin(), url.end());
URL_COMPONENTS parts{sizeof(parts)};
parts.dwHostNameLength = parts.dwUrlPathLength = static_cast<DWORD>(-1);
if (!WinHttpCrackUrl(wide.c_str(), 0, 0, &parts)) return {};
Handle session{WinHttpOpen(L"Artwork test", WINHTTP_ACCESS_TYPE_NO_PROXY, nullptr, nullptr, 0)};
if (!session.value) return {};
WinHttpSetTimeouts(session.value, 3000, 3000, 3000, 3000);
const std::wstring host(parts.lpszHostName, parts.dwHostNameLength), path(parts.lpszUrlPath, parts.dwUrlPathLength);
Handle connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)};
if (!connection.value) return {};
Handle request{WinHttpOpenRequest(connection.value, L"GET", path.c_str(), nullptr, nullptr, nullptr, 0)};
if (!request.value || !WinHttpSendRequest(request.value, nullptr, 0, nullptr, 0, 0, 0) ||
!WinHttpReceiveResponse(request.value, nullptr)) return {};
DWORD status{}, size = sizeof(status);
if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &status, &size, nullptr)) return {};
std::string body;
std::array<char, 1024> buffer{};
DWORD received{};
while (WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &received) && received)
body.append(buffer.data(), received);
return {status, body};
}
// A minimal HTTP/2 cleartext (h2c) client, the way the game's client connects: send the connection
// preface, SETTINGS and a HEADERS frame, then collect the DATA frames of the response.
std::pair<int, std::string> fetch_h2c(unsigned short port, const std::string& path) {
SOCKET socket_value = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (socket_value == INVALID_SOCKET) return {};
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(port);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(socket_value, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) != 0) {
closesocket(socket_value);
return {};
}
constexpr char preface[] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
send(socket_value, preface, 24, 0);
const char settings[] = {0, 0, 0, 4, 0, 0, 0, 0, 0};
send(socket_value, settings, 9, 0);
// HPACK block: :method GET (indexed 2), :scheme http (indexed 6), :path as a literal without
// indexing with the static name index 4 and a raw (non-Huffman) value.
std::string block;
block.push_back(static_cast<char>(0x82));
block.push_back(static_cast<char>(0x86));
block.push_back(static_cast<char>(0x04));
block.push_back(static_cast<char>(path.size()));
block.insert(block.end(), path.begin(), path.end());
const char headers[] = {0, 0, static_cast<char>(block.size()), 1, 4, 0, 0, 0, 1};
send(socket_value, headers, 9, 0);
send(socket_value, block.data(), static_cast<int>(block.size()), 0);
std::string buffer, body;
int status = 0;
for (;;) {
char chunk[16384];
const int got = recv(socket_value, chunk, sizeof(chunk), 0);
if (got <= 0) break;
buffer.append(chunk, got);
bool done = false;
while (buffer.size() >= 9) {
const auto length = (std::size_t(static_cast<unsigned char>(buffer[0])) << 16) |
(std::size_t(static_cast<unsigned char>(buffer[1])) << 8) | std::size_t(static_cast<unsigned char>(buffer[2]));
const auto type = static_cast<unsigned char>(buffer[3]);
const auto flags = static_cast<unsigned char>(buffer[4]);
const auto stream = ((static_cast<unsigned>(static_cast<unsigned char>(buffer[5])) << 24) |
(static_cast<unsigned>(static_cast<unsigned char>(buffer[6])) << 16) |
(static_cast<unsigned>(static_cast<unsigned char>(buffer[7])) << 8) |
static_cast<unsigned>(static_cast<unsigned char>(buffer[8]))) & 0x7fffffffu;
if (buffer.size() < 9 + length) break;
const std::string payload = buffer.substr(9, length);
buffer.erase(0, 9 + length);
if (type == 0x1 && stream == 1 && !payload.empty() && static_cast<unsigned char>(payload[0]) == 0x88) status = 200;
if (type == 0x0 && stream == 1) {
body += payload;
if (flags & 0x1) done = true;
}
}
if (done) break;
}
closesocket(socket_value);
return {status, body};
}
}
int main() try {
namespace fs = std::filesystem;
const auto root = fs::temp_directory_path() / (L"ReSkateArtworkTest-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(GetTickCount64()));
fs::create_directories(root / L"mod" / L"artwork");
struct Cleanup { fs::path root; ~Cleanup() { std::error_code ec; fs::remove_all(root, ec); } } cleanup{root};
// A real one-pixel grayscale PNG. Bytes returned by HTTP must match it exactly.
constexpr unsigned char png[]{
137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,4,0,0,0,181,28,12,2,
0,0,0,11,73,68,65,84,120,218,99,100,248,15,0,1,5,1,1,39,24,227,102,0,0,0,0,73,69,78,68,174,66,96,130};
const std::string bytes(reinterpret_cast<const char*>(png), sizeof(png));
{ std::ofstream out(root / L"mod/artwork/cover.png", std::ios::binary); out.write(bytes.data(), bytes.size()); }
{ std::ofstream out(root / L"mod/artwork/bad.png", std::ios::binary); out << "not an image"; }
{ std::ofstream out(root / L"outside.png", std::ios::binary); out.write(bytes.data(), bytes.size()); }
dingosdk::profile_runtime::MusicArtworkServer server;
const auto url = server.add(root / L"mod", "artwork/cover.png");
check(url.starts_with("http://127.0.0.1:"), "server uses an ephemeral loopback URL");
check(server.add(root / L"mod", "artwork/cover.png") == url, "repeated registration keeps the URL");
check(server.add(root / L"mod", "../outside.png").empty(), "parent traversal is refused");
check(server.add(root / L"mod", "artwork/missing.png").empty(), "missing image is ignored");
check(server.add(root / L"mod", "artwork/bad.png").empty(), "non-PNG bytes are ignored");
if (!url.empty()) {
const auto [status, body] = fetch(url);
check(status == 200 && body == bytes, "HTTP returns the exact registered PNG");
const auto base = url.substr(0, url.find('/', 7));
check(fetch(base + "/music-art/unknown.png").first == 404, "unknown route is not served");
check(fetch(base + "/outside.png").first == 404, "HTTP cannot read arbitrary files");
const auto colon = url.find(':', 7), slash = url.find('/', colon);
const auto port = static_cast<unsigned short>(std::stoi(url.substr(colon + 1, slash - colon - 1)));
const auto [h2status, h2body] = fetch_h2c(port, url.substr(slash));
check(h2status == 200 && h2body == bytes, "h2c returns the exact registered PNG");
}
std::error_code ec;
fs::create_directory_symlink(root, root / L"mod" / L"escape", ec);
if (!ec) check(server.add(root / L"mod", "escape/outside.png").empty(), "symlink escape is refused");
else std::cout << "SKIP: symlink test needs Windows symlink privileges\n";
if (!failures) std::cout << "music artwork tests passed\n";
return failures ? 1 : 0;
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }