mirror of
https://github.com/SirHumza/orbisRPC.git
synced 2026-10-10 20:00:51 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8917270f36 | ||
|
|
45aec45abd | ||
|
|
bfbe227a67 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="config/images/icons/logo.png" width="420" alt="orbisRPC">
|
||||
<img src="config/icons/pslogo.png" width="420" alt="orbisRPC">
|
||||
</p>
|
||||
|
||||
# orbisRPC — Discord Rich Presence for PS4 (GoldHEN RPC)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"schema_version":1,"token":"SET_ME","application_id":"1536977374795538532","art_base_url":"https://raw.githubusercontent.com/SirHumza/orbisrpc-host/main/icons/","enabled":true,"pkgzone_enabled":true,"retro_enabled":true,"show_firmware":true,"show_idle":true,"show_media":true,"show_homebrew":true,"auto_update":true,"debug":false,"poll_interval_s":12,"presence_state":"On PS4","presence_details_game":"Playing on PlayStation 4","presence_details_home":"Idling on Home Menu","presence_settings_text":"In Settings","asset_idle":"","asset_playing":"","large_art":"https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png","small_art":"https://retro-games.cybermask.dpdns.org/images/ps-logo-blue.png","browser_art":"https://retro-games.cybermask.dpdns.org/images/web_browser.png","home_art":"https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png"}
|
||||
{"schema_version":1,"token":"SET_ME","application_id":"1536977374795538532","art_base_url":"https://raw.githubusercontent.com/SirHumza/orbisrpc-host/main/icons/","enabled":true,"pkgzone_enabled":true,"retro_enabled":true,"show_firmware":true,"show_idle":true,"show_media":true,"show_homebrew":true,"auto_update":true,"debug":false,"poll_interval_s":12,"presence_state":"On PS4","presence_details_game":"Playing on PlayStation 4","presence_details_home":"Idling on Home Menu","presence_settings_text":"In Settings","asset_idle":"","asset_playing":"","large_art":"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-full.png","small_art":"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-small.png","browser_art":"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/web_browser.png","home_art":"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-full.png"}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
+4
-4
@@ -44,15 +44,15 @@ void cfg_defaults(cfg_t *c) {
|
||||
/* URLs for the two images, resolved through Discord's mp: external-assets
|
||||
* proxy at post time (a raw https in large_image renders "?" or drops the
|
||||
* activity). large_art falls back to home_art, small_art to large_art. */
|
||||
strncpy(c->large_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png",
|
||||
strncpy(c->large_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-full.png",
|
||||
sizeof(c->large_art)-1);
|
||||
strncpy(c->small_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-blue.png",
|
||||
strncpy(c->small_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-small.png",
|
||||
sizeof(c->small_art)-1);
|
||||
strncpy(c->browser_art, "https://retro-games.cybermask.dpdns.org/images/web_browser.png",
|
||||
strncpy(c->browser_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/web_browser.png",
|
||||
sizeof(c->browser_art)-1);
|
||||
/* Idle tile: same logo as large_art, kept as the legacy fallback for
|
||||
* configs written before large_art existed. */
|
||||
strncpy(c->home_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png",
|
||||
strncpy(c->home_art, "https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-full.png",
|
||||
sizeof(c->home_art)-1);
|
||||
c->n_titles = 0;
|
||||
/* Default art backend: our own Sony-CDN icon pack, resolved through
|
||||
|
||||
+49
-15
@@ -245,19 +245,19 @@ int daemon_run(void){
|
||||
int safe_mode = health_boot_note_crash();
|
||||
if(safe_mode)
|
||||
log_msg("WARN: repeated unclean boots; safe mode (updates off)");
|
||||
/* Boot watchdog: a staged update that never proved itself healthy
|
||||
* gets rolled back to .bak before anything runs it. */
|
||||
/* Sanity check on the installed payload. This used to be a rollback
|
||||
* watchdog: health_verify_or_rollback() tried to restore a <path>.bak
|
||||
* that only a staged install ever wrote. With the updater reduced to a
|
||||
* notification no .bak can exist, so every boot logged
|
||||
* "failed verification (no backup)" forever. The ELF check is still
|
||||
* worth keeping - it catches a truncated or corrupt install. */
|
||||
{
|
||||
static const char *targets[] = {
|
||||
"/data/payloads/orbisrpc.bin",
|
||||
};
|
||||
for(unsigned ti = 0; ti < sizeof targets/sizeof targets[0]; ti++){
|
||||
int vr = health_verify_or_rollback(targets[ti]);
|
||||
if(vr == 1)
|
||||
log_msg("WARN: %s rolled back to last-good backup", targets[ti]);
|
||||
else if(vr == -1)
|
||||
log_msg("WARN: %s failed verification (no backup)", targets[ti]);
|
||||
}
|
||||
for(unsigned ti = 0; ti < sizeof targets/sizeof targets[0]; ti++)
|
||||
if(!health_check_binary(targets[ti]))
|
||||
log_msg("WARN: %s is not a valid payload (truncated?)", targets[ti]);
|
||||
}
|
||||
if(cfg_load(CFG_PATH, &g_cfg) != 0){
|
||||
/* First boot: drop a template so FTP edit is the only step */
|
||||
@@ -303,13 +303,27 @@ int daemon_run(void){
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Self-update once per boot, before first connect. Never fatal:
|
||||
* staged artifacts take effect on next launch/injection. */
|
||||
/* Update check once per boot, before first connect. Never fatal. Does not
|
||||
* download or install anything; only notifies when a newer signed
|
||||
* release exists, so the user can run the setup app. */
|
||||
if(g_cfg.auto_update && !safe_mode){
|
||||
int ur = updater_check_and_stage();
|
||||
log_msg("updater: %s (local %s)",
|
||||
ur > 0 ? "staged newer build" : ur == 0 ? "already current" : "check failed",
|
||||
ORBISRPC_VERSION);
|
||||
/* Update CHECK only. Nothing is downloaded or installed here -- the
|
||||
* setup app applies updates. A verified newer release just raises a
|
||||
* notification so the user knows to run it. */
|
||||
char newer[32] = "";
|
||||
int ur = updater_check_notify(newer, sizeof newer);
|
||||
if(ur > 0){
|
||||
char msg[96];
|
||||
snprintf(msg, sizeof msg,
|
||||
"Update %s available - use setup app to update", newer);
|
||||
notify_once_boot("update", msg);
|
||||
log_msg("updater: notification sent for %s (local %s)",
|
||||
newer, ORBISRPC_VERSION);
|
||||
} else {
|
||||
log_msg("updater: %s (local %s)",
|
||||
ur == 0 ? "already current" : "check failed",
|
||||
ORBISRPC_VERSION);
|
||||
}
|
||||
} else if(safe_mode){
|
||||
log_msg("updater: skipped (safe mode)");
|
||||
}
|
||||
@@ -324,6 +338,16 @@ int daemon_run(void){
|
||||
int backoff = base_poll;
|
||||
int conn_fails = 0;
|
||||
unsigned jctr = 0;
|
||||
/* "Connected to Discord" policy: once on the first successful connect,
|
||||
* then only again if a connect attempt actually failed in between.
|
||||
*
|
||||
* Deliberately NOT counting mid-session drops. The gateway can drop and
|
||||
* come straight back (heartbeat ACKs, a brief outage) and treating that
|
||||
* as a failure would re-notify on every cycle, which is the spam this
|
||||
* replaces. Only a failed connect attempt -- rc != 0 from
|
||||
* discord_connect -- counts as "we could not reach Discord". */
|
||||
int announced_connected = 0;
|
||||
int connect_failed_since_ok = 0;
|
||||
/* Health metrics (hourly HEALTH line). */
|
||||
int64_t boot_mono = orbis_mono_s();
|
||||
unsigned n_posts = 0, n_reconnects = 0;
|
||||
@@ -356,6 +380,7 @@ int daemon_run(void){
|
||||
log_msg("WARN: token rejected by gateway (close 4004). "
|
||||
"Fix \"token\" in %s; retrying", CFG_PATH);
|
||||
notify_throttled("token4004", TOKEN_REJECTED_MSG, 300);
|
||||
connect_failed_since_ok = 1;
|
||||
int wait = reconnect_delay(&conn_fails, base_poll, &jctr);
|
||||
if(sleep_stop(wait)) break;
|
||||
continue;
|
||||
@@ -365,11 +390,20 @@ int daemon_run(void){
|
||||
/* Throttled: the backoff already grows, so an unthrottled
|
||||
* notification here fires every 13s and then every minute. */
|
||||
notify_throttled("connect", "Cannot reach Discord - retrying", 300);
|
||||
connect_failed_since_ok = 1;
|
||||
log_msg("gateway connect failed (attempt %d); retry in %ds",
|
||||
conn_fails, wait);
|
||||
if(sleep_stop(wait)) break;
|
||||
continue;
|
||||
}
|
||||
if(!announced_connected || connect_failed_since_ok){
|
||||
notify_show("Connected to Discord");
|
||||
announced_connected = 1;
|
||||
connect_failed_since_ok = 0;
|
||||
log_msg("notify: %s",
|
||||
conn_fails > 0 ? "connected after failed attempts"
|
||||
: "connected (first)");
|
||||
}
|
||||
if(conn_fails > 0)
|
||||
log_msg("gateway connected after %d failures", conn_fails);
|
||||
conn_fails = 0;
|
||||
|
||||
+4
-3
@@ -214,9 +214,10 @@ int discord_connect(discord_t *d, const char *token){
|
||||
if(go==0 && is_ready(buf, (size_t)nr)){
|
||||
gw_seq(d, buf, (size_t)nr);
|
||||
log_msg("discord: gateway ready");
|
||||
/* Fires on every successful READY, including recovery after an
|
||||
* outage -- that is the notification you actually want. */
|
||||
notify_show("Connected to Discord");
|
||||
/* The "connected" notification is raised by the daemon, which
|
||||
* knows whether this was a first connect or a recovery after a
|
||||
* failed one. Firing it here meant every successful READY
|
||||
* notified -- once per reconnect. */
|
||||
return 0;
|
||||
}
|
||||
/* other pre-READY events: ignore */
|
||||
|
||||
@@ -135,48 +135,3 @@ int health_check_binary(const char *path){
|
||||
if(n < 64 || sz <= 0) return 0;
|
||||
return updater_image_ok(h, n) && (size_t)sz <= 8u*1024u*1024u;
|
||||
}
|
||||
|
||||
int health_rollback(const char *path){
|
||||
char bak[320];
|
||||
snprintf(bak, sizeof bak, "%s.bak", path);
|
||||
FILE *f = fopen(bak, "rb");
|
||||
if(!f) return -1;
|
||||
fclose(f);
|
||||
/* Atomic replace: never remove(path) first (a failed second step
|
||||
* would leave no bootable binary at all). */
|
||||
if(rename(bak, path) != 0) return -1;
|
||||
return health_check_binary(path) ? 0 : -1;
|
||||
}
|
||||
|
||||
int health_stage_activate(const char *path){
|
||||
char tmp[320], bak[320];
|
||||
snprintf(tmp, sizeof tmp, "%s.new", path);
|
||||
snprintf(bak, sizeof bak, "%s.bak", path);
|
||||
if(!health_check_binary(tmp)){ remove(tmp); return -1; }
|
||||
/* backup current (best-effort when a prior file exists) */
|
||||
if(path_exists(path)){
|
||||
/* remove stale .bak first so rename() below cannot fail on
|
||||
* platforms without atomic replace */
|
||||
remove(bak);
|
||||
if(rename(path, bak) != 0){ remove(tmp); return -1; }
|
||||
}
|
||||
if(rename(tmp, path) != 0){
|
||||
/* activation failed: try to restore backup */
|
||||
rename(bak, path);
|
||||
remove(tmp);
|
||||
return -1;
|
||||
}
|
||||
if(!health_check_binary(path)){
|
||||
/* activated bytes somehow bad: restore backup immediately */
|
||||
remove(path);
|
||||
rename(bak, path);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int health_verify_or_rollback(const char *path){
|
||||
if(health_check_binary(path)) return 0;
|
||||
if(health_rollback(path) == 0) return 1;
|
||||
return -1;
|
||||
}
|
||||
@@ -30,18 +30,6 @@ void health_mark_healthy(void);
|
||||
int health_boot_note_crash(void);
|
||||
/* Verify a staged target can run: ELF magic + size sane. 1 ok, 0 bad. */
|
||||
int health_check_binary(const char *path);
|
||||
/* Restore <path> from <path>.bak. 0 ok, -1 failed/none. */
|
||||
int health_rollback(const char *path);
|
||||
/* Verify + activate a staged "<path>.new" over <path> with backup.
|
||||
* Returns 0 activated, -1 refused (missing/invalid; .new removed,
|
||||
* live <path> untouched). Host-testable rollback primitive used by
|
||||
* the updater so staging can never leave a corrupt live binary. */
|
||||
int health_stage_activate(const char *path);
|
||||
/* Post-boot watchdog: if <path> fails health_check_binary, attempt
|
||||
* health_rollback(). Returns 0 healthy, 1 rolled back OK, -1 still bad.
|
||||
* Daemon calls this at startup for each staged target so a bad update
|
||||
* that passed staging is automatically reverted before use. */
|
||||
int health_verify_or_rollback(const char *path);
|
||||
#ifdef HEALTH_TESTABLE
|
||||
/* Override the base dir for crash.count/boot.dirty (host tests). */
|
||||
void health_set_base(const char *dir);
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/* manifest.c - signed release manifest (host-testable + PS4).
|
||||
* Parsing via jsonlite; hashing + ECDSA via mbedTLS (already linked). */
|
||||
#include "manifest.h"
|
||||
#include "jsonlite.h"
|
||||
#include "updater.h"
|
||||
#include <string.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <mbedtls/ecdsa.h>
|
||||
#include <mbedtls/ecp.h>
|
||||
#include <mbedtls/bignum.h>
|
||||
|
||||
int manifest_sha256_hex(const unsigned char *data, size_t n, char out[65]){
|
||||
unsigned char dig[32];
|
||||
if(!data && n) return -1;
|
||||
mbedtls_sha256_context sc;
|
||||
mbedtls_sha256_init(&sc);
|
||||
int ok = mbedtls_sha256_starts(&sc, 0) == 0 &&
|
||||
mbedtls_sha256_update(&sc, data ? data : (const unsigned char *)"", n) == 0 &&
|
||||
mbedtls_sha256_finish(&sc, dig) == 0;
|
||||
mbedtls_sha256_free(&sc);
|
||||
if(!ok) return -1;
|
||||
for(int i = 0; i < 32; i++) snprintf(out + 2 * i, 3, "%02x", dig[i]);
|
||||
out[64] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int copy_str(const jl_val_t *v, char *out, size_t cap){
|
||||
if(!v || v->type != JL_STRING || !v->str) return -1;
|
||||
size_t n = strlen(v->str);
|
||||
if(n >= cap) return -1;
|
||||
memcpy(out, v->str, n + 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int manifest_parse(const char *json, size_t len, manifest_t *out){
|
||||
if(!json || !out) return -1;
|
||||
memset(out, 0, sizeof *out);
|
||||
jl_val_t *r = jl_parse(json, len);
|
||||
if(!r || r->type != JL_OBJECT){ if(r) jl_free(r); return -1; }
|
||||
if(copy_str(jl_obj_get(r, "version"), out->version, sizeof out->version) != 0){
|
||||
jl_free(r); return -1;
|
||||
}
|
||||
/* optional with sane defaults */
|
||||
const jl_val_t *ch = jl_obj_get(r, "channel");
|
||||
if(ch && ch->type == JL_STRING){
|
||||
if(copy_str(ch, out->channel, sizeof out->channel) != 0){ jl_free(r); return -1; }
|
||||
} else {
|
||||
memcpy(out->channel, "stable", 7);
|
||||
}
|
||||
const jl_val_t *mv = jl_obj_get(r, "minimum_version");
|
||||
if(!mv) mv = jl_obj_get(r, "min_version");
|
||||
if(mv && mv->type == JL_STRING){
|
||||
if(copy_str(mv, out->min_version, sizeof out->min_version) != 0){ jl_free(r); return -1; }
|
||||
}
|
||||
const jl_val_t *pl = jl_obj_get(r, "platform");
|
||||
if(!pl) pl = jl_obj_get(r, "supported_platform");
|
||||
if(pl && pl->type == JL_STRING){
|
||||
if(copy_str(pl, out->platform, sizeof out->platform) != 0){ jl_free(r); return -1; }
|
||||
}
|
||||
const jl_val_t *assets = jl_obj_get(r, "assets");
|
||||
if(!assets) assets = jl_obj_get(r, "files");
|
||||
if(assets && assets->type == JL_OBJECT){
|
||||
/* object form: { "orbisrpc.bin": "<hex>", ... } */
|
||||
/* jsonlite has no object iterator in public API; fall back to
|
||||
* array form below. Object form unsupported -> invalid. */
|
||||
jl_free(r);
|
||||
return -1;
|
||||
}
|
||||
if(assets && assets->type == JL_ARRAY){
|
||||
int n = 0;
|
||||
for(size_t i = 0; ; i++){
|
||||
const jl_val_t *a = jl_arr_at(assets, i);
|
||||
if(!a) break;
|
||||
if(n >= MANIFEST_MAX_ASSETS) break;
|
||||
const jl_val_t *nm = jl_obj_get(a, "name");
|
||||
const jl_val_t *sh = jl_obj_get(a, "sha256");
|
||||
if(!sh) sh = jl_obj_get(a, "hash");
|
||||
if(!nm || nm->type != JL_STRING || !sh || sh->type != JL_STRING)
|
||||
continue;
|
||||
if(strlen(nm->str) >= sizeof out->assets[n].name) continue;
|
||||
if(strlen(sh->str) != 64) continue;
|
||||
int hexok = 1;
|
||||
for(int k = 0; k < 64; k++){
|
||||
char c = sh->str[k];
|
||||
if(!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F'))){ hexok = 0; break; }
|
||||
}
|
||||
if(!hexok) continue;
|
||||
memcpy(out->assets[n].name, nm->str, strlen(nm->str) + 1);
|
||||
for(int k = 0; k < 64; k++){
|
||||
char c = sh->str[k];
|
||||
out->assets[n].sha256[k] = (c >= 'A' && c <= 'F') ? (char)(c - 'A' + 'a') : c;
|
||||
}
|
||||
out->assets[n].sha256[64] = 0;
|
||||
n++;
|
||||
}
|
||||
out->nassets = n;
|
||||
}
|
||||
jl_free(r);
|
||||
if(!out->version[0] || out->nassets <= 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int manifest_find(const manifest_t *m, const char *name, char out_hex[65]){
|
||||
if(!m || !name) return -1;
|
||||
for(int i = 0; i < m->nassets; i++){
|
||||
if(!strcmp(m->assets[i].name, name)){
|
||||
if(out_hex) memcpy(out_hex, m->assets[i].sha256, 65);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int manifest_check(const manifest_t *m, const char *name,
|
||||
const unsigned char *data, size_t n){
|
||||
char want[65], got[65];
|
||||
if(manifest_find(m, name, want) != 0) return -1;
|
||||
if(manifest_sha256_hex(data, n, got) != 0) return -1;
|
||||
if(strcmp(got, want) != 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int manifest_is_newer(const manifest_t *m, const char *local_version){
|
||||
if(!m || !local_version) return 0;
|
||||
return updater_cmp(m->version, local_version) > 0;
|
||||
}
|
||||
|
||||
int manifest_gate(const manifest_t *m){
|
||||
if(!m) return 0;
|
||||
if(m->channel[0] && strcmp(m->channel, "stable") != 0) return 0;
|
||||
if(m->platform[0] && strcmp(m->platform, "ps4-goldhen") != 0 &&
|
||||
strcmp(m->platform, "ps4") != 0)
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int manifest_verify_sig(const unsigned char *msg, size_t msglen,
|
||||
const unsigned char sig[64],
|
||||
const unsigned char pubkey[64]){
|
||||
if(!msg || !sig || !pubkey) return -1;
|
||||
unsigned char hash[32];
|
||||
{
|
||||
mbedtls_sha256_context sc;
|
||||
mbedtls_sha256_init(&sc);
|
||||
int ok = mbedtls_sha256_starts(&sc, 0) == 0 &&
|
||||
mbedtls_sha256_update(&sc, msg, msglen) == 0 &&
|
||||
mbedtls_sha256_finish(&sc, hash) == 0;
|
||||
mbedtls_sha256_free(&sc);
|
||||
if(!ok) return -1;
|
||||
}
|
||||
mbedtls_ecp_group grp;
|
||||
mbedtls_ecp_point Q;
|
||||
mbedtls_mpi r, s;
|
||||
mbedtls_ecp_group_init(&grp);
|
||||
mbedtls_ecp_point_init(&Q);
|
||||
mbedtls_mpi_init(&r); mbedtls_mpi_init(&s);
|
||||
int rc = -1;
|
||||
unsigned char uncompressed[65];
|
||||
if(mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1) != 0) goto out;
|
||||
if(mbedtls_mpi_read_binary(&r, sig, 32) != 0) goto out;
|
||||
if(mbedtls_mpi_read_binary(&s, sig + 32, 32) != 0) goto out;
|
||||
/* Public API only (no struct internals): uncompressed point 0x04||X||Y. */
|
||||
uncompressed[0] = 0x04;
|
||||
memcpy(uncompressed + 1, pubkey, 64);
|
||||
if(mbedtls_ecp_point_read_binary(&grp, &Q, uncompressed,
|
||||
sizeof uncompressed) != 0) goto out;
|
||||
if(mbedtls_ecp_check_pubkey(&grp, &Q) != 0) goto out;
|
||||
if(mbedtls_ecdsa_verify(&grp, hash, sizeof hash, &Q, &r, &s) != 0) goto out;
|
||||
rc = 0;
|
||||
out:
|
||||
mbedtls_ecp_group_free(&grp);
|
||||
mbedtls_ecp_point_free(&Q);
|
||||
mbedtls_mpi_free(&r); mbedtls_mpi_free(&s);
|
||||
return rc;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/* manifest.h - signed release manifest: parse, policy, signature verify.
|
||||
*
|
||||
* Release channel trust (v1.0.1+):
|
||||
* manifest.json (version, channel, min_version, platform, asset sha256s)
|
||||
* manifest.sig (raw 64-byte ECDSA P-256 r||s over manifest.json bytes)
|
||||
* verified with the embedded release public key (release_pubkey.h).
|
||||
* Policy: manifest+valid sig REQUIRED for update; SHA256SUMS is a
|
||||
* compat fallback only when no manifest exists; ELF-only is refused.
|
||||
*/
|
||||
#ifndef ORBISRPC_MANIFEST_H
|
||||
#define ORBISRPC_MANIFEST_H
|
||||
#include <stddef.h>
|
||||
|
||||
#define MANIFEST_MAX_ASSETS 8
|
||||
|
||||
typedef struct {
|
||||
char name[64];
|
||||
char sha256[65];
|
||||
} manifest_asset_t;
|
||||
|
||||
typedef struct {
|
||||
char version[32];
|
||||
char channel[16];
|
||||
char min_version[32];
|
||||
char platform[32];
|
||||
manifest_asset_t assets[MANIFEST_MAX_ASSETS];
|
||||
int nassets;
|
||||
} manifest_t;
|
||||
|
||||
/* Parse manifest.json bytes. 0 ok, -1 invalid. */
|
||||
int manifest_parse(const char *json, size_t len, manifest_t *out);
|
||||
/* Find asset hash by filename. 0 ok + out_hex, -1 not listed. */
|
||||
int manifest_find(const manifest_t *m, const char *name, char out_hex[65]);
|
||||
/* Check data against the listed hash for name. 0 match, -1 mismatch/unlisted. */
|
||||
int manifest_check(const manifest_t *m, const char *name,
|
||||
const unsigned char *data, size_t n);
|
||||
/* 1 when manifest version is newer than local, 0 otherwise. */
|
||||
int manifest_is_newer(const manifest_t *m, const char *local_version);
|
||||
/* Channel/platform gate: 1 accepted (stable/ps4-goldhen), 0 refused. */
|
||||
int manifest_gate(const manifest_t *m);
|
||||
/* Verify raw ECDSA P-256 signature (r||s, 64 bytes) over msg with raw
|
||||
* pubkey (X||Y, 64 bytes). 0 valid, -1 invalid/error. */
|
||||
int manifest_verify_sig(const unsigned char *msg, size_t msglen,
|
||||
const unsigned char sig[64],
|
||||
const unsigned char pubkey[64]);
|
||||
/* SHA256 of buffer as lowercase hex (64+NUL). 0 ok. */
|
||||
int manifest_sha256_hex(const unsigned char *data, size_t n, char out[65]);
|
||||
|
||||
#endif
|
||||
@@ -1,22 +0,0 @@
|
||||
/* release_pubkey.h - embedded release-channel public key (P-256, raw X||Y).
|
||||
*
|
||||
* PRODUCTION KEY: generated 2026-09-25 with
|
||||
* openssl ecparam -name prime256v1 -genkey -noout -out release_priv.pem
|
||||
* The private key lives OFFLINE outside the repo (macOS host:
|
||||
* ~/.config/orbisrpc/release_priv.pem) and must be backed up offline:
|
||||
* losing it bricks the on-console updater; leaking it fakes releases.
|
||||
* Sign manifest.json with scripts/make_manifest.py --priv release_priv.pem.
|
||||
*/
|
||||
#ifndef ORBISRPC_RELEASE_PUBKEY_H
|
||||
#define ORBISRPC_RELEASE_PUBKEY_H
|
||||
|
||||
/* Uncompressed P-256 X || Y (64 bytes). */
|
||||
static const unsigned char ORBISRPC_RELEASE_PUBKEY[64] = {
|
||||
0xe7,0x31,0xa1,0x93,0x9f,0xe3,0x85,0x36,0x03,0xbf,0x3e,0xb1,0xf0,0xc0,0x55,0x80,
|
||||
0x4e,0xa9,0x19,0xc5,0xca,0xe5,0xe8,0x5c,0x36,0xdc,0x0d,0x6f,0x7e,0x46,0x03,0xdb,
|
||||
0x23,0x2b,0xb9,0x2e,0xb1,0x72,0xa8,0xc4,0x75,0x15,0x99,0x18,0x58,0x59,0xfe,0x8c,
|
||||
0x4d,0x4f,0x31,0xee,0xa3,0xdd,0xfb,0x15,0xae,0x09,0xc3,0x34,0xee,0xf9,0x16,0x54
|
||||
};
|
||||
#define ORBISRPC_RELEASE_KEY_ID "prod-2026-09-25"
|
||||
|
||||
#endif
|
||||
+11
-3
@@ -13,7 +13,12 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define RETRO_HOST "retro-games.cybermask.dpdns.org"
|
||||
/* Host and base path are separate because a raw.githubusercontent.com URL is
|
||||
* a path, not a hostname: getaddrinfo() and tls_start() below both need a bare
|
||||
* host, and the certificate is issued for raw.githubusercontent.com. Putting the
|
||||
* whole URL in RETRO_HOST made DNS fail outright. */
|
||||
#define RETRO_HOST "raw.githubusercontent.com"
|
||||
#define RETRO_BASE "/SirHumza/orbisRPC/refs/heads/main/config"
|
||||
#define RETRO_DEADLINE_S 25
|
||||
#define RETRO_HDR_MAX 8192
|
||||
/* Working window. Independent of the 1.5 MB file: a chunk plus enough tail to
|
||||
@@ -291,8 +296,11 @@ int retro_resolve(const char *title_id, char *name, size_t name_cap,
|
||||
if(!n) return -1;
|
||||
|
||||
for(int k = 0; k < n; k++){
|
||||
char path[64];
|
||||
snprintf(path, sizeof path, "/%s", s_plat_file[cands[k]]);
|
||||
/* Sized for RETRO_BASE + "/" + the longest index filename, with room
|
||||
* to spare: a longer org/repo name must not truncate the request
|
||||
* path into a silent 404. */
|
||||
char path[192];
|
||||
snprintf(path, sizeof path, RETRO_BASE "/%s", s_plat_file[cands[k]]);
|
||||
name[0] = 0;
|
||||
if(url && url_cap) url[0] = 0;
|
||||
int rc = retro_fetch_scan(path, pat, name, name_cap, url, url_cap);
|
||||
|
||||
+46
-182
@@ -1,21 +1,28 @@
|
||||
/* updater.c - self-updater. Pure logic (host-tested) plus PS4 HTTPS.
|
||||
/* updater.c - update CHECK. Pure logic (host-tested) plus PS4 HTTPS.
|
||||
*
|
||||
* Flow, once per daemon boot when enabled:
|
||||
* Once per daemon boot, when enabled:
|
||||
* GET https://api.github.com/repos/<repo>/releases/latest
|
||||
* -> tag_name + asset browser_download_urls
|
||||
* if tag newer than ORBISRPC_VERSION:
|
||||
* download each known asset (cap 4MB), validate ELF magic,
|
||||
* write <target>.new, rename over target.
|
||||
* Payload .bin takes effect on next injection. Never deletes, never writes unvalidated bytes.
|
||||
* -> tag_name
|
||||
* if the tag is newer than ORBISRPC_VERSION, notify. Nothing else.
|
||||
*
|
||||
* This deliberately does NOT download or install anything. It used to fetch
|
||||
* orbisrpc.bin from the release and activate it over the live payload, which
|
||||
* made every boot a remote-code-execution path. Updates are applied by the
|
||||
* setup app now, and this file only tells the user to go do that.
|
||||
*
|
||||
* There is deliberately no signature check here. It was there to authenticate
|
||||
* a binary that was then downloaded and executed; with the download gone, the
|
||||
* only thing a signature could protect is the truth of a notification, and
|
||||
* that cost a second release asset and a signing key nobody outside the
|
||||
* original publisher holds. The tag arrives over TLS to api.github.com, so
|
||||
* the realistic worst case is a wrong version number in a notification --
|
||||
* not code execution.
|
||||
*/
|
||||
#include "updater.h"
|
||||
#include "updater_http.h"
|
||||
#include "version.h"
|
||||
#include "clock.h"
|
||||
#include "jsonlite.h"
|
||||
#include "health.h"
|
||||
#include "manifest.h"
|
||||
#include "release_pubkey.h"
|
||||
#include "log.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -258,79 +265,28 @@ static char *https_get(const char *host, const char *path,
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int stage_file(const char *target, const unsigned char *data, size_t n){
|
||||
char tmp[192];
|
||||
snprintf(tmp, sizeof tmp, "%s.new", target);
|
||||
if(!updater_image_ok(data, n)){ log_msg("updater: staged bytes failed validation"); return -1; }
|
||||
FILE *f = fopen(tmp, "wb");
|
||||
if(!f){ log_msg("updater: cannot write %s", tmp); return -1; }
|
||||
int ok = (fwrite(data, 1, n, f) == n);
|
||||
if(fflush(f) != 0) ok = 0;
|
||||
if(ok){ int fd = fileno(f); if(fd >= 0 && fsync(fd) != 0) ok = 0; }
|
||||
if(fclose(f) != 0) ok = 0;
|
||||
if(!ok){ remove(tmp); return -1; }
|
||||
/* Atomic verified activation: validates <target>.new, backs up live
|
||||
* to .bak, activates, re-verifies, auto-restores .bak on failure.
|
||||
* A refused stage leaves the live target untouched (ORX-UPDATE-003). */
|
||||
if(health_stage_activate(target) != 0){
|
||||
log_msg("updater: stage activate failed for %s; live kept", target);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
/* No stage_file() here any more: nothing is written to the payload path.
|
||||
* See the header comment -- the download-and-activate path was removed so
|
||||
* the payload cannot execute code fetched from the internet at boot. */
|
||||
|
||||
/* Download a release asset by exact name. Returns heap body or NULL. */
|
||||
static char *fetch_asset(const jl_val_t *assets, const char *want_name,
|
||||
size_t cap, int *status, size_t *out_len){
|
||||
for(size_t i = 0; ; i++){
|
||||
const jl_val_t *a = jl_arr_at(assets, i);
|
||||
if(!a) break;
|
||||
const jl_val_t *nm = jl_obj_get(a, "name");
|
||||
const jl_val_t *dl = jl_obj_get(a, "browser_download_url");
|
||||
if(!nm || nm->type != JL_STRING || !dl || dl->type != JL_STRING) continue;
|
||||
if(strcmp(nm->str, want_name) != 0) continue;
|
||||
const char *url = dl->str;
|
||||
if(strncmp(url, "https://", 8) != 0) return NULL;
|
||||
const char *h0 = url + 8;
|
||||
const char *p0 = strchr(h0, '/');
|
||||
if(!p0 || (size_t)(p0 - h0) >= 128) return NULL;
|
||||
char host[128];
|
||||
memcpy(host, h0, (size_t)(p0 - h0)); host[p0 - h0] = 0;
|
||||
return https_get(host, p0, cap, status, out_len);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
/* Check whether a newer signed release exists, and say so on screen.
|
||||
*
|
||||
* This used to download release assets and activate them over the live
|
||||
* payload (/data/payloads/orbisrpc.bin). That path is gone: the payload no
|
||||
* longer fetches or executes code from the internet, so a compromised
|
||||
* release, CDN, or redirect can at worst lie about a version number.
|
||||
* Updates are applied by the setup app instead.
|
||||
*
|
||||
* The only thing fetched is the release JSON. There is no signature check:
|
||||
* it authenticated a binary that used to be downloaded and executed, and
|
||||
* with the download gone it could only guard the truth of a notification.
|
||||
*
|
||||
* Returns 1 when a newer release tag was found (notification fired),
|
||||
* 0 when already current, -1 when the check itself failed.
|
||||
* Never fatal to the daemon. */
|
||||
int updater_check_notify(char *newer_out, size_t newer_cap){
|
||||
if(newer_out && newer_cap) newer_out[0] = 0;
|
||||
|
||||
/* Decode manifest.sig: raw 64 bytes, or 128 hex chars. 0 ok. */
|
||||
static int decode_sig(const char *body, size_t len, unsigned char out[64]){
|
||||
if(len == 64){ memcpy(out, body, 64); return 0; }
|
||||
/* strip whitespace for hex form; exactly 128 hex chars required —
|
||||
* trailing garbage after the 128 fails closed */
|
||||
char hex[129];
|
||||
size_t hn = 0, total = 0;
|
||||
for(size_t i = 0; i < len; i++){
|
||||
char c = body[i];
|
||||
if(c == ' ' || c == '\t' || c == '\r' || c == '\n') continue;
|
||||
total++;
|
||||
if(hn < 128) hex[hn++] = c;
|
||||
}
|
||||
if(hn != 128 || total != 128) return -1;
|
||||
for(int i = 0; i < 64; i++){
|
||||
unsigned v = 0;
|
||||
for(int k = 0; k < 2; k++){
|
||||
char c = hex[2 * i + k];
|
||||
v <<= 4;
|
||||
if(c >= '0' && c <= '9') v |= (unsigned)(c - '0');
|
||||
else if(c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10);
|
||||
else if(c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10);
|
||||
else return -1;
|
||||
}
|
||||
out[i] = (unsigned char)v;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updater_check_and_stage(void){
|
||||
char path[160];
|
||||
snprintf(path, sizeof path, "/repos/%s/releases/latest", ORBISRPC_REPO);
|
||||
size_t rl = 0;
|
||||
@@ -340,112 +296,20 @@ int updater_check_and_stage(void){
|
||||
jl_val_t *r = jl_parse(js, rl);
|
||||
free(js);
|
||||
if(!r){ log_msg("updater: release parse failed"); return -1; }
|
||||
|
||||
const jl_val_t *tag = jl_obj_get(r, "tag_name");
|
||||
const jl_val_t *assets = jl_obj_get(r, "assets");
|
||||
int updated = 0;
|
||||
int found = 0;
|
||||
|
||||
if(tag && tag->type == JL_STRING && tag->str[0] &&
|
||||
updater_cmp(tag->str, ORBISRPC_VERSION) > 0){
|
||||
log_msg("updater: %s available (local %s)", tag->str, ORBISRPC_VERSION);
|
||||
if(assets && assets->type == JL_ARRAY){
|
||||
/* Preferred: signed manifest (manifest.json + manifest.sig).
|
||||
* Verified with the embedded release pubkey; every binary must
|
||||
* match its listed SHA256. A bad signature refuses the whole
|
||||
* update (ORX-UPDATE-002). */
|
||||
manifest_t mf;
|
||||
int have_manifest = 0;
|
||||
size_t ml = 0;
|
||||
char *mbody = fetch_asset(assets, "manifest.json", 65536, &status, &ml);
|
||||
if(mbody){
|
||||
size_t sl = 0;
|
||||
char *sbody = fetch_asset(assets, "manifest.sig", 4096, &status, &sl);
|
||||
if(sbody && manifest_parse(mbody, ml, &mf) == 0 &&
|
||||
manifest_gate(&mf) && manifest_is_newer(&mf, ORBISRPC_VERSION)){
|
||||
unsigned char sig[64];
|
||||
if(decode_sig(sbody, sl, sig) == 0 &&
|
||||
manifest_verify_sig((unsigned char*)mbody, ml, sig,
|
||||
ORBISRPC_RELEASE_PUBKEY) == 0){
|
||||
have_manifest = 1;
|
||||
log_msg("updater: manifest %s verified (key %s)",
|
||||
mf.version, ORBISRPC_RELEASE_KEY_ID);
|
||||
} else {
|
||||
log_msg("updater: WARN ORX-UPDATE-002: manifest signature invalid; refusing update");
|
||||
}
|
||||
} else if(sbody){
|
||||
log_msg("updater: WARN ORX-UPDATE-002: manifest invalid/gated; refusing update");
|
||||
}
|
||||
free(sbody);
|
||||
if(!have_manifest){ free(mbody); mbody = NULL; }
|
||||
}
|
||||
/* Refuse unsigned updates outright. SHA256SUMS comes from the
|
||||
* same release as the binaries, so it pins nothing against
|
||||
* release-asset compromise — exactly what the signed manifest
|
||||
* defends against (ORX-UPDATE-002). */
|
||||
if(!have_manifest){
|
||||
log_msg("updater: no valid signed manifest; refusing update (ORX-UPDATE-002)");
|
||||
jl_free(r);
|
||||
return 0;
|
||||
}
|
||||
/* Two-phase commit: download + verify the asset first, then
|
||||
* activate it. A failure stages nothing — rollback restores the runtime. */
|
||||
struct { const char *target; char *bin; size_t len; } pend[1];
|
||||
memset(pend, 0, sizeof pend);
|
||||
int pend_n = 0, pend_fail = 0;
|
||||
for(size_t i = 0; ; i++){
|
||||
const jl_val_t *a = jl_arr_at(assets, i);
|
||||
if(!a) break;
|
||||
const jl_val_t *nm = jl_obj_get(a, "name");
|
||||
const jl_val_t *dl = jl_obj_get(a, "browser_download_url");
|
||||
if(!nm || nm->type != JL_STRING || !dl || dl->type != JL_STRING) continue;
|
||||
const char *target = NULL;
|
||||
if(!strcmp(nm->str, "orbisrpc.bin")) target = "/data/payloads/orbisrpc.bin";
|
||||
else continue;
|
||||
/* download URLs must be https (fail closed on http/other). */
|
||||
const char *url = dl->str;
|
||||
if(strncmp(url, "https://", 8) != 0){ pend_fail = 1; break; }
|
||||
const char *h0 = url + 8;
|
||||
const char *p0 = strchr(h0, '/');
|
||||
if(!p0 || (size_t)(p0-h0) >= 128){ pend_fail = 1; break; }
|
||||
char host[128];
|
||||
memcpy(host, h0, (size_t)(p0-h0)); host[p0-h0] = 0;
|
||||
size_t al = 0;
|
||||
char *bin = https_get(host, p0, UPD_BODY_MAX, &status, &al);
|
||||
if(!bin || !updater_image_ok((unsigned char*)bin, al)){
|
||||
log_msg("updater: asset %s failed validation", nm->str);
|
||||
free(bin);
|
||||
pend_fail = 1;
|
||||
break;
|
||||
}
|
||||
if(manifest_check(&mf, nm->str, (unsigned char*)bin, al) != 0){
|
||||
log_msg("updater: asset %s not in manifest or hash mismatch; refusing (ORX-UPDATE-002)", nm->str);
|
||||
free(bin);
|
||||
pend_fail = 1;
|
||||
break;
|
||||
}
|
||||
if(pend_n < 2){
|
||||
pend[pend_n].target = target;
|
||||
pend[pend_n].bin = bin;
|
||||
pend[pend_n].len = al;
|
||||
pend_n++;
|
||||
} else {
|
||||
free(bin); /* more binaries than we stage; ignore extras */
|
||||
}
|
||||
}
|
||||
if(!pend_fail && pend_n > 0){
|
||||
for(int pi = 0; pi < pend_n; pi++){
|
||||
if(stage_file(pend[pi].target,
|
||||
(unsigned char*)pend[pi].bin, pend[pi].len) == 0){
|
||||
log_msg("updater: staged %s (%zu bytes)",
|
||||
pend[pi].target, pend[pi].len);
|
||||
updated = 1;
|
||||
}
|
||||
}
|
||||
} else if(pend_fail){
|
||||
log_msg("updater: incomplete set; staged nothing (versions stay matched)");
|
||||
}
|
||||
for(int pi = 0; pi < pend_n; pi++) free(pend[pi].bin);
|
||||
free(mbody);
|
||||
if(newer_out && newer_cap){
|
||||
snprintf(newer_out, newer_cap, "%s", tag->str);
|
||||
found = 1;
|
||||
}
|
||||
} else {
|
||||
log_msg("updater: already current (local %s)", ORBISRPC_VERSION);
|
||||
}
|
||||
jl_free(r);
|
||||
return updated ? 1 : 0;
|
||||
return found;
|
||||
}
|
||||
+14
-5
@@ -1,4 +1,4 @@
|
||||
/* updater.h - self-updater: version compare, staged install. */
|
||||
/* updater.h - update CHECK: version compare + a notification. No install. */
|
||||
#ifndef UPDATER_H
|
||||
#define UPDATER_H
|
||||
#include <stddef.h>
|
||||
@@ -8,8 +8,17 @@ int updater_cmp(const char *a, const char *b);
|
||||
/* Validate a downloaded payload: ELF magic, 64-bit, x86-64, sane size. */
|
||||
int updater_elf_ok(const unsigned char *buf, size_t n);
|
||||
int updater_image_ok(const unsigned char *buf, size_t n);
|
||||
/* Check latest GitHub release; download + atomically stage newer
|
||||
* artifacts the daemon actually runs from. Returns 1 updated,
|
||||
* 0 already current, -1 failed/checked-off. Never fatal. */
|
||||
int updater_check_and_stage(void);
|
||||
/* Check the latest GitHub release for a newer version tag and report it.
|
||||
*
|
||||
* This does not download or install anything. It used to fetch orbisrpc.bin
|
||||
* and activate it over the live payload, which made every boot a
|
||||
* remote-code-execution path; the setup app applies updates now.
|
||||
*
|
||||
* No signature is verified. It authenticated a binary that was then executed;
|
||||
* with the download gone, it would only guard the truth of a notification.
|
||||
*
|
||||
* Writes the newer tag into newer_out (e.g. "1.7.1") when one is found.
|
||||
* Returns 1 if a newer release exists, 0 if already current, -1 if the check
|
||||
* failed. Never fatal to the daemon. */
|
||||
int updater_check_notify(char *newer_out, size_t newer_cap);
|
||||
#endif
|
||||
+101
-10
@@ -339,9 +339,10 @@ static int valid_frame_header(const unsigned char *b, uint64_t plen){
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ws_skip_plan() lives in ws_skip.c so the host tests can compile it without
|
||||
* the socket/TLS half of this file. */
|
||||
/* ws_skip_plan() lives in ws_skip.c and the envelope scraper in ws_env.c, so
|
||||
* the host tests can compile both without the socket/TLS half of this file. */
|
||||
#include "ws_skip.c"
|
||||
#include "ws_env.c"
|
||||
|
||||
int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out){
|
||||
if(!w || !w->connected || !buf || cap==0) return -1;
|
||||
@@ -352,13 +353,57 @@ int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out)
|
||||
w->rlen -= w->rpos; w->rpos = 0;
|
||||
}
|
||||
if(w->skip_left>0){
|
||||
/* draining an oversized frame: drop whatever is buffered */
|
||||
/* Draining an oversized frame. Every discarded byte is fed to the
|
||||
* envelope scraper first, so op/s/t survive without buffering. */
|
||||
size_t have = w->rlen - w->rpos;
|
||||
size_t take = have < (size_t)w->skip_left ? have : (size_t)w->skip_left;
|
||||
if(take && w->env_active)
|
||||
ws_env_feed(&w->env, w->rbuf + w->rpos, take);
|
||||
w->skip_left -= take; w->rpos += take;
|
||||
if(w->rpos >= w->rlen){ w->rpos=0; w->rlen=0; }
|
||||
/* Hand back the envelope as soon as op/s/t are known instead of
|
||||
* after the whole body. A 5.8 MB READY took longer to stream than
|
||||
* the 20 s identify deadline, so waiting gave
|
||||
* "no READY after identify (timeout)" even though the frame was
|
||||
* arriving perfectly. The remainder keeps draining on later
|
||||
* calls: skip_left and env live in the ws_t, so the next
|
||||
* ws_recv_frame() resumes exactly where this one stopped. */
|
||||
if(w->env_active && !w->env_reported && ws_env_complete(&w->env)){
|
||||
size_t n = ws_env_render(&w->env, buf, cap);
|
||||
if(n){
|
||||
w->env_reported = 1;
|
||||
if(opcode_out)*opcode_out = w->skip_op;
|
||||
if(fin_out)*fin_out = w->skip_fin;
|
||||
log_msg("ws: envelope reported early, %lluB still draining",
|
||||
(unsigned long long)w->skip_left);
|
||||
return (int)n;
|
||||
}
|
||||
}
|
||||
if(w->skip_left==0){
|
||||
if(opcode_out)*opcode_out=w->skip_op;
|
||||
if(fin_out)*fin_out=w->skip_fin;
|
||||
/* Drain finished. If the envelope never got reported, give it
|
||||
* one last chance; otherwise this frame was already seen. */
|
||||
if(w->env_active){
|
||||
if(w->env_reported){
|
||||
/* Already delivered this frame's envelope; only the
|
||||
* tail needed discarding. Report "nothing yet" rather
|
||||
* than -3, which callers log as a lost frame. */
|
||||
w->env_active = 0; w->env_reported = 0;
|
||||
return 0;
|
||||
}
|
||||
/* No early report happened, so say what was actually
|
||||
* captured. Without this the console only shows a bare
|
||||
* timeout and the cause is invisible. */
|
||||
log_msg("ws: drain end, envelope incomplete "
|
||||
"(op=%d %s s=%d %s t=%d %s win=%zu)",
|
||||
w->env.have_op, w->env.op,
|
||||
w->env.have_s, w->env.s,
|
||||
w->env.have_t, w->env.t, w->env.winlen);
|
||||
size_t n = ws_env_render(&w->env, buf, cap);
|
||||
w->env_active = 0; w->env_reported = 0;
|
||||
if(n) return (int)n;
|
||||
}
|
||||
return -3; /* whole oversized frame skipped */
|
||||
}
|
||||
} else {
|
||||
@@ -371,17 +416,33 @@ int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out)
|
||||
b[0]&0x0f, (b[0]&0x80)!=0, (unsigned long long)plen, b[0], b[1]);
|
||||
return -2;
|
||||
}
|
||||
/* absurd length: skip BEFORE arithmetic (hdr+plen could
|
||||
* overflow uint64 and smuggle a tiny "total" past the cap) */
|
||||
if(plen > WS_RBUF_MAX){
|
||||
log_msg("ws: oversized frame op=%d fin=%d plen=%llu (cap %u)",
|
||||
b[0]&0x0f, (b[0]&0x80)!=0, (unsigned long long)plen, WS_RBUF_MAX);
|
||||
/* Oversized: never buffer. Start the drain and scrape op/s/t as the
|
||||
* bytes go past, so a 12 MB READY costs a fixed 2 KB window.
|
||||
* Buffering it used to force rbuf to double to 16 MB, which
|
||||
* exhausted console memory across a long session (2026-10-09).
|
||||
* Size checked BEFORE arithmetic: hdr+plen could overflow
|
||||
* uint64 and smuggle a tiny "total" past the cap. */
|
||||
if(plen > WS_BODY_MAX){
|
||||
log_msg("ws: oversized frame op=%d fin=%d plen=%llu (streamed, body cap %u)",
|
||||
b[0]&0x0f, (b[0]&0x80)!=0, (unsigned long long)plen, WS_BODY_MAX);
|
||||
size_t avail = w->rlen - w->rpos;
|
||||
size_t h = (hdr < avail) ? hdr : avail;
|
||||
w->rpos += h; /* eat the header */
|
||||
size_t payload_here = avail - h;
|
||||
ws_skip_plan(plen, payload_here, &w->skip_left);
|
||||
if(payload_here) ws_env_feed(&w->env, w->rbuf + w->rpos, payload_here);
|
||||
/* skip_left counts EVERY payload byte still to consume,
|
||||
* including the ones already sitting in rbuf. Passing
|
||||
* payload_here here was a double-count: ws_skip_plan()
|
||||
* subtracted them, then the drain below consumed them
|
||||
* again, so the drain ended early and the next parse
|
||||
* read mid-payload as a frame header (observed 2026-10-09
|
||||
* as op=10 plen=116 b1=0x3a b2=0x74 -- ':' and 't' from
|
||||
* the middle of READY's "d" object). */
|
||||
ws_skip_plan(plen, 0, &w->skip_left);
|
||||
w->skip_op = wire_op;
|
||||
w->skip_fin = fin;
|
||||
w->env_active = 1;
|
||||
w->env_reported = 0;
|
||||
if(w->rpos >= w->rlen){ w->rpos=0; w->rlen=0; }
|
||||
continue;
|
||||
}
|
||||
@@ -391,7 +452,16 @@ int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out)
|
||||
while(ncap < (size_t)total && ncap < WS_RBUF_MAX) ncap*=2;
|
||||
unsigned char *nb=(unsigned char*)realloc(w->rbuf,ncap);
|
||||
if(nb){ w->rbuf=nb; w->rcap=ncap; log_msg("ws: rbuf grown to %zu", ncap); }
|
||||
else { log_msg("ws: rbuf grow fail"); }
|
||||
else {
|
||||
/* Once per connection. This used to log on every
|
||||
* loop pass and buried the actual cause under 20
|
||||
* identical lines. */
|
||||
if(!w->grow_warned){
|
||||
w->grow_warned = 1;
|
||||
log_msg("ws: rbuf grow fail (want %zu, have %zu); "
|
||||
"console likely out of memory", ncap, w->rcap);
|
||||
}
|
||||
}
|
||||
}
|
||||
if((uint64_t)(w->rlen-w->rpos) >= total){
|
||||
size_t copy = (size_t)plen;
|
||||
@@ -418,11 +488,32 @@ int ws_recv_frame(ws_t *w, char *buf, size_t cap, int *opcode_out, int *fin_out)
|
||||
if(w->skip_left > 0){
|
||||
size_t take = (size_t)w->skip_left;
|
||||
if(take > w->rlen) take = w->rlen;
|
||||
if(take && w->env_active)
|
||||
ws_env_feed(&w->env, w->rbuf + w->rpos, take);
|
||||
w->skip_left -= take;
|
||||
w->rpos += take;
|
||||
if(w->rpos >= w->rlen){ w->rpos=0; w->rlen=0; }
|
||||
if(w->env_active && !w->env_reported && ws_env_complete(&w->env)){
|
||||
size_t n = ws_env_render(&w->env, buf, cap);
|
||||
if(n){
|
||||
w->env_reported = 1;
|
||||
if(opcode_out)*opcode_out = w->skip_op;
|
||||
if(fin_out)*fin_out = w->skip_fin;
|
||||
return (int)n;
|
||||
}
|
||||
}
|
||||
if(w->skip_left == 0){
|
||||
if(opcode_out)*opcode_out = w->skip_op;
|
||||
if(fin_out)*fin_out = w->skip_fin;
|
||||
if(w->env_active){
|
||||
if(w->env_reported){
|
||||
w->env_active = 0; w->env_reported = 0;
|
||||
return 0;
|
||||
}
|
||||
size_t n = ws_env_render(&w->env, buf, cap);
|
||||
w->env_active = 0; w->env_reported = 0;
|
||||
if(n) return (int)n;
|
||||
}
|
||||
return -3;
|
||||
}
|
||||
continue;
|
||||
|
||||
+28
-9
@@ -5,18 +5,25 @@
|
||||
#define WS_H
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "ws_env.h"
|
||||
|
||||
#define WS_RBUF_MIN 65536 /* initial raw socket buffer (READY is big) */
|
||||
/* Grow limit; anything larger is drained and skipped.
|
||||
/* Grow limit. Now a backstop rather than the working size: any frame with a
|
||||
* payload larger than WS_BODY_MAX is envelope-extracted and never buffered,
|
||||
* so rbuf stays at WS_RBUF_MIN for a whole session.
|
||||
*
|
||||
* 32 MB, raised from 8 MB on 2026-10-06: a real account's READY measured
|
||||
* 11.7 MB across four attempts (11695449 / 11694320 / 11694256 / 11706895),
|
||||
* well past the old cap, so identify never completed and the gateway
|
||||
* reported a connect failure. The value is not stable frame-to-frame, so
|
||||
* this is sized with real headroom rather than matched to one observation.
|
||||
* rbuf doubles from 64 KB, so a full READY holds ~32 MB of PS4 heap for
|
||||
* the life of the session. */
|
||||
* History, kept because the reason is not obvious from the code: this was
|
||||
* 8 MB, raised to 32 MB on 2026-10-06 because a real account's READY
|
||||
* measured 11.7 MB. But 32 MB only moved the failure -- the buffer doubles
|
||||
* to 16 MB to hold a 12 MB frame, and two live payload instances exhausted
|
||||
* the console's memory (observed 2026-10-09 as "ws: rbuf grow fail" and a
|
||||
* permanent connect failure). WS_BODY_MAX is the actual fix. */
|
||||
#define WS_RBUF_MAX (32*1024*1024)
|
||||
/* Largest frame payload ever buffered whole. Above this the payload is
|
||||
* streamed and only op/s/t are kept (see ws_env.h), so an oversized READY
|
||||
* costs a fixed 2 KB window instead of tens of megabytes. Callers cap
|
||||
* payloads at 2 KB anyway. */
|
||||
#define WS_BODY_MAX 65536
|
||||
|
||||
typedef struct {
|
||||
int32_t sock; int32_t connected; int32_t fd;
|
||||
@@ -28,6 +35,13 @@ typedef struct {
|
||||
size_t rpos; /* consumed parse position */
|
||||
uint64_t skip_left; /* bytes left of an oversized frame being drained */
|
||||
int skip_op; /* opcode of the frame being drained */
|
||||
int skip_fin; /* FIN of the frame being drained */
|
||||
/* Envelope scraped while draining an oversized frame. Bounded: only the
|
||||
* first WS_ENV_WINDOW bytes of payload are retained. */
|
||||
ws_env_t env;
|
||||
int env_active; /* 1 while an oversized frame is being drained */
|
||||
int env_reported; /* envelope already handed back for this frame */
|
||||
int grow_warned; /* rbuf realloc already logged a failure */
|
||||
} ws_t;
|
||||
|
||||
int ws_connect(ws_t *w, const char *host, int port, const char *resource, const char *key);
|
||||
@@ -42,5 +56,10 @@ int ws_close(ws_t *w);
|
||||
* already covers plen. Pure, so the host tests can pin it: the drain state
|
||||
* lives in the ws_t and a bad value is only visible on the console as a
|
||||
* desynced frame stream. */
|
||||
void ws_skip_plan(uint64_t plen, size_t payload_here, uint64_t *skip_left_out);
|
||||
/* How many payload bytes the drain still has to discard. payload_here is the
|
||||
* count ALREADY REMOVED from rbuf, never the count sitting in it -- the drain
|
||||
* consumes buffered bytes too, so passing those here double-counts and leaves
|
||||
* the stream mid-frame (seen 2026-10-09). ws_recv_frame() removes none and
|
||||
* passes 0. Returns the value written. */
|
||||
uint64_t ws_skip_plan(uint64_t plen, size_t payload_here, uint64_t *skip_left_out);
|
||||
#endif
|
||||
@@ -0,0 +1,146 @@
|
||||
/* ws_env.c - see ws_env.h. Pure; no sockets, no TLS. */
|
||||
#include "ws_env.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
void ws_env_init(ws_env_t *e){
|
||||
if(!e) return;
|
||||
memset(e, 0, sizeof *e);
|
||||
}
|
||||
|
||||
/* Copy the value of a depth-1 string key out of a JSON prefix.
|
||||
*
|
||||
* Depth matters: Discord's envelope is {"op":..,"s":..,"t":..,"d":{..}}, and
|
||||
* "d" contains keys that collide with ours ("s" for session id, "t" for
|
||||
* timestamps). Only depth 1 counts, so those nested ones are never matched.
|
||||
*
|
||||
* Returns the value length, or 0 when the key is absent OR its value is not
|
||||
* yet fully buffered. That second case is the subtle one: fed one byte at a
|
||||
* time, a naive scan captures "4" out of "41" and then stops rescanning,
|
||||
* which is how "s":41 came back as "s":4. A value counts only once a real
|
||||
* terminator has been seen -- closing quote for strings, comma or brace for
|
||||
* numbers. Everything else is retried on the next feed. */
|
||||
static size_t copy_field(const char *w, size_t n, const char *key,
|
||||
char *out, size_t out_cap){
|
||||
if(!w || !key || !out || out_cap == 0) return 0;
|
||||
size_t klen = strlen(key);
|
||||
int depth = 0, instr = 0, esc = 0;
|
||||
|
||||
for(size_t i = 0; i < n; i++){
|
||||
char c = w[i];
|
||||
if(instr){
|
||||
if(esc) esc = 0;
|
||||
else if(c == '\\') esc = 1;
|
||||
else if(c == '"') instr = 0;
|
||||
continue;
|
||||
}
|
||||
if(c == '"'){
|
||||
/* Candidate key: "key" then optional space then ':' */
|
||||
if(depth == 1 &&
|
||||
i + klen + 2 <= n &&
|
||||
!memcmp(w + i + 1, key, klen) &&
|
||||
w[i + 1 + klen] == '"'){
|
||||
size_t j = i + klen + 2;
|
||||
while(j < n && (w[j] == ' ' || w[j] == '\t')) j++;
|
||||
if(j < n && w[j] == ':'){
|
||||
j++;
|
||||
while(j < n && (w[j] == ' ' || w[j] == '\t')) j++;
|
||||
size_t o = 0;
|
||||
int done = 0;
|
||||
if(j < n && w[j] == '"'){
|
||||
/* string value; escapes copied verbatim */
|
||||
j++;
|
||||
while(j < n && o + 1 < out_cap){
|
||||
if(w[j] == '"'){ done = 1; break; }
|
||||
if(w[j] == '\\' && j + 1 < n) out[o++] = w[j++];
|
||||
out[o++] = w[j++];
|
||||
}
|
||||
} else {
|
||||
/* number / literal: must reach a delimiter first */
|
||||
while(j < n && o + 1 < out_cap){
|
||||
if(w[j] == ',' || w[j] == '}'){ done = 1; break; }
|
||||
out[o++] = w[j++];
|
||||
}
|
||||
}
|
||||
if(!done) return 0; /* value runs past the window */
|
||||
out[o] = 0;
|
||||
return o;
|
||||
}
|
||||
}
|
||||
instr = 1;
|
||||
continue;
|
||||
}
|
||||
if(c == '{' || c == '[') depth++;
|
||||
else if(c == '}' || c == ']') depth--;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ws_env_feed(ws_env_t *e, const unsigned char *p, size_t n){
|
||||
if(!e || !p || n == 0) return;
|
||||
/* Retain a bounded prefix. Once saturated, further bytes are discarded,
|
||||
* which is the whole point: memory does not track payload size. */
|
||||
if(e->winlen < WS_ENV_WINDOW){
|
||||
size_t room = WS_ENV_WINDOW - e->winlen;
|
||||
size_t take = n < room ? n : room;
|
||||
memcpy(e->win + e->winlen, p, take);
|
||||
e->winlen += take;
|
||||
}
|
||||
/* Rescan the whole window each feed so a key split across a chunk
|
||||
* boundary still resolves. copy_field only returns a complete value. */
|
||||
if(!e->have_op && copy_field(e->win, e->winlen, "op", e->op, sizeof e->op))
|
||||
e->have_op = 1;
|
||||
if(!e->have_s && copy_field(e->win, e->winlen, "s", e->s, sizeof e->s))
|
||||
e->have_s = 1;
|
||||
if(!e->have_t && copy_field(e->win, e->winlen, "t", e->t, sizeof e->t))
|
||||
e->have_t = 1;
|
||||
}
|
||||
|
||||
int ws_env_complete(const ws_env_t *e){
|
||||
if(!e) return 0;
|
||||
/* op and t are what the callers read. Requiring "s" as well meant a
|
||||
* single unmatched field silently blocked the report for the whole
|
||||
* 5.8 MB drain -- the timeout seen on console 2026-10-09. gw_seq()
|
||||
* treats a missing "s" as "not a sequence" and returns quietly, so
|
||||
* dropping it from the requirement is safe. */
|
||||
if(e->have_op && e->have_t) return 1;
|
||||
/* Window saturated: nothing further can ever match, so waiting out the
|
||||
* rest of the frame would not change the result. */
|
||||
return e->winlen >= WS_ENV_WINDOW;
|
||||
}
|
||||
|
||||
size_t ws_env_render(const ws_env_t *e, char *out, size_t cap){
|
||||
if(!e || !out || cap == 0) return 0;
|
||||
char tmp[192];
|
||||
size_t t = 0;
|
||||
tmp[0] = 0;
|
||||
/* The braces are load-bearing. discord.c's top_val() only returns keys at
|
||||
* JSON depth 1, and depth is counted by '{' and '['. Emitting a bare
|
||||
* "op":0,"t":"READY" left depth at 0, so is_ready() never matched and the
|
||||
* handshake timed out even though the envelope arrived in one second:
|
||||
* "ws: envelope reported early" immediately followed by "no READY after
|
||||
* identify (timeout)" on console 2026-10-09.
|
||||
*
|
||||
* "d" is deliberately absent -- it is the megabytes nothing here reads. */
|
||||
/* Nothing captured: report 0 so the caller keeps its "frame skipped"
|
||||
* path. Rendering "{}" here would hand back a 2-byte pseudo-frame that no
|
||||
* gateway event ever looks like. */
|
||||
if(!e->have_op && !e->have_s && !e->have_t) return 0;
|
||||
|
||||
if(e->have_op && t + 24 < sizeof tmp)
|
||||
t += (size_t)snprintf(tmp + t, sizeof tmp - t, "{\"op\":%s,", e->op);
|
||||
else
|
||||
t += (size_t)snprintf(tmp + t, sizeof tmp - t, "{");
|
||||
if(e->have_s && t + 24 < sizeof tmp)
|
||||
t += (size_t)snprintf(tmp + t, sizeof tmp - t, "\"s\":%s,", e->s);
|
||||
if(e->have_t && t + 80 < sizeof tmp)
|
||||
t += (size_t)snprintf(tmp + t, sizeof tmp - t, "\"t\":\"%s\",", e->t);
|
||||
if(t == 0) return 0;
|
||||
if(tmp[t - 1] == ',') t--; /* drop trailing comma */
|
||||
if(t + 2 >= sizeof tmp) return 0;
|
||||
tmp[t++] = '}'; /* close the object */
|
||||
if(t >= cap) return 0; /* caller's buffer too small */
|
||||
memcpy(out, tmp, t);
|
||||
out[t] = 0;
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* ws_env.h - streaming envelope extraction for oversized WebSocket frames.
|
||||
*
|
||||
* A Discord READY frame is ~12 MB, almost all of it the "d" object. The
|
||||
* daemon only ever reads three top-level fields from a gateway frame:
|
||||
*
|
||||
* {"op":0,"s":41,"t":"READY","d":{ ...12 MB... }}
|
||||
* ^ ^ ^
|
||||
*
|
||||
* Buffering the whole frame just to read those made the receive buffer grow
|
||||
* from 64 KB to 16 MB by repeated doubling (65536 * 2^8 >= 11695459), and
|
||||
* that 16 MB is held for the life of the session. With more than one payload
|
||||
* instance alive the console runs out of memory and malloc fails -- observed
|
||||
* 2026-10-09 as "ws: rbuf grow fail" followed by "cannot connect to
|
||||
* discord", repeating on every retry.
|
||||
*
|
||||
* So oversized frames are streamed instead of buffered: these three fields
|
||||
* are pulled out of a small rolling window while the rest of the payload is
|
||||
* discarded. A 12 MB READY then costs the same memory as a 2 KB heartbeat.
|
||||
*
|
||||
* Pure logic -- no socket, no TLS, no PS4 headers -- so the host tests can
|
||||
* pin it. Same pattern as ws_skip.c: included by ws.c, compiled directly by
|
||||
* tests, so the two can never drift.
|
||||
*/
|
||||
#ifndef ORBISRPC_WS_ENV_H
|
||||
#define ORBISRPC_WS_ENV_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Bytes of payload prefix retained for scanning. op/s/t are a few bytes and
|
||||
* sit at the front of every gateway frame, so this only has to exceed the
|
||||
* envelope's own length; 2 KB leaves large margin for a reordering while
|
||||
* keeping the footprint fixed and tiny. */
|
||||
#define WS_ENV_WINDOW 2048
|
||||
|
||||
typedef struct {
|
||||
char win[WS_ENV_WINDOW]; /* payload prefix */
|
||||
size_t winlen; /* valid bytes in win */
|
||||
int have_op, have_s, have_t;
|
||||
char op[32];
|
||||
char s[32];
|
||||
char t[64];
|
||||
} ws_env_t;
|
||||
|
||||
void ws_env_init(ws_env_t *e);
|
||||
/* Feed a chunk of frame payload. Any chunking is fine, including a field
|
||||
* split across two calls: the window is rescanned in full each time. */
|
||||
void ws_env_feed(ws_env_t *e, const unsigned char *p, size_t n);
|
||||
/* 1 once the envelope is usable, or the window can take no more.
|
||||
*
|
||||
* op and t are what callers actually need: gw_op() reads "op" and is_ready()
|
||||
* reads "t". "s" is optional -- gw_seq() returns quietly when it is missing,
|
||||
* and requiring it here meant one unmatched field stalled the whole report. */
|
||||
int ws_env_complete(const ws_env_t *e);
|
||||
/* Render a minimal JSON envelope holding only the fields that were found,
|
||||
* shaped so discord.c's existing top_val() reads it unchanged.
|
||||
* Returns bytes written (excluding NUL), or 0 if nothing was captured. */
|
||||
size_t ws_env_render(const ws_env_t *e, char *out, size_t cap);
|
||||
|
||||
#endif /* ORBISRPC_WS_ENV_H */
|
||||
+12
-2
@@ -16,8 +16,18 @@
|
||||
*/
|
||||
#include "ws.h"
|
||||
|
||||
void ws_skip_plan(uint64_t plen, size_t payload_here, uint64_t *skip_left_out){
|
||||
if(!skip_left_out) return;
|
||||
uint64_t ws_skip_plan(uint64_t plen, size_t payload_here, uint64_t *skip_left_out){
|
||||
if(!skip_left_out) return 0;
|
||||
/* payload_here is the number of payload bytes ALREADY REMOVED from rbuf
|
||||
* and therefore no longer pending. It must NOT be the number sitting in
|
||||
* the buffer: the drain loop consumes those too, so counting them here
|
||||
* as well double-counts and the drain terminates early with the stream
|
||||
* left mid-frame (console 2026-10-09, op=10 b1=0x3a b2=0x74).
|
||||
*
|
||||
* ws_recv_frame() removes no payload before arming the drain, so it
|
||||
* passes 0. The saturation branch is kept so a caller that does remove
|
||||
* bytes cannot underflow. */
|
||||
*skip_left_out = ((uint64_t)payload_here >= plen)
|
||||
? 0 : plen - (uint64_t)payload_here;
|
||||
return *skip_left_out;
|
||||
}
|
||||
+1
-1
@@ -47,7 +47,7 @@ LDFLAGS="-m elf_x86_64 -pie --eh-frame-hdr -L$SDK/lib $LIBS $SDK/lib/crt1.o --sc
|
||||
export OO_PS4_TOOLCHAIN="$SDK"
|
||||
OUT="$ROOT/build"; mkdir -p "$OUT"
|
||||
echo "=== compiling (CC=$CC LD=$LD SDK=$SDK) ==="
|
||||
for f in log cfg jsonlite b64 sfo procwalk bigapp fw pkgzone gamecache notify retro tmdb_crypto tmdb updater updater_http updater_util tls ws detect discord daemon compat lock timesync art health manifest main; do
|
||||
for f in log cfg jsonlite b64 sfo procwalk bigapp fw pkgzone gamecache notify retro tmdb_crypto tmdb updater updater_http updater_util tls ws detect discord daemon compat lock timesync art health main; do
|
||||
"$CC" $CFLAGS -c -o "$OUT/$f.o" "orbisrpc/$f.c" || fail "compile $f"
|
||||
done
|
||||
echo "=== mbedtls (skip net_sockets/timing: POSIX-only) ==="
|
||||
|
||||
@@ -51,7 +51,7 @@ mkdir -p "$OUT"
|
||||
CFLAGS="-O2 -Wall -DORBISRPC_SDK_PAYLOAD -Iorbisrpc -Ithird_party/mbedtls/include -Ithird_party/sqlite/sqlite-amalgamation-3510100"
|
||||
SQLITE_DIR="third_party/sqlite/sqlite-amalgamation-3510100"
|
||||
echo "=== daemon sources (SDK) ==="
|
||||
for f in log cfg jsonlite b64 sfo procwalk bigapp fw pkgzone gamecache notify retro tmdb_crypto tmdb updater updater_http updater_util tls ws detect discord daemon compat lock timesync art health manifest appdb main; do
|
||||
for f in log cfg jsonlite b64 sfo procwalk bigapp fw pkgzone gamecache notify retro tmdb_crypto tmdb updater updater_http updater_util tls ws detect discord daemon compat lock timesync art health appdb main; do
|
||||
# clock has no .c (header-only helper lives in compat.c); skip if missing
|
||||
[ -f "orbisrpc/$f.c" ] || continue
|
||||
"$CC" $CFLAGS -c -o "$OUT/$f.o" "orbisrpc/$f.c" || { echo "FAIL: $f"; exit 1; }
|
||||
|
||||
+7
-3
@@ -10,7 +10,7 @@ MBEDTLS_DIR := ../third_party/mbedtls/library
|
||||
# Same exclusion set as scripts/build.sh (POSIX-only modules).
|
||||
MBEDTLS_SRCS := $(filter-out $(MBEDTLS_DIR)/net_sockets.c $(MBEDTLS_DIR)/timing.c $(MBEDTLS_DIR)/entropy_poll.c,$(wildcard $(MBEDTLS_DIR)/*.c))
|
||||
|
||||
ORBIS_SRCS := ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c ../orbisrpc/health.c ../orbisrpc/manifest.c ../orbisrpc/compat.c ../orbisrpc/cfg.c ../orbisrpc/appdb.c ../orbisrpc/discord.c ../orbisrpc/procwalk.c ../orbisrpc/bigapp.c ../orbisrpc/pkgzone.c ../orbisrpc/gamecache.c ../orbisrpc/fw.c ../orbisrpc/notify.c ../orbisrpc/retro.c
|
||||
ORBIS_SRCS := ../orbisrpc/jsonlite.c ../orbisrpc/b64.c ../orbisrpc/sfo.c ../orbisrpc/tmdb_crypto.c ../orbisrpc/updater_util.c ../orbisrpc/art.c ../orbisrpc/log.c ../orbisrpc/health.c ../orbisrpc/compat.c ../orbisrpc/cfg.c ../orbisrpc/appdb.c ../orbisrpc/discord.c ../orbisrpc/procwalk.c ../orbisrpc/bigapp.c ../orbisrpc/pkgzone.c ../orbisrpc/gamecache.c ../orbisrpc/fw.c ../orbisrpc/notify.c ../orbisrpc/retro.c
|
||||
INST_SRCS := ../installer/icfg.c
|
||||
# SQLite amalgamation: -O0 for host iteration speed (payload uses -O2).
|
||||
SQLITE_DIR := ../third_party/sqlite/sqlite-amalgamation-3510100
|
||||
@@ -47,7 +47,11 @@ $(OBJDIR):
|
||||
$(ASAN_OBJDIR):
|
||||
mkdir -p $(ASAN_OBJDIR)
|
||||
|
||||
$(OBJDIR)/test_utils.o: test_utils.c | $(OBJDIR)
|
||||
# test_utils.c #includes ws_skip.c and ws_env.c directly (they are the pure
|
||||
# parts of ws.c, which cannot be linked on the host because it owns a
|
||||
# tls_ctx_t). They must be listed here or make treats the object as up to date
|
||||
# after they change -- which silently tested stale code.
|
||||
$(OBJDIR)/test_utils.o: test_utils.c ../orbisrpc/ws_skip.c ../orbisrpc/ws_env.c ../orbisrpc/ws_env.h | $(OBJDIR)
|
||||
$(CC) $(CFLAGS) -c -o $@ test_utils.c
|
||||
|
||||
$(OBJDIR)/orbis_%.o: ../orbisrpc/%.c | $(OBJDIR)
|
||||
@@ -65,7 +69,7 @@ $(OBJDIR)/sqlite3.o: $(SQLITE_DIR)/sqlite3.c | $(OBJDIR)
|
||||
$(ASAN_OBJDIR)/sqlite3.o: $(SQLITE_DIR)/sqlite3.c | $(ASAN_OBJDIR)
|
||||
$(CC) $(SQLITE_FLAGS) $(ASAN_FLAGS) -c -o $@ $<
|
||||
|
||||
$(ASAN_OBJDIR)/test_utils.o: test_utils.c | $(ASAN_OBJDIR)
|
||||
$(ASAN_OBJDIR)/test_utils.o: test_utils.c ../orbisrpc/ws_skip.c ../orbisrpc/ws_env.c ../orbisrpc/ws_env.h | $(ASAN_OBJDIR)
|
||||
$(CC) $(CFLAGS) $(ASAN_FLAGS) -c -o $@ test_utils.c
|
||||
|
||||
$(ASAN_OBJDIR)/orbis_%.o: ../orbisrpc/%.c | $(ASAN_OBJDIR)
|
||||
|
||||
+326
-153
@@ -5,7 +5,6 @@
|
||||
#include "../orbisrpc/updater.h"
|
||||
#include "../orbisrpc/art.h"
|
||||
#include "../orbisrpc/health.h"
|
||||
#include "../orbisrpc/manifest.h"
|
||||
#include "../orbisrpc/cfg.h"
|
||||
#include "../orbisrpc/appdb.h"
|
||||
#include "../orbisrpc/discord.h"
|
||||
@@ -43,6 +42,7 @@ int ws_close(ws_t *w){ (void)w; return -1; }
|
||||
* drain arithmetic comes in directly. Only ws_skip_plan is needed, and it is
|
||||
* real code compiled from the real source, not a copy. */
|
||||
#include "../orbisrpc/ws_skip.c"
|
||||
#include "../orbisrpc/ws_env.c"
|
||||
#include <mbedtls/ecdsa.h>
|
||||
#include <mbedtls/ecp.h>
|
||||
#include <mbedtls/ctr_drbg.h>
|
||||
@@ -140,6 +140,7 @@ static void test_ws_skip_plan(void) {
|
||||
ws_skip_plan((uint64_t)64 * 1024 * 1024, 0, &left);
|
||||
assert(left == (uint64_t)64 * 1024 * 1024);
|
||||
|
||||
|
||||
/* NULL out-pointer must not crash. */
|
||||
ws_skip_plan(1000, 10, NULL);
|
||||
|
||||
@@ -148,6 +149,315 @@ static void test_ws_skip_plan(void) {
|
||||
assert(WS_RBUF_MAX > 11706895);
|
||||
}
|
||||
|
||||
|
||||
/* The envelope must be reportable BEFORE the body is fully drained.
|
||||
*
|
||||
* Console 2026-10-09: a 5.8 MB READY streamed correctly but took longer than
|
||||
* the 20 s identify deadline, so waiting for the whole frame produced
|
||||
* "no READY after identify (timeout)". op/s/t sit in the first few hundred
|
||||
* bytes, so the caller must be able to act on them immediately while the
|
||||
* remainder drains in the background. */
|
||||
static void test_ws_env_early_report(void) {
|
||||
ws_env_t e;
|
||||
char out[256];
|
||||
|
||||
ws_env_init(&e);
|
||||
/* Only the envelope so far -- the "d" object has not arrived. */
|
||||
const char *head = "{\"op\":0,\"s\":41,\"t\":\"READY\",\"d\":{";
|
||||
ws_env_feed(&e, (const unsigned char*)head, strlen(head));
|
||||
|
||||
assert(ws_env_complete(&e)); /* complete despite the body */
|
||||
size_t n = ws_env_render(&e, out, sizeof out);
|
||||
assert(n > 0);
|
||||
assert(strstr(out, "\"t\":\"READY\""));
|
||||
|
||||
/* A missing "s" must not stall the report. op and t are what the callers
|
||||
* read; requiring all three meant one unmatched field blocked the whole
|
||||
* 5.8 MB drain, which is the 2026-10-09 timeout. */
|
||||
ws_env_init(&e);
|
||||
const char *nos = "{\"op\":0,\"t\":\"READY\",\"d\":{";
|
||||
ws_env_feed(&e, (const unsigned char*)nos, strlen(nos));
|
||||
assert(!e.have_s);
|
||||
assert(ws_env_complete(&e));
|
||||
assert(ws_env_render(&e, out, sizeof out) > 0);
|
||||
|
||||
/* Op alone is not enough: is_ready() needs t. */
|
||||
ws_env_init(&e);
|
||||
const char *not_ = "{\"op\":0,\"d\":{";
|
||||
ws_env_feed(&e, (const unsigned char*)not_, strlen(not_));
|
||||
assert(e.have_op && !e.have_t);
|
||||
assert(!ws_env_complete(&e));
|
||||
|
||||
assert(WS_BODY_MAX < 5836474);
|
||||
}
|
||||
|
||||
static void test_ws_skip_plan_early(void) {
|
||||
/* Re-assert the drain can continue after an early report: arming with 0
|
||||
* leaves the full plen pending, which is what the background drain uses. */
|
||||
uint64_t left = 0;
|
||||
ws_skip_plan(5836474, 0, &left);
|
||||
assert(left == 5836474);
|
||||
/* And a drained frame reports nothing further rather than a lost frame. */
|
||||
ws_skip_plan(0, 0, &left);
|
||||
assert(left == 0);
|
||||
}
|
||||
|
||||
/* Simulate ws_recv_frame's drain over a real-sized frame with a realistically
|
||||
* full buffer, and prove it consumes EXACTLY plen payload bytes.
|
||||
*
|
||||
* This is the regression test for the 2026-10-09 connect failure. ws.c passed
|
||||
* the already-buffered payload count into ws_skip_plan AND let the drain
|
||||
* consume those bytes again, so the drain stopped payload_here bytes early
|
||||
* and the next parse read mid-payload as a frame header (observed:
|
||||
* op=10 fin=0 plen=116 b1=0x3a b2=0x74 -- ':' and 't' out of READY's "d").
|
||||
* Asserting the helper alone could not catch it; the arithmetic has to be
|
||||
* replayed the way ws_recv_frame actually drives it. */
|
||||
static void test_ws_drain_length(void) {
|
||||
const uint64_t plen = 5836474; /* real READY from the 2026-10-09 log */
|
||||
const size_t rcap = 65536; /* WS_BODY_MAX / WS_RBUF_MIN */
|
||||
const size_t hdr = 10; /* 64-bit length prefix */
|
||||
|
||||
/* ws_recv_frame arms the drain with no payload removed, then the drain
|
||||
* loop consumes whatever is buffered before reading more. */
|
||||
uint64_t skip_left = 0;
|
||||
ws_skip_plan(plen, 0, &skip_left);
|
||||
assert(skip_left == plen);
|
||||
|
||||
/* Model the buffer: first read fills it completely. */
|
||||
size_t rlen = rcap;
|
||||
uint64_t consumed = 0;
|
||||
int rounds = 0;
|
||||
|
||||
while(skip_left > 0){
|
||||
size_t take = (size_t)((skip_left < rlen) ? skip_left : rlen);
|
||||
skip_left -= take;
|
||||
consumed += take;
|
||||
rlen -= take; /* compaction: rpos back to 0 */
|
||||
if(rlen == 0){ /* buffer emptied, read more */
|
||||
size_t space = rcap;
|
||||
size_t remaining = (size_t)((skip_left < space) ? skip_left : space);
|
||||
rlen = remaining;
|
||||
skip_left -= remaining;
|
||||
consumed += remaining;
|
||||
}
|
||||
if(++rounds > 100000){ assert(0 && "drain did not terminate"); }
|
||||
}
|
||||
|
||||
/* The whole point: exactly plen bytes consumed, so the next byte read is
|
||||
* the next frame's header. */
|
||||
assert(consumed == plen);
|
||||
assert(rounds > 1); /* it really did take many rounds */
|
||||
|
||||
/* Replay the buggy arming too: it lands short by exactly the buffered
|
||||
* count, which is what desynced the stream. */
|
||||
uint64_t buggy_skip = 0;
|
||||
ws_skip_plan(plen, rcap - hdr, &buggy_skip);
|
||||
assert(buggy_skip == plen - (rcap - hdr));
|
||||
{
|
||||
size_t rlen2 = rcap, rounds2 = 0;
|
||||
uint64_t consumed2 = 0, sl = buggy_skip;
|
||||
while(sl > 0){
|
||||
size_t take = (size_t)((sl < rlen2) ? sl : rlen2);
|
||||
sl -= take; consumed2 += take; rlen2 -= take;
|
||||
if(rlen2 == 0){
|
||||
size_t sp = rcap;
|
||||
size_t rem = (size_t)((sl < sp) ? sl : sp);
|
||||
rlen2 = rem; sl -= rem; consumed2 += rem;
|
||||
}
|
||||
if(++rounds2 > 100000) break;
|
||||
}
|
||||
/* Short by payload_here -> next parse starts inside the payload. */
|
||||
assert(consumed2 == plen - (uint64_t)(rcap - hdr));
|
||||
assert(consumed2 != plen);
|
||||
}
|
||||
}
|
||||
|
||||
/* Replica of discord.c's top_val() depth-1 lookup. The rendered envelope is
|
||||
* only useful if THIS can find "t" inside it, and top_val() is static in
|
||||
* discord.c so the host suite cannot call it directly. Kept byte-for-byte in
|
||||
* behaviour: keys only count at depth 1, counted by '{' and '['.
|
||||
*
|
||||
* This is the check that was missing. ws_env_render() originally emitted a
|
||||
* bare "op":0,"t":"READY" with no braces, so depth never reached 1,
|
||||
* is_ready() never matched, and the handshake timed out even though the
|
||||
* envelope came back in one second (console 2026-10-09). */
|
||||
static const char *test_top_val(const char *json, size_t len, const char *key){
|
||||
size_t kl = strlen(key);
|
||||
int depth = 0, instr = 0, esc = 0;
|
||||
for(size_t i = 0; i < len; i++){
|
||||
char c = json[i];
|
||||
if(instr){
|
||||
if(esc) esc = 0;
|
||||
else if(c == '\\') esc = 1;
|
||||
else if(c == '"') instr = 0;
|
||||
continue;
|
||||
}
|
||||
if(c == '"'){
|
||||
size_t j = i + 1, k = 0;
|
||||
while(j < len && k < kl && json[j] == key[k]){ j++; k++; }
|
||||
if(k == kl && j < len && json[j] == '"'){
|
||||
j++;
|
||||
while(j < len && (json[j] == ' ' || json[j] == '\t')) j++;
|
||||
if(j < len && json[j] == ':'){
|
||||
if(depth == 1) return json + j + 1;
|
||||
i = j; continue;
|
||||
}
|
||||
}
|
||||
instr = 1; continue;
|
||||
}
|
||||
if(c == '{' || c == '[') depth++;
|
||||
else if(c == '}' || c == ']') depth--;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* The rendered envelope must be readable by the real consumer. */
|
||||
static void test_ws_env_renders_parseable(void) {
|
||||
char out[256];
|
||||
ws_env_t e;
|
||||
|
||||
ws_env_init(&e);
|
||||
const char *p = "{\"op\":0,\"s\":41,\"t\":\"READY\",\"d\":{}}";
|
||||
ws_env_feed(&e, (const unsigned char*)p, strlen(p));
|
||||
size_t n = ws_env_render(&e, out, sizeof out);
|
||||
assert(n > 0);
|
||||
|
||||
/* Well-formed object: the braces must be there. */
|
||||
assert(out[0] == '{');
|
||||
assert(out[n - 1] == '}');
|
||||
|
||||
/* And depth-1 lookup, which is what is_ready()/gw_op() actually do. */
|
||||
const char *t = test_top_val(out, n, "t");
|
||||
assert(t != NULL);
|
||||
assert(!memcmp(t, "\"READY\"", 7));
|
||||
|
||||
const char *op = test_top_val(out, n, "op");
|
||||
assert(op != NULL);
|
||||
assert(!memcmp(op, "0", 1));
|
||||
|
||||
const char *s = test_top_val(out, n, "s");
|
||||
assert(s != NULL);
|
||||
assert(!memcmp(s, "41", 2));
|
||||
|
||||
/* "d" was never captured, so it must not be findable. */
|
||||
assert(test_top_val(out, n, "d") == NULL);
|
||||
|
||||
/* A nested "t" must NOT be reachable at depth 1. */
|
||||
ws_env_init(&e);
|
||||
p = "{\"op\":9,\"t\":\"RESUMED\",\"d\":{\"t\":\"NESTED\"}}";
|
||||
ws_env_feed(&e, (const unsigned char*)p, strlen(p));
|
||||
n = ws_env_render(&e, out, sizeof out);
|
||||
t = test_top_val(out, n, "t");
|
||||
assert(t != NULL);
|
||||
assert(!memcmp(t, "\"RESUMED\"", 9));
|
||||
}
|
||||
|
||||
/* --- oversized-frame envelope scraping ---------------------------------
|
||||
* Buffering READY forced rbuf to double to 16 MB and exhausted console
|
||||
* memory (2026-10-09: "ws: rbuf grow fail", then a permanent connect
|
||||
* failure). The payload is now streamed and only op/s/t kept, so these pin
|
||||
* the extractor that makes that safe. */
|
||||
static void test_ws_env(void) {
|
||||
char out[256];
|
||||
|
||||
/* The real shape: envelope first, 12 MB of "d" after. */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
const char *p = "{\"op\":0,\"s\":41,\"t\":\"READY\",\"d\":{"
|
||||
"\"user\":{\"id\":\"1\",\"s\":9},\"guilds\":[]}}";
|
||||
ws_env_feed(&e, (const unsigned char*)p, strlen(p));
|
||||
assert(e.have_op && e.have_s && e.have_t);
|
||||
assert(ws_env_complete(&e));
|
||||
size_t n = ws_env_render(&e, out, sizeof out);
|
||||
assert(n > 0);
|
||||
assert(strstr(out, "\"op\":0"));
|
||||
assert(strstr(out, "\"s\":41"));
|
||||
assert(strstr(out, "\"t\":\"READY\""));
|
||||
assert(!strstr(out, "\"d\"")); /* the 12 MB we never want */
|
||||
}
|
||||
|
||||
/* Nested keys must not be mistaken for the envelope's. This is the real
|
||||
* hazard: "d" contains "s" and "t" of its own. */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
const char *p = "{\"op\":9,\"s\":7,\"t\":\"RESUMED\",\"d\":"
|
||||
"{\"s\":\"SHOULD-NOT-WIN\",\"t\":\"ALSO-NOT\"}}";
|
||||
ws_env_feed(&e, (const unsigned char*)p, strlen(p));
|
||||
assert(!strcmp(e.s, "7"));
|
||||
assert(!strcmp(e.t, "RESUMED"));
|
||||
assert(!strcmp(e.op, "9"));
|
||||
}
|
||||
|
||||
/* Key split across a chunk boundary: "RE" | "ADY". */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
const char *a = "{\"op\":0,\"s\":41,\"t\":\"RE";
|
||||
const char *b = "ADY\",\"d\":{}}";
|
||||
ws_env_feed(&e, (const unsigned char*)a, strlen(a));
|
||||
ws_env_feed(&e, (const unsigned char*)b, strlen(b));
|
||||
assert(!strcmp(e.t, "READY"));
|
||||
assert(ws_env_complete(&e));
|
||||
}
|
||||
|
||||
/* Byte-at-a-time: the worst chunking the socket can produce. */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
const char *p = "{\"op\":0,\"s\":41,\"t\":\"READY\",\"d\":{}}";
|
||||
for(size_t i = 0; p[i]; i++) ws_env_feed(&e, (const unsigned char*)p + i, 1);
|
||||
assert(!strcmp(e.op, "0"));
|
||||
assert(!strcmp(e.s, "41"));
|
||||
assert(!strcmp(e.t, "READY"));
|
||||
}
|
||||
|
||||
/* Window saturation must not spin forever waiting for more, and must not
|
||||
* overflow the fixed 2 KB window. */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
static char big[WS_ENV_WINDOW * 3];
|
||||
memset(big, 'x', sizeof big);
|
||||
ws_env_feed(&e, (const unsigned char*)big, sizeof big);
|
||||
assert(e.winlen == WS_ENV_WINDOW);
|
||||
assert(ws_env_complete(&e)); /* saturated -> done, not stuck */
|
||||
ws_env_feed(&e, (const unsigned char*)big, sizeof big);
|
||||
assert(e.winlen == WS_ENV_WINDOW); /* still bounded */
|
||||
}
|
||||
|
||||
/* Nothing captured -> render returns 0 so the caller keeps the old
|
||||
* "skipped frame" behaviour instead of reading an empty frame. */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
const char *p = "{\"d\":{\"huge\":true}}";
|
||||
ws_env_feed(&e, (const unsigned char*)p, strlen(p));
|
||||
assert(ws_env_render(&e, out, sizeof out) == 0);
|
||||
}
|
||||
|
||||
/* Caller buffer too small must refuse rather than truncate into a
|
||||
* malformed JSON envelope. */
|
||||
{
|
||||
ws_env_t e;
|
||||
ws_env_init(&e);
|
||||
const char *p = "{\"op\":0,\"s\":41,\"t\":\"READY\"}";
|
||||
ws_env_feed(&e, (const unsigned char*)p, strlen(p));
|
||||
assert(ws_env_render(&e, out, 4) == 0);
|
||||
assert(ws_env_render(&e, out, sizeof out) > 0);
|
||||
}
|
||||
|
||||
/* NULL safety. */
|
||||
ws_env_init(NULL);
|
||||
ws_env_feed(NULL, (const unsigned char*)"x", 1);
|
||||
assert(ws_env_complete(NULL) == 0);
|
||||
assert(ws_env_render(NULL, out, sizeof out) == 0);
|
||||
|
||||
/* The cap must sit below a real READY or the whole thing is pointless. */
|
||||
assert(WS_BODY_MAX < 11695449);
|
||||
}
|
||||
|
||||
static void test_json_oom_safe(void) {
|
||||
jl_val_t *t = jl_parse("true", 4); assert(t != NULL); jl_free(t);
|
||||
jl_val_t *f = jl_parse("false", 5); assert(f != NULL); jl_free(f);
|
||||
@@ -329,151 +639,6 @@ static void test_health_safe_mode(void) {
|
||||
assert(health_boot_note_crash() == 0);
|
||||
health_mark_healthy();
|
||||
}
|
||||
|
||||
static void test_health_stage_activate(void) {
|
||||
/* Atomic staging: bad .new never touches live; rollback restores. */
|
||||
char dir[64];
|
||||
assert(make_tmpdir(dir, sizeof dir) == 0);
|
||||
health_set_base(dir);
|
||||
char live[256], tmp[256], bak[256];
|
||||
snprintf(live, sizeof live, "%s/live.bin", dir);
|
||||
snprintf(tmp, sizeof tmp, "%s/live.bin.new", dir);
|
||||
snprintf(bak, sizeof bak, "%s/live.bin.bak", dir);
|
||||
/* live = valid ELF stand-in (>=64B, ELF magic via updater_image_ok?
|
||||
* use real check: write 64 zero bytes won't pass; stage path only
|
||||
* needs .new validation, so craft minimal ELF header). */
|
||||
unsigned char elf[128];
|
||||
memset(elf, 0, sizeof elf);
|
||||
elf[0] = 0x7f; elf[1] = 'E'; elf[2] = 'L'; elf[3] = 'F';
|
||||
elf[4] = 2; elf[5] = 1; elf[18] = 62;
|
||||
FILE *f = fopen(live, "wb");
|
||||
assert(f); assert(fwrite(elf, 1, sizeof elf, f) == sizeof elf); fclose(f);
|
||||
/* corrupt .new is refused, live untouched */
|
||||
f = fopen(tmp, "wb");
|
||||
assert(f); assert(fwrite("garbage-not-elf-at-all......................"
|
||||
"..............................", 1, 64, f) == 64);
|
||||
fclose(f);
|
||||
assert(health_stage_activate(live) != 0);
|
||||
f = fopen(live, "rb");
|
||||
assert(f);
|
||||
unsigned char chk[4];
|
||||
assert(fread(chk, 1, 4, f) == 4);
|
||||
fclose(f);
|
||||
assert(chk[0] == 0x7f && chk[1] == 'E');
|
||||
/* valid .new activates, backup created, rollback restores */
|
||||
f = fopen(tmp, "wb");
|
||||
assert(f);
|
||||
elf[7] = 0x42;
|
||||
assert(fwrite(elf, 1, sizeof elf, f) == sizeof elf);
|
||||
fclose(f);
|
||||
assert(health_stage_activate(live) == 0);
|
||||
f = fopen(bak, "rb");
|
||||
assert(f); fclose(f);
|
||||
assert(health_verify_or_rollback(live) == 0);
|
||||
/* corrupt live + backup present -> rollback */
|
||||
f = fopen(live, "wb");
|
||||
assert(f); assert(fwrite("XX", 1, 2, f) == 2); fclose(f);
|
||||
assert(health_verify_or_rollback(live) == 1);
|
||||
f = fopen(live, "rb");
|
||||
assert(f);
|
||||
assert(fread(chk, 1, 4, f) == 4);
|
||||
fclose(f);
|
||||
assert(chk[0] == 0x7f);
|
||||
}
|
||||
|
||||
static void test_manifest(void) {
|
||||
const char *json = "{\"version\":\"1.0.0\",\"channel\":\"stable\","
|
||||
"\"min_version\":\"0.9.0\",\"platform\":\"ps4-goldhen\","
|
||||
"\"assets\":[{\"name\":\"orbisrpc.bin\","
|
||||
"\"sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}]}";
|
||||
manifest_t m;
|
||||
assert(manifest_parse(json, strlen(json), &m) == 0);
|
||||
assert(strcmp(m.version, "1.0.0") == 0);
|
||||
assert(strcmp(m.channel, "stable") == 0);
|
||||
char hex[65] = {0};
|
||||
assert(manifest_find(&m, "orbisrpc.bin", hex) == 0);
|
||||
assert(!strcmp(hex, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"));
|
||||
assert(manifest_find(&m, "nope.bin", hex) != 0);
|
||||
assert(manifest_gate(&m) == 1);
|
||||
assert(manifest_is_newer(&m, "0.9.0") == 1);
|
||||
assert(manifest_is_newer(&m, "1.0.0") == 0);
|
||||
/* wrong channel / platform refused */
|
||||
const char *bad = "{\"version\":\"9.9.9\",\"channel\":\"beta\","
|
||||
"\"platform\":\"ps5\",\"assets\":[{\"name\":\"x.bin\","
|
||||
"\"sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}]}";
|
||||
manifest_t m2;
|
||||
assert(manifest_parse(bad, strlen(bad), &m2) == 0);
|
||||
assert(manifest_gate(&m2) == 0);
|
||||
/* malformed rejected */
|
||||
assert(manifest_parse("{}", 2, &m) != 0);
|
||||
assert(manifest_parse("not json", 8, &m) != 0);
|
||||
/* hash check: real sha256 of "abc" must match */
|
||||
const char *jh = "{\"version\":\"1\",\"assets\":[{\"name\":\"a\","
|
||||
"\"sha256\":\"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad\"}]}";
|
||||
manifest_t m3;
|
||||
assert(manifest_parse(jh, strlen(jh), &m3) == 0);
|
||||
assert(manifest_check(&m3, "a", (const unsigned char *)"abc", 3) == 0);
|
||||
assert(manifest_check(&m3, "a", (const unsigned char *)"abd", 3) != 0);
|
||||
}
|
||||
|
||||
static void test_manifest_sig(void) {
|
||||
/* Full round trip with a fresh keypair: sign via mbedTLS, verify via
|
||||
* our public-API-only manifest_verify_sig. Tampered bytes must fail.
|
||||
* Uses only public 3.x APIs (raw group + MPIs, no context internals). */
|
||||
static const unsigned char msg[] = "{\"version\":\"9.9.9\"}";
|
||||
mbedtls_entropy_context ent;
|
||||
mbedtls_entropy_init(&ent);
|
||||
mbedtls_ctr_drbg_context rng;
|
||||
mbedtls_ctr_drbg_init(&rng);
|
||||
assert(mbedtls_ctr_drbg_seed(&rng, mbedtls_entropy_func, &ent,
|
||||
(const unsigned char *)"test", 4) == 0);
|
||||
mbedtls_ecp_group grp;
|
||||
mbedtls_ecp_group_init(&grp);
|
||||
assert(mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1) == 0);
|
||||
mbedtls_mpi d, r, s;
|
||||
mbedtls_mpi_init(&d); mbedtls_mpi_init(&r); mbedtls_mpi_init(&s);
|
||||
mbedtls_ecp_point Q;
|
||||
mbedtls_ecp_point_init(&Q);
|
||||
assert(mbedtls_ecp_gen_keypair(&grp, &d, &Q,
|
||||
mbedtls_ctr_drbg_random, &rng) == 0);
|
||||
unsigned char hash[32], sig[64], rawpub[64];
|
||||
{
|
||||
mbedtls_sha256_context sc;
|
||||
mbedtls_sha256_init(&sc);
|
||||
assert(mbedtls_sha256_starts(&sc, 0) == 0);
|
||||
assert(mbedtls_sha256_update(&sc, msg, sizeof msg - 1) == 0);
|
||||
assert(mbedtls_sha256_finish(&sc, hash) == 0);
|
||||
mbedtls_sha256_free(&sc);
|
||||
}
|
||||
assert(mbedtls_ecdsa_sign(&grp, &r, &s, &d, hash, sizeof hash,
|
||||
mbedtls_ctr_drbg_random, &rng) == 0);
|
||||
assert(mbedtls_mpi_write_binary(&r, sig, 32) == 0);
|
||||
assert(mbedtls_mpi_write_binary(&s, sig + 32, 32) == 0);
|
||||
/* export X||Y via the public point-write API */
|
||||
{
|
||||
unsigned char uncomp[65];
|
||||
size_t olen = 0;
|
||||
assert(mbedtls_ecp_point_write_binary(&grp, &Q,
|
||||
MBEDTLS_ECP_PF_UNCOMPRESSED, &olen, uncomp, sizeof uncomp) == 0);
|
||||
assert(olen == 65 && uncomp[0] == 0x04);
|
||||
memcpy(rawpub, uncomp + 1, 64);
|
||||
}
|
||||
assert(manifest_verify_sig(msg, sizeof msg - 1, sig, rawpub) == 0);
|
||||
sig[10] ^= 0x01;
|
||||
assert(manifest_verify_sig(msg, sizeof msg - 1, sig, rawpub) != 0);
|
||||
sig[10] ^= 0x01;
|
||||
unsigned char bad[sizeof msg];
|
||||
memcpy(bad, msg, sizeof bad);
|
||||
bad[5] ^= 0x01;
|
||||
assert(manifest_verify_sig(bad, sizeof bad - 1, sig, rawpub) != 0);
|
||||
assert(manifest_verify_sig(NULL, 0, sig, rawpub) != 0);
|
||||
mbedtls_mpi_free(&d); mbedtls_mpi_free(&r); mbedtls_mpi_free(&s);
|
||||
mbedtls_ecp_point_free(&Q);
|
||||
mbedtls_ecp_group_free(&grp);
|
||||
mbedtls_ctr_drbg_free(&rng);
|
||||
mbedtls_entropy_free(&ent);
|
||||
}
|
||||
|
||||
static void test_base64(void) {
|
||||
char out[32];
|
||||
assert(b64_encode((const unsigned char *)"", 0, out) == 0);
|
||||
@@ -524,10 +689,16 @@ static void test_cfg_titles(void) {
|
||||
assert(c.n_titles == 0);
|
||||
/* home_art is the legacy fallback; large_art is what actually gets used.
|
||||
* Both default to the operator-hosted idle logo. */
|
||||
assert(!strcmp(c.home_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png"));
|
||||
assert(!strcmp(c.large_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-full.png"));
|
||||
assert(!strcmp(c.small_art, "https://retro-games.cybermask.dpdns.org/images/ps-logo-blue.png"));
|
||||
assert(!strcmp(c.browser_art, "https://retro-games.cybermask.dpdns.org/images/web_browser.png"));
|
||||
assert(!strcmp(c.home_art,
|
||||
"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-full.png"));
|
||||
assert(!strcmp(c.large_art,
|
||||
"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-full.png"));
|
||||
/* was ps-logo-blue.png, which exists in no repo; ps-logo-small.png is the
|
||||
* replacement and is the only small-tile image committed. */
|
||||
assert(!strcmp(c.small_art,
|
||||
"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/ps-logo-small.png"));
|
||||
assert(!strcmp(c.browser_art,
|
||||
"https://raw.githubusercontent.com/SirHumza/orbisRPC/refs/heads/main/config/images/icons/web_browser.png"));
|
||||
}
|
||||
|
||||
/* Test-only VFS shim. sqlite's DbPath-based xFullPathname can fail with a
|
||||
@@ -1494,6 +1665,11 @@ int main(void) {
|
||||
test_json();
|
||||
test_gateway_op_spoof();
|
||||
test_ws_skip_plan();
|
||||
test_ws_drain_length();
|
||||
test_ws_env_renders_parseable();
|
||||
test_ws_env();
|
||||
test_ws_env_early_report();
|
||||
test_ws_skip_plan_early();
|
||||
test_json_oom_safe();
|
||||
test_json_hostile();
|
||||
test_tmdb();
|
||||
@@ -1502,9 +1678,6 @@ int main(void) {
|
||||
test_base64();
|
||||
test_art_parse();
|
||||
test_health_safe_mode();
|
||||
test_health_stage_activate();
|
||||
test_manifest();
|
||||
test_manifest_sig();
|
||||
test_cfg_titles();
|
||||
test_cfg_learn();
|
||||
test_installer_cfg();
|
||||
|
||||
Reference in new issue
Block a user