mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-25 08:01:07 +01:00
Pausing, a failed job or a restart of PatchDL dropped every piece that
was in flight; with pieces of several GB that is a lot to fetch again.
A stopped piece now syncs what arrived and records it in the sidecar
("have": "piece:bytes,..."), and the next run continues it with a
Range request. Only bytes confirmed by fdatasync are recorded, so a
crash can't make a resume trust data that never reached the disk. The
card and the download list count these bytes, and a restart still offers
Resume for them.
Also:
- Download again during a pause that landed while the manifest was
loading takes the pause back instead of being ignored.
- A failed final flush (disk full) no longer reports a file download
as done.
- Rejected manifests (too many pieces, gaps, empty) report
bad_manifest instead of no reason.
1318 lines
50 KiB
C
1318 lines
50 KiB
C
/* SPDX-License-Identifier: GPL-3.0-or-later
|
||
* Copyright (C) 2026 Knutwurst
|
||
*
|
||
* PatchDL is free software: you can redistribute it and/or modify it under
|
||
* the terms of the GNU General Public License as published by the Free
|
||
* Software Foundation, either version 3 of the License, or (at your option)
|
||
* any later version. See the LICENSE file in the project root for details.
|
||
*/
|
||
|
||
#include "patchdl_net.h"
|
||
#include "patchdl_dns.h"
|
||
|
||
#include <arpa/inet.h>
|
||
#include <errno.h>
|
||
#include <fcntl.h>
|
||
#include <netinet/in.h>
|
||
#include <pthread.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <strings.h>
|
||
#include <sys/socket.h>
|
||
#include <sys/stat.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
|
||
|
||
/* 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);
|
||
FILE *fp;
|
||
fd = open(path, flags, 0600);
|
||
if (fd < 0) return NULL;
|
||
if (!(fp = fdopen(fd, rw_existing ? "r+b" : "wb"))) close(fd); /* fdopen keeps no fd on failure */
|
||
return fp;
|
||
}
|
||
#endif
|
||
|
||
#ifdef PATCHDL_HAVE_CURL
|
||
|
||
static const char *ALLOWED_HOSTS[] = {
|
||
"sgst.prod.dl.playstation.net",
|
||
"gst.prod.dl.playstation.net",
|
||
"gs2.ww.prod.dl.playstation.net",
|
||
"gs-sec.ww.np.dl.playstation.net", /* PS4 update lists */
|
||
NULL,
|
||
};
|
||
|
||
/* PS4 package manifests are published on gs2 over plain http only (https
|
||
there is a CloudFront edge without Sony's certificate). The console's own
|
||
installer checks the package signature, so a forged manifest can't get an
|
||
unsigned package installed. Everything else stays https-only. */
|
||
#define PLAIN_HTTP_HOST "gs2.ww.prod.dl.playstation.net"
|
||
|
||
static int
|
||
host_allowed(const char *host) {
|
||
size_t hlen = strlen(host);
|
||
/* DNS is case-insensitive; an upstream redirect to "SGST.prod..." would
|
||
otherwise drop out of the allowlist. */
|
||
for (int i = 0; ALLOWED_HOSTS[i]; i++) {
|
||
if (!strcasecmp(host, ALLOWED_HOSTS[i]))
|
||
return 1;
|
||
size_t alen = strlen(ALLOWED_HOSTS[i]);
|
||
if (hlen > alen + 1 &&
|
||
host[hlen - alen - 1] == '.' &&
|
||
!strcasecmp(host + hlen - alen, ALLOWED_HOSTS[i]))
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Extract hostname from https://host/path or http://host/path */
|
||
/* Why the calling thread's last patchdl_http_get failed (or what status it
|
||
got), for error reports. */
|
||
static _Thread_local struct {
|
||
char step[24];
|
||
int curl_rc;
|
||
long http_code;
|
||
} g_last;
|
||
|
||
static void
|
||
set_last(const char *step, int curl_rc, long http_code) {
|
||
snprintf(g_last.step, sizeof g_last.step, "%s", step);
|
||
g_last.curl_rc = curl_rc;
|
||
g_last.http_code = http_code;
|
||
}
|
||
|
||
/* CURLOPT_RESOLVE entries for host on 443 and 80 from PatchDL's own
|
||
resolver (patchdl_dns.c), with every address it found. NULL when the host
|
||
can't be resolved. */
|
||
static struct curl_slist *
|
||
resolve_list_for(const char *host) {
|
||
patchdl_dns_answer_t a;
|
||
char entry[PATCHDL_DNS_ENTRY_MAX];
|
||
struct curl_slist *rl, *both;
|
||
unsigned turn = patchdl_dns_rotation();
|
||
|
||
if (patchdl_dns_lookup(host, &a) ||
|
||
patchdl_dns_resolve_entry(&a, host, 443, turn, entry, sizeof entry) ||
|
||
!(rl = curl_slist_append(NULL, entry)))
|
||
return NULL;
|
||
if (patchdl_dns_resolve_entry(&a, host, 80, turn, entry, sizeof entry) ||
|
||
!(both = curl_slist_append(rl, entry))) {
|
||
curl_slist_free_all(rl);
|
||
return NULL;
|
||
}
|
||
return both;
|
||
}
|
||
|
||
/* The host's addresses refused, dropped or stalled the connection: the next
|
||
request asks DNS again instead of reusing the cached answer, so a CDN edge
|
||
that stopped serving this console is left behind. Includes a transfer that
|
||
stalled mid-way (low-speed timeout): the edges rotate within seconds, and
|
||
a fresh answer usually names other ones. */
|
||
static void
|
||
note_curl_result(const char *host, CURLcode res) {
|
||
if (res == CURLE_COULDNT_CONNECT || res == CURLE_COULDNT_RESOLVE_HOST ||
|
||
res == CURLE_OPERATION_TIMEDOUT || res == CURLE_RECV_ERROR ||
|
||
res == CURLE_SEND_ERROR || res == CURLE_GOT_NOTHING)
|
||
patchdl_dns_forget(host);
|
||
}
|
||
|
||
void
|
||
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
|
||
if (step && step_sz) snprintf(step, step_sz, "%s", g_last.step);
|
||
if (curl_rc) *curl_rc = g_last.curl_rc;
|
||
if (http_code) *http_code = g_last.http_code;
|
||
}
|
||
|
||
/* Manifest piece hashes: PS5 manifests carry SHA-256 (64 hex digits), PS4
|
||
manifests SHA-1 (40). */
|
||
static const EVP_MD *
|
||
digest_for(const char *hex) {
|
||
return (hex && strlen(hex) == 40) ? EVP_sha1() : EVP_sha256();
|
||
}
|
||
|
||
static int
|
||
url_host(const char *url, char *out, size_t out_sz) {
|
||
const char *p = strstr(url, "://");
|
||
const char *end;
|
||
size_t len;
|
||
|
||
if (!p) return -1;
|
||
p += 3;
|
||
end = strpbrk(p, "/:?");
|
||
len = end ? (size_t)(end - p) : strlen(p);
|
||
if (len == 0 || len >= out_sz) return -1;
|
||
memcpy(out, p, len);
|
||
out[len] = '\0';
|
||
return 0;
|
||
}
|
||
|
||
/* ---------- HTTP GET via curl ------------------------------------------- */
|
||
|
||
static size_t
|
||
write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
|
||
patchdl_buf_t *b = userdata;
|
||
size_t total = size * nmemb;
|
||
char *newp;
|
||
/* Overflow guard before the cap check (b->size+total may wrap on 32-bit). */
|
||
if (total > (size_t)-1 - b->size - 1) return 0;
|
||
/* Cap accumulation so a hostile CDN can't drive unbounded RAM growth. */
|
||
if (b->max && b->size + total > b->max) return 0;
|
||
newp = realloc(b->data, b->size + total + 1);
|
||
if (!newp) return 0;
|
||
b->data = newp;
|
||
memcpy(b->data + b->size, ptr, total);
|
||
b->size += total;
|
||
b->data[b->size] = '\0';
|
||
return total;
|
||
}
|
||
|
||
static int http_get_once(const char *url, patchdl_buf_t *out);
|
||
|
||
int
|
||
patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||
return http_get_once(url, out);
|
||
}
|
||
|
||
/* Manifests, fetched when a download starts: a request that failed on the
|
||
network is tried twice more after a short pause, each time starting at
|
||
another CDN address, so a single dropped connection doesn't fail the
|
||
download before its first byte. HTTP errors, TLS failures and refused
|
||
hosts are final. (Update lists aren't retried: the background check walks
|
||
every game and would crawl when an edge is dead.) */
|
||
static int
|
||
http_get_retrying(const char *url, patchdl_buf_t *out) {
|
||
static const unsigned pause_s[] = { 2, 5 };
|
||
size_t max = out->max;
|
||
int rc = -1;
|
||
|
||
for (int attempt = 0; attempt < 3; attempt++) {
|
||
if (attempt) {
|
||
if (strcmp(g_last.step, "connect") && strcmp(g_last.step, "timeout") &&
|
||
strcmp(g_last.step, "network"))
|
||
break;
|
||
sleep(pause_s[attempt - 1]);
|
||
memset(out, 0, sizeof *out);
|
||
out->max = max;
|
||
}
|
||
if (!(rc = http_get_once(url, out))) break;
|
||
}
|
||
return rc;
|
||
}
|
||
|
||
static int
|
||
http_get_once(const char *url, patchdl_buf_t *out) {
|
||
CURL *curl;
|
||
CURLcode res;
|
||
char host[256];
|
||
struct curl_slist *resolve_list = NULL;
|
||
struct curl_blob ca_blob;
|
||
|
||
int plain = !strncmp(url, "http://", 7);
|
||
long http_code = 0;
|
||
|
||
set_last("", 0, 0);
|
||
if (url_host(url, host, sizeof(host))) { set_last("bad_url", 0, 0); return -1; }
|
||
if (!host_allowed(host)) { set_last("host_not_allowed", 0, 0); return -1; }
|
||
if (plain && strcasecmp(host, PLAIN_HTTP_HOST)) { set_last("http_not_allowed", 0, 0); return -1; }
|
||
if (!(resolve_list = resolve_list_for(host))) { set_last("dns", 0, 0); return -1; }
|
||
|
||
{
|
||
size_t caller_max = out->max;
|
||
memset(out, 0, sizeof(*out));
|
||
out->max = caller_max;
|
||
}
|
||
|
||
curl = curl_easy_init();
|
||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||
|
||
/* Trust only the embedded SCEI DNAS Root (Sony's private CDN CA).
|
||
CAINFO_BLOB keeps the cert in memory — no file written anywhere. */
|
||
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_easy_setopt(curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve_list);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
|
||
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);
|
||
/* Some Sony CDN edges still serve SHA-1-signed leaf certs, which
|
||
OpenSSL 3.x rejects at the default security level. Lower it so the
|
||
chain still verifies against the pinned SCEI root. */
|
||
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
|
||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||
/* Redirects must stay on HTTPS — host_allowed gates the initial URL, but
|
||
once libcurl follows a 302 we want the protocol pinned too. The _STR
|
||
variants replaced the bitfield options in libcurl 7.85. Plain http only
|
||
for the PS4 manifest host (see PLAIN_HTTP_HOST). */
|
||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, plain ? "http" : "https");
|
||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||
|
||
res = curl_easy_perform(curl);
|
||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||
note_curl_result(host, res);
|
||
curl_easy_cleanup(curl);
|
||
curl_slist_free_all(resolve_list);
|
||
|
||
if (res != CURLE_OK) {
|
||
/* only "connect", "timeout" and "network" are worth another try */
|
||
set_last(res == CURLE_COULDNT_CONNECT ? "connect" :
|
||
res == CURLE_PEER_FAILED_VERIFICATION ||
|
||
res == CURLE_SSL_CONNECT_ERROR ? "tls"
|
||
: res == CURLE_UNSUPPORTED_PROTOCOL ||
|
||
res == CURLE_TOO_MANY_REDIRECTS ? "redirect"
|
||
: res == CURLE_WRITE_ERROR ? "bad_manifest" /* over the size cap */
|
||
: res == CURLE_OPERATION_TIMEDOUT ? "timeout" : "network",
|
||
(int)res, http_code);
|
||
free(out->data);
|
||
out->data = NULL;
|
||
out->size = 0;
|
||
return -1;
|
||
}
|
||
set_last(http_code >= 400 ? "http_status" : "", 0, http_code);
|
||
return 0;
|
||
}
|
||
|
||
/* ---------- external-host HTTP (no Sony CA, no allowlist) -------------- */
|
||
|
||
static int
|
||
http_external(const char *url, const char *method,
|
||
const char *content_type, const char *body,
|
||
patchdl_buf_t *out) {
|
||
#ifndef PATCHDL_HAVE_CURL
|
||
(void)url; (void)method; (void)content_type; (void)body; (void)out;
|
||
return -1;
|
||
#else
|
||
CURL *curl;
|
||
CURLcode res;
|
||
char host[256];
|
||
struct curl_slist *resolve_list = NULL;
|
||
struct curl_slist *headers = NULL;
|
||
|
||
if (url_host(url, host, sizeof(host))) return -1;
|
||
if (!(resolve_list = resolve_list_for(host))) return -1;
|
||
|
||
{
|
||
size_t caller_max = out->max;
|
||
memset(out, 0, sizeof(*out));
|
||
out->max = caller_max;
|
||
}
|
||
|
||
curl = curl_easy_init();
|
||
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
|
||
|
||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve_list);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
|
||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
||
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,
|
||
"Mozilla/5.0 (PlayStation; PatchDL)");
|
||
|
||
if (body) {
|
||
if (content_type) {
|
||
char ct[256];
|
||
snprintf(ct, sizeof(ct), "Content-Type: %s", content_type);
|
||
headers = curl_slist_append(headers, ct);
|
||
}
|
||
headers = curl_slist_append(headers, "Accept: application/json");
|
||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
||
}
|
||
|
||
res = curl_easy_perform(curl);
|
||
note_curl_result(host, res);
|
||
curl_easy_cleanup(curl);
|
||
curl_slist_free_all(resolve_list);
|
||
curl_slist_free_all(headers);
|
||
|
||
if (res != CURLE_OK) {
|
||
free(out->data);
|
||
out->data = NULL;
|
||
out->size = 0;
|
||
return -1;
|
||
}
|
||
return 0;
|
||
#endif
|
||
}
|
||
|
||
int
|
||
patchdl_http_get_external(const char *url, patchdl_buf_t *out) {
|
||
return http_external(url, "GET", NULL, NULL, out);
|
||
}
|
||
|
||
int
|
||
patchdl_http_post_external(const char *url, const char *content_type,
|
||
const char *body, patchdl_buf_t *out) {
|
||
return http_external(url, "POST", content_type, body, out);
|
||
}
|
||
|
||
/* 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) {
|
||
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;
|
||
}
|
||
|
||
/* 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];
|
||
char range_hdr[48];
|
||
long http_code = 0;
|
||
struct curl_slist *rl = NULL;
|
||
struct curl_blob ca_blob;
|
||
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 (!(rl = resolve_list_for(host))) return -1;
|
||
|
||
if (verify) {
|
||
sink.md = EVP_MD_CTX_new();
|
||
if (!sink.md) { curl_slist_free_all(rl); return -1; } /* never skip a requested check */
|
||
EVP_DigestInit_ex(sink.md, digest_for(expected_sha256_hex), NULL);
|
||
}
|
||
|
||
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, file_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 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);
|
||
/* an error page (404, 503) must not be saved as the file */
|
||
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||
/* No total timeout (patches can be large); abort only on a long stall. */
|
||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||
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);
|
||
note_curl_result(host, res);
|
||
curl_easy_cleanup(curl);
|
||
curl_slist_free_all(rl);
|
||
|
||
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);
|
||
if (fclose(fp) != 0 && !rc) rc = -1; /* the last flush failed (disk full) */
|
||
|
||
if (rc) {
|
||
unlink(dest_path);
|
||
return -1;
|
||
}
|
||
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. A resumed piece starts at
|
||
`resume` bytes; see piece_header_cb and piece_write_cb for how a server
|
||
that ignores the range is handled. */
|
||
typedef struct {
|
||
int fd;
|
||
long long base;
|
||
long long written;
|
||
long long size; /* the piece's length: never write past it */
|
||
long long resume; /* bytes of the piece already on disk */
|
||
long long range_start; /* from Content-Range, -1 if none */
|
||
int checked; /* first body bytes seen */
|
||
int bad_range; /* the server sent a different range */
|
||
CURL *curl;
|
||
EVP_MD_CTX *md;
|
||
const EVP_MD *md_type;
|
||
volatile long long *bytes_slot;
|
||
} piece_sink_t;
|
||
|
||
/* Feed [offset, offset+size) of fd into md. 0 on success, -1 on a read
|
||
error, -2 when *abort was set (pause or cancel) on the way. */
|
||
static int
|
||
digest_fd_region(EVP_MD_CTX *md, int fd, long long offset, long long size,
|
||
volatile int *abort) {
|
||
const size_t CHUNK = 1u << 20;
|
||
unsigned char *buf = malloc(CHUNK);
|
||
if (!buf) return -1;
|
||
while (size > 0) {
|
||
size_t want = size > (long long)CHUNK ? CHUNK : (size_t)size;
|
||
ssize_t got;
|
||
if (abort && *abort) { free(buf); return -2; }
|
||
got = pread(fd, buf, want, (off_t)offset);
|
||
if (got <= 0) { free(buf); return -1; }
|
||
EVP_DigestUpdate(md, buf, (size_t)got);
|
||
offset += got; size -= got;
|
||
}
|
||
free(buf);
|
||
return 0;
|
||
}
|
||
|
||
static size_t
|
||
piece_header_cb(char *b, size_t size, size_t nitems, void *ud) {
|
||
piece_sink_t *s = (piece_sink_t *)ud;
|
||
size_t n = size * nitems;
|
||
long long start;
|
||
/* A redirect's headers come first; only the last response counts. */
|
||
if (n > 5 && !strncmp(b, "HTTP/", 5)) s->range_start = -1;
|
||
if (n > 20 && !strncasecmp(b, "Content-Range:", 14) &&
|
||
sscanf(b + 14, " bytes %lld-", &start) == 1)
|
||
s->range_start = start;
|
||
return n;
|
||
}
|
||
|
||
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;
|
||
if (s->resume && !s->checked) {
|
||
long code = 0;
|
||
s->checked = 1;
|
||
curl_easy_getinfo(s->curl, CURLINFO_RESPONSE_CODE, &code);
|
||
if (code != 206) {
|
||
/* the server sent the whole piece: start it over in place */
|
||
s->written = 0;
|
||
if (s->md) EVP_DigestInit_ex(s->md, s->md_type, NULL);
|
||
} else if (s->range_start != s->resume) {
|
||
s->bad_range = 1;
|
||
return 0; /* not where we left off */
|
||
}
|
||
}
|
||
/* more than the piece holds would land in the next piece's bytes */
|
||
if (s->size > 0 && s->written + (long long)n > s->size) 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,
|
||
long long resume_from,
|
||
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];
|
||
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 (!(rl = resolve_list_for(host))) return -1;
|
||
|
||
if (resume_from < 0 || file_size <= 0 || resume_from >= file_size) resume_from = 0;
|
||
memset(&sink, 0, sizeof(sink));
|
||
sink.fd = fd;
|
||
sink.base = file_offset;
|
||
sink.size = file_size;
|
||
sink.resume = resume_from;
|
||
sink.written = resume_from;
|
||
sink.range_start = -1;
|
||
sink.bytes_slot = ctx ? ctx->bytes_slot : NULL;
|
||
if (sink.bytes_slot) *sink.bytes_slot = resume_from;
|
||
if (verify) {
|
||
sink.md_type = digest_for(expected_sha256_or_null);
|
||
sink.md = EVP_MD_CTX_new();
|
||
if (!sink.md) { curl_slist_free_all(rl); return -1; } /* never skip a requested check */
|
||
EVP_DigestInit_ex(sink.md, sink.md_type, NULL);
|
||
/* the part already on disk goes into the hash first */
|
||
if (sink.md && resume_from) {
|
||
int drc = digest_fd_region(sink.md, fd, file_offset, resume_from,
|
||
ctx ? ctx->abort : NULL);
|
||
if (drc == -2) { /* paused or cancelled meanwhile */
|
||
EVP_MD_CTX_free(sink.md);
|
||
curl_slist_free_all(rl);
|
||
return -1;
|
||
}
|
||
if (drc) { /* unreadable: fetch it all again */
|
||
EVP_DigestInit_ex(sink.md, sink.md_type, NULL);
|
||
sink.resume = sink.written = 0;
|
||
if (sink.bytes_slot) *sink.bytes_slot = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
sink.curl = curl;
|
||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, piece_write_cb);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, piece_header_cb);
|
||
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &sink);
|
||
if (sink.resume) {
|
||
char range[48];
|
||
snprintf(range, sizeof range, "%lld-%lld", sink.resume, file_size - 1);
|
||
curl_easy_setopt(curl, CURLOPT_RANGE, range);
|
||
}
|
||
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);
|
||
note_curl_result(host, res);
|
||
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);
|
||
/* -3: the server can't continue where this piece stopped */
|
||
return sink.resume && (sink.bad_range || http_code == 416) ? -3 : -1;
|
||
}
|
||
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 */
|
||
}
|
||
/* durable before the caller sets the done bit; an error here means
|
||
the bytes may not be on disk, so the piece doesn't count */
|
||
if (fdatasync(fd) != 0) return -1;
|
||
return 0;
|
||
}
|
||
|
||
/* Read-only: hash a [offset, offset+size) region of fd into out_hex (>=65
|
||
bytes), with the algorithm `like_hex` is written in (see digest_for). Uses
|
||
pread so it doesn't disturb the fd offset. 0 on success. */
|
||
int
|
||
patchdl_hash_fd_region(int fd, long long offset, long long size,
|
||
const char *like_hex, 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, digest_for(like_hex), 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 (http_get_retrying(manifest_url, &buf)) return -1;
|
||
if (!buf.data || !buf.size) {
|
||
free(buf.data);
|
||
if (!g_last.step[0]) set_last("empty_reply", 0, g_last.http_code);
|
||
return -1;
|
||
}
|
||
|
||
pieces = strstr(buf.data, "\"pieces\"");
|
||
if (!pieces || !(pieces = strchr(pieces, '['))) {
|
||
free(buf.data);
|
||
if (!g_last.step[0]) set_last("bad_manifest", 0, g_last.http_code);
|
||
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); set_last("bad_manifest", 0, g_last.http_code); 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); set_last("bad_manifest", 0, g_last.http_code); 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, '}');
|
||
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 ||
|
||
(unsigned long long)running + sz > PATCHDL_MAX_TOTAL_BYTES) {
|
||
patchdl_manifest_free(out);
|
||
free(buf.data);
|
||
set_last("bad_manifest", 0, g_last.http_code);
|
||
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;
|
||
}
|
||
json_string_after(buf.data, "playgoChunkCrcUrl", out->crc_url,
|
||
sizeof(out->crc_url), NULL);
|
||
free(buf.data);
|
||
if (n == 0) { patchdl_manifest_free(out); set_last("bad_manifest", 0, g_last.http_code); return -1; }
|
||
out->total = running; /* authoritative assembled size */
|
||
return 0;
|
||
}
|
||
|
||
void
|
||
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
|
||
char host[256] = {0};
|
||
int hostrc, allowrc, dnsrc = -1;
|
||
long http_code = 0;
|
||
CURLcode res = CURLE_OK;
|
||
int did_curl = 0;
|
||
patchdl_dns_answer_t a;
|
||
|
||
memset(&a, 0, sizeof a);
|
||
hostrc = url_host(url, host, sizeof(host));
|
||
allowrc = (hostrc == 0) ? host_allowed(host) : 0;
|
||
if (hostrc == 0) {
|
||
patchdl_dns_refresh(host);
|
||
dnsrc = patchdl_dns_lookup(host, &a);
|
||
}
|
||
|
||
if (hostrc == 0 && allowrc && dnsrc == 0) {
|
||
patchdl_buf_t buf;
|
||
memset(&buf, 0, sizeof(buf));
|
||
CURL *curl = curl_easy_init();
|
||
if (curl) {
|
||
char rs[PATCHDL_DNS_ENTRY_MAX];
|
||
struct curl_slist *rl;
|
||
struct curl_blob ca_blob;
|
||
rl = patchdl_dns_resolve_entry(&a, host, 443, 0, rs, sizeof rs)
|
||
? NULL : curl_slist_append(NULL, rs);
|
||
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_easy_setopt(curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
|
||
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_PROTOCOLS_STR, "https");
|
||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
||
res = curl_easy_perform(curl);
|
||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||
curl_easy_cleanup(curl);
|
||
curl_slist_free_all(rl);
|
||
free(buf.data);
|
||
did_curl = 1;
|
||
}
|
||
}
|
||
|
||
/* host, source and trail are plain ASCII from our own resolver; quotes
|
||
can't occur in them. */
|
||
snprintf(out_json, sz,
|
||
"{\"host\":\"%s\",\"host_ok\":%d,\"allowed\":%d,"
|
||
"\"dns_rc\":%d,\"ip\":\"%s\",\"dns_source\":\"%s\",\"dns_trail\":\"%s\","
|
||
"\"curl_ran\":%d,\"curl_rc\":%d,\"curl_err\":\"%s\",\"http_code\":%ld}",
|
||
host, hostrc == 0, allowrc, dnsrc, a.n ? a.ip[0] : "", a.source, a.trail,
|
||
did_curl, (int)res,
|
||
did_curl ? curl_easy_strerror(res) : "n/a", http_code);
|
||
}
|
||
|
||
#else /* !PATCHDL_HAVE_CURL */
|
||
|
||
int
|
||
patchdl_http_get(const char *url, patchdl_buf_t *out) {
|
||
(void)url;
|
||
(void)out;
|
||
return -1; /* curl not available in this build; set CURL_DIR= to enable */
|
||
}
|
||
|
||
void
|
||
patchdl_http_last_error(char *step, size_t step_sz, int *curl_rc, long *http_code) {
|
||
if (step && step_sz) snprintf(step, step_sz, "no_curl");
|
||
if (curl_rc) *curl_rc = 0;
|
||
if (http_code) *http_code = 0;
|
||
}
|
||
|
||
int
|
||
patchdl_http_download(const char *url, const char *dest_path,
|
||
long long *bytes_out) {
|
||
(void)url; (void)dest_path;
|
||
if (bytes_out) *bytes_out = 0;
|
||
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;
|
||
snprintf(out_json, sz, "{\"error\":\"curl not built\"}");
|
||
}
|
||
|
||
#endif /* PATCHDL_HAVE_CURL */
|
||
|
||
patchdl_buf_t *
|
||
patchdl_buf_new(void) {
|
||
return calloc(1, sizeof(patchdl_buf_t));
|
||
}
|
||
|
||
void
|
||
patchdl_buf_free(patchdl_buf_t *b) {
|
||
if (!b) return;
|
||
free(b->data);
|
||
free(b);
|
||
}
|