Files
SirHumza--orbisRPC/tests/test_utils.c
T
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

1689 lines
72 KiB
C

#include "../orbisrpc/jsonlite.h"
#include "../orbisrpc/b64.h"
#include "../orbisrpc/sfo.h"
#include "../orbisrpc/tmdb_crypto.h"
#include "../orbisrpc/updater.h"
#include "../orbisrpc/art.h"
#include "../orbisrpc/health.h"
#include "../orbisrpc/cfg.h"
#include "../orbisrpc/appdb.h"
#include "../orbisrpc/discord.h"
#include "../orbisrpc/detect.h"
#include "../orbisrpc/procwalk.h"
#include "../orbisrpc/bigapp.h"
#include "../orbisrpc/retro.h"
#include "../orbisrpc/pkgzone.h"
#include "../orbisrpc/gamecache.h"
#include "../orbisrpc/fw.h"
#include "../orbisrpc/ws.h"
#include "../orbisrpc/gamecache.h"
#include "../orbisrpc/pkgzone.h"
#include "../installer/icfg.h"
#include "sqlite3.h"
#include <string.h>
/* discord.c host stubs: builder tests never touch the wire. */
int detect_media_type(const char *t){
if(t && !strcmp(t, "CUSA00127")) return 3;
return 0;
}
int ws_connect(ws_t *w, const char *h, int p, const char *r, const char *k){
(void)w; (void)h; (void)p; (void)r; (void)k; return -1;
}
int ws_send_text(ws_t *w, const char *m, size_t n){
(void)w; (void)m; (void)n; return -1;
}
int ws_recv_frame(ws_t *w, char *b, size_t c, int *o, int *f){
(void)w; (void)b; (void)c; (void)o; (void)f; return -1;
}
int ws_pong(ws_t *w){ (void)w; return -1; }
int ws_close(ws_t *w){ (void)w; return -1; }
/* ws.c itself is not linked on the host (it owns a tls_ctx_t), so the pure
* 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>
#include <mbedtls/entropy.h>
#include <mbedtls/sha256.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/stat.h>
/* Portable temp dir (mkdtemp needs feature macros this toolchain lacks). */
static int make_tmpdir(char *out, size_t cap){
static int seq = 0;
snprintf(out, cap, "/tmp/orx_test_%d_%d", (int)getpid(), seq++);
if(mkdir(out, 0700) != 0) return -1;
return 0;
}
static void test_json(void) {
const char input[] = "{\"name\":\"A\\u00e9\",\"items\":[true,2,null]}";
jl_val_t *root = jl_parse(input, sizeof(input) - 1);
assert(root && root->type == JL_OBJECT);
assert(strcmp(jl_obj_get(root, "name")->str, "A\xc3\xa9") == 0);
assert(jl_arr_at(jl_obj_get(root, "items"), 0)->num == 1);
assert(jl_arr_at(jl_obj_get(root, "items"), 1)->num == 2);
assert(jl_arr_at(jl_obj_get(root, "items"), 2)->type == JL_NULL);
jl_free(root);
assert(jl_parse("{} trailing", 11) == NULL);
assert(jl_parse("[1,]", 4) == NULL);
assert(jl_parse("\"unterminated", 13) == NULL);
}
static void test_gateway_op_spoof(void) {
/* string value containing `"op":` must not spoof the real op */
const char input[] = "{\"note\":\"x \\\"op\\\":99 y\",\"op\":10,\"s\":42}";
jl_val_t *r = jl_parse(input, sizeof(input)-1);
assert(r && r->type == JL_OBJECT);
const jl_val_t *op = jl_obj_get(r, "op");
const jl_val_t *s = jl_obj_get(r, "s");
assert(op && op->type == JL_NUMBER && (int)op->num == 10);
assert(s && s->type == JL_NUMBER && (int)s->num == 42);
jl_free(r);
/* missing op -> NULL, not crash */
const char no_op[] = "{\"t\":\"READY\"}";
jl_val_t *r2 = jl_parse(no_op, sizeof(no_op)-1);
assert(r2 && jl_obj_get(r2, "op") == NULL);
jl_free(r2);
}
/* --- oversized-frame drain arithmetic ---------------------------------
* A real account's READY measured 11.7 MB (four attempts, 11695449 /
* 11694320 / 11694256 / 11706895), past the old 8 MB cap, so identify never
* completed. The drain then left the socket mid-frame and the next parse
* read JSON payload as a header (op=2 fin=0 plen=125 b1=0x22 -- a quote).
* ws_skip_plan is the pure piece, so the silent failure modes are pinned
* here instead of on the console. */
static void test_ws_skip_plan(void) {
uint64_t left = 0xdeadbeef;
/* The real READY, header just eaten, nothing of the body buffered. */
ws_skip_plan(11695449, 0, &left);
assert(left == 11695449);
/* Part of the body already buffered: drain only the remainder. */
ws_skip_plan(11695449, 65536, &left);
assert(left == 11695449 - 65536);
/* Body fully buffered already -> nothing left to discard. This is the
* saturation case; the old bare subtraction underflowed to ~1.8e19 here
* and the drain never terminated. */
ws_skip_plan(1000, 1000, &left);
assert(left == 0);
/* More buffered than the header claimed: must still saturate, never wrap. */
ws_skip_plan(1000, 5000, &left);
assert(left == 0);
ws_skip_plan(100, (size_t)-1, &left);
assert(left == 0);
/* Draining to exactly zero must land on zero, not one byte over. */
ws_skip_plan(2048, 2047, &left);
assert(left == 1);
ws_skip_plan(2048, 2048, &left);
assert(left == 0);
/* The cap the console actually hits: 11.7 MB frame, 32 MB buffer. */
ws_skip_plan(11695449, 32768, &left);
assert(left == 11695449 - 32768);
/* A frame larger than even the raised cap still drains sanely. */
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);
/* The cap must actually be above the measured READY, or every one of
* these scenarios returns to the console as a failed connect. */
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);
jl_val_t *z = jl_parse("null", 4); assert(z != NULL); jl_free(z);
jl_val_t *n = jl_parse("123.5", 5);
assert(n && n->type == JL_NUMBER);
jl_free(n);
/* incomplete pair must fail cleanly, no leak/crash */
assert(jl_parse("{\"a\":", 5) == NULL);
assert(jl_parse("{\"a\":1", 6) == NULL);
/* oversize input refused before any allocation */
assert(jl_parse("[]", 5u*1024u*1024u) == NULL);
}
static void test_json_hostile(void) {
/* 200-deep nesting must be rejected, not stack-smash */
char deep[420];
memset(deep, '[', 200);
memset(deep+200, ']', 200);
deep[400] = 0;
assert(jl_parse(deep, 400) == NULL);
/* 60-deep is fine */
char okd[130];
memset(okd, '[', 60);
memset(okd+60, ']', 60);
okd[120] = 0;
jl_val_t *r = jl_parse(okd, 120);
assert(r && r->type == JL_ARRAY);
jl_free(r);
/* number running exactly to buffer end (no NUL past it) */
char num[16];
memcpy(num, "{\"s\":41250}", 11);
jl_val_t *r2 = jl_parse(num, 11);
assert(r2);
assert(jl_obj_get(r2, "s")->num == 41250);
jl_free(r2);
/* absurd number token fails cleanly */
char big[80];
memset(big, '9', 70);
big[70] = 0;
assert(jl_parse(big, 70) == NULL);
}
static void test_sfo(void) {
/* minimal synthetic param.sfo: header + 1 entry (TITLE="Terraria") */
unsigned char sfo[128];
char out[64];
memset(sfo, 0, sizeof sfo);
sfo[0]=0x00; sfo[1]='P'; sfo[2]='S'; sfo[3]='F';
sfo[4]=0x01; sfo[5]=0x02;
sfo[8]=36; sfo[12]=48; sfo[16]=1; /* keys@36 data@48 */
sfo[20]=0; sfo[21]=0; sfo[22]=0x04; sfo[23]=0; /* key_off=0 fmt=0x0004 */
sfo[24]=9; sfo[28]=16; sfo[32]=0; /* len=9 max=16 data_off=0 */
memcpy(sfo+36, "TITLE", 6);
memcpy(sfo+48, "Terraria", 9);
assert(sfo_title(sfo, 64, out, sizeof out) == 0);
assert(strcmp(out, "Terraria") == 0);
/* malformed: bad magic, truncated, insane count, OOB offsets */
unsigned char bad[64];
memset(bad, 0, sizeof bad);
assert(sfo_title(bad, sizeof bad, out, sizeof out) != 0);
assert(sfo_title(sfo, 10, out, sizeof out) != 0);
sfo[16]=200; /* count overflow */
assert(sfo_title(sfo, 64, out, sizeof out) != 0);
sfo[16]=1; sfo[8]=200; /* key_off OOB */
assert(sfo_title(sfo, 64, out, sizeof out) != 0);
sfo[8]=36;
/* tiny output buffer still safe */
assert(sfo_title(sfo, 64, out, 4) == 0);
assert(out[3] == 0);
/* unterminated key region: must fail, not read OOB */
memset(sfo, 0x41, sizeof sfo);
sfo[0]=0x00; sfo[1]='P'; sfo[2]='S'; sfo[3]='F';
sfo[8]=36; sfo[12]=48; sfo[16]=1;
sfo[20]=0; sfo[22]=0x04; sfo[24]=9; sfo[28]=16; sfo[32]=0;
assert(sfo_title(sfo, 64, out, sizeof out) != 0);
}
static void test_tmdb(void) {
unsigned char dig[20];
char hex[41];
int i;
/* SHA1("abc") = a9993e364706816aba3e25717850c26c9cd0d4d */
tmdb_sha1((const unsigned char *)"abc", 3, dig);
for(i=0;i<20;i++) snprintf(hex+2*i, 3, "%02x", dig[i]);
assert(strcmp(hex, "a9993e364706816aba3e25717850c26c9cd0d89d") == 0);
/* HMAC-SHA1 RFC 2202 case 1 */
{
unsigned char key[20];
memset(key, 0x0b, 20);
tmdb_hmac_sha1(key, 20, (const unsigned char *)"Hi There", 8, dig);
for(i=0;i<20;i++) snprintf(hex+2*i, 3, "%02x", dig[i]);
assert(strcmp(hex, "b617318655057264e28bc0b6fb378c8ef146be00") == 0);
}
/* URL path must match the hash Sony's live service accepts */
{
char path[128];
assert(tmdb_path("CUSA00740", path, sizeof path) == 0);
assert(strcmp(path, "/tmdb2/CUSA00740_00_95C83DE844D155477CB77C577A24D735F2E9AC08/CUSA00740_00.json") == 0);
assert(tmdb_path("junk!", path, sizeof path) != 0);
assert(tmdb_path("CUSA00740", path, 10) != 0);
}
/* response parse: name + icon URL.
* Sony's icon CDN still answers in http on some titles; Discord silently
* drops an activity whose artwork is a non-https external URL, so the
* scheme is upgraded rather than the asset being discarded. */
{
const char body[] = "{\"names\":[{\"name\":\"Terraria\"}],\"icons\":[{\"icon\":\"http://x/y/icon0.png\",\"type\":\"512x512\"}]}";
char name[64], icon[128];
assert(tmdb_parse(body, sizeof(body)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(strcmp(name, "Terraria") == 0);
assert(strcmp(icon, "https://x/y/icon0.png") == 0);
/* already https: untouched */
const char body3[] = "{\"names\":[{\"name\":\"T\"}],\"icons\":[{\"icon\":\"https://x/y/i.png\"}]}";
assert(tmdb_parse(body3, sizeof(body3)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(strcmp(icon, "https://x/y/i.png") == 0);
/* gs2 CDN form from the reference client */
const char body4[] = "{\"names\":[{\"name\":\"T\"}],\"icons\":[{\"icon\":\"http://gs2-sec.ww.prod.dl.playstation.net/a/b.png\"}]}";
assert(tmdb_parse(body4, sizeof(body4)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(strcmp(icon, "https://gs2-sec.ww.prod.dl.playstation.net/a/b.png") == 0);
/* non-URL icon rejected, name still wins */
const char body2[] = "{\"names\":[{\"name\":\"X\"}],\"icons\":[{\"icon\":\"not a url\"}]}";
assert(tmdb_parse(body2, sizeof(body2)-1, name, sizeof name, icon, sizeof icon) == 0);
assert(icon[0] == 0);
/* no names -> fail */
assert(tmdb_parse("{\"icons\":[]}", 12, name, sizeof name, icon, sizeof icon) != 0);
}
}
static void test_updater(void) {
assert(updater_cmp("0.4.0", "0.4.0") == 0);
assert(updater_cmp("v0.4.1", "0.4.0") > 0);
assert(updater_cmp("0.4.0", "v0.4.1") < 0);
assert(updater_cmp("0.10.0", "0.9.9") > 0);
assert(updater_cmp("1.0", "1.0.0") == 0);
assert(updater_cmp(NULL, "0.1") < 0);
unsigned char elf[64];
memset(elf, 0, sizeof elf);
assert(updater_elf_ok(elf, sizeof elf) == 0);
assert(updater_elf_ok(NULL, 100) == 0);
assert(updater_elf_ok(elf, 10) == 0);
elf[0]=0x7f; elf[1]='E'; elf[2]='L'; elf[3]='F';
elf[4]=2; elf[5]=1; elf[18]=62; elf[19]=0;
assert(updater_elf_ok(elf, sizeof elf) == 1);
elf[18]=99;
assert(updater_elf_ok(elf, sizeof elf) == 0);
}
static void test_art_parse(void) {
/* Real external-assets response shape (verified against live API). */
const char *body = "[{\"url\":\"https://example.com/a.png\","
"\"external_asset_path\":\"external/ABC/https/example.com/a.png\"},"
"{\"url\":\"https://example.com/b.png\","
"\"external_asset_path\":\"external/DEF/https/example.com/b.png\"}]";
char out[128] = {0};
assert(art_parse_mp(body, strlen(body),
"https://example.com/b.png", out, sizeof out) == 1);
assert(strcmp(out, "mp:external/DEF/https/example.com/b.png") == 0);
assert(art_parse_mp(body, strlen(body),
"https://example.com/zzz.png", out, sizeof out) == 0);
assert(art_parse_mp("[]", 2, "x", out, sizeof out) == 0);
assert(art_parse_mp(NULL, 0, "x", out, sizeof out) == 0);
assert(art_resolve_mp("1", "t", "https://example.com/a.png", out, sizeof out) == 0);
}
static void test_health_safe_mode(void) {
/* Unclean-boot marker semantics: normal reboots never count. */
char dir[64];
assert(make_tmpdir(dir, sizeof dir) == 0);
health_set_base(dir);
health_mark_clean();
/* clean boot x3: counter stays 0, never safe mode */
assert(health_boot_note_crash() == 0);
health_mark_healthy();
assert(health_boot_note_crash() == 0);
health_mark_healthy();
assert(health_boot_note_crash() == 0);
/* now crash repeatedly WITHOUT going healthy: 1, 2, then safe */
assert(health_boot_note_crash() == 0); /* count=1 */
assert(health_boot_note_crash() == 0); /* count=2 */
assert(health_boot_note_crash() == 1); /* count=3 -> safe mode */
/* recovery clears */
health_mark_healthy();
assert(health_boot_note_crash() == 0);
health_mark_healthy();
}
static void test_base64(void) {
char out[32];
assert(b64_encode((const unsigned char *)"", 0, out) == 0);
assert(strcmp(out, "") == 0);
assert(b64_encode((const unsigned char *)"f", 1, out) == 4);
assert(strcmp(out, "Zg==") == 0);
assert(b64_encode((const unsigned char *)"fo", 2, out) == 4);
assert(strcmp(out, "Zm8=") == 0);
assert(b64_encode((const unsigned char *)"foo", 3, out) == 4);
assert(strcmp(out, "Zm9v") == 0);
}
static void test_cfg_titles(void) {
char dir[64], path[96], path2[96];
assert(make_tmpdir(dir, sizeof dir) == 0);
snprintf(path, sizeof path, "%s/cfg.json", dir);
snprintf(path2, sizeof path2, "%s/cfg2.json", dir);
FILE *f = fopen(path, "wb");
assert(f);
fputs("{\"token\":\"t\",\"titles\":{\"CUSA11995\":\"Marvel's Spider-Man\","
"\"BAD KEY!\": \"junk\", \"CUSA00001\": \"x\", \"TOOLONGTITLEID12345\": \"y\","
"\"CUSA00002\": 42}}", f);
fclose(f);
cfg_t c;
assert(cfg_load(path, &c) == 0);
assert(c.n_titles == 1); /* only the well-formed entry survives */
char name[128];
assert(cfg_title(&c, "CUSA11995", name, sizeof name) == 0);
assert(!strcmp(name, "Marvel's Spider-Man"));
assert(cfg_title(&c, "CUSA99999", name, sizeof name) != 0);
assert(cfg_title(&c, "", name, sizeof name) != 0);
assert(cfg_title(NULL, "CUSA11995", name, sizeof name) != 0);
/* round-trip: save preserves overrides + home_art */
strncpy(c.home_art, "pslogo", sizeof c.home_art - 1);
assert(cfg_save(path2, &c) == 0);
cfg_t c2;
assert(cfg_load(path2, &c2) == 0);
assert(c2.n_titles == 1);
assert(cfg_title(&c2, "CUSA11995", name, sizeof name) == 0);
assert(!strcmp(name, "Marvel's Spider-Man"));
assert(!strcmp(c2.home_art, "pslogo"));
/* missing titles object: zero overrides, lookup misses */
f = fopen(path, "wb");
assert(f);
fputs("{\"token\":\"t\"}", f);
fclose(f);
assert(cfg_load(path, &c) == 0);
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://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
* silent SQLITE_CANTOPEN (no syscall, no errno, nothing logged) on some
* runners. Our test paths are always absolute, so bypass it with a plain
* copy and delegate everything else to the real unix VFS. */
static sqlite3_vfs g_orx_wrap;
static int g_orx_installed = 0;
static int orx_fullpath(sqlite3_vfs *p, const char *z, int n, char *o){
size_t len = strlen(z) + 1;
(void)p;
if (len > (size_t)n) return SQLITE_CANTOPEN;
memcpy(o, z, len);
return SQLITE_OK;
}
static void orx_vfs_install(void){
sqlite3_vfs *under;
if (g_orx_installed) return;
g_orx_installed = 1;
(void)sqlite3_initialize();
under = sqlite3_vfs_find(0);
if (!under) return;
g_orx_wrap = *under; /* inherit szOsFile, mxPathname, all methods */
g_orx_wrap.pNext = 0; /* must not link into the old registry list */
g_orx_wrap.zName = "orxtest";
g_orx_wrap.xFullPathname = orx_fullpath;
(void)sqlite3_vfs_register(&g_orx_wrap, 1); /* default for this process */
}
static void test_appdb(void) {
char dir[64], db[96], meta[96], pj[160];
orx_vfs_install();
assert(make_tmpdir(dir, sizeof dir) == 0);
snprintf(db, sizeof db, "%s/app.db", dir);
/* Brand-new dir, so a pre-existing app.db is a stale artifact. sqlite
* opens with O_NOFOLLOW and fails ELOOP on a symlink where plain
* open() would follow it — clear the path before opening. */
unlink(db);
/* Pre-create so sqlite never exercises its CREATE branch; the test
* validates appdb reads/writes on a real file either way. */
{ int prefd = open(db, O_RDWR|O_CREAT|O_TRUNC, 0600); if (prefd >= 0) close(prefd); }
snprintf(meta, sizeof meta, "%s/appmeta", dir);
assert(mkdir(meta, 0700) == 0);
sqlite3 *s = NULL;
(void)sqlite3_initialize();
int rc = sqlite3_open_v2(db, &s, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE, NULL);
if (rc != SQLITE_OK) {
fprintf(stderr, "DEBUG sqlite3_open_v2 rc=%d db=%s err=%s\n", rc, db, s ? sqlite3_errmsg(s) : "null");
sqlite3_close(s);
}
assert(rc == SQLITE_OK);
assert(sqlite3_exec(s, "CREATE TABLE tbl_appbrowse(titleId TEXT, titleName TEXT);", 0, 0, 0) == SQLITE_OK);
assert(sqlite3_exec(s, "INSERT INTO tbl_appbrowse VALUES('CUSA11995','Marvel''s Spider-Man');", 0, 0, 0) == SQLITE_OK);
assert(sqlite3_exec(s, "CREATE TABLE tbl_appinfo(titleId TEXT, key TEXT, val TEXT);", 0, 0, 0) == SQLITE_OK);
assert(sqlite3_exec(s, "INSERT INTO tbl_appinfo VALUES('CUSA00001','TITLE_01','Lang One');", 0, 0, 0) == SQLITE_OK);
assert(sqlite3_exec(s, "INSERT INTO tbl_appinfo VALUES('CUSA00001','TITLE','Base Name');", 0, 0, 0) == SQLITE_OK);
assert(sqlite3_exec(s, "INSERT INTO tbl_appinfo VALUES('CUSA00002','TITLE','');", 0, 0, 0) == SQLITE_OK);
sqlite3_close(s);
char name[128];
/* tier 1: browse name wins */
assert(appdb_title_from(db, meta, "CUSA11995", name, sizeof name) == 0);
assert(!strcmp(name, "Marvel's Spider-Man"));
/* tier 2: bare TITLE preferred over TITLE_01 */
assert(appdb_title_from(db, meta, "CUSA00001", name, sizeof name) == 0);
assert(!strcmp(name, "Base Name"));
/* empty val rows never match; no param.json staged -> miss */
assert(appdb_title_from(db, meta, "CUSA00002", name, sizeof name) != 0);
assert(appdb_title_from(db, meta, "CUSA99999", name, sizeof name) != 0);
/* tier 3: staged param.json */
char gdir[128];
snprintf(gdir, sizeof gdir, "%s/CUSA00002", meta);
assert(mkdir(gdir, 0700) == 0);
snprintf(pj, sizeof pj, "%s/param.json", gdir);
FILE *f = fopen(pj, "wb");
assert(f);
fputs("{\"titleId\":\"CUSA00002\",\"titleName\":\"JSON Game\"}", f);
fclose(f);
assert(appdb_title_from(db, meta, "CUSA00002", name, sizeof name) == 0);
assert(!strcmp(name, "JSON Game"));
/* invalid inputs fail soft */
assert(appdb_title_from(db, meta, NULL, name, sizeof name) != 0);
assert(appdb_title_from(db, meta, "short", name, sizeof name) != 0);
assert(appdb_title_from(db, meta, "cusa11995", name, sizeof name) != 0);
assert(appdb_title_from(db, meta, "CUSA1199!", name, sizeof name) != 0);
assert(appdb_title_from("/nonexistent/app.db", meta, "CUSA11995", name, sizeof name) != 0);
assert(appdb_title_from(db, meta, "CUSA11995", NULL, 0) != 0);
}
static void test_discord_builder(void) {
/* game presence: name shown, raw ID nowhere visible, hover has the ID */
jl_val_t *a = discord_build_activity("On PS4", "Marvel's Spider-Man","Playing on PlayStation 4","ps_logo_blue",NULL,
"CUSA11995", "1536977374795538532", "https://x/icons/", NULL, NULL,
1700000000LL, "");
assert(a);
const jl_val_t *v = jl_obj_get(a, "name");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Marvel's Spider-Man"));
v = jl_obj_get(a, "details");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Playing on PlayStation 4"));
assert(!strstr(v->str, "CUSA")); /* never the raw ID */
v = jl_obj_get(a, "state");
assert(v && v->type == JL_STRING && !strcmp(v->str, "On PS4"));
v = jl_obj_get(a, "type");
assert(v && v->type == JL_NUMBER && (int)v->num == 0);
const jl_val_t *ts = jl_obj_get(a, "timestamps");
assert(ts && ts->type == JL_OBJECT);
v = jl_obj_get(ts, "start");
assert(v && v->type == JL_NUMBER && v->inum == 1700000000LL * 1000LL);
const jl_val_t *as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "large_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "cusa11995"));
v = jl_obj_get(as, "large_text");
assert(v && v->type == JL_STRING && !strcmp(v->str, "CUSA11995"));
assert(jl_obj_get(as, "small_image") == NULL); /* no badge requested */
jl_free(a);
/* system badge: mp: URL used as-is with platform hover text */
a = discord_build_activity("On PS4", "Marvel's Spider-Man","Playing on PlayStation 4","ps_logo_blue",NULL,
"CUSA11995", "1536977374795538532", NULL, NULL, "mp:1/2/logo",
1700000000LL, "");
assert(a);
as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "small_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "mp:1/2/logo"));
v = jl_obj_get(as, "small_text");
assert(v && v->type == JL_STRING && !strcmp(v->str, "PlayStation 4"));
jl_free(a);
/* media type mapping survives */
a = discord_build_activity(NULL, "YouTube","Watching on PlayStation 4","ps_logo_blue",NULL, "CUSA01015", "app",
NULL, NULL, NULL, 0, "");
assert(a);
v = jl_obj_get(a, "type");
assert(v && (int)v->num == 0); /* stub maps only CUSA00127 */
jl_free(a);
a = discord_build_activity(NULL, "Netflix","Watching on PlayStation 4","ps_logo_blue",NULL, "CUSA00127", "app",
NULL, NULL, NULL, 0, "");
assert(a);
v = jl_obj_get(a, "type");
assert(v && (int)v->num == 3);
jl_free(a);
/* home + uploaded key: trusted key used as-is */
a = discord_build_activity("On PS4", "PlayStation 4","Idling on Home Menu",NULL,NULL, "home", "app",
NULL, "pslogo", NULL, 0, "");
assert(a);
as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "large_image");
assert(v && !strcmp(v->str, "pslogo"));
v = jl_obj_get(as, "large_text");
assert(v && !strcmp(v->str, "PlayStation 4"));
jl_free(a);
/* home with nothing: no assets block at all (never dangling) */
a = discord_build_activity("On PS4", "PlayStation 4","Idling on Home Menu",NULL,NULL, "home", "app",
NULL, NULL, NULL, 0, "");
assert(a);
assert(jl_obj_get(a, "assets") == NULL);
jl_free(a);
/* NULL name rejected */
assert(discord_build_activity(NULL, NULL,"Idling on Home Menu",NULL,NULL, "home", "app", NULL, NULL, NULL, 0, "") == NULL);
}
static void test_cfg_learn(void) {
cfg_t c;
cfg_defaults(&c);
assert(c.n_titles == 0);
assert(cfg_learn(&c, "CUSA11995", "Marvel's Spider-Man") == 1);
assert(cfg_learn(&c, "CUSA11995", "Marvel's Spider-Man") == 0); /* dup */
assert(cfg_learn(&c, "CUSA11995", "CUSA11995") == 0); /* ID echo refused */
assert(cfg_learn(&c, "CUSA11995", "x") == 0); /* too short */
assert(cfg_learn(&c, "bad key!", "Name") == 0);
assert(cfg_learn(&c, "CUSA11995", "Spider-Man 2") == 1); /* update */
char name[128];
assert(cfg_title(&c, "CUSA11995", name, sizeof name) == 0);
assert(!strcmp(name, "Spider-Man 2"));
assert(cfg_learn(NULL, "CUSA11995", "N") == 0);
assert(cfg_learn(&c, NULL, "N") == 0);
/* full map refuses */
c.n_titles = CFG_MAX_TITLES;
assert(cfg_learn(&c, "CUSA00001", "Full Game") == 0);
}
static void test_installer_cfg(void) {
char dir[64], path[96];
assert(make_tmpdir(dir, sizeof dir) == 0);
snprintf(path, sizeof path, "%s/config.json", dir);
/* token validation */
assert(token_valid("MTIz.ABCdef_0123456789-xYz.ABCDEFGHijklmnopQRSTUVwxyz") == 1);
assert(token_valid("short") == 0);
assert(token_valid("SET_ME") == 0);
assert(token_valid("has space.in.it.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == 0);
assert(token_valid("nodots") == 0);
assert(token_valid(NULL) == 0);
/* save creates, preserves, round-trips */
assert(icfg_token_save(path, "MTIz.ABCdef_0123456789-xYz.ABCDEFGHijklmnopQRSTUVwxyz") == 0);
assert(icfg_set_str(path, "presence_state", "On PS4") == 0);
assert(icfg_set_int(path, "poll_interval_s", 12) == 0);
/* invalid token refused, file untouched */
assert(icfg_token_save(path, "junk") == -1);
char tok[160], st[64];
long n = 0;
assert(icfg_token_load(path, tok, sizeof tok) == 0);
assert(!strcmp(tok, "MTIz.ABCdef_0123456789-xYz.ABCDEFGHijklmnopQRSTUVwxyz"));
assert(icfg_get_str(path, "presence_state", st, sizeof st) == 0);
assert(!strcmp(st, "On PS4"));
assert(icfg_get_int(path, "poll_interval_s", &n) == 0 && n == 12);
/* missing file: loads fail soft, save still works */
assert(icfg_get_str("/nonexistent/x.json", "k", st, sizeof st) != 0);
}
/* --- procwalk: process-table record parsing ---------------------------
* detect.c used to hard-code "eboot.bin" at byte 447 and reject records
* under 479 bytes. Those are 9.00 numbers; on a firmware that shifts
* kinfo_proc the scan reported "no game running" forever and the daemon
* posted nothing. procwalk keeps the self-describing ki_structsize framing
* but searches for the name instead of trusting an offset, so these tests
* pin that down: the 9.00 layout must keep working, and shifted layouts
* must now be found. */
/* Write a synthetic kinfo_proc record: leading ki_structsize, then `comm`
* (NUL-terminated) at comm_off, zero padding out to recsz. Sizes are native
* endian ints -- PS4 and the host runner are both little-endian. The comm is
* only written when it fits entirely inside the record, which is how we build
* the deliberately-truncated-name case. */
static size_t put_rec(unsigned char *buf, size_t at, int recsz,
size_t comm_off, const char *comm) {
memset(buf + at, 0, (size_t)recsz);
memcpy(buf + at, &recsz, sizeof recsz);
if (comm && comm_off + strlen(comm) + 1 <= (size_t)recsz)
memcpy(buf + at + comm_off, comm, strlen(comm) + 1);
return at + (size_t)recsz;
}
static void put_i32(unsigned char *p, int v) { memcpy(p, &v, sizeof v); }
static void test_procwalk(void) {
unsigned char buf[4096];
size_t at;
/* The 9.00 layout this code was written against. Regression guard. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_has_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_first_structsize(buf, at) == 479);
/* A firmware that grew the record and moved the name. The old 447/479
* check missed every one of these and reported "no game running". */
at = put_rec(buf, 0, 512, 300, "SystemUI");
at = put_rec(buf, at, 736, 611, "eboot.bin");
at = put_rec(buf, at, 640, 200, "eapServer");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_has_comm(buf, at, PROCWALK_EBOOT) == 1);
assert(procwalk_first_structsize(buf, at) == 512);
/* Name shoved right up against the end of the record, terminator in the
* very last byte, still found. */
at = put_rec(buf, 0, 22, 12, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 1);
/* Two games up -> count 2. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
at = put_rec(buf, at, 479, 447, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 2);
/* No match at all is 0 (an error), not -1 (unknown). */
at = put_rec(buf, 0, 479, 447, "SystemUI");
at = put_rec(buf, at, 600, 500, "webShell");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 0);
assert(procwalk_has_comm(buf, at, PROCWALK_EBOOT) == 0);
/* Empty buffer. */
assert(procwalk_count_comm(buf, 0, PROCWALK_EBOOT) == 0);
assert(procwalk_has_comm(buf, 0, PROCWALK_EBOOT) == 0);
assert(procwalk_first_structsize(buf, 0) == 0);
/* Broken framing must report "unknown" (-1), never 0. Callers read 0 as
* "no game running" and would blank an active presence. */
memset(buf, 0, 16);
assert(procwalk_count_comm(buf, 16, PROCWALK_EBOOT) == -1);
assert(procwalk_has_comm(buf, 16, PROCWALK_EBOOT) == -1);
put_i32(buf, 479);
put_i32(buf + 479, -8);
at = put_rec(buf, 0, 479, 447, "eboot.bin");
put_i32(buf + at, -8);
assert(procwalk_count_comm(buf, at + 4, PROCWALK_EBOOT) == -1);
memset(buf, 0, 16);
put_i32(buf, 0);
assert(procwalk_count_comm(buf, 16, PROCWALK_EBOOT) == -1);
/* A final record cut short by the buffer is reported as unknown, never as
* the count of the records before it and never read past the end. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
at = put_rec(buf, at, 479, 447, "eboot.bin");
size_t second = at - 479;
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 2);
assert(procwalk_count_comm(buf, second + 300, PROCWALK_EBOOT) == -1);
assert(procwalk_count_comm(buf, second + 4, PROCWALK_EBOOT) == -1);
/* A name too long for its own record is not a match: it must not alias
* into whatever record follows it. */
at = put_rec(buf, 0, 16, 12, "eboot.bin");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 0);
/* Prefix of the needle is not a match ("eboot" != "eboot.bin"). */
at = put_rec(buf, 0, 479, 447, "eboot");
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == 0);
/* A record claiming to be larger than the buffer is malformed, not a
* clean truncation, so it reports unknown. */
at = put_rec(buf, 0, 479, 447, "eboot.bin");
put_i32(buf, 999999);
assert(procwalk_count_comm(buf, at, PROCWALK_EBOOT) == -1);
assert(procwalk_first_structsize(buf, at) == 0);
/* Bad arguments rejected rather than crashed on. */
assert(procwalk_count_comm(NULL, 16, PROCWALK_EBOOT) == -1);
assert(procwalk_count_comm(buf, 16, NULL) == -1);
assert(procwalk_count_comm(buf, 16, "") == -1);
assert(procwalk_first_structsize(NULL, 16) == 0);
}
/* --- procwalk: record lookup + offset-free pid search -----------------
* lock.c and evict.c used to read the process name at byte 447 and the pid at
* byte 72 of every kinfo_proc record. Those are 9.00 offsets, so on newer
* firmware they addressed the wrong bytes and every live payload looked dead:
* evict deleted the lock while the daemon kept running, and lock.c let a
* second daemon start alongside the first. procwalk narrows by exact name
* first, then searches the record for the pid. */
static void test_procwalk_find_and_pid(void) {
unsigned char buf[4096];
size_t at, recsz;
const unsigned char *rec;
/* the layout lock.c/evict.c used to assume still works */
at = put_rec(buf, 0, 479, 447, "Payload");
put_i32(buf + 72, 1234);
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == 0);
assert(rec == buf && recsz == 479);
assert(procwalk_record_has_i32(rec, recsz, 1234) == 1);
assert(procwalk_record_has_i32(rec, recsz, 9999) == 0);
/* shifted firmware: record grew, name and pid both moved */
at = put_rec(buf, 0, 512, 300, "SystemUI");
at = put_rec(buf, at, 736, 611, "Payload");
put_i32(buf + 479 + 91, 1234);
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == 0);
assert(rec == buf + 512 && recsz == 736);
assert(procwalk_record_has_i32(rec, recsz, 1234) == 1);
assert(procwalk_record_has_i32(rec, recsz, 4321) == 0);
/* prefix names must not match: PayloadHelper is not Payload, and kill()
* on the wrong pid is the failure this guards */
at = put_rec(buf, 0, 479, 447, "PayloadHelper");
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == -2);
at = put_rec(buf, 0, 479, 447, "Payload");
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == 0);
/* nothing matches: -2, distinct from malformed (-1), outputs cleared */
at = put_rec(buf, 0, 479, 447, "SystemUI");
at = put_rec(buf, at, 600, 500, "eboot.bin");
assert(procwalk_find_comm(buf, at, "Payload", &rec, &recsz) == -2);
assert(rec == NULL && recsz == 0);
/* malformed table -> -1 */
memset(buf, 0, 16);
assert(procwalk_find_comm(buf, 16, "Payload", &rec, &recsz) == -1);
put_i32(buf, 479);
put_i32(buf + 479, -3);
assert(procwalk_find_comm(buf, 483, "Payload", &rec, &recsz) == -1);
/* empty and bad args */
assert(procwalk_find_comm(buf, 0, "Payload", &rec, &recsz) == -2);
assert(procwalk_find_comm(NULL, 16, "Payload", &rec, &recsz) == -1);
assert(procwalk_find_comm(buf, 16, NULL, &rec, &recsz) == -1);
assert(procwalk_find_comm(buf, 16, "Payload", NULL, &recsz) == -1);
/* the int must lie wholly inside the record */
at = put_rec(buf, 0, 16, 0, "Payload");
put_i32(buf + 12, 77);
assert(procwalk_record_has_i32(buf, 16, 77) == 1);
at = put_rec(buf, 0, 15, 0, "Payload");
put_i32(buf + 12, 77);
assert(procwalk_record_has_i32(buf, 15, 77) == 0);
/* degenerate args */
assert(procwalk_record_has_i32(NULL, 100, 1) == 0);
assert(procwalk_record_has_i32(buf, 0, 1) == 0);
assert(procwalk_record_has_i32(buf, 3, 1) == 0);
/* Caveat, pinned down: records are zero-padded and a process name ends in
* a NUL, so a record routinely contains the int32 0 in its padding. A hit
* is corroboration, not proof -- which is why callers reject pid <= 0
* first. Recorded so this is a documented decision, not a surprise. */
at = put_rec(buf, 0, 64, 0, "Payload");
assert(procwalk_record_has_i32(buf, 64, 0) == 1);
}
/* --- bigapp: foreground-app classification ----------------------------
* The daemon's entire "is a game running" signal. The distinctions that matter
* are the ones a single boolean would throw away:
* -1 no big app -> a game may genuinely have closed
* 0 System UI in front -> a game is probably still running underneath
* -1 unreadable -> hold whatever we had, never clear
* Getting "system UI" or "unreadable" wrong is how presence disappeared while
* a game was still open. */
static void test_bigapp(void) {
char out[16];
/* nothing in front: the only reading that may clear presence */
assert(bigapp_classify(-1, -1, "", out, sizeof out) == BIGAPP_NONE);
assert(out[0] == 0);
/* System UI reported as app id 0, and the NPXS namespace: hold, not close */
assert(bigapp_classify(0, 0, "", out, sizeof out) == BIGAPP_SYSTEM);
assert(bigapp_classify(1, 0, "NPXS20001", out, sizeof out) == BIGAPP_SYSTEM);
assert(out[0] == 0);
/* a real title */
assert(bigapp_classify(1, 0, "CUSA00740", out, sizeof out) == BIGAPP_GAME);
assert(strcmp(out, "CUSA00740") == 0);
/* PS5 backports and PS2 titles use other prefixes; accept all [A-Z0-9] */
assert(bigapp_classify(1, 0, "PPSA01234", out, sizeof out) == BIGAPP_GAME);
assert(bigapp_classify(1, 0, "ELUA01234", out, sizeof out) == BIGAPP_GAME);
/* a failed title lookup is unknown, not "no game" */
assert(bigapp_classify(1, -1, "", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0x80990003, "CUSA00740", out, sizeof out) == BIGAPP_UNKNOWN);
assert(out[0] == 0);
/* malformed title ids: wrong length, lowercase, punctuation, empty */
assert(bigapp_classify(1, 0, "", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA0074", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA007400", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "cusa00740", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA-0740", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA0074\x80", out, sizeof out) == BIGAPP_UNKNOWN);
assert(out[0] == 0);
/* a negative app id other than -1: unknown, never "nothing running" */
assert(bigapp_classify(-2, 0, "", out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify((int32_t)0x80990003, 0, "", out, sizeof out) == BIGAPP_UNKNOWN);
/* NULL title must not crash */
assert(bigapp_classify(1, 0, NULL, out, sizeof out) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, NULL, NULL, 0) == BIGAPP_UNKNOWN);
/* a real title but nowhere to write it: unknown rather than a silent
* partial copy */
assert(bigapp_classify(1, 0, "CUSA00740", NULL, 0) == BIGAPP_UNKNOWN);
assert(bigapp_classify(1, 0, "CUSA00740", out, 4) == BIGAPP_UNKNOWN);
/* validator directly */
assert(bigapp_titleid_valid("CUSA00740") == 1);
assert(bigapp_titleid_valid("CUSA0074") == 0);
assert(bigapp_titleid_valid("cusa00740") == 0);
assert(bigapp_titleid_valid(NULL) == 0);
}
/* --- pkgzone: the homebrew name scrape ---------------------------------
* pkg-zone.com is scraped, not queried, so the parsing is pinned down here
* against synthetic pages. A layout change upstream must degrade to "no name"
* (the caller keeps the raw title id), never to a garbled presence. */
static void test_pkgzone(void) {
char name[128], url[256];
/* --- which titles are even worth asking about --- */
/* retail: TMDB already owns these, never spend a request on them */
assert(pkgzone_wants("CUSA00740") == 0);
/* retro: deferred, deliberately excluded */
assert(pkgzone_wants("SLUS20001") == 0);
assert(pkgzone_wants("SCES50361") == 0);
assert(pkgzone_wants("ELUA01234") == 0);
/* The regression that sent SCUS97399 to pkg-zone at 302/500 every poll:
* SCUS is in the retro tables (ps1 + ps2) and must be excluded here. */
assert(pkgzone_wants("SCUS97399") == 0);
/* and the exclusion is delegated to retro_wants(), not re-listed, so the
* two tables cannot drift apart again */
assert(retro_wants("SCUS97399") == 1);
assert(pkgzone_wants("KOEI12345") == 0);
assert(pkgzone_wants("UCUS12345") == 0);
/* homebrew prefixes: 4 letters + 5 digits, e.g. LAPY20009 */
assert(pkgzone_wants("LAPY20009") == 1);
assert(pkgzone_wants("HT0000001") == 1);
assert(pkgzone_wants("JBUA20001") == 1);
/* shape violations: lowercase, wrong length, non-alnum */
assert(pkgzone_wants("lapy20009") == 0);
assert(pkgzone_wants("LAPY2000") == 0);
assert(pkgzone_wants("LAPY200099") == 0);
assert(pkgzone_wants("LAPY-2009") == 0);
/* malformed */
assert(pkgzone_wants("CUSA") == 0);
assert(pkgzone_wants("") == 0);
assert(pkgzone_wants(NULL) == 0);
/* --- a realistic page: boilerplate heading is skipped --- */
{
const char *html =
"<html><head><title>pkg-zone</title></head><body>"
"<h1>Install HB-Store on your Playstation</h1>"
"<div class=\"image\" style=\"background-image: "
"url(/images/LAPY20009/cover.png)\"></div>"
"<h1>Some Homebrew App</h1>"
"</body></html>";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == 0);
assert(strcmp(name, "Some Homebrew App") == 0);
assert(strcmp(url, "https://pkg-zone.com/images/LAPY20009/cover.png") == 0);
}
/* --- boilerplate only: no usable name --- */
{
const char *html = "<h1>Install HB-Store on your Playstation</h1>";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == -1);
assert(name[0] == 0);
}
/* --- whitespace and entities are normalised --- */
{
const char *html = "<h1> Retro Tool &amp; Co </h1>";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == 0);
assert(strcmp(name, "Retro Tool & Co") == 0);
}
/* --- unclosed / absent tags must not read past the buffer --- */
{
const char *html = "<h1>No closing tag here";
assert(pkgzone_parse(html, strlen(html), "LAPY20009",
name, sizeof name, url, sizeof url) == -1);
const char *nohead = "<body><p>no headings</p></body>";
assert(pkgzone_parse(nohead, strlen(nohead), "LAPY20009",
name, sizeof name, url, sizeof url) == -1);
/* an empty <h1> must be skipped in favour of the next real one */
const char *empty_then_real = "<h1></h1><h1>Real Title</h1>";
assert(pkgzone_parse(empty_then_real, strlen(empty_then_real), "LAPY20009",
name, sizeof name, url, sizeof url) == 0);
assert(strcmp(name, "Real Title") == 0);
}
/* --- bad arguments --- */
assert(pkgzone_parse(NULL, 10, "LAPY20009", name, sizeof name, url, sizeof url) == -1);
assert(pkgzone_parse("<h1>x</h1>", 11, NULL, name, sizeof name, url, sizeof url) == -1);
assert(pkgzone_parse("<h1>x</h1>", 11, "LAPY20009", NULL, 0, url, sizeof url) == -1);
/* title id must be 9 chars: the cover URL is built from it */
assert(pkgzone_parse("<h1>x</h1>", 11, "SHORT", name, sizeof name, url, sizeof url) == -1);
}
/* --- games_cache: the persistent id -> {name, cover} store -------------
* Replaces both the config.json titles map and artwork_cache.json. Cover art
* was never persisted before this, so a reboot lost it and the presence fell
* back to a pack URL that may not exist. */
static void test_gamecache(void) {
gamecache_entry_t e[8];
const int64_t now = 1700000000;
/* --- id validation --- */
assert(gamecache_id_valid("CUSA32836") == 1);
assert(gamecache_id_valid("LAPY20009") == 1);
assert(gamecache_id_valid("cusa32836") == 0); /* lowercase */
assert(gamecache_id_valid("CUSA-3283") == 0); /* punctuation */
assert(gamecache_id_valid("CUSA3283612345678") == 0); /* too long */
assert(gamecache_id_valid("") == 0);
assert(gamecache_id_valid(NULL) == 0);
/* --- parse a realistic document --- */
{
const char *doc =
"{\"schema\":1,\"games\":["
"{\"id\":\"CUSA32836\",\"name\":\"Some Game\",\"art\":\"https://cdn/x.png\",\"at\":1699999000},"
"{\"id\":\"LAPY20009\",\"name\":\"Homebrew\",\"art\":\"\",\"at\":1699999000}"
"]}";
int n = gamecache_parse(doc, strlen(doc), e, 8, now);
assert(n == 2);
assert(!strcmp(e[0].id, "CUSA32836"));
assert(!strcmp(e[0].name, "Some Game"));
assert(!strcmp(e[0].art, "https://cdn/x.png"));
assert(!strcmp(e[1].id, "LAPY20009"));
assert(e[1].art[0] == 0); /* empty art is allowed */
}
/* --- malformed rows are skipped, not fatal --- */
{
const char *doc =
"{\"games\":["
"{\"id\":\"CUSA32836\",\"name\":\"Good\",\"at\":1699999000},"
"{\"id\":\"bad-id\",\"name\":\"Bad\",\"at\":1699999000},"
"{\"id\":\"CUSA11111\"},"
"{\"name\":\"NoId\",\"at\":1699999000},"
"\"notanobject\","
"{\"id\":\"CUSA22222\",\"name\":\"Also Good\",\"at\":1699999000}"
"]}";
int n = gamecache_parse(doc, strlen(doc), e, 8, now);
assert(n == 2);
assert(!strcmp(e[0].id, "CUSA32836"));
assert(!strcmp(e[1].id, "CUSA22222"));
}
/* --- unusable documents --- */
assert(gamecache_parse("not json", 8, e, 8, now) == -1);
assert(gamecache_parse("[]", 2, e, 8, now) == -1); /* array at top */
assert(gamecache_parse("{}", 2, e, 8, now) == 0); /* no games key */
assert(gamecache_parse(NULL, 10, e, 8, now) == -1);
assert(gamecache_parse("{}", 2, NULL, 8, now) == -1);
assert(gamecache_parse("{}", 2, e, 0, now) == -1);
/* --- staleness: an old entry is dropped so it gets re-resolved --- */
{
char doc[256];
snprintf(doc, sizeof doc,
"{\"games\":[{\"id\":\"CUSA32836\",\"name\":\"Old\",\"at\":%lld}]}",
(long long)(now - GAMECACHE_TTL_SECS - 60));
assert(gamecache_parse(doc, strlen(doc), e, 8, now) == 0);
/* fresh entry survives */
snprintf(doc, sizeof doc,
"{\"games\":[{\"id\":\"CUSA32836\",\"name\":\"New\",\"at\":%lld}]}",
(long long)(now - 10));
assert(gamecache_parse(doc, strlen(doc), e, 8, now) == 1);
}
/* a missing timestamp is treated as stale, not trusted blindly */
{
const char *doc = "{\"games\":[{\"id\":\"CUSA32836\",\"name\":\"NoAt\"}]}";
assert(gamecache_parse(doc, strlen(doc), e, 8, now) == 0);
}
/* --- round trip, including quotes and backslashes in the name --- */
{
gamecache_entry_t in[2], out[8];
memset(in, 0, sizeof in);
strcpy(in[0].id, "CUSA32836");
strcpy(in[0].name, "He said \"hi\" \\ back");
strcpy(in[0].art, "https://cdn/a.png");
in[0].at = now;
strcpy(in[1].id, "LAPY20009");
strcpy(in[1].name, "Homebrew Tool");
in[1].at = now;
char *json = gamecache_serialize(in, 2);
assert(json != NULL);
int n = gamecache_parse(json, strlen(json), out, 8, now);
assert(n == 2);
assert(!strcmp(out[0].id, "CUSA32836"));
assert(!strcmp(out[0].name, "He said \"hi\" \\ back"));
assert(!strcmp(out[0].art, "https://cdn/a.png"));
assert(!strcmp(out[1].name, "Homebrew Tool"));
assert(out[1].art[0] == 0);
free(json);
}
/* --- serialise edges --- */
assert(gamecache_serialize(NULL, 1) == NULL);
assert(gamecache_serialize(e, 0) == NULL);
/* --- `max` really caps --- */
{
const char *doc =
"{\"games\":["
"{\"id\":\"CUSA00001\",\"name\":\"A\",\"at\":1699999000},"
"{\"id\":\"CUSA00002\",\"name\":\"B\",\"at\":1699999000},"
"{\"id\":\"CUSA00003\",\"name\":\"C\",\"at\":1699999000}"
"]}";
assert(gamecache_parse(doc, strlen(doc), e, 2, now) == 2);
}
/* --- live store: put then get, and art round-trips --- */
gamecache_reset();
{
char nm[64], ar[128];
assert(gamecache_get("CUSA32836", nm, sizeof nm, ar, sizeof ar) == 0);
assert(gamecache_put("CUSA32836", "Cached Game",
"https://cdn/cover.png", "tmdb") == 1);
assert(gamecache_get("CUSA32836", nm, sizeof nm, ar, sizeof ar) == 1);
assert(!strcmp(nm, "Cached Game"));
assert(!strcmp(ar, "https://cdn/cover.png"));
/* id echo is not a name */
assert(gamecache_put("CUSA99999", "CUSA99999", "", "tmdb") == 0);
/* bad id rejected */
assert(gamecache_put("bad id", "X", "", "tmdb") == 0);
/* update replaces */
assert(gamecache_put("CUSA32836", "Renamed", "", "pkgzone") == 1);
assert(gamecache_get("CUSA32836", nm, sizeof nm, ar, sizeof ar) == 1);
assert(!strcmp(nm, "Renamed"));
}
gamecache_reset();
}
/* --- firmware version normalisation ---------------------------------
* The presence card shows this string, so a bad trim is user-visible.
* Sony packs the minor as three digits with a trailing sub-patch zero,
* which is why "13.520.001" has to render as "13.52", not "13.520". */
/* fw.c host stub: the only thing it needs from the console is the
* firmware string. fw_normalize() itself is pure and fully covered. */
typedef struct {
unsigned long long reserved;
char version_string[0x1c];
unsigned int version;
} fw_info_stub_t;
/* What the stub reports. Overridden by the padded case below so fw_version()
* can be driven end-to-end through the whole path, not just fw_normalize(). */
static const char *s_stub_fw_string = "13.520.001";
int sceKernelGetSystemSwVersion(fw_info_stub_t *info){
if(!info) return -1;
memset(info, 0, sizeof *info);
snprintf(info->version_string, sizeof info->version_string, "%s",
s_stub_fw_string);
return 0;
}
static void test_fw_normalize(void) {
static const struct { const char *in; const char *want; } cases[] = {
{ "13.520.001", "13.52" },
{ "13.520", "13.52" },
{ "13.52", "13.52" },
{ "9.000", "9.00" },
{ "9.000.001", "9.00" },
{ "8.500", "8.50" },
{ "11.020", "11.02" },
{ "12.000", "12.00" },
{ "5.050", "5.05" },
{ "7.550", "7.55" },
{ "13", "13" }, /* no minor: left alone */
/* Padded by Sony on some firmware -- 9.00 sends " 9.008.031",
* verified on console 2026-10-05. digits_ok() rejected the space and
* every poll logged "unrecognised version string", so the presence
* fell back to "Firmware unknown". */
{ " 9.008.031", "9.00" },
{ " 9.000", "9.00" }, /* more than one space */
{ " 5.050.123", "5.05" }, /* padded single-digit major */
{ "\t9.008.031", "9.00" }, /* tab padding */
{ "9.008.031 ", "9.00" }, /* trailing padding */
};
for (unsigned i = 0; i < sizeof cases / sizeof cases[0]; i++) {
char v[32];
snprintf(v, sizeof v, "%s", cases[i].in);
fw_normalize(v);
assert(!strcmp(v, cases[i].want));
}
/* end-to-end: fw_version() must render "13.52" for the console the
* stub above describes -- exactly what the presence card shows. */
{
char fw[16];
assert(fw_version(fw, sizeof fw) == 0);
assert(!strcmp(fw, "13.52"));
}
/* The regression that reported "unrecognised version string" forever on
* 9.00. fw_version() normalises before validating, so a padded string
* reaches digits_ok() trimmed. */
{
static const struct { const char *raw; const char *want; } pad[] = {
{ " 9.008.031", "9.00" },
{ "9.008.031", "9.00" },
{ " 5.050.123", "5.05" },
{ " 13.520.001", "13.52" },
};
for (unsigned i = 0; i < sizeof pad / sizeof pad[0]; i++) {
char fw[16];
s_stub_fw_string = pad[i].raw;
assert(fw_version(fw, sizeof fw) == 0);
assert(!strcmp(fw, pad[i].want));
}
s_stub_fw_string = "13.520.001";
}
/* Still rejects genuine garbage rather than passing it through. */
{
char fw[16];
s_stub_fw_string = "not-a-version";
assert(fw_version(fw, sizeof fw) != 0);
assert(!strcmp(fw, "unknown"));
s_stub_fw_string = "13.520.001";
}
}
/* --- NPXS is system; app/media ids resolve like anything else -------
* Console klog 2026-10-03: NPXS20001 backs SceShellUI plus
* SecureUIProcess/SecureWebProcess and is where every AppFocusChanged
* event starts, so it must never be posted as a game. ItemzFlow is
* ITEM00001, a different namespace, and does resolve. */
static void test_bigapp_namespaces(void) {
char out[16];
/* NPXS is never a playing game. */
assert(bigapp_classify(1, 0, "NPXS20001", out, sizeof out) == BIGAPP_SYSTEM);
assert(bigapp_classify(1, 0, "NPXS21002", out, sizeof out) == BIGAPP_SYSTEM);
assert(bigapp_classify(1, 0, "NPXS10001", out, sizeof out) == BIGAPP_SYSTEM);
assert(out[0] == 0); /* nothing written for a system id */
/* App and media containers post normally. */
assert(bigapp_classify(1, 0, "ITEM00001", out, sizeof out) == BIGAPP_GAME);
assert(!strcmp(out, "ITEM00001"));
assert(bigapp_classify(1, 0, "CUSA00127", out, sizeof out) == BIGAPP_GAME);
assert(bigapp_classify(1, 0, "LAPY20009", out, sizeof out) == BIGAPP_GAME);
assert(bigapp_classify(1, 0, "CUSA03887", out, sizeof out) == BIGAPP_GAME);
}
/* --- asset keys land in the right fields ---------------------------
* Every parameter here is const char*, so a mis-ordered signature still
* compiles: it shipped once and put "ps_logo_full" in the details line.
* Assert on values, not on arity. */
static void test_activity_assets(void) {
/* home: idle asset as large image, no badge */
jl_val_t *a = discord_build_activity("Firmware 13.52", "PlayStation 4",
"Idling on Home Menu", "ps_logo_full", NULL,
"home", "app", NULL, NULL, NULL, 0, "");
assert(a);
const jl_val_t *v = jl_obj_get(a, "details");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Idling on Home Menu"));
const jl_val_t *as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "large_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "ps_logo_full"));
assert(jl_obj_get(as, "small_image") == NULL); /* no badge on home */
jl_free(a);
/* playing: game icon large, playing asset as the badge */
a = discord_build_activity("Firmware 13.52", "Some Game",
"Playing on PlayStation 4", NULL, "ps_logo_blue",
"CUSA11995", "app", NULL, NULL, NULL, 1700000000LL, "");
assert(a);
v = jl_obj_get(a, "details");
assert(v && v->type == JL_STRING && !strcmp(v->str, "Playing on PlayStation 4"));
as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
v = jl_obj_get(as, "large_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "cusa11995"));
v = jl_obj_get(as, "small_image");
assert(v && v->type == JL_STRING && !strcmp(v->str, "ps_logo_blue"));
jl_free(a);
/* an empty key falls back rather than sending an empty asset */
a = discord_build_activity("Firmware 13.52", "Some Game",
"Playing on PlayStation 4", "", "",
"CUSA11995", "app", NULL, NULL, NULL, 0, "");
assert(a);
as = jl_obj_get(a, "assets");
assert(as && as->type == JL_OBJECT);
assert(jl_obj_get(as, "small_image") == NULL);
jl_free(a);
}
/* --- retro: prefix routing and the entry extractor -----------------
* Pure logic only; the fetch half is payload-only. Driven against the real
* index shapes verified on console 2026-10-05: compact, BOM-free, a bare
* array of flat {"id","name","cover"} entries. */
static void test_retro(void) {
unsigned char c[3];
/* PS1-only prefixes: one file, no wasted fetches */
assert(retro_wants("SND00123") == 1);
assert(retro_candidates_for("SND00123", c) == 1 && c[0] == 0);
/* LSD is a 3-character prefix: comparing a fixed 4 bytes would miss it */
assert(retro_wants("LSD00123") == 1);
assert(retro_candidates_for("LSD00123", c) == 1 && c[0] == 0);
/* PS2-only */
assert(retro_wants("KOEI12345") == 1);
assert(retro_candidates_for("KOEI12345", c) == 1 && c[0] == 1);
/* PSP-only */
assert(retro_wants("UCUS12345") == 1);
assert(retro_candidates_for("UCUS12345", c) == 1 && c[0] == 2);
/* shared prefixes: SLPM and PBPX are in both PS1 and PS2, and must
* come out in check order so the first hit is deterministic */
assert(retro_candidates_for("SLPM55265", c) == 2);
assert(c[0] == 0 && c[1] == 1);
assert(retro_candidates_for("PBPX12345", c) == 2);
assert(c[0] == 0 && c[1] == 1);
/* retail and unknowns are never routed here */
assert(retro_wants("CUSA32836") == 0);
assert(retro_wants("LAPY20009") == 0);
assert(retro_wants("ITEM00001") == 0);
assert(retro_wants("") == 0);
assert(retro_wants(NULL) == 0);
/* extraction from a real index body */
{
const char *body =
"[{\"id\":\"SLPM87404\",\"name\":\"A GAME\",\"cover\":\"https://x/a.jpg\"},"
"{\"id\":\"SLPM55146\",\"name\":\"OTHER GAME\",\"cover\":\"https://x/b.jpg\"}]";
char name[RETRO_MAX_NAME], url[RETRO_MAX_URL];
assert(retro_extract(body, strlen(body), "\"id\":\"SLPM55146\"",
name, sizeof name, url, sizeof url) == 1);
assert(!strcmp(name, "OTHER GAME"));
assert(!strcmp(url, "https://x/b.jpg"));
/* a miss must not invent a name */
name[0] = 'X';
assert(retro_extract(body, strlen(body), "\"id\":\"NOPE00000\"",
name, sizeof name, url, sizeof url) == 0);
assert(name[0] == 0);
}
/* names carry quotes and backslashes; raw bytes would show \\ and \" */
{
const char *body =
"[{\"id\":\"ABCD12345\",\"name\":\"He said \\\"hi\\\" \\\\ away\",\"cover\":\"\"}]";
char name[RETRO_MAX_NAME], url[RETRO_MAX_URL];
assert(retro_extract(body, strlen(body), "\"id\":\"ABCD12345\"",
name, sizeof name, url, sizeof url) == 1);
assert(!strcmp(name, "He said \"hi\" \\ away"));
assert(url[0] == 0); /* empty cover is valid, not fatal */
}
/* the 164-char name in ps2.json must survive intact */
{
const char *body =
"[{\"id\":\"SLPM00001\",\"name\":\"Kidou Senshi Gundam Giren no Yabou - Zeon Dokuritsu Sensouden & Kidou Senshi Gundam Giren no Yabou - Zeon Dokuritsu Sensouden - Kouryaku Shireisho (Gundam The Best)\",\"cover\":\"https://x/g.jpg\"}]";
char name[RETRO_MAX_NAME], url[RETRO_MAX_URL];
assert(retro_extract(body, strlen(body), "\"id\":\"SLPM00001\"",
name, sizeof name, url, sizeof url) == 1);
assert(strlen(name) == 164);
assert(strstr(name, "Gundam The Best") != NULL);
}
}
/* --- visibility flags + homebrew classification -------------------
* The four show_* flags are a daemon-side decision, so what is testable
* here is that they default to on and that is_homebrew_id puts the right
* ids in the right bucket -- in particular that retro is never homebrew. */
static void test_show_flags(void) {
cfg_t c;
cfg_defaults(&c);
/* all on by default: a config that predates the keys keeps today's
* behaviour rather than silently hiding everything */
assert(c.show_firmware == 1);
assert(c.show_idle == 1);
assert(c.show_media == 1);
assert(c.show_homebrew == 1);
/* is_homebrew_id: the pkg-zone namespace */
assert(is_homebrew_id("LAPY20009") == 1); /* payload-dir homebrew */
assert(is_homebrew_id("HT0000001") == 1);
assert(is_homebrew_id("ITEM00001") == 1); /* app container */
/* retail and system are never homebrew */
assert(is_homebrew_id("CUSA32836") == 0);
assert(is_homebrew_id("NPXS20001") == 0);
/* the point of the exclusion: retro must survive show_homebrew:false */
assert(is_homebrew_id("SLPM55146") == 0);
assert(is_homebrew_id("UCUS12345") == 0);
assert(is_homebrew_id("KOEI12345") == 0);
/* degenerate input */
assert(is_homebrew_id(NULL) == 0);
assert(is_homebrew_id("") == 0);
assert(is_homebrew_id("AB") == 0);
}
int main(void) {
/* First: pure parsing, no I/O, no fixtures. The pre-existing
* test_appdb failure aborts the binary, so anything listed after it
* would never run. */
test_activity_assets();
test_bigapp();
test_retro();
test_show_flags();
test_bigapp_namespaces();
test_pkgzone();
test_gamecache();
test_fw_normalize();
test_procwalk();
test_procwalk_find_and_pid();
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();
test_updater();
test_sfo();
test_base64();
test_art_parse();
test_health_safe_mode();
test_cfg_titles();
test_cfg_learn();
test_installer_cfg();
test_appdb();
test_discord_builder();
puts("utility tests passed");
return 0;
}