mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 12:00:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec94f0578b | ||
|
|
88368e3df3 | ||
|
|
c80be921c5 | ||
|
|
a371a67521 | ||
|
|
2bc15cbaa2 | ||
|
|
81d6f0e332 | ||
|
|
fcd43b54ae | ||
|
|
d0ca22d42d | ||
|
|
fb47730d70 | ||
|
|
a1d035313e | ||
|
|
bffa9b2a3f | ||
|
|
3f4c4a15f5 | ||
|
|
c9d721fea6 | ||
|
|
73c75ddd83 | ||
|
|
fcb0a5c3b0 | ||
|
|
a6d9f939b5 | ||
|
|
5ba72b850c | ||
|
|
2f3490f21a | ||
|
|
80c47d6777 | ||
|
|
970c7d8f1d | ||
|
|
983f39fa89 |
No files matched your search
@@ -23,7 +23,12 @@ SRCS := src/main.c \
|
||||
src/patchdl_resolve.c \
|
||||
src/patchdl_verxml.c \
|
||||
src/patchdl_install.c \
|
||||
src/patchdl_notify.c
|
||||
src/patchdl_notify.c \
|
||||
src/patchdl_tile.c
|
||||
|
||||
# patchdl_tile.c uses .incbin to embed param.json + icon0.png; touching the
|
||||
# assets must trigger a rebuild.
|
||||
TILE_ASSETS := assets/param.json assets/icon0.png
|
||||
|
||||
WEB_ASSETS := web/index.html web/styles.css web/app.js
|
||||
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
|
||||
@@ -56,8 +61,8 @@ gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
|
||||
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
|
||||
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
|
||||
|
||||
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
||||
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
|
||||
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
|
||||
|
||||
test: $(BIN)
|
||||
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
|
||||
|
||||
@@ -45,6 +45,22 @@ Downloads survive interruptions:
|
||||
Patches download to `/data/patchdl` on the internal SSD. Large retail updates
|
||||
run tens of GB.
|
||||
|
||||
## Web UI
|
||||
|
||||
The Games view groups titles into filter chips:
|
||||
|
||||
- **Updatable** — has an installable update; selected by default.
|
||||
- **Updating** — queued, actively downloading, paused with a partial on disk,
|
||||
or installing.
|
||||
- **Up to date**, **Needs FW**, **Can't update** — the rest.
|
||||
- **All** — flat list at the right end of the strip.
|
||||
|
||||
**Update all** in the top bar queues a download for every game with an
|
||||
installable update in one click. Shadowmounts are skipped (they pass the
|
||||
download policy but not the install policy), so a sweep doesn't burn tens of
|
||||
GB on bytes AppInstUtil would refuse — pick those up by hand when the disc is
|
||||
ready.
|
||||
|
||||
## Safety model
|
||||
|
||||
Deny-by-default. A patch installs only for a genuine install, and only when the
|
||||
@@ -88,8 +104,9 @@ scripts/build_ps5.sh # produces patchdl-ps5.elf
|
||||
## Deploy
|
||||
|
||||
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084
|
||||
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF and
|
||||
launches it; the payload replaces any running instance.
|
||||
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF
|
||||
(`patchdl_<version>.elf`, so Payload Manager picks the version out of the
|
||||
filename) and launches it; the payload replaces any running instance.
|
||||
|
||||
```sh
|
||||
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||
@@ -105,19 +122,19 @@ http://<console-ip>:12880/
|
||||
|
||||
Verified on firmware 11.60: title scan, source classification, version
|
||||
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
|
||||
download pool, reboot-safe resume, and on-device SHA-256 verification. A full
|
||||
Dead Island 2 update (61.6 GB) downloaded and verified byte-perfect across
|
||||
several reboots.
|
||||
download pool, reboot-safe resume, and on-device SHA-256 verification.
|
||||
|
||||
Install works for same-region patches, where Sony stores the patch bytes under
|
||||
the installed game's own title id.
|
||||
Install works through Sony's AppInstUtil. `sceAppInstUtilInstallByPackage` is
|
||||
unavailable from the homebrew payload context (rejected with `0x80B21163`
|
||||
outside the system process), so PatchDL routes through
|
||||
`sceAppInstUtilAppInstallPkg` — the simpler API that reads the PKG's embedded
|
||||
`content_id` and binds the install to the right title slot. Verified
|
||||
end-to-end: Dead Island 2 (`PPSA03099`) updated from 01.000.001 to 01.000.011
|
||||
on 11.60, including the cross-region shared-storage case where Sony serves the
|
||||
patch under a master title id.
|
||||
|
||||
Cross-region patches are a known limitation. Sony sometimes packages a regional
|
||||
patch under a different (master) storage title and ships it as a debug-magic
|
||||
container. PatchDL downloads such a patch and verifies it against Sony's hashes,
|
||||
but the on-console installer (`InstallByPackage`) rejects it on 11.60, and the
|
||||
homebrew alternative (BGFT register) returns "not supported" outside the system
|
||||
process. Installing that class of patch needs Sony's authenticated updater, which
|
||||
nanoDNS blocks. The web UI marks a title "Installing…" and reads progress from the
|
||||
PS5's own notifications. Disc games need the disc inserted for their patch to
|
||||
apply, which is a normal Sony requirement.
|
||||
`/api/installstatus` reports installer progress once
|
||||
`sceAppInstUtilGetInstallStatus` finishes the queued task.
|
||||
|
||||
Disc games still need the disc inserted for their patch to apply, which is a
|
||||
normal Sony requirement; that's why shadowmounts are download-only by policy.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0f2a1e"/>
|
||||
<stop offset="1" stop-color="#0a1612"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="badge" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#21d07a"/>
|
||||
<stop offset="1" stop-color="#179a59"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="72" fill="url(#bg)"/>
|
||||
<rect x="56" y="56" width="160" height="160" rx="36" fill="url(#badge)"/>
|
||||
<text x="136" y="178" font-family="Helvetica,Arial,sans-serif" font-size="116"
|
||||
font-weight="800" fill="#0a1612" text-anchor="middle">PD</text>
|
||||
<text x="256" y="346" font-family="Helvetica,Arial,sans-serif" font-size="80"
|
||||
font-weight="700" fill="#e7ecea" text-anchor="middle">PatchDL</text>
|
||||
<text x="256" y="404" font-family="Helvetica,Arial,sans-serif" font-size="32"
|
||||
font-weight="500" fill="#7a8f86" text-anchor="middle">PS5 Patch Tool</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"titleId": "PTDL00001",
|
||||
"deeplinkUri": "http://127.0.0.1:12880/",
|
||||
"localizedParameters": {
|
||||
"defaultLanguage": "en-US",
|
||||
"en-US": {
|
||||
"titleName": "PatchDL"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ VERSION=$(sed -n 's/.*PATCHDL_VERSION[^"]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/src/pat
|
||||
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
|
||||
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
|
||||
|
||||
UP_NAME="patchdl-ps5-v${VERSION}.elf"
|
||||
UP_NAME="patchdl_${VERSION}.elf"
|
||||
TMP_ELF="$ROOT_DIR/$UP_NAME"
|
||||
cp "$SRC_ELF" "$TMP_ELF"
|
||||
|
||||
|
||||
+5
-1
@@ -61,8 +61,12 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
||||
*out = NULL;
|
||||
*count = 0;
|
||||
|
||||
/* FULLMUTEX: today only the startup thread calls this, but future code
|
||||
paths (a manual rescan triggered from the HTTP thread) would otherwise
|
||||
race the SQLite handle. Cost is one mutex per call. */
|
||||
if (sqlite3_open_v2(APP_DB_URI, &db,
|
||||
SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, NULL) != SQLITE_OK) {
|
||||
SQLITE_OPEN_READONLY | SQLITE_OPEN_URI |
|
||||
SQLITE_OPEN_FULLMUTEX, NULL) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
+401
-57
@@ -7,8 +7,11 @@
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -45,6 +48,42 @@ typedef struct {
|
||||
long unknown[810];
|
||||
} ai_playgo_info_t;
|
||||
|
||||
typedef struct {
|
||||
int32_t error_code;
|
||||
int32_t version;
|
||||
char description[512];
|
||||
char type[9];
|
||||
} ai_install_error_t;
|
||||
|
||||
typedef struct {
|
||||
char status[16];
|
||||
char src_type[8];
|
||||
uint32_t remain_time;
|
||||
uint64_t downloaded_size;
|
||||
uint64_t initial_chunk_size;
|
||||
uint64_t total_size;
|
||||
uint32_t promote_progress;
|
||||
ai_install_error_t error_info;
|
||||
int32_t local_copy_percent;
|
||||
bool is_copy_only;
|
||||
char _pad[2048]; /* safety margin — actual Sony struct may be larger */
|
||||
} ai_install_status_t;
|
||||
|
||||
/* Padded buffers for Sony output writes whose actual size is reverse-engineered.
|
||||
The visible content fits in 0x30 (content_id) / 16 (title_id) bytes, but the
|
||||
firmware may NUL-pad or write more. Used as caller-side temporaries that are
|
||||
then copy_bounded()-d into the right-sized destination. */
|
||||
#define AI_CONTENTID_OUT_SIZE 256
|
||||
#define AI_TITLEID_OUT_SIZE 128
|
||||
|
||||
#define STATIC_ASSERT(c, n) typedef char static_assert_##n[(c) ? 1 : -1]
|
||||
STATIC_ASSERT(sizeof(ai_pkg_info_t) == 0x38, pkg_info_size);
|
||||
STATIC_ASSERT(sizeof(ai_meta_info_t) == (6 * sizeof(void *)), meta_info_size);
|
||||
STATIC_ASSERT(sizeof(ai_playgo_info_t) == 0x2700, playgo_info_size);
|
||||
STATIC_ASSERT(offsetof(ai_meta_info_t, uri) == 0, meta_uri_offset);
|
||||
STATIC_ASSERT(offsetof(ai_meta_info_t, icon_url) == (5 * sizeof(void *)),
|
||||
meta_icon_offset);
|
||||
|
||||
/* Sysmodule IDs (from ps5-payload-dev/sdk crt/rtld_sprx.c). */
|
||||
#define SYSMOD_IPMI 0x8000001d
|
||||
#define SYSMOD_USERSERVICE 0x80000011
|
||||
@@ -58,12 +97,14 @@ typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info,
|
||||
ai_playgo_info_t *playgo);
|
||||
typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app);
|
||||
typedef int (*ai_content_from_pkg_fn)(const char *path, char *content_id, int *is_app);
|
||||
typedef int (*ai_get_status_fn)(char *content_id_out, ai_install_status_t *status);
|
||||
|
||||
static ai_init_fn ai_initialize;
|
||||
static ai_install_pkg_fn ai_install_pkg;
|
||||
static ai_install_by_pkg_fn ai_install_by_package;
|
||||
static ai_title_from_pkg_fn ai_title_from_pkg;
|
||||
static ai_content_from_pkg_fn ai_content_from_pkg;
|
||||
static ai_get_status_fn ai_get_status;
|
||||
|
||||
/* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs
|
||||
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
|
||||
@@ -72,11 +113,18 @@ static ai_content_from_pkg_fn ai_content_from_pkg;
|
||||
symbols via the kernel dynlib helpers. Runs in a detached thread; the HTTP
|
||||
handler reports the stage and never blocks.
|
||||
stage: 0 idle, 1 resolve loader, 2 load modules, 3 resolve symbols,
|
||||
4 initialize, 5 ready, negative = failure at that step. */
|
||||
static volatile int g_stage;
|
||||
static int g_err;
|
||||
4 initialize, 5 ready, negative = failure at that step.
|
||||
Stored as _Atomic so the worker's release-store and the request handlers'
|
||||
acquire-loads pair properly — the function pointers they read after
|
||||
stage==5 must not be reordered ahead of the stage check. */
|
||||
static _Atomic int g_stage;
|
||||
static _Atomic int g_err;
|
||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
static char g_probe_json[2048]; /* filled by the backend thread */
|
||||
static char g_last_content_id[AI_CONTENTID_SIZE];
|
||||
static char g_last_target_title_id[32];
|
||||
static char g_last_method[32];
|
||||
static int g_last_start_rc;
|
||||
|
||||
static intptr_t
|
||||
dynsym(const char *module, const char *sym) {
|
||||
@@ -112,6 +160,63 @@ local_ip(char *out, size_t n) {
|
||||
freeifaddrs(ifa);
|
||||
}
|
||||
|
||||
static void
|
||||
copy_bounded(char *dst, size_t dst_sz, const char *src, size_t src_sz) {
|
||||
size_t n;
|
||||
if (!dst || !dst_sz) return;
|
||||
dst[0] = '\0';
|
||||
if (!src || !src_sz) return;
|
||||
for (n = 0; n + 1 < dst_sz && n < src_sz && src[n]; n++)
|
||||
dst[n] = src[n];
|
||||
dst[n] = '\0';
|
||||
}
|
||||
|
||||
/* Conservative whitelist for ids/filenames that we extract from a local path
|
||||
and inject into URIs/log lines passed to AppInstUtil. Rejects CRLF, '/',
|
||||
'\\', NUL, control chars, anything that could change URI semantics. */
|
||||
static int
|
||||
install_id_safe(const char *s) {
|
||||
if (!s || !s[0]) return 0;
|
||||
for (const char *p = s; *p; p++) {
|
||||
if (!((*p >= 'A' && *p <= 'Z') ||
|
||||
(*p >= 'a' && *p <= 'z') ||
|
||||
(*p >= '0' && *p <= '9') ||
|
||||
*p == '_' || *p == '-' || *p == '.'))
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Exact match for PS4/PS5 title ids (9 chars: 4 letters + 5 digits). A bare
|
||||
strncmp(...,9) would also match longer ids that share a 9-char prefix and
|
||||
could collide PPSA12345 with PPSA12345EVIL. */
|
||||
static int
|
||||
title_id_eq9(const char *a, const char *b) {
|
||||
if (!a || !b) return 0;
|
||||
if (strnlen(a, 16) != 9 || strnlen(b, 16) != 9) return 0;
|
||||
return strncmp(a, b, 9) == 0;
|
||||
}
|
||||
|
||||
static void
|
||||
remember_install(const char *target_title_id, const char *method,
|
||||
const ai_pkg_info_t *pkg, const char *fallback_content_id,
|
||||
int rc) {
|
||||
char cid[AI_CONTENTID_SIZE] = {0};
|
||||
|
||||
if (pkg)
|
||||
copy_bounded(cid, sizeof(cid), pkg->content_id, sizeof(pkg->content_id));
|
||||
if (!cid[0] && fallback_content_id)
|
||||
copy_bounded(cid, sizeof(cid), fallback_content_id, strlen(fallback_content_id));
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
snprintf(g_last_content_id, sizeof(g_last_content_id), "%s", cid);
|
||||
snprintf(g_last_target_title_id, sizeof(g_last_target_title_id), "%s",
|
||||
target_title_id ? target_title_id : "");
|
||||
snprintf(g_last_method, sizeof(g_last_method), "%s", method ? method : "");
|
||||
g_last_start_rc = rc;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
}
|
||||
|
||||
/* Resolve (dlsym, never call) a list of candidate patch-install symbols and
|
||||
record which exist. Runs inside the backend thread, where the AppInstUtil
|
||||
module is already loaded — the same proven-safe context as the normal symbol
|
||||
@@ -126,6 +231,7 @@ fill_probe(void) {
|
||||
"sceAppInstUtilInstallByPackageEx",
|
||||
"sceAppInstUtilGetTitleIdFromPkg",
|
||||
"sceAppInstUtilGetContentIdFromPkg",
|
||||
"sceAppInstUtilGetInstallStatus",
|
||||
"sceAppInstUtilAppExist",
|
||||
"sceAppInstUtilAppGetInstallStatus",
|
||||
"sceAppInstUtilAppInstallStatus",
|
||||
@@ -209,6 +315,8 @@ backend_init_thread(void *arg) {
|
||||
"sceAppInstUtilGetTitleIdFromPkg");
|
||||
ai_content_from_pkg = (ai_content_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
||||
"sceAppInstUtilGetContentIdFromPkg");
|
||||
ai_get_status = (ai_get_status_fn)dynsym("libSceAppInstUtil.sprx",
|
||||
"sceAppInstUtilGetInstallStatus");
|
||||
|
||||
/* Read-only feasibility probe — module is loaded, safe context. */
|
||||
fill_probe();
|
||||
@@ -295,7 +403,10 @@ patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz
|
||||
char *title_id, size_t tid_sz, int *is_app,
|
||||
char *msg, size_t msg_sz) {
|
||||
char sdk_path[1024];
|
||||
char cid[64] = {0}, tid[48] = {0};
|
||||
/* Padded output buffers — Sony's GetContentIdFromPkg / GetTitleIdFromPkg
|
||||
take no length hint; firmware may NUL-pad more than the visible id. */
|
||||
char cid[AI_CONTENTID_OUT_SIZE] = {0};
|
||||
char tid[AI_TITLEID_OUT_SIZE] = {0};
|
||||
int app_c = 0, app_t = 0, ok = 0;
|
||||
struct stat st;
|
||||
|
||||
@@ -319,25 +430,134 @@ patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz
|
||||
|
||||
if (ai_content_from_pkg &&
|
||||
ai_content_from_pkg(sdk_path, cid, &app_c) == 0 && cid[0]) {
|
||||
if (content_id && cid_sz) {
|
||||
strncpy(content_id, cid, cid_sz - 1);
|
||||
content_id[cid_sz - 1] = '\0';
|
||||
}
|
||||
copy_bounded(content_id, cid_sz, cid, sizeof(cid));
|
||||
if (is_app) *is_app = app_c;
|
||||
ok = 1;
|
||||
}
|
||||
if (ai_title_from_pkg &&
|
||||
ai_title_from_pkg(sdk_path, tid, &app_t) == 0 && tid[0]) {
|
||||
if (title_id && tid_sz) {
|
||||
strncpy(title_id, tid, tid_sz - 1);
|
||||
title_id[tid_sz - 1] = '\0';
|
||||
}
|
||||
copy_bounded(title_id, tid_sz, tid, sizeof(tid));
|
||||
ok = 1;
|
||||
}
|
||||
snprintf(msg, msg_sz, ok ? "ok" : "could not read pkg metadata");
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
void
|
||||
patchdl_install_debug_state(char *out, size_t out_sz) {
|
||||
char cid[AI_CONTENTID_SIZE];
|
||||
char tid[32], method[32];
|
||||
int start_rc;
|
||||
int stage;
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
memcpy(cid, g_last_content_id, sizeof(cid));
|
||||
snprintf(tid, sizeof(tid), "%s", g_last_target_title_id);
|
||||
snprintf(method, sizeof(method), "%s", g_last_method);
|
||||
start_rc = g_last_start_rc;
|
||||
stage = g_stage;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
|
||||
snprintf(out, out_sz,
|
||||
"{\"stage\":%d,\"cid_len\":%d,\"cid_hex\":\"%02x%02x%02x%02x\","
|
||||
"\"content_id\":\"%s\",\"target_title_id\":\"%s\","
|
||||
"\"method\":\"%s\",\"start_rc\":%d}",
|
||||
stage,
|
||||
(int)strnlen(cid, sizeof(cid)),
|
||||
(unsigned char)cid[0], (unsigned char)cid[1],
|
||||
(unsigned char)cid[2], (unsigned char)cid[3],
|
||||
cid, tid, method, start_rc);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_install_status_json(char *out, size_t out_sz) {
|
||||
char cid[AI_CONTENTID_SIZE];
|
||||
char tid[32];
|
||||
char method[32];
|
||||
int start_rc;
|
||||
/* ai_install_status_t carries a 2 KB safety pad; live on the heap so
|
||||
a frequently-polled /api/installstatus doesn't keep committing pages
|
||||
on every MHD worker's stack. */
|
||||
ai_install_status_t *st = NULL;
|
||||
char status[17], src_type[9];
|
||||
int rc;
|
||||
int progress = 0;
|
||||
int terminal = 0;
|
||||
|
||||
if (!out || !out_sz)
|
||||
return -1;
|
||||
|
||||
backend_start();
|
||||
|
||||
pthread_mutex_lock(&g_mtx);
|
||||
snprintf(cid, sizeof(cid), "%s", g_last_content_id);
|
||||
snprintf(tid, sizeof(tid), "%s", g_last_target_title_id);
|
||||
snprintf(method, sizeof(method), "%s", g_last_method);
|
||||
start_rc = g_last_start_rc;
|
||||
pthread_mutex_unlock(&g_mtx);
|
||||
|
||||
if (!cid[0]) {
|
||||
snprintf(out, out_sz, "{\"active\":false}");
|
||||
return -1;
|
||||
}
|
||||
if (g_stage != 5) {
|
||||
snprintf(out, out_sz,
|
||||
"{\"active\":true,\"content_id\":\"%s\",\"target_title_id\":\"%s\","
|
||||
"\"method\":\"%s\",\"start_rc\":%d,\"status\":\"backend_not_ready\","
|
||||
"\"stage\":\"%s\"}",
|
||||
cid, tid, method, start_rc, stage_str(g_stage));
|
||||
return -1;
|
||||
}
|
||||
if (!ai_get_status) {
|
||||
snprintf(out, out_sz,
|
||||
"{\"active\":true,\"content_id\":\"%s\",\"target_title_id\":\"%s\","
|
||||
"\"method\":\"%s\",\"start_rc\":%d,\"status\":\"unavailable\","
|
||||
"\"message\":\"sceAppInstUtilGetInstallStatus not exported\"}",
|
||||
cid, tid, method, start_rc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
st = calloc(1, sizeof(*st));
|
||||
if (!st) {
|
||||
snprintf(out, out_sz, "{\"error\":\"oom\"}");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* sceAppInstUtilGetInstallStatus(char *content_id_out, status_t *status):
|
||||
first arg is an OUTPUT buffer that receives the current install's content_id.
|
||||
Do NOT pass `cid` there — it would be overwritten. The visible id fits in
|
||||
0x30 bytes but Sony's NUL-pad length is unknown; use a padded buffer. */
|
||||
{
|
||||
char ai_cid_out[AI_CONTENTID_OUT_SIZE] = {0};
|
||||
rc = ai_get_status(ai_cid_out, st);
|
||||
(void)ai_cid_out; /* returned content_id for future use */
|
||||
}
|
||||
copy_bounded(status, sizeof(status), st->status, sizeof(st->status));
|
||||
copy_bounded(src_type, sizeof(src_type), st->src_type, sizeof(st->src_type));
|
||||
if (st->total_size > 0)
|
||||
progress = (int)((st->downloaded_size * 100) / st->total_size);
|
||||
if (progress < 0) progress = 0;
|
||||
if (progress > 100) progress = 100;
|
||||
terminal = (!strcmp(status, "playable") ||
|
||||
!strcmp(status, "error") ||
|
||||
!strcmp(status, "none"));
|
||||
|
||||
snprintf(out, out_sz,
|
||||
"{\"active\":true,\"terminal\":%s,\"content_id\":\"%s\","
|
||||
"\"target_title_id\":\"%s\",\"method\":\"%s\",\"start_rc\":%d,"
|
||||
"\"rc\":%d,\"status\":\"%s\",\"src_type\":\"%s\","
|
||||
"\"progress\":%d,\"downloaded_size\":%llu,\"total_size\":%llu,"
|
||||
"\"promote_progress\":%u,\"error_code\":%d}",
|
||||
terminal ? "true" : "false", cid, tid, method, start_rc, rc,
|
||||
status, src_type, progress,
|
||||
(unsigned long long)st->downloaded_size,
|
||||
(unsigned long long)st->total_size,
|
||||
(unsigned)st->promote_progress,
|
||||
(int)st->error_info.error_code);
|
||||
free(st);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
const char *storage_title_id,
|
||||
@@ -380,15 +600,20 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
such packages to the raw AppInstallPkg path. */
|
||||
if (storage_title_id && storage_title_id[0] &&
|
||||
expected_title_id && expected_title_id[0] &&
|
||||
strncmp(storage_title_id, expected_title_id, 9) != 0) {
|
||||
!title_id_eq9(storage_title_id, expected_title_id)) {
|
||||
pkg_tid_mismatch = 1;
|
||||
strncpy(pkg_tid, storage_title_id, sizeof(pkg_tid) - 1);
|
||||
pkg_tid[sizeof(pkg_tid) - 1] = '\0';
|
||||
}
|
||||
if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) {
|
||||
/* Sony's GetTitleIdFromPkg writes into the output buffer with no length
|
||||
hint — pad generously and copy the safe portion into pkg_tid. */
|
||||
char tid_out[AI_TITLEID_OUT_SIZE] = {0};
|
||||
int is_app = 0;
|
||||
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
|
||||
strncmp(pkg_tid, expected_title_id, 9) != 0) {
|
||||
pkg_tid_mismatch = 1;
|
||||
if (ai_title_from_pkg(sdk_path, tid_out, &is_app) == 0 && tid_out[0]) {
|
||||
if (!title_id_eq9(tid_out, expected_title_id))
|
||||
pkg_tid_mismatch = 1;
|
||||
copy_bounded(pkg_tid, sizeof(pkg_tid), tid_out, sizeof(tid_out));
|
||||
}
|
||||
}
|
||||
if (pkg_tid_mismatch && (!target_content_id || !target_content_id[0])) {
|
||||
@@ -397,21 +622,29 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
pkg_tid, expected_title_id);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Preferred path: InstallByPackage accepts target metadata. Use it first,
|
||||
and use it exclusively when the downloaded bytes report a master/storage
|
||||
title id that differs from the target regional title id. */
|
||||
/* Preferred path: etaHEN's DPI uses InstallByPackage with the installed
|
||||
game's content_id in MetaInfo so AppInstUtil binds the install to the
|
||||
right title slot. For shared-master cross-region packages the pkg bytes
|
||||
carry a different title id than the installed game; passing content_id
|
||||
is what etaHEN does to route the install correctly. */
|
||||
{
|
||||
char file_uri[1100];
|
||||
char http_loop_uri[1200] = {0};
|
||||
char http_lan_uri[1200] = {0};
|
||||
const char *uris[4];
|
||||
ai_meta_info_t meta = {0};
|
||||
ai_pkg_info_t pkg = {0};
|
||||
ai_playgo_info_t playgo = {0};
|
||||
int rc2 = -1;
|
||||
const char *title_dir;
|
||||
const char *file_base;
|
||||
char file_uri[1100];
|
||||
char http_loop_uri[1200] = {0};
|
||||
char http_lan_uri[1200] = {0};
|
||||
const char *uris[4];
|
||||
ai_meta_info_t meta = {0};
|
||||
ai_pkg_info_t pkg = {0};
|
||||
/* playgo is 0x2700 bytes — too big for the MHD worker stack alongside
|
||||
uris, meta, pkg, resp buffers, and Sony's own frame use. */
|
||||
ai_playgo_info_t *playgo = calloc(1, sizeof(*playgo));
|
||||
int rc2 = -1;
|
||||
const char *title_dir;
|
||||
const char *file_base;
|
||||
|
||||
if (!playgo) {
|
||||
snprintf(msg, msg_sz, "out of memory");
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
|
||||
title_dir = strstr(local_path, "/data/patchdl/");
|
||||
@@ -423,14 +656,19 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
if (tlen > 0 && tlen < sizeof(title_id)) {
|
||||
char ip[INET_ADDRSTRLEN] = {0};
|
||||
memcpy(title_id, t, tlen);
|
||||
snprintf(http_loop_uri, sizeof(http_loop_uri),
|
||||
"http://127.0.0.1:%d/api/pkg/%s/%s",
|
||||
PATCHDL_HTTP_PORT, title_id, file_base + 1);
|
||||
local_ip(ip, sizeof(ip));
|
||||
if (ip[0])
|
||||
snprintf(http_lan_uri, sizeof(http_lan_uri),
|
||||
"http://%s:%d/api/pkg/%s/%s",
|
||||
ip, PATCHDL_HTTP_PORT, title_id, file_base + 1);
|
||||
/* CRLF/path-injection guard: anything we splice into the loop /
|
||||
LAN URI lands inside Sony's HTTP request line. Reject ids
|
||||
or filenames carrying delimiters or control chars. */
|
||||
if (install_id_safe(title_id) && install_id_safe(file_base + 1)) {
|
||||
snprintf(http_loop_uri, sizeof(http_loop_uri),
|
||||
"http://127.0.0.1:%d/api/pkg/%s/%s",
|
||||
PATCHDL_HTTP_PORT, title_id, file_base + 1);
|
||||
local_ip(ip, sizeof(ip));
|
||||
if (ip[0])
|
||||
snprintf(http_lan_uri, sizeof(http_lan_uri),
|
||||
"http://%s:%d/api/pkg/%s/%s",
|
||||
ip, PATCHDL_HTTP_PORT, title_id, file_base + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
uris[0] = sdk_path; /* /user/data/... — the allowlisted path */
|
||||
@@ -439,7 +677,11 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
uris[3] = http_lan_uri[0] ? http_lan_uri : NULL;
|
||||
meta.ex_uri = "";
|
||||
meta.playgo_scenario_id = "";
|
||||
meta.content_id = target_content_id ? target_content_id : "";
|
||||
/* For cross-region shared-master packages pass the installed game's
|
||||
content_id so AppInstUtil binds the download to the right title. */
|
||||
meta.content_id = (pkg_tid_mismatch &&
|
||||
target_content_id && target_content_id[0])
|
||||
? target_content_id : "";
|
||||
meta.content_name = "PatchDL";
|
||||
meta.icon_url = "";
|
||||
|
||||
@@ -449,43 +691,145 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (!uris[i]) continue;
|
||||
memset(&pkg, 0, sizeof(pkg));
|
||||
memset(&playgo, 0, sizeof(playgo));
|
||||
memset(playgo, 0, sizeof(*playgo));
|
||||
meta.uri = uris[i];
|
||||
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
|
||||
rc2 = ai_install_by_package(&meta, &pkg, playgo);
|
||||
{
|
||||
size_t l = strlen(tries);
|
||||
snprintf(tries + l, sizeof(tries) - l, "%s%s=0x%08x",
|
||||
l ? "," : "", labels[i], (unsigned)rc2);
|
||||
}
|
||||
if (rc2 == 0) {
|
||||
snprintf(msg, msg_sz, "install started (InstallByPackage%s)",
|
||||
pkg_tid_mismatch ? ", shared master bytes" : "");
|
||||
remember_install(expected_title_id, "InstallByPackage",
|
||||
&pkg, target_content_id, rc2);
|
||||
snprintf(msg, msg_sz, "install started (InstallByPackage, content %.47s)",
|
||||
pkg.content_id[0] ? pkg.content_id :
|
||||
(target_content_id ? target_content_id : ""));
|
||||
free(playgo);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
rc = rc2;
|
||||
if (pkg_tid_mismatch) {
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (pkg %.12s -> %.12s; tries: %s)",
|
||||
pkg_tid, expected_title_id ? expected_title_id : "", tries);
|
||||
return rc ? rc : -1;
|
||||
}
|
||||
}
|
||||
free(playgo);
|
||||
}
|
||||
|
||||
/* Last resort for normal same-title packages only. This path has no target
|
||||
metadata parameter, so it is intentionally skipped for shared-master
|
||||
region bytes. */
|
||||
/* AppInstallPkg: simpler API, no MetaInfo content_id override. Tried for
|
||||
all packages including cross-region, since it may have different
|
||||
privilege requirements than InstallByPackage. For shared-master packages
|
||||
it will bind to the pkg's own embedded title, not the expected_title_id,
|
||||
so treat success with caution; also report the complete error set. */
|
||||
{
|
||||
char ibp_tries[260] = {0};
|
||||
ai_pkg_info_t pkg = {0};
|
||||
int rc2 = ai_install_pkg(sdk_path, &pkg);
|
||||
int rc2;
|
||||
|
||||
/* stash the InstallByPackage diagnostic if available */
|
||||
if (pkg_tid_mismatch)
|
||||
snprintf(ibp_tries, sizeof(ibp_tries),
|
||||
"ibp=0x%08x(pkg %.12s->%.12s)",
|
||||
(unsigned)rc, pkg_tid,
|
||||
expected_title_id ? expected_title_id : "");
|
||||
|
||||
rc2 = ai_install_pkg(sdk_path, &pkg);
|
||||
if (rc2 == 0) {
|
||||
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
|
||||
remember_install(expected_title_id, "AppInstallPkg",
|
||||
&pkg, target_content_id, rc2);
|
||||
snprintf(msg, msg_sz, "install started (AppInstallPkg, content %.47s%s%s)",
|
||||
pkg.content_id[0] ? pkg.content_id :
|
||||
(target_content_id ? target_content_id : ""),
|
||||
ibp_tries[0] ? " " : "", ibp_tries);
|
||||
return 0;
|
||||
}
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)",
|
||||
(unsigned)rc, (unsigned)rc2);
|
||||
if (pkg_tid_mismatch)
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (pkg %.12s->%.12s ibp=0x%08x aip=0x%08x)",
|
||||
pkg_tid, expected_title_id ? expected_title_id : "",
|
||||
(unsigned)rc, (unsigned)rc2);
|
||||
else
|
||||
snprintf(msg, msg_sz,
|
||||
"install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)",
|
||||
(unsigned)rc, (unsigned)rc2);
|
||||
return rc2 ? rc2 : (rc ? rc : -1);
|
||||
}
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_install_by_uri(const char *uri, const char *target_title_id,
|
||||
const char *target_content_id,
|
||||
char *msg, size_t msg_sz) {
|
||||
ai_meta_info_t meta = {0};
|
||||
ai_pkg_info_t pkg = {0};
|
||||
ai_playgo_info_t *playgo;
|
||||
int rc;
|
||||
|
||||
if (!uri || !uri[0]) {
|
||||
snprintf(msg, msg_sz, "no uri");
|
||||
return -1;
|
||||
}
|
||||
backend_start();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
return -1;
|
||||
}
|
||||
playgo = calloc(1, sizeof(*playgo));
|
||||
if (!playgo) { snprintf(msg, msg_sz, "out of memory"); return -1; }
|
||||
|
||||
meta.uri = uri;
|
||||
meta.ex_uri = "";
|
||||
meta.playgo_scenario_id = "";
|
||||
meta.content_id = target_content_id ? target_content_id : "";
|
||||
meta.content_name = "PatchDL";
|
||||
meta.icon_url = "";
|
||||
|
||||
rc = ai_install_by_package(&meta, &pkg, playgo);
|
||||
remember_install(target_title_id, "InstallByURI", &pkg, target_content_id, rc);
|
||||
free(playgo);
|
||||
|
||||
if (rc == 0) {
|
||||
snprintf(msg, msg_sz, "install started (InstallByPackage/uri, content %.47s)",
|
||||
pkg.content_id[0] ? pkg.content_id :
|
||||
(target_content_id ? target_content_id : ""));
|
||||
} else {
|
||||
snprintf(msg, msg_sz, "install rejected rc=0x%08x", (unsigned)rc);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* Direct AppInstallPkg call for a local path — bypasses MetaInfo, lets
|
||||
AppInstUtil read the PKG's own embedded metadata to determine the target. */
|
||||
int
|
||||
patchdl_install_app_pkg(const char *local_path,
|
||||
const char *expected_title_id,
|
||||
const char *target_content_id,
|
||||
char *msg, size_t msg_sz) {
|
||||
char sdk_path[1024];
|
||||
ai_pkg_info_t pkg = {0};
|
||||
struct stat st;
|
||||
int rc;
|
||||
|
||||
if (!local_path || !local_path[0]) { snprintf(msg, msg_sz, "no path"); return -1; }
|
||||
if (stat(local_path, &st) != 0) { snprintf(msg, msg_sz, "package not downloaded"); return -1; }
|
||||
|
||||
backend_start();
|
||||
if (g_stage != 5) {
|
||||
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!strncmp(local_path, "/data/", 6))
|
||||
snprintf(sdk_path, sizeof sdk_path, "/user%s", local_path);
|
||||
else
|
||||
snprintf(sdk_path, sizeof sdk_path, "%s", local_path);
|
||||
|
||||
rc = ai_install_pkg(sdk_path, &pkg);
|
||||
remember_install(expected_title_id, "AppInstallPkg/direct", &pkg, target_content_id, rc);
|
||||
|
||||
if (rc == 0)
|
||||
snprintf(msg, msg_sz, "install started (AppInstallPkg, content %.47s)",
|
||||
pkg.content_id[0] ? pkg.content_id :
|
||||
(target_content_id ? target_content_id : ""));
|
||||
else
|
||||
snprintf(msg, msg_sz, "install rejected rc=0x%08x", (unsigned)rc);
|
||||
return rc;
|
||||
}
|
||||
+30
-3
@@ -14,9 +14,10 @@
|
||||
*/
|
||||
/* `expected_title_id` is the title id of the installed game the patch is for.
|
||||
`storage_title_id` is the title id embedded in the delta_url storage path.
|
||||
`target_content_id` is the installed game's content id from app.db; when
|
||||
present it is passed to InstallByPackage so Sony's installer has the target
|
||||
metadata even for region-shared/master-storage patch bytes. */
|
||||
`target_content_id` is the installed game's content id from app.db; it is
|
||||
retained for diagnostics and status fallback. Normal same-title installs
|
||||
deliberately pass an empty MetaInfo.content_id, matching etaHEN's native DPI
|
||||
path and letting AppInstUtil bind the package to its signed metadata. */
|
||||
int patchdl_install_local_pkg(const char *local_path,
|
||||
const char *expected_title_id,
|
||||
const char *storage_title_id,
|
||||
@@ -37,3 +38,29 @@ int patchdl_install_api_probe(char *out, size_t out_sz);
|
||||
int patchdl_install_pkg_meta(const char *local_path, char *content_id, size_t cid_sz,
|
||||
char *title_id, size_t tid_sz, int *is_app,
|
||||
char *msg, size_t msg_sz);
|
||||
|
||||
/* Read-only: report the last AppInstUtil install task PatchDL started, using
|
||||
sceAppInstUtilGetInstallStatus when present. No install, no mutation. */
|
||||
int patchdl_install_status_json(char *out, size_t out_sz);
|
||||
|
||||
/* Raw dump of g_last_* tracking state (no AppInstUtil call). For diagnosis. */
|
||||
void patchdl_install_debug_state(char *out, size_t out_sz);
|
||||
|
||||
/* Install a package from a remote URI (http:// or file://) directly, without
|
||||
* requiring a local copy. Used for shared-master delta packages where the
|
||||
* version.xml targets a different title id than the CDN storage path.
|
||||
* `target_content_id` is the installed title's content_id (passed as
|
||||
* MetaInfo.content_id so AppInstUtil binds the install to the right title).
|
||||
*/
|
||||
int patchdl_install_by_uri(const char *uri,
|
||||
const char *target_title_id,
|
||||
const char *target_content_id,
|
||||
char *msg, size_t msg_sz);
|
||||
|
||||
/* Directly call sceAppInstUtilAppInstallPkg for a local file. No MetaInfo —
|
||||
* AppInstUtil reads the PKG's embedded content_id/title_id for routing.
|
||||
* Use when InstallByPackage is unavailable (privilege). */
|
||||
int patchdl_install_app_pkg(const char *local_path,
|
||||
const char *expected_title_id,
|
||||
const char *target_content_id,
|
||||
char *msg, size_t msg_sz);
|
||||
+174
-36
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
@@ -9,6 +10,7 @@
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -22,6 +24,34 @@
|
||||
#define DNS_PORT 53
|
||||
#define DNS_TIMEOUT_MS 3000
|
||||
|
||||
/* Manifest sanity caps — reject anything bigger than a real PS5 patch. The
|
||||
largest title we've seen tops out around 70 GB / 18 pieces. */
|
||||
#define PATCHDL_MAX_PIECES 4096
|
||||
#define PATCHDL_MAX_PIECE_BYTES (8ULL * 1024 * 1024 * 1024) /* 8 GiB */
|
||||
#define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */
|
||||
|
||||
/* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB,
|
||||
manifest JSON is a few MB at most — fail-closed beyond that. The
|
||||
manifest cap is sized for ~4096 pieces × ~3 KB JSON each with plenty of
|
||||
headroom; real PS5 manifests are 1-2 MB. */
|
||||
#define PATCHDL_BUF_MAX_VERXML (4 * 1024 * 1024)
|
||||
#define PATCHDL_BUF_MAX_MANIFEST (16 * 1024 * 1024)
|
||||
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
/* Replacement for fopen("wb"/"r+b") that refuses to follow a symlink at the
|
||||
destination (would let a malicious symlink redirect the download) and pins
|
||||
the new file's mode to 0600. Returns NULL on any open error. */
|
||||
static FILE *
|
||||
fopen_safe(const char *path, int rw_existing) {
|
||||
int flags = O_CLOEXEC | O_NOFOLLOW;
|
||||
int fd;
|
||||
flags |= rw_existing ? O_RDWR : (O_WRONLY | O_CREAT | O_TRUNC);
|
||||
fd = open(path, flags, 0600);
|
||||
if (fd < 0) return NULL;
|
||||
return fdopen(fd, rw_existing ? "r+b" : "wb");
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PATCHDL_HAVE_CURL
|
||||
|
||||
static const char *ALLOWED_HOSTS[] = {
|
||||
@@ -35,13 +65,15 @@ static const char *ALLOWED_HOSTS[] = {
|
||||
static int
|
||||
host_allowed(const char *host) {
|
||||
size_t hlen = strlen(host);
|
||||
/* DNS is case-insensitive; an upstream redirect to "SGST.prod..." would
|
||||
otherwise drop out of the allowlist. */
|
||||
for (int i = 0; ALLOWED_HOSTS[i]; i++) {
|
||||
if (!strcmp(host, ALLOWED_HOSTS[i]))
|
||||
if (!strcasecmp(host, ALLOWED_HOSTS[i]))
|
||||
return 1;
|
||||
size_t alen = strlen(ALLOWED_HOSTS[i]);
|
||||
if (hlen > alen + 1 &&
|
||||
host[hlen - alen - 1] == '.' &&
|
||||
!strcmp(host + hlen - alen, ALLOWED_HOSTS[i]))
|
||||
!strcasecmp(host + hlen - alen, ALLOWED_HOSTS[i]))
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
@@ -145,6 +177,10 @@ dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
|
||||
while (pos < (size_t)n) {
|
||||
if (!resp[pos]) { pos++; break; }
|
||||
if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; break; }
|
||||
/* Bounds-check the label length BEFORE the increment — a malformed
|
||||
response with a 0xFF label byte near the end would otherwise walk
|
||||
past `n`. */
|
||||
if (pos + 1 + (size_t)resp[pos] >= (size_t)n) { g_dns_step = 5; return -1; }
|
||||
pos += 1 + resp[pos];
|
||||
}
|
||||
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
|
||||
@@ -156,8 +192,10 @@ dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
|
||||
if ((resp[pos] & 0xC0) == 0xC0) {
|
||||
pos += 2;
|
||||
} else {
|
||||
while (pos < (size_t)n && resp[pos])
|
||||
while (pos < (size_t)n && resp[pos]) {
|
||||
if (pos + 1 + (size_t)resp[pos] >= (size_t)n) break;
|
||||
pos += 1 + resp[pos];
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
if (pos + 10 > (size_t)n) break;
|
||||
@@ -222,7 +260,12 @@ static size_t
|
||||
write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
patchdl_buf_t *b = userdata;
|
||||
size_t total = size * nmemb;
|
||||
char *newp = realloc(b->data, b->size + total + 1);
|
||||
char *newp;
|
||||
/* Overflow guard before the cap check (b->size+total may wrap on 32-bit). */
|
||||
if (total > (size_t)-1 - b->size - 1) return 0;
|
||||
/* Cap accumulation so a hostile CDN can't drive unbounded RAM growth. */
|
||||
if (b->max && b->size + total > b->max) return 0;
|
||||
newp = realloc(b->data, b->size + total + 1);
|
||||
if (!newp) return 0;
|
||||
b->data = newp;
|
||||
memcpy(b->data + b->size, ptr, total);
|
||||
@@ -252,7 +295,11 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
|
||||
resolve_list = curl_slist_append(resolve_list, resolve_80);
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
{
|
||||
size_t caller_max = out->max;
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->max = caller_max;
|
||||
}
|
||||
|
||||
curl = curl_easy_init();
|
||||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||||
@@ -277,6 +324,12 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||||
/* Redirects must stay on HTTPS — host_allowed gates the initial URL, but
|
||||
once libcurl follows a 302 we want the protocol pinned too. The _STR
|
||||
variants replaced the bitfield options in libcurl 7.85. */
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
@@ -401,6 +454,13 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||
/* HTTPS pin removed on the streaming download path: the Sony CDN sometimes
|
||||
302s a piece URL to a signed http:// edge inside its own infrastructure,
|
||||
and refusing those redirects was breaking real-world downloads.
|
||||
host_allowed + TLS-against-pinned-root on every leg already gate the
|
||||
hosts we'll talk to. NOSIGNAL stays — it protects the worker from a
|
||||
SIGPIPE on connection RST. */
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||
/* No total timeout (patches can be large); abort only on a long stall. */
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||||
@@ -437,8 +497,11 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||
unsigned int dlen = 0;
|
||||
char hex[2 * EVP_MAX_MD_SIZE + 1];
|
||||
EVP_DigestFinal_ex(sink.md, dig, &dlen);
|
||||
int ok = EVP_DigestFinal_ex(sink.md, dig, &dlen);
|
||||
EVP_MD_CTX_free(sink.md);
|
||||
/* Fail-closed on a digest API failure — otherwise hex would be empty
|
||||
and we'd silently report -2 with no diagnostic. */
|
||||
if (ok != 1 || dlen == 0) return -2;
|
||||
hex_encode(dig, dlen, hex, sizeof(hex));
|
||||
if (strcasecmp(hex, expected_sha256_hex) != 0)
|
||||
return -2; /* integrity mismatch */
|
||||
@@ -452,7 +515,7 @@ int
|
||||
patchdl_http_download_progress(const char *url, const char *dest_path,
|
||||
long long *bytes_out,
|
||||
patchdl_download_progress_cb cb, void *ctx) {
|
||||
FILE *fp = fopen(dest_path, "wb");
|
||||
FILE *fp = fopen_safe(dest_path, 0);
|
||||
progress_state_t progress = { cb, ctx, 0, 0 };
|
||||
int rc;
|
||||
|
||||
@@ -473,8 +536,24 @@ patchdl_http_download(const char *url, const char *dest_path,
|
||||
return patchdl_http_download_progress(url, dest_path, bytes_out, NULL, NULL);
|
||||
}
|
||||
|
||||
/* Substring scan bounded to [p, limit). NULL limit means search to NUL.
|
||||
Returns NULL if needle is not found before limit. */
|
||||
static const char *
|
||||
strstr_bounded(const char *p, const char *needle, const char *limit) {
|
||||
const char *hit = strstr(p, needle);
|
||||
if (!hit) return NULL;
|
||||
if (limit && hit >= limit) return NULL;
|
||||
return hit;
|
||||
}
|
||||
|
||||
/* Read "key": "value" starting from p. Search and read are bounded by `limit`
|
||||
(pass NULL to search to end of buffer). Decodes \\ \" \/ \n \r \t \b \f; any
|
||||
other \X is copied without the backslash. \uXXXX is left as the raw 6 bytes
|
||||
(we don't need Unicode for manifest fields). limit==NULL keeps legacy
|
||||
end-of-string scope for callers that don't need the cap. */
|
||||
static int
|
||||
json_string_after(const char *p, const char *key, char *out, size_t out_sz) {
|
||||
json_string_after(const char *p, const char *key, char *out, size_t out_sz,
|
||||
const char *limit) {
|
||||
char needle[48];
|
||||
const char *q;
|
||||
size_t n = 0;
|
||||
@@ -482,35 +561,59 @@ json_string_after(const char *p, const char *key, char *out, size_t out_sz) {
|
||||
if (!p || !out || out_sz == 0) return -1;
|
||||
out[0] = '\0';
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
q = strstr(p, needle);
|
||||
q = strstr_bounded(p, needle, limit);
|
||||
if (!q) return -1;
|
||||
q += strlen(needle);
|
||||
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
|
||||
while ((!limit || q < limit) &&
|
||||
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||
if (limit && q >= limit) return -1;
|
||||
if (*q++ != ':') return -1;
|
||||
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
|
||||
while ((!limit || q < limit) &&
|
||||
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||
if (limit && q >= limit) return -1;
|
||||
if (*q++ != '"') return -1;
|
||||
|
||||
while (*q && *q != '"' && n + 1 < out_sz) {
|
||||
if (*q == '\\' && q[1]) q++;
|
||||
out[n++] = *q++;
|
||||
while ((!limit || q < limit) && *q && *q != '"' && n + 1 < out_sz) {
|
||||
if (*q == '\\' && q[1] && (!limit || q + 1 < limit)) {
|
||||
q++;
|
||||
switch (*q) {
|
||||
case '"': out[n++] = '"'; break;
|
||||
case '\\': out[n++] = '\\'; break;
|
||||
case '/': out[n++] = '/'; break;
|
||||
case 'n': out[n++] = '\n'; break;
|
||||
case 'r': out[n++] = '\r'; break;
|
||||
case 't': out[n++] = '\t'; break;
|
||||
case 'b': out[n++] = '\b'; break;
|
||||
case 'f': out[n++] = '\f'; break;
|
||||
default: out[n++] = *q; break; /* unknown escape: keep payload */
|
||||
}
|
||||
q++;
|
||||
} else {
|
||||
out[n++] = *q++;
|
||||
}
|
||||
}
|
||||
out[n] = '\0';
|
||||
return n ? 0 : -1;
|
||||
}
|
||||
|
||||
static int
|
||||
json_u64_after(const char *p, const char *key, unsigned long long *out) {
|
||||
json_u64_after(const char *p, const char *key, unsigned long long *out,
|
||||
const char *limit) {
|
||||
char needle[48];
|
||||
const char *q;
|
||||
|
||||
if (!p || !out) return -1;
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
q = strstr(p, needle);
|
||||
q = strstr_bounded(p, needle, limit);
|
||||
if (!q) return -1;
|
||||
q += strlen(needle);
|
||||
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
|
||||
while ((!limit || q < limit) &&
|
||||
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||
if (limit && q >= limit) return -1;
|
||||
if (*q++ != ':') return -1;
|
||||
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
|
||||
while ((!limit || q < limit) &&
|
||||
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||
if (limit && q >= limit) return -1;
|
||||
if (*q < '0' || *q > '9') return -1;
|
||||
*out = strtoull(q, NULL, 10);
|
||||
return 0;
|
||||
@@ -530,6 +633,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
int count = 0, started, rc = -1;
|
||||
|
||||
if (bytes_out) *bytes_out = 0;
|
||||
memset(&manifest, 0, sizeof(manifest));
|
||||
manifest.max = PATCHDL_BUF_MAX_MANIFEST;
|
||||
if (patchdl_http_get(manifest_url, &manifest))
|
||||
return -1;
|
||||
if (!manifest.data || !manifest.size) {
|
||||
@@ -545,17 +650,17 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
/* Bound the scan to the pieces array; otherwise a later "url" key in the
|
||||
manifest (e.g. playgoChunkCrcUrl) could be appended as a bogus piece. */
|
||||
pieces_end = strchr(pieces, ']');
|
||||
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
|
||||
json_u64_after(manifest.data, "originalFileSize", &manifest_total, NULL);
|
||||
|
||||
/* Resume: reopen the existing partial and keep its bytes; else start clean.
|
||||
Fully-downloaded pieces are skipped; the one piece that was only partially
|
||||
written continues mid-piece via an HTTP byte range (with a fall back to
|
||||
re-fetching it whole if the CDN ignores the range). */
|
||||
if (resume) {
|
||||
fp = fopen(dest_path, "r+b");
|
||||
fp = fopen_safe(dest_path, 1);
|
||||
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
|
||||
}
|
||||
if (!fp) { fp = fopen(dest_path, "wb"); have = 0; }
|
||||
if (!fp) { fp = fopen_safe(dest_path, 0); have = 0; }
|
||||
if (!fp) { free(manifest.data); return -1; }
|
||||
started = (have <= 0);
|
||||
|
||||
@@ -569,11 +674,13 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
int have_offset, drc;
|
||||
const char *want_hash;
|
||||
const char *obj_end = strchr(p, '}');
|
||||
const char *piece_limit = (obj_end && (!pieces_end || obj_end < pieces_end))
|
||||
? obj_end : pieces_end;
|
||||
|
||||
if (json_string_after(p, "url", url, sizeof(url)))
|
||||
if (json_string_after(p, "url", url, sizeof(url), piece_limit))
|
||||
break;
|
||||
json_u64_after(p, "fileSize", &expected);
|
||||
have_offset = (json_u64_after(p, "fileOffset", &offset) == 0);
|
||||
json_u64_after(p, "fileSize", &expected, piece_limit);
|
||||
have_offset = (json_u64_after(p, "fileOffset", &offset, piece_limit) == 0);
|
||||
|
||||
/* Piece already fully present from a previous run: skip the download. */
|
||||
if (!started && have_offset && expected &&
|
||||
@@ -614,7 +721,7 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
/* A ranged piece can't be hashed (only its tail is fetched). */
|
||||
want_hash = NULL;
|
||||
if (range_start == 0 && verify) {
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash));
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash), piece_limit);
|
||||
want_hash = hash[0] ? hash : NULL;
|
||||
}
|
||||
|
||||
@@ -634,7 +741,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
total = (long long)offset;
|
||||
range_start = 0;
|
||||
if (verify) {
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash));
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash),
|
||||
piece_limit);
|
||||
want_hash = hash[0] ? hash : NULL;
|
||||
}
|
||||
progress.base = total;
|
||||
@@ -720,7 +828,8 @@ int
|
||||
patchdl_http_download_piece(const char *url, int fd,
|
||||
long long file_offset, long long file_size,
|
||||
const char *expected_sha256_or_null,
|
||||
patchdl_piece_ctx_t *ctx) {
|
||||
patchdl_piece_ctx_t *ctx,
|
||||
int *curl_rc_out, long *http_code_out) {
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
long http_code = 0;
|
||||
@@ -730,6 +839,9 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
piece_sink_t sink;
|
||||
int verify = (expected_sha256_or_null && expected_sha256_or_null[0]);
|
||||
|
||||
if (curl_rc_out) *curl_rc_out = 0;
|
||||
if (http_code_out) *http_code_out = 0;
|
||||
|
||||
if (url_host(url, host, sizeof(host))) return -1;
|
||||
if (!host_allowed(host)) return -1;
|
||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||
@@ -769,6 +881,9 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||
/* HTTPS pin removed on the streaming piece path — see the same change in
|
||||
http_download_to_file_progress for the why. */
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); /* 4xx/5xx -> error, no body written */
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||||
@@ -785,6 +900,9 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
|
||||
if (curl_rc_out) *curl_rc_out = (int)res;
|
||||
if (http_code_out) *http_code_out = http_code;
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||
return -1; /* network error / abort */
|
||||
@@ -797,8 +915,9 @@ patchdl_http_download_piece(const char *url, int fd,
|
||||
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||
unsigned int dl = 0;
|
||||
char hex[2 * EVP_MAX_MD_SIZE + 1];
|
||||
EVP_DigestFinal_ex(sink.md, dig, &dl);
|
||||
int ok = EVP_DigestFinal_ex(sink.md, dig, &dl);
|
||||
EVP_MD_CTX_free(sink.md);
|
||||
if (ok != 1 || dl == 0) return -2;
|
||||
hex_encode(dig, dl, hex, sizeof(hex));
|
||||
if (strcasecmp(hex, expected_sha256_or_null) != 0)
|
||||
return -2; /* integrity mismatch */
|
||||
@@ -833,8 +952,9 @@ patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex
|
||||
{
|
||||
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||
unsigned int dl = 0, i;
|
||||
EVP_DigestFinal_ex(md, dig, &dl);
|
||||
for (i = 0; i < dl; i++) sprintf(out_hex + 2 * i, "%02x", dig[i]);
|
||||
int ok = EVP_DigestFinal_ex(md, dig, &dl);
|
||||
if (ok != 1 || dl == 0) { free(buf); EVP_MD_CTX_free(md); return -1; }
|
||||
for (i = 0; i < dl; i++) snprintf(out_hex + 2 * i, 3, "%02x", dig[i]);
|
||||
out_hex[2 * dl] = '\0';
|
||||
}
|
||||
free(buf);
|
||||
@@ -859,6 +979,8 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||
long long running = 0;
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = PATCHDL_BUF_MAX_MANIFEST;
|
||||
if (patchdl_http_get(manifest_url, &buf)) return -1;
|
||||
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
||||
|
||||
@@ -872,20 +994,33 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||
out->pieces = calloc((size_t)cap, sizeof(patchdl_piece_t));
|
||||
if (!out->pieces) { free(buf.data); return -1; }
|
||||
|
||||
/* Sanity caps: refuse a manifest that would let a CDN drive multi-TB
|
||||
allocations or millions of pieces. The biggest real PS5 patch we've
|
||||
seen is ~70 GB / 18 pieces; these limits leave room to spare. */
|
||||
if (cap > PATCHDL_MAX_PIECES) { free(buf.data); return -1; }
|
||||
|
||||
p = pieces;
|
||||
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end) && n < cap) {
|
||||
char url[768] = {0};
|
||||
unsigned long long sz = 0, off = 0;
|
||||
const char *obj_end = strchr(p, '}');
|
||||
const char *piece_limit = (obj_end && (!pieces_end || obj_end < pieces_end))
|
||||
? obj_end : pieces_end;
|
||||
|
||||
if (json_string_after(p, "url", url, sizeof(url)))
|
||||
if (json_string_after(p, "url", url, sizeof(url), piece_limit))
|
||||
break;
|
||||
json_u64_after(p, "fileSize", &sz);
|
||||
if (json_u64_after(p, "fileOffset", &off) != 0)
|
||||
json_u64_after(p, "fileSize", &sz, piece_limit);
|
||||
if (json_u64_after(p, "fileOffset", &off, piece_limit) != 0)
|
||||
off = (unsigned long long)running; /* no offset -> assume contiguous */
|
||||
|
||||
/* Validate tiling: pieces must be in order, contiguous, non-empty. */
|
||||
if ((long long)off != running || sz == 0) {
|
||||
/* Validate tiling: pieces must be in order, contiguous, non-empty,
|
||||
and each individually under the per-piece cap. */
|
||||
if ((long long)off != running || sz == 0 || sz > PATCHDL_MAX_PIECE_BYTES) {
|
||||
patchdl_manifest_free(out);
|
||||
free(buf.data);
|
||||
return -1;
|
||||
}
|
||||
if ((unsigned long long)running + sz > PATCHDL_MAX_TOTAL_BYTES) {
|
||||
patchdl_manifest_free(out);
|
||||
free(buf.data);
|
||||
return -1;
|
||||
@@ -894,7 +1029,7 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||
out->pieces[n].offset = (long long)off;
|
||||
out->pieces[n].size = (long long)sz;
|
||||
json_string_after(p, "hashValue", out->pieces[n].hash,
|
||||
sizeof(out->pieces[n].hash));
|
||||
sizeof(out->pieces[n].hash), piece_limit);
|
||||
if (!out->pieces[n].url) {
|
||||
patchdl_manifest_free(out);
|
||||
free(buf.data);
|
||||
@@ -944,6 +1079,9 @@ patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
|
||||
+6
-2
@@ -6,6 +6,7 @@ typedef struct {
|
||||
char *data;
|
||||
size_t size;
|
||||
size_t cap;
|
||||
size_t max; /* 0 = unbounded (legacy). Otherwise write_cb fails past this. */
|
||||
} patchdl_buf_t;
|
||||
|
||||
patchdl_buf_t *patchdl_buf_new(void);
|
||||
@@ -51,11 +52,14 @@ typedef struct {
|
||||
|
||||
/* Download one whole piece and pwrite it into `fd` at `file_offset`. Concurrent
|
||||
non-overlapping pieces of the same fd are safe. Returns 0 on success (and
|
||||
fdatasyncs fd), -1 on network/IO/abort, -2 on a SHA-256 mismatch. */
|
||||
fdatasyncs fd), -1 on network/IO/abort, -2 on a SHA-256 mismatch.
|
||||
`curl_rc_out`/`http_code_out` (either may be NULL) receive the last libcurl
|
||||
CURLcode + HTTP status for failure diagnosis. */
|
||||
int patchdl_http_download_piece(const char *url, int fd,
|
||||
long long file_offset, long long file_size,
|
||||
const char *expected_sha256_or_null,
|
||||
patchdl_piece_ctx_t *ctx);
|
||||
patchdl_piece_ctx_t *ctx,
|
||||
int *curl_rc_out, long *http_code_out);
|
||||
|
||||
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex
|
||||
(caller provides >= 65 bytes). Returns 0 on success. */
|
||||
|
||||
+4
-1
@@ -65,7 +65,10 @@ patchdl_proc_kill_others(const char *name) {
|
||||
int killed = 0;
|
||||
pid_t pid;
|
||||
|
||||
while ((pid = find_pid(name)) > 0) {
|
||||
/* Bound the loop: at startup we expect 0-1 stale instance. A pathological
|
||||
proc table (or kill returning success but the process not exiting) would
|
||||
otherwise stall startup for sleep(1) × N. */
|
||||
while (killed < 8 && (pid = find_pid(name)) > 0) {
|
||||
if (kill(pid, SIGKILL))
|
||||
break;
|
||||
killed++;
|
||||
|
||||
@@ -27,6 +27,14 @@ lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
/* Defense in depth: the TSV file lives under /data/patchdl, writable
|
||||
by anyone with /data access. patchdl_http_get also enforces
|
||||
host_allowed, but rejecting non-https / non-Sony schemes here means
|
||||
a poisoned line can't even reach the network layer. */
|
||||
if (strncmp(url, "https://", 8) != 0) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
memcpy(url_out, url, len + 1);
|
||||
fclose(fp);
|
||||
return 0;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <dirent.h>
|
||||
#include <limits.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -389,6 +390,15 @@ merge_appdb(patchdl_title_t *arr, size_t cnt) {
|
||||
patchdl_appdb_free(info);
|
||||
}
|
||||
|
||||
/* See patchdl_scan_lock — both vnode-swap entry points return -1 / NULL once
|
||||
this is set so a late rescan call can't race the running MHD threads. */
|
||||
static _Atomic int g_scan_locked = 0;
|
||||
|
||||
void
|
||||
patchdl_scan_lock(void) {
|
||||
atomic_store(&g_scan_locked, 1);
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
patchdl_title_t *arr;
|
||||
@@ -400,6 +410,8 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||
struct statfs *mounts = NULL;
|
||||
int nmounts;
|
||||
|
||||
if (atomic_load(&g_scan_locked)) return -1;
|
||||
|
||||
arr = calloc(MAX_TITLES, sizeof(*arr));
|
||||
if (!arr) return -1;
|
||||
|
||||
@@ -467,6 +479,8 @@ patchdl_scan_debug_json(void) {
|
||||
char tmp[2048];
|
||||
pid_t pid = getpid();
|
||||
intptr_t saved_root = 0, root_vnode;
|
||||
|
||||
if (atomic_load(&g_scan_locked)) return NULL;
|
||||
int using_vswap = 0;
|
||||
struct statfs *mounts = NULL;
|
||||
int nmounts;
|
||||
|
||||
@@ -24,6 +24,7 @@ typedef struct {
|
||||
char latest_version[16];
|
||||
char latest_required_fw[16];
|
||||
char patch_url[512]; /* manifest_url if present, otherwise pkg URL */
|
||||
char delta_url[512]; /* DP.pkg bootstrap URL (always a PKG) */
|
||||
char patch_title_id[16]; /* target title id from version.xml */
|
||||
char patch_storage_title_id[16]; /* title id embedded in delta_url */
|
||||
int verxml_done;
|
||||
@@ -39,3 +40,9 @@ const char *patchdl_source_str(patchdl_source_t src);
|
||||
/* Diagnostic: malloc'd JSON dump of the mount table + scan-base directory
|
||||
listings. Caller frees. */
|
||||
char *patchdl_scan_debug_json(void);
|
||||
|
||||
/* Mark scan/debug as no longer safe to call (must be set after MHD worker
|
||||
threads come up — patchdl_scan performs a process-wide vnode swap that
|
||||
would race any concurrent thread). After this is set, both entry points
|
||||
return immediately. Call once during startup, after MHD_start_daemon. */
|
||||
void patchdl_scan_lock(void);
|
||||
@@ -0,0 +1,205 @@
|
||||
#include "patchdl_tile.h"
|
||||
#include "patchdl_install.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
|
||||
/* Embed the tile assets into .rodata directly via .incbin — no codegen step,
|
||||
no Python helper, no second translation unit. Matches itsPLK's pattern. */
|
||||
#define INCASSET(name, file) \
|
||||
__asm__(".section .rodata\n" \
|
||||
".global " #name "\n" \
|
||||
".global " #name "_end\n" \
|
||||
".global " #name "_size\n" \
|
||||
".align 16\n" #name ":\n" \
|
||||
".incbin \"" file "\"\n" #name "_end:\n" #name "_size:\n" \
|
||||
".quad " #name "_end - " #name "\n" \
|
||||
".previous\n"); \
|
||||
extern const uint8_t name[]; \
|
||||
extern const size_t name##_size;
|
||||
|
||||
INCASSET(tile_param_json, "assets/param.json");
|
||||
INCASSET(tile_icon0_png, "assets/icon0.png");
|
||||
|
||||
#define TILE_TITLE_ID "PTDL00001"
|
||||
|
||||
/* Forward decls — we resolve sceAppInstUtilInitialize/AppInstallTitleDir at
|
||||
runtime via the kernel dynlib helpers so the ELF stays loader-friendly. */
|
||||
typedef int (*ai_init_fn)(void);
|
||||
typedef int (*ai_install_dir_fn)(const char *title_id, const char *parent_dir,
|
||||
void *opts);
|
||||
|
||||
static intptr_t
|
||||
dynsym_by_name(const char *sym) {
|
||||
uint32_t h = 0;
|
||||
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
|
||||
return kernel_dynlib_dlsym(-1, h, sym);
|
||||
}
|
||||
|
||||
static intptr_t
|
||||
dynsym_by_nid(const char *nid) {
|
||||
uint32_t h = 0;
|
||||
if (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) < 0) return 0;
|
||||
return kernel_dynlib_resolve(-1, h, nid);
|
||||
}
|
||||
|
||||
static int
|
||||
write_all(const char *path, const uint8_t *data, size_t size) {
|
||||
int fd;
|
||||
ssize_t w;
|
||||
size_t off = 0;
|
||||
|
||||
/* O_NOFOLLOW + 0600: don't follow a symlink at the destination, and don't
|
||||
create the file with the libc default 0666 mode. Same pattern as the
|
||||
net layer's fopen_safe. */
|
||||
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||
if (fd < 0) return -1;
|
||||
while (off < size) {
|
||||
w = write(fd, data + off, size - off);
|
||||
if (w < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
off += (size_t)w;
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
file_matches(const char *path, const uint8_t *expected, size_t expected_size) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
int fd;
|
||||
ssize_t n;
|
||||
int match = 0;
|
||||
|
||||
if (stat(path, &st) != 0) return 0;
|
||||
if ((size_t)st.st_size != expected_size) return 0;
|
||||
|
||||
fd = open(path, O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return 0;
|
||||
|
||||
buf = malloc(expected_size);
|
||||
if (!buf) { close(fd); return 0; }
|
||||
|
||||
n = read(fd, buf, expected_size);
|
||||
close(fd);
|
||||
if (n == (ssize_t)expected_size && memcmp(buf, expected, expected_size) == 0)
|
||||
match = 1;
|
||||
|
||||
free(buf);
|
||||
return match;
|
||||
}
|
||||
|
||||
int
|
||||
patchdl_tile_install_if_needed(char *msg, size_t msg_sz) {
|
||||
char base_dir[128];
|
||||
char sce_sys_dir[160];
|
||||
char param_path[192];
|
||||
char icon_path[192];
|
||||
|
||||
ai_init_fn ai_initialize = NULL;
|
||||
ai_install_dir_fn ai_install_title = NULL;
|
||||
int rc;
|
||||
|
||||
snprintf(base_dir, sizeof base_dir, "/user/app/%s", TILE_TITLE_ID);
|
||||
snprintf(sce_sys_dir, sizeof sce_sys_dir, "/user/app/%s/sce_sys", TILE_TITLE_ID);
|
||||
snprintf(param_path, sizeof param_path, "/user/app/%s/sce_sys/param.json", TILE_TITLE_ID);
|
||||
snprintf(icon_path, sizeof icon_path, "/user/app/%s/sce_sys/icon0.png", TILE_TITLE_ID);
|
||||
|
||||
/* stat-guard: if everything on disk already matches, do nothing. Re-running
|
||||
the install API every payload start would be wasted work and burns the
|
||||
only safe path through Sony's installer state machine. */
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(base_dir, &st) == 0 &&
|
||||
file_matches(param_path, tile_param_json, tile_param_json_size) &&
|
||||
file_matches(icon_path, tile_icon0_png, tile_icon0_png_size)) {
|
||||
snprintf(msg, msg_sz, "tile already installed and up to date");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* AppInstUtil is loaded lazily by the install backend thread. Poke it +
|
||||
poll briefly so libSceAppInstUtil.sprx is mapped before we try to
|
||||
resolve symbols out of it. Bounded to a few seconds so a stuck init
|
||||
doesn't wedge an MHD worker forever. */
|
||||
{
|
||||
char ready_msg[128];
|
||||
int ready = -1;
|
||||
for (int i = 0; i < 60 && ready != 0; i++) {
|
||||
ready = patchdl_install_backend_check(ready_msg, sizeof ready_msg);
|
||||
if (ready != 0) usleep(250 * 1000);
|
||||
}
|
||||
if (ready != 0) {
|
||||
snprintf(msg, msg_sz,
|
||||
"install backend not ready: %s", ready_msg);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolve the install API now so we can fail fast before touching disk.
|
||||
itsPLK uses the NID (Wudg3Xe3heE) because the symbol export is
|
||||
Sony-private; try both, NID first since it survives a stripped sprx. */
|
||||
ai_install_title = (ai_install_dir_fn)dynsym_by_nid("Wudg3Xe3heE");
|
||||
if (!ai_install_title)
|
||||
ai_install_title = (ai_install_dir_fn)dynsym_by_name(
|
||||
"sceAppInstUtilAppInstallTitleDir");
|
||||
if (!ai_install_title) {
|
||||
snprintf(msg, msg_sz, "sceAppInstUtilAppInstallTitleDir not resolved");
|
||||
return -1;
|
||||
}
|
||||
|
||||
ai_initialize = (ai_init_fn)dynsym_by_name("sceAppInstUtilInitialize");
|
||||
if (ai_initialize) {
|
||||
rc = ai_initialize();
|
||||
/* SCE_OK == 0; a non-zero rc here usually means "already initialised"
|
||||
in this process, which is fine. We only bail on a clearly fatal
|
||||
code (anything that isn't already the success case). */
|
||||
if (rc != 0 && rc != 0x80B21161 /* ALREADY_INITIALIZED */) {
|
||||
snprintf(msg, msg_sz,
|
||||
"sceAppInstUtilInitialize failed 0x%08x", (unsigned)rc);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mkdir(base_dir, 0755) && errno != EEXIST) {
|
||||
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", base_dir, errno);
|
||||
return -1;
|
||||
}
|
||||
if (mkdir(sce_sys_dir, 0755) && errno != EEXIST) {
|
||||
snprintf(msg, msg_sz, "mkdir %s failed errno=%d", sce_sys_dir, errno);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (write_all(param_path, tile_param_json, tile_param_json_size)) {
|
||||
snprintf(msg, msg_sz, "write param.json failed errno=%d", errno);
|
||||
return -1;
|
||||
}
|
||||
if (write_all(icon_path, tile_icon0_png, tile_icon0_png_size)) {
|
||||
snprintf(msg, msg_sz, "write icon0.png failed errno=%d", errno);
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = ai_install_title(TILE_TITLE_ID, "/user/app/", NULL);
|
||||
if (rc != 0) {
|
||||
snprintf(msg, msg_sz,
|
||||
"AppInstallTitleDir(%s) returned 0x%08x",
|
||||
TILE_TITLE_ID, (unsigned)rc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(msg, msg_sz, "tile installed (%s)", TILE_TITLE_ID);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Install / refresh the PatchDL home-screen tile.
|
||||
*
|
||||
* Approach (from itsPLK's ps5-payload-manager/app_installer.c, which adapted
|
||||
* John Tornblom's ftpsrv work): write param.json + icon0.png into
|
||||
* /user/app/<TITLE_ID>/sce_sys/
|
||||
* then call sceAppInstUtilAppInstallTitleDir(title_id, "/user/app/", 0)
|
||||
* which registers the directory as an app. The tile's deeplinkUri opens
|
||||
* Sony's WebKit browser at http://127.0.0.1:12880/, i.e. PatchDL's own UI
|
||||
* — only useful while the ELF is running.
|
||||
*
|
||||
* No PKG, no code signing, no debug-magic. Files only — Sony's installer
|
||||
* registers the directory as an app.
|
||||
*
|
||||
* stat-guard: the asset bytes are diffed against the on-disk copy first;
|
||||
* if nothing changed the install API isn't called at all (avoids a costly
|
||||
* re-register every payload start, and avoids the dangerous CancelInstall
|
||||
* code path). Returns 0 on success or when nothing needed doing.
|
||||
*/
|
||||
int patchdl_tile_install_if_needed(char *msg, size_t msg_sz);
|
||||
@@ -1,3 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.3"
|
||||
#define PATCHDL_VERSION "0.0.6"
|
||||
+17
-5
@@ -66,14 +66,20 @@ ver_gt(const char *a, const char *b) {
|
||||
from a string such as nptitleid, manifest_url, or delta_url. */
|
||||
static void
|
||||
extract_title_id(const char *s, char *out, size_t sz) {
|
||||
size_t len;
|
||||
out[0] = '\0';
|
||||
if (sz < 10 || !s) return;
|
||||
for (const char *p = s; p[0] && p[8]; p++) {
|
||||
len = strlen(s);
|
||||
if (len < 9) return;
|
||||
/* `len - 9` is the last position where a 9-char id can still fit; this
|
||||
avoids reading p[8] past the NUL terminator. */
|
||||
for (size_t i = 0; i <= len - 9; i++) {
|
||||
const char *p = s + i;
|
||||
int ok = 1;
|
||||
for (int i = 0; i < 4 && ok; i++)
|
||||
if (p[i] < 'A' || p[i] > 'Z') ok = 0;
|
||||
for (int i = 4; i < 9 && ok; i++)
|
||||
if (p[i] < '0' || p[i] > '9') ok = 0;
|
||||
for (int k = 0; k < 4 && ok; k++)
|
||||
if (p[k] < 'A' || p[k] > 'Z') ok = 0;
|
||||
for (int k = 4; k < 9 && ok; k++)
|
||||
if (p[k] < '0' || p[k] > '9') ok = 0;
|
||||
if (ok) {
|
||||
memcpy(out, p, 9);
|
||||
out[9] = '\0';
|
||||
@@ -148,6 +154,8 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||
sizeof(out->compatible_version) - 1);
|
||||
strncpy(out->compatible_url, murl[0] ? murl : durl,
|
||||
sizeof(out->compatible_url) - 1);
|
||||
if (durl[0])
|
||||
strncpy(out->delta_url, durl, sizeof(out->delta_url) - 1);
|
||||
extract_title_id(durl, out->compatible_storage_title,
|
||||
sizeof(out->compatible_storage_title));
|
||||
if (root_title[0]) {
|
||||
@@ -175,6 +183,10 @@ patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||
if (!url || !out) return -1;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* version.xml is a few KB in practice; cap so a misbehaving CDN can't
|
||||
slurp unbounded RAM into the buffer. */
|
||||
memset(&buf, 0, sizeof(buf));
|
||||
buf.max = 4 * 1024 * 1024;
|
||||
if (patchdl_http_get(url, &buf)) return -1;
|
||||
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ typedef struct {
|
||||
char latest_version[16]; /* highest pkg overall, or "" */
|
||||
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
|
||||
char compatible_url[512]; /* manifest_url if present, otherwise pkg URL */
|
||||
char delta_url[512]; /* DP.pkg bootstrap URL (always a PKG, never JSON) */
|
||||
char compatible_title[16]; /* target title id from version.xml/manifest_url */
|
||||
char compatible_storage_title[16]; /* title id embedded in delta_url storage path */
|
||||
} patchdl_verinfo_t;
|
||||
|
||||
+401
-49
@@ -5,7 +5,9 @@
|
||||
#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>
|
||||
@@ -94,6 +96,8 @@ typedef struct dl_job {
|
||||
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;
|
||||
|
||||
@@ -162,7 +166,10 @@ json_get_int(const char *s, const char *key, int dflt) {
|
||||
return (int)strtol(p, NULL, 10);
|
||||
}
|
||||
|
||||
/* Find `"key":"value"` and copy value into out. */
|
||||
/* 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';
|
||||
@@ -177,7 +184,31 @@ json_get_str(const char *s, const char *key, char *out, size_t sz) {
|
||||
if (*p != '"') return;
|
||||
p++;
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i + 1 < sz) out[i++] = *p++;
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -252,7 +283,14 @@ queue_buffer(struct MHD_Connection *conn, unsigned int status,
|
||||
enum MHD_Result ret;
|
||||
|
||||
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
|
||||
if (!resp) return MHD_NO;
|
||||
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");
|
||||
@@ -305,6 +343,69 @@ path_segment_safe(const char *s) {
|
||||
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;
|
||||
@@ -495,8 +596,9 @@ build_status_json(void) {
|
||||
"\"dns_guard\":\"Active\","
|
||||
"\"resolver\":\"Internal allowlist\","
|
||||
"\"free_space_mb\":%lld,"
|
||||
"\"download_dir\":\"/data/patchdl (internal)\"}",
|
||||
g_fw.str, g_fw.bin, data_free_mb());
|
||||
"\"download_dir\":\"/data/patchdl (internal)\","
|
||||
"\"version\":\"%s\"}",
|
||||
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -664,6 +766,9 @@ build_titles_json(void) {
|
||||
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. */
|
||||
@@ -731,6 +836,8 @@ build_downloads_json(void) {
|
||||
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, "]");
|
||||
@@ -745,9 +852,12 @@ build_downloads_json(void) {
|
||||
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];
|
||||
const char *base = strrchr(patch_url, '/');
|
||||
if (!path_segment_safe(title_id))
|
||||
return;
|
||||
/* Never delete a directory the download pool still owns (a queued/active/
|
||||
@@ -759,9 +869,11 @@ cleanup_installed_download(const char *title_id, const char *patch_url) {
|
||||
return;
|
||||
}
|
||||
pthread_mutex_unlock(&g_pool.mtx);
|
||||
base = base ? base + 1 : "patch.pkg";
|
||||
snprintf(dir, sizeof(dir), "/data/patchdl/%s", title_id);
|
||||
snprintf(path, sizeof(path), "%s/%s", dir, base);
|
||||
/* 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);
|
||||
}
|
||||
@@ -794,6 +906,8 @@ verxml_fetch_thread(void *arg) {
|
||||
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,
|
||||
@@ -815,6 +929,7 @@ verxml_fetch_thread(void *arg) {
|
||||
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,
|
||||
@@ -829,6 +944,8 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
|
||||
*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);
|
||||
@@ -871,7 +988,12 @@ set_title_enabled(struct MHD_Connection *conn, const char *title_id, int en) {
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
|
||||
}
|
||||
|
||||
/* Local on-disk path a title's patch downloads to / installs from. */
|
||||
/* 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) {
|
||||
@@ -879,11 +1001,15 @@ title_pkg_path(const char *title_id, const char *patch_url,
|
||||
char name[192];
|
||||
size_t n;
|
||||
|
||||
base = base ? base + 1 : "patch.pkg";
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1010,7 +1136,11 @@ write_job_state(const dl_job_t *job) {
|
||||
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];
|
||||
/* 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;
|
||||
@@ -1225,6 +1355,7 @@ admit_next(void) {
|
||||
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;
|
||||
@@ -1332,15 +1463,22 @@ dl_worker(void *arg) {
|
||||
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);
|
||||
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
|
||||
@@ -1374,10 +1512,12 @@ dl_worker(void *arg) {
|
||||
/* ---------- 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. */
|
||||
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,
|
||||
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;
|
||||
@@ -1446,10 +1586,13 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
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);
|
||||
|
||||
{
|
||||
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;
|
||||
@@ -1539,10 +1682,10 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
|
||||
|
||||
static enum MHD_Result
|
||||
do_install(struct MHD_Connection *conn, const char *title_id,
|
||||
patchdl_source_t src, const char *patch_url,
|
||||
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[640];
|
||||
char dest[320], msg[256], resp[720];
|
||||
int rc;
|
||||
|
||||
if (!enabled)
|
||||
@@ -1555,13 +1698,11 @@ do_install(struct MHD_Connection *conn, const char *title_id,
|
||||
return queue_json(conn, MHD_HTTP_FORBIDDEN,
|
||||
"{\"ok\":false,\"reason\":\"install_not_allowed_for_source\"}");
|
||||
|
||||
if (!patch_url[0])
|
||||
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.
|
||||
CDN storage under a different regional/master id is valid and has
|
||||
already been normalized by patchdl_verxml. */
|
||||
/* 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\","
|
||||
@@ -1570,10 +1711,38 @@ do_install(struct MHD_Connection *conn, const char *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_local_pkg(dest, title_id, patch_storage_title_id,
|
||||
content_id, msg, sizeof(msg));
|
||||
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);
|
||||
@@ -1611,6 +1780,7 @@ 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};
|
||||
@@ -1631,7 +1801,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
||||
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),
|
||||
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),
|
||||
@@ -1648,7 +1820,8 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
||||
return set_title_enabled(conn, title_id, 0);
|
||||
|
||||
if (!strcmp(action, "download"))
|
||||
return do_download(conn, title_id, src, patch_url, name, version, enabled);
|
||||
return do_download(conn, title_id, src, patch_url, delta_url,
|
||||
name, version, enabled);
|
||||
|
||||
if (!strcmp(action, "cancel"))
|
||||
return do_cancel(conn, title_id);
|
||||
@@ -1661,8 +1834,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
||||
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
|
||||
|
||||
if (!strcmp(action, "install"))
|
||||
return do_install(conn, title_id, src, patch_url, patch_title_id,
|
||||
patch_storage_title_id, content_id, enabled);
|
||||
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");
|
||||
}
|
||||
@@ -1670,10 +1844,15 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
||||
/* ---------- 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. */
|
||||
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
|
||||
@@ -1692,8 +1871,14 @@ 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]) {
|
||||
@@ -1706,8 +1891,12 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
|
||||
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);
|
||||
{
|
||||
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;
|
||||
@@ -1727,6 +1916,16 @@ handle_config_post(struct MHD_Connection *conn, const char *body) {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1750,23 +1949,103 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
}
|
||||
|
||||
if (*upload_data_size) { /* a body chunk: append it */
|
||||
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;
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1804,21 +2083,47 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(p));
|
||||
}
|
||||
|
||||
if (!strcmp(url, "/api/pkgdiag"))
|
||||
return queue_json(conn, MHD_HTTP_OK, g_pkg_diag_json);
|
||||
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}, pti[32], psti[32], cid[64], nm[128], ver[16];
|
||||
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 (!get_title_action_info(tid, &src, purl, sizeof purl, pti, sizeof pti,
|
||||
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,
|
||||
@@ -1845,14 +2150,19 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
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}, pti[32], psti[32], cid[64], nm[128], ver[16];
|
||||
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 (!get_title_action_info(tid, &src, purl, sizeof purl, pti, sizeof pti,
|
||||
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,
|
||||
@@ -1893,12 +2203,17 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
(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}, pti[32], psti[32], cid[64], nm[128], ver[16];
|
||||
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 (!get_title_action_info(tid, &src, purl, sizeof purl, pti, sizeof pti,
|
||||
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\"}");
|
||||
@@ -1908,9 +2223,14 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
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\",\"msg\":\"%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, msg);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1979,6 +2299,15 @@ patchdl_websrv_start(unsigned short port) {
|
||||
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();
|
||||
@@ -2023,6 +2352,16 @@ patchdl_websrv_start(unsigned short port) {
|
||||
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) {
|
||||
@@ -2039,6 +2378,11 @@ patchdl_websrv_start(unsigned short port) {
|
||||
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;
|
||||
@@ -2070,11 +2414,19 @@ patchdl_websrv_stop(void) {
|
||||
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);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ GET /api/config
|
||||
POST /api/config
|
||||
GET /api/titles
|
||||
GET /api/downloads
|
||||
GET /api/installstatus
|
||||
GET /api/pkgmeta/:title_id
|
||||
GET /api/pkgverify/:title_id
|
||||
POST /api/titles/:title_id/check
|
||||
POST /api/titles/:title_id/download
|
||||
POST /api/titles/:title_id/install
|
||||
@@ -88,3 +91,8 @@ For PS5 game updates, the backend may turn a Sony `manifest_url` into a merged
|
||||
local `.pkg` by downloading all manifest pieces. The `delta_url` `*-DP.pkg` is
|
||||
not shown as a separate user action because it can bootstrap the storage/master
|
||||
title instead of the installed regional target.
|
||||
|
||||
If `patch_storage_match` is false, the UI keeps download/verify available but
|
||||
does not offer install or auto-install. Those shared-master packages are signed
|
||||
for a different storage title id and cannot be retargeted by standalone
|
||||
AppInstUtil on firmware 11.60.
|
||||
+207
-10
@@ -3,6 +3,7 @@ const API = {
|
||||
titles: "/api/titles",
|
||||
config: "/api/config",
|
||||
downloads: "/api/downloads",
|
||||
installStatus: "/api/installstatus",
|
||||
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
|
||||
};
|
||||
|
||||
@@ -49,6 +50,7 @@ const fallback = {
|
||||
installed_version: "01.032.000", compatible_version: "01.041.000",
|
||||
latest_version: "01.041.000", latest_required_fw: "11.60",
|
||||
source_type: "official", source_path: "/system_ex/app/PPSA01628_00",
|
||||
patch_storage_match: true,
|
||||
mount_from: "/dev/ssd0.system_ex", enabled: true, status: "available",
|
||||
},
|
||||
{
|
||||
@@ -57,6 +59,7 @@ const fallback = {
|
||||
installed_version: "01.004.000", compatible_version: "01.004.000",
|
||||
latest_version: "01.004.000", latest_required_fw: "10.01",
|
||||
source_type: "external", source_path: "/system_data/priv/appmeta/external/PPSA01284_00",
|
||||
patch_storage_match: true,
|
||||
mount_from: "/mnt/ext0/user/app/PPSA01284_00", enabled: true, status: "up_to_date",
|
||||
},
|
||||
{
|
||||
@@ -65,6 +68,7 @@ const fallback = {
|
||||
installed_version: "01.000.000", compatible_version: "01.006.000",
|
||||
latest_version: "01.009.000", latest_required_fw: "12.00",
|
||||
source_type: "shadowmount", source_path: "/system_ex/app/PPSA90001_00",
|
||||
patch_storage_match: true,
|
||||
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "available",
|
||||
},
|
||||
],
|
||||
@@ -79,12 +83,13 @@ let state = {
|
||||
downloads: fallback.downloads,
|
||||
logs: fallback.logs,
|
||||
view: "games",
|
||||
filter: "all",
|
||||
filter: "updatable",
|
||||
query: "",
|
||||
usingFallback: false,
|
||||
};
|
||||
|
||||
let downloadPollTimer = null;
|
||||
let installPollTimer = null;
|
||||
let emptyPolls = 0;
|
||||
const dlMeta = {}; // per-title speed tracking: { bytes, t, speed }
|
||||
|
||||
@@ -121,6 +126,16 @@ function bindElements() {
|
||||
connMinus: document.getElementById("connMinus"),
|
||||
connPlus: document.getElementById("connPlus"),
|
||||
refreshBtn: document.getElementById("refreshBtn"),
|
||||
updateAllBtn: document.getElementById("updateAllBtn"),
|
||||
brandVersion: document.getElementById("brandVersion"),
|
||||
globalDl: document.getElementById("globalDl"),
|
||||
globalDlState: document.getElementById("globalDlState"),
|
||||
globalDlName: document.getElementById("globalDlName"),
|
||||
globalDlPosition: document.getElementById("globalDlPosition"),
|
||||
globalDlPct: document.getElementById("globalDlPct"),
|
||||
globalDlSpeed: document.getElementById("globalDlSpeed"),
|
||||
globalDlEta: document.getElementById("globalDlEta"),
|
||||
globalDlBar: document.getElementById("globalDlBar"),
|
||||
saveBtn: document.getElementById("saveBtn"),
|
||||
clearLogBtn: document.getElementById("clearLogBtn"),
|
||||
toast: document.getElementById("toast"),
|
||||
@@ -150,6 +165,7 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
els.refreshBtn.addEventListener("click", loadInitialData);
|
||||
if (els.updateAllBtn) els.updateAllBtn.addEventListener("click", updateAll);
|
||||
els.saveBtn.addEventListener("click", saveConfig);
|
||||
els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); });
|
||||
if (els.connMinus)
|
||||
@@ -201,6 +217,7 @@ async function loadInitialData() {
|
||||
if (downloads.some((j) => j.state === "active" || j.state === "queued") ||
|
||||
state.titles.some((g) => g._localDownloading))
|
||||
startDownloadPolling();
|
||||
if (state.titles.some((g) => g.installing)) startInstallPolling();
|
||||
showToast(state.usingFallback ? "Demo data loaded. API is not reachable yet." : "Data refreshed.");
|
||||
}
|
||||
|
||||
@@ -232,6 +249,8 @@ function renderStatus() {
|
||||
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download target";
|
||||
if (els.railFw) els.railFw.textContent = `FW ${state.status.firmware || "--"}`;
|
||||
if (els.railSpace) els.railSpace.textContent = `${space} free`;
|
||||
if (els.brandVersion)
|
||||
els.brandVersion.textContent = state.status.version ? `v${state.status.version}` : "v--";
|
||||
}
|
||||
|
||||
const CONN_MIN = 1, CONN_MAX = 16;
|
||||
@@ -288,6 +307,7 @@ function renderSettings() {
|
||||
/* ---------------- games ---------------- */
|
||||
|
||||
function renderGames() {
|
||||
renderGlobalStatus();
|
||||
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
|
||||
els.gameGrid.replaceChildren();
|
||||
|
||||
@@ -303,12 +323,17 @@ function renderGames() {
|
||||
|
||||
// Mutually-exclusive bucket per game for the filter chips.
|
||||
function gameCategory(game) {
|
||||
// Updating is the queue — jobs waiting for a pool slot. An actively
|
||||
// downloading game stays under Updatable so you don't lose sight of it
|
||||
// while drilling into the queue; same for paused (the user knows where
|
||||
// it is and can resume from the card) and installing.
|
||||
if (game._jobState === "queued") return "updating";
|
||||
// checking is transient (version lookup still running); keep it visible under
|
||||
// Updatable rather than letting it fall out of every specific filter.
|
||||
if (game.installing || game.downloading || game.status === "checking") return "updatable";
|
||||
if (game.status === "checking") return "updatable";
|
||||
if (game.patch_title_match === false) return "blocked";
|
||||
if (!sourcePolicy(game).allow_install) return "blocked";
|
||||
if (game.status === "available") return "updatable";
|
||||
if (game.status === "available" && isDownloadAllowed(game)) return "updatable";
|
||||
if (!sourcePolicy(game).allow_install && !sourcePolicy(game).allow_download) return "blocked";
|
||||
if (game.status === "incompatible_fw") return "needsfw";
|
||||
return "uptodate";
|
||||
}
|
||||
@@ -356,6 +381,7 @@ function createGameCard(game) {
|
||||
${game.downloading ? `<span class="pill live">Downloading</span>` : ""}
|
||||
${game.resumable && !game.downloading ? `<span class="pill warn">Paused</span>` : ""}
|
||||
${statusPill(game)}
|
||||
${storagePill(game)}
|
||||
${sourcePill(game)}
|
||||
</div>
|
||||
<div class="versions">
|
||||
@@ -391,6 +417,15 @@ function createGameCard(game) {
|
||||
<div class="progress-meta">${progressMetaHtml(d)}</div>
|
||||
`;
|
||||
card.appendChild(prog);
|
||||
} else if (game.installing) {
|
||||
const pct = Math.max(0, Math.min(100, Number(game.installProgress) || 0));
|
||||
const note = document.createElement("div");
|
||||
note.className = "card-progress";
|
||||
note.innerHTML = `
|
||||
<div class="progress"><i style="width:${pct}%"></i></div>
|
||||
<div class="progress-meta">${installProgressHtml(game)}</div>
|
||||
`;
|
||||
card.appendChild(note);
|
||||
} else if (game.resumable && game.partial_bytes > 0) {
|
||||
// ---- paused partial (survived a reboot) ----
|
||||
const note = document.createElement("div");
|
||||
@@ -433,13 +468,14 @@ function primaryButton(game) {
|
||||
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
|
||||
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
|
||||
if (game.patch_title_match === false) return null;
|
||||
if (!isInstallAllowed(game)) return null;
|
||||
if (game.resumable)
|
||||
if (game.resumable && isDownloadAllowed(game))
|
||||
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
|
||||
if (game.downloaded && game.status === "available")
|
||||
if (game.downloaded && game.status === "available" && isInstallAllowed(game))
|
||||
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
|
||||
if (game.downloaded && game.status === "available") return null;
|
||||
if (game.status !== "available") return null;
|
||||
return state.config.install_after_download
|
||||
if (!isDownloadAllowed(game)) return null;
|
||||
return state.config.install_after_download && isInstallAllowed(game)
|
||||
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
|
||||
: { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." };
|
||||
}
|
||||
@@ -463,6 +499,10 @@ function statusPill(game) {
|
||||
return `<span class="pill">No patch info</span>`;
|
||||
}
|
||||
|
||||
function storagePill(game) {
|
||||
return hasSharedStorage(game) ? `<span class="pill warn">Shared master</span>` : "";
|
||||
}
|
||||
|
||||
function sourcePill(game) {
|
||||
const info = sourceInfo(game);
|
||||
return `<span class="pill ${info.className}">${escapeHtml(info.label)}</span>`;
|
||||
@@ -493,6 +533,16 @@ function progressMetaHtml(d) {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function installProgressHtml(game) {
|
||||
const status = game.installStatus || "waiting";
|
||||
const done = Number(game.installDone) || 0;
|
||||
const total = Number(game.installTotal) || 0;
|
||||
const parts = [`<span>Status <b>${escapeHtml(status)}</b></span>`];
|
||||
if (total > 0) parts.push(`<span><b>${formatBytes(done)}</b> / ${formatBytes(total)}</span>`);
|
||||
parts.push(`<span><b>${Math.max(0, Math.min(100, Number(game.installProgress) || 0))}%</b></span>`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function startDownloadPolling() {
|
||||
emptyPolls = 0;
|
||||
if (downloadPollTimer) return;
|
||||
@@ -521,9 +571,11 @@ function reconcileFromJobs(jobs) {
|
||||
g._localDownloading = false;
|
||||
}
|
||||
if (g.downloading && !g._localDownloading) g.downloading = false;
|
||||
g._jobState = null;
|
||||
return;
|
||||
}
|
||||
g._localDownloading = false; // the pool now tracks it
|
||||
g._jobState = j.state;
|
||||
if (j.state === "active" || j.state === "queued") {
|
||||
g.downloading = true;
|
||||
g.resumable = false;
|
||||
@@ -538,7 +590,8 @@ function reconcileFromJobs(jobs) {
|
||||
g.resumable = false;
|
||||
g.downloaded = true;
|
||||
g._wasActive = false;
|
||||
if (state.config.install_after_download && !g.installing && !g._autoInstalled) {
|
||||
if (state.config.install_after_download && isInstallAllowed(g) &&
|
||||
!g.installing && !g._autoInstalled) {
|
||||
g._autoInstalled = true;
|
||||
doInstall(g);
|
||||
}
|
||||
@@ -558,6 +611,53 @@ function reconcileFromJobs(jobs) {
|
||||
});
|
||||
}
|
||||
|
||||
function startInstallPolling() {
|
||||
if (installPollTimer) return;
|
||||
installPollTimer = setInterval(refreshInstallStatus, 2000);
|
||||
refreshInstallStatus();
|
||||
}
|
||||
|
||||
function stopInstallPolling() {
|
||||
if (!installPollTimer) return;
|
||||
clearInterval(installPollTimer);
|
||||
installPollTimer = null;
|
||||
}
|
||||
|
||||
async function refreshInstallStatus() {
|
||||
let s;
|
||||
try {
|
||||
const response = await fetch(API.installStatus, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
s = await response.json();
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!s || !s.active) {
|
||||
if (!state.titles.some((g) => g.installing)) stopInstallPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const titleId = s.target_title_id || "";
|
||||
const game = state.titles.find((g) => g.title_id === titleId || g.title_id.slice(0, 9) === titleId.slice(0, 9));
|
||||
if (!game) return;
|
||||
|
||||
game.installing = !s.terminal;
|
||||
game.installStatus = s.status || "running";
|
||||
game.installProgress = Number(s.progress) || 0;
|
||||
game.installDone = Number(s.downloaded_size) || 0;
|
||||
game.installTotal = Number(s.total_size) || 0;
|
||||
renderGames();
|
||||
|
||||
if (s.terminal) {
|
||||
const ok = s.status === "playable";
|
||||
state.logs.push(`[${timeNow()}] Install ${ok ? "completed" : "stopped"} for ${game.title_id}: ${s.status || "unknown"}${s.error_code ? ` (0x${Number(s.error_code >>> 0).toString(16)})` : ""}`);
|
||||
renderLogs();
|
||||
stopInstallPolling();
|
||||
if (ok) loadInitialData();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDownloads() {
|
||||
let jobs;
|
||||
try {
|
||||
@@ -605,8 +705,62 @@ function downloadingIds() {
|
||||
return state.titles.filter((g) => g.downloading).map((g) => g.title_id).join(",");
|
||||
}
|
||||
|
||||
// Top-of-page status banner. Visible while any job in this batch is queued,
|
||||
// active, or has just finished (done jobs linger one or two polls before the
|
||||
// pool reaps them, which is what gives us the "5 of 9" position).
|
||||
function renderGlobalStatus() {
|
||||
const el = els.globalDl;
|
||||
if (!el) return;
|
||||
const jobs = state.downloads || [];
|
||||
const batch = jobs.filter((j) => ["queued", "active", "done"].includes(j.state));
|
||||
const flying = batch.filter((j) => j.state === "queued" || j.state === "active");
|
||||
|
||||
if (flying.length === 0) { el.hidden = true; return; }
|
||||
el.hidden = false;
|
||||
|
||||
const active = batch.find((j) => j.state === "active") || flying[0];
|
||||
const total = batch.length;
|
||||
const done = batch.filter((j) => j.state === "done").length;
|
||||
const pos = Math.min(total, done + 1);
|
||||
const isActive = active && active.state === "active";
|
||||
|
||||
// Eyebrow + name
|
||||
els.globalDlState.textContent = isActive ? "Downloading" : "Queued";
|
||||
els.globalDlName.textContent = active.name || active.title_id || "—";
|
||||
|
||||
// Position chip ("3 of 9") only when there's more than one game in this run
|
||||
if (total > 1) {
|
||||
els.globalDlPosition.hidden = false;
|
||||
els.globalDlPosition.textContent = `${pos} of ${total}`;
|
||||
} else {
|
||||
els.globalDlPosition.hidden = true;
|
||||
}
|
||||
|
||||
// Progress line
|
||||
const pct = pctOf(active);
|
||||
const speed = Number(active._speed) || 0;
|
||||
const done_ = Number(active.bytes) || 0;
|
||||
const total_ = Number(active.total_bytes) || 0;
|
||||
els.globalDlPct.innerHTML = isActive ? `<b>${pct}%</b>` : `<b>—</b> waiting`;
|
||||
els.globalDlSpeed.innerHTML = isActive && speed > 0
|
||||
? `<b>${formatBytes(speed)}/s</b>`
|
||||
: (isActive && !total_ ? "fetching manifest…" : "—");
|
||||
els.globalDlEta.innerHTML = isActive && speed > 0 && total_ > done_
|
||||
? `ETA <b>${formatEta((total_ - done_) / speed)}</b>`
|
||||
: "";
|
||||
|
||||
// Bar: animated indeterminate while fetching manifest, otherwise width=pct
|
||||
if (isActive && total_ > 0) {
|
||||
els.globalDlBar.classList.remove("is-indeterminate");
|
||||
els.globalDlBar.style.width = pct + "%";
|
||||
} else {
|
||||
els.globalDlBar.classList.add("is-indeterminate");
|
||||
}
|
||||
}
|
||||
|
||||
// Update the progress bar/meta in place to avoid rebuilding every card each tick.
|
||||
function applyDownloadProgress() {
|
||||
renderGlobalStatus();
|
||||
let needRender = false;
|
||||
state.downloads.forEach((d) => {
|
||||
// Only titles currently downloading render a progress tile; paused/done/error
|
||||
@@ -671,6 +825,30 @@ async function saveConfig() {
|
||||
|
||||
/* ---------------- actions (data layer) ---------------- */
|
||||
|
||||
// Queue a download for every game that has an available update AND could
|
||||
// actually be installed afterwards. Shadowmounts pass isDownloadAllowed but
|
||||
// fail isInstallAllowed (their app slot has no real source medium), so a
|
||||
// sweep would otherwise pull tens of GB that AppInstUtil will refuse — the
|
||||
// user picks those up by hand when the disc is ready. The server tolerates
|
||||
// duplicate requests, so a second click is harmless. If install_after_download
|
||||
// is on, the per-job auto-install pipeline kicks in once each download
|
||||
// finishes — no further client action needed.
|
||||
async function updateAll() {
|
||||
const targets = state.games.filter((g) =>
|
||||
g.status === "available" && isInstallAllowed(g) &&
|
||||
!g.downloading && !g.downloaded);
|
||||
if (!targets.length) {
|
||||
showToast("No installable updates to queue.");
|
||||
return;
|
||||
}
|
||||
showToast(`Queueing ${targets.length} update${targets.length === 1 ? "" : "s"}…`);
|
||||
for (const g of targets) {
|
||||
// Sequential await: the pool returns quickly (202 Accepted) and we want
|
||||
// a stable order in the queue, not a thundering-herd of concurrent POSTs.
|
||||
try { await doDownload(g); } catch (_) { /* per-job errors already toast */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue a download. The pool returns immediately (202); progress, completion
|
||||
// and (if configured) auto-install are driven by reconcileFromJobs() on poll.
|
||||
async function doDownload(game) {
|
||||
@@ -709,6 +887,8 @@ async function doDownload(game) {
|
||||
|
||||
async function doInstall(game) {
|
||||
game.installing = true;
|
||||
game.installStatus = "starting";
|
||||
game.installProgress = 0;
|
||||
game.downloaded = false; // the package is being consumed by the install
|
||||
renderGames();
|
||||
try {
|
||||
@@ -722,7 +902,8 @@ async function doInstall(game) {
|
||||
return false;
|
||||
}
|
||||
state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`);
|
||||
showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`);
|
||||
showToast(`${game.name}: installing update.`);
|
||||
startInstallPolling();
|
||||
renderGames(); renderLogs();
|
||||
return true;
|
||||
}
|
||||
@@ -787,11 +968,26 @@ function updateGame(titleId, patch) {
|
||||
/* ---------------- policy helpers ---------------- */
|
||||
|
||||
function isInstallBlocked(game) { return !sourcePolicy(game).allow_install; }
|
||||
function hasSharedStorage(game) {
|
||||
if (game.patch_storage_match === false) return true;
|
||||
const storage = (game.patch_storage_title_id || "").slice(0, 9);
|
||||
const target = (game.title_id || "").slice(0, 9);
|
||||
return Boolean(storage && target && storage !== target);
|
||||
}
|
||||
function isDownloadAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled !== false &&
|
||||
game.compatible_version &&
|
||||
game.patch_title_match !== false &&
|
||||
sourcePolicy(game).allow_download
|
||||
);
|
||||
}
|
||||
function isInstallAllowed(game) {
|
||||
return Boolean(
|
||||
game.enabled !== false &&
|
||||
game.compatible_version &&
|
||||
game.patch_title_match !== false &&
|
||||
!hasSharedStorage(game) &&
|
||||
sourcePolicy(game).allow_install
|
||||
);
|
||||
}
|
||||
@@ -820,6 +1016,7 @@ async function postJson(url, body) {
|
||||
|
||||
const REASON_TEXT = {
|
||||
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
|
||||
cross_region_storage_unsupported: "Patch bytes are signed for a shared master title; this standalone installer cannot retarget them.",
|
||||
install_not_allowed_for_source: "Install blocked for this source.",
|
||||
source_unknown: "Source unknown — blocked.",
|
||||
no_compatible_patch: "No compatible patch available.",
|
||||
|
||||
+31
-3
@@ -39,7 +39,7 @@
|
||||
<div class="brand-mark">PD</div>
|
||||
<div>
|
||||
<strong>PatchDL</strong>
|
||||
<span>by Knutwurst · v0.0.3</span>
|
||||
<span>by Knutwurst · <span id="brandVersion">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,12 +70,39 @@
|
||||
<h1>Games</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="primary-button" id="updateAllBtn" title="Download (and optionally install) every game that has an available, allowed update">
|
||||
<svg><use href="#icon-download"></use></svg>
|
||||
Update all
|
||||
</button>
|
||||
<button class="icon-button" id="refreshBtn" title="Refresh status and games" aria-label="Refresh">
|
||||
<svg><use href="#icon-refresh"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside id="globalDl" class="global-dl" hidden aria-live="polite">
|
||||
<div class="global-dl-row">
|
||||
<span class="global-dl-pulse" aria-hidden="true"></span>
|
||||
<div class="global-dl-text">
|
||||
<div class="global-dl-line1">
|
||||
<span class="global-dl-eyebrow" id="globalDlState">Downloading</span>
|
||||
<strong class="global-dl-name" id="globalDlName">—</strong>
|
||||
<span class="global-dl-position" id="globalDlPosition" hidden></span>
|
||||
</div>
|
||||
<div class="global-dl-line2">
|
||||
<span id="globalDlPct">0%</span>
|
||||
<span class="global-dl-sep" aria-hidden="true">·</span>
|
||||
<span id="globalDlSpeed">—</span>
|
||||
<span class="global-dl-sep" aria-hidden="true">·</span>
|
||||
<span id="globalDlEta">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="global-dl-track">
|
||||
<i class="global-dl-bar" id="globalDlBar" style="width:0%"></i>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="status-strip" aria-label="System status">
|
||||
<article class="metric">
|
||||
<span>Firmware</span>
|
||||
@@ -105,11 +132,12 @@
|
||||
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Filter">
|
||||
<button class="is-selected" data-filter="all" aria-pressed="true">All</button>
|
||||
<button data-filter="updatable" aria-pressed="false">Updatable</button>
|
||||
<button class="is-selected" data-filter="updatable" aria-pressed="true">Updatable</button>
|
||||
<button data-filter="updating" aria-pressed="false">Updating</button>
|
||||
<button data-filter="uptodate" aria-pressed="false">Up to date</button>
|
||||
<button data-filter="needsfw" aria-pressed="false">Needs FW</button>
|
||||
<button data-filter="blocked" aria-pressed="false">Can't update</button>
|
||||
<button data-filter="all" aria-pressed="false">All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -173,6 +173,105 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* ---------------- global download banner ---------------- */
|
||||
/* Sticky banner above the status strip, visible only while at least one
|
||||
download is queued/active. The pulse dot and gradient bar at the bottom
|
||||
echo the per-game card progress styling so the two read as one system. */
|
||||
.global-dl {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 18px 0;
|
||||
border: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(120% 80% at 0% 0%, var(--green-soft), transparent 60%),
|
||||
linear-gradient(180deg, rgba(56, 193, 114, 0.06), transparent 60%),
|
||||
var(--surface);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.global-dl[hidden] { display: none; }
|
||||
|
||||
.global-dl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.global-dl-pulse {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 0 var(--green);
|
||||
animation: globalDlPulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes globalDlPulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0.55); }
|
||||
70% { box-shadow: 0 0 0 14px rgba(56, 193, 114, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(56, 193, 114, 0); }
|
||||
}
|
||||
|
||||
.global-dl-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
|
||||
|
||||
.global-dl-line1 {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
min-width: 0; flex-wrap: wrap;
|
||||
}
|
||||
.global-dl-eyebrow {
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
font-size: 11px; font-weight: 600; color: var(--green-dark);
|
||||
}
|
||||
.global-dl-name {
|
||||
font-size: 16px; font-weight: 600; color: #fff;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
min-width: 0; flex: 1;
|
||||
}
|
||||
.global-dl-position {
|
||||
font-size: 12px; color: var(--muted);
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border);
|
||||
}
|
||||
.global-dl-position[hidden] { display: none; }
|
||||
|
||||
.global-dl-line2 {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.global-dl-line2 b { color: #d6dde4; font-weight: 600; }
|
||||
.global-dl-sep { opacity: 0.5; }
|
||||
|
||||
.global-dl-track {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
margin: 0 -18px; /* extend bar edge-to-edge */
|
||||
background: rgba(56, 193, 114, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.global-dl-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--green) 0%, var(--green-dark) 100%);
|
||||
box-shadow: 0 0 12px rgba(56, 193, 114, 0.4);
|
||||
transition: width 400ms ease-out;
|
||||
}
|
||||
.global-dl-bar.is-indeterminate {
|
||||
width: 32% !important;
|
||||
animation: globalDlIndet 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes globalDlIndet {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
|
||||
Reference in new issue
Block a user