diff --git a/src/LibHac/Common/StringUtils.cs b/src/LibHac/Common/StringUtils.cs index 4f548869..bc2173c5 100644 --- a/src/LibHac/Common/StringUtils.cs +++ b/src/LibHac/Common/StringUtils.cs @@ -139,10 +139,10 @@ namespace LibHac.Common /// no null terminating byte will be written to the end of the string. public static int Concat(Span dest, ReadOnlySpan source) { - return Concat(dest, GetLength(dest), source); + return Concat(dest, source, GetLength(dest)); } - public static int Concat(Span dest, int destLength, ReadOnlySpan source) + public static int Concat(Span dest, ReadOnlySpan source, int destLength) { int iDest = destLength; diff --git a/src/LibHac/Diag/Abort.cs b/src/LibHac/Diag/Abort.cs new file mode 100644 index 00000000..fae72dc7 --- /dev/null +++ b/src/LibHac/Diag/Abort.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LibHac.Diag +{ + public static class Abort + { + [DoesNotReturn] + public static void DoAbort(string message = null) + { + if (string.IsNullOrWhiteSpace(message)) + { + throw new LibHacException("Abort."); + } + + throw new LibHacException($"Abort: {message}"); + } + + public static void DoAbortUnless([DoesNotReturnIf(false)] bool condition, string message = null) + { + if (condition) + return; + + DoAbort(message); + } + } +} diff --git a/src/LibHac/Diag/Assert.cs b/src/LibHac/Diag/Assert.cs new file mode 100644 index 00000000..14aab339 --- /dev/null +++ b/src/LibHac/Diag/Assert.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace LibHac.Diag +{ + public static class Assert + { + [Conditional("DEBUG")] + public static void AssertTrue([DoesNotReturnIf(false)] bool condition, string message = null) + { + if (condition) + return; + + if (string.IsNullOrWhiteSpace(message)) + { + throw new LibHacException("Assertion failed."); + } + + throw new LibHacException($"Assertion failed: {message}"); + } + } +} diff --git a/src/LibHac/Fs/PathTool.cs b/src/LibHac/Fs/PathTool.cs index b1610de4..8b4575b6 100644 --- a/src/LibHac/Fs/PathTool.cs +++ b/src/LibHac/Fs/PathTool.cs @@ -9,6 +9,10 @@ namespace LibHac.Fs { public static class PathTool { + // These are kept in nn::fs, but C# requires them to be inside a class + internal const int EntryNameLengthMax = 0x300; + internal const int MountNameLengthMax = 15; + public static bool IsSeparator(byte c) { return c == StringTraits.DirectorySeparator; diff --git a/src/LibHac/FsSystem/DirectorySaveDataFileSystem.cs b/src/LibHac/FsSystem/DirectorySaveDataFileSystem.cs index 1075b7e2..a054f283 100644 --- a/src/LibHac/FsSystem/DirectorySaveDataFileSystem.cs +++ b/src/LibHac/FsSystem/DirectorySaveDataFileSystem.cs @@ -4,8 +4,18 @@ using LibHac.Fs; namespace LibHac.FsSystem { + /// + /// An that provides transactional commits for savedata on top of another base IFileSystem. + /// + /// + /// Transactional commits should be atomic as long as the function of the + /// underlying is atomic. + /// This class is based on nn::fssystem::DirectorySaveDataFileSystem in SDK 10.4.0 used in FS 10.0.0 + /// public class DirectorySaveDataFileSystem : FileSystemBase { + private const int IdealWorkBufferSize = 0x100000; // 1 MiB + private ReadOnlySpan CommittedDirectoryBytes => new[] { (byte)'/', (byte)'0', (byte)'/' }; private ReadOnlySpan WorkingDirectoryBytes => new[] { (byte)'/', (byte)'1', (byte)'/' }; private ReadOnlySpan SynchronizingDirectoryBytes => new[] { (byte)'/', (byte)'_', (byte)'/' }; @@ -18,15 +28,13 @@ namespace LibHac.FsSystem private object Locker { get; } = new object(); private int OpenWritableFileCount { get; set; } private bool IsPersistentSaveData { get; set; } - - // ReSharper disable once UnusedAutoPropertyAccessor.Local - private bool IsUserSaveData { get; set; } + private bool CanCommitProvisionally { get; set; } public static Result CreateNew(out DirectorySaveDataFileSystem created, IFileSystem baseFileSystem, - bool isPersistentSaveData, bool isUserSaveData) + bool isPersistentSaveData, bool canCommitProvisionally) { var obj = new DirectorySaveDataFileSystem(baseFileSystem); - Result rc = obj.Initialize(isPersistentSaveData, isUserSaveData); + Result rc = obj.Initialize(isPersistentSaveData, canCommitProvisionally); if (rc.IsSuccess()) { @@ -44,10 +52,10 @@ namespace LibHac.FsSystem BaseFs = baseFileSystem; } - private Result Initialize(bool isPersistentSaveData, bool isUserSaveData) + private Result Initialize(bool isPersistentSaveData, bool canCommitProvisionally) { IsPersistentSaveData = isPersistentSaveData; - IsUserSaveData = isUserSaveData; + CanCommitProvisionally = canCommitProvisionally; // Ensure the working directory exists Result rc = BaseFs.GetEntryType(out _, WorkingDirectoryPath); @@ -277,7 +285,8 @@ namespace LibHac.FsSystem { lock (Locker) { - if (!IsPersistentSaveData) return Result.Success; + if (!IsPersistentSaveData) + return Result.Success; if (OpenWritableFileCount > 0) { @@ -285,24 +294,31 @@ namespace LibHac.FsSystem return ResultFs.WriteModeFileNotClosed.Log(); } + Result RenameCommittedDir() => BaseFs.RenameDirectory(CommittedDirectoryPath, SynchronizingDirectoryPath); + Result SynchronizeWorkingDir() => SynchronizeDirectory(SynchronizingDirectoryPath, WorkingDirectoryPath); + Result RenameSynchronizingDir() => BaseFs.RenameDirectory(SynchronizingDirectoryPath, CommittedDirectoryPath); + // Get rid of the previous commit by renaming the folder - Result rc = BaseFs.RenameDirectory(CommittedDirectoryPath, SynchronizingDirectoryPath); + Result rc = Utility.RetryFinitelyForTargetLocked(RenameCommittedDir); if (rc.IsFailure()) return rc; // If something goes wrong beyond this point, the commit will be // completed the next time the savedata is opened - rc = SynchronizeDirectory(SynchronizingDirectoryPath, WorkingDirectoryPath); + rc = Utility.RetryFinitelyForTargetLocked(SynchronizeWorkingDir); if (rc.IsFailure()) return rc; - return BaseFs.RenameDirectory(SynchronizingDirectoryPath, CommittedDirectoryPath); + rc = Utility.RetryFinitelyForTargetLocked(RenameSynchronizingDir); + if (rc.IsFailure()) return rc; + + return Result.Success; } } protected override Result CommitProvisionallyImpl(long commitCount) { - if (!IsUserSaveData) - return ResultFs.UnsupportedOperationIdInPartitionFileSystem.Log(); + if (!CanCommitProvisionally) + return ResultFs.UnsupportedOperationInDirectorySaveDataFileSystem.Log(); return Result.Success; } @@ -313,7 +329,39 @@ namespace LibHac.FsSystem if (!IsPersistentSaveData) return Result.Success; - return Initialize(IsPersistentSaveData, IsUserSaveData); + return Initialize(IsPersistentSaveData, CanCommitProvisionally); + } + + protected override Result GetFreeSpaceSizeImpl(out long freeSpace, U8Span path) + { + freeSpace = default; + + FsPath fullPath; + unsafe { _ = &fullPath; } // workaround for CS0165 + + Result rc = ResolveFullPath(fullPath.Str, path); + if (rc.IsFailure()) return rc; + + lock (Locker) + { + return BaseFs.GetFreeSpaceSize(out freeSpace, fullPath); + } + } + + protected override Result GetTotalSpaceSizeImpl(out long totalSpace, U8Span path) + { + totalSpace = default; + + FsPath fullPath; + unsafe { _ = &fullPath; } // workaround for CS0165 + + Result rc = ResolveFullPath(fullPath.Str, path); + if (rc.IsFailure()) return rc; + + lock (Locker) + { + return BaseFs.GetTotalSpaceSize(out totalSpace, fullPath); + } } private Result ResolveFullPath(Span outPath, U8Span relativePath) @@ -322,20 +370,36 @@ namespace LibHac.FsSystem return ResultFs.TooLongPath.Log(); StringUtils.Copy(outPath, WorkingDirectoryBytes); - outPath[^1] = StringTraits.NullTerminator; + outPath[outPath.Length - 1] = StringTraits.NullTerminator; return PathTool.Normalize(outPath.Slice(2), out _, relativePath, false, false); } - private Result SynchronizeDirectory(U8Span dest, U8Span src) + /// + /// Creates the destination directory if needed and copies the source directory to it. + /// + /// The path of the destination directory. + /// The path of the source directory. + /// The of the operation. + private Result SynchronizeDirectory(U8Span destPath, U8Span sourcePath) { - Result rc = BaseFs.DeleteDirectoryRecursively(dest); + // Delete destination dir and recreate it. + Result rc = BaseFs.DeleteDirectoryRecursively(destPath); + + // Nintendo returns error unconditionally because SynchronizeDirectory is always called in situations + // where a PathNotFound error would mean the save directory was in an invalid state. + // We'll ignore PathNotFound errors to be more user-friendly to users who might accidentally + // put the save directory in an invalid state. if (rc.IsFailure() && !ResultFs.PathNotFound.Includes(rc)) return rc; - rc = BaseFs.CreateDirectory(dest); + rc = BaseFs.CreateDirectory(destPath); if (rc.IsFailure()) return rc; - return BaseFs.CopyDirectory(BaseFs, src.ToString(), dest.ToString()); + // Get a work buffer to work with. + using (var buffer = new RentedArray(IdealWorkBufferSize)) + { + return Utility.CopyDirectoryRecursively(BaseFs, destPath, sourcePath, buffer.Span); + } } internal void NotifyCloseWritableFile() diff --git a/src/LibHac/FsSystem/DirectoryUtils.cs b/src/LibHac/FsSystem/DirectoryUtils.cs index 3e98bfe8..feaf3659 100644 --- a/src/LibHac/FsSystem/DirectoryUtils.cs +++ b/src/LibHac/FsSystem/DirectoryUtils.cs @@ -43,7 +43,7 @@ namespace LibHac.FsSystem FsPath dstPath = default; int dstPathLen = StringUtils.Concat(dstPath.Str, destParentPath); - dstPathLen = StringUtils.Concat(dstPath.Str, dstPathLen, dirEntry.Name); + dstPathLen = StringUtils.Concat(dstPath.Str, dirEntry.Name, dstPathLen); if (dstPathLen > FsPath.MaxLength) { diff --git a/src/LibHac/FsSystem/PathTools.cs b/src/LibHac/FsSystem/PathTools.cs index 3e4ad0af..79bbf1ea 100644 --- a/src/LibHac/FsSystem/PathTools.cs +++ b/src/LibHac/FsSystem/PathTools.cs @@ -236,7 +236,7 @@ namespace LibHac.FsSystem { Debug.Assert(IsNormalized(path)); - int i = path.Length - 1; + int i = StringUtils.GetLength(path) - 1; // A trailing separator should be ignored if (path[i] == '/') i--; @@ -267,10 +267,12 @@ namespace LibHac.FsSystem { Debug.Assert(IsNormalized(path)); - if (path.Length == 0) + int pathLength = StringUtils.GetLength(path); + + if (pathLength == 0) return path; - int endIndex = path[path.Length - 1] == DirectorySeparator ? path.Length - 1 : path.Length; + int endIndex = path[pathLength - 1] == DirectorySeparator ? pathLength - 1 : pathLength; int i = endIndex; while (i >= 1 && path[i - 1] != '/') i--; diff --git a/src/LibHac/FsSystem/Utility.cs b/src/LibHac/FsSystem/Utility.cs new file mode 100644 index 00000000..8e03cc33 --- /dev/null +++ b/src/LibHac/FsSystem/Utility.cs @@ -0,0 +1,252 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using LibHac.Common; +using LibHac.Diag; +using LibHac.Fs; + +namespace LibHac.FsSystem +{ + internal static class Utility + { + public delegate Result FsIterationTask(U8Span path, ref DirectoryEntry entry); + + private static U8Span RootPath => new U8Span(new[] { (byte)'/' }); + private static U8Span DirectorySeparator => RootPath; + + public static Result IterateDirectoryRecursively(IFileSystem fs, U8Span rootPath, Span workPath, + ref DirectoryEntry dirEntry, FsIterationTask onEnterDir, FsIterationTask onExitDir, FsIterationTask onFile) + { + Abort.DoAbortUnless(workPath.Length >= PathTool.EntryNameLengthMax + 1); + + // Get size of the root path. + int rootPathLen = StringUtils.GetLength(rootPath, PathTool.EntryNameLengthMax + 1); + if (rootPathLen > PathTool.EntryNameLengthMax) + return ResultFs.TooLongPath.Log(); + + // Copy root path in, add a / if necessary. + rootPath.Value.Slice(0, rootPathLen).CopyTo(workPath); + if (!PathTool.IsSeparator(workPath[rootPathLen - 1])) + { + workPath[rootPathLen++] = StringTraits.DirectorySeparator; + } + + // Make sure the result path is still valid. + if (rootPathLen > PathTool.EntryNameLengthMax) + return ResultFs.TooLongPath.Log(); + + workPath[rootPathLen] = StringTraits.NullTerminator; + + return IterateDirectoryRecursivelyImpl(fs, workPath, ref dirEntry, onEnterDir, onExitDir, onFile); + } + + public static Result IterateDirectoryRecursively(IFileSystem fs, U8Span rootPath, FsIterationTask onEnterDir, + FsIterationTask onExitDir, FsIterationTask onFile) + { + DirectoryEntry entry = default; + Span workPath = stackalloc byte[PathTools.MaxPathLength + 1]; + + return IterateDirectoryRecursively(fs, rootPath, workPath, ref entry, onEnterDir, onExitDir, + onFile); + } + + public static Result IterateDirectoryRecursively(IFileSystem fs, FsIterationTask onEnterDir, + FsIterationTask onExitDir, FsIterationTask onFile) + { + return IterateDirectoryRecursively(fs, RootPath, onEnterDir, onExitDir, onFile); + } + + private static Result IterateDirectoryRecursivelyImpl(IFileSystem fs, Span workPath, + ref DirectoryEntry dirEntry, FsIterationTask onEnterDir, FsIterationTask onExitDir, FsIterationTask onFile) + { + Result rc = fs.OpenDirectory(out IDirectory dir, new U8Span(workPath), OpenDirectoryMode.All); + if (rc.IsFailure()) return rc; + + int parentLen = StringUtils.GetLength(workPath); + + // Read and handle entries. + while (true) + { + // Read a single entry. + rc = dir.Read(out long readCount, SpanHelpers.AsSpan(ref dirEntry)); + if (rc.IsFailure()) return rc; + + // If we're out of entries, we're done. + if (readCount == 0) + break; + + // Validate child path size. + int childNameLen = StringUtils.GetLength(dirEntry.Name); + bool isDir = dirEntry.Type == DirectoryEntryType.Directory; + int separatorSize = isDir ? 1 : 0; + + if (parentLen + childNameLen + separatorSize >= workPath.Length) + return ResultFs.TooLongPath.Log(); + + // Set child path. + StringUtils.Concat(workPath, dirEntry.Name); + { + if (isDir) + { + // Enter directory. + rc = onEnterDir(new U8Span(workPath), ref dirEntry); + if (rc.IsFailure()) return rc; + + // Append separator, recurse. + StringUtils.Concat(workPath, DirectorySeparator); + + rc = IterateDirectoryRecursivelyImpl(fs, workPath, ref dirEntry, onEnterDir, onExitDir, onFile); + if (rc.IsFailure()) return rc; + + // Exit directory. + rc = onExitDir(new U8Span(workPath), ref dirEntry); + if (rc.IsFailure()) return rc; + } + else + { + // Call file handler. + rc = onFile(new U8Span(workPath), ref dirEntry); + if (rc.IsFailure()) return rc; + } + } + + // Restore parent path. + workPath[parentLen] = StringTraits.NullTerminator; + } + + return Result.Success; + } + + public static Result CopyDirectoryRecursively(IFileSystem fileSystem, U8Span destPath, U8Span sourcePath, + Span workBuffer) + { + return CopyDirectoryRecursively(fileSystem, fileSystem, destPath, sourcePath, workBuffer); + } + + public static unsafe Result CopyDirectoryRecursively(IFileSystem destFileSystem, IFileSystem sourceFileSystem, + U8Span destPath, U8Span sourcePath, Span workBuffer) + { + var destPathBuf = new FsPath(); + int originalSize = StringUtils.Copy(destPathBuf.Str, destPath); + Abort.DoAbortUnless(originalSize < Unsafe.SizeOf()); + + // Pin and recreate the span because C# can't use byref-like types in a closure + int workBufferSize = workBuffer.Length; + fixed (byte* pWorkBuffer = workBuffer) + { + // Copy the pointer to workaround CS1764. + // IterateDirectoryRecursively won't store the delegate anywhere, so it should be safe + byte* pWorkBuffer2 = pWorkBuffer; + + Result OnEnterDir(U8Span path, ref DirectoryEntry entry) + { + // Update path, create new dir. + StringUtils.Concat(SpanHelpers.AsByteSpan(ref destPathBuf), entry.Name); + StringUtils.Concat(SpanHelpers.AsByteSpan(ref destPathBuf), DirectorySeparator); + + return destFileSystem.CreateDirectory(destPathBuf); + } + + Result OnExitDir(U8Span path, ref DirectoryEntry entry) + { + // Check we have a parent directory. + int len = StringUtils.GetLength(SpanHelpers.AsByteSpan(ref destPathBuf)); + if (len < 2) + return ResultFs.InvalidPathFormat.Log(); + + // Find previous separator, add null terminator + int cur = len - 2; + while (!PathTool.IsSeparator(SpanHelpers.AsByteSpan(ref destPathBuf)[cur]) && cur > 0) + { + cur--; + } + + SpanHelpers.AsByteSpan(ref destPathBuf)[cur + 1] = StringTraits.NullTerminator; + + return Result.Success; + } + + Result OnFile(U8Span path, ref DirectoryEntry entry) + { + var buffer = new Span(pWorkBuffer2, workBufferSize); + + return CopyFile(destFileSystem, sourceFileSystem, destPathBuf, path, ref entry, buffer); + } + + return IterateDirectoryRecursively(sourceFileSystem, sourcePath, OnEnterDir, OnExitDir, OnFile); + } + } + + public static Result CopyFile(IFileSystem destFileSystem, IFileSystem sourceFileSystem, U8Span destParentPath, + U8Span sourcePath, ref DirectoryEntry entry, Span workBuffer) + { + // Open source file. + Result rc = sourceFileSystem.OpenFile(out IFile sourceFile, sourcePath, OpenMode.Read); + if (rc.IsFailure()) return rc; + + using (sourceFile) + { + // Open dest file. + FsPath destPath; + unsafe { _ = &destPath; } // workaround for CS0165 + + var sb = new U8StringBuilder(destPath.Str); + sb.Append(destParentPath).Append(entry.Name); + + Abort.DoAbortUnless(sb.Length < Unsafe.SizeOf()); + + rc = destFileSystem.CreateFile(new U8Span(destPath.Str), entry.Size, CreateFileOptions.None); + if (rc.IsFailure()) return rc; + + rc = destFileSystem.OpenFile(out IFile destFile, new U8Span(destPath.Str), OpenMode.Write); + if (rc.IsFailure()) return rc; + + using (destFile) + { + // Read/Write file in work buffer sized chunks. + long remaining = entry.Size; + long offset = 0; + + while (remaining > 0) + { + rc = sourceFile.Read(out long bytesRead, offset, workBuffer, ReadOption.None); + if (rc.IsFailure()) return rc; + + rc = destFile.Write(offset, workBuffer.Slice(0, (int)bytesRead), WriteOption.None); + if (rc.IsFailure()) return rc; + + remaining -= bytesRead; + offset += bytesRead; + } + } + } + + return Result.Success; + } + + public static Result RetryFinitelyForTargetLocked(Func function) + { + const int maxRetryCount = 10; + const int retryWaitTimeMs = 100; + + int remainingRetries = maxRetryCount; + + while (true) + { + Result rc = function(); + + if (rc.IsSuccess()) + return rc; + + if (!ResultFs.TargetLocked.Includes(rc)) + return rc; + + if (remainingRetries <= 0) + return rc; + + remainingRetries--; + Thread.Sleep(retryWaitTimeMs); + } + } + } +} diff --git a/tests/LibHac.Tests/Fs/DirectorySaveDataFileSystemTests.cs b/tests/LibHac.Tests/Fs/DirectorySaveDataFileSystemTests.cs new file mode 100644 index 00000000..10014f0e --- /dev/null +++ b/tests/LibHac.Tests/Fs/DirectorySaveDataFileSystemTests.cs @@ -0,0 +1,172 @@ +using LibHac.Common; +using LibHac.Fs; +using LibHac.FsSystem; +using LibHac.Tests.Fs.IFileSystemTestBase; +using Xunit; + +namespace LibHac.Tests.Fs +{ + public class DirectorySaveDataFileSystemTests : CommittableIFileSystemTests + { + protected override IFileSystem CreateFileSystem() + { + return CreateFileSystemInternal().saveFs; + } + + protected override IReopenableFileSystemCreator GetFileSystemCreator() + { + return new DirectorySaveDataFileSystemCreator(); + } + + private class DirectorySaveDataFileSystemCreator : IReopenableFileSystemCreator + { + private IFileSystem _baseFileSystem { get; } + + public DirectorySaveDataFileSystemCreator() + { + _baseFileSystem = new InMemoryFileSystem(); + } + + public IFileSystem Create() + { + DirectorySaveDataFileSystem.CreateNew(out DirectorySaveDataFileSystem saveFs, _baseFileSystem, true, true) + .ThrowIfFailure(); + + return saveFs; + } + } + + private (IFileSystem baseFs, IFileSystem saveFs) CreateFileSystemInternal() + { + var baseFs = new InMemoryFileSystem(); + + DirectorySaveDataFileSystem.CreateNew(out DirectorySaveDataFileSystem saveFs, baseFs, true, true) + .ThrowIfFailure(); + + return (baseFs, saveFs); + } + + [Fact] + public void CreateFile_CreatedInWorkingDirectory() + { + (IFileSystem baseFs, IFileSystem saveFs) = CreateFileSystemInternal(); + + saveFs.CreateFile("/file".ToU8Span(), 0, CreateFileOptions.None); + + Assert.Success(baseFs.GetEntryType(out DirectoryEntryType type, "/1/file".ToU8Span())); + Assert.Equal(DirectoryEntryType.File, type); + } + + [Fact] + public void CreateFile_NotCreatedInCommittedDirectory() + { + (IFileSystem baseFs, IFileSystem saveFs) = CreateFileSystemInternal(); + + saveFs.CreateFile("/file".ToU8Span(), 0, CreateFileOptions.None); + + Assert.Result(ResultFs.PathNotFound, baseFs.GetEntryType(out _, "/0/file".ToU8Span())); + } + + [Fact] + public void Commit_FileExistsInCommittedDirectory() + { + (IFileSystem baseFs, IFileSystem saveFs) = CreateFileSystemInternal(); + + saveFs.CreateFile("/file".ToU8Span(), 0, CreateFileOptions.None); + + Assert.Success(saveFs.Commit()); + + Assert.Success(baseFs.GetEntryType(out DirectoryEntryType type, "/0/file".ToU8Span())); + Assert.Equal(DirectoryEntryType.File, type); + } + + [Fact] + public void Rollback_FileDoesNotExistInBaseAfterRollback() + { + (IFileSystem baseFs, IFileSystem saveFs) = CreateFileSystemInternal(); + + saveFs.CreateFile("/file".ToU8Span(), 0, CreateFileOptions.None); + + // Rollback should succeed + Assert.Success(saveFs.Rollback()); + + // Make sure all the files are gone + Assert.Result(ResultFs.PathNotFound, saveFs.GetEntryType(out _, "/file".ToU8Span())); + Assert.Result(ResultFs.PathNotFound, baseFs.GetEntryType(out _, "/0/file".ToU8Span())); + Assert.Result(ResultFs.PathNotFound, baseFs.GetEntryType(out _, "/1/file".ToU8Span())); + } + + [Fact] + public void Rollback_DeletedFileIsRestoredInBaseAfterRollback() + { + (IFileSystem baseFs, IFileSystem saveFs) = CreateFileSystemInternal(); + + saveFs.CreateFile("/file".ToU8Span(), 0, CreateFileOptions.None); + saveFs.Commit(); + saveFs.DeleteFile("/file".ToU8Span()); + + // Rollback should succeed + Assert.Success(saveFs.Rollback()); + + // Make sure all the files are restored + Assert.Success(saveFs.GetEntryType(out _, "/file".ToU8Span())); + Assert.Success(baseFs.GetEntryType(out _, "/0/file".ToU8Span())); + Assert.Success(baseFs.GetEntryType(out _, "/1/file".ToU8Span())); + } + + [Fact] + public void Initialize_NormalState_UsesCommittedData() + { + var baseFs = new InMemoryFileSystem(); + + baseFs.CreateDirectory("/0".ToU8Span()).ThrowIfFailure(); + baseFs.CreateDirectory("/1".ToU8Span()).ThrowIfFailure(); + + // Set the existing files before initializing the save FS + baseFs.CreateFile("/0/file1".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + baseFs.CreateFile("/1/file2".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + + DirectorySaveDataFileSystem.CreateNew(out DirectorySaveDataFileSystem saveFs, baseFs, true, true) + .ThrowIfFailure(); + + Assert.Success(saveFs.GetEntryType(out _, "/file1".ToU8Span())); + Assert.Result(ResultFs.PathNotFound, saveFs.GetEntryType(out _, "/file2".ToU8Span())); + } + + [Fact] + public void Initialize_InterruptedAfterCommitPart1_UsesWorkingData() + { + var baseFs = new InMemoryFileSystem(); + + baseFs.CreateDirectory("/_".ToU8Span()).ThrowIfFailure(); + baseFs.CreateDirectory("/1".ToU8Span()).ThrowIfFailure(); + + // Set the existing files before initializing the save FS + baseFs.CreateFile("/_/file1".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + baseFs.CreateFile("/1/file2".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + + DirectorySaveDataFileSystem.CreateNew(out DirectorySaveDataFileSystem saveFs, baseFs, true, true) + .ThrowIfFailure(); + + Assert.Result(ResultFs.PathNotFound, saveFs.GetEntryType(out _, "/file1".ToU8Span())); + Assert.Success(saveFs.GetEntryType(out _, "/file2".ToU8Span())); + } + + [Fact] + public void Initialize_InterruptedDuringCommitPart2_UsesWorkingData() + { + var baseFs = new InMemoryFileSystem(); + + baseFs.CreateDirectory("/1".ToU8Span()).ThrowIfFailure(); + + // Set the existing files before initializing the save FS + baseFs.CreateFile("/1/file2".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + + DirectorySaveDataFileSystem.CreateNew(out DirectorySaveDataFileSystem saveFs, baseFs, true, true) + .ThrowIfFailure(); + + Assert.Result(ResultFs.PathNotFound, saveFs.GetEntryType(out _, "/file1".ToU8Span())); + Assert.Success(saveFs.GetEntryType(out _, "/file2".ToU8Span())); + } + } +} diff --git a/tests/LibHac.Tests/Fs/IFileSystemTestBase/CommittableIFileSystemTests.Commit.cs b/tests/LibHac.Tests/Fs/IFileSystemTestBase/CommittableIFileSystemTests.Commit.cs new file mode 100644 index 00000000..3cda8312 --- /dev/null +++ b/tests/LibHac.Tests/Fs/IFileSystemTestBase/CommittableIFileSystemTests.Commit.cs @@ -0,0 +1,143 @@ +using LibHac.Common; +using LibHac.Fs; +using Xunit; + +namespace LibHac.Tests.Fs.IFileSystemTestBase +{ + public abstract partial class CommittableIFileSystemTests + { + [Fact] + public void Commit_AfterSuccessfulCommit_CanReadCommittedData() + { + // "Random" test data + var data1 = new byte[] { 7, 4, 1, 0, 8, 5, 2, 9, 6, 3 }; + var data2 = new byte[] { 6, 1, 6, 8, 0, 3, 9, 7, 5, 1 }; + + IReopenableFileSystemCreator fsCreator = GetFileSystemCreator(); + IFileSystem fs = fsCreator.Create(); + + // Make sure to test both directories and files + fs.CreateDirectory("/dir1".ToU8Span()).ThrowIfFailure(); + fs.CreateDirectory("/dir2".ToU8Span()).ThrowIfFailure(); + + fs.CreateFile("/dir1/file".ToU8Span(), data1.Length, CreateFileOptions.None).ThrowIfFailure(); + fs.CreateFile("/dir2/file".ToU8Span(), data2.Length, CreateFileOptions.None).ThrowIfFailure(); + + fs.OpenFile(out IFile file1, "/dir1/file".ToU8Span(), OpenMode.Write).ThrowIfFailure(); + fs.OpenFile(out IFile file2, "/dir2/file".ToU8Span(), OpenMode.Write).ThrowIfFailure(); + + file1.Write(0, data1, WriteOption.Flush).ThrowIfFailure(); + file2.Write(0, data2, WriteOption.Flush).ThrowIfFailure(); + + file1.Dispose(); + file2.Dispose(); + + fs.Commit().ThrowIfFailure(); + fs.Dispose(); + + // Reopen after committing + fs = fsCreator.Create(); + + var readData1 = new byte[data1.Length]; + var readData2 = new byte[data2.Length]; + + Assert.Success(fs.OpenFile(out file1, "/dir1/file".ToU8Span(), OpenMode.Read)); + + using (file1) + { + Assert.Success(file1.Read(out long bytesRead, 0, readData1, ReadOption.None)); + Assert.Equal(data1.Length, bytesRead); + } + + Assert.Equal(data1, readData1); + + Assert.Success(fs.OpenFile(out file2, "/dir2/file".ToU8Span(), OpenMode.Read)); + + using (file2) + { + Assert.Success(file2.Read(out long bytesRead, 0, readData2, ReadOption.None)); + Assert.Equal(data2.Length, bytesRead); + } + + Assert.Equal(data2, readData2); + } + + [Fact] + public void Rollback_CreateFileThenRollback_FileDoesNotExist() + { + IFileSystem fs = CreateFileSystem(); + + fs.CreateDirectory("/dir".ToU8Span()).ThrowIfFailure(); + fs.CreateFile("/dir/file".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + + // Rollback should succeed + Assert.Success(fs.Rollback()); + + // Make sure the file and directory are gone + Assert.Result(ResultFs.PathNotFound, fs.GetEntryType(out _, "/dir".ToU8Span())); + Assert.Result(ResultFs.PathNotFound, fs.GetEntryType(out _, "/dir/file".ToU8Span())); + } + + [Fact] + public void Rollback_CreateFileThenCloseFs_FileDoesNotExist() + { + IReopenableFileSystemCreator fsCreator = GetFileSystemCreator(); + IFileSystem fs = fsCreator.Create(); + + fs.CreateDirectory("/dir".ToU8Span()).ThrowIfFailure(); + fs.CreateFile("/dir/file".ToU8Span(), 0, CreateFileOptions.None).ThrowIfFailure(); + + // Close without committing and reopen the file system + fs.Dispose(); + fs = fsCreator.Create(); + + // Make sure the file and directory are gone + Assert.Result(ResultFs.PathNotFound, fs.GetEntryType(out _, "/dir".ToU8Span())); + Assert.Result(ResultFs.PathNotFound, fs.GetEntryType(out _, "/dir/file".ToU8Span())); + } + + [Fact] + public void Rollback_AfterChangingExistingFiles_GoesBackToOriginalData() + { + // "Random" test data + var data1 = new byte[] { 7, 4, 1, 0, 8, 5, 2, 9, 6, 3 }; + var data2 = new byte[] { 6, 1, 6, 8, 0, 3, 9, 7, 5, 1 }; + + IReopenableFileSystemCreator fsCreator = GetFileSystemCreator(); + IFileSystem fs = fsCreator.Create(); + + fs.CreateDirectory("/dir".ToU8Span()).ThrowIfFailure(); + fs.CreateFile("/dir/file".ToU8Span(), data1.Length, CreateFileOptions.None).ThrowIfFailure(); + + fs.OpenFile(out IFile file, "/dir/file".ToU8Span(), OpenMode.Write).ThrowIfFailure(); + file.Write(0, data1, WriteOption.Flush).ThrowIfFailure(); + file.Dispose(); + + // Commit and reopen the file system + fs.Commit().ThrowIfFailure(); + fs.Dispose(); + + fs = fsCreator.Create(); + + // Make changes to the file + fs.OpenFile(out file, "/dir/file".ToU8Span(), OpenMode.Write).ThrowIfFailure(); + file.Write(0, data2, WriteOption.Flush).ThrowIfFailure(); + file.Dispose(); + + Assert.Success(fs.Rollback()); + + // The file should contain the original data after the rollback + var readData = new byte[data1.Length]; + + Assert.Success(fs.OpenFile(out file, "/dir/file".ToU8Span(), OpenMode.Read)); + + using (file) + { + Assert.Success(file.Read(out long bytesRead, 0, readData, ReadOption.None)); + Assert.Equal(data1.Length, bytesRead); + } + + Assert.Equal(data1, readData); + } + } +} diff --git a/tests/LibHac.Tests/Fs/IFileSystemTestBase/CommittableIFileSystemTests.cs b/tests/LibHac.Tests/Fs/IFileSystemTestBase/CommittableIFileSystemTests.cs new file mode 100644 index 00000000..8aacb0fe --- /dev/null +++ b/tests/LibHac.Tests/Fs/IFileSystemTestBase/CommittableIFileSystemTests.cs @@ -0,0 +1,14 @@ +using LibHac.Fs; + +namespace LibHac.Tests.Fs.IFileSystemTestBase +{ + public abstract partial class CommittableIFileSystemTests : IFileSystemTests + { + protected interface IReopenableFileSystemCreator + { + IFileSystem Create(); + } + + protected abstract IReopenableFileSystemCreator GetFileSystemCreator(); + } +}