diff --git a/.editorconfig b/.editorconfig index f0d7709..272fc95 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,76 +2,35 @@ root = true [*] charset = utf-8 +end_of_line = lf insert_final_newline = true -trim_trailing_whitespace = true -max_line_length = 120 - -# Microsoft .NET properties -csharp_new_line_before_members_in_object_initializers = false -csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion -csharp_style_prefer_utf8_string_literals = true:suggestion -csharp_style_var_elsewhere = true:warning -csharp_style_var_for_built_in_types = true:warning -csharp_style_var_when_type_is_apparent = true:warning -dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True -dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field -dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef -dotnet_naming_rule.unity_serialized_field_rule.severity = warning -dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style -dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols -dotnet_naming_style.lower_camel_case_style.capitalization = camel_case -dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = -dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field -dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance -dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none -dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_predefined_type_for_member_access = true:suggestion -dotnet_style_qualification_for_event = false:warning -dotnet_style_qualification_for_field = false:warning -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_property = false:warning -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion - -# ReSharper properties -resharper_apply_auto_detected_rules = false -resharper_autodetect_indent_settings = true -resharper_csharp_empty_block_style = together_same_line -resharper_csharp_stick_comment = false -resharper_outdent_statement_labels = true -resharper_show_autodetect_configure_formatting_tip = false -resharper_use_indent_from_vs = false -resharper_wrap_lines = true - -# ReSharper inspection severities -resharper_arrange_redundant_parentheses_highlighting = hint -resharper_arrange_type_member_modifiers_highlighting = hint -resharper_arrange_type_modifiers_highlighting = hint -resharper_built_in_type_reference_style_for_member_access_highlighting = hint -resharper_built_in_type_reference_style_highlighting = hint - -[*.cs] -indent_size = 4 indent_style = space -tab_width = 4 - -# CS4014: Task not awaited -dotnet_diagnostic.cs4014.severity = error - -# CS8509: Missing switch case for named enum value -dotnet_diagnostic.CS8509.severity = error - -# CS824: Missing switch case for unnamed enum value -dotnet_diagnostic.CS8524.severity = none +indent_size = 4 -# Enums should not have duplicate values -dotnet_diagnostic.CA1069.severity = error +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}] +indent_style = space +indent_size = 2 [{*.yaml,*.yml}] indent_style = space indent_size = 2 -[*.csproj] +[{*.bash,*.sh,*.zsh}] +indent_style = space +indent_size = 2 + +[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,cxxm,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,mxx,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,uxml,vb,xaml,xamlx,xoml,xsd}] +indent_style = space indent_size = 4 +tab_width = 4 + +# Verify settings +[*.{received,verified}.{txt,xml,json}] +charset = "utf-8-bom" +end_of_line = lf +indent_size = +indent_style = +insert_final_newline = false +tab_width = +trim_trailing_whitespace = false + diff --git a/.globalconfig b/.globalconfig new file mode 100644 index 0000000..acb99d7 --- /dev/null +++ b/.globalconfig @@ -0,0 +1,20 @@ +is_global = true + +# CS4014: Task not awaited +dotnet_diagnostic.cs4014.severity = error + +# CS8509: Missing switch case for named enum value +dotnet_diagnostic.CS8509.severity = error + +# CS824: Missing switch case for unnamed enum value +dotnet_diagnostic.CS8524.severity = none + +# Enums should not have duplicate values +dotnet_diagnostic.CA1069.severity = error + +# Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = error + +# Don't call Enumerable.Cast or Enumerable.OfType with incompatible types +dotnet_diagnostic.CA2021.severity = error + diff --git a/NexusMods.Paths.sln b/NexusMods.Paths.sln index 9e94c7a..d057d4a 100644 --- a/NexusMods.Paths.sln +++ b/NexusMods.Paths.sln @@ -32,6 +32,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Paths.TestingHelp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Paths.Benchmarks", "src\NexusMods.Paths.Benchmarks\NexusMods.Paths.Benchmarks.csproj", "{E86B44A1-D57E-4B0B-8F62-869E1784C49C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{1351E5E7-6B95-405D-92CE-D7ECAF67464C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Paths.Extensions.Nx", "src\Extensions\NexusMods.Paths.Extensions.Nx\NexusMods.Paths.Extensions.Nx.csproj", "{4656B671-8002-461D-8C4C-74A77546C187}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{332D93F6-E1F1-4F51-88D6-B4B364DDBDBC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Paths.Extensions.Nx.Tests", "tests\Extensions\NexusMods.Paths.Extensions.Nx.Tests\NexusMods.Paths.Extensions.Nx.Tests.csproj", "{D5012909-9405-4DE0-84E7-354A5EFDF0FC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,6 +50,10 @@ Global {30CBEB4A-E0C0-4B11-A0CF-F97BFACEEF89} = {6ED01F9D-5E12-4EB2-9601-64A2ADC719DE} {FABE9B73-49FF-472C-9013-F52876F25E1D} = {0377EBE6-F147-4233-86AD-32C821B9567E} {E86B44A1-D57E-4B0B-8F62-869E1784C49C} = {0377EBE6-F147-4233-86AD-32C821B9567E} + {1351E5E7-6B95-405D-92CE-D7ECAF67464C} = {0377EBE6-F147-4233-86AD-32C821B9567E} + {4656B671-8002-461D-8C4C-74A77546C187} = {1351E5E7-6B95-405D-92CE-D7ECAF67464C} + {332D93F6-E1F1-4F51-88D6-B4B364DDBDBC} = {6ED01F9D-5E12-4EB2-9601-64A2ADC719DE} + {D5012909-9405-4DE0-84E7-354A5EFDF0FC} = {332D93F6-E1F1-4F51-88D6-B4B364DDBDBC} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A92DED3D-BC67-4E04-9A06-9A1B302B3070}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -60,5 +72,13 @@ Global {E86B44A1-D57E-4B0B-8F62-869E1784C49C}.Debug|Any CPU.Build.0 = Debug|Any CPU {E86B44A1-D57E-4B0B-8F62-869E1784C49C}.Release|Any CPU.ActiveCfg = Release|Any CPU {E86B44A1-D57E-4B0B-8F62-869E1784C49C}.Release|Any CPU.Build.0 = Release|Any CPU + {4656B671-8002-461D-8C4C-74A77546C187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4656B671-8002-461D-8C4C-74A77546C187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4656B671-8002-461D-8C4C-74A77546C187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4656B671-8002-461D-8C4C-74A77546C187}.Release|Any CPU.Build.0 = Release|Any CPU + {D5012909-9405-4DE0-84E7-354A5EFDF0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5012909-9405-4DE0-84E7-354A5EFDF0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5012909-9405-4DE0-84E7-354A5EFDF0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5012909-9405-4DE0-84E7-354A5EFDF0FC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/extern/meta b/extern/meta index 0aee3da..75faebd 160000 --- a/extern/meta +++ b/extern/meta @@ -1 +1 @@ -Subproject commit 0aee3dafa39a3a03c1f2c2b62a46ba14ad8ac469 +Subproject commit 75faebd40fc091fcaf995c6c174867537caf2b05 diff --git a/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/FileData/PathsMemoryMappedFileData.cs b/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/FileData/PathsMemoryMappedFileData.cs new file mode 100644 index 0000000..2b7f834 --- /dev/null +++ b/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/FileData/PathsMemoryMappedFileData.cs @@ -0,0 +1,60 @@ +using System; +using NexusMods.Archives.Nx.Interfaces; + +namespace NexusMods.Paths.Extensions.Nx.FileProviders.FileData; + +/// +/// Provides access to data of a Paths memory-mapped file. +/// +public unsafe class PathsMemoryMappedFileData : IFileData +{ + private readonly MemoryMappedFileHandle _memoryMappedFileHandle; + private readonly bool _disposeHandle; + private bool _disposed; + + /// + public byte* Data { get; } + + /// + public ulong DataLength { get; } + + /// + /// Paths memory mapped file data. + /// + /// The handle to use + /// The start offset in the file + /// The length of the data to map + /// Disposes the handle on close. + public PathsMemoryMappedFileData(MemoryMappedFileHandle handle, ulong start, ulong length, bool disposeHandle = true) + { + _memoryMappedFileHandle = handle; + _disposeHandle = disposeHandle; + if (start >= handle.Length) + { + Data = handle.Pointer; + DataLength = 0; + } + else + { + Data = handle.Pointer + start; + DataLength = Math.Min(length, handle.Length - start); + } + } + + /// + ~PathsMemoryMappedFileData() => Dispose(); + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + if (_disposeHandle) + _memoryMappedFileHandle.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/FromAbsolutePathProvider.cs b/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/FromAbsolutePathProvider.cs new file mode 100644 index 0000000..d5c7a36 --- /dev/null +++ b/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/FromAbsolutePathProvider.cs @@ -0,0 +1,29 @@ +using NexusMods.Archives.Nx.Interfaces; +using NexusMods.Paths.Extensions.Nx.FileProviders.FileData; +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace NexusMods.Paths.Extensions.Nx.FileProviders; + +/// +/// A provider for creating instances from an absolute path +/// +public class FromAbsolutePathProvider : IFileDataProvider +{ + /// + /// The full path to the file from which the data will be fetched. + /// + public required AbsolutePath FilePath { get; init; } + + /// + public IFileData GetFileData(ulong start, ulong length) + { + // TODO: This could probably be better, as it's unoptimal for chunked files. + // Ideally the file should be opened once in the provider and then calls in GetFileData + // could work on slices of the larger MMF. + // This however requires a change in Nx itself, which should be done at some point. + var fileSystem = FilePath.FileSystem; + var handle = fileSystem.CreateMemoryMappedFile(FilePath, FileMode.Open, MemoryMappedFileAccess.Read, 0); + return new PathsMemoryMappedFileData(handle, start, length); + } +} diff --git a/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/OutputAbsolutePathProvider.cs b/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/OutputAbsolutePathProvider.cs new file mode 100644 index 0000000..3c497e3 --- /dev/null +++ b/src/Extensions/NexusMods.Paths.Extensions.Nx/FileProviders/OutputAbsolutePathProvider.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using NexusMods.Archives.Nx.FileProviders.FileData; +using NexusMods.Archives.Nx.Headers.Managed; +using NexusMods.Archives.Nx.Interfaces; +using NexusMods.Paths.Extensions.Nx.FileProviders.FileData; + +namespace NexusMods.Paths.Extensions.Nx.FileProviders; + +/// +/// A provider for creating instances which allow +/// the user to output information to an absolute path. +/// +public class OutputAbsolutePathProvider : IOutputDataProvider +{ + /// + public string RelativePath { get; } + + /// + public FileEntry Entry { get; } + + /// + /// Full path to the file. + /// + public AbsolutePath FullPath { get; } + + private readonly MemoryMappedFileHandle? _mappedFileHandle; + private bool _isDisposed; + private readonly bool _isEmpty; + + /// + /// Initializes outputting a file to an absolute path. + /// + /// The absolute path to output the file. + /// The relative path of the file (context from the Nx archive). + /// The individual file entry (context from the Nx archive). + public OutputAbsolutePathProvider(AbsolutePath fullPath, string relativePath, FileEntry entry) + { + RelativePath = relativePath; + Entry = entry; + FullPath = fullPath; + + TryCreate: + try + { + if (entry.DecompressedSize <= 0) + { + using var _ = FullPath.FileSystem.CreateFile(FullPath); + _isEmpty = true; + return; + } + + // Ensure the directory exists + FullPath.FileSystem.CreateDirectory(FullPath.Parent); + + // Delete the file if it exists to ensure we start with an empty file + if (FullPath.FileSystem.FileExists(FullPath)) + FullPath.FileSystem.DeleteFile(FullPath); + + // Create the memory mapped file + _mappedFileHandle = FullPath.FileSystem.CreateMemoryMappedFile(FullPath, FileMode.CreateNew, MemoryMappedFileAccess.ReadWrite, entry.DecompressedSize); + } + catch (DirectoryNotFoundException) + { + // This is written this way because explicit check is slow. + FullPath.FileSystem.CreateDirectory(FullPath.Parent); + goto TryCreate; + } + } + + /// + public IFileData GetFileData(ulong start, ulong length) + { + if (_isEmpty) + return new ArrayFileData(Array.Empty(), 0, 0); + + return new PathsMemoryMappedFileData(_mappedFileHandle!.Value, start, length, false); + } + + /// + ~OutputAbsolutePathProvider() => Dispose(); + + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + _mappedFileHandle?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Extensions/NexusMods.Paths.Extensions.Nx/NexusMods.Paths.Extensions.Nx.csproj b/src/Extensions/NexusMods.Paths.Extensions.Nx/NexusMods.Paths.Extensions.Nx.csproj new file mode 100644 index 0000000..74f764e --- /dev/null +++ b/src/Extensions/NexusMods.Paths.Extensions.Nx/NexusMods.Paths.Extensions.Nx.csproj @@ -0,0 +1,16 @@ + + + + + true + + + + + + + + + + + diff --git a/src/NexusMods.Paths/FileSystemAbstraction/BaseFileSystem.cs b/src/NexusMods.Paths/FileSystemAbstraction/BaseFileSystem.cs index 5e62594..529241e 100644 --- a/src/NexusMods.Paths/FileSystemAbstraction/BaseFileSystem.cs +++ b/src/NexusMods.Paths/FileSystemAbstraction/BaseFileSystem.cs @@ -424,9 +424,9 @@ public virtual UnixFileMode GetUnixFileMode(AbsolutePath absolutePath) } /// - public MemoryMappedFileHandle CreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access) + public MemoryMappedFileHandle CreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access, ulong fileSize) { - return InternalCreateMemoryMappedFile(GetMappedPath(absPath), mode, access); + return InternalCreateMemoryMappedFile(GetMappedPath(absPath), mode, access, fileSize); } #endregion @@ -470,6 +470,6 @@ public MemoryMappedFileHandle CreateMemoryMappedFile(AbsolutePath absPath, FileM protected abstract void InternalMoveFile(AbsolutePath source, AbsolutePath dest, bool overwrite); /// - protected abstract MemoryMappedFileHandle InternalCreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access); + protected abstract MemoryMappedFileHandle InternalCreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access, ulong fileSize); #endregion } diff --git a/src/NexusMods.Paths/FileSystemAbstraction/IFileSystem.cs b/src/NexusMods.Paths/FileSystemAbstraction/IFileSystem.cs index d6ea349..64e48da 100644 --- a/src/NexusMods.Paths/FileSystemAbstraction/IFileSystem.cs +++ b/src/NexusMods.Paths/FileSystemAbstraction/IFileSystem.cs @@ -289,5 +289,6 @@ Stream OpenFile(AbsolutePath path, /// Path of the file to memory map. /// The mode the file is opened with. /// What you intend to do with the memory mapped file. - MemoryMappedFileHandle CreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access); + /// The size of the file, if creating a new file. + MemoryMappedFileHandle CreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access, ulong fileSize); } diff --git a/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileEntry.cs b/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileEntry.cs index 9549585..58b5e4c 100644 --- a/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileEntry.cs +++ b/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileEntry.cs @@ -44,7 +44,7 @@ public FileVersionInfo GetFileVersionInfo() public MemoryStream CreateReadStream() { - var ms = new MemoryStream(_contents, 0, _contents.Length, false); + var ms = new MemoryStream(_contents, 0, _contents.Length, false, true); return ms; } diff --git a/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileSystem.cs b/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileSystem.cs index 8b38968..6469403 100644 --- a/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileSystem.cs +++ b/src/NexusMods.Paths/FileSystemAbstraction/InMemoryFileSystem/InMemoryFileSystem.cs @@ -7,7 +7,6 @@ using System.Text; using JetBrains.Annotations; using NexusMods.Paths.Utilities; -using Reloaded.Memory.Utilities; namespace NexusMods.Paths; @@ -232,7 +231,7 @@ protected override IEnumerable InternalEnumerateFileEntries(Absolute /// protected override Stream InternalOpenFile(AbsolutePath path, FileMode mode, FileAccess access, FileShare share) { - var inMemoryFileEntry = InternalCreateFile(path, mode, access); + var inMemoryFileEntry = InternalCreateFile(path, mode, access, 0); return access switch { FileAccess.Read => inMemoryFileEntry.CreateReadStream(), @@ -241,7 +240,7 @@ protected override Stream InternalOpenFile(AbsolutePath path, FileMode mode, Fil }; } - private InMemoryFileEntry InternalCreateFile(AbsolutePath path, FileMode mode, FileAccess access) + private InMemoryFileEntry InternalCreateFile(AbsolutePath path, FileMode mode, FileAccess access, ulong fileSize) { if (access == FileAccess.Read && mode != FileMode.Open && mode != FileMode.OpenOrCreate) { @@ -259,7 +258,7 @@ private InMemoryFileEntry InternalCreateFile(AbsolutePath path, FileMode mode, F case FileMode.Create: { if (!_files.TryGetValue(path, out inMemoryFileEntry)) - inMemoryFileEntry = InternalAddFile(path, Array.Empty()); + inMemoryFileEntry = InternalAddFile(path, new byte[fileSize]); else inMemoryFileEntry.SetContents(Array.Empty()); break; @@ -268,13 +267,13 @@ private InMemoryFileEntry InternalCreateFile(AbsolutePath path, FileMode mode, F { if (_files.ContainsKey(path)) throw new IOException($"{FileMode.CreateNew} can't be used if the file already exists!"); - inMemoryFileEntry = InternalAddFile(path, Array.Empty()); + inMemoryFileEntry = InternalAddFile(path, new byte[fileSize]); break; } case FileMode.OpenOrCreate: { if (!_files.TryGetValue(path, out inMemoryFileEntry)) - inMemoryFileEntry = InternalAddFile(path, Array.Empty()); + inMemoryFileEntry = InternalAddFile(path, new byte[fileSize]); break; } case FileMode.Truncate: @@ -399,7 +398,7 @@ protected override void InternalMoveFile(AbsolutePath source, AbsolutePath dest, } /// - protected override unsafe MemoryMappedFileHandle InternalCreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access) + protected override unsafe MemoryMappedFileHandle InternalCreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access, ulong fileSize) { var fileAccess = access switch { @@ -409,16 +408,9 @@ protected override unsafe MemoryMappedFileHandle InternalCreateMemoryMappedFile( _ => throw new ArgumentOutOfRangeException(nameof(access), access, null) }; - var file = InternalCreateFile(absPath, mode, fileAccess); - var stream = fileAccess switch - { - FileAccess.Read => file.CreateReadStream(), - FileAccess.Write => file.CreateWriteStream(), - FileAccess.ReadWrite => file.CreateReadWriteStream(), - }; - - var buffer = stream.GetBuffer(); - var pin = new Pinnable(buffer); + var file = InternalCreateFile(absPath, mode, fileAccess, fileSize); + var buffer = file.GetContents(); + var pin = new Pin(buffer); return new MemoryMappedFileHandle(pin.Pointer, (nuint)file.Size.Value, pin); } diff --git a/src/NexusMods.Paths/FileSystemAbstraction/RealFileSystem/FileSystem.cs b/src/NexusMods.Paths/FileSystemAbstraction/RealFileSystem/FileSystem.cs index 4c2d92d..e35c29a 100644 --- a/src/NexusMods.Paths/FileSystemAbstraction/RealFileSystem/FileSystem.cs +++ b/src/NexusMods.Paths/FileSystemAbstraction/RealFileSystem/FileSystem.cs @@ -228,23 +228,37 @@ protected override void InternalMoveFile(AbsolutePath source, AbsolutePath dest, => File.Move(source.GetFullPath(), dest.GetFullPath(), overwrite); /// - protected override unsafe MemoryMappedFileHandle InternalCreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access) + protected override unsafe MemoryMappedFileHandle InternalCreateMemoryMappedFile(AbsolutePath absPath, FileMode mode, MemoryMappedFileAccess access, ulong fileSize) { var fs = new FileStream(absPath.GetFullPath(), new FileStreamOptions { Mode = mode, Access = GetFileAccess(access), Share = FileShare.Read, - BufferSize = 0 + BufferSize = 0, + PreallocationSize = (long)fileSize }); MemoryMappedFile? mmf = null; MemoryMappedViewAccessor? view = null; try { - mmf = MemoryMappedFile.CreateFromFile(fs, null, fs.Length, access, HandleInheritability.None, false); + // Note(sewer): + // If the file is empty, we can't create a memory mapped file. + // So instead return a null pointer. + // This pointer is null because this helps us detect invalid read/write via Access Violation (0xC0000005). + if (fileSize == 0) + fileSize = (ulong)fs.Length; + + if (fileSize == 0) + { + fs.Dispose(); // Dispose early. + return new MemoryMappedFileHandle((byte*)0, 0, null); + } + + mmf = MemoryMappedFile.CreateFromFile(fs, null, (long)fileSize, access, HandleInheritability.None, false); view = mmf.CreateViewAccessor(0, 0, access); var ptrData = (byte*)view.SafeMemoryMappedViewHandle.DangerousGetHandle(); - return new MemoryMappedFileHandle(ptrData, (nuint)fs.Length, new FilesystemMemoryMappedHandle(view, mmf)); + return new MemoryMappedFileHandle(ptrData, (nuint)fileSize, new FilesystemMemoryMappedHandle(view, mmf)); } catch { diff --git a/src/NexusMods.Paths/Utilities/Pin.cs b/src/NexusMods.Paths/Utilities/Pin.cs new file mode 100644 index 0000000..21f6025 --- /dev/null +++ b/src/NexusMods.Paths/Utilities/Pin.cs @@ -0,0 +1,50 @@ +using System; +using System.Runtime.InteropServices; +namespace NexusMods.Paths.Utilities; + +/// +/// Pins an existing array to memory. +/// +/// The type of element being pinned in native memory. +internal unsafe class Pin : IDisposable where T : unmanaged +{ + /// + /// Pointer to the first value of the pinned array. + /// + public T* Pointer { get; private set; } + + // Handle keeping the object pinned. + private GCHandle _handle; + private bool _disposed; + + /// + /// Pins an array of values to the heap. + /// + /// + /// The values to be pinned on the heap. + /// Depending on runtime used, these may be copied; so please use property. + /// Do not use original array once passed to this function. + /// + public Pin(T[] value) + { + _handle = GCHandle.Alloc(value, GCHandleType.Pinned); + Pointer = (T*)_handle.AddrOfPinnedObject(); + } + + ~Pin() => Dispose(); + + /// + public void Dispose() + { + // Add disposed check + if (_disposed) + return; + + _disposed = true; + if (_handle.IsAllocated) + _handle.Free(); + + Pointer = (T*)0; + GC.SuppressFinalize(this); + } +} diff --git a/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/FileData/PathsMemoryMappedDataTests.cs b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/FileData/PathsMemoryMappedDataTests.cs new file mode 100644 index 0000000..d6d19ad --- /dev/null +++ b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/FileData/PathsMemoryMappedDataTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using NexusMods.Paths.Extensions.Nx.FileProviders.FileData; +using Xunit; +namespace NexusMods.Paths.Extensions.Nx.Tests.FileProviders.FileData; + +public unsafe class PathsMemoryMappedDataTests +{ + [Fact] + public void Constructor_RespectsStartOffsetAndLength() + { + // Arrange + var testData = new byte[] { 1, 2, 3, 4, 5 }; + fixed (byte* testDataPtr = &testData[0]) + { + var testHandle = new MemoryMappedFileHandle(testDataPtr, (nuint)testData.Length, null); + + // Act + var fileData = new PathsMemoryMappedFileData(testHandle, 1, 3, false); + + // Assert + fileData.DataLength.Should().Be(3ul); + fileData.Data[0].Should().Be(2); + fileData.Data[1].Should().Be(3); + fileData.Data[2].Should().Be(4); + } + } + + [Fact] + public void Constructor_HandlesOverflow() + { + // Arrange + var testData = new byte[] { 1, 2, 3, 4, 5 }; + fixed (byte* testDataPtr = &testData[0]) + { + var testHandle = new MemoryMappedFileHandle(testDataPtr, (nuint)testData.Length, null); + + // Act + var fileData = new PathsMemoryMappedFileData(testHandle, 3, 10, false); + + // Assert + fileData.DataLength.Should().Be(2ul); + fileData.Data[0].Should().Be(4); + fileData.Data[1].Should().Be(5); + } + } +} diff --git a/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/FromAbsolutePathProviderTests.cs b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/FromAbsolutePathProviderTests.cs new file mode 100644 index 0000000..5433ba9 --- /dev/null +++ b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/FromAbsolutePathProviderTests.cs @@ -0,0 +1,114 @@ +using FluentAssertions; +using NexusMods.Paths.Extensions.Nx.FileProviders; +using Xunit; +namespace NexusMods.Paths.Extensions.Nx.Tests.FileProviders; + +public class FromAbsolutePathProviderTests +{ + public static IEnumerable FileSystemTypes() + { + yield return new object[] { FileSystem.Shared }; + yield return new object[] { new InMemoryFileSystem() }; + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public async Task GetFileData_ReturnsCorrectData(IFileSystem fileSystem) + { + // Arrange + var path = await CreateTestFile(fileSystem, new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + var provider = new FromAbsolutePathProvider { FilePath = path }; + + // Act + using (var fileData = provider.GetFileData(2, 3)) + { + // Assert + fileData.DataLength.Should().Be(3ul); + unsafe + { + fileData.Data[0].Should().Be(2); + fileData.Data[1].Should().Be(3); + fileData.Data[2].Should().Be(4); + } + } + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public async Task GetFileData_HandlesOverflow(IFileSystem fileSystem) + { + // Arrange + var path = await CreateTestFile(fileSystem, new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + var provider = new FromAbsolutePathProvider { FilePath = path }; + + // Act + using (var fileData = provider.GetFileData(8, 5)) + { + // Assert + fileData.DataLength.Should().Be(2ul); + unsafe + { + fileData.Data[0].Should().Be(8); + fileData.Data[1].Should().Be(9); + } + } + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public async Task GetFileData_HandlesEmptyFile(IFileSystem fileSystem) + { + // Arrange + var path = await CreateTestFile(fileSystem, Array.Empty()); + var provider = new FromAbsolutePathProvider { FilePath = path }; + + // Act + using (var fileData = provider.GetFileData(0, 5)) + { + // Assert + fileData.DataLength.Should().Be(0ul); + } + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public async Task GetFileData_HandlesOutOfBoundsOverflow(IFileSystem fileSystem) + { + // Arrange + var path = await CreateTestFile(fileSystem, new byte[] { 0, 1, 2, 3, 4 }); + var provider = new FromAbsolutePathProvider { FilePath = path }; + + // Act + using (var fileData = provider.GetFileData(10, 5)) + { + // Assert + fileData.DataLength.Should().Be(0ul); + } + + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + private static async Task CreateTestFile(IFileSystem fileSystem, byte[] testData) + { + var path = fileSystem.GetKnownPath(KnownPath.TempDirectory).Combine(Guid.NewGuid().ToString()); + await fileSystem.WriteAllBytesAsync(path, testData); + return path; + } + + private static void CleanupTestFile(IFileSystem fileSystem, AbsolutePath path) + { + if (fileSystem.FileExists(path)) + fileSystem.DeleteFile(path); + } +} diff --git a/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/OutputAbsolutePathProviderTests.cs b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/OutputAbsolutePathProviderTests.cs new file mode 100644 index 0000000..01b7394 --- /dev/null +++ b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/FileProviders/OutputAbsolutePathProviderTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using NexusMods.Archives.Nx.FileProviders.FileData; +using NexusMods.Archives.Nx.Headers.Managed; +using NexusMods.Paths.Extensions.Nx.FileProviders; +using Xunit; +namespace NexusMods.Paths.Extensions.Nx.Tests.FileProviders; + +public class OutputAbsolutePathProviderTests +{ + public static IEnumerable FileSystemTypes() + { + yield return new object[] { FileSystem.Shared }; + yield return new object[] { new InMemoryFileSystem() }; + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public void GetFileData_ReturnsCorrectData(IFileSystem fileSystem) + { + // Arrange + var path = CreateTestPath(fileSystem); + var entry = new FileEntry { DecompressedSize = 100 }; + + // Act + using (var provider = new OutputAbsolutePathProvider(path, "relative/path.txt", entry)) + using (var fileData = provider.GetFileData(10, 5)) + { + // Assert + fileData.DataLength.Should().Be(5ul); + } + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public void GetFileData_HandlesEmptyFile(IFileSystem fileSystem) + { + // Arrange + var path = CreateTestPath(fileSystem); + var entry = new FileEntry { DecompressedSize = 0 }; + + // Act + using (var provider = new OutputAbsolutePathProvider(path, "relative/path.txt", entry)) + using (var fileData = provider.GetFileData(0, 5)) + { + // Assert + fileData.DataLength.Should().Be(0ul); + Assert.IsType(fileData); + } + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + [Theory] + [MemberData(nameof(FileSystemTypes))] + public void Constructor_CreatesFile(IFileSystem fileSystem) + { + // Arrange + var path = CreateTestPath(fileSystem); + var entry = new FileEntry { DecompressedSize = 100 }; + + // Act + using (_ = new OutputAbsolutePathProvider(path, "relative/path.txt", entry)) + { + // Assert + fileSystem.FileExists(path).Should().BeTrue(); + } + + // Cleanup + CleanupTestFile(fileSystem, path); + } + + private static AbsolutePath CreateTestPath(IFileSystem fileSystem) + { + return fileSystem.GetKnownPath(KnownPath.TempDirectory).Combine(Guid.NewGuid().ToString()); + } + + private static void CleanupTestFile(IFileSystem fileSystem, AbsolutePath path) + { + if (fileSystem.FileExists(path)) + { + fileSystem.DeleteFile(path); + } + } +} diff --git a/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/NexusMods.Paths.Extensions.Nx.Tests.csproj b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/NexusMods.Paths.Extensions.Nx.Tests.csproj new file mode 100644 index 0000000..6036dec --- /dev/null +++ b/tests/Extensions/NexusMods.Paths.Extensions.Nx.Tests/NexusMods.Paths.Extensions.Nx.Tests.csproj @@ -0,0 +1,13 @@ + + + + false + true + + + + + + + + diff --git a/tests/Extensions/SharedAssemblyAttributes.cs b/tests/Extensions/SharedAssemblyAttributes.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/Extensions/SharedUsings.cs b/tests/Extensions/SharedUsings.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/NexusMods.Paths.Tests/FileSystem/FileSystemTests.cs b/tests/NexusMods.Paths.Tests/FileSystem/FileSystemTests.cs index 768178a..ceb0457 100644 --- a/tests/NexusMods.Paths.Tests/FileSystem/FileSystemTests.cs +++ b/tests/NexusMods.Paths.Tests/FileSystem/FileSystemTests.cs @@ -93,7 +93,7 @@ public async Task Test_CreateMemoryMappedFile_CanOpen(RelativePath relativePath, unsafe { - using var mmf = fs.CreateMemoryMappedFile(file, FileMode.Open, MemoryMappedFileAccess.ReadWrite); + using var mmf = fs.CreateMemoryMappedFile(file, FileMode.Open, MemoryMappedFileAccess.ReadWrite, 0); mmf.Should().NotBeNull(); ((nuint)mmf.Pointer).Should().NotBe(0); mmf.Length.Should().Be((nuint)file.FileInfo.Size); @@ -102,4 +102,52 @@ public async Task Test_CreateMemoryMappedFile_CanOpen(RelativePath relativePath, mmf.AsSpan().SequenceEqual(contents).Should().BeTrue(); } } + + [Fact] + public async Task Test_CreateMemoryMappedFile_CanCreateAndWrite() + { + var fs = new Paths.FileSystem(); + var tempFile = fs.GetKnownPath(KnownPath.TempDirectory).Combine(Path.GetRandomFileName()); + var contents = new byte[] { 1, 2, 3, 4, 5 }; + + // Create a new MemoryMappedFile + try + { + using (var mmf = fs.CreateMemoryMappedFile(tempFile, FileMode.CreateNew, MemoryMappedFileAccess.ReadWrite, + (ulong)contents.Length)) + { + unsafe + { + mmf.Should().NotBeNull(); + ((nuint)mmf.Pointer).Should().NotBe(0); + mmf.Length.Should().Be((nuint)contents.Length); + + // Write data to the MemoryMappedFile + contents.CopyTo(new Span(mmf.Pointer, contents.Length)); + } + } + + /* + Note(sewer) + + There's something weird going on in the Memory Mapped File abstraction, + we open a FileStream with `FileShare.Read`, to create the MemoryMappedFile from under the hood, + and we propagate this when opening the MemoryMappedFile. + + Below in `ReadAllBytesAsync`, we are opening a second FileStream with `FileAccess.Read` and `FileShare.Read`. + It should technically work, but it does not. Chances are it's some not properly documented part + of .NET's MemoryMappedFile abstraction for Win32. + */ + + // Verify the data was written correctly + var writtenData = await fs.ReadAllBytesAsync(tempFile); + writtenData.Should().BeEquivalentTo(contents); + } + finally + { + // Clean up + if (fs.FileExists(tempFile)) + fs.DeleteFile(tempFile); + } + } } diff --git a/tests/NexusMods.Paths.Tests/FileSystem/InMemoryFileSystemTests.cs b/tests/NexusMods.Paths.Tests/FileSystem/InMemoryFileSystemTests.cs index f847981..30cc1d1 100644 --- a/tests/NexusMods.Paths.Tests/FileSystem/InMemoryFileSystemTests.cs +++ b/tests/NexusMods.Paths.Tests/FileSystem/InMemoryFileSystemTests.cs @@ -352,7 +352,7 @@ public void Test_CreateMemoryMappedFile_CanOpen(InMemoryFileSystem fs, unsafe { - using var mmf = fs.CreateMemoryMappedFile(file, FileMode.Open, MemoryMappedFileAccess.ReadWrite); + using var mmf = fs.CreateMemoryMappedFile(file, FileMode.Open, MemoryMappedFileAccess.ReadWrite, 0); mmf.Should().NotBeNull(); ((nuint)mmf.Pointer).Should().NotBe(0); mmf.Length.Should().Be((nuint)file.FileInfo.Size); @@ -361,4 +361,26 @@ public void Test_CreateMemoryMappedFile_CanOpen(InMemoryFileSystem fs, mmf.AsSpan().SequenceEqual(contents).Should().BeTrue(); } } + + [Theory, AutoFileSystem] + public async Task Test_CreateMemoryMappedFile_CanCreateAndWrite(InMemoryFileSystem fs, + AbsolutePath file, byte[] contents) + { + // Create a new MemoryMappedFile + using var mmf = fs.CreateMemoryMappedFile(file, FileMode.CreateNew, MemoryMappedFileAccess.ReadWrite, (ulong)contents.Length); + + unsafe + { + mmf.Should().NotBeNull(); + ((nuint)mmf.Pointer).Should().NotBe(0); + mmf.Length.Should().Be((nuint)contents.Length); + + // Write data to the MemoryMappedFile + contents.CopyTo(new Span(mmf.Pointer, contents.Length)); + } + + // Verify the data was written correctly + var writtenData = await fs.ReadAllBytesAsync(file); + writtenData.Should().BeEquivalentTo(contents); + } }