feat(libkernel_sys): create symlink targets with parent-directory components

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Dom3005andClaude Opus 5.5 committed 2026-10-24 15:29:09 +02:00
1 parent 6cd36e6517
commit 69a7bd2c19
3 files changed
+65 -8

No files matched your search

+32 -6
View File
@@ -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<int>(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<int>(Fail(GuestEexist));
#endif
if (!std::filesystem::is_directory(native.parent_path(), error)) return static_cast<int>(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;
+32 -2
View File
@@ -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<std::int64_t>(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");
+1
View File
@@ -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