Files
knutwurst--ps5-patchdl/src/patchdl_websrv.c
T
Knutwurst 73c75ddd83 Medium hardening: JSON escapes, policy whitelist, scan lock, EVP check
json_get_str now decodes the common JSON escapes (\" \\ \/ \n \r \t \b
\f) and collapses \uXXXX to '?'. Previously \" terminated the value
early and \\ was copied literal, so a body containing escapes turned
into garbage at the install backend.

default_policy is constrained to "allow" or "deny" before being stored,
so a malformed POST can't write an arbitrary string into config.json
and round-trip it back out of /api/config as broken JSON.

/api/manifest/<tid>, /api/pkgverify/<tid>, /api/pkgmeta/<tid> now run
path_segment_safe(tid) explicitly. The lookup gate they relied on
(get_title_action_info) is defense-by-coincidence — a future refactor
that populates g_titles via another path would lose the check.

patchdl_scan and patchdl_scan_debug_json perform a process-wide vnode
swap that is only safe single-threaded. patchdl_scan_lock() is now
called once right after MHD_start_daemon; subsequent calls return -1 /
NULL instead of racing the worker threads.

EVP_DigestFinal_ex return code is now checked. A failed final left dig
uninitialized; hex_encode would have produced empty hex and a silent
-2 with no diagnostic. patchdl_sha256_fd_region switches its inner
sprintf to snprintf — same effect, no -Wformat-security warning.
2026-06-24 20:39:43 +02:00

2361 lines
93 KiB
C

#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_verxml.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 */
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) 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)\"}",
g_fw.str, g_fw.bin, data_free_mb());
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_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) {
char path[320], buf[16384], 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->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 ---- */
{
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);
}
pthread_mutex_lock(&g_pool.mtx);
g_pool.inflight_bytes[slot] = 0;
job->inflight--;
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;
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);
g_cfg.home_shortcut =
json_get_bool(body, "home_shortcut", 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();
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));
}
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();
/* 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: bound concurrent sockets + per-IP to keep a misbehaving
LAN client from exhausting pthreads on the PS5. */
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)64,
MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30,
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;
patchdl_net_global_cleanup();
pthread_mutex_lock(&g_mutex);
patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL;
g_title_count = 0;
pthread_mutex_unlock(&g_mutex);
}