diff --git a/core/libs/prx/libkernel_sys/Export.cpp b/core/libs/prx/libkernel_sys/Export.cpp index cd60e58230..c042d2944f 100644 --- a/core/libs/prx/libkernel_sys/Export.cpp +++ b/core/libs/prx/libkernel_sys/Export.cpp @@ -243,6 +243,33 @@ bool HasLongComponent(std::string_view path) { return false; } +bool HostFollowsGuestTarget(const char* created, const char* target, const std::filesystem::path& native) { + auto directory = native.parent_path(); + for (const auto& part : std::filesystem::path(target)) { + if (part.empty() || part == ".") continue; + if (part != "..") { + directory /= part; + continue; + } + std::error_code error; + if (!std::filesystem::is_directory(directory, error) || IsSymbolicLink(directory)) return false; + directory = directory.parent_path(); + } + const std::string_view link(created); + const auto separator = link.find_last_of("/\\"); + std::string guest = separator == std::string_view::npos ? std::string(".") : std::string(link.substr(0, separator == 0 ? 1 : separator)); + guest += '/'; + guest += target; + std::filesystem::path resolved; + if (ResolvePathError_nid_no_patch(guest.c_str(), resolved)) return false; + const auto trimmed = [](std::filesystem::path path) { + path = path.lexically_normal(); + if (!path.has_filename() && path.has_relative_path()) path = path.parent_path(); + return path; + }; + return trimmed(resolved) == trimmed(directory); +} + bool IsDotOrDotDot(std::string_view path) { while (!path.empty() && (path.back() == '/' || path.back() == '\\')) path.remove_suffix(1); const auto separator = path.find_last_of("/\\"); @@ -518,12 +545,6 @@ int APS5_VABI symlink_nid_postfix(const char* target, const char* created) { NotImplemented_nid_no_patch("symlink: absolute targets"); return -1; } - for (const auto& part : contents) { - if (part == "..") { - NotImplemented_nid_no_patch("symlink: parent-directory targets"); - return -1; - } - } std::filesystem::path native; if (const int error = ResolvePathError_nid_no_patch(created, native)) return static_cast(Fail(error)); std::error_code error; @@ -533,6 +554,11 @@ int APS5_VABI symlink_nid_postfix(const char* target, const char* created) { if (std::filesystem::exists(std::filesystem::symlink_status(native, error))) return static_cast(Fail(GuestEexist)); #endif if (!std::filesystem::is_directory(native.parent_path(), error)) return static_cast(Fail(HasNonDirectoryPrefix(native) ? GuestEnotdir : GuestEnoent)); + if (std::any_of(contents.begin(), contents.end(), [](const std::filesystem::path& part) { return part == ".."; }) && + !HostFollowsGuestTarget(created, target, native)) { + NotImplemented_nid_no_patch("symlink: a parent-directory target the host would not follow to the guest path it names"); + return -1; + } const bool directory = std::filesystem::is_directory(native.parent_path() / contents, error); #ifdef _WIN32 DWORD flags = SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE; diff --git a/core/libs/tests/GuestKernelSys.cpp b/core/libs/tests/GuestKernelSys.cpp index 1650d050d4..c57b34ee8c 100644 --- a/core/libs/tests/GuestKernelSys.cpp +++ b/core/libs/tests/GuestKernelSys.cpp @@ -61,6 +61,8 @@ int APS5_VABI dup2_nid_postfix(int, int); int APS5_VABI setsid_nid_postfix(void); int APS5_VABI link_nid_postfix(const char*, const char*); int APS5_VABI symlink_nid_postfix(const char*, const char*); +void AddPathAlias_nid_no_patch(const char*, const char*); +void RemovePathAlias_nid_no_patch(const char*); int APS5_VABI openat_nid_postfix(int, const char*, int, ...); int APS5_VABI _openat_nid_postfix(int, const char*, int, ...); int APS5_VABI unlinkat_nid_postfix(int, const char*, int); @@ -213,14 +215,14 @@ bool RemoveSymlink(const std::string& link) { void CheckSymlinkTargetRestrictions(const std::filesystem::path& root) { const auto created = (root / "rejected").string(); - for (const auto& target : {std::string(".."), std::string("../x"), std::string("a/../b"), std::string("a/../../x"), std::string("/abs")}) + for (const auto& target : {std::string("a/../b"), std::string("a/../../x"), std::string("/abs")}) Require(SymlinkTargetThrows(target.c_str(), created)); #ifdef _WIN32 for (const auto& target : {std::string("C:/"), std::string("\\\\server\\share")}) Require(SymlinkTargetThrows(target.c_str(), created)); #endif const auto links = [](const std::string& created, const char* target) { - char buffer[16]; + char buffer[64]; const auto length = std::strlen(target); return readlink_nid_postfix(created.c_str(), buffer, sizeof(buffer)) == static_cast(length) && std::memcmp(buffer, target, length) == 0; @@ -234,6 +236,34 @@ void CheckSymlinkTargetRestrictions(const std::filesystem::path& root) { const auto ellipsis = (root / "accepted-ellipsis").string(); Require(symlink_nid_postfix("...", ellipsis.c_str()) == 0 && links(ellipsis, "...")); Require(RemoveSymlink(nested) && RemoveSymlink(dots) && RemoveSymlink(ellipsis)); + const auto climb = root / "climb"; + Require(std::filesystem::create_directories(climb / "deeper")); + const auto readsFile = [](const std::string& link) { + const int fd = sceKernelOpen(link.c_str(), SCE_KERNEL_O_RDONLY, 0); + char byte = 0; + const bool read = fd >= 0 && sceKernelRead(fd, &byte, 1) == 1 && byte == 'x'; + return fd >= 0 && sceKernelClose(fd) == 0 && read; + }; + const auto up = (climb / "up").string(); + Require(symlink_nid_postfix("../file.txt", up.c_str()) == 0 && links(up, "../file.txt") && readsFile(up)); + const auto inner = (climb / "inner").string(); + Require(symlink_nid_postfix("deeper/../../file.txt", inner.c_str()) == 0 && links(inner, "deeper/../../file.txt") && readsFile(inner)); + const auto parent = (climb / "parent").string(); + Require(symlink_nid_postfix("..", parent.c_str()) == 0 && links(parent, "..") && readsFile((climb / "parent" / "file.txt").string())); + const auto parentDangling = (climb / "dangling").string(); + Require(symlink_nid_postfix("../missing/x", parentDangling.c_str()) == 0 && links(parentDangling, "../missing/x")); + const auto through = (root / "through").string(); + Require(symlink_nid_postfix("climb", through.c_str()) == 0); + Require(SymlinkTargetThrows("../file.txt", (root / "through" / "via").string())); + Require(!std::filesystem::exists(std::filesystem::symlink_status(climb / "via"))); + const auto mount = "/anyps5-symlink-climb-mount"; + AddPathAlias_nid_no_patch(mount, std::filesystem::absolute(climb).string().c_str()); + Require(SymlinkTargetThrows("../file.txt", std::string(mount) + "/escape")); + Require(symlink_nid_postfix("deeper/../up", (std::string(mount) + "/kept").c_str()) == 0 && readsFile((climb / "kept").string())); + RemovePathAlias_nid_no_patch(mount); + Require(RemoveSymlink(up) && RemoveSymlink(inner) && RemoveSymlink(parent) && RemoveSymlink(parentDangling)); + Require(RemoveSymlink((climb / "kept").string()) && RemoveSymlink(through)); + Require(std::filesystem::remove(climb / "deeper") && std::filesystem::remove(climb)); } else { Require(link == -1 && Error() == 1); std::fprintf(stderr, "symbolic links unavailable: the symlink target success cases not tested\n"); diff --git a/docs/dev/debt/libkernel_sys.md b/docs/dev/debt/libkernel_sys.md index 5f015be839..a49f4deb39 100644 --- a/docs/dev/debt/libkernel_sys.md +++ b/docs/dev/debt/libkernel_sys.md @@ -19,6 +19,7 @@ - [mkdirat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `mkdir`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. On Windows, `mode` is ignored. Console behaviour is unverified - [readlink](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) on Windows reads a symbolic link's target from its reparse data and returns it with `/` separators, only when the target is relative, as `symlink` creates them. A symbolic link with an absolute target returns its print name, or its substitute name when the print name is empty, without a `\??\` or `\\?\` prefix. A directory junction returns the same kind of name when it is a drive path (`X:\...`), using the print name when that is a drive path and the substitute name otherwise. A mounted volume (`\??\Volume{GUID}\`), any other name surrogate and every reparse point without the name surrogate bit (WOF compression, deduplication, cloud files) return `EINVAL`, as FreeBSD does for a file that is not a link and as CPython's `os.readlink`, which reads only symbolic links and junctions, does - [aio_read, aio_write, aio_fsync, aio_error, aio_return, aio_suspend, aio_waitcomplete, aio_cancel](../../../core/libs/prx/libkernel_sys/Aio.cpp) (libkernel_sys) - a request with `SIGEV_SIGNAL`, `SIGEV_THREAD_ID` or `SIGEV_KEVENT` completion, or on a socket, throws, because delivering a guest signal or kevent and socket aio are not modelled. One worker thread runs the process's requests one at a time in submission order, so a request is cancellable only until that thread takes it. A queued request keeps the descriptor number, not the open file: closing or reusing the descriptor before it runs changes what it reaches. Pipes fail with `EOPNOTSUPP`, as FreeBSD 11 does with `vfs.aio.enable_unsafe` 0; the console's setting is unverified. Waits are not interrupted by signals (`EINTR`), and timeouts under 1 ms round up to 1 ms (hz 1000). Otherwise the rules follow FreeBSD 11 `vfs_aio.c` +- [symlink](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) stores the target unchanged in a host symbolic link, which the host follows later, not the guest path resolver. It throws for an absolute target, which would need a host path in the link and a translation back in `readlink`. A target with a `..` component is created only when the host and the guest would reach the same place: every directory a `..` leaves must exist and not be a symbolic link, so the host's physical `..` matches the guest resolver's lexical one, and the target joined to the link's host directory must equal the guest path it names, resolved through the guest mounts. A `..` that climbs out of a mount such as `/savedata0`, or past a directory reached through a symbolic link, throws, where FreeBSD 11.4's `namei` follows it from the link's directory. Console behaviour is unverified - [readlinkat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `readlink`. Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory. Console behaviour is unverified - [link](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) on Windows resolves a symbolic link source through the host, which follows longer chains than FreeBSD: a chain of 40 links is linked, where FreeBSD 11.4 returns `ELOOP` after 32 (`MAXSYMLINKS`). A loop returns `ELOOP` as on FreeBSD - [fstatat](../../../core/libs/prx/libkernel_sys/Export.cpp) (libkernel_sys) passes `AT_FDCWD` (-100) and absolute paths to `stat`, or to `lstat` with `AT_SYMLINK_NOFOLLOW` (0x200). Any other relative path is joined to the descriptor's host directory, found the same way as for `fchdir`, so guest path aliases and case-insensitive name matching do not apply to it, and a `..` component is not kept inside the guest root. It returns `EBADF` for a descriptor that is not open and `ENOTDIR` for one that is not a directory, and flags other than `AT_SYMLINK_NOFOLLOW` return `EINVAL`. PPSA74126 imports it. Console behaviour is unverified