Files
knutwurst--ps5-patchdl/src/patchdl_websrv.c
T
Knutwurst 88368e3df3 Plug response-OOM leak, cap RAM, free pool on shutdown
queue_buffer leaked the MUST_FREE payload (every queue_json_owned/
build_*_json/strdup(resp)) when MHD_create_response_from_buffer hit OOM.
The MUST_FREE contract hands ownership to MHD only on success — on the
NULL return path the caller still owns the buffer, so free it before
bailing. Critical under memory pressure where the first OOM turns into
a cascade.

patchdl_websrv_stop walked the verxml thread + workers but never freed
the remaining dl_job_t entries or g_debug_json. Today main never calls
the function, but the early-failure path inside patchdl_websrv_start
does, and any later graceful-shutdown work would hit the same leak.
Drain g_pool.jobs through free_job_locked under the pool lock; free
g_debug_json under g_mutex.

RAM caps:

- MHD: CONNECTION_LIMIT 64 -> 8 (single-user UI), and
  THREAD_STACK_SIZE = 512 KB. THREAD_PER_CONNECTION on libc's default
  pthread stack (multi-MB) was reserving hundreds of MB of VM per
  burst; the largest stack frame in any handler is the 8 KB sidecar
  buffer, so 512 KB is generous even with curl + openssl in the path.
- patchdl_buf_t caps: manifest 64 -> 16 MiB, version.xml 16 -> 4 MiB.
  Real PS5 manifests are 1-2 MiB; the old caps allowed 64 MiB per
  fetch with multiple fetches possible in flight.
- patchdl_install_status_json: ai_install_status_t (2 KB pad) moves
  from the MHD worker stack to a calloc/free pair so a polling browser
  doesn't keep committing pages on each /api/installstatus tick.
- seed_from_sidecar: 16 KB stack buf -> 8 KB. 4096-piece cap fits with
  the JSON wrapper inside 8 KB.

No functional changes; download/install/scan/tile paths unaffected.
2026-06-25 06:10:48 +02:00

2433 lines
96 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "patchdl_websrv.h"
#include "patchdl_assets.h"
#include "patchdl_fw.h"
#include "patchdl_scan.h"
#include "patchdl_install.h"
#include "patchdl_net.h"
#include "patchdl_resolve.h"
#include "patchdl_tile.h"
#include "patchdl_verxml.h"
#include "patchdl_version.h"
#include <microhttpd.h>
#include <pthread.h>
#include <dirent.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h>
static struct MHD_Daemon *web_daemon;
static patchdl_fw_t g_fw;
static patchdl_title_t *g_titles;
static size_t g_title_count;
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
static char *g_debug_json; /* built once at startup */
/* Background version.xml fetch thread. Joinable so shutdown can wait for it
before patchdl_scan_free frees g_titles out from under it. */
static pthread_t g_verxml_tid;
static int g_verxml_started;
static volatile int g_verxml_stop;
static unsigned long g_pkg_hits;
static char g_pkg_diag_json[768] = "{\"hits\":0}";
/* Persisted, user-editable settings. Per-title enable/disable lives in the
title structs (t->enabled) so it travels with the scan; the global fields
live here. Both are saved to PATCHDL_CFG_PATH (homebrew data dir, never a
system file) and reloaded on the next start. Guarded by g_mutex. */
#define POOL_MAX_CONN 16
#define POOL_MAX_JOBS 16
static struct {
char default_policy[8]; /* "deny" | "allow" */
int install_after_download;
int delete_pkg_after_install;
int verify_downloads; /* SHA-256 each manifest piece (default off) */
int home_shortcut; /* user wants a home-screen browser tile */
int max_connections; /* parallel download connections (1..POOL_MAX_CONN) */
} g_cfg = { "deny", 0, 1, 0, 1, 4 };
/* ---------- download connection pool ----------------------------------- */
/* N worker threads share one job at a time (admit_max=1): a single download
spreads its pieces across all N connections; extra titles queue. Resume uses
a per-piece done-bitmap in the sidecar so an out-of-order parallel download
survives a reboot. All pool state is guarded by g_pool.mtx (NOT g_mutex). */
typedef enum { PC_PENDING = 0, PC_INFLIGHT, PC_DONE, PC_FAILED } pc_state_t;
typedef struct {
pc_state_t state;
int attempts;
int slot; /* worker slot while INFLIGHT, else -1 */
} dl_pstate_t;
typedef enum {
JOB_QUEUED = 0, JOB_ADMITTING, JOB_ACTIVE, JOB_PAUSING, JOB_CANCELLING,
JOB_PAUSED, JOB_DONE, JOB_FAILED
} job_state_t;
typedef struct dl_job {
char title_id[32];
char name[128];
char version[16];
char manifest_url[768];
char dir[288];
char dest[320];
int is_manifest;
int verify;
job_state_t state;
unsigned int seq; /* bumped on cancel; stale completions discarded */
volatile int abort; /* read by the piece transfer callback */
long long total; /* assembled size (preallocated) */
long long done_bytes; /* sum of DONE piece sizes (committed) */
long long speed_bps;
long long sample_bytes;
double sample_ts;
patchdl_manifest_t mf; /* pieces (url/offset/size/hash) */
dl_pstate_t *ps; /* per-piece runtime state, mf.count entries */
unsigned char *bitmap; /* (count+7)/8; bit set == DONE + durable */
int pieces_done;
int pieces_failed;
int inflight;
int fd; /* O_RDWR dest fd, -1 until admit */
int rc; /* 0 ok, -1 net/io, -2 verify */
int last_curl_rc; /* CURLcode from the most recent piece */
long last_http_code; /* HTTP status from the most recent piece */
struct dl_job *next;
} dl_job_t;
static struct {
pthread_mutex_t mtx;
pthread_cond_t cv; /* workers wait; broadcast on any change */
dl_job_t *jobs; /* linked list, queue order */
dl_job_t *active; /* the one admitted job, or NULL */
int admitting; /* a worker is fetching/preallocating next job */
int stopping;
int n_workers; /* threads actually spawned (== POOL_MAX_CONN) */
int active_conns; /* live connection limit (1..n_workers); a worker
with slot >= active_conns idles. Changing this
+ broadcast resizes the pool with no restart. */
pthread_t workers[POOL_MAX_CONN];
volatile long long inflight_bytes[POOL_MAX_CONN]; /* per-slot live counters */
} g_pool = { .mtx = PTHREAD_MUTEX_INITIALIZER, .cv = PTHREAD_COND_INITIALIZER };
#define PATCHDL_DL_DIR "/data/patchdl"
#define PATCHDL_CFG_PATH "/data/patchdl/config.json"
/* The source policy and CDN allowlist are fixed (the safety model), so they
stay constant; only the four mutable fields above are user-controlled. */
static const char config_tail_json[] =
"\"download_dir\":\"/data/patchdl (internal)\","
"\"source_policy\":{"
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"shadowmount\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":false},"
"\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}"
"},"
"\"cdn_allowlist\":["
"\"sgst.prod.dl.playstation.net\","
"\"gst.prod.dl.playstation.net\","
"\"gs2.ww.prod.dl.playstation.net\""
"]}";
/* ---------- tiny JSON value lookups (flat objects only) ----------------- */
/* Find `"key"` then the following `true`/`false`; returns dflt if absent. */
static int
json_get_bool(const char *s, const char *key, int dflt) {
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(s, pat);
if (!p) return dflt;
p = strchr(p + strlen(pat), ':');
if (!p) return dflt;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (!strncmp(p, "true", 4)) return 1;
if (!strncmp(p, "false", 5)) return 0;
return dflt;
}
static int
json_get_int(const char *s, const char *key, int dflt) {
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(s, pat);
if (!p) return dflt;
p = strchr(p + strlen(pat), ':');
if (!p) return dflt;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p < '0' || *p > '9') return dflt;
return (int)strtol(p, NULL, 10);
}
/* Find `"key":"value"` and copy value into out. Decodes the common JSON
string escapes (\" \\ \/ \n \r \t \b \f). \uXXXX is collapsed to '?' —
we don't accept multi-byte content in any field here. An unknown escape
keeps the payload byte. */
static void
json_get_str(const char *s, const char *key, char *out, size_t sz) {
out[0] = '\0';
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(s, pat);
if (!p) return;
p = strchr(p + strlen(pat), ':');
if (!p) return;
p++;
while (*p == ' ' || *p == '\t') p++;
if (*p != '"') return;
p++;
size_t i = 0;
while (*p && *p != '"' && i + 1 < sz) {
if (*p == '\\' && p[1]) {
p++;
switch (*p) {
case '"': out[i++] = '"'; break;
case '\\': out[i++] = '\\'; break;
case '/': out[i++] = '/'; break;
case 'n': out[i++] = '\n'; break;
case 'r': out[i++] = '\r'; break;
case 't': out[i++] = '\t'; break;
case 'b': out[i++] = '\b'; break;
case 'f': out[i++] = '\f'; break;
case 'u':
/* \uXXXX: not needed for any field we accept; drop to '?' so
neither the surrogate pair nor the hex digits leak. */
if (p[1] && p[2] && p[3] && p[4]) p += 4;
out[i++] = '?';
break;
default: out[i++] = *p; break;
}
p++;
} else {
out[i++] = *p++;
}
}
out[i] = '\0';
}
/* ---------- JSON builder ------------------------------------------------ */
typedef struct {
char *buf;
size_t len;
size_t cap;
} jbuf_t;
static void
jbuf_ensure(jbuf_t *j, size_t extra) {
if (!j->buf || j->len + extra + 1 > j->cap) {
size_t newcap = (j->cap + extra + 1) * 2;
if (newcap < 1024) newcap = 1024;
char *p = realloc(j->buf, newcap);
if (!p) return;
j->buf = p;
j->cap = newcap;
}
}
static void
jbuf_append(jbuf_t *j, const char *s) {
size_t slen = strlen(s);
jbuf_ensure(j, slen);
if (!j->buf) return;
memcpy(j->buf + j->len, s, slen + 1);
j->len += slen;
}
static void
jbuf_appendf(jbuf_t *j, const char *fmt, ...) {
char tmp[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
jbuf_append(j, tmp);
}
static void
jbuf_append_str(jbuf_t *j, const char *s) {
jbuf_append(j, "\"");
for (const char *p = s; *p; p++) {
if (*p == '"') jbuf_append(j, "\\\"");
else if (*p == '\\') jbuf_append(j, "\\\\");
else if (*p == '\n') jbuf_append(j, "\\n");
else if (*p == '\r') jbuf_append(j, "\\r");
else if (*p == '\t') jbuf_append(j, "\\t");
else { char c[2] = {*p, '\0'}; jbuf_append(j, c); }
}
jbuf_append(j, "\"");
}
static void
jbuf_append_ver_or_null(jbuf_t *j, const char *ver) {
if (ver[0])
jbuf_append_str(j, ver);
else
jbuf_append(j, "null");
}
/* ---------- HTTP helpers ------------------------------------------------ */
static enum MHD_Result
queue_buffer(struct MHD_Connection *conn, unsigned int status,
const char *mime, const void *data, size_t size,
enum MHD_ResponseMemoryMode mm) {
struct MHD_Response *resp;
enum MHD_Result ret;
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
if (!resp) {
/* MUST_FREE hands ownership to MHD on success; on failure we still
own it and have to free it ourselves or the caller's strdup'd
JSON leaks. PERSISTENT/MUST_COPY buffers are caller-owned and
we leave them alone. */
if (mm == MHD_RESPMEM_MUST_FREE) free((void *)data);
return MHD_NO;
}
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
if (mime)
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, mime);
ret = MHD_queue_response(conn, status, resp);
MHD_destroy_response(resp);
return ret;
}
static enum MHD_Result
queue_text(struct MHD_Connection *conn, unsigned int status, const char *text) {
return queue_buffer(conn, status, "text/plain; charset=utf-8",
text, strlen(text), MHD_RESPMEM_PERSISTENT);
}
static enum MHD_Result
queue_json(struct MHD_Connection *conn, unsigned int status, const char *json) {
return queue_buffer(conn, status, "application/json",
json, strlen(json), MHD_RESPMEM_PERSISTENT);
}
/* Takes ownership of json — MHD will free() it */
static enum MHD_Result
queue_json_owned(struct MHD_Connection *conn, unsigned int status, char *json) {
if (!json) return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
return queue_buffer(conn, status, "application/json",
json, strlen(json), MHD_RESPMEM_MUST_FREE);
}
static enum MHD_Result
queue_asset(struct MHD_Connection *conn, const char *url) {
const patchdl_asset_t *asset = patchdl_asset_find(url);
if (!asset) return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
return queue_buffer(conn, MHD_HTTP_OK, asset->mime,
asset->data, asset->size, MHD_RESPMEM_PERSISTENT);
}
static int
path_segment_safe(const char *s) {
if (!s || !s[0] || strstr(s, "..")) return 0;
for (const char *p = s; *p; p++) {
int ok = (*p >= 'A' && *p <= 'Z') ||
(*p >= 'a' && *p <= 'z') ||
(*p >= '0' && *p <= '9') ||
*p == '_' || *p == '-' || *p == '.';
if (!ok) return 0;
}
return 1;
}
/* Validate a local PKG path for /api/install_aip: must be exactly
<PATCHDL_DL_DIR>/<safe-title-id>/<safe-filename>. Rejects anything
outside /data/patchdl/, anything with .. or unsafe chars. */
static int
local_install_path_safe(const char *path) {
const char *prefix = PATCHDL_DL_DIR "/";
size_t plen = strlen(prefix);
const char *tail, *slash;
char seg[64];
size_t len;
if (!path || strncmp(path, prefix, plen) != 0) return 0;
tail = path + plen;
slash = strchr(tail, '/');
if (!slash || slash == tail) return 0;
len = (size_t)(slash - tail);
if (len >= sizeof(seg)) return 0;
memcpy(seg, tail, len);
seg[len] = '\0';
if (!path_segment_safe(seg)) return 0;
tail = slash + 1;
if (!tail[0] || strchr(tail, '/')) return 0;
if (strlen(tail) >= sizeof(seg)) return 0;
return path_segment_safe(tail);
}
/* Whitelist of CDN hosts /api/install_uri may target. Mirrors
ALLOWED_HOSTS in patchdl_net.c — kept in sync by hand. */
static const char *INSTALL_URI_HOSTS[] = {
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net",
NULL,
};
/* Validate an install URI for /api/install_uri: must be https:// to one of
INSTALL_URI_HOSTS (or a subdomain). Rejects file://, http://, redirects
to other hosts (libcurl enforces via host_allowed at fetch time). */
static int
install_uri_safe(const char *uri) {
const char *host_start, *end;
size_t hlen;
char host[128];
if (!uri || strncmp(uri, "https://", 8) != 0) return 0;
host_start = uri + 8;
end = strpbrk(host_start, "/:?");
hlen = end ? (size_t)(end - host_start) : strlen(host_start);
if (hlen == 0 || hlen >= sizeof(host)) return 0;
memcpy(host, host_start, hlen);
host[hlen] = '\0';
for (int i = 0; INSTALL_URI_HOSTS[i]; i++) {
size_t alen = strlen(INSTALL_URI_HOSTS[i]);
if (hlen == alen && !strcasecmp(host, INSTALL_URI_HOSTS[i])) return 1;
if (hlen > alen + 1 &&
host[hlen - alen - 1] == '.' &&
!strcasecmp(host + hlen - alen, INSTALL_URI_HOSTS[i])) return 1;
}
return 0;
}
typedef struct {
int fd;
uint64_t start;
uint64_t size;
} pkg_reader_t;
static ssize_t
pkg_reader_cb(void *cls, uint64_t pos, char *buf, size_t max) {
pkg_reader_t *r = (pkg_reader_t *)cls;
uint64_t rem;
ssize_t n;
if (!r || pos >= r->size)
return MHD_CONTENT_READER_END_WITH_ERROR;
rem = r->size - pos;
if ((uint64_t)max > rem)
max = (size_t)rem;
n = pread(r->fd, buf, max, (off_t)(r->start + pos));
if (n <= 0)
return MHD_CONTENT_READER_END_WITH_ERROR;
return n;
}
static void
pkg_reader_free(void *cls) {
pkg_reader_t *r = (pkg_reader_t *)cls;
if (!r) return;
close(r->fd);
free(r);
}
static void
record_pkg_diag(const char *url, const char *range, unsigned int status,
uint64_t start, uint64_t end, uint64_t total) {
pthread_mutex_lock(&g_mutex);
g_pkg_hits++;
snprintf(g_pkg_diag_json, sizeof(g_pkg_diag_json),
"{\"hits\":%lu,\"url\":\"%.220s\",\"range\":\"%.160s\","
"\"status\":%u,\"start\":%llu,\"end\":%llu,\"total\":%llu}",
g_pkg_hits, url ? url : "", range ? range : "", status,
(unsigned long long)start,
(unsigned long long)end,
(unsigned long long)total);
pthread_mutex_unlock(&g_mutex);
}
/* Serve a downloaded package back to Sony's installer over localhost. This is
only for files PatchDL already placed under /data/patchdl/<title>/<pkg>. */
static enum MHD_Result
queue_pkg_file(struct MHD_Connection *conn, const char *url) {
const char *prefix = "/api/pkg/";
const char *p, *slash;
char title_id[32], file[160], path[360];
size_t len;
struct stat st;
int fd;
const char *range;
uint64_t total, start = 0, end = 0, send_size = 0;
unsigned int status = MHD_HTTP_OK;
char content_range[96];
struct MHD_Response *resp;
pkg_reader_t *reader;
enum MHD_Result ret;
if (strncmp(url, prefix, strlen(prefix)))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
p = url + strlen(prefix);
slash = strchr(p, '/');
if (!slash || slash == p || !slash[1])
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
len = (size_t)(slash - p);
if (len >= sizeof(title_id))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
memcpy(title_id, p, len);
title_id[len] = '\0';
len = strlen(slash + 1);
if (len >= sizeof(file))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
memcpy(file, slash + 1, len + 1);
if (!path_segment_safe(title_id) || !path_segment_safe(file))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
snprintf(path, sizeof(path), "%s/%s/%s", PATCHDL_DL_DIR, title_id, file);
fd = open(path, O_RDONLY);
if (fd < 0)
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
if (fstat(fd, &st) || st.st_size <= 0) {
close(fd);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
total = (uint64_t)st.st_size;
end = total - 1;
send_size = total;
range = MHD_lookup_connection_value(conn, MHD_HEADER_KIND,
MHD_HTTP_HEADER_RANGE);
if (range && !strncmp(range, "bytes=", 6)) {
const char *spec = range + 6;
char *dash = strchr(spec, '-');
if (!dash || strchr(dash + 1, ',')) {
close(fd);
return queue_text(conn, MHD_HTTP_RANGE_NOT_SATISFIABLE, "invalid range");
}
if (dash == spec) {
uint64_t suffix = strtoull(dash + 1, NULL, 10);
if (!suffix) {
close(fd);
return queue_text(conn, MHD_HTTP_RANGE_NOT_SATISFIABLE, "invalid range");
}
start = suffix >= total ? 0 : total - suffix;
} else {
start = strtoull(spec, NULL, 10);
if (dash[1])
end = strtoull(dash + 1, NULL, 10);
}
if (start >= total || end < start) {
close(fd);
return queue_text(conn, MHD_HTTP_RANGE_NOT_SATISFIABLE, "range not satisfiable");
}
if (end >= total) end = total - 1;
send_size = end - start + 1;
status = 206;
}
record_pkg_diag(url, range, status, start, end, total);
reader = calloc(1, sizeof(*reader));
if (!reader) {
close(fd);
return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
}
reader->fd = fd;
reader->start = start;
reader->size = send_size;
resp = MHD_create_response_from_callback(send_size, 65536,
pkg_reader_cb, reader,
pkg_reader_free);
if (!resp) {
close(fd);
free(reader);
return MHD_NO;
}
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, "application/octet-stream");
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCEPT_RANGES, "bytes");
if (status == 206) {
snprintf(content_range, sizeof(content_range), "bytes %llu-%llu/%llu",
(unsigned long long)start,
(unsigned long long)end,
(unsigned long long)total);
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_RANGE, content_range);
}
ret = MHD_queue_response(conn, status, resp);
MHD_destroy_response(resp); /* pkg_reader_free closes fd */
return ret;
}
/* ---------- status JSON ------------------------------------------------- */
/* Free space on the download partition, in MB. Best-effort: 0 if unavailable. */
static long long
data_free_mb(void) {
struct statvfs vfs;
if (statvfs(PATCHDL_DL_DIR, &vfs) == 0 || statvfs("/data", &vfs) == 0)
return (long long)(((unsigned long long)vfs.f_bavail *
(unsigned long long)vfs.f_frsize) / (1024ULL * 1024ULL));
return 0;
}
/* Built fresh per request so free space stays live (a 60+ GB download moves
it a lot). g_fw is read-only after startup, so this is thread-safe. */
static char *
build_status_json(void) {
char *out = malloc(512);
if (!out) return NULL;
snprintf(out, 512,
"{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\","
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\","
"\"version\":\"%s\"}",
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
return out;
}
/* ---------- config persistence (/data/patchdl/config.json) -------------- */
/* Serialize the current global settings; the fixed source policy + allowlist
are appended from config_tail_json. Caller owns the result (queue_json_owned). */
static char *
build_config_json(void) {
char head[224];
pthread_mutex_lock(&g_mutex);
snprintf(head, sizeof(head),
"{\"default_policy\":\"%s\","
"\"install_after_download\":%s,"
"\"delete_pkg_after_install\":%s,"
"\"verify_downloads\":%s,"
"\"home_shortcut\":%s,"
"\"max_connections\":%d,",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false",
g_cfg.max_connections);
pthread_mutex_unlock(&g_mutex);
char *out = malloc(strlen(head) + sizeof(config_tail_json));
if (!out) return NULL;
strcpy(out, head);
strcat(out, config_tail_json);
return out;
}
/* Write global settings + per-title enabled flags. Must NOT be called while
holding g_mutex (it takes the lock itself). */
static void
save_config(void) {
FILE *f;
mkdir(PATCHDL_DL_DIR, 0777);
f = fopen(PATCHDL_CFG_PATH, "w");
if (!f) return;
pthread_mutex_lock(&g_mutex);
fprintf(f,
"{\n\"default_policy\":\"%s\",\n"
"\"install_after_download\":%s,\n"
"\"delete_pkg_after_install\":%s,\n"
"\"verify_downloads\":%s,\n"
"\"home_shortcut\":%s,\n"
"\"max_connections\":%d,\n\"titles\":{",
g_cfg.default_policy[0] ? g_cfg.default_policy : "deny",
g_cfg.install_after_download ? "true" : "false",
g_cfg.delete_pkg_after_install ? "true" : "false",
g_cfg.verify_downloads ? "true" : "false",
g_cfg.home_shortcut ? "true" : "false",
g_cfg.max_connections);
for (size_t i = 0; i < g_title_count; i++)
fprintf(f, "%s\"%s\":%s", i ? "," : "",
g_titles[i].title_id, g_titles[i].enabled ? "true" : "false");
fprintf(f, "}\n}\n");
pthread_mutex_unlock(&g_mutex);
fclose(f);
}
/* Load persisted settings over the defaults. Called once at startup, after the
scan, while still single-threaded. */
static void
load_config(void) {
FILE *f;
char buf[16384];
size_t n;
char pol[8];
f = fopen(PATCHDL_CFG_PATH, "r");
if (!f) return;
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
json_get_str(buf, "default_policy", pol, sizeof(pol));
if (pol[0]) {
strncpy(g_cfg.default_policy, pol, sizeof(g_cfg.default_policy) - 1);
g_cfg.default_policy[sizeof(g_cfg.default_policy) - 1] = '\0';
}
g_cfg.install_after_download =
json_get_bool(buf, "install_after_download", g_cfg.install_after_download);
g_cfg.delete_pkg_after_install =
json_get_bool(buf, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
g_cfg.verify_downloads =
json_get_bool(buf, "verify_downloads", g_cfg.verify_downloads);
g_cfg.home_shortcut =
json_get_bool(buf, "home_shortcut", g_cfg.home_shortcut);
g_cfg.max_connections =
json_get_int(buf, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
/* per-title overrides live under "titles": { "<id>": true|false, ... } */
const char *titles = strstr(buf, "\"titles\"");
if (titles)
for (size_t i = 0; i < g_title_count; i++)
g_titles[i].enabled =
json_get_bool(titles, g_titles[i].title_id, g_titles[i].enabled);
}
/* ---------- titles JSON (built per request under mutex) ----------------- */
static const char *
title_status_str(const patchdl_title_t *t) {
if (!t->verxml_done) return "checking";
if (t->source_type == PATCHDL_SOURCE_UNKNOWN) return "blocked";
if (t->compatible_version[0]) {
/* Versions are zero-padded (01.000.011), so strcmp orders them.
A compatible patch newer than what's installed = update available. */
if (t->installed_version[0] &&
strcmp(t->compatible_version, t->installed_version) <= 0)
return "up_to_date";
return "available";
}
if (t->latest_version[0]) return "incompatible_fw";
return "no_update";
}
static const char *
title_mode_str(patchdl_source_t src) {
switch (src) {
case PATCHDL_SOURCE_SHADOWMOUNT: return "download_only";
case PATCHDL_SOURCE_UNKNOWN: return "disabled";
default: return "latest_compatible";
}
}
static char *
build_titles_json(void) {
jbuf_t j = {0};
pthread_mutex_lock(&g_mutex);
jbuf_append(&j, "[");
for (size_t i = 0; i < g_title_count; i++) {
const patchdl_title_t *t = &g_titles[i];
if (i > 0) jbuf_append(&j, ",");
jbuf_append(&j, "{");
jbuf_append(&j, "\"title_id\":"); jbuf_append_str(&j, t->title_id);
jbuf_append(&j, ",\"name\":"); jbuf_append_str(&j, t->name);
jbuf_append(&j, ",\"content_id\":"); jbuf_append_str(&j, t->content_id);
jbuf_append(&j, ",\"installed_version\":");
jbuf_append_ver_or_null(&j, t->installed_version);
jbuf_append(&j, ",\"compatible_version\":");
jbuf_append_ver_or_null(&j, t->compatible_version);
jbuf_append(&j, ",\"latest_version\":");
jbuf_append_ver_or_null(&j, t->latest_version);
jbuf_append(&j, ",\"latest_required_fw\":");
jbuf_append_ver_or_null(&j, t->latest_required_fw);
jbuf_append(&j, ",\"source_type\":");
jbuf_append_str(&j, patchdl_source_str(t->source_type));
jbuf_append(&j, ",\"source_path\":"); jbuf_append_str(&j, t->source_path);
jbuf_append(&j, ",\"mount_from\":"); jbuf_append_str(&j, t->mount_from);
jbuf_append(&j, ",\"version_file_uri\":");
jbuf_append_ver_or_null(&j, t->version_file_uri);
jbuf_append(&j, ",\"patch_title_id\":");
jbuf_append_ver_or_null(&j, t->patch_title_id);
jbuf_append(&j, ",\"patch_storage_title_id\":");
jbuf_append_ver_or_null(&j, t->patch_storage_title_id);
jbuf_appendf(&j, ",\"patch_storage_match\":%s",
(!t->patch_storage_title_id[0] ||
!strncmp(t->patch_storage_title_id, t->title_id, 9)) ? "true" : "false");
/* This is the target title id parsed from version.xml/manifest_url.
CDN storage paths may use another regional/master title id; that is
not exposed here and must not block a valid target match. */
jbuf_appendf(&j, ",\"patch_title_match\":%s",
(!t->patch_title_id[0] ||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false");
jbuf_appendf(&j, ",\"resumable\":%s", t->resumable ? "true" : "false");
jbuf_appendf(&j, ",\"partial_bytes\":%lld", t->partial_bytes);
jbuf_append(&j, ",\"mode\":");
jbuf_append_str(&j, title_mode_str(t->source_type));
jbuf_append(&j, ",\"queued\":false");
jbuf_append(&j, ",\"status\":");
jbuf_append_str(&j, title_status_str(t));
jbuf_append(&j, "}");
}
jbuf_append(&j, "]");
pthread_mutex_unlock(&g_mutex);
return j.buf; /* caller owns; use queue_json_owned */
}
static const char *
job_state_str(job_state_t s) {
switch (s) {
case JOB_QUEUED: return "queued";
case JOB_ADMITTING: return "active";
case JOB_ACTIVE: return "active";
case JOB_PAUSING: return "active";
case JOB_CANCELLING: return "active";
case JOB_PAUSED: return "paused";
case JOB_DONE: return "done";
default: return "error";
}
}
/* List every download in the pool (queued/active/paused/done/error) with live
bytes = committed done_bytes + in-flight piece bytes. */
static char *
build_downloads_json(void) {
jbuf_t j = {0};
pthread_mutex_lock(&g_pool.mtx);
jbuf_append(&j, "[");
int first = 1;
for (dl_job_t *job = g_pool.jobs; job; job = job->next) {
long long bytes = job->done_bytes, total = job->total;
int progress;
if (job == g_pool.active)
for (int s = 0; s < g_pool.n_workers; s++)
bytes += g_pool.inflight_bytes[s];
if (total > 0) {
progress = (int)((bytes * 100) / total);
if (progress < 0) progress = 0;
if (progress > 100) progress = 100;
} else {
progress = (job->state == JOB_DONE) ? 100 : 0;
}
if (!first) jbuf_append(&j, ",");
first = 0;
jbuf_append(&j, "{");
jbuf_append(&j, "\"title_id\":"); jbuf_append_str(&j, job->title_id);
jbuf_append(&j, ",\"name\":"); jbuf_append_str(&j, job->name);
jbuf_append(&j, ",\"version\":"); jbuf_append_str(&j, job->version);
jbuf_append(&j, ",\"state\":"); jbuf_append_str(&j, job_state_str(job->state));
jbuf_appendf(&j, ",\"progress\":%d", progress);
jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total);
jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld",
job->rc, job->last_curl_rc, job->last_http_code);
jbuf_append(&j, "}");
}
jbuf_append(&j, "]");
pthread_mutex_unlock(&g_pool.mtx);
return j.buf;
}
/* ---------- verxml background fetch ------------------------------------ */
/* Remove a downloaded patch once the game is at/past that version, i.e. the
install completed. Patches stay internal (/data/patchdl) and are cleaned up
here at the next scan — not during the async install, which still reads the
file. */
static void
title_pkg_path(const char *title_id, const char *patch_url,
char *out, size_t out_sz);
static void
cleanup_installed_download(const char *title_id, const char *patch_url) {
char dir[256], path[320];
if (!path_segment_safe(title_id))
return;
/* Never delete a directory the download pool still owns (a queued/active/
paused job for this title would have its fd/sidecar pulled out). */
pthread_mutex_lock(&g_pool.mtx);
for (dl_job_t *j = g_pool.jobs; j; j = j->next)
if (!strcmp(j->title_id, title_id)) {
pthread_mutex_unlock(&g_pool.mtx);
return;
}
pthread_mutex_unlock(&g_pool.mtx);
snprintf(dir, sizeof(dir), "/data/patchdl/%s", title_id);
/* Reuse title_pkg_path so the basename is validated the same way as on
download — a basename with .. or delimiters falls back to "<tid>.pkg"
and we don't end up unlinking outside the title directory. */
title_pkg_path(title_id, patch_url, path, sizeof(path));
unlink(path);
rmdir(dir);
}
static void *
verxml_fetch_thread(void *arg) {
(void)arg;
for (size_t i = 0; i < g_title_count; i++) {
patchdl_title_t *t = &g_titles[i];
char url[512] = {0};
patchdl_verinfo_t info;
if (g_verxml_stop) break; /* shutdown: stop before the next blocking query */
memset(&info, 0, sizeof(info));
/* The version.xml URL comes straight from the app.db (UUID included);
fall back to the resolver only if the app.db had no entry. */
if (t->version_file_uri[0])
patchdl_verxml_query(t->version_file_uri, g_fw.bin, &info);
else if (!patchdl_resolve_url(t->title_id, url, sizeof(url)))
patchdl_verxml_query(url, g_fw.bin, &info);
pthread_mutex_lock(&g_mutex);
strncpy(t->compatible_version, info.compatible_version,
sizeof(t->compatible_version) - 1);
strncpy(t->latest_version, info.latest_version,
sizeof(t->latest_version) - 1);
strncpy(t->latest_required_fw, info.latest_required_fw,
sizeof(t->latest_required_fw) - 1);
strncpy(t->patch_url, info.compatible_url,
sizeof(t->patch_url) - 1);
strncpy(t->delta_url, info.delta_url,
sizeof(t->delta_url) - 1);
strncpy(t->patch_title_id, info.compatible_title,
sizeof(t->patch_title_id) - 1);
strncpy(t->patch_storage_title_id, info.compatible_storage_title,
sizeof(t->patch_storage_title_id) - 1);
t->verxml_done = 1;
int up_to_date = (t->installed_version[0] && info.compatible_version[0] &&
strcmp(t->installed_version, info.compatible_version) >= 0);
pthread_mutex_unlock(&g_mutex);
if (up_to_date && info.compatible_url[0])
cleanup_installed_download(t->title_id, info.compatible_url);
}
return NULL;
}
/* ---------- title action helper ---------------------------------------- */
/* Snapshot the fields an action needs, under the lock. Returns 0 if found. */
static int
get_title_action_info(const char *title_id, patchdl_source_t *src,
char *patch_url, size_t url_sz,
char *delta_url, size_t durl_sz,
char *patch_title_id, size_t pt_sz,
char *patch_storage_title_id, size_t pst_sz,
char *content_id, size_t ci_sz,
char *name, size_t name_sz,
char *version, size_t ver_sz,
int *enabled) {
int found = 0;
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (!strcmp(g_titles[i].title_id, title_id)) {
*src = g_titles[i].source_type;
strncpy(patch_url, g_titles[i].patch_url, url_sz - 1);
patch_url[url_sz - 1] = '\0';
strncpy(delta_url, g_titles[i].delta_url, durl_sz - 1);
delta_url[durl_sz - 1] = '\0';
strncpy(patch_title_id, g_titles[i].patch_title_id, pt_sz - 1);
patch_title_id[pt_sz - 1] = '\0';
strncpy(patch_storage_title_id, g_titles[i].patch_storage_title_id, pst_sz - 1);
patch_storage_title_id[pst_sz - 1] = '\0';
strncpy(content_id, g_titles[i].content_id, ci_sz - 1);
content_id[ci_sz - 1] = '\0';
strncpy(name, g_titles[i].name, name_sz - 1);
name[name_sz - 1] = '\0';
strncpy(version, g_titles[i].compatible_version, ver_sz - 1);
version[ver_sz - 1] = '\0';
*enabled = g_titles[i].enabled;
found = 1;
break;
}
}
pthread_mutex_unlock(&g_mutex);
return found;
}
/* Persist a per-title enable/disable toggle. */
static enum MHD_Result
set_title_enabled(struct MHD_Connection *conn, const char *title_id, int en) {
int found = 0;
char r[64];
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (!strcmp(g_titles[i].title_id, title_id)) {
g_titles[i].enabled = en;
found = 1;
break;
}
}
pthread_mutex_unlock(&g_mutex);
if (!found) return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
save_config();
snprintf(r, sizeof(r), "{\"ok\":true,\"enabled\":%s}", en ? "true" : "false");
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
}
/* Local on-disk path a title's patch downloads to / installs from. The
basename comes from the patch_url (CDN-controlled), so it MUST be
validated — a malicious or corrupted version.xml could otherwise yield
"..", an empty string, or a path with delimiters that escape the title
directory when concatenated. Fallback to a deterministic per-title name
on any rejection so file ops still land somewhere safe. */
static void
title_pkg_path(const char *title_id, const char *patch_url,
char *out, size_t out_sz) {
const char *base = strrchr(patch_url, '/');
char name[192];
size_t n;
base = base ? base + 1 : "";
snprintf(name, sizeof(name), "%s", base);
n = strlen(name);
if (n > 5 && !strcmp(name + n - 5, ".json"))
snprintf(name + n - 5, sizeof(name) - (n - 5), ".pkg");
if (!name[0] || !path_segment_safe(name)) {
snprintf(name, sizeof(name), "%s.pkg",
path_segment_safe(title_id) ? title_id : "patch");
}
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, name);
}
static int
url_is_manifest(const char *url) {
size_t n = url ? strlen(url) : 0;
return n > 5 && !strcmp(url + n - 5, ".json");
}
/* ---------- resume sidecar + pool helpers ------------------------------- */
static void
title_state_path(const char *title_id, char *out, size_t sz) {
snprintf(out, sz, "%s/%s/state.json", PATCHDL_DL_DIR, title_id);
}
static void
remove_dl_state(const char *title_id) {
char path[320];
title_state_path(title_id, path, sizeof(path));
unlink(path);
}
/* Delete a title's internal download directory and its contents. Best-effort;
only ever touches /data/patchdl (self-protecting against a bad segment). */
static void
remove_title_dir(const char *title_id) {
char dir[288], path[560];
DIR *d;
struct dirent *e;
if (!path_segment_safe(title_id)) return;
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
if ((d = opendir(dir))) {
while ((e = readdir(d))) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, ".."))
continue;
snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
unlink(path);
}
closedir(d);
}
rmdir(dir);
}
/* Mirror the resumable flag into g_titles (read by /api/titles). Takes g_mutex,
so it must be called with the POOL lock NOT held (lock order: g_mutex first). */
static void
set_title_resumable(const char *title_id, int resumable, long long bytes) {
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, title_id)) {
g_titles[i].resumable = resumable;
g_titles[i].partial_bytes = bytes;
break;
}
pthread_mutex_unlock(&g_mutex);
}
static long long
json_get_ll(const char *s, const char *key, long long dflt) {
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(s, pat);
if (!p) return dflt;
p = strchr(p + strlen(pat), ':');
if (!p) return dflt;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p < '0' || *p > '9') return dflt;
return strtoll(p, NULL, 10);
}
static void
bitmap_to_hex(const unsigned char *bm, int nbytes, char *out, size_t sz) {
static const char hx[] = "0123456789abcdef";
int i = 0;
for (; i < nbytes && (size_t)(2 * i + 2) < sz; i++) {
out[2 * i] = hx[(bm[i] >> 4) & 0xf];
out[2 * i + 1] = hx[bm[i] & 0xf];
}
out[2 * i] = '\0';
}
static void
hex_to_bitmap(const char *hex, unsigned char *bm, int nbytes) {
for (int i = 0; i < nbytes; i++) {
char a = hex[2 * i], b = a ? hex[2 * i + 1] : 0;
int hi, lo;
if (!a || !b) break;
hi = (a <= '9') ? a - '0' : (a | 32) - 'a' + 10;
lo = (b <= '9') ? b - '0' : (b | 32) - 'a' + 10;
bm[i] = (unsigned char)((hi << 4) | lo);
}
}
/* Write the job's resume sidecar atomically. Each set bit's bytes were already
fdatasync'd in patchdl_http_download_piece, so a set bit implies durable data.
Called with the pool lock held (the write is tiny). */
static void
write_job_state(const dl_job_t *job) {
char path[320], tmp[340], *hex;
int nbytes = (job->mf.count + 7) / 8;
FILE *f;
if (!job->bitmap) return;
title_state_path(job->title_id, path, sizeof(path));
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
hex = malloc((size_t)nbytes * 2 + 1);
if (!hex) return;
bitmap_to_hex(job->bitmap, nbytes, hex, (size_t)nbytes * 2 + 1);
f = fopen(tmp, "w");
if (f) {
fprintf(f,
"{\"manifest_url\":\"%s\",\"total\":%lld,\"piece_count\":%d,"
"\"done_bytes\":%lld,\"done\":\"%s\"}\n",
job->manifest_url, job->total, job->mf.count, job->done_bytes, hex);
fclose(f);
rename(tmp, path);
}
free(hex);
}
/* Seed a freshly-admitted job's piece states from its resume sidecar — only if
it belongs to THIS manifest (same url + size + piece count). Sets DONE bits,
ps[] states and done_bytes. Called with the pool lock held. */
static void
seed_from_sidecar(dl_job_t *job) {
/* 8 KB sidecar buffer: 4096-piece cap × 2 hex chars / 8 bits = 1024 hex
chars for the bitmap, plus the JSON wrapper and the up-to-768-byte
manifest_url — fits with room. Halved from 16 KB to lighten the
per-admit stack frame on the pool worker. */
char path[320], buf[8192], murl[768];
FILE *f;
size_t n;
int nbytes = (job->mf.count + 7) / 8;
title_state_path(job->title_id, path, sizeof(path));
f = fopen(path, "r");
if (!f) return;
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
json_get_str(buf, "manifest_url", murl, sizeof(murl));
if (strcmp(murl, job->manifest_url) ||
json_get_ll(buf, "total", -1) != job->total ||
json_get_int(buf, "piece_count", -1) != job->mf.count)
return; /* stale / different patch -> start fresh */
{
size_t hexsz = (size_t)nbytes * 2 + 1;
char *hex = malloc(hexsz);
if (!hex) return;
json_get_str(buf, "done", hex, hexsz);
hex_to_bitmap(hex, job->bitmap, nbytes);
free(hex);
}
for (int i = 0; i < job->mf.count; i++)
if (job->bitmap[i / 8] & (1 << (i % 8))) {
job->ps[i].state = PC_DONE;
job->done_bytes += job->mf.pieces[i].size;
}
}
/* ---------- pool scheduler + workers ------------------------------------ */
static int
pick_pending_piece(const dl_job_t *job) {
/* pieces are stored in ascending offset order -> first PENDING is lowest */
for (int i = 0; i < job->mf.count; i++)
if (job->ps[i].state == PC_PENDING) return i;
return -1;
}
/* Unlink a job from the list and free it. Pool lock held. */
static void
free_job_locked(dl_job_t *job) {
dl_job_t **pp = &g_pool.jobs;
while (*pp && *pp != job) pp = &(*pp)->next;
if (*pp) *pp = job->next;
if (job->fd >= 0) close(job->fd);
patchdl_manifest_free(&job->mf);
free(job->ps);
free(job->bitmap);
free(job);
}
/* Cancel finalize: delete the file + sidecar and drop the job. Pool lock held
on entry/exit; dropped briefly for set_title_resumable (g_mutex). */
static void
finalize_cancel_locked(dl_job_t *job) {
char title_id[32], dir[288], dest[320];
snprintf(title_id, sizeof title_id, "%s", job->title_id);
snprintf(dir, sizeof dir, "%s", job->dir);
snprintf(dest, sizeof dest, "%s", job->dest);
if (g_pool.active == job) g_pool.active = NULL;
free_job_locked(job); /* closes fd, frees job */
unlink(dest);
remove_dl_state(title_id);
rmdir(dir);
pthread_mutex_unlock(&g_pool.mtx);
set_title_resumable(title_id, 0, 0);
pthread_mutex_lock(&g_pool.mtx);
}
/* Pause finalize: keep the partial, persist the bitmap, mark resumable. The job
stays in the list as JOB_PAUSED. Pool lock held on entry/exit. */
static void
finalize_pause_locked(dl_job_t *job) {
char title_id[32];
long long done;
snprintf(title_id, sizeof title_id, "%s", job->title_id);
job->abort = 0;
write_job_state(job);
if (job->fd >= 0) { close(job->fd); job->fd = -1; }
job->state = JOB_PAUSED;
if (g_pool.active == job) g_pool.active = NULL;
done = job->done_bytes;
pthread_mutex_unlock(&g_pool.mtx);
set_title_resumable(title_id, done > 0, done);
pthread_mutex_lock(&g_pool.mtx);
}
/* Settle an active job whose pieces are all done or some failed. Pool lock held
on entry/exit. */
static void
settle_active_locked(dl_job_t *job) {
char title_id[32], dir[288], dest[320];
snprintf(title_id, sizeof title_id, "%s", job->title_id);
snprintf(dir, sizeof dir, "%s", job->dir);
snprintf(dest, sizeof dest, "%s", job->dest);
if (g_pool.active == job) g_pool.active = NULL;
if (job->fd >= 0) { close(job->fd); job->fd = -1; }
if (job->pieces_failed == 0 && job->pieces_done == job->mf.count) {
/* complete: keep the .pkg, drop the resume sidecar */
job->state = JOB_DONE;
remove_dl_state(title_id);
pthread_mutex_unlock(&g_pool.mtx);
set_title_resumable(title_id, 0, 0);
pthread_mutex_lock(&g_pool.mtx);
} else if (job->rc == -2) {
/* corrupt (failed SHA-256): delete, not resumable */
job->state = JOB_FAILED;
write_job_state(job); /* harmless; overwritten by delete below */
unlink(dest);
remove_dl_state(title_id);
rmdir(dir);
pthread_mutex_unlock(&g_pool.mtx);
set_title_resumable(title_id, 0, 0);
pthread_mutex_lock(&g_pool.mtx);
} else {
/* network failure on a piece after retries: keep partial, resumable */
long long done = job->done_bytes;
job->state = JOB_FAILED;
write_job_state(job);
pthread_mutex_unlock(&g_pool.mtx);
set_title_resumable(title_id, done > 0, done);
pthread_mutex_lock(&g_pool.mtx);
}
}
/* Promote the head QUEUED job to ACTIVE: fetch+parse manifest, preallocate the
file, seed the resume bitmap. Network/disk I/O runs with the pool lock
DROPPED. Pool lock held on entry/exit. */
static void
admit_next(void) {
dl_job_t *q = NULL;
patchdl_manifest_t mf;
char murl[768], dest[320], dir[288];
int is_manifest, fd = -1, ok = 0;
long long total = 0, old_size = 0;
dl_pstate_t *ps = NULL;
unsigned char *bitmap = NULL;
for (dl_job_t *j = g_pool.jobs; j; j = j->next)
if (j->state == JOB_QUEUED) { q = j; break; }
if (!q) return;
g_pool.admitting = 1;
q->state = JOB_ADMITTING; /* NOT claimable; a cancel/pause mid-admit only
flags it — admit_next is the sole finalizer.
g_pool.active stays NULL until fully built. */
is_manifest = q->is_manifest;
snprintf(murl, sizeof murl, "%s", q->manifest_url);
snprintf(dest, sizeof dest, "%s", q->dest);
snprintf(dir, sizeof dir, "%s", q->dir);
pthread_mutex_unlock(&g_pool.mtx);
/* ---- network + disk, NO pool lock ---- */
memset(&mf, 0, sizeof mf);
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
if (is_manifest) {
if (patchdl_fetch_manifest(murl, &mf) == 0 && mf.count > 0) {
total = mf.total;
fd = open(dest, O_RDWR | O_CREAT, 0666);
if (fd >= 0) {
struct stat st;
if (fstat(fd, &st) == 0) old_size = (long long)st.st_size;
if (ftruncate(fd, (off_t)total) == 0) ok = 1;
else { close(fd); fd = -1; }
}
}
} else {
mf.pieces = calloc(1, sizeof(patchdl_piece_t));
if (mf.pieces) {
mf.pieces[0].url = strdup(murl);
mf.pieces[0].offset = 0;
mf.pieces[0].size = 0; /* unknown -> size check skipped */
mf.count = 1; mf.total = 0; total = 0;
fd = open(dest, O_RDWR | O_CREAT | O_TRUNC, 0666);
if (fd >= 0 && mf.pieces[0].url) ok = 1;
else if (fd >= 0) { close(fd); fd = -1; }
}
}
if (ok) {
ps = calloc((size_t)mf.count, sizeof(dl_pstate_t));
bitmap = calloc((size_t)((mf.count + 7) / 8), 1);
if (!ps || !bitmap) ok = 0;
}
pthread_mutex_lock(&g_pool.mtx);
g_pool.admitting = 0;
/* During the unlocked I/O window do_cancel/do_pause may have flagged q
(it stays JOB_ADMITTING otherwise). The job was never published as
g_pool.active, so no worker touched it and q is still valid here. */
if (!ok || q->state == JOB_CANCELLING) {
if (fd >= 0) close(fd);
patchdl_manifest_free(&mf);
free(ps);
free(bitmap);
if (q->state == JOB_CANCELLING) finalize_cancel_locked(q);
else { q->state = JOB_FAILED; q->rc = -1; }
pthread_cond_broadcast(&g_pool.cv);
return;
}
/* Resume of a previously paused job reuses the same dl_job_t, which still
carries the prior mf/ps/bitmap and a committed done_bytes. Free the stale
buffers and zero the committed counters before attaching the fresh ones so
the sidecar bitmap is the single source of truth (no leak, no double-count).
Harmless on a fresh job: mf is zeroed, ps/bitmap are NULL, free(NULL) is ok. */
patchdl_manifest_free(&q->mf);
free(q->ps); q->ps = NULL;
free(q->bitmap); q->bitmap = NULL;
q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0;
q->last_curl_rc = 0; q->last_http_code = 0;
q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total;
for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1;
seed_from_sidecar(q);
q->pieces_done = 0;
for (int i = 0; i < mf.count; i++)
if (q->ps[i].state == PC_DONE) q->pieces_done++;
/* One-time migration: an old sequential partial (written in order, no
per-piece bitmap) is a contiguous prefix on disk. If the bitmap seed found
nothing and the file is a partial (smaller than the full preallocated
size), mark every piece fully within the on-disk bytes as done so we don't
re-download what's already there. The partial frontier piece is excluded. */
if (q->pieces_done == 0 && old_size > 0 && old_size < total) {
for (int i = 0; i < mf.count; i++)
if (mf.pieces[i].offset + mf.pieces[i].size <= old_size) {
q->ps[i].state = PC_DONE;
q->bitmap[i / 8] |= (unsigned char)(1 << (i % 8));
q->done_bytes += mf.pieces[i].size;
q->pieces_done++;
}
if (q->pieces_done > 0) write_job_state(q); /* persist the migrated bitmap */
}
/* A pause that landed during admit: persist the seeded/migrated bitmap and
park as PAUSED (keeping the on-disk partial) rather than starting the
transfer. finalize_pause_locked writes the sidecar and closes the fd. */
if (q->state == JOB_PAUSING) {
finalize_pause_locked(q);
pthread_cond_broadcast(&g_pool.cv);
return;
}
q->state = JOB_ACTIVE;
g_pool.active = q;
pthread_cond_broadcast(&g_pool.cv);
}
static void *
dl_worker(void *arg) {
int slot = (int)(intptr_t)arg;
for (;;) {
dl_job_t *job;
int pidx = -1;
char url[768], hash[80];
long long off = 0, sz = 0;
unsigned int my_seq = 0;
int fd = -1, verify = 0, rc;
volatile int *abort_ptr = NULL;
pthread_mutex_lock(&g_pool.mtx);
for (;;) {
if (g_pool.stopping) break;
/* Live connection limit: a worker above the configured count idles
here (claims no new piece, doesn't admit). Lowering active_conns
lets in-flight pieces finish first, then those workers park here;
raising it wakes them to start pulling pieces — no restart. */
if (slot >= g_pool.active_conns) {
pthread_cond_wait(&g_pool.cv, &g_pool.mtx);
continue;
}
job = g_pool.active;
if (!job) {
if (!g_pool.admitting) {
int queued = 0;
for (dl_job_t *j = g_pool.jobs; j; j = j->next)
if (j->state == JOB_QUEUED) { queued = 1; break; }
if (queued) { admit_next(); continue; }
}
pthread_cond_wait(&g_pool.cv, &g_pool.mtx);
continue;
}
if (job->state == JOB_ACTIVE) {
pidx = pick_pending_piece(job);
if (pidx >= 0) break; /* claim below */
if (job->inflight == 0) { settle_active_locked(job); pthread_cond_broadcast(&g_pool.cv); continue; }
pthread_cond_wait(&g_pool.cv, &g_pool.mtx);
continue;
}
if (job->state == JOB_PAUSING || job->state == JOB_CANCELLING) {
if (job->inflight == 0) {
if (job->state == JOB_PAUSING) finalize_pause_locked(job);
else finalize_cancel_locked(job);
pthread_cond_broadcast(&g_pool.cv);
continue;
}
pthread_cond_wait(&g_pool.cv, &g_pool.mtx);
continue;
}
pthread_cond_wait(&g_pool.cv, &g_pool.mtx);
}
if (g_pool.stopping) { pthread_mutex_unlock(&g_pool.mtx); break; }
/* claim piece pidx */
job->ps[pidx].state = PC_INFLIGHT;
job->ps[pidx].slot = slot;
job->inflight++;
my_seq = job->seq;
fd = job->fd;
verify = job->verify;
off = job->mf.pieces[pidx].offset;
sz = job->mf.pieces[pidx].size;
abort_ptr = &job->abort;
snprintf(url, sizeof url, "%s", job->mf.pieces[pidx].url);
snprintf(hash, sizeof hash, "%s", job->mf.pieces[pidx].hash);
g_pool.inflight_bytes[slot] = 0;
pthread_mutex_unlock(&g_pool.mtx);
/* ---- download the piece, NO lock ---- */
int last_curl_rc = 0;
long last_http_code = 0;
{
patchdl_piece_ctx_t ctx = { &g_pool.inflight_bytes[slot], abort_ptr };
rc = patchdl_http_download_piece(url, fd, off, sz,
verify && hash[0] ? hash : NULL, &ctx,
&last_curl_rc, &last_http_code);
}
pthread_mutex_lock(&g_pool.mtx);
g_pool.inflight_bytes[slot] = 0;
job->inflight--;
if (rc != 0) {
job->last_curl_rc = last_curl_rc;
job->last_http_code = last_http_code;
}
job->ps[pidx].slot = -1;
if (my_seq != job->seq) {
/* job cancelled/torn down under us: discard result (do not touch
bitmap/counters); the finalizer runs once inflight hits 0 */
} else if (rc == 0) {
job->ps[pidx].state = PC_DONE;
job->pieces_done++;
job->done_bytes += sz;
job->bitmap[pidx / 8] |= (unsigned char)(1 << (pidx % 8));
/* Persist after every completed piece: the bytes were already
fdatasync'd, and a tiny atomic sidecar write means an unclean
kill re-downloads only the pieces still in flight, not a batch
of up-to-8 already-finished ones. */
write_job_state(job);
} else if (job->abort &&
(job->state == JOB_PAUSING || job->state == JOB_CANCELLING)) {
job->ps[pidx].state = PC_PENDING; /* aborted by pause -> redo on resume */
} else if (rc == -2) {
job->ps[pidx].state = PC_FAILED; job->pieces_failed++; job->rc = -2;
} else {
job->ps[pidx].attempts++;
if (job->ps[pidx].attempts < 4) job->ps[pidx].state = PC_PENDING;
else { job->ps[pidx].state = PC_FAILED; job->pieces_failed++; job->rc = -1; }
}
pthread_cond_broadcast(&g_pool.cv);
pthread_mutex_unlock(&g_pool.mtx);
}
return NULL;
}
/* ---------- HTTP handlers (enqueue/pause/cancel) ------------------------ */
/* Enqueue a download. Returns 202 immediately; the pool admits + downloads it
across N connections. De-dupes by title_id; resumes a paused job.
When patch_url is a manifest JSON and delta_url is set, downloads the DP.pkg
bootstrap directly (44 MB) instead of the multi-piece 66 GB split package. */
static enum MHD_Result
do_download(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url, const char *delta_url,
const char *name, const char *version, int enabled) {
dl_job_t *job, *existing = NULL;
int count = 0, verify;
if (!enabled)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"title_disabled\"}");
if (src == PATCHDL_SOURCE_UNKNOWN)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_unknown\"}");
if (!patch_url[0])
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
/* Snapshot the g_mutex-guarded flag before taking the pool lock (lock order
is g_mutex-before-g_pool, so we cannot read it while holding g_pool.mtx). */
pthread_mutex_lock(&g_mutex);
verify = g_cfg.verify_downloads;
pthread_mutex_unlock(&g_mutex);
pthread_mutex_lock(&g_pool.mtx);
for (job = g_pool.jobs; job; job = job->next) {
count++;
if (!strcmp(job->title_id, title_id)) existing = job;
}
if (existing) {
job_state_t st = existing->state;
if (st == JOB_QUEUED || st == JOB_ADMITTING ||
st == JOB_ACTIVE || st == JOB_PAUSING) {
pthread_mutex_unlock(&g_pool.mtx);
return queue_json(conn, MHD_HTTP_OK,
"{\"ok\":true,\"queued\":true,\"already\":true}");
}
if (st == JOB_CANCELLING) { /* mid-cancel teardown — don't touch it */
pthread_mutex_unlock(&g_pool.mtx);
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"cancelling\"}");
}
if (st == JOB_PAUSED) { /* resume */
existing->state = JOB_QUEUED;
pthread_cond_broadcast(&g_pool.cv);
pthread_mutex_unlock(&g_pool.mtx);
return queue_json(conn, MHD_HTTP_OK,
"{\"ok\":true,\"queued\":true,\"resumed\":true}");
}
if (st == JOB_DONE) { /* already downloaded */
pthread_mutex_unlock(&g_pool.mtx);
return queue_json(conn, MHD_HTTP_OK,
"{\"ok\":true,\"downloaded\":true,\"already\":true}");
}
/* FAILED: drop it and re-enqueue fresh below */
free_job_locked(existing);
count--;
}
if (count >= POOL_MAX_JOBS) {
pthread_mutex_unlock(&g_pool.mtx);
return queue_json(conn, MHD_HTTP_SERVICE_UNAVAILABLE,
"{\"ok\":false,\"reason\":\"queue_full\"}");
}
job = calloc(1, sizeof(*job));
if (!job) {
pthread_mutex_unlock(&g_pool.mtx);
return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
}
snprintf(job->title_id, sizeof job->title_id, "%s", title_id);
snprintf(job->name, sizeof job->name, "%s", name && name[0] ? name : title_id);
snprintf(job->version, sizeof job->version, "%s", version ? version : "");
{
snprintf(job->manifest_url, sizeof job->manifest_url, "%s", patch_url);
snprintf(job->dir, sizeof job->dir, "%s/%s", PATCHDL_DL_DIR, title_id);
title_pkg_path(title_id, patch_url, job->dest, sizeof job->dest);
job->is_manifest = url_is_manifest(patch_url);
}
job->verify = verify;
job->state = JOB_QUEUED;
job->fd = -1;
/* append to keep FIFO queue order */
{
dl_job_t **pp = &g_pool.jobs;
while (*pp) pp = &(*pp)->next;
*pp = job;
}
pthread_cond_broadcast(&g_pool.cv);
pthread_mutex_unlock(&g_pool.mtx);
return queue_json(conn, MHD_HTTP_ACCEPTED, "{\"ok\":true,\"queued\":true}");
}
/* Pause: keep the partial (resumable). The active job aborts; a queued job is
just parked. */
static enum MHD_Result
do_pause(struct MHD_Connection *conn, const char *title_id) {
dl_job_t *job;
int acted = 0;
pthread_mutex_lock(&g_pool.mtx);
for (job = g_pool.jobs; job; job = job->next) {
if (strcmp(job->title_id, title_id)) continue;
if (job->state == JOB_ACTIVE || job->state == JOB_ADMITTING) {
/* ADMITTING: admit_next sees JOB_PAUSING on relock and parks it
PAUSED, keeping the partial — no transfer is started. */
job->state = JOB_PAUSING;
job->abort = 1;
pthread_cond_broadcast(&g_pool.cv);
acted = 1;
} else if (job->state == JOB_QUEUED) {
job->state = JOB_PAUSED;
acted = 1;
}
break;
}
pthread_mutex_unlock(&g_pool.mtx);
if (!acted)
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"not_downloading\"}");
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"paused\":true}");
}
/* Cancel: stop AND delete. The active job's workers abort and a worker finalizes
the delete once in-flight pieces drain; an idle/queued/paused/done job is
deleted directly; a leftover on-disk partial (no job) is removed too. */
static enum MHD_Result
do_cancel(struct MHD_Connection *conn, const char *title_id) {
dl_job_t *job;
int had_job = 0;
pthread_mutex_lock(&g_pool.mtx);
for (job = g_pool.jobs; job; job = job->next) {
if (strcmp(job->title_id, title_id)) continue;
had_job = 1;
if (job->state == JOB_ADMITTING) {
/* being admitted (lock dropped for manifest I/O): only flag it.
admit_next holds a raw pointer to this job and is the sole
finalizer once it relocks — freeing here would be a UAF. */
job->state = JOB_CANCELLING;
job->seq++;
job->abort = 1;
pthread_cond_broadcast(&g_pool.cv);
} else if (job->state == JOB_ACTIVE || job->state == JOB_PAUSING) {
job->state = JOB_CANCELLING;
job->seq++; /* discard late piece completions */
job->abort = 1;
pthread_cond_broadcast(&g_pool.cv);
if (job->inflight == 0) finalize_cancel_locked(job);
} else {
/* QUEUED / PAUSED / DONE / FAILED: no in-flight pieces */
finalize_cancel_locked(job);
}
break;
}
pthread_mutex_unlock(&g_pool.mtx);
if (!had_job) {
/* a partial left on disk from a previous boot has no live job */
remove_title_dir(title_id);
set_title_resumable(title_id, 0, 0);
}
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"cancelled\":true}");
}
static enum MHD_Result
do_install(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url, const char *delta_url,
const char *patch_title_id, const char *patch_storage_title_id,
const char *content_id, int enabled) {
char dest[320], msg[256], resp[720];
int rc;
if (!enabled)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"title_disabled\"}");
/* Source policy: only genuine installs may be patched in place.
Shadowmounts are download-only; unknown/preinstall are blocked. */
if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"install_not_allowed_for_source\"}");
if (!patch_url[0] && !delta_url[0])
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
/* GUARD (app layer): the version.xml target title id must match the game. */
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"patch_title_mismatch\","
"\"patch_title_id\":\"%.15s\",\"title_id\":\"%.15s\"}",
patch_title_id, title_id);
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
}
/* Shared-master: CDN stores the patch under a different regional title id.
The assembled PKG (from the manifest download) embeds the game's own
content_id, so AppInstUtil routes the install to the right title slot.
sceAppInstUtilInstallByPackage is unavailable in our process context
(0x80B21163), so only sceAppInstUtilAppInstallPkg works here. */
if (patch_storage_title_id[0] &&
strncmp(patch_storage_title_id, title_id, 9) != 0) {
char assembled_local[320] = {0};
struct stat assembled_st;
title_pkg_path(title_id, patch_url, assembled_local, sizeof(assembled_local));
if (!assembled_local[0] || stat(assembled_local, &assembled_st) != 0) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"pkg_not_downloaded\","
"\"hint\":\"download_required\",\"path\":\"%.319s\"}",
assembled_local);
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
}
rc = patchdl_install_app_pkg(assembled_local, title_id, content_id,
msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%.319s\"}",
rc == 0 ? "true" : "false", rc, msg, assembled_local);
return queue_json_owned(conn,
rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
rc = patchdl_install_app_pkg(dest, title_id, content_id, msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}",
rc == 0 ? "true" : "false", rc, msg, dest);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
/* ---------- request routing -------------------------------------------- */
static int
parse_title_action(const char *url, char *title_id, size_t tid_sz,
char *action, size_t act_sz) {
const char *prefix = "/api/titles/";
const char *start, *slash;
size_t len;
if (strncmp(url, prefix, strlen(prefix))) return -1;
start = url + strlen(prefix);
slash = strchr(start, '/');
if (!slash || slash == start || !slash[1]) return -1;
len = (size_t)(slash - start);
if (len >= tid_sz) return -1;
memcpy(title_id, start, len);
title_id[len] = '\0';
len = strlen(slash + 1);
if (len >= act_sz) return -1;
memcpy(action, slash + 1, len + 1);
return 0;
}
static enum MHD_Result
handle_title_action(struct MHD_Connection *conn, const char *url) {
char title_id[32];
char action[24];
char patch_url[512] = {0};
char delta_url[512] = {0};
char patch_title_id[16] = {0};
char patch_storage_title_id[16] = {0};
char content_id[64] = {0};
char name[128] = {0};
char version[16] = {0};
int enabled = 0;
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
if (parse_title_action(url, title_id, sizeof(title_id),
action, sizeof(action)))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
/* Defense in depth: title_id becomes part of /data/patchdl/<id> paths that
get created, written, and recursively deleted. Reject anything that
isn't a plain id BEFORE it can reach mkdir/unlink/rmdir, so no request
can ever escape the download directory (a title_id of ".." would resolve
the dir to /data). Real PS5 title ids only use [A-Za-z0-9_-.]. */
if (!path_segment_safe(title_id))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
if (!get_title_action_info(title_id, &src,
patch_url, sizeof(patch_url),
delta_url, sizeof(delta_url),
patch_title_id, sizeof(patch_title_id),
patch_storage_title_id,
sizeof(patch_storage_title_id),
content_id, sizeof(content_id),
name, sizeof(name),
version, sizeof(version),
&enabled))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
if (!strcmp(action, "enable"))
return set_title_enabled(conn, title_id, 1);
if (!strcmp(action, "disable"))
return set_title_enabled(conn, title_id, 0);
if (!strcmp(action, "download"))
return do_download(conn, title_id, src, patch_url, delta_url,
name, version, enabled);
if (!strcmp(action, "cancel"))
return do_cancel(conn, title_id);
if (!strcmp(action, "pause"))
return do_pause(conn, title_id);
if (!strcmp(action, "check"))
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
if (!strcmp(action, "install"))
return do_install(conn, title_id, src, patch_url, delta_url,
patch_title_id, patch_storage_title_id,
content_id, enabled);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
/* ---------- POST body accumulation ------------------------------------- */
/* MHD delivers a POST body across several callback invocations. We stash a
growing buffer in con_cls and dispatch once the body is complete. The
largest legitimate body we accept is the config JSON or a small install
request — a few hundred bytes; 64 KiB leaves ample headroom while keeping
a misbehaving LAN client from forcing unbounded allocations. */
#define PATCHDL_POST_MAX_BYTES (64 * 1024)
typedef struct {
char *data;
size_t len;
int oversized;
} post_body_t;
static void
request_completed(void *cls, struct MHD_Connection *conn, void **con_cls,
enum MHD_RequestTerminationCode toe) {
(void)cls; (void)conn; (void)toe;
post_body_t *pb = *con_cls;
if (pb) {
free(pb->data);
free(pb);
*con_cls = NULL;
}
}
static enum MHD_Result
handle_config_post(struct MHD_Connection *conn, const char *body) {
char pol[8];
int mc;
int tile_just_enabled = 0;
json_get_str(body, "default_policy", pol, sizeof(pol));
/* Only the two real values are accepted; anything else is silently
dropped so a malformed POST can't corrupt config.json or the JSON
we later round-trip out of /api/config. */
if (pol[0] && strcmp(pol, "allow") != 0 && strcmp(pol, "deny") != 0)
pol[0] = '\0';
pthread_mutex_lock(&g_mutex);
if (pol[0]) {
strncpy(g_cfg.default_policy, pol, sizeof(g_cfg.default_policy) - 1);
g_cfg.default_policy[sizeof(g_cfg.default_policy) - 1] = '\0';
}
g_cfg.install_after_download =
json_get_bool(body, "install_after_download", g_cfg.install_after_download);
g_cfg.delete_pkg_after_install =
json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install);
g_cfg.verify_downloads =
json_get_bool(body, "verify_downloads", g_cfg.verify_downloads);
{
int prev_tile = g_cfg.home_shortcut;
g_cfg.home_shortcut =
json_get_bool(body, "home_shortcut", g_cfg.home_shortcut);
tile_just_enabled = (!prev_tile && g_cfg.home_shortcut);
}
g_cfg.max_connections =
json_get_int(body, "max_connections", g_cfg.max_connections);
if (g_cfg.max_connections < 1) g_cfg.max_connections = 1;
if (g_cfg.max_connections > POOL_MAX_CONN) g_cfg.max_connections = POOL_MAX_CONN;
mc = g_cfg.max_connections;
pthread_mutex_unlock(&g_mutex);
/* Apply the new connection count to the live pool — no restart. Raising it
wakes idle workers to pull pieces immediately; lowering it lets the extra
workers finish their current piece, then they park. (g_mutex released
first to keep the g_mutex-before-g_pool lock order.) */
pthread_mutex_lock(&g_pool.mtx);
g_pool.active_conns = mc;
if (g_pool.active_conns < 1) g_pool.active_conns = 1;
if (g_pool.active_conns > g_pool.n_workers) g_pool.active_conns = g_pool.n_workers;
pthread_cond_broadcast(&g_pool.cv);
pthread_mutex_unlock(&g_pool.mtx);
save_config();
/* Trigger the tile install when the toggle just flipped on. Best-effort —
the config save is already committed; we don't want a tile error to
roll that back. Stat-guard inside the helper makes repeated calls a
no-op, so a save without flipping the toggle still costs nothing. */
if (tile_just_enabled) {
char tile_msg[256];
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
}
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
}
static enum MHD_Result
on_request(void *cls, struct MHD_Connection *conn, const char *url,
const char *method, const char *version, const char *upload_data,
size_t *upload_data_size, void **con_cls) {
(void)cls; (void)version;
if (!strcmp(method, MHD_HTTP_METHOD_OPTIONS))
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
if (!strcmp(method, MHD_HTTP_METHOD_POST)) {
post_body_t *pb = *con_cls;
if (!pb) { /* first call: set up the buffer */
pb = calloc(1, sizeof(*pb));
if (!pb) return MHD_NO;
*con_cls = pb;
return MHD_YES;
}
if (*upload_data_size) { /* a body chunk: append it */
if (!pb->oversized &&
pb->len + *upload_data_size <= PATCHDL_POST_MAX_BYTES) {
char *n = realloc(pb->data, pb->len + *upload_data_size + 1);
if (n) {
memcpy(n + pb->len, upload_data, *upload_data_size);
pb->len += *upload_data_size;
n[pb->len] = '\0';
pb->data = n;
} else {
pb->oversized = 1;
}
} else {
pb->oversized = 1; /* drop further chunks to bound RAM */
}
*upload_data_size = 0;
return MHD_YES;
}
if (pb->oversized)
return queue_json(conn, MHD_HTTP_CONTENT_TOO_LARGE,
"{\"ok\":false,\"reason\":\"body_too_large\"}");
/* final call: the full body (if any) is in pb->data */
const char *body = pb->data ? pb->data : "";
if (!strcmp(url, "/api/config"))
return handle_config_post(conn, body);
if (!strncmp(url, "/api/titles/", 12))
return handle_title_action(conn, url);
if (!strcmp(url, "/api/install_uri")) {
char uri[768] = {0}, cid[64] = {0}, tid[32] = {0};
char msg[256], resp[1100];
int rc;
json_get_str(body, "uri", uri, sizeof(uri));
json_get_str(body, "content_id", cid, sizeof(cid));
json_get_str(body, "title_id", tid, sizeof(tid));
if (!uri[0])
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"uri required\"}");
if (!install_uri_safe(uri))
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"uri_not_allowed\","
"\"hint\":\"https:// only, host must be Sony CDN\"}");
if (tid[0] && !path_segment_safe(tid))
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"title_id_unsafe\"}");
if (cid[0] && !path_segment_safe(cid))
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"content_id_unsafe\"}");
rc = patchdl_install_by_uri(uri, tid[0] ? tid : NULL,
cid[0] ? cid : NULL, msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"rc_hex\":\"0x%08x\",\"message\":\"%s\","
"\"uri\":\"%.511s\",\"content_id\":\"%.63s\",\"title_id\":\"%.31s\"}",
rc == 0 ? "true" : "false", rc, (unsigned)rc, msg,
uri, cid, tid);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
if (!strcmp(url, "/api/install_aip")) {
char path[768] = {0}, cid[64] = {0}, tid[32] = {0};
char msg[256], resp[1100];
int rc;
json_get_str(body, "path", path, sizeof(path));
json_get_str(body, "content_id", cid, sizeof(cid));
json_get_str(body, "title_id", tid, sizeof(tid));
if (!path[0])
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"path required\"}");
if (!local_install_path_safe(path))
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"path_not_allowed\","
"\"hint\":\"must be " PATCHDL_DL_DIR
"/<title_id>/<filename>\"}");
if (tid[0] && !path_segment_safe(tid))
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"title_id_unsafe\"}");
if (cid[0] && !path_segment_safe(cid))
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"content_id_unsafe\"}");
rc = patchdl_install_app_pkg(path, tid[0] ? tid : NULL,
cid[0] ? cid : NULL, msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"rc_hex\":\"0x%08x\",\"message\":\"%s\","
"\"path\":\"%.511s\",\"content_id\":\"%.63s\",\"title_id\":\"%.31s\"}",
rc == 0 ? "true" : "false", rc, (unsigned)rc, msg,
path, cid, tid);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
if (!strcmp(url, "/api/install_tile")) {
char msg[256], resp[320];
int rc = patchdl_tile_install_if_needed(msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\"}",
rc == 0 ? "true" : "false", rc, msg);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
if (strcmp(method, MHD_HTTP_METHOD_GET) &&
strcmp(method, MHD_HTTP_METHOD_HEAD))
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
if (!strcmp(url, "/api/status"))
return queue_json_owned(conn, MHD_HTTP_OK, build_status_json());
if (!strcmp(url, "/api/config"))
return queue_json_owned(conn, MHD_HTTP_OK, build_config_json());
if (!strcmp(url, "/api/titles"))
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
if (!strcmp(url, "/api/downloads"))
return queue_json_owned(conn, MHD_HTTP_OK, build_downloads_json());
if (!strcmp(url, "/api/debug"))
return queue_json(conn, MHD_HTTP_OK,
g_debug_json ? g_debug_json : "{}");
if (!strcmp(url, "/api/installcheck")) {
char m[256], r[320];
int rc = patchdl_install_backend_check(m, sizeof(m));
snprintf(r, sizeof(r), "{\"ready\":%s,\"message\":\"%s\"}",
rc == 0 ? "true" : "false", m);
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
}
if (!strcmp(url, "/api/apiprobe")) {
char p[2048];
patchdl_install_api_probe(p, sizeof(p));
return queue_json_owned(conn, MHD_HTTP_OK, strdup(p));
}
if (!strcmp(url, "/api/installstatus")) {
char p[1024];
patchdl_install_status_json(p, sizeof(p));
return queue_json_owned(conn, MHD_HTTP_OK, strdup(p));
}
if (!strcmp(url, "/api/debug_install")) {
char p[512];
patchdl_install_debug_state(p, sizeof(p));
return queue_json_owned(conn, MHD_HTTP_OK, strdup(p));
}
if (!strcmp(url, "/api/pkgdiag")) {
/* Snapshot under the lock — otherwise MHD would read g_pkg_diag_json
in-place (RESPMEM_PERSISTENT) while record_pkg_diag is mid-snprintf,
producing a torn read or a missing NUL terminator. */
char snap[sizeof(g_pkg_diag_json)];
pthread_mutex_lock(&g_mutex);
memcpy(snap, g_pkg_diag_json, sizeof(snap));
pthread_mutex_unlock(&g_mutex);
snap[sizeof(snap) - 1] = '\0';
return queue_json_owned(conn, MHD_HTTP_OK, strdup(snap));
}
/* Read-only diagnostic: re-fetch the patch manifest for a title (PatchDL can
bypass the DNS block) and dump each piece's offset/size/SHA-256 so the
assembled .pkg can be verified against Sony's own hashes. No install. */
if (!strncmp(url, "/api/manifest/", 14)) {
const char *tid = url + 14;
char purl[768] = {0}, durl_[512] = {0};
char pti[32], psti[32], cid[64], nm[128], ver[16];
patchdl_source_t src;
int en;
patchdl_manifest_t mf;
jbuf_t j = {0};
if (!path_segment_safe(tid))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
if (!get_title_action_info(tid, &src, purl, sizeof purl,
durl_, sizeof durl_,
pti, sizeof pti,
psti, sizeof psti, cid, sizeof cid,
nm, sizeof nm, ver, sizeof ver, &en) || !purl[0])
return queue_json(conn, MHD_HTTP_NOT_FOUND,
"{\"error\":\"no patch_url for title\"}");
memset(&mf, 0, sizeof mf);
if (patchdl_fetch_manifest(purl, &mf) != 0)
return queue_json(conn, MHD_HTTP_BAD_GATEWAY,
"{\"error\":\"manifest fetch failed\"}");
jbuf_appendf(&j, "{\"count\":%d,\"total\":%lld,\"pieces\":[",
mf.count, mf.total);
for (int i = 0; i < mf.count; i++) {
if (i) jbuf_append(&j, ",");
jbuf_appendf(&j, "{\"o\":%lld,\"s\":%lld,\"h\":\"%s\"}",
mf.pieces[i].offset, mf.pieces[i].size, mf.pieces[i].hash);
}
jbuf_append(&j, "]}");
patchdl_manifest_free(&mf);
return queue_json_owned(conn, MHD_HTTP_OK,
j.buf ? j.buf : strdup("{}"));
}
/* Read-only: SHA-256 every piece of the on-disk .pkg and compare to Sony's
manifest hashes — proves whether the assembled file is byte-correct. No
install. Hashing runs on-device (SSD), so no multi-GB transfer. */
if (!strncmp(url, "/api/pkgverify/", 15)) {
const char *tid = url + 15;
char purl[768] = {0}, durl_[512] = {0};
char pti[32], psti[32], cid[64], nm[128], ver[16];
char dest[320];
patchdl_source_t src;
int en, fd, okc = 0, badc = 0, first_bad = -1;
patchdl_manifest_t mf;
jbuf_t j = {0};
if (!path_segment_safe(tid))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
if (!get_title_action_info(tid, &src, purl, sizeof purl,
durl_, sizeof durl_,
pti, sizeof pti,
psti, sizeof psti, cid, sizeof cid,
nm, sizeof nm, ver, sizeof ver, &en) || !purl[0])
return queue_json(conn, MHD_HTTP_NOT_FOUND,
"{\"error\":\"no patch_url for title\"}");
title_pkg_path(tid, purl, dest, sizeof dest);
fd = open(dest, O_RDONLY);
if (fd < 0)
return queue_json(conn, MHD_HTTP_NOT_FOUND,
"{\"error\":\"pkg not on disk\"}");
memset(&mf, 0, sizeof mf);
if (patchdl_fetch_manifest(purl, &mf) != 0) {
close(fd);
return queue_json(conn, MHD_HTTP_BAD_GATEWAY,
"{\"error\":\"manifest fetch failed\"}");
}
jbuf_appendf(&j, "{\"count\":%d,\"pieces\":[", mf.count);
for (int i = 0; i < mf.count; i++) {
char got[80] = {0};
int ok = 0;
if (mf.pieces[i].hash[0] &&
patchdl_sha256_fd_region(fd, mf.pieces[i].offset,
mf.pieces[i].size, got) == 0)
ok = (strcasecmp(got, mf.pieces[i].hash) == 0);
if (ok) okc++; else { badc++; if (first_bad < 0) first_bad = i; }
if (i) jbuf_append(&j, ",");
jbuf_appendf(&j, "{\"i\":%d,\"o\":%lld,\"s\":%lld,\"ok\":%s}",
i, mf.pieces[i].offset, mf.pieces[i].size,
ok ? "true" : "false");
}
jbuf_appendf(&j, "],\"ok\":%d,\"bad\":%d,\"first_bad\":%d,\"all_ok\":%s}",
okc, badc, first_bad, (badc == 0 ? "true" : "false"));
patchdl_manifest_free(&mf);
close(fd);
return queue_json_owned(conn, MHD_HTTP_OK, j.buf ? j.buf : strdup("{}"));
}
/* Read-only: report the .pkg's embedded content id / title id vs the target
(installed app) ids, to expose the cross-region linkage. No install. */
if (!strncmp(url, "/api/pkgmeta/", 13)) {
const char *tid = url + 13;
char purl[768] = {0}, durl_[512] = {0};
char pti[32], psti[32], cid[64], nm[128], ver[16];
char dest[320], ecid[64] = {0}, etid[48] = {0}, msg[128], resp[900];
patchdl_source_t src;
int en, is_app = 0, rc;
if (!path_segment_safe(tid))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
if (!get_title_action_info(tid, &src, purl, sizeof purl,
durl_, sizeof durl_,
pti, sizeof pti,
psti, sizeof psti, cid, sizeof cid,
nm, sizeof nm, ver, sizeof ver, &en) || !purl[0])
return queue_json(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"unknown title\"}");
title_pkg_path(tid, purl, dest, sizeof dest);
rc = patchdl_install_pkg_meta(dest, ecid, sizeof ecid, etid, sizeof etid,
&is_app, msg, sizeof msg);
snprintf(resp, sizeof resp,
"{\"ok\":%s,\"pkg_content_id\":\"%s\",\"pkg_title_id\":\"%s\","
"\"is_app\":%s,\"target_content_id\":\"%s\",\"target_title_id\":\"%s\","
"\"storage_title_id\":\"%s\",\"installable\":%s,\"install_plan\":\"%s\","
"\"msg\":\"%s\"}",
rc == 0 ? "true" : "false", ecid, etid, is_app ? "true" : "false",
cid, tid, psti,
(psti[0] && strncmp(psti, tid, 9) != 0) ? "false" : "true",
(psti[0] && strncmp(psti, tid, 9) != 0) ?
"cross_region_storage_unsupported" : "installbypackage",
msg);
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
if (!strcmp(url, "/api/netcheck")) {
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (g_titles[i].version_file_uri[0]) {
patchdl_net_diag(g_titles[i].version_file_uri, diag, sizeof(diag));
break;
}
}
pthread_mutex_unlock(&g_mutex);
return queue_json(conn, MHD_HTTP_OK, diag);
}
if (!strncmp(url, "/api/pkg/", 9))
return queue_pkg_file(conn, url);
return queue_asset(conn, url);
}
/* A title is resumable when its resume sidecar reports committed bytes between
0 and total (the .pkg is preallocated to the full size, so its on-disk size is
not progress — done_bytes from the bitmap sidecar is). Single-threaded. */
static void
detect_resumable_partials(void) {
char state[320], buf[16384];
FILE *f;
size_t n;
for (size_t i = 0; i < g_title_count; i++) {
patchdl_title_t *t = &g_titles[i];
long long total, done;
title_state_path(t->title_id, state, sizeof(state));
f = fopen(state, "r");
if (!f) continue;
n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
total = json_get_ll(buf, "total", 0);
done = json_get_ll(buf, "done_bytes", 0);
if (done > 0 && (total <= 0 || done < total)) {
t->resumable = 1;
t->partial_bytes = done;
}
}
}
/* ---------- lifecycle -------------------------------------------------- */
int
patchdl_websrv_start(unsigned short port) {
if (web_daemon) return 0;
/* Collect real FW version and installed titles synchronously.
The web UI is reachable only after this completes. */
patchdl_fw_get(&g_fw);
if (patchdl_scan(&g_titles, &g_title_count))
g_title_count = 0;
/* Default per-title policy (unknown sources off), then overlay anything the
user previously saved to /data/patchdl/config.json. */
for (size_t i = 0; i < g_title_count; i++)
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
load_config();
/* If the user opted into a home-screen tile, refresh it now. The helper
is stat-guarded — when nothing changed on disk it's a no-op, so the
cost on subsequent startups is two file reads. Best-effort: never
block startup on a tile install failure. */
if (g_cfg.home_shortcut) {
char tile_msg[256];
(void)patchdl_tile_install_if_needed(tile_msg, sizeof(tile_msg));
}
/* Flag titles that have a partial download on disk so the UI can offer
Resume after a reboot. */
detect_resumable_partials();
/* Build the diagnostic dump now, while single-threaded — the root-vnode
swap it performs is unsafe once MHD worker threads are running. */
g_debug_json = patchdl_scan_debug_json();
/* curl's global/OpenSSL init MUST run once, single-threaded, before the
download workers race their first curl_easy_init. */
patchdl_net_global_init();
/* Spawn the full set of workers BEFORE the web server accepts work, then
cap how many are *active* with g_pool.active_conns. Spawning all of them
up front lets the connection count be raised live (up to POOL_MAX_CONN)
without creating threads at runtime; idle workers just wait on the cv.
active_conns/n_workers are set before the first pthread_create so every
worker reads a valid limit from its gate (no startup data race). */
g_pool.n_workers = POOL_MAX_CONN;
g_pool.active_conns = g_cfg.max_connections;
if (g_pool.active_conns < 1) g_pool.active_conns = 1;
if (g_pool.active_conns > POOL_MAX_CONN) g_pool.active_conns = POOL_MAX_CONN;
g_pool.stopping = 0;
{
int spawned = 0;
for (int s = 0; s < POOL_MAX_CONN; s++) {
if (pthread_create(&g_pool.workers[s], NULL, dl_worker,
(void *)(intptr_t)s))
break; /* only the threads that started exist */
spawned++;
}
if (spawned < g_pool.n_workers) {
pthread_mutex_lock(&g_pool.mtx);
g_pool.n_workers = spawned;
if (g_pool.active_conns > g_pool.n_workers)
g_pool.active_conns = g_pool.n_workers;
pthread_mutex_unlock(&g_pool.mtx);
}
}
web_daemon = MHD_start_daemon(
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
port, NULL, NULL, &on_request, NULL,
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
/* DoS guards + RAM caps: the PS5 process budget is a few hundred MB
and the default pthread stack on this libc is multiple MB. We're
a single-user UI — 8 concurrent connections is plenty for the
SSE poll + a couple of AJAX, and 256 KB per worker is more than
enough for our handlers (largest stack use is a 16 KB sidecar
buffer in seed_from_sidecar). */
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30,
MHD_OPTION_THREAD_STACK_SIZE, (size_t)(512 * 1024),
MHD_OPTION_END);
if (!web_daemon) {
pthread_mutex_lock(&g_pool.mtx);
g_pool.stopping = 1;
pthread_cond_broadcast(&g_pool.cv);
pthread_mutex_unlock(&g_pool.mtx);
for (int s = 0; s < g_pool.n_workers; s++)
pthread_join(g_pool.workers[s], NULL);
patchdl_net_global_cleanup();
patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL;
g_title_count = 0;
return -1;
}
/* Lock further patchdl_scan / patchdl_scan_debug_json calls — both do a
process-wide vnode swap that is only safe before MHD worker threads
come up. After this point the only scan happens internally above. */
patchdl_scan_lock();
/* Start background verxml fetch — joinable so patchdl_websrv_stop can wait
for it before freeing g_titles (it reads g_titles across blocking queries). */
g_verxml_stop = 0;
if (pthread_create(&g_verxml_tid, NULL, verxml_fetch_thread, NULL) == 0)
g_verxml_started = 1;
return 0;
}
void
patchdl_websrv_stop(void) {
if (web_daemon) {
MHD_stop_daemon(web_daemon);
web_daemon = NULL;
}
/* Stop and join the background version.xml thread before anything frees
g_titles — it dereferences g_titles across blocking network queries. */
if (g_verxml_started) {
g_verxml_stop = 1;
pthread_join(g_verxml_tid, NULL);
g_verxml_started = 0;
}
/* Stop the pool: signal, join every worker (each finishes its current piece
write then exits), THEN tear curl + the title list down. */
pthread_mutex_lock(&g_pool.mtx);
g_pool.stopping = 1;
pthread_cond_broadcast(&g_pool.cv);
pthread_mutex_unlock(&g_pool.mtx);
for (int s = 0; s < g_pool.n_workers; s++)
pthread_join(g_pool.workers[s], NULL);
g_pool.n_workers = 0;
/* Free every job still in the pool list. Workers have joined so nobody
else touches the list. free_job_locked unlinks + frees mf.pieces[i].url,
mf.pieces, ps, bitmap, and closes the fd. */
pthread_mutex_lock(&g_pool.mtx);
while (g_pool.jobs) free_job_locked(g_pool.jobs);
pthread_mutex_unlock(&g_pool.mtx);
patchdl_net_global_cleanup();
pthread_mutex_lock(&g_mutex);
patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL;
g_title_count = 0;
free(g_debug_json);
g_debug_json = NULL;
pthread_mutex_unlock(&g_mutex);
}