diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 0bee6f1..90c322d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -282,6 +282,11 @@ target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17) add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests) +add_executable(mkw_nand_move_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_move_tests.cpp") +target_include_directories(mkw_nand_move_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_nand_move_tests PRIVATE cxx_std_17) +add_test(NAME mkw_nand_move_tests COMMAND mkw_nand_move_tests) + add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp") find_package(Threads REQUIRED) target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads) diff --git a/runtime/include/nand_move.h b/runtime/include/nand_move.h new file mode 100644 index 0000000..d0a4102 --- /dev/null +++ b/runtime/include/nand_move.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace RuntimeNandMove { + +// NANDMove does not replace an existing entry. Riivolution save redirects can +// put the destination on a different filesystem from the guest's /tmp files. +inline void Move(const std::filesystem::path& source, const std::filesystem::path& destination, + std::error_code& ec) { + namespace fs = std::filesystem; + const auto status = fs::symlink_status(destination, ec); + if (ec && ec != std::errc::no_such_file_or_directory) return; + ec.clear(); + if (fs::exists(status)) { + ec = std::make_error_code(std::errc::file_exists); + return; + } + fs::rename(source, destination, ec); + if (ec != std::errc::cross_device_link) return; + + // Do not turn a directory move into a partially completed recursive copy, + // or follow a symlink and delete the link after copying its target. + const auto sourceStatus = fs::symlink_status(source, ec); + if (ec) return; + if (!fs::is_regular_file(sourceStatus)) { + ec = std::make_error_code(std::errc::cross_device_link); + return; + } + + static std::atomic sequence{0}; + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + fs::path stagingDirectory; + bool created = false; + for (int attempt = 0; attempt < 64; ++attempt) { + stagingDirectory = destination.parent_path() / + (".nand-move-" + std::to_string(stamp) + "-" + std::to_string(sequence++)); + created = fs::create_directory(stagingDirectory, ec); + if (created) break; + if (ec && ec != std::errc::file_exists) return; + } + if (!created) { + ec = std::make_error_code(std::errc::file_exists); + return; + } + + const auto staged = stagingDirectory / "data"; + fs::copy_file(source, staged, fs::copy_options::none, ec); + if (!ec) { + // Publication is a same-filesystem rename: readers never see a partial + // copy. Keep the source until the complete destination is in place. + const auto current = fs::symlink_status(destination, ec); + if (ec == std::errc::no_such_file_or_directory) ec.clear(); + if (!ec && fs::exists(current)) ec = std::make_error_code(std::errc::file_exists); + if (!ec) fs::rename(staged, destination, ec); + if (!ec) fs::remove(source, ec); + } + // On failure the source remains available; after publication a failed + // source removal leaves both complete copies. Preserve the original error. + std::error_code cleanupError; + fs::remove(staged, cleanupError); + fs::remove(stagingDirectory, cleanupError); +} + +} // namespace RuntimeNandMove diff --git a/runtime/src/hle/storage/nand_api.cpp b/runtime/src/hle/storage/nand_api.cpp index 6796b5b..5864107 100644 --- a/runtime/src/hle/storage/nand_api.cpp +++ b/runtime/src/hle/storage/nand_api.cpp @@ -3,6 +3,7 @@ // Shared state and helpers live in nand_internal.h. #include "nand_internal.h" +#include "nand_move.h" // ============================================================================ // Local helpers @@ -378,7 +379,7 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) { } std::error_code ec; - std::filesystem::rename(srcHost, dstHost, ec); + RuntimeNandMove::Move(srcHost, dstHost, ec); if (!ec) { return NAND_RESULT_OK; } diff --git a/runtime/tests/nand_move_tests.cpp b/runtime/tests/nand_move_tests.cpp new file mode 100644 index 0000000..61b8a4e --- /dev/null +++ b/runtime/tests/nand_move_tests.cpp @@ -0,0 +1,129 @@ +#include "nand_move.h" + +#include +#include +#include + +#ifdef __linux__ +#include +#include +#include +#include +#endif + +namespace fs = std::filesystem; + +static void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static void Write(const fs::path& path, const std::string& data) { + std::ofstream output(path, std::ios::binary); + output << data; + output.close(); + Require(bool(output), "Fixture write failed"); +} + +static std::string Read(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + Require(bool(input), "Fixture read failed"); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +int main() { + const auto name = "wiicomp-nand-move-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + const auto root = fs::temp_directory_path() / name; + fs::path other; + try { + fs::create_directory(root); + const auto source = root / "banner.bin"; + const auto destination = root / "moved.bin"; + std::error_code ec; + Write(source, "banner"); + RuntimeNandMove::Move(source, destination, ec); + Require(!ec && !fs::exists(source) && Read(destination) == "banner", "Same-device move failed"); + Write(source, "keep source"); + RuntimeNandMove::Move(source, destination, ec); + Require(ec == std::errc::file_exists && Read(source) == "keep source" && Read(destination) == "banner", + "Existing destination must not be overwritten"); + RuntimeNandMove::Move(root / "missing", root / "absent", ec); + Require(bool(ec) && !fs::exists(root / "absent"), "Missing source must fail"); + fs::create_directory(root / "directory"); + Write(root / "directory/child", "child"); + RuntimeNandMove::Move(root / "directory", root / "renamed-directory", ec); + Require(!ec && Read(root / "renamed-directory/child") == "child", "Same-device directory move regressed"); + +#ifdef __linux__ + // /dev/shm is a separate tmpfs on ordinary Linux systems, including CI + // and WSL. Fail rather than silently passing without exercising EXDEV. + other = fs::path("/dev/shm") / name; + fs::create_directory(other); + struct stat left{}, right{}; + Require(::stat(root.c_str(), &left) == 0 && ::stat(other.c_str(), &right) == 0 && left.st_dev != right.st_dev, + "Cross-device test requires /tmp and /dev/shm on separate filesystems"); + const auto target = other / "banner.bin"; + const std::string bytes = std::string(8192, '\0') + "banner payload"; + Write(source, bytes); + fs::rename(source, target, ec); + Require(ec == std::errc::cross_device_link, "Fixture must reproduce the original EXDEV failure"); + RuntimeNandMove::Move(source, target, ec); + Require(!ec && !fs::exists(source) && Read(target) == bytes, "Cross-device move must preserve every byte"); + + Write(source, "do not overwrite"); + RuntimeNandMove::Move(source, target, ec); + Require(ec == std::errc::file_exists && Read(source) == "do not overwrite" && Read(target) == bytes, + "Cross-device move must preserve an existing destination"); + fs::remove(target); + fs::create_symlink(other / "missing", target); + RuntimeNandMove::Move(source, target, ec); + Require(ec == std::errc::file_exists && fs::is_symlink(target) && Read(source) == "do not overwrite", + "Dangling destination symlink must not be replaced"); + fs::remove(target); + + RuntimeNandMove::Move(root / "renamed-directory", other / "directory", ec); + Require(ec == std::errc::cross_device_link && Read(root / "renamed-directory/child") == "child" && + !fs::exists(other / "directory"), "Unsupported directory move must leave source intact"); + fs::create_symlink(source, root / "link"); + RuntimeNandMove::Move(root / "link", other / "link", ec); + Require(ec == std::errc::cross_device_link && fs::is_symlink(root / "link") && !fs::exists(other / "link"), + "Cross-device source symlinks must not be dereferenced"); + + // Force a real write failure after a partial copy without filling disk. + Write(source, bytes); + struct rlimit saved{}, limited{}; + Require(getrlimit(RLIMIT_FSIZE, &saved) == 0, "Cannot read file-size limit"); + limited = saved; + limited.rlim_cur = 1024; + const auto oldHandler = std::signal(SIGXFSZ, SIG_IGN); + Require(setrlimit(RLIMIT_FSIZE, &limited) == 0, "Cannot set file-size limit"); + RuntimeNandMove::Move(source, target, ec); + const auto copyError = ec; + const auto restored = setrlimit(RLIMIT_FSIZE, &saved); + std::signal(SIGXFSZ, oldHandler); + Require(restored == 0, "Cannot restore file-size limit"); + Require(bool(copyError) && Read(source) == bytes && !fs::exists(target) && fs::is_empty(other), + "Failed copy must retain source and remove partial staging files"); + + Require(geteuid() != 0, "Run permission tests as an unprivileged user"); + fs::permissions(root, fs::perms::owner_read | fs::perms::owner_exec); + RuntimeNandMove::Move(source, target, ec); + fs::permissions(root, fs::perms::owner_all); + Require(bool(ec) && Read(source) == bytes && Read(target) == bytes, + "Failed source deletion must leave both complete copies"); + Require(std::distance(fs::directory_iterator(other), fs::directory_iterator{}) == 1, + "Move must clean up staging directory"); + fs::remove_all(other); +#endif + fs::remove_all(root); + std::cout << "NAND move tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + std::error_code ignored; + fs::permissions(root, fs::perms::owner_all, ignored); + fs::remove_all(root, ignored); + if (!other.empty()) fs::remove_all(other, ignored); + return 1; + } +}