Merge pull request #1321 from TibixDev/pr/guest-path-case

fix(libc): resolve case differences in guest paths and mounts
This commit is contained in:
Adrià 2026-10-08 15:01:28 +02:00 committed by GitHub
commit 79d23eb76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 148 additions and 10 deletions

View File

@ -160,6 +160,15 @@ if(BUILD_TESTING)
set_tests_properties(guest_working_directory PROPERTIES ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
endif()
add_test_executable(guest_path_case_tests ${LIBS_INCLUDE_DIR}/tests/GuestPathCase.cpp)
target_include_directories(guest_path_case_tests PRIVATE ${LIBS_INCLUDE_DIR})
target_link_libraries(guest_path_case_tests PRIVATE libc libkernel)
configure_windows_unwind(guest_path_case_tests)
add_test(NAME guest_path_case COMMAND guest_path_case_tests)
if(WIN32)
set_tests_properties(guest_path_case PROPERTIES ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:libc>")
endif()
add_test_executable(guest_directory_tests ${LIBS_INCLUDE_DIR}/tests/GuestDirectory.cpp)
target_include_directories(guest_directory_tests PRIVATE ${LIBS_INCLUDE_DIR})
target_link_libraries(guest_directory_tests PRIVATE libc)

View File

@ -1,6 +1,7 @@
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <filesystem>
@ -52,19 +53,54 @@ std::string TrimSlashes(const char* path) {
return s.substr(start, end - start);
}
bool SameName(std::string_view left, std::string_view right) {
if (left.size() != right.size()) return false;
const auto fold = [](unsigned char value) { return value >= 'A' && value <= 'Z' ? value + ('a' - 'A') : value; };
for (std::size_t i = 0; i < left.size(); ++i) if (fold(left[i]) != fold(right[i])) return false;
return true;
}
bool HasPrefix(const std::string& path, const std::string& prefix) {
return path.size() >= prefix.size() && SameName(std::string_view(path).substr(0, prefix.size()), prefix) &&
(path.size() == prefix.size() || path[prefix.size()] == '/');
}
std::filesystem::path ResolveHostPath(std::filesystem::path root, const std::filesystem::path& relative) {
const auto direct = root / relative;
std::error_code error;
if (std::filesystem::exists(direct, error)) return direct;
for (const auto& part : relative) {
const auto candidate = root / part;
const auto status = std::filesystem::symlink_status(candidate, error);
if (std::filesystem::exists(status) || (error && error != std::errc::no_such_file_or_directory)) {
root = candidate;
continue;
}
std::optional<std::filesystem::path> matched;
const auto name = part.string();
std::filesystem::directory_iterator entry(root, error), end;
for (; !error && entry != end; entry.increment(error)) {
if (!SameName(entry->path().filename().string(), name)) continue;
if (matched) throw std::runtime_error("Ambiguous case-insensitive guest path: " + candidate.string());
matched = entry->path();
}
root = !error && matched ? *matched : candidate;
}
return root;
}
std::optional<std::filesystem::path> ResolveAlias(const std::string& guestPath) {
const auto relative = TrimSlashes(guestPath.c_str());
auto& aliases = Aliases();
std::lock_guard lock(aliases.mutex);
for (const auto& prefix : aliases.blocked) {
if (relative == prefix || (relative.size() > prefix.size() && relative.compare(0, prefix.size(), prefix) == 0 && relative[prefix.size()] == '/'))
if (HasPrefix(relative, prefix))
throw std::filesystem::filesystem_error("Guest mount is unavailable", guestPath, std::make_error_code(std::errc::no_such_file_or_directory));
}
for (const auto& [prefix, host] : aliases.entries) {
if (relative.size() < prefix.size() || relative.compare(0, prefix.size(), prefix) != 0) continue;
if (!HasPrefix(relative, prefix)) continue;
if (relative.size() == prefix.size()) return std::filesystem::path(host).make_preferred();
if (relative[prefix.size()] != '/') continue;
std::filesystem::path result = std::filesystem::path(host) / std::filesystem::path(relative.substr(prefix.size() + 1));
std::filesystem::path result = ResolveHostPath(host, relative.substr(prefix.size() + 1));
return result.make_preferred();
}
return std::nullopt;
@ -87,7 +123,7 @@ std::filesystem::path Resolve(WorkingDirectory& state, const char* path) {
auto guest = (std::filesystem::path("/") / state.current.lexically_relative(state.root));
guest = (input.is_absolute() ? input : guest / input).lexically_normal();
if (auto aliased = ResolveAlias(guest.relative_path().generic_string())) return *aliased;
return (state.root / guest.relative_path()).make_preferred();
return ResolveHostPath(state.root, guest.relative_path()).make_preferred();
}
struct WrittenPathRegistry {
std::mutex syncMutex;
@ -141,9 +177,9 @@ extern "C" void AddPathAlias_nid_no_patch(const char* guestPrefix, const char* h
auto& aliases = Aliases();
std::lock_guard lock(aliases.mutex);
const auto prefix = TrimSlashes(guestPrefix);
std::erase(aliases.blocked, prefix);
std::erase_if(aliases.blocked, [&](const auto& entry) { return SameName(entry, prefix); });
for (auto& entry : aliases.entries) {
if (entry.first == prefix) {
if (SameName(entry.first, prefix)) {
entry.second = hostPath;
return;
}
@ -158,7 +194,7 @@ extern "C" void RemovePathAlias_nid_no_patch(const char* guestPrefix) {
auto& aliases = Aliases();
std::lock_guard lock(aliases.mutex);
const auto prefix = TrimSlashes(guestPrefix);
std::erase_if(aliases.entries, [&](const auto& entry) { return entry.first == prefix; });
std::erase_if(aliases.entries, [&](const auto& entry) { return SameName(entry.first, prefix); });
}
extern "C" void RecordWrittenPath_nid_no_patch(const std::filesystem::path& path) {
@ -223,8 +259,8 @@ extern "C" void BlockPathAlias_nid_no_patch(const char* guestPrefix) {
if (prefix.empty()) { APS5_INVALID_ARG_EX; }
auto& aliases = Aliases();
std::lock_guard lock(aliases.mutex);
std::erase_if(aliases.entries, [&](const auto& entry) { return entry.first == prefix; });
std::erase(aliases.blocked, prefix);
std::erase_if(aliases.entries, [&](const auto& entry) { return SameName(entry.first, prefix); });
std::erase_if(aliases.blocked, [&](const auto& entry) { return SameName(entry, prefix); });
aliases.blocked.push_back(prefix);
}

View File

@ -0,0 +1,92 @@
#include "prx/libc/include/General.hpp"
#include "prx/libc/include/FileStream.hpp"
#include "prx/libkernel/File/include/FileFlags.hpp"
#include "SceTypes.hpp"
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <stdexcept>
extern "C" {
int APS5_VABI sceKernelOpen(const char*, int, std::uint16_t);
int APS5_VABI sceKernelClose(int);
std::int64_t APS5_VABI sceKernelRead(int, void*, std::size_t);
std::int64_t APS5_VABI sceKernelWrite(int, const void*, std::size_t);
int APS5_VABI sceKernelStat(const char*, FileStat*);
int APS5_VABI chdir_nid_postfix(const char*);
int APS5_VABI access_nid_postfix(const char*, int);
int APS5_VABI rename_nid_postfix(const char*, const char*);
int APS5_VABI remove_nid_postfix(const char*);
FileStream* APS5_VABI fopen_nid_postfix(const char*, const char*);
std::size_t APS5_VABI fread_nid_postfix(void*, std::size_t, std::size_t, FileStream*);
int APS5_VABI fclose_nid_postfix(FileStream*);
}
static void Require(bool value) { if (!value) std::abort(); }
static void CheckRead(const std::string& path, char expected) {
const int fd = sceKernelOpen(path.c_str(), SCE_KERNEL_O_RDONLY, 0);
Require(fd >= 0);
char value = 0;
Require(sceKernelRead(fd, &value, 1) == 1 && value == expected);
Require(sceKernelClose(fd) == 0);
}
int main() {
const auto host = std::filesystem::canonical(std::filesystem::current_path());
const auto name = "AnyPS5-Case-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const auto directory = host / name;
Require(std::filesystem::create_directories(directory / "Data" / "Nested"));
{ std::ofstream file(directory / "Data" / "Settings.ini"); file << 'x'; }
auto lower = name;
for (auto& c : lower) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
CheckRead("/" + lower + "/dAtA/SETTINGS.INI", 'x');
FileStat stat{};
Require(sceKernelStat(("/" + lower + "/DATA/settings.INI").c_str(), &stat) == 0);
Require(chdir_nid_postfix(lower.c_str()) == 0);
Require(access_nid_postfix("DATA/SETTINGS.ini", 4) == 0);
auto* stream = fopen_nid_postfix("data\\SETTINGS.INI", "rb");
char value = 0;
Require(stream && fread_nid_postfix(&value, 1, 1, stream) == 1 && value == 'x');
Require(fclose_nid_postfix(stream) == 0);
Require(chdir_nid_postfix("DATA/../data") == 0);
CheckRead("settings.INI", 'x');
Require(chdir_nid_postfix("..") == 0);
const int created = sceKernelOpen("data/nested/New.DAT", SCE_KERNEL_O_CREAT | SCE_KERNEL_O_EXCL | SCE_KERNEL_O_WRONLY, 0600);
Require(created >= 0 && sceKernelWrite(created, "y", 1) == 1 && sceKernelClose(created) == 0);
Require(std::filesystem::exists(directory / "Data" / "Nested" / "New.DAT"));
CheckRead("DATA/NESTED/new.dat", 'y');
Require(sceKernelOpen("data/nested/NEW.dat", SCE_KERNEL_O_CREAT | SCE_KERNEL_O_EXCL | SCE_KERNEL_O_WRONLY, 0600) == static_cast<int>(0x80020011u));
Require(rename_nid_postfix("DATA/NESTED/new.dat", "data/nested/Moved.DAT") == 0);
Require(remove_nid_postfix("DATA/NESTED/moved.dat") == 0);
Require(sceKernelOpen("data/nested/missing", SCE_KERNEL_O_RDONLY, 0) == static_cast<int>(0x80020002u));
AddPathAlias_nid_no_patch("case-mount", (directory / "Data").string().c_str());
CheckRead("/CASE-MOUNT/settings.INI", 'x');
Require(access_nid_postfix("/case-mount-other/Settings.ini", 0) == -1);
BlockPathAlias_nid_no_patch("CASE-MOUNT");
Require(access_nid_postfix("/case-mount/settings.ini", 0) == -1);
AddPathAlias_nid_no_patch("Case-Mount", (directory / "Data").string().c_str());
CheckRead("/case-MOUNT/settings.INI", 'x');
RemovePathAlias_nid_no_patch("CASE-mount");
Require(access_nid_postfix("/case-mount/settings.ini", 0) == -1);
#ifndef _WIN32
std::filesystem::create_directory_symlink("Data", directory / "Linked");
CheckRead("linked/SETTINGS.INI", 'x');
{ std::ofstream file(directory / "Data" / "SETTINGS.INI"); file << 'z'; }
CheckRead("Data/Settings.ini", 'x');
CheckRead("Data/SETTINGS.INI", 'z');
bool ambiguous = false;
try { CheckRead("data/settings.ini", 'x'); } catch (const std::runtime_error&) { ambiguous = true; }
Require(ambiguous);
std::filesystem::remove(directory / "Data" / "SETTINGS.INI");
std::filesystem::remove(directory / "Data" / "Settings.ini");
{ std::ofstream file(directory / "Data" / "SETTINGS.ini"); file << 'n'; }
CheckRead("data/settings.ini", 'n');
std::filesystem::create_symlink("absent", directory / "Dangling");
Require(sceKernelOpen("dangling", SCE_KERNEL_O_RDONLY, 0) == static_cast<int>(0x80020002u));
#endif
Require(chdir_nid_postfix("/") == 0);
Require(std::filesystem::current_path() == host);
std::filesystem::remove_all(directory);
}

View File

@ -327,3 +327,4 @@ Throughout the project, every function at every stage either **does exactly what
- [Player review dialog](../../core/libs/prx/libSceCdlgPlayerReview/Export.cpp) (libSceCdlgPlayerReview) shows nothing: `scePlayerReviewDialogOpen` finishes at once and the result reports that the user canceled, as `sceWebBrowserDialogOpen` does
- [NP reachability callbacks](../../core/libs/prx/libSceNpManager/Export.cpp) retain one callback and its user data; duplicate registration and unregistering an absent callback return the errors used by [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/945dbc3cc3eee80ac3e053b438502ed936fa6bb2/src/core/libraries/np/np_manager.cpp). No callback is delivered because the existing NP model remains signed out with reachability unavailable.
- [Temporary app-content storage](../../core/libs/prx/libSceAppContent/Export.cpp) tracks one `/temp0` mount, preserves its files on unmount, and rejects new path resolutions until remount. Mount, format, space-query and unmount errors follow [fpPS4](https://github.com/red-prig/fpPS4/blob/04cefd43e6fddd1ab033e7980cd356d14c964905/sys/sys_path.pas); the pointer ABI is also declared by [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/main/src/SharpProspero/Interop/AppContent/AppContent.cs). Existing open host descriptors remain usable after unmount; console behaviour with open files is unverified. Host filesystem failures still throw.
- [Guest path resolution](../../core/libs/prx/libc/src/General.cpp) falls back to ASCII case-insensitive component lookup, following [shadPS4's host filesystem](https://github.com/shadps4-emu/shadPS4/blob/945dbc3cc3eee80ac3e053b438502ed936fa6bb2/src/core/file_sys/backends/host_fs.cpp). PPSA03747 requests `Skyrim.INI`, `Data/Video` and lowercase archive/plugin names that differ from the dump's filenames. Exact host names take precedence; multiple fallback matches throw instead of selecting an arbitrary file. Existing parent names are resolved when creating a new file, and guest mount aliases use the same ASCII comparison. Results are not cached, so renames and newly created files remain visible. Unicode case folding and PS5 behaviour for case-colliding host entries are unverified; host roots are used as supplied.