mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 18:03:04 +02:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
11 KiB
C++
295 lines
11 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See fetch.h.
|
|
//
|
|
// Linux and SteamOS: the system's curl (on SteamOS and nearly every
|
|
// distribution), so the game links no HTTP or TLS library of its own and
|
|
// uses the system's certificates. It runs with:
|
|
// - a fixed argument list, through posix_spawn (no shell), -q first so no
|
|
// ~/.curlrc adds anything;
|
|
// - a small environment: PATH, the proxy variables and the CA bundle ones
|
|
// (not LD_LIBRARY_PATH or LD_PRELOAD: the SteamOS download's bundled
|
|
// libraries and Steam's overlay are the game's, not curl's);
|
|
// - stdin and stderr on /dev/null, stdout a pipe read with a deadline and
|
|
// a size cap; no other descriptor of the game's;
|
|
// - default signal handling and an empty signal mask.
|
|
// curl stops itself at --max-time; the game kills it two seconds later if
|
|
// it hasn't, and always reaps it.
|
|
//
|
|
// NFSMW_UPDATE_CURL=<path>: that curl instead (tests: a missing one).
|
|
#if !defined(__APPLE__) && !defined(_WIN32)
|
|
#include "fetch.h"
|
|
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <csignal>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <format>
|
|
|
|
#include <fcntl.h>
|
|
#include <poll.h>
|
|
#include <spawn.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
extern char** environ;
|
|
|
|
namespace update
|
|
{
|
|
namespace
|
|
{
|
|
bool Executable(const std::string& path)
|
|
{
|
|
struct stat st;
|
|
return !path.empty() && path[0] == '/' && stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode) && access(path.c_str(), X_OK) == 0;
|
|
}
|
|
|
|
std::string FindCurl()
|
|
{
|
|
if (const char* v = std::getenv("NFSMW_UPDATE_CURL"); v && *v)
|
|
return Executable(v) ? v : "";
|
|
std::vector<std::string> dirs;
|
|
if (const char* path = std::getenv("PATH"))
|
|
for (std::string_view rest = path; !rest.empty();)
|
|
{
|
|
size_t colon = rest.find(':');
|
|
std::string_view dir = rest.substr(0, colon);
|
|
rest = colon == std::string_view::npos ? std::string_view() : rest.substr(colon + 1);
|
|
if (dir.starts_with('/')) // never the current folder
|
|
dirs.emplace_back(dir);
|
|
}
|
|
for (const char* dir : { "/usr/bin", "/bin", "/usr/local/bin" })
|
|
dirs.emplace_back(dir);
|
|
for (const std::string& dir : dirs)
|
|
if (std::string curl = dir + "/curl"; Executable(curl))
|
|
return curl;
|
|
return {};
|
|
}
|
|
|
|
// curl(1), "EXIT CODES".
|
|
Transport FromExitCode(int code)
|
|
{
|
|
switch (code)
|
|
{
|
|
case 0:
|
|
return Transport::Ok;
|
|
case 5: // couldn't resolve the proxy
|
|
case 6: // couldn't resolve the host
|
|
case 7: // couldn't connect
|
|
case 45: // interface error
|
|
case 52: // nothing came back
|
|
case 55: // send failed
|
|
case 56: // receive failed (a reset connection)
|
|
return Transport::Offline;
|
|
case 28:
|
|
return Transport::Timeout;
|
|
case 35: // the TLS handshake failed
|
|
case 53: case 54: case 58: case 59: case 60: case 64: case 66: case 77: case 80: case 82: case 83: case 90: case 91:
|
|
return Transport::Secure;
|
|
case 63: // over --max-filesize
|
|
return Transport::TooLarge;
|
|
case 1: // "unsupported protocol": with the protocols fixed, a reply that isn't HTTP (HTTP/0.9 refused)
|
|
case 8: // a reply curl couldn't parse
|
|
return Transport::Garbled;
|
|
default:
|
|
return Transport::Failed;
|
|
}
|
|
}
|
|
|
|
// waitpid, through EINTR. 0: still running; -1: not ours to reap.
|
|
pid_t Reap(pid_t pid, int& status, int flags)
|
|
{
|
|
pid_t r;
|
|
do
|
|
r = waitpid(pid, &status, flags);
|
|
while (r < 0 && errno == EINTR);
|
|
return r;
|
|
}
|
|
}
|
|
|
|
Fetched Fetch(const Request& request)
|
|
{
|
|
Fetched result;
|
|
const std::string curl = FindCurl();
|
|
if (curl.empty())
|
|
{
|
|
result.transport = Transport::Unavailable;
|
|
result.detail = "Checking needs curl, which this system doesn't have. Install curl, or see the releases page.";
|
|
return result;
|
|
}
|
|
|
|
const std::string seconds = std::format("{}", std::ceil(request.timeoutSeconds));
|
|
std::vector<std::string> args = {
|
|
curl, "-q", "--silent", "--dump-header", "-",
|
|
"--max-time", seconds, "--connect-timeout", seconds,
|
|
"--max-filesize", std::to_string(request.maxBytes),
|
|
"--proto", request.allowHttp ? "=http,https" : "=https",
|
|
"--user-agent", request.userAgent,
|
|
};
|
|
for (const auto& [name, value] : request.headers)
|
|
{
|
|
args.push_back("--header");
|
|
args.push_back(name + ": " + value);
|
|
}
|
|
args.push_back("--url");
|
|
args.push_back(request.url);
|
|
std::vector<char*> argv;
|
|
for (std::string& a : args)
|
|
argv.push_back(a.data());
|
|
argv.push_back(nullptr);
|
|
|
|
std::vector<std::string> env;
|
|
for (const char* name : { "PATH", "https_proxy", "HTTPS_PROXY", "http_proxy", "all_proxy", "ALL_PROXY", "no_proxy", "NO_PROXY",
|
|
"SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE" })
|
|
if (const char* value = std::getenv(name))
|
|
env.push_back(std::string(name) + "=" + value);
|
|
std::vector<char*> envp;
|
|
for (std::string& e : env)
|
|
envp.push_back(e.data());
|
|
envp.push_back(nullptr);
|
|
|
|
int fds[2];
|
|
if (pipe2(fds, O_CLOEXEC) != 0)
|
|
{
|
|
result.detail = std::format("pipe: {}", strerror(errno));
|
|
return result;
|
|
}
|
|
posix_spawn_file_actions_t actions;
|
|
posix_spawn_file_actions_init(&actions);
|
|
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0);
|
|
posix_spawn_file_actions_adddup2(&actions, fds[1], 1);
|
|
posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0);
|
|
#if defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 34))
|
|
posix_spawn_file_actions_addclosefrom_np(&actions, 3); // the game's descriptors without O_CLOEXEC too
|
|
#endif
|
|
posix_spawnattr_t attr;
|
|
posix_spawnattr_init(&attr);
|
|
sigset_t none, all;
|
|
sigemptyset(&none);
|
|
sigfillset(&all);
|
|
posix_spawnattr_setsigmask(&attr, &none);
|
|
posix_spawnattr_setsigdefault(&attr, &all);
|
|
posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF);
|
|
pid_t pid = -1;
|
|
const int spawned = posix_spawn(&pid, curl.c_str(), &actions, &attr, argv.data(), envp.data());
|
|
posix_spawn_file_actions_destroy(&actions);
|
|
posix_spawnattr_destroy(&attr);
|
|
close(fds[1]);
|
|
if (spawned != 0)
|
|
{
|
|
close(fds[0]);
|
|
result.transport = spawned == ENOENT ? Transport::Unavailable : Transport::Failed;
|
|
result.detail = spawned == ENOENT ? "Checking needs curl, which this system doesn't have. Install curl, or see the releases page."
|
|
: std::format("couldn't run curl: {}", strerror(spawned));
|
|
return result;
|
|
}
|
|
|
|
// Its output, by the deadline and within the cap.
|
|
using Clock = std::chrono::steady_clock;
|
|
const auto deadline = Clock::now() + std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(request.timeoutSeconds + 2.0));
|
|
const size_t cap = request.maxBytes + 64 * 1024; // the body and its headers
|
|
std::string out;
|
|
bool killed = false;
|
|
Transport killedAs = Transport::Failed;
|
|
char buffer[16384];
|
|
while (true)
|
|
{
|
|
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now()).count();
|
|
if (left <= 0)
|
|
{
|
|
killed = true, killedAs = Transport::Timeout;
|
|
result.detail = "curl was still running after the deadline";
|
|
break;
|
|
}
|
|
pollfd p{ fds[0], POLLIN, 0 };
|
|
int ready = poll(&p, 1, int(std::min<int64_t>(left, 1000)));
|
|
if (ready < 0 && errno == EINTR)
|
|
continue;
|
|
if (ready < 0)
|
|
{
|
|
killed = true;
|
|
result.detail = std::format("poll: {}", strerror(errno));
|
|
break;
|
|
}
|
|
if (ready == 0)
|
|
continue;
|
|
ssize_t n = read(fds[0], buffer, sizeof(buffer));
|
|
if (n < 0 && (errno == EINTR || errno == EAGAIN))
|
|
continue;
|
|
if (n < 0)
|
|
{
|
|
killed = true;
|
|
result.detail = std::format("read: {}", strerror(errno));
|
|
break;
|
|
}
|
|
if (n == 0)
|
|
break; // curl closed its output: done
|
|
if (out.size() + size_t(n) > cap)
|
|
{
|
|
killed = true, killedAs = Transport::TooLarge;
|
|
result.detail = std::format("over {} bytes", cap);
|
|
break;
|
|
}
|
|
out.append(buffer, size_t(n));
|
|
}
|
|
close(fds[0]);
|
|
if (killed)
|
|
kill(pid, SIGKILL); // still unreaped, so still ours
|
|
|
|
// Reaped in any case: at its end of output curl is exiting; give it
|
|
// two seconds, then the kill.
|
|
int status = 0;
|
|
pid_t reaped = Reap(pid, status, WNOHANG);
|
|
for (auto until = Clock::now() + std::chrono::seconds(2); reaped == 0 && Clock::now() < until;)
|
|
{
|
|
usleep(10'000);
|
|
reaped = Reap(pid, status, WNOHANG);
|
|
}
|
|
if (reaped == 0)
|
|
{
|
|
kill(pid, SIGKILL);
|
|
reaped = Reap(pid, status, 0);
|
|
if (!killed)
|
|
{
|
|
killed = true, killedAs = Transport::Timeout;
|
|
result.detail = "curl didn't exit";
|
|
}
|
|
}
|
|
|
|
if (killed)
|
|
{
|
|
result.transport = killedAs;
|
|
return result;
|
|
}
|
|
if (reaped < 0 || !WIFEXITED(status))
|
|
{
|
|
result.transport = Transport::Failed;
|
|
result.detail = reaped < 0 ? "curl's exit status was lost" : std::format("curl ended by signal {}", WTERMSIG(status));
|
|
return result;
|
|
}
|
|
const int code = WEXITSTATUS(status);
|
|
if (Transport t = FromExitCode(code); t != Transport::Ok)
|
|
{
|
|
result.transport = t;
|
|
result.detail = std::format("curl exit {}", code);
|
|
return result;
|
|
}
|
|
if (!ParseHttpResponse(out, result.response))
|
|
{
|
|
result.transport = Transport::Garbled;
|
|
result.detail = std::format("curl's output isn't an HTTP answer ({} bytes)", out.size());
|
|
return result;
|
|
}
|
|
if (result.response.body.size() > request.maxBytes)
|
|
{
|
|
result.transport = Transport::TooLarge;
|
|
result.detail = std::format("{} bytes", result.response.body.size());
|
|
return result;
|
|
}
|
|
result.transport = Transport::Ok;
|
|
return result;
|
|
}
|
|
}
|
|
#endif
|