mirror of
https://github.com/itsPLK/ps5-pkg-manager.git
synced 2026-10-22 11:53:08 -05:00
* ws: direct-install streaming over WebSocket (RAM-only, no disk spool) Browser pushes .pkg bytes over ws://:8846 while the PS5 installer pulls ranges over :8845 concurrently through a demand-paged 1MB-segment ring (64 slots, seg 0 pinned). Install can start at header_ready, mid-upload. Backend (src/ws_stream.c, src/ws_upload.c): - ws_live_* ring with waiter shielding, served-past / waited+acked / peak>0+acked LRU eviction, single store_seg_locked() truth shared by blocking write and non-blocking try_write (0/-1/-2 busy). - Conn worker never blocks on storage (busy reply, browser retries); socket idle kill suppressed while readers are parked; stall diagnostics when a write parks. - http_server.c: guarded /api/upload/* REST branch; multipart.c live: scheme adapter (frozen pull path untouched); installer_start_live. Frontend: DirectInstallView + useDirectUpload pusher (FIFO seek queue for parallel installer workers, busy requeue+retry, demand-paging standby until install finalizes, silent-socket watchdog), wired into the history router (#/direct-install, Circle-back closes it). Parser: pkg_parser_parse_mem reuses in-file sub-parsers for live header parsing (differential-tested). Tests: ring unit (test_ws_stream), far-seek/pivot/busy/waiter unit (test_ws_stream_far), transport (test_ws_upload, incl. socket busy->retry), concurrent e2e incl. 90MB/64-slot parallel-pivot replay of the production capture (test_direct_install_e2e), PS5 pull-replay simulator (ps5_sim) + CLI tools. Proven on console: 1GB PKG installs at status='playable' with zero idle kills. Full host suite (13 binaries + CSS) green. * perf(smb): pipeline reads for faster package streaming Use a private SMB session per stream connection and keep up to eight credit-aware reads in flight, with requests up to 512 KiB. Reduce each request to the credits available in the current SMB2 window, including queued requests, so libsmb2 cannot silently shorten a request and leave a gap in the stream. Increase stream and socket buffers, and leave signing optional while still honoring servers that require it. Add SMB timing and throughput logs plus negotiated session diagnostics to separate SMB read latency from HTTP streaming overhead. Include the SMB logger in PS5 and host builds and update the mock async API used by tests. * fix(ws): harden direct install sessions and package preview Tie uploads to a browser owner and one authenticated WebSocket. Resume from acknowledged segments so cache eviction does not lose progress. Move Direct Install into media sources, show local PKG title and icon before installation, and warn before leaving an active transfer. Pass package metadata to the live installer and cover ownership and metadata parsing with tests. * fix(smb): honor package install debug setting Gate SMB debug file creation and per-read diagnostics behind pkg_install_debug, matching WebSocket stream logging behavior. Keep regular SMB package streaming unchanged. * fix(ui): return direct install to drives route Make the Direct Install Back to drives action replace the current route and restore the media source view directly. This prevents browsers that opened the page from leaving the app for an unrelated previous history entry. * fix(ui): restore deep-linked pages on reload Apply the initial hash route to the visible view during startup, including storage media and package details. Load package data for the linked drive so a direct package URL opens its detail page after a reload. * fix(ui): accept package drops across the app Handle package drops at the application level, open Direct Install, and pass the file through the existing package preview flow. Keep active uploads and installations protected from replacement. * fix(direct): check package eligibility before install Share the scanner's installed state and version rules with Direct Install. Check selected package metadata before enabling Install and again before starting a new transfer, while preserving resumed sessions. Show storage and active install blockers on the direct install page. * fix(direct): share live install icon across browsers Upload the parsed package icon once to the active direct install session and serve it from RAM through the existing icon API. Other browsers can resolve the live session path shown in installer status. Clear icon data when the session is canceled or replaced. * fix(direct): cancel install when uploader closes Use sendBeacon during browser teardown to call the authenticated Direct Install cancel endpoint for the sending session. Keep observer browsers unaffected and avoid duplicate cancellation requests. * fix(direct): cancel when uploader socket closes Treat an unexpected close of the authenticated uploader WebSocket before the final finish message as an install cancellation. Preserve normal completed sessions and reduce repetitive seek, eviction, and read-short logging in the main log. * bump version to 1.1.1 * fix(ui): stop duplicate direct upload polling Keep the observer watchdog stable across renders, disable it for the owning upload tab, and prevent overlapping status requests in both polling paths. * Add PS4 and PS5 package badges * feat: support platform-aware install storage * fix: preserve catalog during provisional scans * bump version to 1.2.0 * docs: add changelog for 1.2.0 * chore: remove stale refactor references * feat: move streaming services to dedicated ports * docs: update 1.2.0 documentation
815 lines
40 KiB
C
815 lines
40 KiB
C
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <assert.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
#include "pkg_scanner.h"
|
|
#include "app_info.h"
|
|
#include "test_fixture.h"
|
|
|
|
int main(void) {
|
|
/* All fixtures are generated synthetically (fictional IDs/titles) so
|
|
* this test never needs external files. */
|
|
system("rm -rf /tmp/test_scan_fixtures && mkdir -p /tmp/test_scan_fixtures");
|
|
assert(fixture_write_ps4_pkg("/tmp/test_scan_fixtures/ab_bonus.pkg",
|
|
"CUSA90003", "Aerobeat: Bonus Tracks", "ac", "01.00") == 0);
|
|
assert(fixture_write_ps4_pkg("/tmp/test_scan_fixtures/ab_sunset.pkg",
|
|
"CUSA90003", "Aerobeat: Sunset Pack", "ac", "01.00") == 0);
|
|
assert(fixture_write_ps4_pkg("/tmp/test_scan_fixtures/bq_base.pkg",
|
|
"CUSA90002", "BounceQuest", "gd", "01.00") == 0);
|
|
assert(fixture_write_ps4_pkg("/tmp/test_scan_fixtures/bq_upd.pkg",
|
|
"CUSA90002", "BounceQuest", "gp", "01.06") == 0);
|
|
assert(fixture_write_ps5_pkg("/tmp/test_scan_fixtures/nv.pkg",
|
|
"PPSA90011", "NebulaView", "gd", "06.000.000", 1) == 0);
|
|
assert(fixture_write_ps5_pkg("/tmp/test_scan_fixtures/wc.pkg",
|
|
"PPSA90012", "WaveCast", "gd", "01.003.000", 1) == 0);
|
|
/* Large package for the multipart quick-scan step (80MB -> 2x60M parts). */
|
|
assert(fixture_write_ps5_pkg("/tmp/test_scan_big.pkg",
|
|
"PPSA90010", "NebulaDrift", "gd", "06.000.000", 1) == 0);
|
|
assert(fixture_grow_file("/tmp/test_scan_big.pkg", 80ULL * 1024 * 1024) == 0);
|
|
|
|
/* Clean any leftover cache from previous runs */
|
|
system("rm -rf /tmp/pkgmgr");
|
|
|
|
/* =========================================================================
|
|
* Test 1: Scan 6 Example Packages & Default Alphabetical Title Sorting
|
|
* ========================================================================= */
|
|
printf("=== Testing Scan of 6 Example Packages & Default Title Sort ===\n");
|
|
setenv("PKG_SCAN_DIR", "/tmp/test_scan_fixtures", 1);
|
|
unsetenv("PKG_APPMETA_DIR");
|
|
unsetenv("PKG_ADDCONT_DIR");
|
|
unsetenv("PKG_APP_DB_PATH");
|
|
|
|
pkg_scanner_init();
|
|
int count = pkg_scanner_scan();
|
|
printf("Scanned packages count: %d (expected: 6)\n", count);
|
|
assert(count == 6);
|
|
|
|
/* Verify alphabetical sorting by title_name */
|
|
pkg_detail_t d0, d1, d2, d3, d4, d5;
|
|
assert(pkg_scanner_get_at(0, &d0) == 0);
|
|
assert(pkg_scanner_get_at(1, &d1) == 0);
|
|
assert(pkg_scanner_get_at(2, &d2) == 0);
|
|
assert(pkg_scanner_get_at(3, &d3) == 0);
|
|
assert(pkg_scanner_get_at(4, &d4) == 0);
|
|
assert(pkg_scanner_get_at(5, &d5) == 0);
|
|
|
|
printf("Sorted pkgs:\n 0: %s [%s]\n 1: %s [%s]\n 2: %s [%s]\n 3: %s [%s]\n 4: %s [%s]\n 5: %s [%s]\n",
|
|
d0.title_name, d0.pkg_type_str,
|
|
d1.title_name, d1.pkg_type_str,
|
|
d2.title_name, d2.pkg_type_str,
|
|
d3.title_name, d3.pkg_type_str,
|
|
d4.title_name, d4.pkg_type_str,
|
|
d5.title_name, d5.pkg_type_str);
|
|
|
|
assert(strcmp(d0.title_name, "Aerobeat: Bonus Tracks") == 0);
|
|
assert(d0.pkg_type == PKG_TYPE_DLC);
|
|
assert(strcmp(d1.title_name, "Aerobeat: Sunset Pack") == 0);
|
|
assert(d1.pkg_type == PKG_TYPE_DLC);
|
|
assert(strcmp(d2.title_name, "BounceQuest") == 0);
|
|
assert(d2.pkg_type == PKG_TYPE_BASE);
|
|
assert(strcmp(d3.title_name, "BounceQuest") == 0);
|
|
assert(d3.pkg_type == PKG_TYPE_UPDATE);
|
|
assert(strcmp(d4.title_name, "NebulaView") == 0);
|
|
assert(d4.pkg_type == PKG_TYPE_BASE);
|
|
assert(strcmp(d5.title_name, "WaveCast") == 0);
|
|
assert(d5.pkg_type == PKG_TYPE_BASE);
|
|
|
|
char *json = pkg_scanner_to_json();
|
|
assert(json != NULL);
|
|
assert(strstr(json, "PPSA90011") != NULL);
|
|
assert(strstr(json, "NebulaView") != NULL);
|
|
assert(strstr(json, "PPSA90012") != NULL);
|
|
assert(strstr(json, "WaveCast") != NULL);
|
|
assert(strstr(json, "CUSA90002") != NULL);
|
|
assert(strstr(json, "BounceQuest") != NULL);
|
|
assert(strstr(json, "CUSA90003") != NULL);
|
|
assert(strstr(json, "\"pkg_type\":\"base\"") != NULL);
|
|
assert(strstr(json, "\"pkg_type\":\"update\"") != NULL);
|
|
assert(strstr(json, "\"pkg_type\":\"dlc\"") != NULL);
|
|
assert(strstr(json, "\"mtime\":") != NULL);
|
|
free(json);
|
|
|
|
/* =========================================================================
|
|
* Test 2: Installed Detection & can_install Logic via Mock Environment
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Installed App & DLC Detection and can_install Gating ===\n");
|
|
system("rm -rf /tmp/mock_appmeta /tmp/mock_addcont /tmp/mock_usb_install_test /tmp/mock_user_app");
|
|
system("mkdir -p /tmp/mock_appmeta /tmp/mock_addcont /tmp/mock_usb_install_test /tmp/mock_user_app");
|
|
|
|
/* Copy base and update fixture pkgs to test drive */
|
|
system("cp /tmp/test_scan_fixtures/bq_base.pkg /tmp/test_scan_fixtures/bq_upd.pkg /tmp/mock_usb_install_test/");
|
|
/* Copy DLC fixture pkg to test drive */
|
|
system("cp /tmp/test_scan_fixtures/ab_bonus.pkg /tmp/mock_usb_install_test/");
|
|
|
|
setenv("PKG_SCAN_DIR", "/tmp/mock_usb_install_test", 1);
|
|
setenv("PKG_APPMETA_DIR", "/tmp/mock_appmeta", 1);
|
|
setenv("PKG_ADDCONT_DIR", "/tmp/mock_addcont", 1);
|
|
setenv("PKG_USER_APP_DIR", "/tmp/mock_user_app", 1);
|
|
unsetenv("PKG_USB_PREFIX");
|
|
|
|
/* Step A: Base packages are NOT installed initially */
|
|
pkg_scanner_init();
|
|
pkg_scanner_scan();
|
|
char *json_not_installed = pkg_scanner_packages_for_drive_to_json("/tmp/mock_usb_install_test");
|
|
printf("JSON with base NOT installed:\n%s\n", json_not_installed);
|
|
|
|
/* Base game can be installed */
|
|
assert(strstr(json_not_installed, "\"title_id\":\"CUSA90002\"") != NULL);
|
|
/* Update CANNOT be installed without base */
|
|
assert(strstr(json_not_installed, "Base package is not installed") != NULL);
|
|
/* DLC CANNOT be installed without base */
|
|
free(json_not_installed);
|
|
|
|
/* Step B: Install base game v01.00 into mock_appmeta and mock_user_app */
|
|
system("mkdir -p /tmp/mock_appmeta/CUSA90002 /tmp/mock_user_app/CUSA90002/sce_sys");
|
|
/* Write a mock param.json with contentVersion 01.000.000 */
|
|
FILE *fp = fopen("/tmp/mock_appmeta/CUSA90002/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"CUSA90002\",\"contentVersion\":\"01.000.000\",\"titleName\":\"BounceQuest\"}\n");
|
|
fclose(fp);
|
|
fp = fopen("/tmp/mock_user_app/CUSA90002/sce_sys/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"CUSA90002\",\"contentVersion\":\"01.000.000\",\"titleName\":\"BounceQuest\"}\n");
|
|
fclose(fp);
|
|
|
|
/* Also install base music game into mock_appmeta and mock_user_app */
|
|
system("mkdir -p /tmp/mock_appmeta/CUSA90003 /tmp/mock_user_app/CUSA90003/sce_sys");
|
|
fp = fopen("/tmp/mock_appmeta/CUSA90003/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"CUSA90003\",\"contentVersion\":\"01.000.000\",\"titleName\":\"Aerobeat\"}\n");
|
|
fclose(fp);
|
|
fp = fopen("/tmp/mock_user_app/CUSA90003/sce_sys/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"CUSA90003\",\"contentVersion\":\"01.000.000\",\"titleName\":\"Aerobeat\"}\n");
|
|
fclose(fp);
|
|
|
|
/* Now check status with base installed */
|
|
pkg_scanner_init();
|
|
pkg_scanner_scan();
|
|
char *json_base_installed = pkg_scanner_packages_for_drive_to_json("/tmp/mock_usb_install_test");
|
|
printf("JSON with base INSTALLED (v01.00):\n%s\n", json_base_installed);
|
|
|
|
/* Both update (v01.06 > v01.00) and DLC should now be installable! */
|
|
assert(strstr(json_base_installed, "\"installed_version\":\"v1.00\"") != NULL);
|
|
assert(strstr(json_base_installed, "\"can_install\":true") != NULL);
|
|
/* Base game MUST now be disabled because installed version is same or newer */
|
|
assert(strstr(json_base_installed, "Installed version is same or newer") != NULL);
|
|
free(json_base_installed);
|
|
|
|
/* Direct Install uses the same eligibility check without a scanned drive. */
|
|
pkg_detail_t direct_pkg = {0};
|
|
pkg_install_eligibility_t eligibility;
|
|
snprintf(direct_pkg.title_id, sizeof(direct_pkg.title_id), "CUSA90002");
|
|
snprintf(direct_pkg.app_version, sizeof(direct_pkg.app_version), "v1.00");
|
|
direct_pkg.pkg_type = PKG_TYPE_BASE;
|
|
pkg_scanner_check_install_eligibility(&direct_pkg, &eligibility);
|
|
assert(!eligibility.can_install);
|
|
assert(strcmp(eligibility.disabled_reason, "Installed version is same or newer") == 0);
|
|
|
|
direct_pkg.pkg_type = PKG_TYPE_UPDATE;
|
|
snprintf(direct_pkg.app_version, sizeof(direct_pkg.app_version), "v1.06");
|
|
pkg_scanner_check_install_eligibility(&direct_pkg, &eligibility);
|
|
assert(eligibility.can_install);
|
|
|
|
snprintf(direct_pkg.title_id, sizeof(direct_pkg.title_id), "CUSA90099");
|
|
pkg_scanner_check_install_eligibility(&direct_pkg, &eligibility);
|
|
assert(!eligibility.can_install);
|
|
assert(strcmp(eligibility.disabled_reason, "Base package is not installed") == 0);
|
|
|
|
/* Step C: Simulate updating base game to v01.06 in mock_appmeta */
|
|
fp = fopen("/tmp/mock_appmeta/CUSA90002/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"CUSA90002\",\"contentVersion\":\"01.060.000\",\"titleName\":\"BounceQuest\"}\n");
|
|
fclose(fp);
|
|
|
|
/* Simulate installing the DLC into mock_addcont (dirname must match
|
|
* the scanned fixture's content_id). */
|
|
system("mkdir -p /tmp/mock_addcont/CUSA90003/EP0001-CUSA90003_00-TEST000000000001");
|
|
|
|
pkg_scanner_init();
|
|
pkg_scanner_scan();
|
|
char *json_updated = pkg_scanner_packages_for_drive_to_json("/tmp/mock_usb_install_test");
|
|
printf("JSON with update v01.06 already installed and DLC already installed:\n%s\n", json_updated);
|
|
|
|
/* Base game should remain disabled: "Installed version is same or newer" */
|
|
assert(strstr(json_updated, "Installed version is same or newer") != NULL);
|
|
/* Update should now be disabled: "Installed version is same or newer" */
|
|
assert(strstr(json_updated, "Installed version is same or newer") != NULL);
|
|
/* DLC should now be disabled: "DLC is already installed" */
|
|
assert(strstr(json_updated, "DLC is already installed") != NULL);
|
|
free(json_updated);
|
|
|
|
/* Step D: Test cumulative base upgrade (older version installed on console, newer base package on drive) */
|
|
system("mkdir -p /tmp/mock_appmeta/PPSA90011 /tmp/mock_user_app/PPSA90011/sce_sys /tmp/mock_upgrade_test");
|
|
fp = fopen("/tmp/mock_appmeta/PPSA90011/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"PPSA90011\",\"contentVersion\":\"05.000.000\",\"titleName\":\"NebulaView\"}\n");
|
|
fclose(fp);
|
|
fp = fopen("/tmp/mock_user_app/PPSA90011/sce_sys/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"PPSA90011\",\"contentVersion\":\"05.000.000\",\"titleName\":\"NebulaView\"}\n");
|
|
fclose(fp);
|
|
system("cp /tmp/test_scan_fixtures/nv.pkg /tmp/mock_upgrade_test/");
|
|
setenv("PKG_SCAN_DIR", "/tmp/mock_upgrade_test", 1);
|
|
pkg_scanner_init();
|
|
pkg_scanner_scan();
|
|
char *json_upgrade = pkg_scanner_packages_for_drive_to_json("/tmp/mock_upgrade_test");
|
|
printf("JSON with cumulative base UPGRADE (installed v5.00, pkg v6.00):\n%s\n", json_upgrade);
|
|
/* Newer base package MUST be installable (can_install: true) */
|
|
assert(strstr(json_upgrade, "\"can_install\":true") != NULL);
|
|
free(json_upgrade);
|
|
|
|
|
|
/* Step D2: Simulate newer version installed on console (v7.00), base package on drive is v6.00 */
|
|
fp = fopen("/tmp/mock_appmeta/PPSA90011/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"PPSA90011\",\"contentVersion\":\"07.000.000\",\"titleName\":\"NebulaView\"}\n");
|
|
fclose(fp);
|
|
fp = fopen("/tmp/mock_user_app/PPSA90011/sce_sys/param.json", "w");
|
|
assert(fp != NULL);
|
|
fprintf(fp, "{\"titleId\":\"PPSA90011\",\"contentVersion\":\"07.000.000\",\"titleName\":\"NebulaView\"}\n");
|
|
fclose(fp);
|
|
pkg_scanner_init();
|
|
pkg_scanner_scan();
|
|
char *json_downgrade = pkg_scanner_packages_for_drive_to_json("/tmp/mock_upgrade_test");
|
|
printf("JSON with base installed v7.00 > pkg v6.00:\n%s\n", json_downgrade);
|
|
assert(strstr(json_downgrade, "\"can_install\":false") != NULL);
|
|
assert(strstr(json_downgrade, "Installed version is same or newer") != NULL);
|
|
free(json_downgrade);
|
|
system("rm -rf /tmp/mock_upgrade_test");
|
|
|
|
system("rm -rf /tmp/mock_appmeta /tmp/mock_addcont /tmp/mock_usb_install_test /tmp/mock_user_app");
|
|
unsetenv("PKG_SCAN_DIR");
|
|
unsetenv("PKG_USB_PREFIX");
|
|
unsetenv("PKG_APPMETA_DIR");
|
|
unsetenv("PKG_ADDCONT_DIR");
|
|
unsetenv("PKG_USER_APP_DIR");
|
|
|
|
/* =========================================================================
|
|
* Test 3: Fallback handling for unparsed / non-standard .pkg
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Fallback for Non-Standard .pkg ===\n");
|
|
system("mkdir -p /tmp/test_pkg_scan && echo 'NOT_A_REAL_PKG' > /tmp/test_pkg_scan/CustomGameName_v1.0.pkg");
|
|
setenv("PKG_SCAN_DIR", "/tmp/test_pkg_scan", 1);
|
|
pkg_scanner_init();
|
|
int count2 = pkg_scanner_scan();
|
|
printf("Unparsed test scan count: %d\n", count2);
|
|
assert(count2 == 1);
|
|
|
|
pkg_detail_t unparsed;
|
|
assert(pkg_scanner_get_at(0, &unparsed) == 0);
|
|
printf("Unparsed title_name: %s\n", unparsed.title_name);
|
|
assert(strcmp(unparsed.title_name, "Unknown Package") == 0);
|
|
assert(strstr(unparsed.title_name, "CustomGameName") == NULL); /* Crucial: never extracted from filename! */
|
|
|
|
char *json2 = pkg_scanner_to_json();
|
|
assert(strstr(json2, "Unknown Package") != NULL);
|
|
assert(strstr(json2, "CustomGameName") != NULL);
|
|
free(json2);
|
|
system("rm -rf /tmp/test_pkg_scan");
|
|
|
|
/* =========================================================================
|
|
* Test 4: Drive Scanning, Clickable Logic, and Drive Filtering
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Drive Scanning & Root Directory Isolation ===\n");
|
|
unsetenv("PKG_SCAN_DIR");
|
|
|
|
system("rm -rf /tmp/mock_usb* /tmp/mock_disc*");
|
|
system("mkdir -p /tmp/mock_usb0/nested /tmp/mock_usb1 /tmp/mock_disc");
|
|
system("cp /tmp/test_scan_fixtures/wc.pkg /tmp/mock_usb0/");
|
|
system("cp /tmp/test_scan_fixtures/nv.pkg /tmp/mock_usb0/nested/");
|
|
system("cp /tmp/test_scan_fixtures/nv.pkg /tmp/mock_disc/");
|
|
|
|
setenv("PKG_USB_PREFIX", "/tmp/mock_usb", 1);
|
|
setenv("PKG_DISC_DIR", "/tmp/mock_disc", 1);
|
|
|
|
pkg_scanner_init();
|
|
int drive_scan_pkgs = pkg_scanner_scan();
|
|
printf("Drive scan found %d packages total\n", drive_scan_pkgs);
|
|
|
|
size_t dcount = pkg_scanner_get_drive_count();
|
|
printf("Detected drives count: %zu\n", dcount);
|
|
assert(dcount == 3); /* usb0, usb1, disc */
|
|
|
|
pkg_drive_t drive0, drive1, ddisc;
|
|
assert(pkg_scanner_get_drive_at(0, &drive0) == 0);
|
|
assert(pkg_scanner_get_drive_at(1, &drive1) == 0);
|
|
assert(pkg_scanner_get_drive_at(2, &ddisc) == 0);
|
|
|
|
assert(strcmp(drive0.id, "usb0") == 0);
|
|
assert(drive0.pkg_count == 1); /* Only root pkg! Not nested/ */
|
|
assert(drive0.clickable == 1);
|
|
|
|
assert(strcmp(drive1.id, "usb1") == 0);
|
|
assert(drive1.pkg_count == 0);
|
|
assert(drive1.clickable == 0); /* Must NOT be clickable */
|
|
|
|
assert(strcmp(ddisc.id, "disc") == 0);
|
|
assert(ddisc.pkg_count == 1);
|
|
assert(ddisc.clickable == 1);
|
|
|
|
char *drives_json = pkg_scanner_drives_to_json();
|
|
assert(strstr(drives_json, "\"id\":\"usb0\"") != NULL);
|
|
assert(strstr(drives_json, "\"clickable\":true") != NULL);
|
|
assert(strstr(drives_json, "\"id\":\"usb1\"") != NULL);
|
|
assert(strstr(drives_json, "\"clickable\":false") != NULL);
|
|
assert(strstr(drives_json, "\"id\":\"disc\"") != NULL);
|
|
free(drives_json);
|
|
|
|
char *usb0_pkgs_json = pkg_scanner_packages_for_drive_to_json("usb0");
|
|
assert(strstr(usb0_pkgs_json, "WaveCast") != NULL);
|
|
assert(strstr(usb0_pkgs_json, "NebulaView") == NULL);
|
|
free(usb0_pkgs_json);
|
|
|
|
char *usb1_pkgs_json = pkg_scanner_packages_for_drive_to_json("usb1");
|
|
assert(strcmp(usb1_pkgs_json, "[]") == 0);
|
|
free(usb1_pkgs_json);
|
|
char *disc_pkgs_json = pkg_scanner_packages_for_drive_to_json("disc");
|
|
assert(strstr(disc_pkgs_json, "NebulaView") != NULL);
|
|
assert(strstr(disc_pkgs_json, "WaveCast") == NULL);
|
|
free(disc_pkgs_json);
|
|
|
|
system("rm -rf /tmp/mock_usb* /tmp/mock_disc*");
|
|
unsetenv("PKG_USB_PREFIX");
|
|
unsetenv("PKG_DISC_DIR");
|
|
|
|
/* =========================================================================
|
|
* Test 5: Dotfile and Dot-directory Filtering (._*.pkg, .hidden.pkg, etc.)
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Dotfile Exclusion (.dot.pkg and ._*.pkg) ===\n");
|
|
system("rm -rf /tmp/mock_dotfiles_test");
|
|
system("mkdir -p /tmp/mock_dotfiles_test/.dot_dir");
|
|
system("cp /tmp/test_scan_fixtures/wc.pkg /tmp/mock_dotfiles_test/visible_app.pkg");
|
|
system("cp /tmp/test_scan_fixtures/wc.pkg /tmp/mock_dotfiles_test/._mac_metadata.pkg");
|
|
system("cp /tmp/test_scan_fixtures/wc.pkg /tmp/mock_dotfiles_test/.hidden_app.pkg");
|
|
system("cp /tmp/test_scan_fixtures/wc.pkg /tmp/mock_dotfiles_test/.dot_dir/nested.pkg");
|
|
|
|
setenv("PKG_SCAN_DIR", "/tmp/mock_dotfiles_test", 1);
|
|
pkg_scanner_init();
|
|
int dot_scan_count = pkg_scanner_scan();
|
|
printf("Dotfile scan count: %d (expected: 1)\n", dot_scan_count);
|
|
assert(dot_scan_count == 1);
|
|
|
|
char *dot_json = pkg_scanner_to_json();
|
|
assert(strstr(dot_json, "visible_app.pkg") != NULL);
|
|
assert(strstr(dot_json, "._mac_metadata.pkg") == NULL);
|
|
assert(strstr(dot_json, ".hidden_app.pkg") == NULL);
|
|
assert(strstr(dot_json, "nested.pkg") == NULL);
|
|
free(dot_json);
|
|
|
|
system("rm -rf /tmp/mock_dotfiles_test");
|
|
unsetenv("PKG_SCAN_DIR");
|
|
|
|
/* =========================================================================
|
|
* Test 6: Deep Version Normalization & Comparison Edge Cases
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Version Normalization & Comparison Edge Cases ===\n");
|
|
char norm[32];
|
|
app_info_normalize_version("01.000.003", norm, sizeof(norm));
|
|
assert(strcmp(norm, "v1.03") == 0);
|
|
app_info_normalize_version("06.000.000", norm, sizeof(norm));
|
|
assert(strcmp(norm, "v6.00") == 0);
|
|
app_info_normalize_version("01.06", norm, sizeof(norm));
|
|
assert(strcmp(norm, "v01.06") == 0);
|
|
app_info_normalize_version("v1.03", norm, sizeof(norm));
|
|
assert(strcmp(norm, "v1.03") == 0);
|
|
|
|
/* Symmetrical matching between PS5 3-part format and 2-part normalized versions */
|
|
assert(app_info_compare_versions("01.000.003", "v1.03") == 0);
|
|
assert(app_info_compare_versions("v1.03", "01.000.003") == 0);
|
|
assert(app_info_compare_versions("01.000.003", "v01.03") == 0);
|
|
assert(app_info_compare_versions("01.000.004", "v1.03") > 0);
|
|
assert(app_info_compare_versions("01.000.002", "v1.03") < 0);
|
|
assert(app_info_compare_versions("06.000.000", "v6.00") == 0);
|
|
assert(app_info_compare_versions("v6.00", "05.000.000") > 0);
|
|
assert(app_info_compare_versions("v01.06", "v01.00") > 0);
|
|
assert(app_info_compare_versions("v01.06", "v01.06") == 0);
|
|
assert(app_info_compare_versions("v1.10", "v1.2") > 0);
|
|
|
|
/* =========================================================================
|
|
* Test 7: DLC Substring False-Positive Edge Cases
|
|
* ========================================================================= */
|
|
printf("\n=== Testing DLC Subdirectory Isolation (Preventing False Positives) ===\n");
|
|
system("rm -rf /tmp/mock_addcont_fp");
|
|
system("mkdir -p /tmp/mock_addcont_fp/CUSA12346/00 /tmp/mock_addcont_fp/CUSA12346/temp /tmp/mock_addcont_fp/CUSA12346/UP");
|
|
setenv("PKG_ADDCONT_DIR", "/tmp/mock_addcont_fp", 1);
|
|
unsetenv("PKG_APP_DB_PATH");
|
|
|
|
/* Substrings "00", "temp", "UP" exist in /user/addcont/CUSA12346, but DLC is NOT installed! */
|
|
int dlc_installed = app_info_check_dlc_installed("CUSA12346", "UP1234-CUSA12346_00-TESTPACK0000008");
|
|
printf("DLC installed with substring subdirs: %d (expected: 0)\n", dlc_installed);
|
|
assert(dlc_installed == 0);
|
|
|
|
/* Now add exact DLC label folder */
|
|
system("mkdir -p /tmp/mock_addcont_fp/CUSA12346/TESTPACK0000008");
|
|
dlc_installed = app_info_check_dlc_installed("CUSA12346", "UP1234-CUSA12346_00-TESTPACK0000008");
|
|
printf("DLC installed with exact label subdir: %d (expected: 1)\n", dlc_installed);
|
|
assert(dlc_installed == 1);
|
|
|
|
system("rm -rf /tmp/mock_addcont_fp");
|
|
unsetenv("PKG_ADDCONT_DIR");
|
|
|
|
/* =========================================================================
|
|
* Test 8: SQLite app.db Key-Value and Wide-Column Schema Support
|
|
* ========================================================================= */
|
|
printf("\n=== Testing SQLite app.db Schemas (Key-Value & Wide-Column) ===\n");
|
|
system("rm -f /tmp/mock_test_app.db");
|
|
|
|
/* Create SQLite db with wide-column table (without version column, only appVer) */
|
|
system("python3 -c \"import sqlite3; conn = sqlite3.connect('/tmp/mock_test_app.db'); "
|
|
"conn.execute('CREATE TABLE tbl_appinfo (titleId TEXT, appVer TEXT);'); "
|
|
"conn.execute('INSERT INTO tbl_appinfo VALUES (\\\"PPSA12345\\\", \\\"01.000.003\\\");'); "
|
|
"conn.commit(); conn.close()\"");
|
|
|
|
setenv("PKG_APP_DB_PATH", "/tmp/mock_test_app.db", 1);
|
|
char ver_from_db[32] = {0};
|
|
int app_in_db = app_info_check_installed("PPSA12345", ver_from_db, sizeof(ver_from_db));
|
|
printf("App in wide-column DB: %d, version: %s\n", app_in_db, ver_from_db);
|
|
assert(app_in_db == 1);
|
|
assert(strcmp(ver_from_db, "v1.03") == 0);
|
|
|
|
/* Test key-value table variant */
|
|
system("rm -f /tmp/mock_test_app.db");
|
|
system("python3 -c \"import sqlite3; conn = sqlite3.connect('/tmp/mock_test_app.db'); "
|
|
"conn.execute('CREATE TABLE tbl_appinfo (titleId TEXT, key TEXT, val TEXT);'); "
|
|
"conn.execute('INSERT INTO tbl_appinfo VALUES (\\\"PPSA12346\\\", \\\"APP_VER\\\", \\\"06.000.000\\\");'); "
|
|
"conn.commit(); conn.close()\"");
|
|
|
|
ver_from_db[0] = '\0';
|
|
app_in_db = app_info_check_installed("PPSA12346", ver_from_db, sizeof(ver_from_db));
|
|
printf("App in key-value DB: %d, version: %s\n", app_in_db, ver_from_db);
|
|
assert(app_in_db == 1);
|
|
assert(strcmp(ver_from_db, "v6.00") == 0);
|
|
|
|
/* Test PS5 tbl_contentinfo with AppInfoJson */
|
|
system("rm -f /tmp/mock_test_app.db");
|
|
system("python3 -c \"import sqlite3; conn = sqlite3.connect('/tmp/mock_test_app.db'); "
|
|
"conn.execute('CREATE TABLE tbl_contentinfo (titleId TEXT, AppInfoJson TEXT, metaDataPath TEXT);'); "
|
|
"json_val = '{\\\"field_list\\\":[{\\\"data\\\":\\\"01.06\\\",\\\"key\\\":\\\"APP_VER\\\"},{\\\"data\\\":\\\"01.00\\\",\\\"key\\\":\\\"VERSION\\\"}]}'; "
|
|
"conn.execute('INSERT INTO tbl_contentinfo VALUES (\\\"CUSA12345\\\", ?, \\\"\\\");', (json_val,)); "
|
|
"conn.commit(); conn.close()\"");
|
|
|
|
ver_from_db[0] = '\0';
|
|
app_in_db = app_info_check_installed("CUSA12345", ver_from_db, sizeof(ver_from_db));
|
|
printf("App in tbl_contentinfo with AppInfoJson: %d, version: %s\n", app_in_db, ver_from_db);
|
|
assert(app_in_db == 1);
|
|
assert(app_info_compare_versions(ver_from_db, "v1.06") == 0 || app_info_compare_versions(ver_from_db, "v01.06") == 0);
|
|
|
|
/* Test patch version priority: base app has v1.00 in user/app, patch has v1.06 in user/patch */
|
|
system("rm -rf /tmp/mock_test_user_app /tmp/mock_test_user_patch");
|
|
system("mkdir -p /tmp/mock_test_user_app/CUSA12345/sce_sys /tmp/mock_test_user_patch/CUSA12345/sce_sys");
|
|
FILE *fp_base = fopen("/tmp/mock_test_user_app/CUSA12345/sce_sys/param.json", "w");
|
|
assert(fp_base != NULL);
|
|
fprintf(fp_base, "{\"titleId\":\"CUSA12345\",\"contentVersion\":\"01.000.000\"}\n");
|
|
fclose(fp_base);
|
|
FILE *fp_patch = fopen("/tmp/mock_test_user_patch/CUSA12345/sce_sys/param.json", "w");
|
|
assert(fp_patch != NULL);
|
|
fprintf(fp_patch, "{\"titleId\":\"CUSA12345\",\"contentVersion\":\"01.060.000\"}\n");
|
|
fclose(fp_patch);
|
|
|
|
setenv("PKG_USER_APP_DIR", "/tmp/mock_test_user_app", 1);
|
|
setenv("PKG_USER_PATCH_DIR", "/tmp/mock_test_user_patch", 1);
|
|
unsetenv("PKG_APP_DB_PATH");
|
|
unsetenv("PKG_APPMETA_DIR");
|
|
|
|
char ver_patch_test[32] = {0};
|
|
int installed_patch = app_info_check_installed("CUSA12345", ver_patch_test, sizeof(ver_patch_test));
|
|
printf("App with base v1.00 and patch v1.06 detected version: %s\n", ver_patch_test);
|
|
assert(installed_patch == 1);
|
|
assert(app_info_compare_versions(ver_patch_test, "v1.00") > 0);
|
|
system("rm -rf /tmp/mock_test_user_app /tmp/mock_test_user_patch");
|
|
unsetenv("PKG_USER_APP_DIR");
|
|
unsetenv("PKG_USER_PATCH_DIR");
|
|
system("rm -f /tmp/mock_test_app.db");
|
|
unsetenv("PKG_APP_DB_PATH");
|
|
|
|
/* Test 9: Uninstalled app with leftover appmeta and empty user/app must NOT be detected as installed */
|
|
printf("\n=== Testing Uninstalled App with Leftover Appmeta (CUSA90001 case) ===\n");
|
|
system("rm -rf /tmp/mock_test_user_app /tmp/mock_test_appmeta");
|
|
/* Empty directory in user/app */
|
|
system("mkdir -p /tmp/mock_test_user_app/CUSA90001");
|
|
/* Leftover patch/trophy param.json in appmeta */
|
|
system("mkdir -p /tmp/mock_test_appmeta/CUSA90001");
|
|
FILE *fp_stale = fopen("/tmp/mock_test_appmeta/CUSA90001/param.json", "w");
|
|
assert(fp_stale != NULL);
|
|
fprintf(fp_stale, "{\"titleId\":\"CUSA90001\",\"contentVersion\":\"01.260.000\"}\n");
|
|
fclose(fp_stale);
|
|
|
|
setenv("PKG_USER_APP_DIR", "/tmp/mock_test_user_app", 1);
|
|
setenv("PKG_APPMETA_DIR", "/tmp/mock_test_appmeta", 1);
|
|
unsetenv("PKG_APP_DB_PATH");
|
|
|
|
char ver_uninstalled[32] = {0};
|
|
int is_uninstalled = app_info_check_installed("CUSA90001", ver_uninstalled, sizeof(ver_uninstalled));
|
|
printf("Uninstalled app with stale appmeta detected as installed: %d (expected: 0)\n", is_uninstalled);
|
|
assert(is_uninstalled == 0);
|
|
|
|
system("rm -rf /tmp/mock_test_user_app /tmp/mock_test_appmeta");
|
|
unsetenv("PKG_USER_APP_DIR");
|
|
unsetenv("PKG_APPMETA_DIR");
|
|
|
|
/* =========================================================================
|
|
* Test 10: Partially Installed (Aborted Install) App Detection & Gating
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Partially Installed / Aborted App Detection & Gating ===\n");
|
|
system("rm -f /tmp/mock_partial_app.db");
|
|
system("rm -rf /tmp/mock_partial_scan");
|
|
system("mkdir -p /tmp/mock_partial_scan");
|
|
|
|
/* Create SQLite db with aborted CUSA90002 (contentStatus=1, size=0, _install_sub_status=2) */
|
|
system("python3 -c \"import sqlite3; conn = sqlite3.connect('/tmp/mock_partial_app.db'); "
|
|
"conn.execute('CREATE TABLE tbl_contentinfo (titleId TEXT, AppInfoJson TEXT, metaDataPath TEXT, installStatus INTEGER, contentStatus INTEGER, size INTEGER);'); "
|
|
"json_val = '{\\\"field_list\\\":[{\\\"data\\\":2,\\\"key\\\":\\\"_install_sub_status\\\"},{\\\"data\\\":1,\\\"key\\\":\\\"#_contents_status\\\"}]}'; "
|
|
"conn.execute('INSERT INTO tbl_contentinfo VALUES (\\\"CUSA90002\\\", ?, \\\"\\\", 0, 1, 0);', (json_val,)); "
|
|
"conn.commit(); conn.close()\"");
|
|
|
|
setenv("PKG_APP_DB_PATH", "/tmp/mock_partial_app.db", 1);
|
|
|
|
char partial_ver[32] = {0};
|
|
int is_inst = app_info_check_installed("CUSA90002", partial_ver, sizeof(partial_ver));
|
|
printf("Aborted app detected as installed: %d (expected: 0)\n", is_inst);
|
|
assert(is_inst == 0);
|
|
|
|
char partial_desc[128] = {0};
|
|
int is_part = app_info_check_partially_installed("CUSA90002", partial_desc, sizeof(partial_desc));
|
|
printf("Aborted app detected as partially installed: %d, desc: %s\n", is_part, partial_desc);
|
|
assert(is_part == 1);
|
|
assert(strlen(partial_desc) > 0);
|
|
|
|
/* Copy base and update fixture pkgs to scan dir */
|
|
system("cp /tmp/test_scan_fixtures/bq_base.pkg /tmp/test_scan_fixtures/bq_upd.pkg /tmp/mock_partial_scan/");
|
|
setenv("PKG_SCAN_DIR", "/tmp/mock_partial_scan", 1);
|
|
unsetenv("PKG_USB_PREFIX");
|
|
unsetenv("PKG_APPMETA_DIR");
|
|
unsetenv("PKG_ADDCONT_DIR");
|
|
unsetenv("PKG_USER_APP_DIR");
|
|
|
|
pkg_scanner_init();
|
|
int part_scanned = pkg_scanner_scan();
|
|
assert(part_scanned == 2);
|
|
|
|
char *partial_json = pkg_scanner_packages_for_drive_to_json("/tmp/mock_partial_scan");
|
|
printf("JSON with partially installed app:\n%s\n", partial_json);
|
|
|
|
/* Assertions for JSON:
|
|
* 1. is_partially_installed is true
|
|
* 2. partial_desc is present
|
|
* 3. Base package: can_install is true (allows reinstall!)
|
|
* 4. Update package: can_install is false, disabled_reason contains 'aborted'
|
|
*/
|
|
assert(strstr(partial_json, "\"is_partially_installed\":true") != NULL);
|
|
assert(strstr(partial_json, "\"partial_desc\":\"Incomplete or aborted installation on console\"") != NULL);
|
|
assert(strstr(partial_json, "Base package installation was aborted. Reinstall base package first.") != NULL);
|
|
|
|
/* Verify base package has can_install:true */
|
|
char *base_pos = strstr(partial_json, "bq_base.pkg");
|
|
assert(base_pos != NULL);
|
|
char *can_inst_base = strstr(base_pos, "\"can_install\":true");
|
|
assert(can_inst_base != NULL);
|
|
|
|
/* Verify update package has can_install:false */
|
|
char *upd_pos = strstr(partial_json, "bq_upd.pkg");
|
|
assert(upd_pos != NULL);
|
|
char *can_inst_upd = strstr(upd_pos, "\"can_install\":false");
|
|
assert(can_inst_upd != NULL);
|
|
|
|
free(partial_json);
|
|
|
|
system("rm -f /tmp/mock_partial_app.db");
|
|
system("rm -rf /tmp/mock_partial_scan");
|
|
unsetenv("PKG_SCAN_DIR");
|
|
unsetenv("PKG_APP_DB_PATH");
|
|
|
|
/* =========================================================================
|
|
* Test 11: Manifest Save, Load & Verification
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Manifest Save, Load & Verification ===\n");
|
|
system("rm -rf /tmp/mock_manifest_test /tmp/mock_manifest_cache");
|
|
system("mkdir -p /tmp/mock_manifest_test /tmp/mock_manifest_cache");
|
|
setenv("PKG_CACHE_DIR", "/tmp/mock_manifest_cache", 1);
|
|
setenv("PKG_SCAN_DIR", "/tmp/mock_manifest_test", 1);
|
|
unsetenv("PKG_DISC_DIR");
|
|
unsetenv("PKG_USB_PREFIX");
|
|
|
|
/* Copy 2 example packages */
|
|
system("cp /tmp/test_scan_fixtures/bq_base.pkg /tmp/mock_manifest_test/pkg1.pkg");
|
|
system("cp /tmp/test_scan_fixtures/bq_upd.pkg /tmp/mock_manifest_test/pkg2.pkg");
|
|
|
|
pkg_scanner_init();
|
|
int full_count = pkg_scanner_scan();
|
|
printf("Initial full scan found %d packages\n", full_count);
|
|
assert(full_count == 2);
|
|
|
|
/* Manifest should exist on disk */
|
|
assert(pkg_scanner_has_manifest() == 1);
|
|
|
|
/* Verify manifest.json exists in PKG_CACHE_DIR */
|
|
struct stat mst;
|
|
assert(stat("/tmp/mock_manifest_cache/manifest.json", &mst) == 0);
|
|
assert(mst.st_size > 100);
|
|
|
|
/* Re-initialize scanner: should automatically load from manifest */
|
|
pkg_scanner_init();
|
|
size_t restored_count = pkg_scanner_get_count();
|
|
printf("Restored packages count on init from manifest: %zu\n", restored_count);
|
|
assert(restored_count == 2);
|
|
printf("Restored drive count on init from manifest: %zu\n", pkg_scanner_get_drive_count());
|
|
assert(pkg_scanner_get_drive_count() == 1);
|
|
|
|
pkg_detail_t m_d0;
|
|
assert(pkg_scanner_get_at(0, &m_d0) == 0);
|
|
assert(strcmp(m_d0.title_id, "CUSA90002") == 0);
|
|
|
|
/* =========================================================================
|
|
* Test 12: Quick Rescan (Unchanged Skip, Add, Remove, and Startup Simulation)
|
|
* ========================================================================= */
|
|
printf("\n=== Testing Quick Rescan Behavior ===\n");
|
|
|
|
/* Step A: Quick scan with NO changes -> must return changed == 0 */
|
|
int changed = -1;
|
|
int q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (no changes): count=%d, changed=%d (expected: 2, 0)\n", q_count, changed);
|
|
assert(q_count == 2);
|
|
assert(changed == 0);
|
|
|
|
/* Step B: Add a 3rd package -> quick scan must detect new item and return changed == 1 */
|
|
system("cp /tmp/test_scan_fixtures/ab_bonus.pkg /tmp/mock_manifest_test/pkg3.pkg");
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (new item added): count=%d, changed=%d (expected: 3, 1)\n", q_count, changed);
|
|
assert(q_count == 3);
|
|
assert(changed == 1);
|
|
|
|
/* Step B2: An actively copied package may parse as Unknown Package, and
|
|
* the source listing can be incomplete while the copy is in progress.
|
|
* Preserve the previous catalog until a later scan confirms removals. */
|
|
unlink("/tmp/mock_manifest_test/pkg2.pkg");
|
|
FILE *copying_pkg = fopen("/tmp/mock_manifest_test/copying.pkg", "wb");
|
|
assert(copying_pkg != NULL);
|
|
fputs("partial package data", copying_pkg);
|
|
fclose(copying_pkg);
|
|
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (active copy): count=%d, changed=%d (expected: 4, 1)\n", q_count, changed);
|
|
assert(q_count == 4);
|
|
assert(changed == 1);
|
|
char *copying_json = pkg_scanner_to_json();
|
|
assert(strstr(copying_json, "pkg2.pkg") != NULL); /* preserved */
|
|
assert(strstr(copying_json, "copying.pkg") != NULL);
|
|
assert(strstr(copying_json, "Unknown Package") != NULL);
|
|
free(copying_json);
|
|
|
|
/* Once the copy disappears and the valid package is restored, the
|
|
* following non-provisional scan is allowed to purge the old entry. */
|
|
unlink("/tmp/mock_manifest_test/copying.pkg");
|
|
system("cp /tmp/test_scan_fixtures/bq_upd.pkg /tmp/mock_manifest_test/pkg2.pkg");
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
assert(q_count == 3);
|
|
assert(changed == 1);
|
|
|
|
/* Step C: Quick scan again -> must return changed == 0 */
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (second run): count=%d, changed=%d (expected: 3, 0)\n", q_count, changed);
|
|
assert(q_count == 3);
|
|
assert(changed == 0);
|
|
|
|
/* Step D: Remove 1 package -> quick scan must purge removed item and return changed == 1 */
|
|
unlink("/tmp/mock_manifest_test/pkg1.pkg");
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (item removed): count=%d, changed=%d (expected: 2, 1)\n", q_count, changed);
|
|
assert(q_count == 2);
|
|
assert(changed == 1);
|
|
|
|
/* Step E: Modify file mtime -> quick scan must re-parse and return changed == 1 */
|
|
system("touch -t 202001010000 /tmp/mock_manifest_test/pkg2.pkg");
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (item modified): count=%d, changed=%d (expected: 2, 1)\n", q_count, changed);
|
|
assert(q_count == 2);
|
|
assert(changed == 1);
|
|
|
|
/* Step F: Specific drive targeting */
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick("usb0", &changed);
|
|
assert(q_count == 2);
|
|
assert(changed == 0);
|
|
|
|
/* Step G: Startup emulation: init loads manifest, quick scan skips unchanged */
|
|
pkg_scanner_init();
|
|
assert(pkg_scanner_get_count() == 2);
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
assert(q_count == 2);
|
|
assert(changed == 0);
|
|
|
|
/* Step H: Multi-part package handling in quick scan */
|
|
system("python3 tools/pkg_split.py /tmp/test_scan_big.pkg -o /tmp/mock_manifest_test -s 60M > /dev/null 2>&1");
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (multipart added): count=%d, changed=%d (expected: 3, 1)\n", q_count, changed);
|
|
assert(q_count == 3);
|
|
assert(changed == 1);
|
|
|
|
/* Unchanged multi-part package MUST skip re-parsing on subsequent quick scan */
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick(NULL, &changed);
|
|
printf("Quick scan (multipart unchanged): count=%d, changed=%d (expected: 3, 0)\n", q_count, changed);
|
|
assert(q_count == 3);
|
|
assert(changed == 0);
|
|
|
|
/* Step I: __all__ packages serialization */
|
|
char *all_json = pkg_scanner_packages_for_drive_to_json("__all__");
|
|
assert(all_json != NULL);
|
|
assert(strstr(all_json, "CUSA90002") != NULL);
|
|
assert(strstr(all_json, "PPSA90010") != NULL);
|
|
free(all_json);
|
|
|
|
/* Step J: Empty drive invariance: quick scan on empty mounted drive must return changed == 0 */
|
|
system("mkdir -p /tmp/mock_usb_empty0");
|
|
unsetenv("PKG_SCAN_DIR");
|
|
setenv("PKG_USB_PREFIX", "/tmp/mock_usb_empty", 1);
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick("usb0", &changed);
|
|
/* Empty drive with no files matches 0 recorded files -> changed == 0 */
|
|
printf("Quick scan (empty drive mounted): count=%d, changed=%d (expected: 0)\n", q_count, changed);
|
|
assert(changed == 0);
|
|
|
|
/* Second quick scan on unchanged empty drive MUST return changed == 0 */
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick("usb0", &changed);
|
|
printf("Quick scan (empty drive unchanged): count=%d, changed=%d (expected: 0)\n", q_count, changed);
|
|
assert(changed == 0);
|
|
|
|
/* Step K: Unmount empty drive -> quick scan must detect unmount (changed == 1) */
|
|
system("rm -rf /tmp/mock_usb_empty0");
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick("usb0", &changed);
|
|
printf("Quick scan (empty drive unmounted): count=%d, changed=%d (expected: 1)\n", q_count, changed);
|
|
assert(changed == 1);
|
|
|
|
/* Subsequent scan with drive remaining unmounted MUST return changed == 0 */
|
|
changed = -1;
|
|
q_count = pkg_scanner_scan_quick("usb0", &changed);
|
|
printf("Quick scan (drive remaining unmounted): count=%d, changed=%d (expected: 0)\n", q_count, changed);
|
|
assert(changed == 0);
|
|
|
|
/* Step L: Multi-Language Package Scanning & Accept-Language Resolution */
|
|
printf("\n=== Testing Multi-Language Package Scanning & Accept-Language Resolution ===\n");
|
|
system("mkdir -p /tmp/mock_multilang_test");
|
|
assert(fixture_write_ps5_pkg_multilang("/tmp/mock_multilang_test/ml.pkg",
|
|
"PPSA90099", "en-US", "gd", "01.000.000", 0) == 0);
|
|
assert(fixture_write_ps5_pkg("/tmp/mock_multilang_test/single.pkg",
|
|
"PPSA90011", "SingleLangGame", "gd", "01.000.000", 0) == 0);
|
|
|
|
setenv("PKG_SCAN_DIR", "/tmp/mock_multilang_test", 1);
|
|
pkg_scanner_init();
|
|
int ml_count = pkg_scanner_scan();
|
|
assert(ml_count == 2);
|
|
|
|
/* Without Accept-Language, defaults to defaultLanguage ("English Title") */
|
|
char *json_def = pkg_scanner_packages_for_drive_to_json_ex("/tmp/mock_multilang_test", NULL);
|
|
assert(json_def != NULL);
|
|
assert(strstr(json_def, "\"title_name\":\"English Title\"") != NULL);
|
|
assert(strstr(json_def, "\"default_language\":\"en-US\"") != NULL);
|
|
assert(strstr(json_def, "\"localized_titles\":{") != NULL);
|
|
assert(strstr(json_def, "\"ar-AE\":\"Arabic Title\"") != NULL);
|
|
assert(strstr(json_def, "\"title_name\":\"SingleLangGame\"") != NULL);
|
|
free(json_def);
|
|
|
|
/* With Accept-Language: ar, resolves to Arabic Title */
|
|
char *json_ar = pkg_scanner_packages_for_drive_to_json_ex("/tmp/mock_multilang_test", "ar-AE,ar;q=0.9");
|
|
assert(json_ar != NULL);
|
|
assert(strstr(json_ar, "\"title_name\":\"Arabic Title\"") != NULL);
|
|
assert(strstr(json_ar, "\"title_name\":\"SingleLangGame\"") != NULL);
|
|
free(json_ar);
|
|
|
|
/* With Accept-Language: pl, resolves to Polish Title */
|
|
char *json_pl = pkg_scanner_packages_for_drive_to_json_ex("/tmp/mock_multilang_test", "pl-PL,pl;q=0.8");
|
|
assert(json_pl != NULL);
|
|
assert(strstr(json_pl, "\"title_name\":\"Polish Title\"") != NULL);
|
|
assert(strstr(json_pl, "\"title_name\":\"SingleLangGame\"") != NULL);
|
|
free(json_pl);
|
|
|
|
/* With unsupported Accept-Language (e.g. ja), falls back to defaultLanguage */
|
|
char *json_ja = pkg_scanner_packages_for_drive_to_json_ex("/tmp/mock_multilang_test", "ja-JP,ja;q=0.9");
|
|
assert(json_ja != NULL);
|
|
assert(strstr(json_ja, "\"title_name\":\"English Title\"") != NULL);
|
|
free(json_ja);
|
|
|
|
system("rm -rf /tmp/mock_multilang_test");
|
|
system("rm -rf /tmp/mock_manifest_test /tmp/mock_manifest_cache /tmp/mock_usb_empty*");
|
|
unsetenv("PKG_CACHE_DIR");
|
|
unsetenv("PKG_SCAN_DIR");
|
|
unsetenv("PKG_USB_PREFIX");
|
|
unsetenv("PKG_DISC_DIR");
|
|
|
|
printf("\n>>> ALL PKG SCANNER TESTS PASSED! <<<\n");
|
|
return 0;
|
|
}
|