mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 09:00:23 +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 | ||
|
|
79e1c377ed | ||
|
|
fb9d06fb5b | ||
|
|
3d2ee430e1 | ||
|
|
986ff00c36 | ||
|
|
6024dbfc5d | ||
|
|
21f6bd61ad | ||
|
|
01eaef79f2 | ||
|
|
66e4912485 | ||
|
|
3f40dc1d7c | ||
|
|
5f0d1be078 | ||
|
|
ea1328de79 | ||
|
|
9006965a75 | ||
|
|
510f199b89 |
No files matched your search
@@ -23,7 +23,12 @@ SRCS := src/main.c \
|
|||||||
src/patchdl_resolve.c \
|
src/patchdl_resolve.c \
|
||||||
src/patchdl_verxml.c \
|
src/patchdl_verxml.c \
|
||||||
src/patchdl_install.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
|
WEB_ASSETS := web/index.html web/styles.css web/app.js
|
||||||
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
|
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
|
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
|
||||||
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
|
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
|
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(TILE_ASSETS)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
|
$(CC) $(CFLAGS) -o $@ $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ) $(LDADD)
|
||||||
|
|
||||||
test: $(BIN)
|
test: $(BIN)
|
||||||
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
|
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
|
||||||
|
|||||||
@@ -1,31 +1,70 @@
|
|||||||
# PatchDL
|
# PatchDL
|
||||||
|
|
||||||
A standalone PlayStation 5 ELF payload that downloads and installs official game
|
A standalone PlayStation 5 ELF payload that downloads and installs official game
|
||||||
patches on your terms. It serves its own web UI and runs without etaHEN.
|
patches on your terms. It serves its own dark-mode web UI and runs without
|
||||||
|
etaHEN.
|
||||||
|
|
||||||
PatchDL is built for setups where nanoDNS blocks Sony's servers for the whole
|
PatchDL is built for setups where nanoDNS blocks Sony's servers for the whole
|
||||||
console. It resolves the Sony patch CDN on its own path, so the rest of the
|
console. It resolves the Sony patch CDN on its own DNS path, so the rest of the
|
||||||
system stays offline and only the patches you pick get fetched.
|
system stays offline and only the patches you pick get fetched.
|
||||||
|
|
||||||
by Knutwurst
|
by Knutwurst
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- Scans installed titles and classifies each one: genuine install,
|
- Scans installed titles and classifies each: genuine install, ShadowMountPlus
|
||||||
ShadowMountPlus mount, preinstall, or unknown.
|
mount, preinstall, or unknown.
|
||||||
- Reads the title name, installed version, and the Sony `version.xml` URL from
|
- Reads the title name, installed version, and Sony `version.xml` URL from the
|
||||||
the PS5 app database.
|
PS5 app database.
|
||||||
- Fetches each title's `version.xml` from Sony's CDN past nanoDNS (a raw DNS
|
- Fetches each title's `version.xml` past nanoDNS (a raw DNS query to 1.1.1.1)
|
||||||
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
|
and verifies TLS against the pinned SCEI DNAS root.
|
||||||
- Picks the newest patch compatible with the current firmware
|
- Picks the newest patch compatible with the current firmware
|
||||||
(`system_ver <= firmware`), so an update never forces a firmware upgrade.
|
(`system_ver <= firmware`), so an update never forces a firmware upgrade.
|
||||||
- Downloads the patch package and installs it through Sony's AppInstUtil
|
- Downloads the patch from Sony's manifest pieces into one local `.pkg`, then
|
||||||
service.
|
installs it through Sony's AppInstUtil service.
|
||||||
|
|
||||||
|
## Downloading
|
||||||
|
|
||||||
|
PatchDL pulls each patch over a pool of connections instead of one stream, which
|
||||||
|
lifts the ~7 MB/s per-connection ceiling on the Sony CDN. Set the connection
|
||||||
|
count (1 to 16) in Settings with the stepper; the change applies live, with no
|
||||||
|
payload restart. One patch downloads at a time and further requests queue.
|
||||||
|
|
||||||
|
Downloads survive interruptions:
|
||||||
|
|
||||||
|
- **Resume across a reboot.** PatchDL records progress per manifest piece in a
|
||||||
|
sidecar beside the `.pkg`, written after every completed piece, so a reboot or
|
||||||
|
relaunch continues where it stopped.
|
||||||
|
- **Pause, Resume, Cancel.** Pause keeps the partial file, Resume continues it,
|
||||||
|
Cancel deletes it. All three work at any point in a download.
|
||||||
|
- **Verification.** Turn on "Verify downloaded pieces (SHA-256)" to check each
|
||||||
|
piece against its manifest hash while downloading. After a download you can
|
||||||
|
also verify the assembled package on-device against Sony's per-piece hashes
|
||||||
|
(`GET /api/pkgverify/<title_id>`).
|
||||||
|
|
||||||
|
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
|
## Safety model
|
||||||
|
|
||||||
Deny-by-default. A patch is installed only for a genuine install, and only when
|
Deny-by-default. A patch installs only for a genuine install, and only when the
|
||||||
the package's title id matches the installed game:
|
patch metadata targets the installed game:
|
||||||
|
|
||||||
| Source | Check | Download | Install |
|
| Source | Check | Download | Install |
|
||||||
|-----------------------|-------|----------|---------|
|
|-----------------------|-------|----------|---------|
|
||||||
@@ -33,19 +72,30 @@ the package's title id matches the installed game:
|
|||||||
| shadowmount | yes | yes | no |
|
| shadowmount | yes | yes | no |
|
||||||
| preinstall / unknown | yes | no | no |
|
| preinstall / unknown | yes | no | no |
|
||||||
|
|
||||||
Two independent guards stop the wrong package being installed: the patch's title
|
Two guards stop the wrong target being installed: the patch target id (from
|
||||||
id (read from its download URL) must match the game, and just before install the
|
`version.xml` / `manifest_url`) must match the installed game, and the install
|
||||||
real title id is read back from the package
|
call receives the installed game's content id from app.db. PatchDL refuses a
|
||||||
(`sceAppInstUtilGetTitleIdFromPkg`) and checked again. A cross-region or
|
true target-title mismatch rather than installing a phantom title.
|
||||||
cross-title package is refused instead of installed as a phantom title.
|
|
||||||
|
All writes stay under `/data/patchdl`. PatchDL never writes to the system
|
||||||
|
partition and never touches firmware.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Settings persist to `/data/patchdl/config.json` and survive a restart:
|
||||||
|
|
||||||
|
- Default policy (allow or deny) and a per-game enable toggle.
|
||||||
|
- Install after download, as a global default with a per-game override.
|
||||||
|
- Delete the PKG after a successful install.
|
||||||
|
- Verify downloaded pieces (SHA-256).
|
||||||
|
- Parallel download connections (1 to 16), applied live.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Requires `ps5-payload-dev/sdk`. The network and install features also need the
|
Requires `ps5-payload-dev/sdk`. The network and install features need the
|
||||||
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` placed in the SDK
|
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` in the SDK sysroot
|
||||||
sysroot (`target/user/homebrew`); `scripts/build_ps5.sh` enables them
|
(`target/user/homebrew`); `scripts/build_ps5.sh` enables them when present.
|
||||||
automatically when present. libmicrohttpd is vendored under `vendor/etahen`, and
|
libmicrohttpd is vendored under `vendor/etahen`, SQLite under `vendor/sqlite`.
|
||||||
SQLite is vendored under `vendor/sqlite`.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/build_ps5.sh # produces patchdl-ps5.elf
|
scripts/build_ps5.sh # produces patchdl-ps5.elf
|
||||||
@@ -53,9 +103,10 @@ scripts/build_ps5.sh # produces patchdl-ps5.elf
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port
|
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084
|
||||||
8084 (not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the ELF named with its
|
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF
|
||||||
version and launches it; the payload replaces any running instance itself.
|
(`patchdl_<version>.elf`, so Payload Manager picks the version out of the
|
||||||
|
filename) and launches it; the payload replaces any running instance.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
|
||||||
@@ -69,9 +120,21 @@ http://<console-ip>:12880/
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
0.0.1, early. Title scan, version resolution, firmware-compatibility filtering,
|
Verified on firmware 11.60: title scan, source classification, version
|
||||||
download, and install work and have been verified on firmware 11.60. Open items:
|
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
|
||||||
the web UI marks a title "Installing…" but reads progress from the PS5's own
|
download pool, reboot-safe resume, and on-device SHA-256 verification.
|
||||||
notifications rather than a percentage; config persistence and a download queue
|
|
||||||
are not built yet; disc-based games need the disc inserted for their patch to
|
Install works through Sony's AppInstUtil. `sceAppInstUtilInstallByPackage` is
|
||||||
apply (a normal Sony requirement).
|
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.
|
||||||
|
|
||||||
|
`/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-3
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Deploy patchdl to the PS5 via the Payload Manager HTTP API (port 8084).
|
# Deploy patchdl to the PS5 via the Payload Manager HTTP API (port 8084).
|
||||||
# The uploaded filename carries the version so it is identifiable in the
|
# The uploaded filename carries the version so it is identifiable in the
|
||||||
# Payload Manager UI. patchdl self-kills any running instance, so this is
|
# Payload Manager UI. Any running instance is killed first (the payload's own
|
||||||
# an idempotent redeploy.
|
# self-kill is racy when the port is still held), so this is a clean redeploy.
|
||||||
#
|
#
|
||||||
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
|
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
|
||||||
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
|
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
|
||||||
@@ -19,7 +19,7 @@ VERSION=$(sed -n 's/.*PATCHDL_VERSION[^"]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/src/pat
|
|||||||
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
|
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
|
||||||
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
|
[ -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"
|
TMP_ELF="$ROOT_DIR/$UP_NAME"
|
||||||
cp "$SRC_ELF" "$TMP_ELF"
|
cp "$SRC_ELF" "$TMP_ELF"
|
||||||
|
|
||||||
@@ -40,6 +40,30 @@ for p in paths:
|
|||||||
')
|
')
|
||||||
[ -n "$PAYLOAD_PATH" ] || { echo "uploaded payload not found in list_payloads" >&2; exit 1; }
|
[ -n "$PAYLOAD_PATH" ] || { echo "uploaded payload not found in list_payloads" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Kill any running patchdl first so the new instance can bind the port
|
||||||
|
# deterministically (the in-payload self-kill races on the held socket).
|
||||||
|
echo "Stopping any running patchdl ..."
|
||||||
|
curl -fsS -m15 "http://$HOST:$PM_PORT/processes_list" 2>/dev/null | \
|
||||||
|
HOST="$HOST" PM_PORT="$PM_PORT" python3 -c '
|
||||||
|
import os, sys, json, urllib.request
|
||||||
|
obj = json.load(sys.stdin)
|
||||||
|
procs = obj if isinstance(obj, list) else obj.get("processes", [])
|
||||||
|
host, port = os.environ["HOST"], os.environ["PM_PORT"]
|
||||||
|
for p in procs:
|
||||||
|
if "patchdl" in str(p.get("name", "")).lower():
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen("http://%s:%s/process_kill?pid=%d" % (host, port, int(p["pid"])), timeout=10).read()
|
||||||
|
print(" killed pid %d" % int(p["pid"]))
|
||||||
|
except Exception as e:
|
||||||
|
print(" kill pid %s failed: %s" % (p.get("pid"), e))
|
||||||
|
' || true
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 8 ]; do
|
||||||
|
curl -fsS -m4 "http://$HOST:$HTTP_PORT/api/status" >/dev/null 2>&1 || break
|
||||||
|
sleep 1
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
echo "Launching $PAYLOAD_PATH ..."
|
echo "Launching $PAYLOAD_PATH ..."
|
||||||
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
|
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -61,8 +61,12 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
|
|||||||
*out = NULL;
|
*out = NULL;
|
||||||
*count = 0;
|
*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,
|
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);
|
if (db) sqlite3_close(db);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|||||||
+636
-46
@@ -1,12 +1,19 @@
|
|||||||
#include "patchdl_install.h"
|
#include "patchdl_install.h"
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
#include <ps5/kernel.h>
|
#include <ps5/kernel.h>
|
||||||
|
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
#include <stdatomic.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
|
||||||
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
|
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
|
||||||
@@ -41,6 +48,42 @@ typedef struct {
|
|||||||
long unknown[810];
|
long unknown[810];
|
||||||
} ai_playgo_info_t;
|
} 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). */
|
/* Sysmodule IDs (from ps5-payload-dev/sdk crt/rtld_sprx.c). */
|
||||||
#define SYSMOD_IPMI 0x8000001d
|
#define SYSMOD_IPMI 0x8000001d
|
||||||
#define SYSMOD_USERSERVICE 0x80000011
|
#define SYSMOD_USERSERVICE 0x80000011
|
||||||
@@ -53,11 +96,15 @@ typedef int (*ai_install_pkg_fn)(const char *path, ai_pkg_info_t *info);
|
|||||||
typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info,
|
typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info,
|
||||||
ai_playgo_info_t *playgo);
|
ai_playgo_info_t *playgo);
|
||||||
typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app);
|
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_init_fn ai_initialize;
|
||||||
static ai_install_pkg_fn ai_install_pkg;
|
static ai_install_pkg_fn ai_install_pkg;
|
||||||
static ai_install_by_pkg_fn ai_install_by_package;
|
static ai_install_by_pkg_fn ai_install_by_package;
|
||||||
static ai_title_from_pkg_fn ai_title_from_pkg;
|
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
|
/* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs
|
||||||
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
|
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
|
||||||
@@ -66,10 +113,18 @@ static ai_title_from_pkg_fn ai_title_from_pkg;
|
|||||||
symbols via the kernel dynlib helpers. Runs in a detached thread; the HTTP
|
symbols via the kernel dynlib helpers. Runs in a detached thread; the HTTP
|
||||||
handler reports the stage and never blocks.
|
handler reports the stage and never blocks.
|
||||||
stage: 0 idle, 1 resolve loader, 2 load modules, 3 resolve symbols,
|
stage: 0 idle, 1 resolve loader, 2 load modules, 3 resolve symbols,
|
||||||
4 initialize, 5 ready, negative = failure at that step. */
|
4 initialize, 5 ready, negative = failure at that step.
|
||||||
static volatile int g_stage;
|
Stored as _Atomic so the worker's release-store and the request handlers'
|
||||||
static int g_err;
|
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 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
|
static intptr_t
|
||||||
dynsym(const char *module, const char *sym) {
|
dynsym(const char *module, const char *sym) {
|
||||||
@@ -79,6 +134,157 @@ dynsym(const char *module, const char *sym) {
|
|||||||
return kernel_dynlib_dlsym(-1, h, sym);
|
return kernel_dynlib_dlsym(-1, h, sym);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
local_ip(char *out, size_t n) {
|
||||||
|
struct ifaddrs *ifa = NULL, *p;
|
||||||
|
|
||||||
|
out[0] = '\0';
|
||||||
|
if (getifaddrs(&ifa))
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (p = ifa; p; p = p->ifa_next) {
|
||||||
|
char ip[INET_ADDRSTRLEN];
|
||||||
|
struct sockaddr_in *s;
|
||||||
|
|
||||||
|
if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET)
|
||||||
|
continue;
|
||||||
|
s = (struct sockaddr_in *)p->ifa_addr;
|
||||||
|
if (!inet_ntop(AF_INET, &s->sin_addr, ip, sizeof(ip)))
|
||||||
|
continue;
|
||||||
|
if (strcmp(ip, "127.0.0.1") && strncmp(ip, "0.", 2)) {
|
||||||
|
strncpy(out, ip, n - 1);
|
||||||
|
out[n - 1] = '\0';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
resolution. No sysmod_load and no calls, so it is side-effect free. */
|
||||||
|
static void
|
||||||
|
fill_probe(void) {
|
||||||
|
static const char *ai_syms[] = {
|
||||||
|
"sceAppInstUtilInitialize",
|
||||||
|
"sceAppInstUtilAppInstallPkg",
|
||||||
|
"sceAppInstUtilAppInstallTitleDir", /* takes an explicit title id */
|
||||||
|
"sceAppInstUtilInstallByPackage",
|
||||||
|
"sceAppInstUtilInstallByPackageEx",
|
||||||
|
"sceAppInstUtilGetTitleIdFromPkg",
|
||||||
|
"sceAppInstUtilGetContentIdFromPkg",
|
||||||
|
"sceAppInstUtilGetInstallStatus",
|
||||||
|
"sceAppInstUtilAppExist",
|
||||||
|
"sceAppInstUtilAppGetInstallStatus",
|
||||||
|
"sceAppInstUtilAppInstallStatus",
|
||||||
|
"sceAppInstUtilAppUnInstall",
|
||||||
|
"sceAppInstUtilGetMetaInfoFromPkg",
|
||||||
|
"sceAppInstUtilUpdateTitleByTitleId",
|
||||||
|
"sceAppInstUtilInstallByChunk",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
static const char *bgft_syms[] = {
|
||||||
|
"sceBgftInitialize",
|
||||||
|
"sceBgftServiceIntInit",
|
||||||
|
"sceBgftServiceDownloadRegisterTask",
|
||||||
|
"sceBgftServiceDownloadRegisterTaskByStorage",
|
||||||
|
"sceBgftServiceDownloadRegisterTaskByStorageEx",
|
||||||
|
"sceBgftServiceIntDownloadRegisterTaskByStorageEx",
|
||||||
|
"sceBgftServiceDownloadStartTask",
|
||||||
|
"sceBgftServiceDownloadGetProgress",
|
||||||
|
"sceBgftServiceInstallPackage",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
uint32_t h = 0;
|
||||||
|
int ai_loaded = (kernel_dynlib_handle(-1, "libSceAppInstUtil.sprx", &h) >= 0);
|
||||||
|
int bgft_loaded = (kernel_dynlib_handle(-1, "libSceBgft.sprx", &h) >= 0);
|
||||||
|
char tmp[sizeof(g_probe_json)];
|
||||||
|
size_t n = 0, sz = sizeof(tmp);
|
||||||
|
char *out = tmp;
|
||||||
|
int first = 1;
|
||||||
|
|
||||||
|
n += snprintf(out + n, sz - n,
|
||||||
|
"{\"appinstutil_loaded\":%s,\"bgft_loaded\":%s,\"symbols\":{",
|
||||||
|
ai_loaded ? "true" : "false", bgft_loaded ? "true" : "false");
|
||||||
|
for (int i = 0; ai_syms[i] && n < sz - 80; i++) {
|
||||||
|
intptr_t a = dynsym("libSceAppInstUtil.sprx", ai_syms[i]);
|
||||||
|
n += snprintf(out + n, sz - n, "%s\"%s\":%s",
|
||||||
|
first ? "" : ",", ai_syms[i], a ? "true" : "false");
|
||||||
|
first = 0;
|
||||||
|
}
|
||||||
|
for (int i = 0; bgft_syms[i] && n < sz - 80; i++) {
|
||||||
|
intptr_t a = bgft_loaded ? dynsym("libSceBgft.sprx", bgft_syms[i]) : 0;
|
||||||
|
n += snprintf(out + n, sz - n, "%s\"%s\":%s",
|
||||||
|
first ? "" : ",", bgft_syms[i], a ? "true" : "false");
|
||||||
|
first = 0;
|
||||||
|
}
|
||||||
|
snprintf(out + n, sz - n, "}}");
|
||||||
|
|
||||||
|
/* Publish atomically: the getter runs on an MHD worker thread and reads
|
||||||
|
g_probe_json under the same lock, so it never sees a half-built buffer. */
|
||||||
|
pthread_mutex_lock(&g_mtx);
|
||||||
|
memcpy(g_probe_json, tmp, sizeof(g_probe_json));
|
||||||
|
pthread_mutex_unlock(&g_mtx);
|
||||||
|
}
|
||||||
|
|
||||||
static void *
|
static void *
|
||||||
backend_init_thread(void *arg) {
|
backend_init_thread(void *arg) {
|
||||||
(void)arg;
|
(void)arg;
|
||||||
@@ -107,6 +313,14 @@ backend_init_thread(void *arg) {
|
|||||||
"sceAppInstUtilInstallByPackage");
|
"sceAppInstUtilInstallByPackage");
|
||||||
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
|
||||||
"sceAppInstUtilGetTitleIdFromPkg");
|
"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();
|
||||||
|
|
||||||
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
|
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
|
||||||
g_stage = -3;
|
g_stage = -3;
|
||||||
return NULL;
|
return NULL;
|
||||||
@@ -161,12 +375,199 @@ patchdl_install_backend_check(char *msg, size_t msg_sz) {
|
|||||||
return (s == 5) ? 0 : -1;
|
return (s == 5) ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public getter: trigger the backend (which fills the probe in its own thread)
|
||||||
|
and return the cached result. Runs from the MHD worker thread, so it only
|
||||||
|
reads the cached string — it never loads modules or resolves symbols here. */
|
||||||
|
int
|
||||||
|
patchdl_install_api_probe(char *out, size_t out_sz) {
|
||||||
|
int ready;
|
||||||
|
|
||||||
|
backend_start();
|
||||||
|
pthread_mutex_lock(&g_mtx);
|
||||||
|
ready = (g_probe_json[0] != '\0');
|
||||||
|
if (ready)
|
||||||
|
snprintf(out, out_sz, "%s", g_probe_json);
|
||||||
|
pthread_mutex_unlock(&g_mtx);
|
||||||
|
if (ready)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
snprintf(out, out_sz,
|
||||||
|
"{\"pending\":true,\"stage\":\"%s\"}", stage_str(g_stage));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only: report the .pkg's embedded content id + title id (and whether it
|
||||||
|
is a full app vs a patch). No install, no side effects. 0 if anything read. */
|
||||||
|
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) {
|
||||||
|
char sdk_path[1024];
|
||||||
|
/* 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;
|
||||||
|
|
||||||
|
if (content_id && cid_sz) content_id[0] = '\0';
|
||||||
|
if (title_id && tid_sz) title_id[0] = '\0';
|
||||||
|
if (is_app) *is_app = 0;
|
||||||
|
|
||||||
|
if (!local_path || !local_path[0] || stat(local_path, &st) != 0) {
|
||||||
|
snprintf(msg, msg_sz, "package not on disk");
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (ai_content_from_pkg &&
|
||||||
|
ai_content_from_pkg(sdk_path, cid, &app_c) == 0 && cid[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]) {
|
||||||
|
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
|
int
|
||||||
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
||||||
|
const char *storage_title_id,
|
||||||
|
const char *target_content_id,
|
||||||
char *msg, size_t msg_sz) {
|
char *msg, size_t msg_sz) {
|
||||||
char sdk_path[1024];
|
char sdk_path[1024];
|
||||||
|
char pkg_tid[48] = {0};
|
||||||
struct stat st;
|
struct stat st;
|
||||||
int rc;
|
int rc;
|
||||||
|
int pkg_tid_mismatch = 0;
|
||||||
|
|
||||||
if (!local_path || !local_path[0]) {
|
if (!local_path || !local_path[0]) {
|
||||||
snprintf(msg, msg_sz, "no package path");
|
snprintf(msg, msg_sz, "no package path");
|
||||||
@@ -183,63 +584,252 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The install service runs in its own sandbox that sees the user
|
/* Sony's installer sees the user partition as /user/data, not /data, and
|
||||||
partition as /user/data, not /data — remap so it can read the file. */
|
PATH-ALLOWLISTS the URI passed to InstallByPackage: /user/data/ and
|
||||||
|
/mnt/usb are accepted, but a bare /data/... path is REJECTED with
|
||||||
|
0x80B2116F (empirically confirmed by the ps5upload project). So feed the
|
||||||
|
/user/data view of the file to both InstallByPackage and AppInstallPkg. */
|
||||||
if (!strncmp(local_path, "/data/", 6))
|
if (!strncmp(local_path, "/data/", 6))
|
||||||
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
|
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
|
||||||
else
|
else
|
||||||
snprintf(sdk_path, sizeof(sdk_path), "%s", local_path);
|
snprintf(sdk_path, sizeof(sdk_path), "%s", local_path);
|
||||||
|
|
||||||
/* GUARD: read the PKG's own title id and refuse if it does not match the
|
/* Diagnostic guard: Sony sometimes stores one patch byte stream under a
|
||||||
installed game. A cross-title/region package (e.g. a US PPSA03098 patch
|
master title id while the version.xml targets a regional title id. That
|
||||||
on an EU PPSA03099 install) would otherwise be registered as a separate
|
is valid only when the caller supplies target metadata, so do not feed
|
||||||
phantom title instead of patching the game. */
|
such packages to the raw AppInstallPkg path. */
|
||||||
|
if (storage_title_id && storage_title_id[0] &&
|
||||||
|
expected_title_id && expected_title_id[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]) {
|
if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) {
|
||||||
char pkg_tid[48] = {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;
|
int is_app = 0;
|
||||||
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
|
if (ai_title_from_pkg(sdk_path, tid_out, &is_app) == 0 && tid_out[0]) {
|
||||||
strncmp(pkg_tid, expected_title_id, 9) != 0) {
|
if (!title_id_eq9(tid_out, expected_title_id))
|
||||||
snprintf(msg, msg_sz,
|
pkg_tid_mismatch = 1;
|
||||||
"refused: package is for %.12s, installed game is %.12s "
|
copy_bounded(pkg_tid, sizeof(pkg_tid), tid_out, sizeof(tid_out));
|
||||||
"(cross-title/region)", pkg_tid, expected_title_id);
|
}
|
||||||
|
}
|
||||||
|
if (pkg_tid_mismatch && (!target_content_id || !target_content_id[0])) {
|
||||||
|
snprintf(msg, msg_sz,
|
||||||
|
"refused: package metadata is %.12s, target is %.12s",
|
||||||
|
pkg_tid, expected_title_id);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* 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};
|
||||||
|
/* 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;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Primary: direct package install. */
|
|
||||||
{
|
|
||||||
ai_pkg_info_t pkg = {0};
|
|
||||||
rc = ai_install_pkg(sdk_path, &pkg);
|
|
||||||
if (rc == 0) {
|
|
||||||
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback: InstallByPackage with a file:// URI. */
|
|
||||||
{
|
|
||||||
char file_uri[1100];
|
|
||||||
ai_meta_info_t meta = {0};
|
|
||||||
ai_pkg_info_t pkg = {0};
|
|
||||||
ai_playgo_info_t playgo = {0};
|
|
||||||
int rc2;
|
|
||||||
|
|
||||||
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
|
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
|
||||||
meta.uri = file_uri;
|
title_dir = strstr(local_path, "/data/patchdl/");
|
||||||
|
file_base = strrchr(local_path, '/');
|
||||||
|
if (title_dir && file_base && file_base > title_dir + strlen("/data/patchdl/")) {
|
||||||
|
char title_id[32] = {0};
|
||||||
|
const char *t = title_dir + strlen("/data/patchdl/");
|
||||||
|
size_t tlen = (size_t)(file_base - t);
|
||||||
|
if (tlen > 0 && tlen < sizeof(title_id)) {
|
||||||
|
char ip[INET_ADDRSTRLEN] = {0};
|
||||||
|
memcpy(title_id, t, tlen);
|
||||||
|
/* 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 */
|
||||||
|
uris[1] = file_uri; /* file:///user/data/... */
|
||||||
|
uris[2] = http_loop_uri[0] ? http_loop_uri : NULL;
|
||||||
|
uris[3] = http_lan_uri[0] ? http_lan_uri : NULL;
|
||||||
meta.ex_uri = "";
|
meta.ex_uri = "";
|
||||||
meta.playgo_scenario_id = "";
|
meta.playgo_scenario_id = "";
|
||||||
meta.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.content_name = "PatchDL";
|
||||||
meta.icon_url = "";
|
meta.icon_url = "";
|
||||||
|
|
||||||
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
|
{
|
||||||
|
char tries[260] = {0};
|
||||||
|
const char *labels[4] = { "userdata", "file", "loop", "lan" };
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
if (!uris[i]) continue;
|
||||||
|
memset(&pkg, 0, sizeof(pkg));
|
||||||
|
memset(playgo, 0, sizeof(*playgo));
|
||||||
|
meta.uri = uris[i];
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
free(playgo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* 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) {
|
if (rc2 == 0) {
|
||||||
snprintf(msg, msg_sz, "install started (InstallByPackage)");
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
snprintf(msg, msg_sz,
|
if (pkg_tid_mismatch)
|
||||||
"install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)",
|
snprintf(msg, msg_sz,
|
||||||
(unsigned)rc, (unsigned)rc2);
|
"install rejected (pkg %.12s->%.12s ibp=0x%08x aip=0x%08x)",
|
||||||
return rc2;
|
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;
|
||||||
|
}
|
||||||
+44
-3
@@ -13,13 +13,54 @@
|
|||||||
* (official) titles and obtain explicit user intent before calling.
|
* (official) titles and obtain explicit user intent before calling.
|
||||||
*/
|
*/
|
||||||
/* `expected_title_id` is the title id of the installed game the patch is for.
|
/* `expected_title_id` is the title id of the installed game the patch is for.
|
||||||
The PKG's own title id is read and must match, else the install is refused
|
`storage_title_id` is the title id embedded in the delta_url storage path.
|
||||||
(prevents a cross-region/cross-title package being installed as a new
|
`target_content_id` is the installed game's content id from app.db; it is
|
||||||
phantom title). */
|
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,
|
int patchdl_install_local_pkg(const char *local_path,
|
||||||
const char *expected_title_id,
|
const char *expected_title_id,
|
||||||
|
const char *storage_title_id,
|
||||||
|
const char *target_content_id,
|
||||||
char *msg, size_t msg_sz);
|
char *msg, size_t msg_sz);
|
||||||
|
|
||||||
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
|
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
|
||||||
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
|
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
|
||||||
int patchdl_install_backend_check(char *msg, size_t msg_sz);
|
int patchdl_install_backend_check(char *msg, size_t msg_sz);
|
||||||
|
|
||||||
|
/* Read-only feasibility probe: resolve (dlsym, never call) a list of candidate
|
||||||
|
AppInstUtil/Bgft patch-install symbols and report which exist on this
|
||||||
|
firmware. Writes a JSON object into `out`. No install, no side effects. */
|
||||||
|
int patchdl_install_api_probe(char *out, size_t out_sz);
|
||||||
|
|
||||||
|
/* Read-only: report the .pkg's embedded content id + title id (and whether it
|
||||||
|
is a full app vs a patch, via *is_app). No install. 0 if anything was read. */
|
||||||
|
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);
|
||||||
+742
-23
@@ -2,17 +2,21 @@
|
|||||||
|
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
#include <netinet/in.h>
|
#include <netinet/in.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
#include <sys/time.h>
|
#include <sys/time.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#ifdef PATCHDL_HAVE_CURL
|
#ifdef PATCHDL_HAVE_CURL
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
#include "patchdl_ca.h"
|
#include "patchdl_ca.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -20,6 +24,34 @@
|
|||||||
#define DNS_PORT 53
|
#define DNS_PORT 53
|
||||||
#define DNS_TIMEOUT_MS 3000
|
#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
|
#ifdef PATCHDL_HAVE_CURL
|
||||||
|
|
||||||
static const char *ALLOWED_HOSTS[] = {
|
static const char *ALLOWED_HOSTS[] = {
|
||||||
@@ -33,13 +65,15 @@ static const char *ALLOWED_HOSTS[] = {
|
|||||||
static int
|
static int
|
||||||
host_allowed(const char *host) {
|
host_allowed(const char *host) {
|
||||||
size_t hlen = strlen(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++) {
|
for (int i = 0; ALLOWED_HOSTS[i]; i++) {
|
||||||
if (!strcmp(host, ALLOWED_HOSTS[i]))
|
if (!strcasecmp(host, ALLOWED_HOSTS[i]))
|
||||||
return 1;
|
return 1;
|
||||||
size_t alen = strlen(ALLOWED_HOSTS[i]);
|
size_t alen = strlen(ALLOWED_HOSTS[i]);
|
||||||
if (hlen > alen + 1 &&
|
if (hlen > alen + 1 &&
|
||||||
host[hlen - alen - 1] == '.' &&
|
host[hlen - alen - 1] == '.' &&
|
||||||
!strcmp(host + hlen - alen, ALLOWED_HOSTS[i]))
|
!strcasecmp(host + hlen - alen, ALLOWED_HOSTS[i]))
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -143,6 +177,10 @@ dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
|
|||||||
while (pos < (size_t)n) {
|
while (pos < (size_t)n) {
|
||||||
if (!resp[pos]) { pos++; break; }
|
if (!resp[pos]) { pos++; break; }
|
||||||
if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; 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];
|
pos += 1 + resp[pos];
|
||||||
}
|
}
|
||||||
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
|
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
|
||||||
@@ -154,8 +192,10 @@ dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
|
|||||||
if ((resp[pos] & 0xC0) == 0xC0) {
|
if ((resp[pos] & 0xC0) == 0xC0) {
|
||||||
pos += 2;
|
pos += 2;
|
||||||
} else {
|
} 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 += 1 + resp[pos];
|
||||||
|
}
|
||||||
pos++;
|
pos++;
|
||||||
}
|
}
|
||||||
if (pos + 10 > (size_t)n) break;
|
if (pos + 10 > (size_t)n) break;
|
||||||
@@ -196,14 +236,14 @@ dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pthread_mutex_unlock(&dns_cache_mtx);
|
/* Cold miss: resolve while HOLDING the cache lock (single-flight). N pool
|
||||||
|
workers needing the same CDN host would otherwise each blast Sony's
|
||||||
|
rate-limited resolver; this way one resolves and the rest get the cache.
|
||||||
|
It also serializes dns_resolve so its diagnostic globals can't be raced.
|
||||||
|
(This is the DNS lock, independent of the pool lock.) */
|
||||||
for (int attempt = 0; attempt < 4 && rc; attempt++)
|
for (int attempt = 0; attempt < 4 && rc; attempt++)
|
||||||
rc = dns_resolve(host, ip_out, ip_sz);
|
rc = dns_resolve(host, ip_out, ip_sz);
|
||||||
if (rc) return -1;
|
if (!rc && dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
|
||||||
|
|
||||||
pthread_mutex_lock(&dns_cache_mtx);
|
|
||||||
if (dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
|
|
||||||
strncpy(dns_cache[dns_cache_n].host, host,
|
strncpy(dns_cache[dns_cache_n].host, host,
|
||||||
sizeof(dns_cache[0].host) - 1);
|
sizeof(dns_cache[0].host) - 1);
|
||||||
strncpy(dns_cache[dns_cache_n].ip, ip_out,
|
strncpy(dns_cache[dns_cache_n].ip, ip_out,
|
||||||
@@ -211,7 +251,7 @@ dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
|
|||||||
dns_cache_n++;
|
dns_cache_n++;
|
||||||
}
|
}
|
||||||
pthread_mutex_unlock(&dns_cache_mtx);
|
pthread_mutex_unlock(&dns_cache_mtx);
|
||||||
return 0;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- HTTP GET via curl ------------------------------------------- */
|
/* ---------- HTTP GET via curl ------------------------------------------- */
|
||||||
@@ -220,7 +260,12 @@ static size_t
|
|||||||
write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||||
patchdl_buf_t *b = userdata;
|
patchdl_buf_t *b = userdata;
|
||||||
size_t total = size * nmemb;
|
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;
|
if (!newp) return 0;
|
||||||
b->data = newp;
|
b->data = newp;
|
||||||
memcpy(b->data + b->size, ptr, total);
|
memcpy(b->data + b->size, ptr, total);
|
||||||
@@ -250,7 +295,11 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
|||||||
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
|
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
|
||||||
resolve_list = curl_slist_append(resolve_list, resolve_80);
|
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();
|
curl = curl_easy_init();
|
||||||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||||||
@@ -275,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_TIMEOUT, 15L);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
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");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||||
|
|
||||||
res = curl_easy_perform(curl);
|
res = curl_easy_perform(curl);
|
||||||
@@ -290,29 +345,88 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Write sink: tees the body to the file and, when verifying, into a running
|
||||||
|
SHA-256. curl always calls with size==1, so nmemb is the byte count. */
|
||||||
|
typedef struct {
|
||||||
|
FILE *fp;
|
||||||
|
EVP_MD_CTX *md; /* NULL when not verifying */
|
||||||
|
} write_sink_t;
|
||||||
|
|
||||||
static size_t
|
static size_t
|
||||||
file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||||
return fwrite(ptr, size, nmemb, (FILE *)userdata);
|
write_sink_t *s = (write_sink_t *)userdata;
|
||||||
|
size_t written = fwrite(ptr, size, nmemb, s->fp);
|
||||||
|
if (s->md && written)
|
||||||
|
EVP_DigestUpdate(s->md, ptr, written * size);
|
||||||
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
int
|
/* Hex-encode a digest, lowercase. */
|
||||||
patchdl_http_download(const char *url, const char *dest_path,
|
static void
|
||||||
long long *bytes_out) {
|
hex_encode(const unsigned char *d, unsigned int len, char *out, size_t out_sz) {
|
||||||
|
static const char hexd[] = "0123456789abcdef";
|
||||||
|
unsigned int i;
|
||||||
|
for (i = 0; i < len && (2u * i + 2u) < out_sz; i++) {
|
||||||
|
out[2 * i] = hexd[(d[i] >> 4) & 0xf];
|
||||||
|
out[2 * i + 1] = hexd[d[i] & 0xf];
|
||||||
|
}
|
||||||
|
out[2 * i] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
patchdl_download_progress_cb cb;
|
||||||
|
void *ctx;
|
||||||
|
long long base;
|
||||||
|
long long total;
|
||||||
|
} progress_state_t;
|
||||||
|
|
||||||
|
static int
|
||||||
|
curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
|
||||||
|
curl_off_t ultotal, curl_off_t ulnow) {
|
||||||
|
progress_state_t *p = (progress_state_t *)clientp;
|
||||||
|
long long total;
|
||||||
|
|
||||||
|
(void)ultotal;
|
||||||
|
(void)ulnow;
|
||||||
|
if (!p || !p->cb) return 0;
|
||||||
|
|
||||||
|
total = p->total > 0 ? p->total : (long long)dltotal;
|
||||||
|
/* A non-zero return aborts the transfer (CURLE_ABORTED_BY_CALLBACK),
|
||||||
|
which is how a cancel request stops a piece mid-flight. */
|
||||||
|
return p->cb(p->ctx, p->base + (long long)dlnow, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Returns 0 on success, -1 on download/network failure, -2 when an expected
|
||||||
|
SHA-256 was given and the downloaded bytes did not match it, -3 when a byte
|
||||||
|
range was requested (range_start>0) but the server ignored it (no HTTP 206).
|
||||||
|
When range_start>0 the body is appended at the file's current position, so
|
||||||
|
the caller must have it positioned at range_start and must not verify. */
|
||||||
|
static int
|
||||||
|
http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||||
|
progress_state_t *progress,
|
||||||
|
const char *expected_sha256_hex,
|
||||||
|
long long range_start) {
|
||||||
CURL *curl;
|
CURL *curl;
|
||||||
CURLcode res;
|
CURLcode res;
|
||||||
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||||
|
char range_hdr[48];
|
||||||
|
long http_code = 0;
|
||||||
struct curl_slist *rl = NULL;
|
struct curl_slist *rl = NULL;
|
||||||
struct curl_blob ca_blob;
|
struct curl_blob ca_blob;
|
||||||
FILE *fp;
|
|
||||||
curl_off_t dl = 0;
|
curl_off_t dl = 0;
|
||||||
|
write_sink_t sink = { fp, NULL };
|
||||||
|
int verify = (expected_sha256_hex && expected_sha256_hex[0]);
|
||||||
|
|
||||||
if (bytes_out) *bytes_out = 0;
|
if (bytes_out) *bytes_out = 0;
|
||||||
if (url_host(url, host, sizeof(host))) return -1;
|
if (url_host(url, host, sizeof(host))) return -1;
|
||||||
if (!host_allowed(host)) return -1;
|
if (!host_allowed(host)) return -1;
|
||||||
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
if (dns_lookup(host, ip, sizeof(ip))) return -1;
|
||||||
|
|
||||||
fp = fopen(dest_path, "wb");
|
if (verify) {
|
||||||
if (!fp) return -1;
|
sink.md = EVP_MD_CTX_new();
|
||||||
|
if (sink.md)
|
||||||
|
EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
|
||||||
|
}
|
||||||
|
|
||||||
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||||
rl = curl_slist_append(NULL, rs443);
|
rl = curl_slist_append(NULL, rs443);
|
||||||
@@ -324,35 +438,611 @@ patchdl_http_download(const char *url, const char *dest_path,
|
|||||||
ca_blob.flags = CURL_BLOB_COPY;
|
ca_blob.flags = CURL_BLOB_COPY;
|
||||||
|
|
||||||
curl = curl_easy_init();
|
curl = curl_easy_init();
|
||||||
if (!curl) { fclose(fp); curl_slist_free_all(rl); return -1; }
|
if (!curl) {
|
||||||
|
curl_slist_free_all(rl);
|
||||||
|
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||||
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, file_write_cb);
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, file_write_cb);
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||||
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
|
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
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);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||||
/* No total timeout (patches can be large); abort only on a long stall. */
|
/* No total timeout (patches can be large); abort only on a long stall. */
|
||||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||||
|
if (range_start > 0) {
|
||||||
|
snprintf(range_hdr, sizeof(range_hdr), "%lld-", range_start);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_RANGE, range_hdr);
|
||||||
|
}
|
||||||
|
if (progress && progress->cb) {
|
||||||
|
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, progress);
|
||||||
|
}
|
||||||
|
|
||||||
res = curl_easy_perform(curl);
|
res = curl_easy_perform(curl);
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
|
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
|
||||||
curl_easy_cleanup(curl);
|
curl_easy_cleanup(curl);
|
||||||
curl_slist_free_all(rl);
|
curl_slist_free_all(rl);
|
||||||
fclose(fp);
|
|
||||||
|
|
||||||
if (res != CURLE_OK) {
|
if (res != CURLE_OK) {
|
||||||
|
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* Asked for a byte range but the server sent the whole file (no 206): the
|
||||||
|
caller must drop the piece and re-fetch it whole. */
|
||||||
|
if (range_start > 0 && http_code != 206) {
|
||||||
|
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||||
|
return -3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sink.md) {
|
||||||
|
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||||
|
unsigned int dlen = 0;
|
||||||
|
char hex[2 * EVP_MAX_MD_SIZE + 1];
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes_out) *bytes_out = (long long)dl;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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_safe(dest_path, 0);
|
||||||
|
progress_state_t progress = { cb, ctx, 0, 0 };
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (!fp) return -1;
|
||||||
|
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL, 0);
|
||||||
|
fclose(fp);
|
||||||
|
|
||||||
|
if (rc) {
|
||||||
unlink(dest_path);
|
unlink(dest_path);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (bytes_out) *bytes_out = (long long)dl;
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_http_download(const char *url, const char *dest_path,
|
||||||
|
long long *bytes_out) {
|
||||||
|
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,
|
||||||
|
const char *limit) {
|
||||||
|
char needle[48];
|
||||||
|
const char *q;
|
||||||
|
size_t n = 0;
|
||||||
|
|
||||||
|
if (!p || !out || out_sz == 0) return -1;
|
||||||
|
out[0] = '\0';
|
||||||
|
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||||
|
q = strstr_bounded(p, needle, limit);
|
||||||
|
if (!q) return -1;
|
||||||
|
q += strlen(needle);
|
||||||
|
while ((!limit || q < limit) &&
|
||||||
|
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||||
|
if (limit && q >= limit) return -1;
|
||||||
|
if (*q++ != ':') return -1;
|
||||||
|
while ((!limit || q < limit) &&
|
||||||
|
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||||
|
if (limit && q >= limit) return -1;
|
||||||
|
if (*q++ != '"') return -1;
|
||||||
|
|
||||||
|
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,
|
||||||
|
const char *limit) {
|
||||||
|
char needle[48];
|
||||||
|
const char *q;
|
||||||
|
|
||||||
|
if (!p || !out) return -1;
|
||||||
|
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||||
|
q = strstr_bounded(p, needle, limit);
|
||||||
|
if (!q) return -1;
|
||||||
|
q += strlen(needle);
|
||||||
|
while ((!limit || q < limit) &&
|
||||||
|
(*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++;
|
||||||
|
if (limit && q >= limit) return -1;
|
||||||
|
if (*q++ != ':') return -1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||||
|
const char *dest_path,
|
||||||
|
long long *bytes_out,
|
||||||
|
patchdl_download_progress_cb cb,
|
||||||
|
void *ctx, int verify, int resume) {
|
||||||
|
patchdl_buf_t manifest;
|
||||||
|
const char *pieces, *pieces_end, *p;
|
||||||
|
FILE *fp = NULL;
|
||||||
|
long long total = 0, have = 0;
|
||||||
|
unsigned long long manifest_total = 0;
|
||||||
|
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) {
|
||||||
|
free(manifest.data);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces = strstr(manifest.data, "\"pieces\"");
|
||||||
|
if (!pieces || !(pieces = strchr(pieces, '['))) {
|
||||||
|
free(manifest.data);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* 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, 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_safe(dest_path, 1);
|
||||||
|
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
|
||||||
|
}
|
||||||
|
if (!fp) { fp = fopen_safe(dest_path, 0); have = 0; }
|
||||||
|
if (!fp) { free(manifest.data); return -1; }
|
||||||
|
started = (have <= 0);
|
||||||
|
|
||||||
|
p = pieces;
|
||||||
|
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
|
||||||
|
char url[768];
|
||||||
|
char hash[80] = {0};
|
||||||
|
long long got = 0, range_start = 0;
|
||||||
|
unsigned long long expected = 0;
|
||||||
|
unsigned long long offset = 0;
|
||||||
|
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), piece_limit))
|
||||||
|
break;
|
||||||
|
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 &&
|
||||||
|
have >= (long long)(offset + expected)) {
|
||||||
|
total = (long long)(offset + expected);
|
||||||
|
count++;
|
||||||
|
if (cb && cb(ctx, total, manifest_total ? (long long)manifest_total : total))
|
||||||
|
goto done;
|
||||||
|
p = obj_end ? obj_end + 1 : p + 5;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* First piece to (re)download while resuming. If part of it is already
|
||||||
|
on disk, resume WITHIN it with a byte range; otherwise drop any stray
|
||||||
|
bytes and fetch it whole. After this, every piece is fetched whole. */
|
||||||
|
if (!started) {
|
||||||
|
if (have_offset && expected && have > (long long)offset &&
|
||||||
|
have < (long long)(offset + expected)) {
|
||||||
|
range_start = have - (long long)offset; /* this piece's bytes on disk */
|
||||||
|
fseek(fp, 0, SEEK_END); /* append at `have` */
|
||||||
|
total = have;
|
||||||
|
} else {
|
||||||
|
long long start_at = have_offset ? (long long)offset : 0;
|
||||||
|
fflush(fp);
|
||||||
|
if (ftruncate(fileno(fp), (off_t)start_at) != 0)
|
||||||
|
goto done; /* can't resume cleanly; keep partial */
|
||||||
|
fseek(fp, 0, SEEK_END);
|
||||||
|
total = start_at;
|
||||||
|
}
|
||||||
|
started = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Whole pieces are concatenated in array order; a ranged (partial) piece
|
||||||
|
starts mid-piece, so the contiguity guard applies only to whole ones. */
|
||||||
|
if (have_offset && range_start == 0 && offset != (unsigned long long)total)
|
||||||
|
goto done;
|
||||||
|
|
||||||
|
/* 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), piece_limit);
|
||||||
|
want_hash = hash[0] ? hash : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
progress_state_t progress = {
|
||||||
|
cb, ctx, total, manifest_total ? (long long)manifest_total : 0
|
||||||
|
};
|
||||||
|
/* drc: 0 ok, -1 network/cancel, -2 SHA-256, -3 range ignored. */
|
||||||
|
drc = http_download_to_file_progress(url, fp, &got, &progress,
|
||||||
|
want_hash, range_start);
|
||||||
|
if (drc == -3) {
|
||||||
|
/* Server ignored the range: drop the piece and fetch it whole. */
|
||||||
|
fflush(fp);
|
||||||
|
if (ftruncate(fileno(fp), (off_t)offset) != 0)
|
||||||
|
goto done;
|
||||||
|
fseek(fp, 0, SEEK_END);
|
||||||
|
total = (long long)offset;
|
||||||
|
range_start = 0;
|
||||||
|
if (verify) {
|
||||||
|
json_string_after(p, "hashValue", hash, sizeof(hash),
|
||||||
|
piece_limit);
|
||||||
|
want_hash = hash[0] ? hash : NULL;
|
||||||
|
}
|
||||||
|
progress.base = total;
|
||||||
|
drc = http_download_to_file_progress(url, fp, &got, &progress,
|
||||||
|
want_hash, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (drc) {
|
||||||
|
if (drc == -2) rc = -2;
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
/* range_start + got = this piece's bytes now on disk. */
|
||||||
|
if (expected && (unsigned long long)(range_start + got) != expected)
|
||||||
|
goto done;
|
||||||
|
|
||||||
|
total += got;
|
||||||
|
/* A non-zero callback return between pieces means cancel requested. */
|
||||||
|
if (cb && cb(ctx, total, manifest_total ? (long long)manifest_total : total))
|
||||||
|
goto done;
|
||||||
|
count++;
|
||||||
|
p = obj_end ? obj_end + 1 : p + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
rc = 0;
|
||||||
|
if (bytes_out) *bytes_out = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
done:
|
||||||
|
fclose(fp);
|
||||||
|
free(manifest.data);
|
||||||
|
/* Keep the partial on failure so it can be resumed; the caller deletes it
|
||||||
|
on cancel or on a corrupt-verify (-2). */
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
|
||||||
|
long long *bytes_out) {
|
||||||
|
return patchdl_http_download_manifest_progress(manifest_url, dest_path,
|
||||||
|
bytes_out, NULL, NULL, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- global init + parallel piece download (connection pool) ----------- */
|
||||||
|
|
||||||
|
void patchdl_net_global_init(void) { curl_global_init(CURL_GLOBAL_ALL); }
|
||||||
|
void patchdl_net_global_cleanup(void) { curl_global_cleanup(); }
|
||||||
|
|
||||||
|
/* Write sink for one piece: pwrite at a fixed base offset (concurrent
|
||||||
|
non-overlapping pieces of the same fd are safe), tee into SHA-256 if asked,
|
||||||
|
and publish bytes-so-far for live progress. */
|
||||||
|
typedef struct {
|
||||||
|
int fd;
|
||||||
|
long long base;
|
||||||
|
long long written;
|
||||||
|
EVP_MD_CTX *md;
|
||||||
|
volatile long long *bytes_slot;
|
||||||
|
} piece_sink_t;
|
||||||
|
|
||||||
|
static size_t
|
||||||
|
piece_write_cb(void *ptr, size_t size, size_t nmemb, void *ud) {
|
||||||
|
piece_sink_t *s = (piece_sink_t *)ud;
|
||||||
|
size_t n = size * nmemb;
|
||||||
|
ssize_t w;
|
||||||
|
if (n == 0) return 0;
|
||||||
|
w = pwrite(s->fd, ptr, n, (off_t)(s->base + s->written));
|
||||||
|
if (w < 0 || (size_t)w != n) return 0; /* short write -> curl errors out */
|
||||||
|
if (s->md) EVP_DigestUpdate(s->md, ptr, n);
|
||||||
|
s->written += (long long)n;
|
||||||
|
if (s->bytes_slot) *s->bytes_slot = s->written;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
piece_xfer_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
|
||||||
|
curl_off_t ultotal, curl_off_t ulnow) {
|
||||||
|
volatile int *abort_flag = (volatile int *)clientp;
|
||||||
|
(void)dltotal; (void)dlnow; (void)ultotal; (void)ulnow;
|
||||||
|
return (abort_flag && *abort_flag) ? 1 : 0; /* non-zero aborts the transfer */
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
int *curl_rc_out, long *http_code_out) {
|
||||||
|
CURL *curl;
|
||||||
|
CURLcode res;
|
||||||
|
long http_code = 0;
|
||||||
|
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||||
|
struct curl_slist *rl = NULL;
|
||||||
|
struct curl_blob ca_blob;
|
||||||
|
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;
|
||||||
|
|
||||||
|
memset(&sink, 0, sizeof(sink));
|
||||||
|
sink.fd = fd;
|
||||||
|
sink.base = file_offset;
|
||||||
|
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
|
||||||
|
if (verify) {
|
||||||
|
sink.md = EVP_MD_CTX_new();
|
||||||
|
if (sink.md) EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
|
||||||
|
rl = curl_slist_append(NULL, rs443);
|
||||||
|
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
|
||||||
|
rl = curl_slist_append(rl, rs80);
|
||||||
|
|
||||||
|
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
|
||||||
|
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
|
||||||
|
ca_blob.flags = CURL_BLOB_COPY;
|
||||||
|
|
||||||
|
curl = curl_easy_init();
|
||||||
|
if (!curl) {
|
||||||
|
curl_slist_free_all(rl);
|
||||||
|
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, piece_write_cb);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
|
||||||
|
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_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);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||||
|
if (ctx && ctx->abort) {
|
||||||
|
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, piece_xfer_cb);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, (void *)ctx->abort);
|
||||||
|
}
|
||||||
|
|
||||||
|
res = curl_easy_perform(curl);
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
if (file_size > 0 && sink.written != file_size) {
|
||||||
|
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||||
|
return -1; /* short or over-long -> failed */
|
||||||
|
}
|
||||||
|
if (sink.md) {
|
||||||
|
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||||
|
unsigned int dl = 0;
|
||||||
|
char hex[2 * EVP_MAX_MD_SIZE + 1];
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
fdatasync(fd); /* durable before the caller sets the done bit */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only: SHA-256 a [offset, offset+size) region of fd into out_hex (>=65
|
||||||
|
bytes). Uses pread so it doesn't disturb the fd offset. 0 on success. */
|
||||||
|
int
|
||||||
|
patchdl_sha256_fd_region(int fd, long long offset, long long size, char *out_hex) {
|
||||||
|
EVP_MD_CTX *md;
|
||||||
|
unsigned char *buf;
|
||||||
|
long long pos = offset, remaining = size;
|
||||||
|
const size_t CHUNK = 1u << 20;
|
||||||
|
|
||||||
|
out_hex[0] = '\0';
|
||||||
|
if (fd < 0 || size < 0) return -1;
|
||||||
|
md = EVP_MD_CTX_new();
|
||||||
|
if (!md) return -1;
|
||||||
|
buf = malloc(CHUNK);
|
||||||
|
if (!buf) { EVP_MD_CTX_free(md); return -1; }
|
||||||
|
EVP_DigestInit_ex(md, EVP_sha256(), NULL);
|
||||||
|
while (remaining > 0) {
|
||||||
|
size_t want = remaining > (long long)CHUNK ? CHUNK : (size_t)remaining;
|
||||||
|
ssize_t got = pread(fd, buf, want, (off_t)pos);
|
||||||
|
if (got <= 0) { free(buf); EVP_MD_CTX_free(md); return -1; }
|
||||||
|
EVP_DigestUpdate(md, buf, (size_t)got);
|
||||||
|
pos += got; remaining -= got;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||||
|
unsigned int dl = 0, 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);
|
||||||
|
EVP_MD_CTX_free(md);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
patchdl_manifest_free(patchdl_manifest_t *m) {
|
||||||
|
if (!m || !m->pieces) return;
|
||||||
|
for (int i = 0; i < m->count; i++) free(m->pieces[i].url);
|
||||||
|
free(m->pieces);
|
||||||
|
m->pieces = NULL;
|
||||||
|
m->count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) {
|
||||||
|
patchdl_buf_t buf;
|
||||||
|
const char *pieces, *pieces_end, *p;
|
||||||
|
int cap = 0, n = 0;
|
||||||
|
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; }
|
||||||
|
|
||||||
|
pieces = strstr(buf.data, "\"pieces\"");
|
||||||
|
if (!pieces || !(pieces = strchr(pieces, '['))) { free(buf.data); return -1; }
|
||||||
|
pieces_end = strchr(pieces, ']');
|
||||||
|
|
||||||
|
for (p = pieces; (p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end); p += 5)
|
||||||
|
cap++;
|
||||||
|
if (cap <= 0) { free(buf.data); return -1; }
|
||||||
|
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), piece_limit))
|
||||||
|
break;
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
out->pieces[n].url = strdup(url);
|
||||||
|
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), piece_limit);
|
||||||
|
if (!out->pieces[n].url) {
|
||||||
|
patchdl_manifest_free(out);
|
||||||
|
free(buf.data);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
running += (long long)sz;
|
||||||
|
n++;
|
||||||
|
out->count = n; /* keep current so manifest_free frees exactly n */
|
||||||
|
p = obj_end ? obj_end + 1 : p + 5;
|
||||||
|
}
|
||||||
|
free(buf.data);
|
||||||
|
if (n == 0) { patchdl_manifest_free(out); return -1; }
|
||||||
|
out->total = running; /* authoritative assembled size */
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,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_VERIFYPEER, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
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);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||||||
res = curl_easy_perform(curl);
|
res = curl_easy_perform(curl);
|
||||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
@@ -427,6 +1120,32 @@ patchdl_http_download(const char *url, const char *dest_path,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_http_download_progress(const char *url, const char *dest_path,
|
||||||
|
long long *bytes_out,
|
||||||
|
patchdl_download_progress_cb cb, void *ctx) {
|
||||||
|
(void)cb; (void)ctx;
|
||||||
|
return patchdl_http_download(url, dest_path, bytes_out);
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
|
||||||
|
long long *bytes_out) {
|
||||||
|
(void)manifest_url; (void)dest_path;
|
||||||
|
if (bytes_out) *bytes_out = 0;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||||
|
const char *dest_path,
|
||||||
|
long long *bytes_out,
|
||||||
|
patchdl_download_progress_cb cb,
|
||||||
|
void *ctx, int verify, int resume) {
|
||||||
|
(void)cb; (void)ctx; (void)verify; (void)resume;
|
||||||
|
return patchdl_http_download_manifest(manifest_url, dest_path, bytes_out);
|
||||||
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||||||
(void)url;
|
(void)url;
|
||||||
|
|||||||
@@ -6,17 +6,93 @@ typedef struct {
|
|||||||
char *data;
|
char *data;
|
||||||
size_t size;
|
size_t size;
|
||||||
size_t cap;
|
size_t cap;
|
||||||
|
size_t max; /* 0 = unbounded (legacy). Otherwise write_cb fails past this. */
|
||||||
} patchdl_buf_t;
|
} patchdl_buf_t;
|
||||||
|
|
||||||
patchdl_buf_t *patchdl_buf_new(void);
|
patchdl_buf_t *patchdl_buf_new(void);
|
||||||
void patchdl_buf_free(patchdl_buf_t *b);
|
void patchdl_buf_free(patchdl_buf_t *b);
|
||||||
|
|
||||||
|
/* Call once, single-threaded, before any concurrent download worker starts /
|
||||||
|
after they have all joined. curl's global/OpenSSL init is otherwise lazy and
|
||||||
|
races across threads. */
|
||||||
|
void patchdl_net_global_init(void);
|
||||||
|
void patchdl_net_global_cleanup(void);
|
||||||
|
|
||||||
int patchdl_http_get(const char *url, patchdl_buf_t *out);
|
int patchdl_http_get(const char *url, patchdl_buf_t *out);
|
||||||
|
|
||||||
|
/* ---- parallel piece download (used by the connection pool) ------------- */
|
||||||
|
|
||||||
|
/* One piece of a split manifest package. `url` is heap-allocated. */
|
||||||
|
typedef struct {
|
||||||
|
char *url;
|
||||||
|
long long offset; /* byte offset of this piece in the assembled file */
|
||||||
|
long long size; /* exact length of this piece */
|
||||||
|
char hash[80]; /* manifest SHA-256 hex, or "" */
|
||||||
|
} patchdl_piece_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
patchdl_piece_t *pieces;
|
||||||
|
int count;
|
||||||
|
long long total; /* assembled file size = sum of piece sizes */
|
||||||
|
} patchdl_manifest_t;
|
||||||
|
|
||||||
|
/* Fetch + parse a Sony JSON manifest into a validated, contiguously-tiled
|
||||||
|
piece list. Returns 0 on success (caller frees with patchdl_manifest_free),
|
||||||
|
-1 on fetch/parse/tiling failure. */
|
||||||
|
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
|
||||||
|
void patchdl_manifest_free(patchdl_manifest_t *m);
|
||||||
|
|
||||||
|
/* Live state shared with one in-flight piece download. The worker owns these;
|
||||||
|
the curl callbacks read `abort` (set elsewhere) and publish progress into
|
||||||
|
`bytes_slot` (single-writer per worker slot). */
|
||||||
|
typedef struct {
|
||||||
|
volatile long long *bytes_slot; /* bytes written so far for this piece */
|
||||||
|
volatile int *abort; /* non-zero -> stop this transfer */
|
||||||
|
} patchdl_piece_ctx_t;
|
||||||
|
|
||||||
|
/* 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.
|
||||||
|
`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,
|
||||||
|
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. */
|
||||||
|
int patchdl_sha256_fd_region(int fd, long long offset, long long size,
|
||||||
|
char *out_hex);
|
||||||
|
|
||||||
|
/* Progress callback. Return non-zero to ABORT the in-flight download (used to
|
||||||
|
cancel large patch downloads); return 0 to continue. */
|
||||||
|
typedef int (*patchdl_download_progress_cb)(void *ctx,
|
||||||
|
long long downloaded,
|
||||||
|
long long total);
|
||||||
|
|
||||||
/* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on
|
/* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on
|
||||||
success and writes the byte count to *bytes_out. */
|
success and writes the byte count to *bytes_out. */
|
||||||
int patchdl_http_download(const char *url, const char *dest_path,
|
int patchdl_http_download(const char *url, const char *dest_path,
|
||||||
long long *bytes_out);
|
long long *bytes_out);
|
||||||
|
int patchdl_http_download_progress(const char *url, const char *dest_path,
|
||||||
|
long long *bytes_out,
|
||||||
|
patchdl_download_progress_cb cb, void *ctx);
|
||||||
|
|
||||||
|
/* Download a Sony JSON package manifest by concatenating every entry in
|
||||||
|
"pieces" into one installable PKG. When `verify` is non-zero each piece is
|
||||||
|
checked against its manifest SHA-256 (a mismatch returns -2). When `resume`
|
||||||
|
is non-zero an existing partial at dest_path is kept: fully-downloaded pieces
|
||||||
|
are skipped and only the remainder is fetched (survives a reboot). On any
|
||||||
|
failure the partial is left in place for a later resume. */
|
||||||
|
int patchdl_http_download_manifest(const char *manifest_url, const char *dest_path,
|
||||||
|
long long *bytes_out);
|
||||||
|
int patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||||
|
const char *dest_path,
|
||||||
|
long long *bytes_out,
|
||||||
|
patchdl_download_progress_cb cb,
|
||||||
|
void *ctx, int verify, int resume);
|
||||||
|
|
||||||
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
|
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
|
||||||
(dns result/ip, curl code, http status, bytes) into `out_json`. */
|
(dns result/ip, curl code, http status, bytes) into `out_json`. */
|
||||||
|
|||||||
+21
-8
@@ -34,16 +34,26 @@ find_pid(const char *name) {
|
|||||||
if (!(buf = malloc(buf_size))) return -1;
|
if (!(buf = malloc(buf_size))) return -1;
|
||||||
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
|
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
|
||||||
|
|
||||||
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) {
|
/* The loop guard guarantees the structsize/pid/tdname fields are inside the
|
||||||
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
|
buffer before we read them, and the per-record check below keeps the name
|
||||||
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
|
compare within the record (and thus the buffer). */
|
||||||
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
|
for (uint8_t *ptr = buf; ptr + KINFO_OFF_TDNAME < buf + buf_size; ) {
|
||||||
|
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
|
||||||
|
pid_t ki_pid;
|
||||||
|
char *ki_tdname;
|
||||||
|
size_t name_max;
|
||||||
|
|
||||||
if (ki_structsize <= 0) break; /* guard against malformed entries */
|
if (ki_structsize <= KINFO_OFF_TDNAME) break; /* malformed/truncated */
|
||||||
ptr += ki_structsize;
|
if (ptr + ki_structsize > buf + buf_size) break; /* record past buffer */
|
||||||
|
|
||||||
if (!strcmp(name, ki_tdname) && ki_pid != mypid)
|
ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
|
||||||
|
ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
|
||||||
|
name_max = (size_t)(ptr + ki_structsize - (uint8_t *)ki_tdname);
|
||||||
|
|
||||||
|
if (!strncmp(name, ki_tdname, name_max) && ki_pid != mypid)
|
||||||
pid = ki_pid;
|
pid = ki_pid;
|
||||||
|
|
||||||
|
ptr += ki_structsize;
|
||||||
}
|
}
|
||||||
|
|
||||||
free(buf);
|
free(buf);
|
||||||
@@ -55,7 +65,10 @@ patchdl_proc_kill_others(const char *name) {
|
|||||||
int killed = 0;
|
int killed = 0;
|
||||||
pid_t pid;
|
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))
|
if (kill(pid, SIGKILL))
|
||||||
break;
|
break;
|
||||||
killed++;
|
killed++;
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
|
|||||||
fclose(fp);
|
fclose(fp);
|
||||||
return -1;
|
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);
|
memcpy(url_out, url, len + 1);
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
+86
-28
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <dirent.h>
|
#include <dirent.h>
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
|
#include <stdatomic.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -140,13 +141,23 @@ sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
|
|||||||
|
|
||||||
entries = (const sfo_entry_t *)(buf + sizeof(*h));
|
entries = (const sfo_entry_t *)(buf + sizeof(*h));
|
||||||
|
|
||||||
|
/* The entry table itself must fit in the bytes we actually read; a crafted
|
||||||
|
param.sfo (from a shadow-mounted game dir) could otherwise drive
|
||||||
|
entries[i] past the buffer. */
|
||||||
|
if (sizeof(*h) + (size_t)h->num_entries * sizeof(sfo_entry_t) > bufsz)
|
||||||
|
return -1;
|
||||||
|
|
||||||
for (i = 0; i < h->num_entries; i++) {
|
for (i = 0; i < h->num_entries; i++) {
|
||||||
size_t key_off = h->key_table_start + entries[i].key_offset;
|
size_t key_off = h->key_table_start + entries[i].key_offset;
|
||||||
size_t val_off = h->data_table_start + entries[i].data_offset;
|
size_t val_off = h->data_table_start + entries[i].data_offset;
|
||||||
|
|
||||||
if (key_off >= bufsz || val_off >= bufsz) continue;
|
if (key_off >= bufsz || val_off >= bufsz) continue;
|
||||||
|
|
||||||
const char *k = (const char *)(buf + key_off);
|
const char *k = (const char *)(buf + key_off);
|
||||||
|
size_t kmax = bufsz - key_off;
|
||||||
|
/* Require the key to be NUL-terminated within the buffer before the
|
||||||
|
strcmp, otherwise it would read past the end. */
|
||||||
|
if (strnlen(k, kmax) == kmax) continue;
|
||||||
if (strcmp(k, key)) continue;
|
if (strcmp(k, key)) continue;
|
||||||
if (entries[i].data_fmt != SFO_FMT_STR) return -1;
|
if (entries[i].data_fmt != SFO_FMT_STR) return -1;
|
||||||
|
|
||||||
@@ -229,20 +240,37 @@ is_game_title(const char *title_id) {
|
|||||||
|
|
||||||
/* ---------- directory scanner ------------------------------------------- */
|
/* ---------- directory scanner ------------------------------------------- */
|
||||||
|
|
||||||
|
/* Authoritative shadowmount test: ShadowMountPlus routes its images through
|
||||||
|
/mnt/shadowmnt (a pfs from /dev/lvdN there, then a nullfs onto the app dir),
|
||||||
|
so a title whose mount table references /mnt/shadowmnt is a shadowmount. The
|
||||||
|
on-disk mount.lnk marker is unreliable across reboots/remounts; the live
|
||||||
|
mount table is not. */
|
||||||
|
static int
|
||||||
|
mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts) {
|
||||||
|
for (int i = 0; i < nmounts; i++) {
|
||||||
|
const char *from = mounts[i].f_mntfromname;
|
||||||
|
const char *on = mounts[i].f_mntonname;
|
||||||
|
if ((strstr(from, "/mnt/shadowmnt") || strstr(on, "/mnt/shadowmnt")) &&
|
||||||
|
(strstr(from, title_id) || strstr(on, title_id)))
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Distinguish genuine installs from ShadowMountPlus mounts by on-disk layout
|
* Classify each /user/app/<TID> (verified on fw 11.60):
|
||||||
* under /user/app/<TID>/ (verified on fw 11.60):
|
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
|
||||||
* - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount
|
* mount (authoritative; mount.lnk is only a fallback hint)
|
||||||
* - app.pkg (no mount.lnk) -> genuine install; app.json
|
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
|
||||||
* with CDN piece URLs means a not-downloaded preinstall stub, local
|
* URLs means a not-downloaded preinstall stub, local URLs a real install
|
||||||
* URLs mean a real install
|
* - app.json with "fake":true -> homebrew fake (skip)
|
||||||
* - app.json with "fake":true -> homebrew fake (skip)
|
|
||||||
*/
|
*/
|
||||||
static int
|
static int
|
||||||
scan_one(const char *base, const char *name, patchdl_title_t *t) {
|
scan_one(const char *base, const char *name, patchdl_title_t *t,
|
||||||
|
const struct statfs *mounts, int nmounts) {
|
||||||
char dir[PATH_MAX];
|
char dir[PATH_MAX];
|
||||||
char appjson[4096];
|
char appjson[4096];
|
||||||
int has_mountlnk, has_app_pkg, has_paramjson, is_fake = 0, is_cdn = 0;
|
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0, is_cdn = 0;
|
||||||
|
|
||||||
snprintf(dir, sizeof(dir), "%s/%s", base, name);
|
snprintf(dir, sizeof(dir), "%s/%s", base, name);
|
||||||
memset(t, 0, sizeof(*t));
|
memset(t, 0, sizeof(*t));
|
||||||
@@ -252,6 +280,7 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
|
|||||||
if (!is_game_title(t->title_id))
|
if (!is_game_title(t->title_id))
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
|
is_shadow = mount_is_shadow(t->title_id, mounts, nmounts);
|
||||||
has_mountlnk = path_exists(dir, "mount.lnk") ||
|
has_mountlnk = path_exists(dir, "mount.lnk") ||
|
||||||
path_exists(dir, "mount_img.lnk");
|
path_exists(dir, "mount_img.lnk");
|
||||||
has_app_pkg = path_exists(dir, "app.pkg");
|
has_app_pkg = path_exists(dir, "app.pkg");
|
||||||
@@ -266,19 +295,18 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
|
|||||||
if (is_fake)
|
if (is_fake)
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
if (has_mountlnk) {
|
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
|
||||||
/* metadata lives in the mounted sce_sys (param.json, or param.sfo
|
titles never have it (they have mounted/leftover sce_sys content). So
|
||||||
for PS4 titles) */
|
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
|
||||||
if (try_param_json(dir, t))
|
whether the shadow image is currently mounted. */
|
||||||
|
if (has_app_pkg) {
|
||||||
|
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN /* CDN pkg = preinstall */
|
||||||
|
: PATCHDL_SOURCE_OFFICIAL;
|
||||||
|
} else if (is_shadow || has_mountlnk || has_paramjson ||
|
||||||
|
path_exists(dir, "sce_sys/param.sfo")) {
|
||||||
|
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
|
||||||
try_param_sfo(dir, t);
|
try_param_sfo(dir, t);
|
||||||
t->source_type = PATCHDL_SOURCE_SHADOWMOUNT;
|
t->source_type = PATCHDL_SOURCE_SHADOWMOUNT;
|
||||||
} else if (has_app_pkg) {
|
|
||||||
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN
|
|
||||||
: PATCHDL_SOURCE_OFFICIAL;
|
|
||||||
} else if (has_paramjson || path_exists(dir, "sce_sys/param.sfo")) {
|
|
||||||
if (try_param_json(dir, t)) /* genuine game currently mounted */
|
|
||||||
try_param_sfo(dir, t);
|
|
||||||
t->source_type = PATCHDL_SOURCE_OFFICIAL;
|
|
||||||
} else {
|
} else {
|
||||||
return -1; /* empty / leftover directory */
|
return -1; /* empty / leftover directory */
|
||||||
}
|
}
|
||||||
@@ -300,7 +328,8 @@ already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void
|
static void
|
||||||
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
|
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap,
|
||||||
|
const struct statfs *mounts, int nmounts) {
|
||||||
DIR *d;
|
DIR *d;
|
||||||
struct dirent *de;
|
struct dirent *de;
|
||||||
|
|
||||||
@@ -310,7 +339,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
|
|||||||
while ((de = readdir(d))) {
|
while ((de = readdir(d))) {
|
||||||
if (de->d_name[0] == '.') continue;
|
if (de->d_name[0] == '.') continue;
|
||||||
if (*cnt >= cap) break;
|
if (*cnt >= cap) break;
|
||||||
if (scan_one(base, de->d_name, &arr[*cnt]) != 0)
|
if (scan_one(base, de->d_name, &arr[*cnt], mounts, nmounts) != 0)
|
||||||
continue;
|
continue;
|
||||||
if (already_seen(arr, *cnt, arr[*cnt].title_id))
|
if (already_seen(arr, *cnt, arr[*cnt].title_id))
|
||||||
continue; /* dedupe a title already found in an earlier base */
|
continue; /* dedupe a title already found in an earlier base */
|
||||||
@@ -361,6 +390,15 @@ merge_appdb(patchdl_title_t *arr, size_t cnt) {
|
|||||||
patchdl_appdb_free(info);
|
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
|
int
|
||||||
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
||||||
patchdl_title_t *arr;
|
patchdl_title_t *arr;
|
||||||
@@ -369,6 +407,11 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
|||||||
intptr_t saved_root = 0, root_vnode;
|
intptr_t saved_root = 0, root_vnode;
|
||||||
int using_vswap = 0;
|
int using_vswap = 0;
|
||||||
|
|
||||||
|
struct statfs *mounts = NULL;
|
||||||
|
int nmounts;
|
||||||
|
|
||||||
|
if (atomic_load(&g_scan_locked)) return -1;
|
||||||
|
|
||||||
arr = calloc(MAX_TITLES, sizeof(*arr));
|
arr = calloc(MAX_TITLES, sizeof(*arr));
|
||||||
if (!arr) return -1;
|
if (!arr) return -1;
|
||||||
|
|
||||||
@@ -376,15 +419,25 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
|
|||||||
readable; same authid ftpsrv uses. No-op without kernel R/W. */
|
readable; same authid ftpsrv uses. No-op without kernel R/W. */
|
||||||
kernel_set_ucred_authid(pid, 0x4801000000000013L);
|
kernel_set_ucred_authid(pid, 0x4801000000000013L);
|
||||||
|
|
||||||
|
/* Global mount table for shadowmount detection. getmntinfo's buffer is
|
||||||
|
libc-managed — must NOT be freed. */
|
||||||
|
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
|
||||||
|
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
|
||||||
|
|
||||||
root_vnode = kernel_get_root_vnode();
|
root_vnode = kernel_get_root_vnode();
|
||||||
if (root_vnode) {
|
if (root_vnode) {
|
||||||
saved_root = kernel_get_proc_rootdir(pid);
|
saved_root = kernel_get_proc_rootdir(pid);
|
||||||
kernel_set_proc_rootdir(pid, root_vnode);
|
/* Only swap if we captured the current root, so we can always restore
|
||||||
using_vswap = 1;
|
it. Leaving the process rooted at the system root would make later
|
||||||
|
absolute-path writes land in the wrong place. */
|
||||||
|
if (saved_root) {
|
||||||
|
kernel_set_proc_rootdir(pid, root_vnode);
|
||||||
|
using_vswap = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; SCAN_DIRS[i]; i++)
|
for (int i = 0; SCAN_DIRS[i]; i++)
|
||||||
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES);
|
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
|
||||||
|
|
||||||
merge_appdb(arr, cnt);
|
merge_appdb(arr, cnt);
|
||||||
|
|
||||||
@@ -426,6 +479,8 @@ patchdl_scan_debug_json(void) {
|
|||||||
char tmp[2048];
|
char tmp[2048];
|
||||||
pid_t pid = getpid();
|
pid_t pid = getpid();
|
||||||
intptr_t saved_root = 0, root_vnode;
|
intptr_t saved_root = 0, root_vnode;
|
||||||
|
|
||||||
|
if (atomic_load(&g_scan_locked)) return NULL;
|
||||||
int using_vswap = 0;
|
int using_vswap = 0;
|
||||||
struct statfs *mounts = NULL;
|
struct statfs *mounts = NULL;
|
||||||
int nmounts;
|
int nmounts;
|
||||||
@@ -450,8 +505,11 @@ patchdl_scan_debug_json(void) {
|
|||||||
root_vnode = kernel_get_root_vnode();
|
root_vnode = kernel_get_root_vnode();
|
||||||
if (root_vnode) {
|
if (root_vnode) {
|
||||||
saved_root = kernel_get_proc_rootdir(pid);
|
saved_root = kernel_get_proc_rootdir(pid);
|
||||||
kernel_set_proc_rootdir(pid, root_vnode);
|
/* Only swap if we can restore it afterwards (see patchdl_scan). */
|
||||||
using_vswap = 1;
|
if (saved_root) {
|
||||||
|
kernel_set_proc_rootdir(pid, root_vnode);
|
||||||
|
using_vswap = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; SCAN_DIRS[i]; i++) {
|
for (int i = 0; SCAN_DIRS[i]; i++) {
|
||||||
|
|||||||
+13
-2
@@ -23,9 +23,14 @@ typedef struct {
|
|||||||
char compatible_version[16];
|
char compatible_version[16];
|
||||||
char latest_version[16];
|
char latest_version[16];
|
||||||
char latest_required_fw[16];
|
char latest_required_fw[16];
|
||||||
char patch_url[512]; /* delta_url of the compatible patch */
|
char patch_url[512]; /* manifest_url if present, otherwise pkg URL */
|
||||||
char patch_title_id[16]; /* title id embedded in patch_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;
|
int verxml_done;
|
||||||
|
int enabled; /* user policy, persisted in config.json */
|
||||||
|
int resumable; /* a partial download is on disk */
|
||||||
|
long long partial_bytes; /* size of that partial, for the UI */
|
||||||
} patchdl_title_t;
|
} patchdl_title_t;
|
||||||
|
|
||||||
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
|
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
|
||||||
@@ -35,3 +40,9 @@ const char *patchdl_source_str(patchdl_source_t src);
|
|||||||
/* Diagnostic: malloc'd JSON dump of the mount table + scan-base directory
|
/* Diagnostic: malloc'd JSON dump of the mount table + scan-base directory
|
||||||
listings. Caller frees. */
|
listings. Caller frees. */
|
||||||
char *patchdl_scan_debug_json(void);
|
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
|
#pragma once
|
||||||
|
|
||||||
#define PATCHDL_VERSION "0.0.1"
|
#define PATCHDL_VERSION "0.0.6"
|
||||||
+67
-11
@@ -44,6 +44,11 @@ attr_val(const char *tag_start, const char *tag_end, const char *attr,
|
|||||||
p += strlen(needle);
|
p += strlen(needle);
|
||||||
for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++)
|
for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++)
|
||||||
;
|
;
|
||||||
|
/* The value must actually end on its closing quote inside the tag —
|
||||||
|
otherwise we hit tag_end or the buffer limit and would return a
|
||||||
|
silently truncated URL/title as if it were valid. */
|
||||||
|
if (p + len >= tag_end || p[len] != '"')
|
||||||
|
return -1;
|
||||||
memcpy(out, p, len);
|
memcpy(out, p, len);
|
||||||
out[len] = '\0';
|
out[len] = '\0';
|
||||||
return 0;
|
return 0;
|
||||||
@@ -57,18 +62,24 @@ ver_gt(const char *a, const char *b) {
|
|||||||
return strcmp(a, b) > 0;
|
return strcmp(a, b) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03098)
|
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03099)
|
||||||
from a string such as a delta_url. Used to detect cross-title patches. */
|
from a string such as nptitleid, manifest_url, or delta_url. */
|
||||||
static void
|
static void
|
||||||
extract_title_id(const char *s, char *out, size_t sz) {
|
extract_title_id(const char *s, char *out, size_t sz) {
|
||||||
|
size_t len;
|
||||||
out[0] = '\0';
|
out[0] = '\0';
|
||||||
if (sz < 10 || !s) return;
|
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;
|
int ok = 1;
|
||||||
for (int i = 0; i < 4 && ok; i++)
|
for (int k = 0; k < 4 && ok; k++)
|
||||||
if (p[i] < 'A' || p[i] > 'Z') ok = 0;
|
if (p[k] < 'A' || p[k] > 'Z') ok = 0;
|
||||||
for (int i = 4; i < 9 && ok; i++)
|
for (int k = 4; k < 9 && ok; k++)
|
||||||
if (p[i] < '0' || p[i] > '9') ok = 0;
|
if (p[k] < '0' || p[k] > '9') ok = 0;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
memcpy(out, p, 9);
|
memcpy(out, p, 9);
|
||||||
out[9] = '\0';
|
out[9] = '\0';
|
||||||
@@ -77,9 +88,31 @@ extract_title_id(const char *s, char *out, size_t sz) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
parse_root_title_id(const char *xml, char *out, size_t sz) {
|
||||||
|
const char *p;
|
||||||
|
const char *tag_end;
|
||||||
|
char nptitleid[64] = {0};
|
||||||
|
|
||||||
|
out[0] = '\0';
|
||||||
|
if (!xml || sz < 10) return;
|
||||||
|
|
||||||
|
p = strstr(xml, "<title_patch");
|
||||||
|
if (!p) return;
|
||||||
|
tag_end = strchr(p, '>');
|
||||||
|
if (!tag_end) return;
|
||||||
|
tag_end++;
|
||||||
|
|
||||||
|
if (!attr_val(p, tag_end, "nptitleid", nptitleid, sizeof(nptitleid)))
|
||||||
|
extract_title_id(nptitleid, out, sz);
|
||||||
|
}
|
||||||
|
|
||||||
static void
|
static void
|
||||||
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
||||||
const char *p = xml;
|
const char *p = xml;
|
||||||
|
char root_title[16] = {0};
|
||||||
|
|
||||||
|
parse_root_title_id(xml, root_title, sizeof(root_title));
|
||||||
|
|
||||||
while ((p = strstr(p, "<package "))) {
|
while ((p = strstr(p, "<package "))) {
|
||||||
const char *tag_end = strchr(p, '>');
|
const char *tag_end = strchr(p, '>');
|
||||||
@@ -89,12 +122,14 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
|||||||
char ver[16] = {0};
|
char ver[16] = {0};
|
||||||
char sver[16] = {0};
|
char sver[16] = {0};
|
||||||
char durl[512] = {0};
|
char durl[512] = {0};
|
||||||
|
char murl[512] = {0};
|
||||||
|
|
||||||
/* PS5 version.xml uses content_ver; PS4 uses version. */
|
/* PS5 version.xml uses content_ver; PS4 uses version. */
|
||||||
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
|
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
|
||||||
attr_val(p, tag_end, "version", ver, sizeof(ver));
|
attr_val(p, tag_end, "version", ver, sizeof(ver));
|
||||||
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
|
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
|
||||||
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
|
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
|
||||||
|
attr_val(p, tag_end, "manifest_url", murl, sizeof(murl));
|
||||||
|
|
||||||
if (ver[0] && sver[0]) {
|
if (ver[0] && sver[0]) {
|
||||||
uint32_t pkg_sver = parse_hex(sver);
|
uint32_t pkg_sver = parse_hex(sver);
|
||||||
@@ -106,16 +141,33 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
|||||||
sizeof(out->latest_required_fw));
|
sizeof(out->latest_required_fw));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track latest compatible + its patch URL */
|
/* Track latest compatible + its installable patch source. PS5
|
||||||
|
updates expose a small delta_url (DP.pkg) plus a manifest_url
|
||||||
|
containing the actual split package pieces. Feeding the DP
|
||||||
|
bootstrap directly can make the system download the full patch
|
||||||
|
under the storage/master title id, so prefer the target-title
|
||||||
|
manifest whenever present. */
|
||||||
if (pkg_sver <= fw_bin) {
|
if (pkg_sver <= fw_bin) {
|
||||||
if (!out->compatible_version[0] ||
|
if (!out->compatible_version[0] ||
|
||||||
ver_gt(ver, out->compatible_version)) {
|
ver_gt(ver, out->compatible_version)) {
|
||||||
strncpy(out->compatible_version, ver,
|
strncpy(out->compatible_version, ver,
|
||||||
sizeof(out->compatible_version) - 1);
|
sizeof(out->compatible_version) - 1);
|
||||||
strncpy(out->compatible_url, durl,
|
strncpy(out->compatible_url, murl[0] ? murl : durl,
|
||||||
sizeof(out->compatible_url) - 1);
|
sizeof(out->compatible_url) - 1);
|
||||||
extract_title_id(durl, out->compatible_title,
|
if (durl[0])
|
||||||
sizeof(out->compatible_title));
|
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]) {
|
||||||
|
strncpy(out->compatible_title, root_title,
|
||||||
|
sizeof(out->compatible_title) - 1);
|
||||||
|
} else {
|
||||||
|
extract_title_id(murl, out->compatible_title,
|
||||||
|
sizeof(out->compatible_title));
|
||||||
|
if (!out->compatible_title[0])
|
||||||
|
extract_title_id(durl, out->compatible_title,
|
||||||
|
sizeof(out->compatible_title));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +183,10 @@ patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
|
|||||||
if (!url || !out) return -1;
|
if (!url || !out) return -1;
|
||||||
memset(out, 0, sizeof(*out));
|
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 (patchdl_http_get(url, &buf)) return -1;
|
||||||
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
if (!buf.data || !buf.size) { free(buf.data); return -1; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ typedef struct {
|
|||||||
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */
|
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */
|
||||||
char latest_version[16]; /* highest pkg overall, or "" */
|
char latest_version[16]; /* highest pkg overall, or "" */
|
||||||
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
|
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
|
||||||
char compatible_url[512]; /* delta_url of the chosen compatible pkg */
|
char compatible_url[512]; /* manifest_url if present, otherwise pkg URL */
|
||||||
char compatible_title[16]; /* title id embedded in that delta_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;
|
} patchdl_verinfo_t;
|
||||||
|
|
||||||
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
|
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
|
||||||
+1938
-72
File diff suppressed because it is too large.
Load diff
+18
-2
@@ -16,8 +16,14 @@ GET /api/config
|
|||||||
POST /api/config
|
POST /api/config
|
||||||
GET /api/titles
|
GET /api/titles
|
||||||
GET /api/downloads
|
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/check
|
||||||
POST /api/titles/:title_id/download
|
POST /api/titles/:title_id/download
|
||||||
|
POST /api/titles/:title_id/install
|
||||||
|
POST /api/titles/:title_id/enable
|
||||||
|
POST /api/titles/:title_id/disable
|
||||||
```
|
```
|
||||||
|
|
||||||
## Policy Model
|
## Policy Model
|
||||||
@@ -27,9 +33,9 @@ The UI assumes deny-by-default behavior:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"default_policy": "deny",
|
"default_policy": "deny",
|
||||||
"download_dir": "/mnt/usb0/patches",
|
"download_dir": "/data/patchdl (internal)",
|
||||||
"install_after_download": false,
|
"install_after_download": false,
|
||||||
"delete_pkg_after_install": false,
|
"delete_pkg_after_install": true,
|
||||||
"source_policy": {
|
"source_policy": {
|
||||||
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
|
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||||
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
|
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
|
||||||
@@ -80,3 +86,13 @@ unknown
|
|||||||
The frontend treats `shadowmount` as download-only and `unknown` as blocked for
|
The frontend treats `shadowmount` as download-only and `unknown` as blocked for
|
||||||
downloads and installs. Backend code should enforce the same policy even if a
|
downloads and installs. Backend code should enforce the same policy even if a
|
||||||
client sends a forged request.
|
client sends a forged request.
|
||||||
|
|
||||||
|
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.
|
||||||
+752
-373
File diff suppressed because it is too large.
Load diff
+170
-123
@@ -2,48 +2,34 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>PatchDL</title>
|
<title>PatchDL</title>
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg class="icon-sprite" aria-hidden="true">
|
<svg class="icon-sprite" aria-hidden="true">
|
||||||
<symbol id="icon-shield" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 3l7 3v5c0 5-3 8-7 10-4-2-7-5-7-10V6l7-3z" />
|
|
||||||
<path d="M9 12l2 2 4-5" />
|
|
||||||
</symbol>
|
|
||||||
<symbol id="icon-download" viewBox="0 0 24 24">
|
<symbol id="icon-download" viewBox="0 0 24 24">
|
||||||
<path d="M12 3v11" />
|
<path d="M12 3v11" /><path d="M7 10l5 5 5-5" /><path d="M5 20h14" />
|
||||||
<path d="M7 10l5 5 5-5" />
|
|
||||||
<path d="M5 20h14" />
|
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="icon-refresh" viewBox="0 0 24 24">
|
<symbol id="icon-refresh" viewBox="0 0 24 24">
|
||||||
<path d="M20 6v6h-6" />
|
<path d="M20 6v6h-6" /><path d="M4 18v-6h6" />
|
||||||
<path d="M4 18v-6h6" />
|
<path d="M19 12A7 7 0 0 0 7 7" /><path d="M5 12a7 7 0 0 0 12 5" />
|
||||||
<path d="M19 12A7 7 0 0 0 7 7" />
|
|
||||||
<path d="M5 12a7 7 0 0 0 12 5" />
|
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="icon-settings" viewBox="0 0 24 24">
|
<symbol id="icon-settings" viewBox="0 0 24 24">
|
||||||
<path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
<path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||||
<path d="M4 12h2M18 12h2M12 4v2M12 18v2M6.5 6.5 8 8M16 16l1.5 1.5M17.5 6.5 16 8M8 16l-1.5 1.5" />
|
<path d="M4 12h2M18 12h2M12 4v2M12 18v2M6.5 6.5 8 8M16 16l1.5 1.5M17.5 6.5 16 8M8 16l-1.5 1.5" />
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="icon-search" viewBox="0 0 24 24">
|
<symbol id="icon-search" viewBox="0 0 24 24">
|
||||||
<path d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" />
|
<path d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" /><path d="M16 16l5 5" />
|
||||||
<path d="M16 16l5 5" />
|
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="icon-save" viewBox="0 0 24 24">
|
<symbol id="icon-save" viewBox="0 0 24 24">
|
||||||
<path d="M5 4h12l2 2v14H5V4z" />
|
<path d="M5 4h12l2 2v14H5V4z" /><path d="M8 4v6h8V4" /><path d="M8 20v-6h8v6" />
|
||||||
<path d="M8 4v6h8V4" />
|
|
||||||
<path d="M8 20v-6h8v6" />
|
|
||||||
</symbol>
|
|
||||||
<symbol id="icon-pause" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14" />
|
|
||||||
<path d="M16 5v14" />
|
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="icon-log" viewBox="0 0 24 24">
|
<symbol id="icon-log" viewBox="0 0 24 24">
|
||||||
<path d="M7 4h10l3 3v13H7V4z" />
|
<path d="M7 4h10l3 3v13H7V4z" /><path d="M17 4v4h4" /><path d="M10 12h7M10 16h5" />
|
||||||
<path d="M17 4v4h4" />
|
</symbol>
|
||||||
<path d="M10 12h7M10 16h5" />
|
<symbol id="icon-grid" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" />
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@@ -53,111 +39,127 @@
|
|||||||
<div class="brand-mark">PD</div>
|
<div class="brand-mark">PD</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>PatchDL</strong>
|
<strong>PatchDL</strong>
|
||||||
<span>by Knutwurst · v0.0.1</span>
|
<span>by Knutwurst · <span id="brandVersion">--</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav-list">
|
<nav class="nav-list" aria-label="Sections">
|
||||||
<a class="nav-link is-active" href="#games">
|
<button class="nav-link is-active" data-view="games" aria-label="Games" aria-current="page">
|
||||||
<svg><use href="#icon-download"></use></svg>
|
<svg><use href="#icon-grid"></use></svg><span>Games</span>
|
||||||
Games
|
</button>
|
||||||
</a>
|
<button class="nav-link" data-view="settings" aria-label="Settings">
|
||||||
<a class="nav-link" href="#downloads">
|
<svg><use href="#icon-settings"></use></svg><span>Settings</span>
|
||||||
<svg><use href="#icon-refresh"></use></svg>
|
</button>
|
||||||
Queue
|
<button class="nav-link" data-view="logs" aria-label="Logs">
|
||||||
</a>
|
<svg><use href="#icon-log"></use></svg><span>Logs</span>
|
||||||
<a class="nav-link" href="#settings">
|
</button>
|
||||||
<svg><use href="#icon-settings"></use></svg>
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href="#logs">
|
|
||||||
<svg><use href="#icon-log"></use></svg>
|
|
||||||
Logs
|
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="rail-foot">
|
||||||
|
<span id="railFw">FW --</span>
|
||||||
|
<span id="railSpace">-- free</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<!-- ============ GAMES ============ -->
|
||||||
<div>
|
<section class="view is-active" data-view="games">
|
||||||
<p class="eyebrow">Standalone ELF Web UI</p>
|
<header class="topbar">
|
||||||
<h1>Controlled game patch downloads</h1>
|
<div>
|
||||||
</div>
|
<p class="eyebrow">Standalone ELF Web UI</p>
|
||||||
<div class="topbar-actions">
|
<h1>Games</h1>
|
||||||
<button class="icon-button" id="refreshBtn" title="Refresh status and games">
|
</div>
|
||||||
<svg><use href="#icon-refresh"></use></svg>
|
<div class="topbar-actions">
|
||||||
</button>
|
<button class="primary-button" id="updateAllBtn" title="Download (and optionally install) every game that has an available, allowed update">
|
||||||
<button class="primary-button" id="saveBtn">
|
<svg><use href="#icon-download"></use></svg>
|
||||||
<svg><use href="#icon-save"></use></svg>
|
Update all
|
||||||
Save
|
</button>
|
||||||
</button>
|
<button class="icon-button" id="refreshBtn" title="Refresh status and games" aria-label="Refresh">
|
||||||
</div>
|
<svg><use href="#icon-refresh"></use></svg>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="status-strip" aria-label="System status">
|
|
||||||
<article class="metric">
|
|
||||||
<span>Firmware</span>
|
|
||||||
<strong id="firmwareValue">--</strong>
|
|
||||||
<em id="firmwareBuild">System version</em>
|
|
||||||
</article>
|
|
||||||
<article class="metric">
|
|
||||||
<span>DNS Guard</span>
|
|
||||||
<strong id="dnsValue">--</strong>
|
|
||||||
<em>Sony blocked by nanoDNS</em>
|
|
||||||
</article>
|
|
||||||
<article class="metric">
|
|
||||||
<span>CDN Access</span>
|
|
||||||
<strong id="resolverValue">--</strong>
|
|
||||||
<em>PatchDL resolver only</em>
|
|
||||||
</article>
|
|
||||||
<article class="metric">
|
|
||||||
<span>Storage</span>
|
|
||||||
<strong id="spaceValue">--</strong>
|
|
||||||
<em id="downloadDirValue">Download target</em>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="toolbar" id="games">
|
|
||||||
<div class="search-box">
|
|
||||||
<svg><use href="#icon-search"></use></svg>
|
|
||||||
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="segmented" role="tablist" aria-label="Filter">
|
|
||||||
<button class="is-selected" data-filter="all">All</button>
|
|
||||||
<button data-filter="updatable">Updatable</button>
|
|
||||||
<button data-filter="uptodate">Up to date</button>
|
|
||||||
<button data-filter="needsfw">Needs FW</button>
|
|
||||||
<button data-filter="blocked">Can't update</button>
|
|
||||||
<button data-filter="queued">Queue</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="game-grid" id="gameGrid" aria-live="polite"></section>
|
|
||||||
|
|
||||||
<section class="split-band">
|
|
||||||
<div class="panel" id="downloads">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Downloads</p>
|
|
||||||
<h2>Active Queue</h2>
|
|
||||||
</div>
|
|
||||||
<button class="ghost-button" id="pauseAllBtn">
|
|
||||||
<svg><use href="#icon-pause"></use></svg>
|
|
||||||
Pause
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-list" id="queueList"></div>
|
</header>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel" id="settings">
|
<aside id="globalDl" class="global-dl" hidden aria-live="polite">
|
||||||
<div class="panel-heading">
|
<div class="global-dl-row">
|
||||||
<div>
|
<span class="global-dl-pulse" aria-hidden="true"></span>
|
||||||
<p class="eyebrow">Policy</p>
|
<div class="global-dl-text">
|
||||||
<h2>Update Rules</h2>
|
<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>
|
</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>
|
||||||
|
<strong id="firmwareValue">--</strong>
|
||||||
|
<em id="firmwareBuild">System version</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>DNS Guard</span>
|
||||||
|
<strong id="dnsValue">--</strong>
|
||||||
|
<em>Sony blocked by nanoDNS</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>CDN Access</span>
|
||||||
|
<strong id="resolverValue">--</strong>
|
||||||
|
<em>PatchDL resolver only</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>Storage</span>
|
||||||
|
<strong id="spaceValue">--</strong>
|
||||||
|
<em id="downloadDirValue">Download target</em>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<svg><use href="#icon-search"></use></svg>
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
<section class="game-grid" id="gameGrid"></section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ SETTINGS ============ -->
|
||||||
|
<section class="view" data-view="settings">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Policy</p>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button class="primary-button" id="saveBtn">
|
||||||
|
<svg><use href="#icon-save"></use></svg>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Default Policy</span>
|
<span>Default Policy</span>
|
||||||
@@ -168,22 +170,61 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Download Folder</span>
|
<span>Download Folder</span>
|
||||||
<input id="downloadDir" type="text" spellcheck="false" />
|
<input id="downloadDir" type="text" spellcheck="false" readonly
|
||||||
|
title="Patches always download internally and are removed after install" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="switch-row">
|
<label class="switch-row">
|
||||||
<input id="installAfterDownload" type="checkbox" />
|
|
||||||
<span>
|
<span>
|
||||||
<strong>Install after download</strong>
|
<strong>Install after download</strong>
|
||||||
<em>Global default, overridable per game</em>
|
<em>Global default, overridable per game</em>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="toggle">
|
||||||
|
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" />
|
||||||
|
<span class="track"></span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="switch-row">
|
<label class="switch-row">
|
||||||
<input id="deleteAfterInstall" type="checkbox" />
|
|
||||||
<span>
|
<span>
|
||||||
<strong>Delete PKG after install</strong>
|
<strong>Delete PKG after install</strong>
|
||||||
<em>Only after a successful install</em>
|
<em>Only after a successful install</em>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="toggle">
|
||||||
|
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" />
|
||||||
|
<span class="track"></span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>Verify downloaded pieces (SHA-256)</strong>
|
||||||
|
<em>Checks each manifest piece; off by default, verify on-device first</em>
|
||||||
|
</span>
|
||||||
|
<span class="toggle">
|
||||||
|
<input id="verifyDownloads" type="checkbox" aria-label="Verify downloaded pieces" />
|
||||||
|
<span class="track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>Home-screen shortcut</strong>
|
||||||
|
<em id="shortcutHint">Add a PS5 home-screen icon that opens this UI in the browser</em>
|
||||||
|
</span>
|
||||||
|
<span class="toggle">
|
||||||
|
<input id="homeShortcut" type="checkbox" aria-label="Install home-screen shortcut" />
|
||||||
|
<span class="track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>Parallel download connections</strong>
|
||||||
|
<em>1–16 · applies live, no payload restart</em>
|
||||||
|
</span>
|
||||||
|
<div class="stepper" role="group" aria-label="Parallel download connections">
|
||||||
|
<button type="button" class="stepper-btn" id="connMinus" aria-label="Fewer connections">−</button>
|
||||||
|
<output class="stepper-value" id="connValue" aria-live="polite">4</output>
|
||||||
|
<button type="button" class="stepper-btn" id="connPlus" aria-label="More connections">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="allowlist">
|
<div class="allowlist">
|
||||||
@@ -198,13 +239,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="log-panel" id="logs">
|
<!-- ============ LOGS ============ -->
|
||||||
<div class="panel-heading">
|
<section class="view" data-view="logs">
|
||||||
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Runtime</p>
|
<p class="eyebrow">Runtime</p>
|
||||||
<h2>Recent Events</h2>
|
<h1>Logs</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="topbar-actions">
|
||||||
|
<button class="icon-button" id="clearLogBtn" title="Clear log" aria-label="Clear log">
|
||||||
|
<svg><use href="#icon-log"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<pre id="logOutput" tabindex="0"></pre>
|
<pre id="logOutput" tabindex="0"></pre>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+380
-490
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user