mirror of
https://github.com/SirHumza/orbisRPC.git
synced 2026-10-08 09:01:36 +02:00
- tests: link vendored sqlite amalgamation instead of system -lsqlite3; wrapper VFS with trivial xFullPathname; unlink stale app.db + pre-create before open (runner CANTOPEN) - tests: free parsed values in test_json_oom_safe (LSan leak) - ci: pin SDK via checkout+fetch-depth 0 (raw SHA is not a ref) - README: credit SirHumza & CyberMask367, no-PKG beta payload install, beta access via Discord
1460 lines
64 KiB
C
1460 lines
64 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/manifest.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/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; }
|
|
#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);
|
|
}
|
|
|
|
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_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);
|
|
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://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"));
|
|
}
|
|
|
|
/* 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 & 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_json_oom_safe();
|
|
test_json_hostile();
|
|
test_tmdb();
|
|
test_updater();
|
|
test_sfo();
|
|
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();
|
|
test_appdb();
|
|
test_discord_builder();
|
|
puts("utility tests passed");
|
|
return 0;
|
|
}
|