13 Commits
Author SHA1 Message Date
Knutwurst 79e1c377ed Update README for 0.0.3: parallel pool, resume, verify, honest install status
Document the connection-pool download (configurable 1–16, applied live), the
download queue, reboot-safe per-piece resume, Pause/Resume/Cancel, optional
SHA-256 verification, and on-device package verification. Add a Settings section.
Rewrite Status: download + verify is proven on 11.60 (a 61.6 GB update verified
byte-perfect across reboots); same-region install works; cross-region debug-magic
patches download and verify but cannot be installed via homebrew on 11.60.
2026-06-24 15:53:45 +02:00
Knutwurst fb9d06fb5b Install: pass the /user/data path Sony allowlists; report per-URI rc
Sony path-allowlists the URI given to sceAppInstUtilInstallByPackage —
/user/data/ and /mnt/usb are accepted, a bare /data/... path is rejected with
0x80B2116F (confirmed by the ps5upload project). PatchDL stored the pkg under
/data/patchdl and passed that /data path, so every install was rejected at the
path stage. Pass the /user/data view of the same file instead (the code already
computed it as sdk_path; it was only used for AppInstallPkg before).

Also report each URI's individual rc instead of only the last attempt's, which
revealed the real wall: via file:// the installer reaches header parsing and
rejects with 0x80B21106 — the assembled file is a valid but DEBUG-magic PKG
(\x7FFIH, not retail \x7FCNT), the format Sony's system updater consumes rather
than the retail-pkg format InstallByPackage expects.
2026-06-24 14:25:50 +02:00
Knutwurst 3d2ee430e1 Add read-only pkg diagnostics: manifest dump, integrity verify, embedded ids
Three read-only endpoints (no install, no writes) to inspect a downloaded
package on-device:

- GET /api/manifest/<title_id> — re-fetch the patch manifest (PatchDL bypasses
  the DNS block) and dump each piece's offset/size/SHA-256.
- GET /api/pkgverify/<title_id> — SHA-256 every piece of the assembled .pkg
  against the manifest hashes, on-device (SSD, no multi-GB transfer), and report
  per-piece pass/fail. Proves whether the file is byte-correct.
- GET /api/pkgmeta/<title_id> — read the pkg's embedded content id + title id
  (GetContentIdFromPkg) vs the target ids, to expose cross-region linkage.

Supporting code: patchdl_sha256_fd_region() (pread + OpenSSL EVP) in the net
layer, and bind sceAppInstUtilGetContentIdFromPkg in the install backend.

Used to diagnose the Dead Island 2 install: the 61.6 GB package verifies
byte-perfect (17/17 pieces) and its content id matches the target, so the
0x80B2116F install rejection is a Sony install-method limitation, not the data.
2026-06-24 14:10:39 +02:00
Knutwurst 986ff00c36 Persist resume state every piece; replace conn field with a stepper
Resume: write the sidecar after every completed piece instead of batching
every 8. Each piece's bytes are already fdatasync'd and the sidecar write is
a tiny atomic tmp+rename, so an unclean kill now re-downloads only the pieces
still in flight, not a batch of up-to-8 already-finished ones. Drops the now
-unused 'unpersisted' counter.

UI: the "parallel download connections" control is now a stepper — two large
54px -/+ buttons around a tabular value, in a row beside its label, instead of
a full-width number field for a 1-16 value. Big targets and a clear green focus
ring suit controller navigation (the UI is driven by the PS5 pad via the home
tile). Tapping -/+ updates and auto-saves that field alone (debounced), applying
live on the server.
2026-06-24 12:10:29 +02:00
Knutwurst 6024dbfc5d Apply the connection-count setting live, without a payload restart
The pool now spawns the full worker set at startup and gates each worker by
its slot against a live active_conns limit, instead of spawning exactly
max_connections threads once. Saving a new value in Settings updates the
limit and broadcasts: idle workers wake to pull pieces, and a lowered limit
parks the extra workers after they finish their current piece. No restart,
and no thread creation/teardown at runtime.

Verified on device: max_connections changed 4 -> 8 -> 16 -> 4 through the API
while a download stayed active throughout. (Throughput did not scale with
connections on this CDN, which caps aggregate bandwidth per source IP; 4 is a
sensible default.)
2026-06-24 11:36:48 +02:00
Knutwurst 21f6bd61ad Download patches over a connection pool with a queue and resume
Replace the single sequential transfer with a pool of N worker threads
that pull pieces of one manifest in parallel, lifting the per-connection
~7 MB/s ceiling. One job runs at a time; the rest queue. The connection
count is configurable (1-16, default 4) and applies on the next start.

Resume is tracked per piece in a sidecar bitmap that survives a reboot,
and a one-time migration recognises a partial written by the old
sequential build (a piece-aligned contiguous prefix on disk) and marks
those pieces done so an in-progress download is not restarted from zero.

Pause keeps the partial; Cancel deletes it. Both, plus Resume, are
available at any point in a download's life.

Concurrency review fixes folded in:
- a job is published as the active (claimable) job only after its
  manifest/state/fd are attached, so a half-built job can no longer be
  settled to "done" before any bytes are fetched
- cancel/pause during the admit I/O window only flag the job; admit_next
  is the sole finalizer, closing a use-after-free and a lost-pause race
- resuming a paused job frees the stale per-job buffers and zeroes the
  committed counters before re-seeding, fixing a leak and a double-count
- the background version.xml thread is joined on shutdown before the
  title list is freed
- verify_downloads is snapshotted under its own lock before the pool lock
- the web UI keeps Resume/Cancel after a failed transfer and bounds the
  local "downloading" bridge flag so a card cannot wedge
2026-06-24 11:12:25 +02:00
Knutwurst 01eaef79f2 Add pause/resume, within-part byte-range resume; bump to 0.0.3
Split the single morphing button into a green/amber play-pause (Update →
Pause → Resume) and a red stop (Cancel). Pause aborts the download but
keeps the partial (resumable); Cancel aborts and deletes. Backend gets a
separate pause flag distinct from cancel.

Resume now continues WITHIN a part: the partially-written piece is fetched
from its last byte via an HTTP byte range (with a safe fall back to
re-fetching the whole piece if the CDN ignores the range), instead of
re-downloading the whole part. A title is resumable as soon as any bytes
are on disk.

Version bumped to 0.0.3 (no release tagged).
2026-06-24 09:47:44 +02:00
Knutwurst 66e4912485 Resume interrupted downloads across a reboot
An interrupted download (cancel excepted) now keeps its partial package on
disk instead of deleting it, and records the manifest it belongs to in a
sidecar (state.json). On the next start the title is flagged resumable and
the UI shows a "Paused — X downloaded" note with a Resume button.

Resume refetches the manifest, skips every piece already fully on disk, and
re-fetches only the one partially-written piece (piece-granular, no HTTP
range needed), appending the rest. The fresh-download path is unchanged. A
partial belonging to a different/older manifest is dropped and the download
starts clean; a corrupt (failed SHA-256) download is not kept.

Survives a reboot: a killed payload runs no cleanup, so the partial and its
sidecar persist under /data/patchdl until resumed, completed, or deleted.
2026-06-24 08:58:32 +02:00
Knutwurst 3f40dc1d7c Rework the web UI: tile-based progress, view nav, responsive
Replace the single-page layout with a view-based one (Games / Settings /
Logs via the left nav) and fold all download UI into the game tile — the
separate download queue is gone.

Per tile while downloading: an in-tile progress bar with auto-scaled size
(B/KB/MB/GB/TB), live transfer speed and ETA from the poll deltas, and a
green "Downloading" marker. One fixed-width action button that no longer
reflows with its label: a blue Update/Download/Install that morphs into an
amber Cancel while the download runs, plus a ghost Delete for a finished
package.

Settings and Logs moved to their own pages. Mobile-first responsive layout
(rail collapses to a top bar, icon-only nav, single-column tiles, >=44px
touch targets, 16px inputs). Real free space (statvfs) is shown auto-scaled
in the rail and status strip.

New "Home-screen shortcut" toggle (default on, persisted as home_shortcut
in config.json). The actual PS5 tile install is not wired yet: it needs a
prebuilt deeplinkUri stub PKG installed via sceAppInstUtil.

Fixes from an adversarial review pass:
- Reconcile in-flight downloads from /api/downloads onto the cards, so
  progress + Cancel appear after a reload or a download started elsewhere,
  and stop the per-poll full-grid rebuild.
- Clear the downloaded flag on install and once the server reports the
  title up to date, so a patched title no longer shows Install/Delete
  forever.
- Zero a stale speed/ETA if the byte counter goes backwards.
- a11y: nav buttons keep an accessible name when the label is hidden on
  small screens; visible focus ring on the search box; filter group is
  role=group with aria-pressed; drop the noisy grid-level aria-live; fold
  the transient "checking" state into a visible filter bucket.
2026-06-23 21:48:54 +02:00
Knutwurst 5f0d1be078 Harden filesystem safety after a security review
Defense in depth around the only operations that touch the filesystem,
so no request can escape /data/patchdl or leave the process able to
write to a system path:

- Validate the HTTP title_id with path_segment_safe at the top of the
  title-action route, and again inside remove_title_dir and
  cleanup_installed_download. The delete primitives are now self-
  protecting instead of relying only on the "title exists in the scan"
  guard, so a future refactor cannot reintroduce a /data-wiping
  traversal (a title_id of ".." would otherwise resolve the dir to
  /data).
- Only swap the process root vnode when the current root was captured
  and can be restored, in both the scan and the debug dump. Otherwise
  the process could be left rooted at the system root, sending later
  absolute-path writes to the wrong place.

Reviewed and confirmed safe with no change needed: the installer only
delegates to Sony's signed AppInstUtil service (it never writes or
redirects to system paths itself), app.db is opened read-only and
immutable, every write targets /data/patchdl, the patch picker never
selects an update that needs a newer firmware, and the /api/pkg file
server already blocks path traversal.
2026-06-23 19:46:23 +02:00
Knutwurst ea1328de79 Add optional SHA-256 verification of downloaded manifest pieces
Each Sony manifest piece carries a SHA-256 (hashValue). When the new
"Verify downloaded pieces" setting is on, every piece is hashed while it
streams to disk (OpenSSL EVP, already linked) and compared against the
manifest value; a mismatch aborts the download, deletes the partial, and
reports piece_verify_failed instead of handing a corrupt 60 GB package to
the installer.

Off by default: TLS already protects the bytes in transit and the PS5
installer verifies the whole packageDigest before applying, so this is a
fail-fast belt-and-suspenders check. It is also unverified on hardware
yet, so it stays opt-in (persisted in config.json) until confirmed
on-device; the hex compare is case-insensitive since Sony mixes cases.
2026-06-23 18:08:57 +02:00
Knutwurst 9006965a75 Add download cancel/delete and harden the patch pipeline
Cancel a running download (the worker aborts mid-piece and the partial
file is removed) or delete a finished package, from the queue or the
title card. The progress callback now returns an abort signal that
reaches libcurl and the manifest merge loop.

Manifest merge: bound the piece scan to the "pieces" array so a later
"url" key (e.g. playgoChunkCrcUrl) can't be appended as a bogus piece,
and require each piece's fileOffset to match the bytes written so far so
an out-of-order manifest fails instead of silently producing a corrupt
package.

Report real free space on the download partition via statvfs; it was a
hardcoded 0.

Fixes found in review:
- scan: bound the SFO entry table to the bytes actually read and require
  the key to be NUL-terminated before strcmp (OOB read on a crafted
  param.sfo from a shadow-mounted dir).
- proc: bound the kinfo_proc walk and the name compare to the record and
  the buffer.
- install: publish the API probe under the lock (data race with the MHD
  worker thread) and initialize rc2.
- verxml: reject a truncated attribute value instead of returning it as
  valid.
- web: keep download/install/downloaded flags across a refresh, stop the
  queue poll only after repeated empty results, coerce the progress
  number, and treat a cancelled download (HTTP 200, ok:false) as
  not-downloaded.
2026-06-23 17:52:29 +02:00
Knutwurst 510f199b89 Handle target-aware patch installs 2026-06-23 17:03:51 +02:00
17 changed files with 3786 additions and 1186 deletions

No files matched your search

+77 -31
View File
@@ -1,31 +1,54 @@
# PatchDL
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
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.
by Knutwurst
## What it does
- Scans installed titles and classifies each one: genuine install,
ShadowMountPlus mount, preinstall, or unknown.
- Reads the title name, installed version, and the Sony `version.xml` URL from
the PS5 app database.
- Fetches each title's `version.xml` from Sony's CDN past nanoDNS (a raw DNS
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
- Scans installed titles and classifies each: genuine install, ShadowMountPlus
mount, preinstall, or unknown.
- Reads the title name, installed version, and Sony `version.xml` URL from the
PS5 app database.
- Fetches each title's `version.xml` past nanoDNS (a raw DNS query to 1.1.1.1)
and verifies TLS against the pinned SCEI DNAS root.
- Picks the newest patch compatible with the current firmware
(`system_ver <= firmware`), so an update never forces a firmware upgrade.
- Downloads the patch package and installs it through Sony's AppInstUtil
service.
- Downloads the patch from Sony's manifest pieces into one local `.pkg`, then
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.
## Safety model
Deny-by-default. A patch is installed only for a genuine install, and only when
the package's title id matches the installed game:
Deny-by-default. A patch installs only for a genuine install, and only when the
patch metadata targets the installed game:
| Source | Check | Download | Install |
|-----------------------|-------|----------|---------|
@@ -33,19 +56,30 @@ the package's title id matches the installed game:
| shadowmount | yes | yes | no |
| preinstall / unknown | yes | no | no |
Two independent guards stop the wrong package being installed: the patch's title
id (read from its download URL) must match the game, and just before install the
real title id is read back from the package
(`sceAppInstUtilGetTitleIdFromPkg`) and checked again. A cross-region or
cross-title package is refused instead of installed as a phantom title.
Two guards stop the wrong target being installed: the patch target id (from
`version.xml` / `manifest_url`) must match the installed game, and the install
call receives the installed game's content id from app.db. PatchDL refuses a
true target-title mismatch rather than installing 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
Requires `ps5-payload-dev/sdk`. The network and install features also need the
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` placed in the SDK
sysroot (`target/user/homebrew`); `scripts/build_ps5.sh` enables them
automatically when present. libmicrohttpd is vendored under `vendor/etahen`, and
SQLite is vendored under `vendor/sqlite`.
Requires `ps5-payload-dev/sdk`. The network and install features need the
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` in the SDK sysroot
(`target/user/homebrew`); `scripts/build_ps5.sh` enables them when present.
libmicrohttpd is vendored under `vendor/etahen`, SQLite under `vendor/sqlite`.
```sh
scripts/build_ps5.sh # produces patchdl-ps5.elf
@@ -53,9 +87,9 @@ scripts/build_ps5.sh # produces patchdl-ps5.elf
## Deploy
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port
8084 (not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the ELF named with its
version and launches it; the payload replaces any running instance itself.
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port 8084
(not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the version-named ELF and
launches it; the payload replaces any running instance.
```sh
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
@@ -69,9 +103,21 @@ http://<console-ip>:12880/
## Status
0.0.1, early. Title scan, version resolution, firmware-compatibility filtering,
download, and install work and have been verified on firmware 11.60. Open items:
the web UI marks a title "Installing…" but reads progress from the PS5's own
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
apply (a normal Sony requirement).
Verified on firmware 11.60: title scan, source classification, version
resolution past the nanoDNS block, firmware-compatibility filtering, the parallel
download pool, reboot-safe resume, and on-device SHA-256 verification. A full
Dead Island 2 update (61.6 GB) downloaded and verified byte-perfect across
several reboots.
Install works for same-region patches, where Sony stores the patch bytes under
the installed game's own title id.
Cross-region patches are a known limitation. Sony sometimes packages a regional
patch under a different (master) storage title and ships it as a debug-magic
container. PatchDL downloads such a patch and verifies it against Sony's hashes,
but the on-console installer (`InstallByPackage`) rejects it on 11.60, and the
homebrew alternative (BGFT register) returns "not supported" outside the system
process. Installing that class of patch needs Sony's authenticated updater, which
nanoDNS blocks. The web UI marks a title "Installing…" and reads progress from the
PS5's own notifications. Disc games need the disc inserted for their patch to
apply, which is a normal Sony requirement.
+26 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
# 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
# Payload Manager UI. patchdl self-kills any running instance, so this is
# an idempotent redeploy.
# Payload Manager UI. Any running instance is killed first (the payload's own
# self-kill is racy when the port is still held), so this is a clean redeploy.
#
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
@@ -40,6 +40,30 @@ for p in paths:
')
[ -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 ..."
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
+278 -32
View File
@@ -1,5 +1,8 @@
#include "patchdl_install.h"
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <ps5/kernel.h>
#include <pthread.h>
@@ -7,6 +10,7 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
@@ -53,11 +57,13 @@ 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,
ai_playgo_info_t *playgo);
typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app);
typedef int (*ai_content_from_pkg_fn)(const char *path, char *content_id, int *is_app);
static ai_init_fn ai_initialize;
static ai_install_pkg_fn ai_install_pkg;
static ai_install_by_pkg_fn ai_install_by_package;
static ai_title_from_pkg_fn ai_title_from_pkg;
static ai_init_fn ai_initialize;
static ai_install_pkg_fn ai_install_pkg;
static ai_install_by_pkg_fn ai_install_by_package;
static ai_title_from_pkg_fn ai_title_from_pkg;
static ai_content_from_pkg_fn ai_content_from_pkg;
/* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
@@ -70,6 +76,7 @@ static ai_title_from_pkg_fn ai_title_from_pkg;
static volatile int g_stage;
static int g_err;
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static char g_probe_json[2048]; /* filled by the backend thread */
static intptr_t
dynsym(const char *module, const char *sym) {
@@ -79,6 +86,99 @@ dynsym(const char *module, const char *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);
}
/* 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",
"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 *
backend_init_thread(void *arg) {
(void)arg;
@@ -107,6 +207,12 @@ backend_init_thread(void *arg) {
"sceAppInstUtilInstallByPackage");
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilGetTitleIdFromPkg");
ai_content_from_pkg = (ai_content_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilGetContentIdFromPkg");
/* Read-only feasibility probe — module is loaded, safe context. */
fill_probe();
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
g_stage = -3;
return NULL;
@@ -161,12 +267,87 @@ patchdl_install_backend_check(char *msg, size_t msg_sz) {
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];
char cid[64] = {0}, tid[48] = {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]) {
if (content_id && cid_sz) {
strncpy(content_id, cid, cid_sz - 1);
content_id[cid_sz - 1] = '\0';
}
if (is_app) *is_app = app_c;
ok = 1;
}
if (ai_title_from_pkg &&
ai_title_from_pkg(sdk_path, tid, &app_t) == 0 && tid[0]) {
if (title_id && tid_sz) {
strncpy(title_id, tid, tid_sz - 1);
title_id[tid_sz - 1] = '\0';
}
ok = 1;
}
snprintf(msg, msg_sz, ok ? "ok" : "could not read pkg metadata");
return ok ? 0 : -1;
}
int
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 sdk_path[1024];
char pkg_tid[48] = {0};
struct stat st;
int rc;
int pkg_tid_mismatch = 0;
if (!local_path || !local_path[0]) {
snprintf(msg, msg_sz, "no package path");
@@ -183,63 +364,128 @@ patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
return -1;
}
/* The install service runs in its own sandbox that sees the user
partition as /user/data, not /data — remap so it can read the file. */
/* Sony's installer sees the user partition as /user/data, not /data, and
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))
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
else
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
installed game. A cross-title/region package (e.g. a US PPSA03098 patch
on an EU PPSA03099 install) would otherwise be registered as a separate
phantom title instead of patching the game. */
/* Diagnostic guard: Sony sometimes stores one patch byte stream under a
master title id while the version.xml targets a regional title id. That
is valid only when the caller supplies target metadata, so do not feed
such packages to the raw AppInstallPkg path. */
if (storage_title_id && storage_title_id[0] &&
expected_title_id && expected_title_id[0] &&
strncmp(storage_title_id, expected_title_id, 9) != 0) {
pkg_tid_mismatch = 1;
strncpy(pkg_tid, storage_title_id, sizeof(pkg_tid) - 1);
}
if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) {
char pkg_tid[48] = {0};
int is_app = 0;
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
strncmp(pkg_tid, expected_title_id, 9) != 0) {
snprintf(msg, msg_sz,
"refused: package is for %.12s, installed game is %.12s "
"(cross-title/region)", pkg_tid, expected_title_id);
return -1;
pkg_tid_mismatch = 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;
}
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;
}
/* Fallback: InstallByPackage with a file:// URI. */
/* Preferred path: InstallByPackage accepts target metadata. Use it first,
and use it exclusively when the downloaded bytes report a master/storage
title id that differs from the target regional title id. */
{
char file_uri[1100];
char http_loop_uri[1200] = {0};
char http_lan_uri[1200] = {0};
const char *uris[4];
ai_meta_info_t meta = {0};
ai_pkg_info_t pkg = {0};
ai_playgo_info_t playgo = {0};
int rc2;
int rc2 = -1;
const char *title_dir;
const char *file_base;
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);
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.playgo_scenario_id = "";
meta.content_id = "";
meta.content_id = target_content_id ? target_content_id : "";
meta.content_name = "PatchDL";
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) {
snprintf(msg, msg_sz, "install started (InstallByPackage%s)",
pkg_tid_mismatch ? ", shared master bytes" : "");
return 0;
}
}
rc = rc2;
if (pkg_tid_mismatch) {
snprintf(msg, msg_sz,
"install rejected (pkg %.12s -> %.12s; tries: %s)",
pkg_tid, expected_title_id ? expected_title_id : "", tries);
return rc ? rc : -1;
}
}
}
/* Last resort for normal same-title packages only. This path has no target
metadata parameter, so it is intentionally skipped for shared-master
region bytes. */
{
ai_pkg_info_t pkg = {0};
int rc2 = ai_install_pkg(sdk_path, &pkg);
if (rc2 == 0) {
snprintf(msg, msg_sz, "install started (InstallByPackage)");
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
return 0;
}
snprintf(msg, msg_sz,
"install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)",
"install rejected (InstallByPackage=0x%08x, AppInstallPkg=0x%08x)",
(unsigned)rc, (unsigned)rc2);
return rc2;
return rc2 ? rc2 : (rc ? rc : -1);
}
}
+17 -3
View File
@@ -13,13 +13,27 @@
* (official) titles and obtain explicit user intent before calling.
*/
/* `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
(prevents a cross-region/cross-title package being installed as a new
phantom title). */
`storage_title_id` is the title id embedded in the delta_url storage path.
`target_content_id` is the installed game's content id from app.db; when
present it is passed to InstallByPackage so Sony's installer has the target
metadata even for region-shared/master-storage patch bytes. */
int 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);
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
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);
+599 -18
View File
@@ -7,12 +7,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#ifdef PATCHDL_HAVE_CURL
#include <curl/curl.h>
#include <openssl/evp.h>
#include "patchdl_ca.h"
#endif
@@ -196,14 +198,14 @@ dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
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++)
rc = dns_resolve(host, ip_out, ip_sz);
if (rc) return -1;
pthread_mutex_lock(&dns_cache_mtx);
if (dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
if (!rc && dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
strncpy(dns_cache[dns_cache_n].host, host,
sizeof(dns_cache[0].host) - 1);
strncpy(dns_cache[dns_cache_n].ip, ip_out,
@@ -211,7 +213,7 @@ dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
dns_cache_n++;
}
pthread_mutex_unlock(&dns_cache_mtx);
return 0;
return rc;
}
/* ---------- HTTP GET via curl ------------------------------------------- */
@@ -290,29 +292,88 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
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
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
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
/* Hex-encode a digest, lowercase. */
static void
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;
CURLcode res;
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_blob ca_blob;
FILE *fp;
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 (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
fp = fopen(dest_path, "wb");
if (!fp) return -1;
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);
@@ -324,12 +385,16 @@ patchdl_http_download(const char *url, const char *dest_path,
ca_blob.flags = CURL_BLOB_COPY;
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_RESOLVE, rl);
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_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
@@ -341,18 +406,508 @@ patchdl_http_download(const char *url, const char *dest_path,
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 (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);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
fclose(fp);
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];
EVP_DigestFinal_ex(sink.md, dig, &dlen);
EVP_MD_CTX_free(sink.md);
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(dest_path, "wb");
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);
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);
}
static int
json_string_after(const char *p, const char *key, char *out, size_t out_sz) {
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(p, needle);
if (!q) return -1;
q += strlen(needle);
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != ':') return -1;
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != '"') return -1;
while (*q && *q != '"' && n + 1 < out_sz) {
if (*q == '\\' && q[1]) q++;
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) {
char needle[48];
const char *q;
if (!p || !out) return -1;
snprintf(needle, sizeof(needle), "\"%s\"", key);
q = strstr(p, needle);
if (!q) return -1;
q += strlen(needle);
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
if (*q++ != ':') return -1;
while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++;
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;
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);
/* Resume: reopen the existing partial and keep its bytes; else start clean.
Fully-downloaded pieces are skipped; the one piece that was only partially
written continues mid-piece via an HTTP byte range (with a fall back to
re-fetching it whole if the CDN ignores the range). */
if (resume) {
fp = fopen(dest_path, "r+b");
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
}
if (!fp) { fp = fopen(dest_path, "wb"); have = 0; }
if (!fp) { 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, '}');
if (json_string_after(p, "url", url, sizeof(url)))
break;
json_u64_after(p, "fileSize", &expected);
have_offset = (json_u64_after(p, "fileOffset", &offset) == 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));
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));
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) {
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 (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);
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 (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];
EVP_DigestFinal_ex(sink.md, dig, &dl);
EVP_MD_CTX_free(sink.md);
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;
EVP_DigestFinal_ex(md, dig, &dl);
for (i = 0; i < dl; i++) sprintf(out_hex + 2 * i, "%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));
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; }
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, '}');
if (json_string_after(p, "url", url, sizeof(url)))
break;
json_u64_after(p, "fileSize", &sz);
if (json_u64_after(p, "fileOffset", &off) != 0)
off = (unsigned long long)running; /* no offset -> assume contiguous */
/* Validate tiling: pieces must be in order, contiguous, non-empty. */
if ((long long)off != running || sz == 0) {
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));
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;
}
@@ -427,6 +982,32 @@ patchdl_http_download(const char *url, const char *dest_path,
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
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
(void)url;
+72
View File
@@ -11,12 +11,84 @@ typedef struct {
patchdl_buf_t *patchdl_buf_new(void);
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);
/* ---- 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. */
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);
/* 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
success and writes the byte count to *bytes_out. */
int patchdl_http_download(const char *url, const char *dest_path,
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
(dns result/ip, curl code, http status, bytes) into `out_json`. */
+17 -7
View File
@@ -34,16 +34,26 @@ find_pid(const char *name) {
if (!(buf = malloc(buf_size))) return -1;
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) {
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
/* The loop guard guarantees the structsize/pid/tdname fields are inside the
buffer before we read them, and the per-record check below keeps the name
compare within the record (and thus the buffer). */
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 */
ptr += ki_structsize;
if (ki_structsize <= KINFO_OFF_TDNAME) break; /* malformed/truncated */
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;
ptr += ki_structsize;
}
free(buf);
+72 -28
View File
@@ -140,13 +140,23 @@ sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
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++) {
size_t key_off = h->key_table_start + entries[i].key_offset;
size_t val_off = h->data_table_start + entries[i].data_offset;
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 (entries[i].data_fmt != SFO_FMT_STR) return -1;
@@ -229,20 +239,37 @@ is_game_title(const char *title_id) {
/* ---------- 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
* under /user/app/<TID>/ (verified on fw 11.60):
* - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount
* - app.pkg (no mount.lnk) -> genuine install; app.json
* with CDN piece URLs means a not-downloaded preinstall stub, local
* URLs mean a real install
* - app.json with "fake":true -> homebrew fake (skip)
* Classify each /user/app/<TID> (verified on fw 11.60):
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
* mount (authoritative; mount.lnk is only a fallback hint)
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
* URLs means a not-downloaded preinstall stub, local URLs a real install
* - app.json with "fake":true -> homebrew fake (skip)
*/
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 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);
memset(t, 0, sizeof(*t));
@@ -252,6 +279,7 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
if (!is_game_title(t->title_id))
return -1;
is_shadow = mount_is_shadow(t->title_id, mounts, nmounts);
has_mountlnk = path_exists(dir, "mount.lnk") ||
path_exists(dir, "mount_img.lnk");
has_app_pkg = path_exists(dir, "app.pkg");
@@ -266,19 +294,18 @@ scan_one(const char *base, const char *name, patchdl_title_t *t) {
if (is_fake)
return -1;
if (has_mountlnk) {
/* metadata lives in the mounted sce_sys (param.json, or param.sfo
for PS4 titles) */
if (try_param_json(dir, t))
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
titles never have it (they have mounted/leftover sce_sys content). So
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
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);
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 {
return -1; /* empty / leftover directory */
}
@@ -300,7 +327,8 @@ already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
}
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;
struct dirent *de;
@@ -310,7 +338,7 @@ scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
while ((de = readdir(d))) {
if (de->d_name[0] == '.') continue;
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;
if (already_seen(arr, *cnt, arr[*cnt].title_id))
continue; /* dedupe a title already found in an earlier base */
@@ -369,6 +397,9 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
struct statfs *mounts = NULL;
int nmounts;
arr = calloc(MAX_TITLES, sizeof(*arr));
if (!arr) return -1;
@@ -376,15 +407,25 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
readable; same authid ftpsrv uses. No-op without kernel R/W. */
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();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
/* Only swap if we captured the current root, so we can always restore
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++)
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES);
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
merge_appdb(arr, cnt);
@@ -450,8 +491,11 @@ patchdl_scan_debug_json(void) {
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
/* Only swap if we can restore it afterwards (see patchdl_scan). */
if (saved_root) {
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
}
for (int i = 0; SCAN_DIRS[i]; i++) {
+6 -2
View File
@@ -23,9 +23,13 @@ typedef struct {
char compatible_version[16];
char latest_version[16];
char latest_required_fw[16];
char patch_url[512]; /* delta_url of the compatible patch */
char patch_title_id[16]; /* title id embedded in patch_url */
char patch_url[512]; /* manifest_url if present, otherwise pkg URL */
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 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;
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once
#define PATCHDL_VERSION "0.0.1"
#define PATCHDL_VERSION "0.0.3"
+50 -6
View File
@@ -44,6 +44,11 @@ attr_val(const char *tag_start, const char *tag_end, const char *attr,
p += strlen(needle);
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);
out[len] = '\0';
return 0;
@@ -57,8 +62,8 @@ ver_gt(const char *a, const char *b) {
return strcmp(a, b) > 0;
}
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03098)
from a string such as a delta_url. Used to detect cross-title patches. */
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03099)
from a string such as nptitleid, manifest_url, or delta_url. */
static void
extract_title_id(const char *s, char *out, size_t sz) {
out[0] = '\0';
@@ -77,9 +82,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
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
const char *p = xml;
char root_title[16] = {0};
parse_root_title_id(xml, root_title, sizeof(root_title));
while ((p = strstr(p, "<package "))) {
const char *tag_end = strchr(p, '>');
@@ -89,12 +116,14 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
char ver[16] = {0};
char sver[16] = {0};
char durl[512] = {0};
char murl[512] = {0};
/* PS5 version.xml uses content_ver; PS4 uses version. */
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, "system_ver", sver, sizeof(sver));
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]) {
uint32_t pkg_sver = parse_hex(sver);
@@ -106,16 +135,31 @@ parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
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 (!out->compatible_version[0] ||
ver_gt(ver, out->compatible_version)) {
strncpy(out->compatible_version, ver,
sizeof(out->compatible_version) - 1);
strncpy(out->compatible_url, durl,
strncpy(out->compatible_url, murl[0] ? murl : durl,
sizeof(out->compatible_url) - 1);
extract_title_id(durl, out->compatible_title,
sizeof(out->compatible_title));
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));
}
}
}
}
+3 -2
View File
@@ -6,8 +6,9 @@ typedef struct {
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, 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 compatible_url[512]; /* delta_url of the chosen compatible pkg */
char compatible_title[16]; /* title id embedded in that delta_url */
char compatible_url[512]; /* manifest_url if present, otherwise pkg URL */
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;
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
+1579 -65
View File
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -18,6 +18,9 @@ GET /api/titles
GET /api/downloads
POST /api/titles/:title_id/check
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
@@ -27,9 +30,9 @@ The UI assumes deny-by-default behavior:
```json
{
"default_policy": "deny",
"download_dir": "/mnt/usb0/patches",
"download_dir": "/data/patchdl (internal)",
"install_after_download": false,
"delete_pkg_after_install": false,
"delete_pkg_after_install": true,
"source_policy": {
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
@@ -80,3 +83,8 @@ unknown
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
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.
+555 -373
View File
File diff suppressed because it is too large. Load diff
+143 -124
View File
@@ -2,48 +2,34 @@
<html lang="en">
<head>
<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>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<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">
<path d="M12 3v11" />
<path d="M7 10l5 5 5-5" />
<path d="M5 20h14" />
<path d="M12 3v11" /><path d="M7 10l5 5 5-5" /><path d="M5 20h14" />
</symbol>
<symbol id="icon-refresh" viewBox="0 0 24 24">
<path d="M20 6v6h-6" />
<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="M20 6v6h-6" /><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" />
</symbol>
<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="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 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="M16 16l5 5" />
<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" />
</symbol>
<symbol id="icon-save" viewBox="0 0 24 24">
<path d="M5 4h12l2 2v14H5V4z" />
<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" />
<path d="M5 4h12l2 2v14H5V4z" /><path d="M8 4v6h8V4" /><path d="M8 20v-6h8v6" />
</symbol>
<symbol id="icon-log" viewBox="0 0 24 24">
<path d="M7 4h10l3 3v13H7V4z" />
<path d="M17 4v4h4" />
<path d="M10 12h7M10 16h5" />
<path d="M7 4h10l3 3v13H7V4z" /><path d="M17 4v4h4" /><path d="M10 12h7M10 16h5" />
</symbol>
<symbol id="icon-grid" viewBox="0 0 24 24">
<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" />
</symbol>
</svg>
@@ -53,111 +39,99 @@
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>by Knutwurst · v0.0.1</span>
<span>by Knutwurst · v0.0.3</span>
</div>
</div>
<nav class="nav-list">
<a class="nav-link is-active" href="#games">
<svg><use href="#icon-download"></use></svg>
Games
</a>
<a class="nav-link" href="#downloads">
<svg><use href="#icon-refresh"></use></svg>
Queue
</a>
<a class="nav-link" href="#settings">
<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 class="nav-list" aria-label="Sections">
<button class="nav-link is-active" data-view="games" aria-label="Games" aria-current="page">
<svg><use href="#icon-grid"></use></svg><span>Games</span>
</button>
<button class="nav-link" data-view="settings" aria-label="Settings">
<svg><use href="#icon-settings"></use></svg><span>Settings</span>
</button>
<button class="nav-link" data-view="logs" aria-label="Logs">
<svg><use href="#icon-log"></use></svg><span>Logs</span>
</button>
</nav>
<div class="rail-foot">
<span id="railFw">FW --</span>
<span id="railSpace">-- free</span>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow">Standalone ELF Web UI</p>
<h1>Controlled game patch downloads</h1>
</div>
<div class="topbar-actions">
<button class="icon-button" id="refreshBtn" title="Refresh status and games">
<svg><use href="#icon-refresh"></use></svg>
</button>
<button class="primary-button" id="saveBtn">
<svg><use href="#icon-save"></use></svg>
Save
</button>
</div>
</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
<!-- ============ GAMES ============ -->
<section class="view is-active" data-view="games">
<header class="topbar">
<div>
<p class="eyebrow">Standalone ELF Web UI</p>
<h1>Games</h1>
</div>
<div class="topbar-actions">
<button class="icon-button" id="refreshBtn" title="Refresh status and games" aria-label="Refresh">
<svg><use href="#icon-refresh"></use></svg>
</button>
</div>
<div class="queue-list" id="queueList"></div>
</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>
<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="all" aria-pressed="true">All</button>
<button data-filter="updatable" aria-pressed="false">Updatable</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>
</div>
</div>
<div class="panel" id="settings">
<div class="panel-heading">
<div>
<p class="eyebrow">Policy</p>
<h2>Update Rules</h2>
</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">
<label class="field">
<span>Default Policy</span>
@@ -168,22 +142,61 @@
</label>
<label class="field">
<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 class="switch-row">
<input id="installAfterDownload" type="checkbox" />
<span>
<strong>Install after download</strong>
<em>Global default, overridable per game</em>
</span>
<span class="toggle">
<input id="installAfterDownload" type="checkbox" aria-label="Install after download" />
<span class="track"></span>
</span>
</label>
<label class="switch-row">
<input id="deleteAfterInstall" type="checkbox" />
<span>
<strong>Delete PKG after install</strong>
<em>Only after a successful install</em>
</span>
<span class="toggle">
<input id="deleteAfterInstall" type="checkbox" aria-label="Delete package after install" />
<span class="track"></span>
</span>
</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 class="allowlist">
@@ -198,13 +211,19 @@
</div>
</section>
<section class="log-panel" id="logs">
<div class="panel-heading">
<!-- ============ LOGS ============ -->
<section class="view" data-view="logs">
<header class="topbar">
<div>
<p class="eyebrow">Runtime</p>
<h2>Recent Events</h2>
<h1>Logs</h1>
</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>
</section>
</main>
+281 -490
View File
File diff suppressed because it is too large. Load diff