3 Commits
Author SHA1 Message Date
SirHumza 8917270f36 fix: README logo pointed at missing path, use config/icons/pslogo.png 2026-10-10 13:21:46 +02:00
SirHumza 45aec45abd Merge branch 'pr-35-cybermask' 2026-10-10 13:12:04 +02:00
CyberMask367 bfbe227a67 Simplify updates and harden websocket drains
This removes the old signed manifest staging flow and makes upgrades check-only notifications, while deleting dead manifest/release-key code and switching default art URLs to the GitHub raw asset pack. It also fixes oversized Discord READY handling by streaming envelope extraction instead of buffering giant frames, avoids repeated reconnect notifications, and updates host tests/build scripts to reflect the new no-install updater path.
2026-10-10 03:25:25 +01:00
23 changed files with 811 additions and 697 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
-45
View File
@@ -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;
}
-12
View File
@@ -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);
-176
View File
@@ -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;
}
-49
View File
@@ -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
-22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+146
View File
@@ -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;
}
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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) ==="
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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();