From 9b9b5f13ee120fdeb33dae906c9bd30afa344943 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:13:05 +0200 Subject: [PATCH] fix: write RFL_DB.dat and rksys.dat atomically Both save files were rewritten in place, so an interrupted write left a truncated file and lost all Miis or all licenses. Writes now go through a temp file that is flushed and swapped in with File.Replace, keeping a .bak. Also refuse to save rksys.dat when the buffer is not exactly RksysSize. --- .../Helpers/AtomicFileHelperTests.cs | 58 ++++++++++++++++ .../GameLicense/GameLicenseService.cs | 16 ++--- .../MiiManagement/MiiRepositoryService.cs | 12 +--- WheelWizard/Helpers/AtomicFileHelper.cs | 67 +++++++++++++++++++ 4 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 WheelWizard.Test/Helpers/AtomicFileHelperTests.cs create mode 100644 WheelWizard/Helpers/AtomicFileHelper.cs diff --git a/WheelWizard.Test/Helpers/AtomicFileHelperTests.cs b/WheelWizard.Test/Helpers/AtomicFileHelperTests.cs new file mode 100644 index 00000000..c5df3b0f --- /dev/null +++ b/WheelWizard.Test/Helpers/AtomicFileHelperTests.cs @@ -0,0 +1,58 @@ +using Testably.Abstractions.Testing; +using WheelWizard.Helpers; + +namespace WheelWizard.Test.Helpers; + +public class AtomicFileHelperTests +{ + private const string FilePath = "/save/rksys.dat"; + + [Fact] + public void WriteAllBytesAtomic_CreatesFileAndDirectory_WhenFileDoesNotExist() + { + var fileSystem = new MockFileSystem(); + var contents = new byte[] { 1, 2, 3, 4 }; + + var result = fileSystem.WriteAllBytesAtomic(FilePath, contents); + + Assert.True(result.IsSuccess); + Assert.Equal(contents, fileSystem.File.ReadAllBytes(FilePath)); + Assert.False(fileSystem.File.Exists(FilePath + AtomicFileHelper.TempExtension)); + Assert.False(fileSystem.File.Exists(FilePath + AtomicFileHelper.BackupExtension)); + } + + [Fact] + public void WriteAllBytesAtomic_ReplacesFileAndKeepsBackup_WhenFileAlreadyExists() + { + var fileSystem = new MockFileSystem(); + var oldContents = new byte[] { 9, 9, 9 }; + var newContents = new byte[] { 1, 2, 3, 4 }; + fileSystem.Directory.CreateDirectory("/save"); + fileSystem.File.WriteAllBytes(FilePath, oldContents); + + var result = fileSystem.WriteAllBytesAtomic(FilePath, newContents); + + Assert.True(result.IsSuccess); + Assert.Equal(newContents, fileSystem.File.ReadAllBytes(FilePath)); + Assert.Equal(oldContents, fileSystem.File.ReadAllBytes(FilePath + AtomicFileHelper.BackupExtension)); + Assert.False(fileSystem.File.Exists(FilePath + AtomicFileHelper.TempExtension)); + } + + [Fact] + public void WriteAllBytesAtomic_LeavesOriginalIntact_WhenWriteFails() + { + var fileSystem = new MockFileSystem(); + var oldContents = new byte[] { 9, 9, 9 }; + fileSystem.Directory.CreateDirectory("/save"); + fileSystem.File.WriteAllBytes(FilePath, oldContents); + + // A directory on the temp path makes writing the temp file fail before anything is swapped in. + fileSystem.Directory.CreateDirectory(FilePath + AtomicFileHelper.TempExtension); + + var result = fileSystem.WriteAllBytesAtomic(FilePath, [1, 2, 3, 4], "Failed to save rksys.dat."); + + Assert.True(result.IsFailure); + Assert.Equal("Failed to save rksys.dat.", result.Error.Message); + Assert.Equal(oldContents, fileSystem.File.ReadAllBytes(FilePath)); + } +} diff --git a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs index 9bcbfb25..5e8c95a2 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/GameLicenseService.cs @@ -758,18 +758,16 @@ private OperationResult SaveRksysToFile() { if (_rksysData == null || !_settingsManager.PathsSetupCorrectly()) return Fail("Invalid save data or config is not setup properly."); + + // Never write a save file with a wrong size, that would corrupt every license on it. + if (_rksysData.Length != RksysSize) + return Fail($"Refusing to save rksys.dat: expected {RksysSize} bytes but got {_rksysData.Length}."); + FixRksysCrc(_rksysData); var currentRegion = _settingsManager.Get(_settingsManager.RR_REGION); var saveFolder = _fileSystem.Path.Combine(PathManager.SaveFolderPath, RRRegionManager.ConvertRegionToGameId(currentRegion)); - var trySaveRksys = TryCatch(() => - { - _fileSystem.Directory.CreateDirectory(saveFolder); - var path = _fileSystem.Path.Combine(saveFolder, "rksys.dat"); - _fileSystem.File.WriteAllBytes(path, _rksysData); - }); - if (trySaveRksys.IsFailure) - return trySaveRksys.Error; - return Ok(); + var path = _fileSystem.Path.Combine(saveFolder, "rksys.dat"); + return _fileSystem.WriteAllBytesAtomic(path, _rksysData, "Failed to save rksys.dat."); } protected override Task ExecuteTaskAsync() diff --git a/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs b/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs index e51c8015..2f379233 100644 --- a/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs +++ b/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs @@ -126,8 +126,7 @@ public OperationResult SaveAllBlocks(List blocks) db[CrcOffset + 1] = (byte)(crc & 0xFF); } - fileSystem.File.WriteAllBytes(_miiDbFilePath, db); - return Ok(); + return fileSystem.WriteAllBytesAtomic(_miiDbFilePath, db, "Failed to save RFL_DB.dat."); } public byte[]? GetRawBlockByAvatarId(uint clientId) @@ -156,12 +155,6 @@ public OperationResult ForceCreateDatabase() if (fileSystem.File.Exists(_miiDbFilePath)) return Fail("Database already exists.", MessageTranslation.Error_MiiDBAlreadyExists); - var directory = Path.GetDirectoryName(_miiDbFilePath); - if (!string.IsNullOrEmpty(directory) && !fileSystem.Directory.Exists(directory)) - { - fileSystem.Directory.CreateDirectory(directory); - } - var db = new byte[779_968]; // first 4 bytes should be the RNOD magic "RNOD" db[0] = 0x52; @@ -184,9 +177,8 @@ public OperationResult ForceCreateDatabase() var crc = CrcHelper.ComputeCrc16Ccitt(db, 0, CrcOffset); db[CrcOffset] = (byte)(crc >> 8); db[CrcOffset + 1] = (byte)(crc & 0xFF); - fileSystem.File.WriteAllBytes(_miiDbFilePath, db); - return Ok(); + return fileSystem.WriteAllBytesAtomic(_miiDbFilePath, db, "Failed to create RFL_DB.dat."); } public OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock) diff --git a/WheelWizard/Helpers/AtomicFileHelper.cs b/WheelWizard/Helpers/AtomicFileHelper.cs new file mode 100644 index 00000000..47ac1d72 --- /dev/null +++ b/WheelWizard/Helpers/AtomicFileHelper.cs @@ -0,0 +1,67 @@ +using System.IO.Abstractions; + +namespace WheelWizard.Helpers; + +/// +/// Helpers for writing files that must never end up half written (Wii save files for example). +/// The new contents are written to a temporary file first, flushed to disk, and only then swapped +/// in place, keeping a backup of the previous file. +/// +public static class AtomicFileHelper +{ + /// + /// The extension appended to the file that is being written before it is swapped in. + /// + public const string TempExtension = ".tmp"; + + /// + /// The extension appended to the backup of the previous version of the file. + /// + public const string BackupExtension = ".bak"; + + /// + /// Writes the given bytes to the given path without ever leaving the destination truncated. + /// The data is written to a temporary file, flushed to disk, and then atomically swapped in. + /// When the destination already exists, the previous version is kept as a .bak file. + /// + /// The file system to write with. + /// The final path of the file. + /// The complete contents of the file. + /// The error message to return when writing fails. + public static OperationResult WriteAllBytesAtomic( + this IFileSystem fileSystem, + string filePath, + byte[] contents, + string? errorMessage = null + ) + { + return TryCatch( + () => + { + var directory = fileSystem.Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory) && !fileSystem.Directory.Exists(directory)) + fileSystem.Directory.CreateDirectory(directory); + + var tempPath = filePath + TempExtension; + var backupPath = filePath + BackupExtension; + + using (var stream = fileSystem.File.Create(tempPath)) + { + stream.Write(contents, 0, contents.Length); + stream.Flush(flushToDisk: true); + } + + // File.Replace requires the destination to already exist, so for a brand new file + // there is nothing to replace (or to back up) and a plain move is already atomic. + if (!fileSystem.File.Exists(filePath)) + { + fileSystem.File.Move(tempPath, filePath); + return; + } + + fileSystem.File.Replace(tempPath, filePath, backupPath, ignoreMetadataErrors: true); + }, + errorMessage ?? $"Failed to write file: {filePath}" + ); + } +}