diff --git a/config/config.json b/config/config.json index b83757f..6b50931 100644 --- a/config/config.json +++ b/config/config.json @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/orbisrpc/cfg.c b/orbisrpc/cfg.c index 3be1857..af88dd9 100644 --- a/orbisrpc/cfg.c +++ b/orbisrpc/cfg.c @@ -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 diff --git a/orbisrpc/daemon.c b/orbisrpc/daemon.c index b5edfda..ba50a1e 100644 --- a/orbisrpc/daemon.c +++ b/orbisrpc/daemon.c @@ -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 .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; diff --git a/orbisrpc/discord.c b/orbisrpc/discord.c index 6b0f666..f452747 100644 --- a/orbisrpc/discord.c +++ b/orbisrpc/discord.c @@ -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 */ diff --git a/orbisrpc/health.c b/orbisrpc/health.c index dda4549..3fee110 100644 --- a/orbisrpc/health.c +++ b/orbisrpc/health.c @@ -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; -} diff --git a/orbisrpc/health.h b/orbisrpc/health.h index 031bb5e..1e3d47f 100644 --- a/orbisrpc/health.h +++ b/orbisrpc/health.h @@ -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 from .bak. 0 ok, -1 failed/none. */ -int health_rollback(const char *path); -/* Verify + activate a staged ".new" over with backup. - * Returns 0 activated, -1 refused (missing/invalid; .new removed, - * live 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 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); diff --git a/orbisrpc/manifest.c b/orbisrpc/manifest.c deleted file mode 100644 index 66e1c9c..0000000 --- a/orbisrpc/manifest.c +++ /dev/null @@ -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 -#include -#include -#include -#include - -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": "", ... } */ - /* 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; -} diff --git a/orbisrpc/manifest.h b/orbisrpc/manifest.h deleted file mode 100644 index 476394e..0000000 --- a/orbisrpc/manifest.h +++ /dev/null @@ -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 - -#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 diff --git a/orbisrpc/release_pubkey.h b/orbisrpc/release_pubkey.h deleted file mode 100644 index ddac819..0000000 --- a/orbisrpc/release_pubkey.h +++ /dev/null @@ -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 diff --git a/orbisrpc/retro.c b/orbisrpc/retro.c index b4e90d8..c116519 100644 --- a/orbisrpc/retro.c +++ b/orbisrpc/retro.c @@ -13,7 +13,12 @@ #include #include -#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); diff --git a/orbisrpc/updater.c b/orbisrpc/updater.c index d206f7a..17e030e 100644 --- a/orbisrpc/updater.c +++ b/orbisrpc/updater.c @@ -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//releases/latest - * -> tag_name + asset browser_download_urls - * if tag newer than ORBISRPC_VERSION: - * download each known asset (cap 4MB), validate ELF magic, - * write .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 #include @@ -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 .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; } diff --git a/orbisrpc/updater.h b/orbisrpc/updater.h index 5c5f95c..9a687e7 100644 --- a/orbisrpc/updater.h +++ b/orbisrpc/updater.h @@ -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 @@ -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 diff --git a/orbisrpc/ws.c b/orbisrpc/ws.c index f49f8c3..60dbf44 100644 --- a/orbisrpc/ws.c +++ b/orbisrpc/ws.c @@ -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; diff --git a/orbisrpc/ws.h b/orbisrpc/ws.h index 9cf294b..b18a5c9 100644 --- a/orbisrpc/ws.h +++ b/orbisrpc/ws.h @@ -5,18 +5,25 @@ #define WS_H #include #include +#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 diff --git a/orbisrpc/ws_env.c b/orbisrpc/ws_env.c new file mode 100644 index 0000000..e4fa3ee --- /dev/null +++ b/orbisrpc/ws_env.c @@ -0,0 +1,146 @@ +/* ws_env.c - see ws_env.h. Pure; no sockets, no TLS. */ +#include "ws_env.h" +#include +#include + +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; +} diff --git a/orbisrpc/ws_env.h b/orbisrpc/ws_env.h new file mode 100644 index 0000000..03baee0 --- /dev/null +++ b/orbisrpc/ws_env.h @@ -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 + +/* 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 */ \ No newline at end of file diff --git a/orbisrpc/ws_skip.c b/orbisrpc/ws_skip.c index 3d600c1..fcb8f5c 100644 --- a/orbisrpc/ws_skip.c +++ b/orbisrpc/ws_skip.c @@ -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; } \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 359e635..2189af3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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) ===" diff --git a/scripts/build_sdk.sh b/scripts/build_sdk.sh index 0c3d3d6..6dd6710 100755 --- a/scripts/build_sdk.sh +++ b/scripts/build_sdk.sh @@ -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; } diff --git a/tests/Makefile b/tests/Makefile index 52705c3..fc4c54d 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -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) diff --git a/tests/test_utils.c b/tests/test_utils.c index fdb099c..f7028a4 100644 --- a/tests/test_utils.c +++ b/tests/test_utils.c @@ -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 #include #include @@ -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();