diff --git a/evidence/permissions-0777/capture.json b/evidence/permissions-0777/capture.json new file mode 100644 index 0000000..42fe7fd --- /dev/null +++ b/evidence/permissions-0777/capture.json @@ -0,0 +1,16 @@ +{ + "step": "permissions-0777", + "recorded": "2026-10-07T04:00:46Z", + "console_run": "base PS5 13.40, home-screen launches (picker), FTP audit of /data/homebrew/PPSA99169", + "revision": "working tree on cc5ded2 (this commit)", + "command": "tools/run-title.sh --no-build --home-launch --watch 30; FTP LIST walk of the title folder counting modes other than rwxrwxrwx", + "raw_capture": "klog/permissions-0777/audit-20261007.txt", + "raw_lines": 4, + "raw_bytes": 257, + "lines": [ + "before (console, 2026-10-07): 17993 entries, 175 not 0777: -rw-rw-rw- 175", + "permissions: /app0 checked=17992 repaired=174 refused=0 in 589.8 ms", + "permissions: handover waited 596.2 ms for the repair", + "after picker-held launch and close: 17992 entries, 0 not 0777" + ] +} diff --git a/evidence/permissions-0777/expectation.json b/evidence/permissions-0777/expectation.json new file mode 100644 index 0000000..727dbc4 --- /dev/null +++ b/evidence/permissions-0777/expectation.json @@ -0,0 +1,10 @@ +{ + "step": "permissions-0777", + "note": "Every file and folder the title, a core or a frontend makes is 0777; the start-up repair finishes before any handover (it was cut short by LoadExec into the picker or ES-DE); the picker opens RADV's shader cache to FTP after its first frames.", + "must_contain": [ + "permissions: handover waited", + "after picker-held launch and close: 17992 entries, 0 not 0777" + ], + "must_not_contain": [], + "human_check": "FTP audit by the agent over the whole title folder." +} diff --git a/frontends/es-de/ps5/library_ps5.cpp b/frontends/es-de/ps5/library_ps5.cpp index 21a6e2f..dfb484a 100644 --- a/frontends/es-de/ps5/library_ps5.cpp +++ b/frontends/es-de/ps5/library_ps5.cpp @@ -172,7 +172,7 @@ bool save(pugi::xml_document &document, const std::string &path) const std::string temporary = path + ".tmp"; if (!document.save_file(temporary.c_str(), "\t", pugi::format_default, pugi::encoding_utf8)) return false; - chmod(temporary.c_str(), 0666); + chmod(temporary.c_str(), 0777); /* FTP's reach: the title's files are 0777 */ if (std::rename(temporary.c_str(), path.c_str()) != 0) { std::remove(temporary.c_str()); @@ -195,8 +195,12 @@ extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *pa } const std::map reference = read_reference(paths->reference); const std::string data = paths->data; - mkdir((data + "/custom_systems").c_str(), 0777); - mkdir((data + "/gamelists").c_str(), 0777); + /* Folders 0777 whatever the umask, for FTP's reach (src/permissions_ps5.cpp). */ + for (const char *folder : {"/custom_systems", "/gamelists"}) + { + mkdir((data + folder).c_str(), 0777); + chmod((data + folder).c_str(), 0777); + } enable_collections(data); /* RetroArch's favourites as they were at the last start. */ const std::string favorites_record = data + "/ps5-favorites.txt"; @@ -243,6 +247,7 @@ extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *pa const std::string folder = data + "/gamelists/" + name; const std::string gamelist = folder + "/gamelist.xml"; mkdir(folder.c_str(), 0777); + chmod(folder.c_str(), 0777); pugi::xml_document previous, games; previous.load_file(gamelist.c_str()); std::map known_games; diff --git a/frontends/picker/picker.cpp b/frontends/picker/picker.cpp index 7872661..4d7904f 100644 --- a/frontends/picker/picker.cpp +++ b/frontends/picker/picker.cpp @@ -37,6 +37,8 @@ #include "kit.hpp" #include "ps5_frontend_choice.h" +#include +#include extern "C" int sceSystemServiceLoadExec(const char *path, const char *const *argv); extern "C" int sceKernelUsleep(uint32_t microseconds); @@ -109,8 +111,11 @@ std::vector readPreview(const char *path, int &width, int &height) return pixels; } +void openForFtp(const char *path, int depth); // below + class VulkanExample : public ps5ui::KitExample { + unsigned framesSinceStart = 0; // for opening the shader cache to FTP public: int focus{ 0 }; int chosen{ -2 }; // -2: still choosing, -1: close, else the frontend @@ -327,6 +332,11 @@ public: compose(); buildKitCommandBuffer(); submitFrame(); + // Once the first frames are drawn RADV has written its shader cache: open it + // to FTP now, as the title may be closed from here without a choice. + if (++framesSinceStart == 30) { + openForFtp("/app0/radv-shader-cache", 6); + } if (chosen != -2) { nextFrontend = chosen; rememberChoice = remember; @@ -342,6 +352,30 @@ public: } }; +// What the picker wrote below a folder is given 0777, as eboot.bin gives everything +// under /app0 (src/permissions_ps5.cpp): this executable has none of the title's +// wrappers, and RADV writes its shader cache 0666. Without it FTP met those files +// as the picker left them until eboot.bin's next start. +void openForFtp(const char *path, int depth) +{ + chmod(path, 0777); + DIR *directory = depth > 0 ? ps5_opendir(path) : nullptr; + if (!directory) { + return; + } + while (const struct dirent *entry = ps5_readdir(directory)) { + if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) { + continue; + } + char child[1024]; + const int length = std::snprintf(child, sizeof(child), "%s/%s", path, entry->d_name); + if (length > 0 && static_cast(length) < sizeof(child)) { + openForFtp(child, depth - 1); + } + } + ps5_closedir(directory); +} + } // namespace // Called by the title's main once the program has ended and released its device @@ -349,6 +383,7 @@ public: extern "C" void ps5_title_next(void) { const Frontend *next = nextFrontend >= 0 && nextFrontend < cardCount ? &frontends[nextFrontend] : nullptr; + openForFtp("/app0/radv-shader-cache", 6); // the device is released: the cache is final // Starting a frontend records Remember: that frontend, or "ask"; closing leaves it if (next) { const int written = ps5_frontend_choice_write(PS5_FRONTEND_CHOICE_PATH, rememberChoice ? next->id : "ask"); @@ -358,7 +393,7 @@ extern "C" void ps5_title_next(void) if (FILE *record = std::fopen(testRecord, "a")) { std::fprintf(record, "{%s,\"next\":\"%s\"}\n", testLine.c_str(), next ? next->next : ""); std::fclose(record); - chmod(testRecord, 0666); + chmod(testRecord, 0777); } } if (!next) { diff --git a/src/frontend_mode_ps5.cpp b/src/frontend_mode_ps5.cpp index 497bff3..3536a4a 100644 --- a/src/frontend_mode_ps5.cpp +++ b/src/frontend_mode_ps5.cpp @@ -68,6 +68,8 @@ struct ps5_game game; bool game_running = false; std::time_t game_started = 0; +extern "C" void ps5_permissions_settle(); /* src/permissions_ps5.cpp */ + bool exists(const std::string &path) { std::FILE *file = std::fopen(path.c_str(), "rb"); @@ -82,6 +84,7 @@ void restart_as(const std::string &image, const char *argument, unsigned replace const char *failure) { const char *const arguments[] = {argument, nullptr}; + ps5_permissions_settle(); std::fflush(nullptr); const int result = sceSystemServiceLoadExec(image.c_str(), arguments); if (result >= 0) @@ -99,6 +102,7 @@ void restart_as(const std::string &image, unsigned replaced_wait_seconds, const * that did not happen. */ void close_title(unsigned replaced_wait_seconds) { + ps5_permissions_settle(); std::fflush(nullptr); const int result = sceSystemServiceLoadExec("exit", nullptr); if (result >= 0) diff --git a/src/main.cpp b/src/main.cpp index fc3efc6..06a8373 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -68,6 +68,7 @@ extern "C" void ps5_crash_report_install(); extern "C" void ps5_core_threads_start(); extern "C" void ps5_sampler_start(); extern "C" void ps5_open_permissions(); +extern "C" void ps5_permissions_settle(); /* ../PS5_Vulkan's driver/ps5vk_debug.h: whether VideoOut outlives a swapchain. * Weak, so a build without the driver links. */ extern "C" void ps5vk_display_retain(bool retain) __attribute__((weak)); @@ -636,6 +637,7 @@ extern "C" void catchReturnFromMain(int status) /* A game mode game goes back to its frontend, and RetroArch the picker started to * the picker (src/frontend_mode_ps5.cpp). */ ps5_frontend_after_retroarch(status, ps5_update::exit_requested() ? 1 : 0); + ps5_permissions_settle(); std::fflush(nullptr); const int result = sceSystemServiceLoadExec("exit", nullptr); ps5::debug::mark_value("native quit: system service result", result); diff --git a/src/permissions_ps5.cpp b/src/permissions_ps5.cpp index c8fcf3d..7ffd59c 100644 --- a/src/permissions_ps5.cpp +++ b/src/permissions_ps5.cpp @@ -14,14 +14,22 @@ * (2026-09-25). src/frontend_ps5.cpp already gave the seven top-level folders * 0777; everything below them kept RetroArch's modes. * - * So every folder the title or a core creates is 0777 and every file at least - * 0666: mkdir, open and fopen are wrapped at link time (tools/build-title.sh; a + * So every folder and every file the title or a core creates is 0777 (files were + * 0666 until users of v0.6.7 met FTP refusals again, 2026-10-07): mkdir, open + * and fopen are wrapped at link time (tools/build-title.sh; a * core's own imports of them bind to the same wrappers) and give what they * create those bits after creating it, which the umask cannot take away. The * console's libraries do not resolve umask itself: a first build that cleared * it jumped to address 0 at start. What an earlier build made is repaired at * start: every folder and file under /app0 that lacks those bits is given * them. Only permission bits change; no file's contents are touched. + * + * The repair runs beside the frontend's start, and since v0.6.7 the title usually + * hands itself over at once - to the picker or EmulationStation, through + * LoadExec, which ends this process - so the walk was cut short and what + * EmulationStation and the picker wrote (they are other executables, without + * these wrappers) stayed out of FTP's reach. ps5_permissions_settle waits for the + * walk to finish; every handover calls it first. */ #include @@ -31,7 +39,9 @@ #include #include +#include #include +#include #include #include @@ -45,7 +55,7 @@ extern "C" std::FILE *__real_fopen(const char *path, const char *mode); namespace { constexpr mode_t kFolderMode = 0777; -constexpr mode_t kFileMode = 0666; +constexpr mode_t kFileMode = 0777; /* Deeper than any folder the title or a core makes (Dolphin's user folder is * the deepest, about six below /app0). */ constexpr int kRepairDepth = 16; @@ -158,6 +168,8 @@ namespace * console, 389 ms -- so it runs beside the frontend's start rather than before * it. Which of them the title made cannot be told from inside it: /app0's * stat reports every entry as uid 0, the FTP account's own as well. */ +std::atomic repair_state{0}; /* 0 not started, 1 walking, 2 done */ + void *repair_thread(void *) { const double start = now_ms(); @@ -168,6 +180,7 @@ void *repair_thread(void *) "permissions: /app0 checked=%u repaired=%u refused=%u in %.1f ms", counts.checked, counts.repaired, counts.refused, now_ms() - start); ps5::debug::mark(line); + repair_state.store(2); return nullptr; } } // namespace @@ -176,8 +189,26 @@ void *repair_thread(void *) extern "C" void ps5_open_permissions() { pthread_t thread; + repair_state.store(1); if (create_title_thread(&thread, repair_thread, nullptr) == 0) pthread_detach(thread); else + { + repair_state.store(0); ps5::debug::mark("permissions: the repair thread could not start"); + } +} + +/* Waits, at most a minute, for the repair to finish; before any LoadExec. */ +extern "C" void ps5_permissions_settle() +{ + if (repair_state.load() != 1) + return; + const double start = now_ms(); + while (repair_state.load() == 1 && now_ms() - start < 60000.0) + usleep(10000); + char line[96]; + std::snprintf(line, sizeof(line), "permissions: handover waited %.1f ms for the repair%s", + now_ms() - start, repair_state.load() == 2 ? "" : " (gave up)"); + ps5::debug::mark(line); } diff --git a/src/ps5_frontend_choice.h b/src/ps5_frontend_choice.h index 66f8255..8ec14aa 100644 --- a/src/ps5_frontend_choice.h +++ b/src/ps5_frontend_choice.h @@ -73,7 +73,7 @@ static inline int ps5_frontend_choice_write(const char *path, const char *choice return -1; } /* FTP runs as another user: the file stays reachable, as the title's others are. */ - chmod(temporary, 0666); + chmod(temporary, 0777); /* FTP's reach: the title's files are 0777 */ if (rename(temporary, path) != 0) { remove(temporary); diff --git a/src/relaunch_ps5.cpp b/src/relaunch_ps5.cpp index 2e30de8..151bfea 100644 --- a/src/relaunch_ps5.cpp +++ b/src/relaunch_ps5.cpp @@ -24,6 +24,7 @@ * error, or that is accepted but leaves this process running, is recorded too, and * the test is disarmed so the launch still ends in RetroArch. */ +extern "C" void ps5_permissions_settle(); /* src/permissions_ps5.cpp */ #include "relaunch_ps5.h" #include @@ -242,6 +243,7 @@ bool run_test(const Paths &paths, int argc, char **argv, unsigned replaced_wait_ const char *const arguments[] = {next.c_str(), nullptr}; std::fflush(nullptr); const std::string &image = arm.image.empty() ? paths.image : arm.image; + ps5_permissions_settle(); const int result = sceSystemServiceLoadExec(image.c_str(), arguments); std::string event = line_start(arm.run, current) + ",\"event\":\"loadexec\",\"result\":" + std::to_string(result); diff --git a/tests/frontend_mode_ps5_test.cpp b/tests/frontend_mode_ps5_test.cpp index de7e0d1..d7d3253 100644 --- a/tests/frontend_mode_ps5_test.cpp +++ b/tests/frontend_mode_ps5_test.cpp @@ -86,6 +86,10 @@ std::string launch(const ps5::frontend_mode::Paths &paths, std::vector argv, b } } // namespace +/* src/permissions_ps5.cpp: the repair is the title's; here it has nothing to wait for. */ +extern "C" void ps5_permissions_settle() +{ +} extern "C" int sceSystemServiceLoadExec(const char *path, const char *const *argv) { exec_calls++;