Fix NAND file moves across filesystems

This commit is contained in:
patchzyy
2026-09-07 22:33:52 +02:00
parent a135beb201
commit 58bfd32022
4 changed files with 205 additions and 1 deletions
+5
View File
@@ -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)
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <atomic>
#include <chrono>
#include <filesystem>
#include <string>
#include <system_error>
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<unsigned long long> 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
+2 -1
View File
@@ -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;
}
+129
View File
@@ -0,0 +1,129 @@
#include "nand_move.h"
#include <fstream>
#include <iostream>
#include <stdexcept>
#ifdef __linux__
#include <csignal>
#include <sys/resource.h>
#include <sys/stat.h>
#include <unistd.h>
#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<char>(input), std::istreambuf_iterator<char>()};
}
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;
}
}