From 67b8fca2e4e053bc297c41ec65370c1873dabe01 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 18 Aug 2023 08:51:37 -0500 Subject: [PATCH 01/11] add case insensitive pathing for proper cross-platform kotor support of file io --- KotorDotNET/KotorDotNET.csproj | 1 + KotorDotNET/Utility/PathHelper.cs | 567 ++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+) create mode 100644 KotorDotNET/Utility/PathHelper.cs diff --git a/KotorDotNET/KotorDotNET.csproj b/KotorDotNET/KotorDotNET.csproj index 95a3246..1c9f353 100644 --- a/KotorDotNET/KotorDotNET.csproj +++ b/KotorDotNET/KotorDotNET.csproj @@ -8,6 +8,7 @@ + diff --git a/KotorDotNET/Utility/PathHelper.cs b/KotorDotNET/Utility/PathHelper.cs new file mode 100644 index 0000000..dbf1dbe --- /dev/null +++ b/KotorDotNET/Utility/PathHelper.cs @@ -0,0 +1,567 @@ +// Copyright 2021-2023 KOTORModSync +// Licensed under the GNU General Public License v3.0 (GPLv3). +// See LICENSE.txt file in the project root for full license information. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace KotorDotNET.Utility +{ + public static class PathValidator + { + // Characters not allowed in Windows file and directory names + // we don't check colon or any slashes because we aren't validating file/folder names, only a full path string. + private static readonly char[] s_invalidPathCharsWindows = { + '<', '>', '"', '|', '?', '*', + '\0', '\n', '\r', '\t', '\b', '\a', '\v', '\f', + }; + + // Characters not allowed in Unix file and directory names + private static readonly char[] s_invalidPathCharsUnix = { + '\0', + }; + + // Reserved file names in Windows + private static readonly string[] s_reservedFileNamesWindows = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + public static bool IsValidPath( string path) + { + if ( string.IsNullOrEmpty( path ) ) + return false; + + try + { + // Check for forbidden printable ASCII characters + char[] invalidChars = GetInvalidCharsForPlatform(); + if (path.IndexOfAny( invalidChars ) >= 0) + return false; + + // Check for non-printable characters + if ( ContainsNonPrintableChars(path) ) + return false; + + // Check for reserved file names in Windows + //if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) + //{ + if ( IsReservedFileNameWindows(path) ) + return false; + + // Check for invalid filename parts + // ReSharper disable once ConvertIfStatementToReturnStatement + if ( HasInvalidWindowsFileNameParts(path) ) + return false; + //} + + return true; + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return false; + } + } + + private static char[] GetInvalidCharsForPlatform() + { + return Environment.OSVersion.Platform == PlatformID.Unix + ? s_invalidPathCharsUnix + : s_invalidPathCharsWindows; + } + + private static bool ContainsNonPrintableChars(string? path) => path?.Any( c => c < ' ' && c != '\t' ) ?? false; + private static bool IsReservedFileNameWindows(string path) + { + string fileName = Path.GetFileNameWithoutExtension(path); + + // Check if any reserved filename matches the filename (case-insensitive) + return s_reservedFileNamesWindows.Any(reservedName => string.Equals(reservedName, fileName, StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasInvalidWindowsFileNameParts(string path) + { + string fileName = Path.GetFileNameWithoutExtension(path); + + // Check for a filename ending with a period or space + if (fileName.EndsWith(" ") || fileName.EndsWith(".")) + return true; + + // Check for consecutive periods in the filename + for (int i = 0; i < fileName.Length - 1; i++) + { + if (fileName[i] == '.' && fileName[i + 1] == '.') + return true; + } + + return false; + } + } + + public static class PathHelper + { + // if it's a folder, return path as is, if it's a file get the parent dir. + public static string? GetFolderName( string? filePath ) + { + return Path.HasExtension( filePath ) + ? Path.GetDirectoryName( filePath ) + : filePath; + } + + public static DirectoryInfo? TryGetValidDirectoryInfo(string? destinationPath) + { + if ( destinationPath is null ) + return null; + if ( destinationPath.IndexOfAny( Path.GetInvalidPathChars() ) >= 0 ) + return null; + + try + { + return new DirectoryInfo(destinationPath); + } + catch (Exception) + { + // In .NET Framework 4.6.2, the DirectoryInfo constructor throws an exception + // when the path is invalid. We catch the exception and return null instead. + return null; + } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern int GetLongPathName(string shortPath, StringBuilder longPath, int bufferSize); + + public static string ConvertWindowsPathToCaseSensitive( string path ) + { + if ( Environment.OSVersion.Platform != PlatformID.Win32NT ) + return path; + + const int bufferSize = 260; // MAX_PATH on Windows + var longPathBuffer = new StringBuilder(bufferSize); + + int result = GetLongPathName(path, longPathBuffer, bufferSize); + // ReSharper disable once InvertIf + if ( result <= 0 || result >= bufferSize ) + { + // Handle the error, e.g., the function call failed, or the buffer was too small + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception( error ); + } + + // The function succeeded, and the result contains the case-sensitive long path + return longPathBuffer.ToString(); + } + + public static string? GetCaseSensitivePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); + + if (!PathValidator.IsValidPath(path)) + throw new ArgumentException($"{path} is not a valid path!"); + + path = Path.GetFullPath(path); + if (File.Exists(path) || Directory.Exists(path)) + return ConvertWindowsPathToCaseSensitive(path); + + string parentDirPath = Path.GetDirectoryName(path) + ?? throw new NullReferenceException($"Path.GetDirectoryName(path) when path is '{path}'"); + + DirectoryInfo parentDir = TryGetValidDirectoryInfo(parentDirPath) + ?? throw new NullReferenceException( "TryGetValidDirectoryInfo(parentDirPath)" ); + return !parentDir.Exists && !( parentDir = TryGetValidDirectoryInfo( GetCaseSensitivePath(parentDirPath) ) + ?? throw new DirectoryNotFoundException($"Could not find case-sensitive directory for path string '{parentDirPath}'") ).Exists + ? throw new DirectoryNotFoundException($"Could not find case-sensitive directory for path string '{parentDirPath}'") + : GetCaseSensitiveChildPath(parentDir, path); + } + + private static string? GetCaseSensitiveChildPath(DirectoryInfo? parentDir, string path) => + ( + from item in parentDir?.GetFileSystemInfos("*", SearchOption.TopDirectoryOnly) + where item.FullName.Equals( path, StringComparison.OrdinalIgnoreCase ) + select ConvertWindowsPathToCaseSensitive( item.FullName ) + ).FirstOrDefault(); + + public static string? GetCaseSensitivePath( FileInfo file ) => GetCaseSensitivePath( file.FullName ); + + public static string? GetCaseSensitivePath( DirectoryInfo directory ) => GetCaseSensitivePath( directory.FullName ); + + public static async Task MoveFileAsync( string sourcePath, string destinationPath ) + { + if ( sourcePath is null ) + throw new ArgumentNullException( nameof( sourcePath ) ); + if ( destinationPath is null ) + throw new ArgumentNullException( nameof( destinationPath ) ); + + await using ( var sourceStream = new FileStream( + sourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true + ) ) + { + await using ( var destinationStream = new FileStream( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true + ) ) + { + await sourceStream.CopyToAsync( destinationStream ); + } + } + + // The file is closed at this point, so it can be safely deleted + File.Delete( sourcePath ); + } + + public static List EnumerateFilesWithWildcards( + IEnumerable filesAndFolders, + bool topLevelOnly = false + ) + { + if ( filesAndFolders is null ) + throw new ArgumentNullException( nameof( filesAndFolders ) ); + + List result = new(); + HashSet uniquePaths = new( filesAndFolders ); + + foreach ( string path in uniquePaths.Where( path => !string.IsNullOrEmpty( path ) ) ) + { + try + { + string formattedPath = FixPathFormatting( path ); + + if ( !ContainsWildcards( formattedPath ) ) + { + // Handle non-wildcard paths + if ( File.Exists( formattedPath ) ) + { + result.Add( formattedPath ); + continue; + } + + if ( Directory.Exists( formattedPath ) ) + { + IEnumerable matchingFiles = Directory.EnumerateFiles( + formattedPath, + searchPattern: "*", + topLevelOnly + ? SearchOption.TopDirectoryOnly + : SearchOption.AllDirectories + ); + + result.AddRange( matchingFiles ); + } + + continue; + } + + // Handle wildcard paths + string? directory = Path.GetDirectoryName( formattedPath ); + + if ( !string.IsNullOrEmpty( directory ) + && directory.IndexOfAny( Path.GetInvalidPathChars() ) != -1 + && Directory.Exists( directory ) ) + { + IEnumerable matchingFiles = Directory.EnumerateFiles( + directory, + Path.GetFileName( formattedPath ), + topLevelOnly + ? SearchOption.TopDirectoryOnly + : SearchOption.AllDirectories + ); + + result.AddRange( matchingFiles ); + continue; + } + + // Handle wildcard paths + string currentDirectory = formattedPath; + + while ( ContainsWildcards( currentDirectory ) ) + { + string? parentDirectory = Path.GetDirectoryName( currentDirectory ); + + // Exit the loop if no parent directory is found or if the parent directory is the same as the current directory + if ( string.IsNullOrEmpty( parentDirectory ) || parentDirectory == currentDirectory ) + break; + + currentDirectory = parentDirectory; + } + + if ( string.IsNullOrEmpty( currentDirectory ) || !Directory.Exists( currentDirectory ) ) + continue; + + IEnumerable checkFiles = Directory.EnumerateFiles( + currentDirectory, + searchPattern: "*", + topLevelOnly + ? SearchOption.TopDirectoryOnly + : SearchOption.AllDirectories + ); + + result.AddRange( + checkFiles.Where( + thisFile => WildcardPathMatch( thisFile, formattedPath ) + ) + ); + } + catch ( Exception ex ) + { + // Handle or log the exception as required + Console.WriteLine( $"An error occurred while processing path '{path}': {ex.Message}" ); + } + } + + return result; + } + + private static bool ContainsWildcards( string path ) => path.Contains( '*' ) || path.Contains( '?' ); + + public static bool WildcardPathMatch( string input, string patternInput ) + { + if ( input is null ) + throw new ArgumentNullException( nameof( input ) ); + if ( patternInput is null ) + throw new ArgumentNullException( nameof( patternInput ) ); + + // Fix path formatting + input = FixPathFormatting( input ); + patternInput = FixPathFormatting( patternInput ); + + // Split the input and pattern into directory levels + string[] inputLevels = input.Split( Path.DirectorySeparatorChar ); + string[] patternLevels = patternInput.Split( Path.DirectorySeparatorChar ); + + // Ensure the number of levels match + if ( inputLevels.Length != patternLevels.Length ) + return false; + + // Iterate over each level and perform wildcard matching + for ( int i = 0; i < inputLevels.Length; i++ ) + { + string inputLevel = inputLevels[i]; + string patternLevel = patternLevels[i]; + + // Check if the current level is a wildcard + if ( patternLevel is "*" or "?" ) + continue; + + // Check if the current level matches the pattern + if ( !WildcardMatch( inputLevel, patternLevel ) ) + return false; + } + + return true; + } + + // Most end users don't know Regex, this function will convert basic wildcards to regex patterns. + private static bool WildcardMatch( string input, string pattern ) + { + if ( input is null ) + throw new ArgumentNullException( nameof( input ) ); + if ( pattern is null ) + throw new ArgumentNullException( nameof( pattern ) ); + + // Escape special characters in the pattern + pattern = Regex.Escape( pattern ); + + // Replace * with .* and ? with . in the pattern + pattern = pattern.Replace( oldValue: @"\*", newValue: ".*" ) + .Replace( oldValue: @"\?", newValue: "." ); + + // Use regex to perform the wildcard matching + return Regex.IsMatch( input, $"^{pattern}$" ); + } + + public static string FixPathFormatting( string path ) + { + // Replace backslashes with forward slashes + string formattedPath = path.Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ) + .Replace( oldChar: '\\', Path.DirectorySeparatorChar ) + .Replace( oldChar: '/', Path.DirectorySeparatorChar ); + + // Fix repeated slashes + formattedPath = Regex.Replace( + formattedPath, + $"(? FindCaseInsensitiveDuplicates(DirectoryInfo directory) + { + if (directory is null) + throw new ArgumentNullException(nameof(directory)); + + var duplicates = new List(); + + try + { + FindDuplicatesRecursively(directory, duplicates); + } + catch (Exception ex) + { + Console.WriteLine(ex); + return duplicates; + } + + return duplicates; + } + + private static void FindDuplicatesRecursively(DirectoryInfo directory, List duplicates) + { + if (duplicates is null) + throw new ArgumentNullException(nameof(duplicates)); + + // Check if the directory exists and if we have access to it. + if (!directory.Exists) + throw new DirectoryNotFoundException("Directory not found."); + + var fileList = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var folderList = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + // Search for files and add them to the file dictionary. + foreach (FileInfo file in directory.GetFiles()) + { + if (file.Exists != true) + continue; + + string fileNameWithExtension = file.Name; + if (!fileList.TryGetValue(fileNameWithExtension, out List? files)) + { + files = new List(); + fileList.Add(fileNameWithExtension, files); + } + + files.Add(file); + } + + // Search for subdirectories and add them to the folder dictionary. + foreach (DirectoryInfo subdirectory in directory.GetDirectories()) + { + if (subdirectory.Exists != true) + continue; + + if (!folderList.TryGetValue(subdirectory.Name, out List? folders)) + { + folders = new List(); + folderList.Add(subdirectory.Name, folders); + } + + folders.Add(subdirectory); + + // Recursively search the sub-directory. + FindDuplicatesRecursively(subdirectory, duplicates); + + // Check for file duplicates within the current sub-directory. + foreach (KeyValuePair> fileListEntry in fileList) + { + List files = fileListEntry.Value; + if (files.Count > 1) + duplicates.AddRange(files); + } + + // Check for folder duplicates within the current sub-directory. + foreach (KeyValuePair> folderListEntry in folderList) + { + List foldersInCurrentDir = folderListEntry.Value; + if (foldersInCurrentDir.Count > 1) + duplicates.AddRange(foldersInCurrentDir); + } + + // Clear the dictionaries for the next sub-directory. + fileList.Clear(); + folderList.Clear(); + } + } + + public static List FindCaseInsensitiveDuplicates( string path ) + { + if ( !PathValidator.IsValidPath( path ) ) + throw new ArgumentException( nameof( path ) + " is not a valid path string" ); + + var directory = new DirectoryInfo( path ); + return FindCaseInsensitiveDuplicates( directory ); + } + + public static (FileSystemInfo?, List) GetClosestMatchingEntry( string path ) + { + if ( !PathValidator.IsValidPath( path ) ) + throw new ArgumentException( nameof( path ) + " is not a valid path string" ); + + string? directoryName = Path.GetDirectoryName( path ); + string searchPattern = Path.GetFileName( path ); + + FileSystemInfo? closestMatch = null; + int maxMatchingCharacters = -1; + var duplicatePaths = new List(); + + if ( directoryName is null ) + return ( null, duplicatePaths ); + + var directory = new DirectoryInfo( directoryName ); + foreach ( FileSystemInfo entry in directory.EnumerateFileSystemInfos( searchPattern ) ) + { + if ( string.IsNullOrWhiteSpace( entry.FullName ) ) + continue; + + int matchingCharacters = GetMatchingCharactersCount( entry.FullName, path ); + if ( matchingCharacters == path.Length ) + { + // Exact match found + closestMatch = entry; + } + else if ( matchingCharacters > maxMatchingCharacters ) + { + closestMatch = entry; + maxMatchingCharacters = matchingCharacters; + duplicatePaths.Clear(); + } + else if ( matchingCharacters == maxMatchingCharacters ) + { + duplicatePaths.Add( entry.FullName ); + } + } + + return ( closestMatch, duplicatePaths ); + } + + private static int GetMatchingCharactersCount( string str1, string str2 ) + { + if ( string.IsNullOrEmpty( str1 ) ) + throw new ArgumentException( message: "Value cannot be null or empty.", nameof( str1 ) ); + if ( string.IsNullOrEmpty( str2 ) ) + throw new ArgumentException( message: "Value cannot be null or empty.", nameof( str2 ) ); + + int matchingCount = 0; + + for ( + int i = 0; + i < str1.Length && i < str2.Length; + i++ + ) + { + if ( str1[i] != str2[i] ) + break; + + matchingCount++; + } + + return matchingCount; + } + } +} From 19bdc6e3e37ffb3151b373a1a053f5cc30523afd Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 18 Aug 2023 09:17:06 -0500 Subject: [PATCH 02/11] add path case sensitivity tests --- KotorDotNET.Tests/PathCaseSensitivityTests.cs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 KotorDotNET.Tests/PathCaseSensitivityTests.cs diff --git a/KotorDotNET.Tests/PathCaseSensitivityTests.cs b/KotorDotNET.Tests/PathCaseSensitivityTests.cs new file mode 100644 index 0000000..eef9c7f --- /dev/null +++ b/KotorDotNET.Tests/PathCaseSensitivityTests.cs @@ -0,0 +1,136 @@ +// Copyright 2021-2023 KOTORModSync +// Licensed under the GNU General Public License v3.0 (GPLv3). +// See LICENSE.txt file in the project root for full license information. + +using KOTORModSync.Core.Utility; + +namespace KOTORModSync.Tests +{ + internal class PathCaseSensitivityTests + { +#pragma warning disable CS8618 + private static string s_testDirectory; +#pragma warning restore CS8618 + + [OneTimeSetUp] + public static void InitializeTestDirectory() + { + s_testDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory( s_testDirectory ); + } + + [OneTimeTearDown] + public static void CleanUpTestDirectory() => Directory.Delete(s_testDirectory, true); + + [Test] + public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() + { + // Arrange + string testFilePath = Path.Combine(s_testDirectory, "test.txt"); + File.Create(testFilePath).Close(); + + // Act + string? result = PathHelper.GetCaseSensitivePath(testFilePath); + + // Assert + Assert.That( result, Is.EqualTo( testFilePath ) ); + } + + [Test] + public void GetCaseSensitivePath_ValidDirectory_ReturnsSamePath() + { + // Arrange + string testDirPath = Path.Combine(s_testDirectory, "testDir"); + _ = Directory.CreateDirectory( testDirPath ); + + // Act + string? result = PathHelper.GetCaseSensitivePath(testDirPath); + + // Assert + Assert.That( result, Is.EqualTo( testDirPath ) ); + } + + [Test] + public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() + { + // Arrange + string? nullPath = null; + string emptyPath = string.Empty; + const string whiteSpacePath = " "; + + // Act & Assert + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath ) ); + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( emptyPath ) ); + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( whiteSpacePath ) ); + } + + [Test] + public void GetCaseSensitivePath_InvalidCharactersInPath_ThrowsArgumentException() + { + // Arrange + string invalidPath = Path.Combine(s_testDirectory, "invalid>path"); + + // Act & Assert + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( invalidPath ) ); + } + + [Test] + public void GetCaseSensitivePath_RelativePath_ReturnsAbsolutePath() + { + // Arrange + string testFilePath = Path.Combine(s_testDirectory, "test.txt"); + File.Create(testFilePath).Close(); + string relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); + + // Act + string? result = PathHelper.GetCaseSensitivePath(relativePath); + + // Assert + Assert.That( result, Is.EqualTo( testFilePath ) ); + } + + + + [Test] + // TODO: doesn't work correctly on windows (returns "...Data\\Local\\Temp\\426FCFF0-3DC3-4FD7-9C7A-D6C0878DACDF\\test.txt" instead of "...Data\\Local\\Temp\\426fcff0-3dc3-4fd7-9c7a-d6c0878dacdf\\test.txt") + public void GetCaseSensitivePath_EntirePathCaseIncorrect_ReturnsCorrectPath() + { + // Arrange + string testFilePath = Path.Combine(s_testDirectory, "test.txt"); + File.Create(testFilePath).Close(); + string relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); + + // Act + string? result = PathHelper.GetCaseSensitivePath( relativePath.ToUpperInvariant() ); + + // Assert + Assert.That( result, Is.EqualTo( testFilePath ) ); + } + + [Test] + public void GetCaseSensitivePath_NonExistentFile_ReturnsNull() + { + // Arrange + string nonExistentFilePath = Path.Combine(s_testDirectory, "non_existent_file.txt"); + + // Act + string? result = PathHelper.GetCaseSensitivePath(nonExistentFilePath); + + // Assert + Assert.That( result, Is.Null ); + } + + [Test] + public void GetCaseSensitivePath_NonExistentDirectory_ReturnsNull() + { + // Arrange + string nonExistentDirPath = Path.Combine(s_testDirectory, "non_existent_dir"); + + // Act + string? result = PathHelper.GetCaseSensitivePath(nonExistentDirPath); + + // Assert + Assert.That( result, Is.Null ); + } + } +} From 272575632bf2f31d75407ab7ba80f85f13947da9 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 18 Aug 2023 13:04:28 -0500 Subject: [PATCH 03/11] refactor PathHelper and PathValidator classes --- KotorDotNET.sln.DotSettings | 2 + KotorDotNET/Utility/PathHelper.cs | 437 ++++++++++++++++++------------ 2 files changed, 260 insertions(+), 179 deletions(-) create mode 100644 KotorDotNET.sln.DotSettings diff --git a/KotorDotNET.sln.DotSettings b/KotorDotNET.sln.DotSettings new file mode 100644 index 0000000..549f48a --- /dev/null +++ b/KotorDotNET.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/KotorDotNET/Utility/PathHelper.cs b/KotorDotNET/Utility/PathHelper.cs index dbf1dbe..bd1fc53 100644 --- a/KotorDotNET/Utility/PathHelper.cs +++ b/KotorDotNET/Utility/PathHelper.cs @@ -30,16 +30,22 @@ public static class PathValidator "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", }; - public static bool IsValidPath( string path) + // Checks if the path is valid on running platform, or optionally (default) enforce for all platforms. + public static bool IsValidPath( string? path, bool enforceAllPlatforms=true) { - if ( string.IsNullOrEmpty( path ) ) + if ( string.IsNullOrWhiteSpace( path ) ) + return false; + if ( path == string.Empty ) return false; try { // Check for forbidden printable ASCII characters - char[] invalidChars = GetInvalidCharsForPlatform(); - if (path.IndexOfAny( invalidChars ) >= 0) + char[] invalidChars = enforceAllPlatforms + ? s_invalidPathCharsWindows // already contains the unix ones + : GetInvalidCharsForPlatform(); + + if ( path.IndexOfAny( invalidChars ) >= 0 ) return false; // Check for non-printable characters @@ -47,18 +53,33 @@ public static bool IsValidPath( string path) return false; // Check for reserved file names in Windows - //if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) - //{ - if ( IsReservedFileNameWindows(path) ) + if ( enforceAllPlatforms || RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) + { + if ( IsReservedFileNameWindows(path) ) + return false; + + // Check for invalid filename parts + // ReSharper disable once ConvertIfStatementToReturnStatement + if ( HasInvalidWindowsFileNameParts(path) ) + return false; + } + + // double-check + try + { + FileInfo _ = new(path); + DirectoryInfo __ = new(path); + return true; + } + catch (ArgumentException) + { return false; - - // Check for invalid filename parts - // ReSharper disable once ConvertIfStatementToReturnStatement - if ( HasInvalidWindowsFileNameParts(path) ) + } + catch (Exception e) + { + Console.WriteLine(e.Message); return false; - //} - - return true; + } } catch ( Exception e ) { @@ -112,21 +133,38 @@ public static class PathHelper : filePath; } - public static DirectoryInfo? TryGetValidDirectoryInfo(string? destinationPath) + public static DirectoryInfo? TryGetValidDirectoryInfo(string? folderPath) { - if ( destinationPath is null ) + string formattedPath = FixPathFormatting(folderPath); + if ( PathValidator.IsValidPath(formattedPath) ) return null; - if ( destinationPath.IndexOfAny( Path.GetInvalidPathChars() ) >= 0 ) + + try + { + return new DirectoryInfo(folderPath!); + } + catch (Exception) + { + // In .NET Framework 4.6.2 and earlier, the DirectoryInfo constructor throws an exception + // when the path is invalid. We catch the exception and return null instead for a unified experience. + return null; + } + } + + public static FileInfo? TryGetValidFileInfo(string? filePath) + { + string formattedPath = FixPathFormatting(filePath); + if ( PathValidator.IsValidPath(formattedPath) ) return null; try { - return new DirectoryInfo(destinationPath); + return new FileInfo(filePath!); } catch (Exception) { - // In .NET Framework 4.6.2, the DirectoryInfo constructor throws an exception - // when the path is invalid. We catch the exception and return null instead. + // In .NET Framework 4.6.2 and earlier, the FileInfo constructor throws an exception + // when the path is invalid. We catch the exception and return null instead for a unified experience. return null; } } @@ -134,32 +172,46 @@ public static class PathHelper [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern int GetLongPathName(string shortPath, StringBuilder longPath, int bufferSize); - public static string ConvertWindowsPathToCaseSensitive( string path ) + public static string ConvertWindowsPathToCaseSensitive(string path) { - if ( Environment.OSVersion.Platform != PlatformID.Win32NT ) + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); + if (!PathValidator.IsValidPath(path)) + throw new ArgumentException($"{path} is not a valid path!"); + + if (Environment.OSVersion.Platform != PlatformID.Win32NT) return path; - const int bufferSize = 260; // MAX_PATH on Windows - var longPathBuffer = new StringBuilder(bufferSize); + // Call with zero buffer size to get the required size, including the null-terminating character + int requiredSize = GetLongPathName( + path, + new StringBuilder(""), + 0 + ); + if (requiredSize == 0) + { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error); + } + + StringBuilder longPathBuffer = new(requiredSize); - int result = GetLongPathName(path, longPathBuffer, bufferSize); - // ReSharper disable once InvertIf - if ( result <= 0 || result >= bufferSize ) + int result = GetLongPathName(path, longPathBuffer, requiredSize); + if (result <= 0 || result >= requiredSize) { - // Handle the error, e.g., the function call failed, or the buffer was too small int error = Marshal.GetLastWin32Error(); - throw new Win32Exception( error ); + throw new Win32Exception(error); } - // The function succeeded, and the result contains the case-sensitive long path return longPathBuffer.ToString(); } + public static string? GetCaseSensitivePath( FileInfo file ) => GetCaseSensitivePath( file.FullName ); + public static string? GetCaseSensitivePath( DirectoryInfo directory ) => GetCaseSensitivePath( directory.FullName ); public static string? GetCaseSensitivePath(string path) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); - if (!PathValidator.IsValidPath(path)) throw new ArgumentException($"{path} is not a valid path!"); @@ -184,10 +236,6 @@ public static string ConvertWindowsPathToCaseSensitive( string path ) where item.FullName.Equals( path, StringComparison.OrdinalIgnoreCase ) select ConvertWindowsPathToCaseSensitive( item.FullName ) ).FirstOrDefault(); - - public static string? GetCaseSensitivePath( FileInfo file ) => GetCaseSensitivePath( file.FullName ); - - public static string? GetCaseSensitivePath( DirectoryInfo directory ) => GetCaseSensitivePath( directory.FullName ); public static async Task MoveFileAsync( string sourcePath, string destinationPath ) { @@ -196,7 +244,7 @@ public static async Task MoveFileAsync( string sourcePath, string destinationPat if ( destinationPath is null ) throw new ArgumentNullException( nameof( destinationPath ) ); - await using ( var sourceStream = new FileStream( + await using ( FileStream sourceStream = new( sourcePath, FileMode.Open, FileAccess.Read, @@ -205,7 +253,7 @@ public static async Task MoveFileAsync( string sourcePath, string destinationPat useAsync: true ) ) { - await using ( var destinationStream = new FileStream( + await using ( FileStream destinationStream = new( destinationPath, FileMode.CreateNew, FileAccess.Write, @@ -224,7 +272,7 @@ public static async Task MoveFileAsync( string sourcePath, string destinationPat public static List EnumerateFilesWithWildcards( IEnumerable filesAndFolders, - bool topLevelOnly = false + bool includeSubFolders = true ) { if ( filesAndFolders is null ) @@ -233,98 +281,101 @@ public static List EnumerateFilesWithWildcards( List result = new(); HashSet uniquePaths = new( filesAndFolders ); - foreach ( string path in uniquePaths.Where( path => !string.IsNullOrEmpty( path ) ) ) + foreach (string path in uniquePaths) { + if (string.IsNullOrEmpty(path)) + continue; + try { - string formattedPath = FixPathFormatting( path ); - - if ( !ContainsWildcards( formattedPath ) ) + string formattedPath = FixPathFormatting(path); + if (!PathValidator.IsValidPath(formattedPath)) + throw new ArgumentException($"Not a valid path: '{path}'"); + + if (!ContainsWildcards(formattedPath)) { // Handle non-wildcard paths - if ( File.Exists( formattedPath ) ) + if (File.Exists(formattedPath)) { - result.Add( formattedPath ); - continue; + result.Add(formattedPath); } - - if ( Directory.Exists( formattedPath ) ) + else if (Directory.Exists(formattedPath)) { IEnumerable matchingFiles = Directory.EnumerateFiles( formattedPath, searchPattern: "*", - topLevelOnly - ? SearchOption.TopDirectoryOnly - : SearchOption.AllDirectories + includeSubFolders + ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly ); - result.AddRange( matchingFiles ); + result.AddRange(matchingFiles); } continue; } - // Handle wildcard paths - string? directory = Path.GetDirectoryName( formattedPath ); - - if ( !string.IsNullOrEmpty( directory ) - && directory.IndexOfAny( Path.GetInvalidPathChars() ) != -1 - && Directory.Exists( directory ) ) + // Handle simple wildcard paths + if (PathValidator.IsValidPath(formattedPath)) { - IEnumerable matchingFiles = Directory.EnumerateFiles( - directory, - Path.GetFileName( formattedPath ), - topLevelOnly - ? SearchOption.TopDirectoryOnly - : SearchOption.AllDirectories - ); - - result.AddRange( matchingFiles ); - continue; + string? parentDir = Path.GetDirectoryName(formattedPath); + if ( Directory.Exists(parentDir) ) + { + IEnumerable matchingFiles = Directory.EnumerateFiles( + parentDir, + Path.GetFileName(formattedPath), + includeSubFolders + ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly + ); + + result.AddRange(matchingFiles); + continue; + } } // Handle wildcard paths - string currentDirectory = formattedPath; - - while ( ContainsWildcards( currentDirectory ) ) + // + // determine the closest parent folder in hierarchy that doesn't have wildcards + // then wildcard match them all by hierarchy level. + string currentDir = formattedPath; + while (ContainsWildcards(currentDir)) { - string? parentDirectory = Path.GetDirectoryName( currentDirectory ); + string? parentDirectory = Path.GetDirectoryName(currentDir); // Exit the loop if no parent directory is found or if the parent directory is the same as the current directory - if ( string.IsNullOrEmpty( parentDirectory ) || parentDirectory == currentDirectory ) + if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDir) break; - currentDirectory = parentDirectory; + currentDir = parentDirectory; } - if ( string.IsNullOrEmpty( currentDirectory ) || !Directory.Exists( currentDirectory ) ) + if (!Directory.Exists(currentDir)) continue; + // Get all files in the parent directory. IEnumerable checkFiles = Directory.EnumerateFiles( - currentDirectory, + currentDir, searchPattern: "*", - topLevelOnly - ? SearchOption.TopDirectoryOnly - : SearchOption.AllDirectories + includeSubFolders + ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly ); - result.AddRange( - checkFiles.Where( - thisFile => WildcardPathMatch( thisFile, formattedPath ) - ) - ); + // wildcard match them all with WildcardPatchMatch and add to result + result.AddRange(checkFiles.Where(thisFile => WildcardPathMatch(thisFile, formattedPath))); } - catch ( Exception ex ) + catch (Exception ex) { // Handle or log the exception as required - Console.WriteLine( $"An error occurred while processing path '{path}': {ex.Message}" ); + Console.WriteLine($"An error occurred while processing path '{path}': {ex.Message}"); } } return result; } - private static bool ContainsWildcards( string path ) => path.Contains( '*' ) || path.Contains( '?' ); + public static bool ContainsWildcards( string path ) => path.Contains( '*' ) || path.Contains( '?' ); public static bool WildcardPathMatch( string input, string patternInput ) { @@ -337,7 +388,7 @@ public static bool WildcardPathMatch( string input, string patternInput ) input = FixPathFormatting( input ); patternInput = FixPathFormatting( patternInput ); - // Split the input and pattern into directory levels + // Split the input and patternInput into directory levels string[] inputLevels = input.Split( Path.DirectorySeparatorChar ); string[] patternLevels = patternInput.Split( Path.DirectorySeparatorChar ); @@ -351,8 +402,7 @@ public static bool WildcardPathMatch( string input, string patternInput ) string inputLevel = inputLevels[i]; string patternLevel = patternLevels[i]; - // Check if the current level is a wildcard - if ( patternLevel is "*" or "?" ) + if (patternLevel is "*") continue; // Check if the current level matches the pattern @@ -364,28 +414,35 @@ public static bool WildcardPathMatch( string input, string patternInput ) } // Most end users don't know Regex, this function will convert basic wildcards to regex patterns. - private static bool WildcardMatch( string input, string pattern ) + public static bool WildcardMatch( string input, string patternInput ) { if ( input is null ) throw new ArgumentNullException( nameof( input ) ); - if ( pattern is null ) - throw new ArgumentNullException( nameof( pattern ) ); + if ( patternInput is null ) + throw new ArgumentNullException( nameof( patternInput ) ); // Escape special characters in the pattern - pattern = Regex.Escape( pattern ); + patternInput = Regex.Escape( patternInput ); // Replace * with .* and ? with . in the pattern - pattern = pattern.Replace( oldValue: @"\*", newValue: ".*" ) - .Replace( oldValue: @"\?", newValue: "." ); + patternInput = patternInput + .Replace( oldValue: @"\*", newValue: ".*" ) + .Replace( oldValue: @"\?", newValue: "." ); // Use regex to perform the wildcard matching - return Regex.IsMatch( input, $"^{pattern}$" ); + return Regex.IsMatch( input, $"^{patternInput}$" ); } - public static string FixPathFormatting( string path ) + public static string FixPathFormatting( string? path ) { - // Replace backslashes with forward slashes - string formattedPath = path.Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ) + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + // Replace all slashes with the operating system's path separator + string formattedPath = path + .Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ) .Replace( oldChar: '\\', Path.DirectorySeparatorChar ) .Replace( oldChar: '/', Path.DirectorySeparatorChar ); @@ -402,138 +459,158 @@ public static string FixPathFormatting( string path ) return formattedPath; } - public static List FindCaseInsensitiveDuplicates(DirectoryInfo directory) + public static IEnumerable FindCaseInsensitiveDuplicates(DirectoryInfo dirInfo, bool includeSubFolders=true) { - if (directory is null) - throw new ArgumentNullException(nameof(directory)); + return FindDuplicatesRecursively(dirInfo.FullName, includeSubFolders, isFile: false); + } - var duplicates = new List(); + public static IEnumerable FindCaseInsensitiveDuplicates(FileInfo fileInfo) + { + // assumed Path.GetDirectoryName can't be null when passing a FileInfo's path. + return FindDuplicatesRecursively(fileInfo.DirectoryName, isFile: true); + } - try + // Finds all duplicate items in a path. + public static IEnumerable FindDuplicatesRecursively(string path, bool includeSubFolders=true, bool? isFile=null) + { + string formattedPath = FixPathFormatting(path); + if (!PathValidator.IsValidPath(formattedPath)) + throw new ArgumentException( nameof( path ) + " is not a valid path string" ); + + // determine if path arg is a folder or a file. + DirectoryInfo? dirInfo; + if (isFile == false) { - FindDuplicatesRecursively(directory, duplicates); + dirInfo = new DirectoryInfo( formattedPath ); } - catch (Exception ex) + else if (isFile == true) { - Console.WriteLine(ex); - return duplicates; + dirInfo = new DirectoryInfo(Path.GetDirectoryName(formattedPath)); + } + else + { + dirInfo = new DirectoryInfo(formattedPath); + if (!dirInfo.Exists) + { + string? folderPath = Path.GetDirectoryName(formattedPath); + isFile = true; + if ( folderPath is not null ) + dirInfo = new DirectoryInfo(folderPath); + } } - return duplicates; - } - - private static void FindDuplicatesRecursively(DirectoryInfo directory, List duplicates) - { - if (duplicates is null) - throw new ArgumentNullException(nameof(duplicates)); - - // Check if the directory exists and if we have access to it. - if (!directory.Exists) - throw new DirectoryNotFoundException("Directory not found."); - - var fileList = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var folderList = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!dirInfo.Exists) + throw new ArgumentException($"Path item doesn't exist on disk: '{formattedPath}'"); - // Search for files and add them to the file dictionary. - foreach (FileInfo file in directory.GetFiles()) + // build duplicate files/folders list + Dictionary> fileList = new(StringComparer.OrdinalIgnoreCase); + Dictionary> folderList = new(StringComparer.OrdinalIgnoreCase); + foreach (FileInfo file in dirInfo.GetFiles()) { - if (file.Exists != true) + if (!file.Exists) continue; - string fileNameWithExtension = file.Name; - if (!fileList.TryGetValue(fileNameWithExtension, out List? files)) + string filePath = file.FullName; + if (!fileList.TryGetValue(filePath, out List? files)) { files = new List(); - fileList.Add(fileNameWithExtension, files); + fileList.Add(filePath, files); } files.Add(file); } - - // Search for subdirectories and add them to the folder dictionary. - foreach (DirectoryInfo subdirectory in directory.GetDirectories()) + + foreach (KeyValuePair> fileListEntry in fileList) { - if (subdirectory.Exists != true) + List files = fileListEntry.Value; + if (files.Count <= 1) continue; - if (!folderList.TryGetValue(subdirectory.Name, out List? folders)) + foreach (FileSystemInfo duplicate in files) { - folders = new List(); - folderList.Add(subdirectory.Name, folders); + yield return duplicate; } + } - folders.Add(subdirectory); + // don't iterate folders in the parent folder if original path is a file. + if (isFile == true) + yield break; - // Recursively search the sub-directory. - FindDuplicatesRecursively(subdirectory, duplicates); + foreach (DirectoryInfo subDirectory in dirInfo.GetDirectories()) + { + if (!subDirectory.Exists) + continue; - // Check for file duplicates within the current sub-directory. - foreach (KeyValuePair> fileListEntry in fileList) + if (!folderList.TryGetValue(subDirectory.FullName, out List? folders)) { - List files = fileListEntry.Value; - if (files.Count > 1) - duplicates.AddRange(files); + folders = new List(); + folderList.Add(subDirectory.FullName, folders); + } + folders.Add(subDirectory); + + if (includeSubFolders) + { + foreach (FileSystemInfo duplicate in FindDuplicatesRecursively(subDirectory.FullName)) + { + yield return duplicate; + } } - // Check for folder duplicates within the current sub-directory. foreach (KeyValuePair> folderListEntry in folderList) { List foldersInCurrentDir = folderListEntry.Value; - if (foldersInCurrentDir.Count > 1) - duplicates.AddRange(foldersInCurrentDir); - } + if (foldersInCurrentDir.Count <= 1) + continue; - // Clear the dictionaries for the next sub-directory. - fileList.Clear(); + foreach (FileSystemInfo duplicate in foldersInCurrentDir) + { + yield return duplicate; + } + } + folderList.Clear(); } } - public static List FindCaseInsensitiveDuplicates( string path ) - { - if ( !PathValidator.IsValidPath( path ) ) - throw new ArgumentException( nameof( path ) + " is not a valid path string" ); - - var directory = new DirectoryInfo( path ); - return FindCaseInsensitiveDuplicates( directory ); - } - public static (FileSystemInfo?, List) GetClosestMatchingEntry( string path ) { if ( !PathValidator.IsValidPath( path ) ) throw new ArgumentException( nameof( path ) + " is not a valid path string" ); + path = FixPathFormatting(path); + string? directoryName = Path.GetDirectoryName( path ); + if ( string.IsNullOrEmpty(directoryName) ) + { + return ( null, new List() ); + } + string searchPattern = Path.GetFileName( path ); FileSystemInfo? closestMatch = null; int maxMatchingCharacters = -1; - var duplicatePaths = new List(); - - if ( directoryName is null ) - return ( null, duplicatePaths ); + List duplicatePaths = new(); - var directory = new DirectoryInfo( directoryName ); - foreach ( FileSystemInfo entry in directory.EnumerateFileSystemInfos( searchPattern ) ) + DirectoryInfo directory = new( directoryName ); + foreach (FileSystemInfo entry in directory.EnumerateFileSystemInfos(searchPattern, SearchOption.TopDirectoryOnly)) { - if ( string.IsNullOrWhiteSpace( entry.FullName ) ) + if (string.IsNullOrWhiteSpace(entry.FullName)) continue; - int matchingCharacters = GetMatchingCharactersCount( entry.FullName, path ); - if ( matchingCharacters == path.Length ) - { - // Exact match found - closestMatch = entry; - } - else if ( matchingCharacters > maxMatchingCharacters ) + int matchingCharacters = GetMatchingCharactersCount(entry.FullName, path); + if (matchingCharacters > maxMatchingCharacters) { + if (closestMatch != null) + { + duplicatePaths.Add(closestMatch.FullName); + } + closestMatch = entry; maxMatchingCharacters = matchingCharacters; - duplicatePaths.Clear(); } - else if ( matchingCharacters == maxMatchingCharacters ) + else if (matchingCharacters != 0) { - duplicatePaths.Add( entry.FullName ); + duplicatePaths.Add(entry.FullName); } } @@ -548,17 +625,19 @@ private static int GetMatchingCharactersCount( string str1, string str2 ) throw new ArgumentException( message: "Value cannot be null or empty.", nameof( str2 ) ); int matchingCount = 0; - for ( int i = 0; i < str1.Length && i < str2.Length; i++ ) { - if ( str1[i] != str2[i] ) - break; + // don't consider a match if any char in the paths are not case-insensitive matches. + if (char.ToLowerInvariant(str1[i]) != char.ToLowerInvariant(str2[i])) + return 0; - matchingCount++; + // increment matching count if case-sensitive match at this char index succeeds + if ( str1[i] == str2[i] ) + matchingCount++; } return matchingCount; From f2943bdbbf5ed70972f8418043e4ab3f324f2bd1 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 18 Aug 2023 13:12:23 -0500 Subject: [PATCH 04/11] fix tests --- KotorDotNET.Tests/KotorDotNET.Tests.csproj | 1 + KotorDotNET.Tests/PathCaseSensitivityTests.cs | 4 +++- KotorDotNET/Patching/Parsers/LegacyINI/LegacyINIParser.cs | 5 +++-- KotorDotNET/Patching/Patcher.cs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/KotorDotNET.Tests/KotorDotNET.Tests.csproj b/KotorDotNET.Tests/KotorDotNET.Tests.csproj index 09de19a..1e23f3b 100644 --- a/KotorDotNET.Tests/KotorDotNET.Tests.csproj +++ b/KotorDotNET.Tests/KotorDotNET.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/KotorDotNET.Tests/PathCaseSensitivityTests.cs b/KotorDotNET.Tests/PathCaseSensitivityTests.cs index eef9c7f..78c6f34 100644 --- a/KotorDotNET.Tests/PathCaseSensitivityTests.cs +++ b/KotorDotNET.Tests/PathCaseSensitivityTests.cs @@ -2,7 +2,9 @@ // Licensed under the GNU General Public License v3.0 (GPLv3). // See LICENSE.txt file in the project root for full license information. -using KOTORModSync.Core.Utility; +using KotorDotNET.Utility; +using NUnit.Framework; +using Assert = NUnit.Framework.Assert; namespace KOTORModSync.Tests { diff --git a/KotorDotNET/Patching/Parsers/LegacyINI/LegacyINIParser.cs b/KotorDotNET/Patching/Parsers/LegacyINI/LegacyINIParser.cs index 4441903..2b02220 100644 --- a/KotorDotNET/Patching/Parsers/LegacyINI/LegacyINIParser.cs +++ b/KotorDotNET/Patching/Parsers/LegacyINI/LegacyINIParser.cs @@ -46,8 +46,9 @@ protected IModifier ReadEditRowModifier(string sectionKey) target = new ColumnTarget("label", section["LabelIndex"]); } - EditRowModifier modifier = new(target, data, toStoreInMemory); - return modifier; + /*EditRowModifier modifier = new(target, data, toStoreInMemory); + return modifier;*/ + return default; // TODO } } } diff --git a/KotorDotNET/Patching/Patcher.cs b/KotorDotNET/Patching/Patcher.cs index 36c13d4..0dded97 100644 --- a/KotorDotNET/Patching/Patcher.cs +++ b/KotorDotNET/Patching/Patcher.cs @@ -1,5 +1,5 @@ using KotorDotNET.Common.Data; -using KotorDotNET.Common.FileFormats.Kotor2DA; +//using KotorDotNET.Common.FileFormats.Kotor2DA; using KotorDotNET.FileFormats.Kotor2DA; using System; using System.Collections.Generic; From b726a8649f44636d9bf206a9c79a0993b2fc4a1b Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 19 Aug 2023 19:43:13 -0500 Subject: [PATCH 05/11] Fix GetCaseSensitivePath and FindCaseInsensitiveDuplicates --- KotorDotNET.Tests/PathCaseSensitivityTests.cs | 16 +- KotorDotNET/Utility/PathHelper.cs | 293 ++++++++++++------ 2 files changed, 203 insertions(+), 106 deletions(-) diff --git a/KotorDotNET.Tests/PathCaseSensitivityTests.cs b/KotorDotNET.Tests/PathCaseSensitivityTests.cs index 78c6f34..b40cf7e 100644 --- a/KotorDotNET.Tests/PathCaseSensitivityTests.cs +++ b/KotorDotNET.Tests/PathCaseSensitivityTests.cs @@ -28,7 +28,7 @@ public static void InitializeTestDirectory() public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() { // Arrange - string testFilePath = Path.Combine(s_testDirectory, "test.txt"); + string? testFilePath = Path.Combine(s_testDirectory, "test.txt"); File.Create(testFilePath).Close(); // Act @@ -42,7 +42,7 @@ public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() public void GetCaseSensitivePath_ValidDirectory_ReturnsSamePath() { // Arrange - string testDirPath = Path.Combine(s_testDirectory, "testDir"); + string? testDirPath = Path.Combine(s_testDirectory, "testDir"); _ = Directory.CreateDirectory( testDirPath ); // Act @@ -57,8 +57,8 @@ public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() { // Arrange string? nullPath = null; - string emptyPath = string.Empty; - const string whiteSpacePath = " "; + string? emptyPath = string.Empty; + const string? whiteSpacePath = " "; // Act & Assert _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath ) ); @@ -70,7 +70,7 @@ public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() public void GetCaseSensitivePath_InvalidCharactersInPath_ThrowsArgumentException() { // Arrange - string invalidPath = Path.Combine(s_testDirectory, "invalid>path"); + string? invalidPath = Path.Combine(s_testDirectory, "invalid>path"); // Act & Assert _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( invalidPath ) ); @@ -82,7 +82,7 @@ public void GetCaseSensitivePath_RelativePath_ReturnsAbsolutePath() // Arrange string testFilePath = Path.Combine(s_testDirectory, "test.txt"); File.Create(testFilePath).Close(); - string relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); + string? relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); // Act string? result = PathHelper.GetCaseSensitivePath(relativePath); @@ -113,7 +113,7 @@ public void GetCaseSensitivePath_EntirePathCaseIncorrect_ReturnsCorrectPath() public void GetCaseSensitivePath_NonExistentFile_ReturnsNull() { // Arrange - string nonExistentFilePath = Path.Combine(s_testDirectory, "non_existent_file.txt"); + string? nonExistentFilePath = Path.Combine(s_testDirectory, "non_existent_file.txt"); // Act string? result = PathHelper.GetCaseSensitivePath(nonExistentFilePath); @@ -126,7 +126,7 @@ public void GetCaseSensitivePath_NonExistentFile_ReturnsNull() public void GetCaseSensitivePath_NonExistentDirectory_ReturnsNull() { // Arrange - string nonExistentDirPath = Path.Combine(s_testDirectory, "non_existent_dir"); + string? nonExistentDirPath = Path.Combine(s_testDirectory, "non_existent_dir"); // Act string? result = PathHelper.GetCaseSensitivePath(nonExistentDirPath); diff --git a/KotorDotNET/Utility/PathHelper.cs b/KotorDotNET/Utility/PathHelper.cs index bd1fc53..39b84bf 100644 --- a/KotorDotNET/Utility/PathHelper.cs +++ b/KotorDotNET/Utility/PathHelper.cs @@ -3,6 +3,7 @@ // See LICENSE.txt file in the project root for full license information. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -31,7 +32,7 @@ public static class PathValidator }; // Checks if the path is valid on running platform, or optionally (default) enforce for all platforms. - public static bool IsValidPath( string? path, bool enforceAllPlatforms=true) + public static bool IsValidPath( string path, bool enforceAllPlatforms=true) { if ( string.IsNullOrWhiteSpace( path ) ) return false; @@ -67,8 +68,8 @@ public static bool IsValidPath( string? path, bool enforceAllPlatforms=true) // double-check try { - FileInfo _ = new(path); - DirectoryInfo __ = new(path); + var _ = new FileInfo(path); + var __ = new DirectoryInfo(path); return true; } catch (ArgumentException) @@ -95,7 +96,7 @@ private static char[] GetInvalidCharsForPlatform() : s_invalidPathCharsWindows; } - private static bool ContainsNonPrintableChars(string? path) => path?.Any( c => c < ' ' && c != '\t' ) ?? false; + private static bool ContainsNonPrintableChars( string path) => path?.Any( c => c < ' ' && c != '\t' ) ?? false; private static bool IsReservedFileNameWindows(string path) { string fileName = Path.GetFileNameWithoutExtension(path); @@ -126,22 +127,24 @@ private static bool HasInvalidWindowsFileNameParts(string path) public static class PathHelper { // if it's a folder, return path as is, if it's a file get the parent dir. - public static string? GetFolderName( string? filePath ) + + public static string GetFolderName( string filePath ) { return Path.HasExtension( filePath ) ? Path.GetDirectoryName( filePath ) : filePath; } - public static DirectoryInfo? TryGetValidDirectoryInfo(string? folderPath) + + public static DirectoryInfo TryGetValidDirectoryInfo( string folderPath) { string formattedPath = FixPathFormatting(folderPath); - if ( PathValidator.IsValidPath(formattedPath) ) + if ( formattedPath is null || PathValidator.IsValidPath(formattedPath) ) return null; try { - return new DirectoryInfo(folderPath!); + return new DirectoryInfo(formattedPath); } catch (Exception) { @@ -151,15 +154,16 @@ public static class PathHelper } } - public static FileInfo? TryGetValidFileInfo(string? filePath) + + public static FileInfo TryGetValidFileInfo( string filePath) { string formattedPath = FixPathFormatting(filePath); - if ( PathValidator.IsValidPath(formattedPath) ) + if ( formattedPath is null || PathValidator.IsValidPath(formattedPath) ) return null; try { - return new FileInfo(filePath!); + return new FileInfo(formattedPath); } catch (Exception) { @@ -174,69 +178,154 @@ public static class PathHelper public static string ConvertWindowsPathToCaseSensitive(string path) { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + return path; if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); if (!PathValidator.IsValidPath(path)) throw new ArgumentException($"{path} is not a valid path!"); - if (Environment.OSVersion.Platform != PlatformID.Win32NT) - return path; - // Call with zero buffer size to get the required size, including the null-terminating character - int requiredSize = GetLongPathName( + const uint FILE_SHARE_READ = 1; + const uint OPEN_EXISTING = 3; + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint VOLUME_NAME_DOS = 0; + + IntPtr handle = CreateFile( path, - new StringBuilder(""), - 0 - ); - if (requiredSize == 0) + 0, + FILE_SHARE_READ, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + IntPtr.Zero); + + if (handle == IntPtr.Zero) { - int error = Marshal.GetLastWin32Error(); - throw new Win32Exception(error); + throw new Win32Exception(Marshal.GetLastWin32Error()); } - StringBuilder longPathBuffer = new(requiredSize); + try + { + var buffer = new StringBuilder(4096); + uint result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS); + + if (result == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // The result may be prefixed with "\\?\" + string finalPath = buffer.ToString(); + const string prefix = @"\\?\"; + if (finalPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + finalPath = finalPath.Substring(prefix.Length); + } - int result = GetLongPathName(path, longPathBuffer, requiredSize); - if (result <= 0 || result >= requiredSize) + return finalPath; + } + finally { - int error = Marshal.GetLastWin32Error(); - throw new Win32Exception(error); + _ = CloseHandle( handle ); } - - return longPathBuffer.ToString(); } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern uint GetFinalPathNameByHandle(IntPtr hFile, StringBuilder lpszFilePath, uint cchFilePath, uint dwFlags); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern IntPtr CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); - public static string? GetCaseSensitivePath( FileInfo file ) => GetCaseSensitivePath( file.FullName ); - public static string? GetCaseSensitivePath( DirectoryInfo directory ) => GetCaseSensitivePath( directory.FullName ); - public static string? GetCaseSensitivePath(string path) + public static string GetCaseSensitivePath( FileInfo file ) => GetCaseSensitivePath( file?.FullName ); + public static string GetCaseSensitivePath( DirectoryInfo directory ) => GetCaseSensitivePath( directory?.FullName ); + + public static string GetCaseSensitivePath(string path) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); if (!PathValidator.IsValidPath(path)) throw new ArgumentException($"{path} is not a valid path!"); - path = Path.GetFullPath(path); - if (File.Exists(path) || Directory.Exists(path)) - return ConvertWindowsPathToCaseSensitive(path); + string formattedPath = FixPathFormatting(Path.GetFullPath(path)); + if (File.Exists(formattedPath) || Directory.Exists(formattedPath)) + return ConvertWindowsPathToCaseSensitive(formattedPath); + + var parts = formattedPath.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries).ToList(); + + string currentPath = Path.GetPathRoot( formattedPath ); + if ( currentPath != parts[0] ) + { + parts.Insert(index: 0, currentPath); + } + + int largestExistingPathPartsIndex = -1; + string caseSensitiveCurrentPath = string.Empty; + for (int i = 1; i < parts.Count; i++) + { + string parentDir = i == 1 + ? parts[0] + : Path.Combine(parts.Take(i).ToArray()); + + if ( Directory.Exists( parentDir ) ) + { + foreach ( string childFolder in Directory.EnumerateFileSystemEntries(parentDir) ) + { + string childFolderActual = Path.GetFileName(childFolder); + if ( !childFolderActual.Equals( parts[i], StringComparison.OrdinalIgnoreCase ) ) + continue; + + parts[i] = childFolderActual; + break; + } + } - string parentDirPath = Path.GetDirectoryName(path) - ?? throw new NullReferenceException($"Path.GetDirectoryName(path) when path is '{path}'"); + currentPath = Path.Combine(currentPath, parts[i]); - DirectoryInfo parentDir = TryGetValidDirectoryInfo(parentDirPath) - ?? throw new NullReferenceException( "TryGetValidDirectoryInfo(parentDirPath)" ); - return !parentDir.Exists && !( parentDir = TryGetValidDirectoryInfo( GetCaseSensitivePath(parentDirPath) ) - ?? throw new DirectoryNotFoundException($"Could not find case-sensitive directory for path string '{parentDirPath}'") ).Exists - ? throw new DirectoryNotFoundException($"Could not find case-sensitive directory for path string '{parentDirPath}'") - : GetCaseSensitiveChildPath(parentDir, path); + if ( !File.Exists( currentPath ) + && !Directory.Exists( currentPath ) + && string.IsNullOrEmpty( caseSensitiveCurrentPath ) ) + { + // Get the case-sensitive path based on the existing parts we've determined. + largestExistingPathPartsIndex = i-1; + string currentExistingPath = Path.Combine(parts.Take(largestExistingPathPartsIndex).ToArray()); + caseSensitiveCurrentPath = ConvertWindowsPathToCaseSensitive(currentExistingPath); + } + + } + + if (largestExistingPathPartsIndex > -1) + { + return Path.Combine( + caseSensitiveCurrentPath, + Path.Combine(parts.Skip(largestExistingPathPartsIndex).ToArray()) + ); + } + + return Path.Combine( parts.ToArray() ); } - private static string? GetCaseSensitiveChildPath(DirectoryInfo? parentDir, string path) => + + + private static string GetCaseSensitiveChildPath( DirectoryInfo parentDir, string path) => ( - from item in parentDir?.GetFileSystemInfos("*", SearchOption.TopDirectoryOnly) + from item in parentDir?.GetFileSystemInfos(searchPattern: "*", SearchOption.TopDirectoryOnly) where item.FullName.Equals( path, StringComparison.OrdinalIgnoreCase ) select ConvertWindowsPathToCaseSensitive( item.FullName ) ).FirstOrDefault(); + public static async Task MoveFileAsync( string sourcePath, string destinationPath ) { if ( sourcePath is null ) @@ -244,7 +333,7 @@ public static async Task MoveFileAsync( string sourcePath, string destinationPat if ( destinationPath is null ) throw new ArgumentNullException( nameof( destinationPath ) ); - await using ( FileStream sourceStream = new( + using ( var sourceStream = new FileStream( sourcePath, FileMode.Open, FileAccess.Read, @@ -253,7 +342,7 @@ public static async Task MoveFileAsync( string sourcePath, string destinationPat useAsync: true ) ) { - await using ( FileStream destinationStream = new( + using ( var destinationStream = new FileStream( destinationPath, FileMode.CreateNew, FileAccess.Write, @@ -278,8 +367,8 @@ public static List EnumerateFilesWithWildcards( if ( filesAndFolders is null ) throw new ArgumentNullException( nameof( filesAndFolders ) ); - List result = new(); - HashSet uniquePaths = new( filesAndFolders ); + var result = new List(); + var uniquePaths = new HashSet( filesAndFolders ); foreach (string path in uniquePaths) { @@ -289,9 +378,10 @@ public static List EnumerateFilesWithWildcards( try { string formattedPath = FixPathFormatting(path); - if (!PathValidator.IsValidPath(formattedPath)) + if ( path.IndexOfAny( Path.GetInvalidPathChars() ) >= 0 && path.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 ) throw new ArgumentException($"Not a valid path: '{path}'"); + // ReSharper disable once AssignNullToNotNullAttribute if (!ContainsWildcards(formattedPath)) { // Handle non-wildcard paths @@ -318,8 +408,8 @@ public static List EnumerateFilesWithWildcards( // Handle simple wildcard paths if (PathValidator.IsValidPath(formattedPath)) { - string? parentDir = Path.GetDirectoryName(formattedPath); - if ( Directory.Exists(parentDir) ) + string parentDir = Path.GetDirectoryName(formattedPath); + if ( !(parentDir is null) && Directory.Exists(parentDir) ) { IEnumerable matchingFiles = Directory.EnumerateFiles( parentDir, @@ -341,7 +431,7 @@ public static List EnumerateFilesWithWildcards( string currentDir = formattedPath; while (ContainsWildcards(currentDir)) { - string? parentDirectory = Path.GetDirectoryName(currentDir); + string parentDirectory = Path.GetDirectoryName(currentDir); // Exit the loop if no parent directory is found or if the parent directory is the same as the current directory if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDir) @@ -375,7 +465,9 @@ public static List EnumerateFilesWithWildcards( return result; } - public static bool ContainsWildcards( string path ) => path.Contains( '*' ) || path.Contains( '?' ); + + public static bool ContainsWildcards( [NotNull] string path ) => path.Contains( '*' ) || path.Contains( '?' ); + public static bool WildcardPathMatch( string input, string patternInput ) { @@ -389,15 +481,15 @@ public static bool WildcardPathMatch( string input, string patternInput ) patternInput = FixPathFormatting( patternInput ); // Split the input and patternInput into directory levels - string[] inputLevels = input.Split( Path.DirectorySeparatorChar ); - string[] patternLevels = patternInput.Split( Path.DirectorySeparatorChar ); + string[] inputLevels = input?.Split( Path.DirectorySeparatorChar ); + string[] patternLevels = patternInput?.Split( Path.DirectorySeparatorChar ); // Ensure the number of levels match - if ( inputLevels.Length != patternLevels.Length ) + if ( inputLevels?.Length != patternLevels?.Length ) return false; // Iterate over each level and perform wildcard matching - for ( int i = 0; i < inputLevels.Length; i++ ) + for ( int i = 0; i < inputLevels?.Length; i++ ) { string inputLevel = inputLevels[i]; string patternLevel = patternLevels[i]; @@ -432,12 +524,14 @@ public static bool WildcardMatch( string input, string patternInput ) // Use regex to perform the wildcard matching return Regex.IsMatch( input, $"^{patternInput}$" ); } + + - public static string FixPathFormatting( string? path ) + public static string FixPathFormatting( string path ) { if (string.IsNullOrWhiteSpace(path)) { - return string.Empty; + return null; } // Replace all slashes with the operating system's path separator @@ -461,24 +555,26 @@ public static string FixPathFormatting( string? path ) public static IEnumerable FindCaseInsensitiveDuplicates(DirectoryInfo dirInfo, bool includeSubFolders=true) { - return FindDuplicatesRecursively(dirInfo.FullName, includeSubFolders, isFile: false); + return FindCaseInsensitiveDuplicates(dirInfo?.FullName, includeSubFolders, isFile: false); } public static IEnumerable FindCaseInsensitiveDuplicates(FileInfo fileInfo) { - // assumed Path.GetDirectoryName can't be null when passing a FileInfo's path. - return FindDuplicatesRecursively(fileInfo.DirectoryName, isFile: true); + return FindCaseInsensitiveDuplicates(fileInfo?.FullName, isFile: true); } // Finds all duplicate items in a path. - public static IEnumerable FindDuplicatesRecursively(string path, bool includeSubFolders=true, bool? isFile=null) + public static IEnumerable FindCaseInsensitiveDuplicates( [NotNull] string path, bool includeSubFolders=true, bool? isFile=null) { + if ( path is null ) + throw new ArgumentNullException( nameof( path ) ); + string formattedPath = FixPathFormatting(path); if (!PathValidator.IsValidPath(formattedPath)) - throw new ArgumentException( nameof( path ) + " is not a valid path string" ); + throw new ArgumentException( $"'{path}' is not a valid path string" ); - // determine if path arg is a folder or a file. - DirectoryInfo? dirInfo; + // determine if path is a folder or a file. + DirectoryInfo dirInfo; if (isFile == false) { dirInfo = new DirectoryInfo( formattedPath ); @@ -489,12 +585,12 @@ public static IEnumerable FindDuplicatesRecursively(string path, } else { - dirInfo = new DirectoryInfo(formattedPath); + dirInfo = new DirectoryInfo( formattedPath ); if (!dirInfo.Exists) { - string? folderPath = Path.GetDirectoryName(formattedPath); + string folderPath = Path.GetDirectoryName(formattedPath); isFile = true; - if ( folderPath is not null ) + if ( !(folderPath is null ) ) dirInfo = new DirectoryInfo(folderPath); } } @@ -503,20 +599,19 @@ public static IEnumerable FindDuplicatesRecursively(string path, throw new ArgumentException($"Path item doesn't exist on disk: '{formattedPath}'"); // build duplicate files/folders list - Dictionary> fileList = new(StringComparer.OrdinalIgnoreCase); - Dictionary> folderList = new(StringComparer.OrdinalIgnoreCase); + var fileList = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var folderList = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (FileInfo file in dirInfo.GetFiles()) { if (!file.Exists) continue; - string filePath = file.FullName; - if (!fileList.TryGetValue(filePath, out List? files)) + string filePath = file.FullName.ToLowerInvariant(); + if (!fileList.TryGetValue(filePath, out List files)) { files = new List(); fileList.Add(filePath, files); } - files.Add(file); } @@ -536,50 +631,52 @@ public static IEnumerable FindDuplicatesRecursively(string path, if (isFile == true) yield break; - foreach (DirectoryInfo subDirectory in dirInfo.GetDirectories()) + foreach ( DirectoryInfo subDirectory in dirInfo.GetDirectories() ) { - if (!subDirectory.Exists) + if ( !subDirectory.Exists ) continue; - if (!folderList.TryGetValue(subDirectory.FullName, out List? folders)) + if ( !folderList.TryGetValue( + subDirectory.FullName.ToLowerInvariant(), + out List folders + ) ) { folders = new List(); - folderList.Add(subDirectory.FullName, folders); + folderList.Add( subDirectory.FullName.ToLowerInvariant(), folders ); } - folders.Add(subDirectory); - if (includeSubFolders) + folders.Add( subDirectory ); + + if ( includeSubFolders ) { - foreach (FileSystemInfo duplicate in FindDuplicatesRecursively(subDirectory.FullName)) + foreach ( FileSystemInfo duplicate in FindCaseInsensitiveDuplicates( subDirectory ) ) { yield return duplicate; } } + } + + foreach (KeyValuePair> folderListEntry in folderList) + { + List foldersInCurrentDir = folderListEntry.Value; + if (foldersInCurrentDir.Count <= 1) + continue; - foreach (KeyValuePair> folderListEntry in folderList) + foreach (FileSystemInfo duplicate in foldersInCurrentDir) { - List foldersInCurrentDir = folderListEntry.Value; - if (foldersInCurrentDir.Count <= 1) - continue; - - foreach (FileSystemInfo duplicate in foldersInCurrentDir) - { - yield return duplicate; - } + yield return duplicate; } - - folderList.Clear(); } } - public static (FileSystemInfo?, List) GetClosestMatchingEntry( string path ) + public static (FileSystemInfo, List) GetClosestMatchingEntry( string path ) { if ( !PathValidator.IsValidPath( path ) ) throw new ArgumentException( nameof( path ) + " is not a valid path string" ); path = FixPathFormatting(path); - string? directoryName = Path.GetDirectoryName( path ); + string directoryName = Path.GetDirectoryName( path ); if ( string.IsNullOrEmpty(directoryName) ) { return ( null, new List() ); @@ -587,14 +684,14 @@ public static (FileSystemInfo?, List) GetClosestMatchingEntry( string pa string searchPattern = Path.GetFileName( path ); - FileSystemInfo? closestMatch = null; + FileSystemInfo closestMatch = null; int maxMatchingCharacters = -1; - List duplicatePaths = new(); + var duplicatePaths = new List(); - DirectoryInfo directory = new( directoryName ); + var directory = new DirectoryInfo( directoryName ); foreach (FileSystemInfo entry in directory.EnumerateFileSystemInfos(searchPattern, SearchOption.TopDirectoryOnly)) { - if (string.IsNullOrWhiteSpace(entry.FullName)) + if (string.IsNullOrWhiteSpace(entry?.FullName)) continue; int matchingCharacters = GetMatchingCharactersCount(entry.FullName, path); From a2687102fe7651b47b79fc74835839db75168fec Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 19 Aug 2023 19:51:20 -0500 Subject: [PATCH 06/11] Fix tests and add Ubuntu test environment w/ WSL see https://stackoverflow.com/questions/65565361/how-can-i-debug-my-net-core-unit-tests-using-visual-studio-with-wsl-2 for info --- KotorDotNET.Tests/PathCaseSensitivityTests.cs | 174 +++++++++++++++--- KotorDotNET/Utility/PathHelper.cs | 21 +-- testenvironments.json | 10 + 3 files changed, 163 insertions(+), 42 deletions(-) create mode 100644 testenvironments.json diff --git a/KotorDotNET.Tests/PathCaseSensitivityTests.cs b/KotorDotNET.Tests/PathCaseSensitivityTests.cs index b40cf7e..92aa7bb 100644 --- a/KotorDotNET.Tests/PathCaseSensitivityTests.cs +++ b/KotorDotNET.Tests/PathCaseSensitivityTests.cs @@ -1,12 +1,9 @@ -// Copyright 2021-2023 KOTORModSync -// Licensed under the GNU General Public License v3.0 (GPLv3). -// See LICENSE.txt file in the project root for full license information. - +using System.Text; using KotorDotNET.Utility; using NUnit.Framework; using Assert = NUnit.Framework.Assert; -namespace KOTORModSync.Tests +namespace KotorDotNET.Tests { internal class PathCaseSensitivityTests { @@ -14,21 +11,124 @@ internal class PathCaseSensitivityTests private static string s_testDirectory; #pragma warning restore CS8618 - [OneTimeSetUp] + [SetUp] public static void InitializeTestDirectory() { s_testDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); _ = Directory.CreateDirectory( s_testDirectory ); } - [OneTimeTearDown] - public static void CleanUpTestDirectory() => Directory.Delete(s_testDirectory, true); + [TearDown] + public static void CleanUpTestDirectory() => Directory.Delete(s_testDirectory, recursive: true); + + [Test] + public void TestDuplicatesWithFileInfo() + { + File.WriteAllText(Path.Combine(s_testDirectory, "file.txt"), "Test content"); + File.WriteAllText(Path.Combine(s_testDirectory, "File.txt"), "Test content"); + + var fileInfo = new FileInfo(Path.Combine(s_testDirectory, "file.txt")); + List result = PathHelper.FindCaseInsensitiveDuplicates(fileInfo).ToList(); + var failureMessage = new StringBuilder(); + foreach (FileSystemInfo item in result) + { + failureMessage.AppendLine(item.FullName); + } + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); + } + + [Test] + public void TestDuplicatesWithDirectoryNameString() + { + File.WriteAllText(Path.Combine(s_testDirectory, "file.txt"), "Test content"); + File.WriteAllText(Path.Combine(s_testDirectory, "File.txt"), "Test content"); + + List result = PathHelper.FindCaseInsensitiveDuplicates(s_testDirectory).ToList(); + var failureMessage = new StringBuilder(); + foreach (FileSystemInfo item in result) + { + failureMessage.AppendLine(item.FullName); + } + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); + } + + [Test] + public void TestDuplicateDirectories() + { + _ = Directory.CreateDirectory( Path.Combine( s_testDirectory, "subdir" ) ); + _ = Directory.CreateDirectory( Path.Combine( s_testDirectory, "SubDir" ) ); + + var dirInfo = new DirectoryInfo(s_testDirectory); + List result = PathHelper.FindCaseInsensitiveDuplicates(dirInfo).ToList(); + var failureMessage = new StringBuilder(); + foreach (FileSystemInfo item in result) + { + failureMessage.AppendLine(item.FullName); + } + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); + } + + [Test] + public void TestDuplicatesWithDifferentCasingFilesInNestedDirectories() + { + string subDirectory = Path.Combine(s_testDirectory, "SubDirectory"); + _ = Directory.CreateDirectory( subDirectory ); + + File.WriteAllText(Path.Combine(s_testDirectory, "file.txt"), "Test content"); + File.WriteAllText(Path.Combine(s_testDirectory, "file.TXT"), "Test content"); + File.WriteAllText(Path.Combine(subDirectory, "FILE.txt"), "Test content"); + File.WriteAllText(Path.Combine(subDirectory, "file.tXT"), "Test content"); + + var dirInfo = new DirectoryInfo(s_testDirectory); + List result = PathHelper.FindCaseInsensitiveDuplicates(dirInfo, includeSubFolders: true).ToList(); + var failureMessage = new StringBuilder(); + foreach (FileSystemInfo item in result) + { + failureMessage.AppendLine(item.FullName); + } + + Assert.That( result, Has.Count.EqualTo(4), $"Expected 4 items, but found {result?.Count}. Output: {failureMessage}"); + } + + [Test] + public void TestDuplicateNestedDirectories() + { + string subDir1 = Path.Combine(s_testDirectory, "SubDir"); + string subDir2 = Path.Combine(s_testDirectory, "subdir"); + + _ = Directory.CreateDirectory( subDir1 ); + _ = Directory.CreateDirectory( subDir2 ); + + File.WriteAllText(Path.Combine(subDir1, "file.txt"), "Test content"); + File.WriteAllText(Path.Combine(subDir2, "file.txt"), "Test content"); + + var dirInfo = new DirectoryInfo(s_testDirectory); + List result = PathHelper.FindCaseInsensitiveDuplicates(dirInfo, includeSubFolders: true).ToList(); + var failureMessage = new StringBuilder(); + foreach (FileSystemInfo item in result) + { + failureMessage.AppendLine(item.FullName); + } + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); + } + + [Test] + public void TestInvalidPath() + { + ArgumentException? ex = Assert.Throws( + () => PathHelper.FindCaseInsensitiveDuplicates( "Invalid>Path" )?.ToList() + ); + } [Test] public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() { // Arrange - string? testFilePath = Path.Combine(s_testDirectory, "test.txt"); + string testFilePath = Path.Combine(s_testDirectory, "test.txt"); File.Create(testFilePath).Close(); // Act @@ -42,7 +142,7 @@ public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() public void GetCaseSensitivePath_ValidDirectory_ReturnsSamePath() { // Arrange - string? testDirPath = Path.Combine(s_testDirectory, "testDir"); + string testDirPath = Path.Combine(s_testDirectory, "testDir"); _ = Directory.CreateDirectory( testDirPath ); // Act @@ -57,8 +157,8 @@ public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() { // Arrange string? nullPath = null; - string? emptyPath = string.Empty; - const string? whiteSpacePath = " "; + string emptyPath = string.Empty; + const string whiteSpacePath = " "; // Act & Assert _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath ) ); @@ -70,10 +170,11 @@ public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() public void GetCaseSensitivePath_InvalidCharactersInPath_ThrowsArgumentException() { // Arrange - string? invalidPath = Path.Combine(s_testDirectory, "invalid>path"); + string invalidPath = Path.Combine(s_testDirectory, "invalid>path"); + string upperCasePath = invalidPath.ToUpperInvariant(); // Act & Assert - _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( invalidPath ) ); + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( upperCasePath ) ); } [Test] @@ -82,10 +183,11 @@ public void GetCaseSensitivePath_RelativePath_ReturnsAbsolutePath() // Arrange string testFilePath = Path.Combine(s_testDirectory, "test.txt"); File.Create(testFilePath).Close(); - string? relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); + string relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); + string upperCasePath = relativePath.ToUpperInvariant(); // Act - string? result = PathHelper.GetCaseSensitivePath(relativePath); + string? result = PathHelper.GetCaseSensitivePath(upperCasePath); // Assert Assert.That( result, Is.EqualTo( testFilePath ) ); @@ -94,45 +196,63 @@ public void GetCaseSensitivePath_RelativePath_ReturnsAbsolutePath() [Test] - // TODO: doesn't work correctly on windows (returns "...Data\\Local\\Temp\\426FCFF0-3DC3-4FD7-9C7A-D6C0878DACDF\\test.txt" instead of "...Data\\Local\\Temp\\426fcff0-3dc3-4fd7-9c7a-d6c0878dacdf\\test.txt") public void GetCaseSensitivePath_EntirePathCaseIncorrect_ReturnsCorrectPath() { // Arrange string testFilePath = Path.Combine(s_testDirectory, "test.txt"); File.Create(testFilePath).Close(); - string relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); + string upperCasePath = testFilePath.ToUpperInvariant(); // Act - string? result = PathHelper.GetCaseSensitivePath( relativePath.ToUpperInvariant() ); + string? result = PathHelper.GetCaseSensitivePath( upperCasePath ); // Assert Assert.That( result, Is.EqualTo( testFilePath ) ); } [Test] - public void GetCaseSensitivePath_NonExistentFile_ReturnsNull() + public void GetCaseSensitivePath_NonExistentFile_ReturnsCaseSensitivePath() + { + // Arrange + string nonExistentFileName = "non_existent_file.txt"; + string nonExistentFilePath = Path.Combine(s_testDirectory, nonExistentFileName); + string upperCasePath = nonExistentFilePath.ToUpperInvariant(); + + // Act + string? result = PathHelper.GetCaseSensitivePath(upperCasePath); + + // Assert + Assert.That( result, Is.EqualTo( Path.Combine(s_testDirectory, nonExistentFileName.ToUpperInvariant()) ) ); + } + + [Test] + public void GetCaseSensitivePath_NonExistentDirAndChildFile_ReturnsCaseSensitivePath() { // Arrange - string? nonExistentFilePath = Path.Combine(s_testDirectory, "non_existent_file.txt"); + string nonExistentRelFilePath = Path.Combine( "non_existent_dir", "non_existent_file.txt" ); + string nonExistentFilePath = Path.Combine(s_testDirectory, nonExistentRelFilePath); + string upperCasePath = nonExistentFilePath.ToUpperInvariant(); // Act - string? result = PathHelper.GetCaseSensitivePath(nonExistentFilePath); + string? result = PathHelper.GetCaseSensitivePath(upperCasePath); // Assert - Assert.That( result, Is.Null ); + Assert.That(result, Is.EqualTo(Path.Combine( s_testDirectory, nonExistentRelFilePath.ToUpperInvariant() ))); } [Test] - public void GetCaseSensitivePath_NonExistentDirectory_ReturnsNull() + public void GetCaseSensitivePath_NonExistentDirectory_ReturnsCaseSensitivePath() { // Arrange - string? nonExistentDirPath = Path.Combine(s_testDirectory, "non_existent_dir"); + string nonExistentRelPath = Path.Combine( "non_existent_dir", "non_existent_child_dir" ); + string nonExistentDirPath = Path.Combine(s_testDirectory, nonExistentRelPath); + string upperCasePath = nonExistentDirPath.ToUpperInvariant(); // Act - string? result = PathHelper.GetCaseSensitivePath(nonExistentDirPath); + string? result = PathHelper.GetCaseSensitivePath(upperCasePath); // Assert - Assert.That( result, Is.Null ); + Assert.That( result, Is.EqualTo( Path.Combine(s_testDirectory, nonExistentRelPath.ToUpperInvariant()) ) ); } } } diff --git a/KotorDotNET/Utility/PathHelper.cs b/KotorDotNET/Utility/PathHelper.cs index 39b84bf..772de08 100644 --- a/KotorDotNET/Utility/PathHelper.cs +++ b/KotorDotNET/Utility/PathHelper.cs @@ -158,7 +158,7 @@ public static DirectoryInfo TryGetValidDirectoryInfo( string folderPath) public static FileInfo TryGetValidFileInfo( string filePath) { string formattedPath = FixPathFormatting(filePath); - if ( formattedPath is null || PathValidator.IsValidPath(formattedPath) ) + if ( PathValidator.IsValidPath(formattedPath) ) return null; try @@ -220,7 +220,7 @@ public static string ConvertWindowsPathToCaseSensitive(string path) const string prefix = @"\\?\"; if (finalPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { - finalPath = finalPath.Substring(prefix.Length); + finalPath = finalPath[prefix.Length..]; } return finalPath; @@ -317,15 +317,6 @@ public static string GetCaseSensitivePath(string path) } - - private static string GetCaseSensitiveChildPath( DirectoryInfo parentDir, string path) => - ( - from item in parentDir?.GetFileSystemInfos(searchPattern: "*", SearchOption.TopDirectoryOnly) - where item.FullName.Equals( path, StringComparison.OrdinalIgnoreCase ) - select ConvertWindowsPathToCaseSensitive( item.FullName ) - ).FirstOrDefault(); - - public static async Task MoveFileAsync( string sourcePath, string destinationPath ) { if ( sourcePath is null ) @@ -431,7 +422,7 @@ public static List EnumerateFilesWithWildcards( string currentDir = formattedPath; while (ContainsWildcards(currentDir)) { - string parentDirectory = Path.GetDirectoryName(currentDir); + string? parentDirectory = Path.GetDirectoryName(currentDir); // Exit the loop if no parent directory is found or if the parent directory is the same as the current directory if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDir) @@ -481,11 +472,11 @@ public static bool WildcardPathMatch( string input, string patternInput ) patternInput = FixPathFormatting( patternInput ); // Split the input and patternInput into directory levels - string[] inputLevels = input?.Split( Path.DirectorySeparatorChar ); - string[] patternLevels = patternInput?.Split( Path.DirectorySeparatorChar ); + string[] inputLevels = input.Split( Path.DirectorySeparatorChar ); + string[] patternLevels = patternInput.Split( Path.DirectorySeparatorChar ); // Ensure the number of levels match - if ( inputLevels?.Length != patternLevels?.Length ) + if ( inputLevels.Length != patternLevels.Length ) return false; // Iterate over each level and perform wildcard matching diff --git a/testenvironments.json b/testenvironments.json new file mode 100644 index 0000000..539ead6 --- /dev/null +++ b/testenvironments.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "environments": [ + { + "name": "Ubuntu", + "type": "wsl", + "wslDistribution": "Ubuntu" + } + ] +} From c61c23132d900611ba08cca9c95957bffaa5eac3 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 26 Aug 2023 21:34:35 -0500 Subject: [PATCH 07/11] Large refactor --- .vscode/extensions.json | 5 + .vscode/settings.json | 6 + KotorDotNET.Tests/PathCaseSensitivityTests.cs | 665 ++++++++++------ .../DirectoryInfoExtensions.cs | 139 ++++ .../FileSystemPathing/InsensitivePath.cs | 78 ++ KotorDotNET/FileSystemPathing/PathHelper.cs | 753 ++++++++++++++++++ .../FileSystemPathing/PathValidator.cs | 174 ++++ KotorDotNET/Utility/PathHelper.cs | 734 ----------------- 8 files changed, 1571 insertions(+), 983 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 KotorDotNET/FileSystemPathing/DirectoryInfoExtensions.cs create mode 100644 KotorDotNET/FileSystemPathing/InsensitivePath.cs create mode 100644 KotorDotNET/FileSystemPathing/PathHelper.cs create mode 100644 KotorDotNET/FileSystemPathing/PathValidator.cs delete mode 100644 KotorDotNET/Utility/PathHelper.cs diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..af4c332 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "JipitiAI.askcodebase" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..63287d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "KOTOR", + "Pathing" + ] +} diff --git a/KotorDotNET.Tests/PathCaseSensitivityTests.cs b/KotorDotNET.Tests/PathCaseSensitivityTests.cs index 92aa7bb..3c739d8 100644 --- a/KotorDotNET.Tests/PathCaseSensitivityTests.cs +++ b/KotorDotNET.Tests/PathCaseSensitivityTests.cs @@ -1,258 +1,425 @@ -using System.Text; -using KotorDotNET.Utility; + +using System.Text; +using KotorDotNET.FileSystemPathing; using NUnit.Framework; using Assert = NUnit.Framework.Assert; namespace KotorDotNET.Tests { - internal class PathCaseSensitivityTests - { + internal class PathCaseSensitivityTests + { #pragma warning disable CS8618 - private static string s_testDirectory; + private static string s_testDirectory; #pragma warning restore CS8618 - [SetUp] - public static void InitializeTestDirectory() - { - s_testDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - _ = Directory.CreateDirectory( s_testDirectory ); - } - - [TearDown] - public static void CleanUpTestDirectory() => Directory.Delete(s_testDirectory, recursive: true); - - [Test] - public void TestDuplicatesWithFileInfo() - { - File.WriteAllText(Path.Combine(s_testDirectory, "file.txt"), "Test content"); - File.WriteAllText(Path.Combine(s_testDirectory, "File.txt"), "Test content"); - - var fileInfo = new FileInfo(Path.Combine(s_testDirectory, "file.txt")); - List result = PathHelper.FindCaseInsensitiveDuplicates(fileInfo).ToList(); - var failureMessage = new StringBuilder(); - foreach (FileSystemInfo item in result) - { - failureMessage.AppendLine(item.FullName); - } - - Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); - } - - [Test] - public void TestDuplicatesWithDirectoryNameString() - { - File.WriteAllText(Path.Combine(s_testDirectory, "file.txt"), "Test content"); - File.WriteAllText(Path.Combine(s_testDirectory, "File.txt"), "Test content"); - - List result = PathHelper.FindCaseInsensitiveDuplicates(s_testDirectory).ToList(); - var failureMessage = new StringBuilder(); - foreach (FileSystemInfo item in result) - { - failureMessage.AppendLine(item.FullName); - } - - Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); - } - - [Test] - public void TestDuplicateDirectories() - { - _ = Directory.CreateDirectory( Path.Combine( s_testDirectory, "subdir" ) ); - _ = Directory.CreateDirectory( Path.Combine( s_testDirectory, "SubDir" ) ); - - var dirInfo = new DirectoryInfo(s_testDirectory); - List result = PathHelper.FindCaseInsensitiveDuplicates(dirInfo).ToList(); - var failureMessage = new StringBuilder(); - foreach (FileSystemInfo item in result) - { - failureMessage.AppendLine(item.FullName); - } - - Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); - } - - [Test] - public void TestDuplicatesWithDifferentCasingFilesInNestedDirectories() - { - string subDirectory = Path.Combine(s_testDirectory, "SubDirectory"); - _ = Directory.CreateDirectory( subDirectory ); - - File.WriteAllText(Path.Combine(s_testDirectory, "file.txt"), "Test content"); - File.WriteAllText(Path.Combine(s_testDirectory, "file.TXT"), "Test content"); - File.WriteAllText(Path.Combine(subDirectory, "FILE.txt"), "Test content"); - File.WriteAllText(Path.Combine(subDirectory, "file.tXT"), "Test content"); - - var dirInfo = new DirectoryInfo(s_testDirectory); - List result = PathHelper.FindCaseInsensitiveDuplicates(dirInfo, includeSubFolders: true).ToList(); - var failureMessage = new StringBuilder(); - foreach (FileSystemInfo item in result) - { - failureMessage.AppendLine(item.FullName); - } - - Assert.That( result, Has.Count.EqualTo(4), $"Expected 4 items, but found {result?.Count}. Output: {failureMessage}"); - } - - [Test] - public void TestDuplicateNestedDirectories() - { - string subDir1 = Path.Combine(s_testDirectory, "SubDir"); - string subDir2 = Path.Combine(s_testDirectory, "subdir"); - - _ = Directory.CreateDirectory( subDir1 ); - _ = Directory.CreateDirectory( subDir2 ); - - File.WriteAllText(Path.Combine(subDir1, "file.txt"), "Test content"); - File.WriteAllText(Path.Combine(subDir2, "file.txt"), "Test content"); - - var dirInfo = new DirectoryInfo(s_testDirectory); - List result = PathHelper.FindCaseInsensitiveDuplicates(dirInfo, includeSubFolders: true).ToList(); - var failureMessage = new StringBuilder(); - foreach (FileSystemInfo item in result) - { - failureMessage.AppendLine(item.FullName); - } - - Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result?.Count}. Output: {failureMessage}"); - } - - [Test] - public void TestInvalidPath() - { - ArgumentException? ex = Assert.Throws( - () => PathHelper.FindCaseInsensitiveDuplicates( "Invalid>Path" )?.ToList() - ); - } - - [Test] - public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() - { - // Arrange - string testFilePath = Path.Combine(s_testDirectory, "test.txt"); - File.Create(testFilePath).Close(); - - // Act - string? result = PathHelper.GetCaseSensitivePath(testFilePath); - - // Assert - Assert.That( result, Is.EqualTo( testFilePath ) ); - } - - [Test] - public void GetCaseSensitivePath_ValidDirectory_ReturnsSamePath() - { - // Arrange - string testDirPath = Path.Combine(s_testDirectory, "testDir"); - _ = Directory.CreateDirectory( testDirPath ); - - // Act - string? result = PathHelper.GetCaseSensitivePath(testDirPath); - - // Assert - Assert.That( result, Is.EqualTo( testDirPath ) ); - } - - [Test] - public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() - { - // Arrange - string? nullPath = null; - string emptyPath = string.Empty; - const string whiteSpacePath = " "; - - // Act & Assert - _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath ) ); - _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( emptyPath ) ); - _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( whiteSpacePath ) ); - } - - [Test] - public void GetCaseSensitivePath_InvalidCharactersInPath_ThrowsArgumentException() - { - // Arrange - string invalidPath = Path.Combine(s_testDirectory, "invalid>path"); - string upperCasePath = invalidPath.ToUpperInvariant(); - - // Act & Assert - _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( upperCasePath ) ); - } - - [Test] - public void GetCaseSensitivePath_RelativePath_ReturnsAbsolutePath() - { - // Arrange - string testFilePath = Path.Combine(s_testDirectory, "test.txt"); - File.Create(testFilePath).Close(); - string relativePath = Path.GetRelativePath(Directory.GetCurrentDirectory(), testFilePath); - string upperCasePath = relativePath.ToUpperInvariant(); - - // Act - string? result = PathHelper.GetCaseSensitivePath(upperCasePath); - - // Assert - Assert.That( result, Is.EqualTo( testFilePath ) ); - } - - - - [Test] - public void GetCaseSensitivePath_EntirePathCaseIncorrect_ReturnsCorrectPath() - { - // Arrange - string testFilePath = Path.Combine(s_testDirectory, "test.txt"); - File.Create(testFilePath).Close(); - string upperCasePath = testFilePath.ToUpperInvariant(); - - // Act - string? result = PathHelper.GetCaseSensitivePath( upperCasePath ); - - // Assert - Assert.That( result, Is.EqualTo( testFilePath ) ); - } - - [Test] - public void GetCaseSensitivePath_NonExistentFile_ReturnsCaseSensitivePath() - { - // Arrange - string nonExistentFileName = "non_existent_file.txt"; - string nonExistentFilePath = Path.Combine(s_testDirectory, nonExistentFileName); - string upperCasePath = nonExistentFilePath.ToUpperInvariant(); - - // Act - string? result = PathHelper.GetCaseSensitivePath(upperCasePath); - - // Assert - Assert.That( result, Is.EqualTo( Path.Combine(s_testDirectory, nonExistentFileName.ToUpperInvariant()) ) ); - } - - [Test] - public void GetCaseSensitivePath_NonExistentDirAndChildFile_ReturnsCaseSensitivePath() - { - // Arrange - string nonExistentRelFilePath = Path.Combine( "non_existent_dir", "non_existent_file.txt" ); - string nonExistentFilePath = Path.Combine(s_testDirectory, nonExistentRelFilePath); - string upperCasePath = nonExistentFilePath.ToUpperInvariant(); - - // Act - string? result = PathHelper.GetCaseSensitivePath(upperCasePath); - - // Assert - Assert.That(result, Is.EqualTo(Path.Combine( s_testDirectory, nonExistentRelFilePath.ToUpperInvariant() ))); - } - - [Test] - public void GetCaseSensitivePath_NonExistentDirectory_ReturnsCaseSensitivePath() - { - // Arrange - string nonExistentRelPath = Path.Combine( "non_existent_dir", "non_existent_child_dir" ); - string nonExistentDirPath = Path.Combine(s_testDirectory, nonExistentRelPath); - string upperCasePath = nonExistentDirPath.ToUpperInvariant(); - - // Act - string? result = PathHelper.GetCaseSensitivePath(upperCasePath); - - // Assert - Assert.That( result, Is.EqualTo( Path.Combine(s_testDirectory, nonExistentRelPath.ToUpperInvariant()) ) ); - } - } + [SetUp] + public static void InitializeTestDirectory() + { + s_testDirectory = Path.Combine( Path.GetTempPath(), Guid.NewGuid().ToString() ); + _ = Directory.CreateDirectory( s_testDirectory ); + } + + [TearDown] + public static void CleanUpTestDirectory() => Directory.Delete( s_testDirectory, recursive: true ); + + private DirectoryInfo _tempDirectory = null!; + private DirectoryInfo _subDirectory = null!; + + [SetUp] + public void Setup() + { + _tempDirectory = new DirectoryInfo( Path.Combine( Path.GetTempPath(), "UnitTestTempDir" ) ); + _tempDirectory.Create(); + _subDirectory = new DirectoryInfo( Path.Combine( _tempDirectory.FullName, "SubDir" ) ); + _subDirectory.Create(); + } + + [TearDown] + public void TearDown() + { + _subDirectory.Delete( true ); + _tempDirectory.Delete( true ); + } + + [Test] + public void FindCaseInsensitiveDuplicates_ThrowsArgumentNullException_WhenDirectoryIsNull() + { + DirectoryInfo? directory = null; + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + _ = Assert.Throws( () => PathHelper.FindCaseInsensitiveDuplicates( directory! )?.ToList() ); + } + + + [Test] + public void FindCaseInsensitiveDuplicates_ReturnsEmptyList_WhenDirectoryIsEmpty() + { + List result = PathHelper.FindCaseInsensitiveDuplicates( _tempDirectory ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine(string.Join(Environment.NewLine, result.Select(item => item.FullName))); + + Assert.That( result, Is.Empty, $"Expected 0 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void FindCaseInsensitiveDuplicates_ReturnsEmptyList_WhenNoDuplicatesExist() + { + // Arrange + var file1 = new FileInfo( Path.Combine( _tempDirectory.FullName, "file1.txt" ) ); + file1.Create().Close(); + var file2 = new FileInfo( Path.Combine( _tempDirectory.FullName, "file2.txt" ) ); + file2.Create().Close(); + + // Act + List result = PathHelper.FindCaseInsensitiveDuplicates( _tempDirectory ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine(string.Join(Environment.NewLine, result.Select(item => item.FullName))); + + // Assert + Assert.That( result, Is.Empty, $"Expected 0 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + // will always fail on windows + public void FindCaseInsensitiveDuplicates_FindsFileDuplicates_CaseInsensitive() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + // Arrange + var file1 = new FileInfo( Path.Combine( _tempDirectory.FullName, "file1.txt" ) ); + file1.Create().Close(); + var file2 = new FileInfo( Path.Combine( _tempDirectory.FullName, "FILE1.txt" ) ); + file2.Create().Close(); + + // Act + List result = PathHelper.FindCaseInsensitiveDuplicates( _tempDirectory ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine(string.Join(Environment.NewLine, result.Select(item => item.FullName))); + + // Assert + Assert.That( result.ToList(), Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void FindCaseInsensitiveDuplicates_IgnoresNonDuplicates() + { + // Arrange + var file1 = new FileInfo( Path.Combine( _tempDirectory.FullName, "file1.txt" ) ); + file1.Create().Close(); + var file2 = new FileInfo( Path.Combine( _subDirectory.FullName, "file2.txt" ) ); + file2.Create().Close(); + + // Act + List result = PathHelper.FindCaseInsensitiveDuplicates( _tempDirectory ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine(string.Join(Environment.NewLine, result.Select(item => item.FullName))); + + // Assert + Assert.That( result, Is.Empty, $"Expected 0 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void FindCaseInsensitiveDuplicates_IgnoresExtensions() + { + // Arrange + var file1 = new FileInfo( Path.Combine( _tempDirectory.FullName, "file1.txt" ) ); + file1.Create().Close(); + var file2 = new FileInfo( Path.Combine( _subDirectory.FullName, "FILE1.png" ) ); + file2.Create().Close(); + + // Act + List result = PathHelper.FindCaseInsensitiveDuplicates( _tempDirectory ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine( string.Join( Environment.NewLine, result.Select( item => item.FullName ) ) ); + + // Assert + Assert.That( result, Is.Empty, $"Expected 0 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void TestGetClosestMatchingEntry() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + string file1 = Path.Combine( s_testDirectory, "file.txt" ); + string file2 = Path.Combine( s_testDirectory, "FILE.TXT" ); + File.WriteAllText( file1, contents: "Test content" ); + File.WriteAllText( file2, contents: "Test content" ); + Assert.Multiple( () => + { + Assert.That( PathHelper.GetCaseSensitivePath( Path.Combine( Path.GetDirectoryName( file1 )!, Path.GetFileName( file1 ).ToUpperInvariant() ) ).Item1, Is.EqualTo( file2 ) ); + Assert.That( PathHelper.GetCaseSensitivePath( file1.ToUpperInvariant() ).Item1, Is.EqualTo( file2 ) ); + } ); + } + + [Test] + public void TestDuplicatesWithFileInfo() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + File.WriteAllText( Path.Combine( s_testDirectory, "file.txt" ), contents: "Test content" ); + File.WriteAllText( Path.Combine( s_testDirectory, "File.txt" ), contents: "Test content" ); + + var fileInfo = new FileInfo( Path.Combine( s_testDirectory, "file.txt" ) ); + List result = PathHelper.FindCaseInsensitiveDuplicates( fileInfo ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine( string.Join( Environment.NewLine, result.Select( item => item.FullName ) ) ); + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void TestDuplicatesWithDirectoryNameString() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + File.WriteAllText( Path.Combine( s_testDirectory, "file.txt" ), contents: "Test content" ); + File.WriteAllText( Path.Combine( s_testDirectory, "File.txt" ), contents: "Test content" ); + + List result = PathHelper.FindCaseInsensitiveDuplicates( s_testDirectory ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine( string.Join( Environment.NewLine, result.Select( item => item.FullName ) ) ); + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void TestDuplicateDirectories() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + _ = Directory.CreateDirectory( Path.Combine( s_testDirectory, "subdir" ) ); + _ = Directory.CreateDirectory( Path.Combine( s_testDirectory, "SubDir" ) ); + + var dirInfo = new DirectoryInfo( s_testDirectory ); + List result = PathHelper.FindCaseInsensitiveDuplicates( dirInfo ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine( string.Join( Environment.NewLine, result.Select( item => item.FullName ) ) ); + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void TestDuplicatesWithDifferentCasingFilesInNestedDirectories() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + string subDirectory = Path.Combine( s_testDirectory, "SubDirectory" ); + _ = Directory.CreateDirectory( subDirectory ); + + File.WriteAllText( Path.Combine( s_testDirectory, "file.txt" ), contents: "Test content" ); + File.WriteAllText( Path.Combine( s_testDirectory, "file.TXT" ), contents: "Test content" ); + File.WriteAllText( Path.Combine( subDirectory, "FILE.txt" ), contents: "Test content" ); + File.WriteAllText( Path.Combine( subDirectory, "file.tXT" ), contents: "Test content" ); + + var dirInfo = new DirectoryInfo( s_testDirectory ); + List result = PathHelper.FindCaseInsensitiveDuplicates( dirInfo, includeSubFolders: true ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine( string.Join( Environment.NewLine, result.Select( item => item.FullName ) ) ); + + Assert.That( result, Has.Count.EqualTo( 4 ), $"Expected 4 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void TestDuplicateNestedDirectories() + { + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + { + Console.WriteLine( "Test is not possible on Windows." ); + return; + } + + string subDir1 = Path.Combine( s_testDirectory, "SubDir" ); + string subDir2 = Path.Combine( s_testDirectory, "subdir" ); + + _ = Directory.CreateDirectory( subDir1 ); + _ = Directory.CreateDirectory( subDir2 ); + + File.WriteAllText( Path.Combine( subDir1, "file.txt" ), contents: "Test content" ); + File.WriteAllText( Path.Combine( subDir2, "file.txt" ), contents: "Test content" ); + + var dirInfo = new DirectoryInfo( s_testDirectory ); + List result = PathHelper.FindCaseInsensitiveDuplicates( dirInfo, includeSubFolders: true ).ToList(); + + var failureMessage = new StringBuilder(); + failureMessage.AppendLine( string.Join( Environment.NewLine, result.Select( item => item.FullName ) ) ); + + Assert.That( result, Has.Count.EqualTo( 2 ), $"Expected 2 items, but found {result.Count}. Output: {failureMessage}" ); + } + + [Test] + public void TestInvalidPath() + { + Assert.Throws( + // ReSharper disable once ReturnValueOfPureMethodIsNotUsed + () => PathHelper.FindCaseInsensitiveDuplicates( "Invalid>Path" )?.ToList() + ); + } + + [Test] + public void GetCaseSensitivePath_ValidFile_ReturnsSamePath() + { + // Arrange + string testFilePath = Path.Combine( s_testDirectory, "test.txt" ); + File.Create( testFilePath ).Close(); + + // Act + string? result = PathHelper.GetCaseSensitivePath( testFilePath, isFile: true ).Item1; + + // Assert + Assert.That( result, Is.EqualTo( testFilePath ) ); + } + + [Test] + public void GetCaseSensitivePath_ValidDirectory_ReturnsSamePath() + { + // Arrange + string testDirPath = Path.Combine( s_testDirectory, "testDir" ); + _ = Directory.CreateDirectory( testDirPath ); + + // Act + DirectoryInfo? result = PathHelper.GetCaseSensitivePath( new DirectoryInfo( testDirPath ) ); + + // Assert + Assert.That( result.FullName, Is.EqualTo( testDirPath ) ); + } + + [Test] + public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() + { + // Arrange + string? nullPath = null; + string emptyPath = string.Empty; + const string whiteSpacePath = " "; + + // Act & Assert + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath ) ); + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( emptyPath ) ); + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( whiteSpacePath ) ); + } + + [Test] + public void GetCaseSensitivePath_InvalidCharactersInPath_ReturnsOriginalPath() + { + // Arrange + string fileName = "invalid>path"; + string invalidPath = Path.Combine( s_testDirectory, fileName ); + string upperCasePath = invalidPath.ToUpperInvariant(); + + // Act & Assert + (string, bool?) result = PathHelper.GetCaseSensitivePath( upperCasePath ); + Assert.That( result.Item1, Is.EqualTo( Path.Combine( s_testDirectory, fileName.ToUpperInvariant() ) ) ); + Assert.That( result.Item2, Is.Null ); + } + + [Test] + public void GetCaseSensitivePath_RelativePath_ReturnsAbsolutePath() + { + // Arrange + string testFilePath = Path.Combine( s_testDirectory, "test.txt" ); + File.Create( testFilePath ).Close(); + string relativePath = Path.GetRelativePath( Directory.GetCurrentDirectory(), testFilePath ); + string upperCasePath = relativePath.ToUpperInvariant(); + + // Act + string? result = PathHelper.GetCaseSensitivePath( upperCasePath ).Item1; + + // Assert + Assert.That( result, Is.EqualTo( testFilePath ) ); + } + + + + [Test] + public void GetCaseSensitivePath_EntirePathCaseIncorrect_ReturnsCorrectPath() + { + // Arrange + string testFilePath = Path.Combine( s_testDirectory, "test.txt" ); + File.Create( testFilePath ).Close(); + string upperCasePath = testFilePath.ToUpperInvariant(); + + // Act + string? result = PathHelper.GetCaseSensitivePath( upperCasePath, isFile: true ).Item1; + + // Assert + Assert.That( result, Is.EqualTo( testFilePath ) ); + } + + [Test] + public void GetCaseSensitivePath_NonExistentFile_ReturnsCaseSensitivePath() + { + // Arrange + string nonExistentFileName = "non_existent_file.txt"; + string nonExistentFilePath = Path.Combine( s_testDirectory, nonExistentFileName ); + string upperCasePath = nonExistentFilePath.ToUpperInvariant(); + + // Act + string? result = PathHelper.GetCaseSensitivePath( upperCasePath ).Item1; + + // Assert + Assert.That( result, Is.EqualTo( Path.Combine( s_testDirectory, nonExistentFileName.ToUpperInvariant() ) ) ); + } + + [Test] + public void GetCaseSensitivePath_NonExistentDirAndChildFile_ReturnsCaseSensitivePath() + { + // Arrange + string nonExistentRelFilePath = Path.Combine( "non_existent_dir", "non_existent_file.txt" ); + string nonExistentFilePath = Path.Combine( s_testDirectory, nonExistentRelFilePath ); + string upperCasePath = nonExistentFilePath.ToUpperInvariant(); + + // Act + string? result = PathHelper.GetCaseSensitivePath( upperCasePath, isFile: true ).Item1; + + // Assert + Assert.That( result, Is.EqualTo( Path.Combine( s_testDirectory, nonExistentRelFilePath.ToUpperInvariant() ) ) ); + } + + [Test] + public void GetCaseSensitivePath_NonExistentDirectory_ReturnsCaseSensitivePath() + { + // Arrange + string nonExistentRelPath = Path.Combine( "non_existent_dir", "non_existent_child_dir" ); + string nonExistentDirPath = Path.Combine( s_testDirectory, nonExistentRelPath ); + string upperCasePath = nonExistentDirPath.ToUpperInvariant(); + + // Act + string? result = PathHelper.GetCaseSensitivePath( upperCasePath ).Item1; + + // Assert + Assert.That( result, Is.EqualTo( Path.Combine( s_testDirectory, nonExistentRelPath.ToUpperInvariant() ) ) ); + } + } } diff --git a/KotorDotNET/FileSystemPathing/DirectoryInfoExtensions.cs b/KotorDotNET/FileSystemPathing/DirectoryInfoExtensions.cs new file mode 100644 index 0000000..0729556 --- /dev/null +++ b/KotorDotNET/FileSystemPathing/DirectoryInfoExtensions.cs @@ -0,0 +1,139 @@ + + +namespace KotorDotNET.FileSystemPathing +{ + public static class DirectoryInfoExtensions + { + private static IEnumerable SafeEnumerate( + IEnumerator enumerator) + { + while (true) + { + T thisEntry; + try + { + if (!enumerator.MoveNext()) + break; + + thisEntry = enumerator.Current; + } + catch (UnauthorizedAccessException permEx) + { + Console.WriteLine($"Permission denied while enumerating file/folder wildcards: {permEx.Message} Skipping..."); + continue; // Skip files or directories with access issues + } + catch (IOException ioEx) + { + Console.WriteLine($"IO exception enumerating file/folder wildcards: {ioEx.Message} Skipping file/folder..."); + continue; // Skip files or directories with IO issues + } + catch (Exception ex) + { + Console.WriteLine($"Unhandled exception enumerating file/folder wildcards: {ex.Message}. Attempting to skip file/folder item..."); + continue; + } + + if (thisEntry == null) + continue; + + yield return thisEntry; + } + } + + public static IEnumerable EnumerateFilesSafely( + this DirectoryInfo dirInfo, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + try + { + return SafeEnumerate(dirInfo.EnumerateFiles(searchPattern, searchOption).GetEnumerator()); + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return Array.Empty(); + } + } + + public static IEnumerable EnumerateDirectoriesSafely( + this DirectoryInfo dirInfo, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + try + { + return SafeEnumerate(dirInfo.EnumerateDirectories(searchPattern, searchOption).GetEnumerator()); + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return Array.Empty(); + } + } + + public static IEnumerable EnumerateFileSystemInfosSafely( + this DirectoryInfo dirInfo, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + try + { + return SafeEnumerate(dirInfo.EnumerateFileSystemInfos(searchPattern, searchOption).GetEnumerator()); + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return Array.Empty(); + } + } + + public static DirectoryInfo[] GetDirectoriesSafely( + this DirectoryInfo dirInfo, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + try + { + return dirInfo.EnumerateDirectoriesSafely(searchPattern, searchOption).ToArray(); + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return Array.Empty(); + } + } + + public static FileInfo[] GetFilesSafely( + this DirectoryInfo dirInfo, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + try + { + return dirInfo.EnumerateFilesSafely(searchPattern, searchOption).ToArray(); + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return Array.Empty(); + } + } + + public static FileSystemInfo[] GetFileInfosSafely( + this DirectoryInfo dirInfo, + string searchPattern = "*", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + try + { + return dirInfo.EnumerateFileSystemInfosSafely(searchPattern, searchOption).ToArray(); + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return Array.Empty(); + } + } + } +} diff --git a/KotorDotNET/FileSystemPathing/InsensitivePath.cs b/KotorDotNET/FileSystemPathing/InsensitivePath.cs new file mode 100644 index 0000000..e55802a --- /dev/null +++ b/KotorDotNET/FileSystemPathing/InsensitivePath.cs @@ -0,0 +1,78 @@ + +namespace KotorDotNET.FileSystemPathing +{ + public class InsensitivePath : FileSystemInfo + { + private FileSystemInfo _fileSystemInfo { get; set; } + private bool _isFile { get; } + public bool IsFile => _isFile; + public List FindDuplicates() => PathHelper.FindCaseInsensitiveDuplicates( FullName, includeSubFolders: true, isFile: IsFile ).ToList(); + public override string Name => _fileSystemInfo.Name; + public override string FullName => _fileSystemInfo.FullName; + public override bool Exists + { + get + { + if ( IsFile && File.Exists( FullName ) ) + return true; + if ( !IsFile && Directory.Exists( FullName ) ) + return true; + + Refresh(); + + return _fileSystemInfo.Exists; + } + } + public override void Delete() + { + _fileSystemInfo.Delete(); + FindDuplicates()?.ToList().ForEach(duplicate => duplicate?.Delete()); + } + + public override string ToString() => FullName; + + public InsensitivePath( FileSystemInfo fileSystemInfo ) => _fileSystemInfo = fileSystemInfo; + public InsensitivePath( string inputPath, bool isFile ) + { + string formattedPath = PathHelper.FixPathFormatting( inputPath ); + OriginalPath = formattedPath; + _isFile = isFile; + _fileSystemInfo = _isFile + ? (FileSystemInfo)new FileInfo( formattedPath ) + : (FileSystemInfo)new DirectoryInfo( formattedPath ); + + Refresh(); + } + + public new void Refresh() + { + if ( _fileSystemInfo is null ) + throw new NullReferenceException("_fileSystemInfo cannot be null"); + + _fileSystemInfo.Refresh(); + + if ( _fileSystemInfo.Exists ) + return; + + ( string fileSystemItemPath, bool? isFile ) = PathHelper.GetCaseSensitivePath( OriginalPath ); + + switch ( isFile ) + { + case true: + _fileSystemInfo = new FileInfo(fileSystemItemPath); + break; + case false: + _fileSystemInfo = new DirectoryInfo(fileSystemItemPath); + break; + default: + return; + } + + _fileSystemInfo.Refresh(); + } + + //public static implicit operator string( InsensitivePath insensitivePath ) => insensitivePath._fileSystemInfo?.FullName; + public static implicit operator FileInfo?( InsensitivePath insensitivePath ) => insensitivePath._fileSystemInfo as FileInfo; + public static implicit operator DirectoryInfo?( InsensitivePath insensitivePath ) => insensitivePath._fileSystemInfo as DirectoryInfo; + } +} diff --git a/KotorDotNET/FileSystemPathing/PathHelper.cs b/KotorDotNET/FileSystemPathing/PathHelper.cs new file mode 100644 index 0000000..37d9419 --- /dev/null +++ b/KotorDotNET/FileSystemPathing/PathHelper.cs @@ -0,0 +1,753 @@ + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace KotorDotNET.FileSystemPathing +{ + public static class PathHelper + { + // if it's a folder, return path as is, if it's a file get the parent dir. + public static string? GetFolderName( string? filePath ) + { + return Path.HasExtension( filePath ) + ? Path.GetDirectoryName( filePath ) + : filePath; + } + + public static DirectoryInfo? TryGetValidDirectoryInfo( string folderPath ) + { + if ( string.IsNullOrWhiteSpace( folderPath ) ) + return null; + + string formattedPath = FixPathFormatting( folderPath ); + if ( !PathValidator.IsValidPath( formattedPath ) ) + return null; + + try + { + return new DirectoryInfo( formattedPath ); + } + catch ( Exception ) + { + // In .NET Framework 4.6.2 and earlier, the DirectoryInfo constructor throws an exception + // when the path is invalid. We catch the exception and return null instead for a unified experience. + return null; + } + } + + public static FileInfo? TryGetValidFileInfo( string? filePath ) + { + if ( string.IsNullOrWhiteSpace( filePath ) ) + return null; + + string formattedPath = FixPathFormatting( filePath ); + if ( !PathValidator.IsValidPath( formattedPath ) ) + return null; + + try + { + return new FileInfo( formattedPath ); + } + catch ( Exception ) + { + // In .NET Framework 4.6.2 and earlier, the FileInfo constructor throws an exception + // when the path is invalid. We catch the exception and return null instead for a unified experience. + return null; + } + } + + public static string ConvertWindowsPathToCaseSensitive( string path ) + { + if ( Environment.OSVersion.Platform != PlatformID.Win32NT ) + return path; + if ( string.IsNullOrWhiteSpace( path ) ) + throw new ArgumentException( $"'{nameof( path )}' cannot be null or whitespace.", nameof( path ) ); + if ( !PathValidator.IsValidPath( path ) ) + throw new ArgumentException( $"{path} is not a valid path!" ); + + + const uint FILE_SHARE_READ = 1; + const uint OPEN_EXISTING = 3; + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint VOLUME_NAME_DOS = 0; + + IntPtr handle = CreateFile( + path, + dwDesiredAccess: 0, + FILE_SHARE_READ, + lpSecurityAttributes: IntPtr.Zero, + dwCreationDisposition: OPEN_EXISTING, + dwFlagsAndAttributes: FILE_FLAG_BACKUP_SEMANTICS, + hTemplateFile: IntPtr.Zero + ); + + if ( handle == IntPtr.Zero ) + throw new Win32Exception( Marshal.GetLastWin32Error() ); + + try + { + var buffer = new StringBuilder( 4096 ); + uint result = GetFinalPathNameByHandle( handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS ); + + if ( result == 0 ) + throw new Win32Exception( Marshal.GetLastWin32Error() ); + + // The result may be prefixed with "\\?\" + string finalPath = buffer.ToString(); + const string prefix = @"\\?\"; + if ( finalPath.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) + finalPath = finalPath.Substring( prefix.Length ); + + return finalPath; + } + finally + { + _ = CloseHandle( handle ); + } + } + + public static string GetRelativePath(string relativeTo, string path) => GetRelativePath(relativeTo, path, StringComparison.OrdinalIgnoreCase); + + private static string GetRelativePath(string relativeTo, string path, StringComparison comparisonType) + { + if (string.IsNullOrEmpty(relativeTo)) + throw new ArgumentException("Path cannot be empty", nameof(relativeTo)); + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Path cannot be empty", nameof(path)); + + relativeTo = Path.GetFullPath(FixPathFormatting(relativeTo)); + path = Path.GetFullPath(FixPathFormatting(path)); + + if (!AreRootsEqual(relativeTo, path, comparisonType)) + return path; + + int commonLength = GetCommonPathLength( + relativeTo, + path, + ignoreCase: comparisonType == StringComparison.OrdinalIgnoreCase + ); + + if (commonLength == 0) + return path; + + bool pathEndsInSeparator = path.EndsWith(Path.DirectorySeparatorChar.ToString()); + int pathLength = path.Length; + if (pathEndsInSeparator) + pathLength--; + + if (relativeTo.Length == pathLength && commonLength >= relativeTo.Length) return "."; + + var sb = new StringBuilder(Math.Max(relativeTo.Length, path.Length)); + + if (commonLength < relativeTo.Length) + { + sb.Append(".."); + + for (int i = commonLength + 1; i < relativeTo.Length; i++) + { + if (relativeTo[i] == Path.DirectorySeparatorChar) + { + sb.Append(Path.DirectorySeparatorChar); + sb.Append(".."); + } + } + } + else if (path[commonLength] == Path.DirectorySeparatorChar) + { + commonLength++; + } + + int differenceLength = pathLength - commonLength; + if (pathEndsInSeparator) + differenceLength++; + + if (differenceLength > 0) + { + if (sb.Length > 0) + { + sb.Append(Path.DirectorySeparatorChar); + } + + sb.Append(path.AsSpan(commonLength, differenceLength)); + } + + return sb.ToString(); + } + + private static bool AreRootsEqual(string first, string second, StringComparison comparisonType) + { + int? firstRootLength = Path.GetPathRoot(first)?.Length; + if ( firstRootLength == null ) + return false; + int? secondRootLength = Path.GetPathRoot(second)?.Length; + + return firstRootLength == secondRootLength + && 0 == string.Compare( + strA: first, + indexA: 0, + strB: second, + indexB: 0, + (int)firstRootLength, + comparisonType + ); + } + + private static int GetCommonPathLength(string first, string second, bool ignoreCase) + { + int commonChars = Math.Min(first.Length, second.Length); + + int commonLength = 0; + for (int i = 0; i < commonChars; i++) + { + if ( first[i] != Path.DirectorySeparatorChar && second[i] != Path.DirectorySeparatorChar ) + continue; + + if ( 0 != string.Compare( + strA: first, + indexA: 0, + strB: second, + indexB: 0, + length: i + 1, + comparisonType: ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) ) + { + return commonLength; + } + + commonLength = i + 1; + } + + return commonLength; + } + + [DllImport( "kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode )] + private static extern uint GetFinalPathNameByHandle( IntPtr hFile, StringBuilder lpszFilePath, uint cchFilePath, uint dwFlags ); + + [DllImport( "kernel32.dll", SetLastError = true, CharSet = CharSet.Auto )] + [return: MarshalAs( UnmanagedType.Bool )] + private static extern bool CloseHandle( IntPtr hObject ); + + [DllImport( "kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode )] + private static extern IntPtr CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile ); + + + public static FileSystemInfo GetCaseSensitivePath(FileSystemInfo fileSystemInfoItem) + { + switch ( fileSystemInfoItem ) + { + case DirectoryInfo dirInfo: return GetCaseSensitivePath( dirInfo ); + case FileInfo fileInfo: return GetCaseSensitivePath( fileInfo ); + default: throw new ArgumentException("Unsupported file system info type", nameof(fileSystemInfoItem)); + } + } + + public static FileInfo GetCaseSensitivePath( FileInfo file ) + { + ( string thisFilePath, _ ) = GetCaseSensitivePath( file?.FullName, isFile: true); + return new FileInfo( thisFilePath ); + } + + public static DirectoryInfo GetCaseSensitivePath( DirectoryInfo file ) + { + ( string thisFilePath, _ ) = GetCaseSensitivePath( file?.FullName, isFile: true); + return new DirectoryInfo( thisFilePath ); + } + + public static (string, bool?) GetCaseSensitivePath(string path, bool? isFile = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); + + string formattedPath = Path.GetFullPath(FixPathFormatting(path)); + + // quick lookup + bool fileExists = File.Exists(formattedPath); + bool folderExists = Directory.Exists(formattedPath); + if (fileExists && (isFile == true || !folderExists)) return (ConvertWindowsPathToCaseSensitive(formattedPath), true); + if (folderExists && (isFile == false || !fileExists)) return (ConvertWindowsPathToCaseSensitive(formattedPath), false); + + string[] parts = formattedPath.Split(new [] {Path.DirectorySeparatorChar}, StringSplitOptions.RemoveEmptyEntries); + + // no path parts available (no separators found). Maybe it's a file/folder that exists in cur directory. + if (parts.Length == 0) + parts = new[] { formattedPath }; + + // insert the root into the list (will be / on unix, and drive name (e.g. C:\\ on windows) + string? currentPath = Path.GetPathRoot(formattedPath); + if (!string.IsNullOrEmpty(currentPath) && !Path.IsPathRooted(parts[0])) + parts = new[] { currentPath }.Concat( parts ).ToArray(); + // append directory separator to drive roots + if (parts[0].EndsWith(":")) + parts[0] += Path.DirectorySeparatorChar; + + int largestExistingPathPartsIndex = -1; + string? caseSensitiveCurrentPath = null; + for (int i = 1; i < parts.Length; i++) + { + // find the closest matching file/folder in the current path for unix, useful for duplicates. + string previousCurrentPath = Path.Combine(parts.Take(i).ToArray()); + currentPath = Path.Combine(previousCurrentPath, parts[i]); + if (Environment.OSVersion.Platform != PlatformID.Win32NT && Directory.Exists(previousCurrentPath)) + { + int maxMatchingCharacters = -1; + string closestMatch = parts[i]; + + foreach ( + FileSystemInfo folderOrFileInfo + in new DirectoryInfo( previousCurrentPath ) + .EnumerateFileSystemInfosSafely( searchPattern: "*", SearchOption.TopDirectoryOnly ) + ) + { + if (folderOrFileInfo is null || !folderOrFileInfo.Exists) + continue; + + int matchingCharacters = GetMatchingCharactersCount(folderOrFileInfo.Name, parts[i]); + if ( matchingCharacters > maxMatchingCharacters ) + { + maxMatchingCharacters = matchingCharacters; + closestMatch = folderOrFileInfo.Name; + if ( i == parts.Length ) + isFile = folderOrFileInfo is FileInfo; + } + } + + parts[i] = closestMatch; + } + // resolve case-sensitive pathing. largestExistingPathPartsIndex determines the largest index of the existing path parts. + // todo: check if it's the last part of the path, then conditionally call directory.exists OR file.exists based on isFile. + else if ( string.IsNullOrEmpty(caseSensitiveCurrentPath) + && !File.Exists(currentPath) + && !Directory.Exists(currentPath) ) + { + // Get the case-sensitive path based on the existing parts we've determined. + largestExistingPathPartsIndex = i; + caseSensitiveCurrentPath = ConvertWindowsPathToCaseSensitive(previousCurrentPath); + } + } + + if ( caseSensitiveCurrentPath is null ) + return ( Path.Combine( parts ), isFile ); + + string combinedPath = largestExistingPathPartsIndex > -1 + ? Path.Combine( + caseSensitiveCurrentPath, + Path.Combine( parts.Skip( largestExistingPathPartsIndex ).ToArray() ) + ) + : Path.Combine( parts ); + + return ( combinedPath, isFile ); + } + + private static int GetMatchingCharactersCount(string str1, string str2) + { + if (string.IsNullOrEmpty(str1)) + throw new ArgumentException("Value cannot be null or empty.", nameof(str1)); + if (string.IsNullOrEmpty(str2)) + throw new ArgumentException("Value cannot be null or empty.", nameof(str2)); + + int matchingCount = 0; + for (int i = 0; i < str1.Length && i < str2.Length; i++) + { + // don't consider a match if any char in the paths are not case-insensitive matches. + if (char.ToLowerInvariant(str1[i]) != char.ToLowerInvariant(str2[i])) + return -1; + + // increment matching count if case-sensitive match at this char index succeeds + if (str1[i] == str2[i]) + matchingCount++; + } + + return matchingCount; + } + + + public static async Task MoveFileAsync( string sourcePath, string destinationPath ) + { + if ( sourcePath is null ) + throw new ArgumentNullException( nameof( sourcePath ) ); + if ( destinationPath is null ) + throw new ArgumentNullException( nameof( destinationPath ) ); + + await using ( var sourceStream = new FileStream( + sourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true + ) ) + { + await using ( var destinationStream = new FileStream( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true + ) ) + { + await sourceStream.CopyToAsync( destinationStream ); + } + } + + // The file is closed at this point, so it can be safely deleted + await Task.Run( () => File.Delete( sourcePath ) ); + } + + public static List EnumerateFilesWithWildcards( + IEnumerable filesAndFolders, + bool includeSubFolders = true + ) + { + if ( filesAndFolders is null ) + throw new ArgumentNullException( nameof( filesAndFolders ) ); + + var result = new List(); + var uniquePaths = new HashSet( filesAndFolders ); + + foreach ( string path in uniquePaths ) + { + if ( string.IsNullOrEmpty( path ) ) + continue; + + try + { + string formattedPath = FixPathFormatting( path ); + + // ReSharper disable once AssignNullToNotNullAttribute + if ( !ContainsWildcards( formattedPath ) ) + { + // Handle non-wildcard paths + if ( File.Exists( formattedPath ) ) + result.Add( formattedPath ); + + continue; + } + + // Handle wildcard paths + // + // determine the closest parent folder in hierarchy that doesn't have wildcards + // then wildcard match them all by hierarchy level. + string currentDir = formattedPath; + while ( ContainsWildcards( currentDir ) ) + { + string? parentDirectory = Path.GetDirectoryName( currentDir ); + + // Exit the loop if no parent directory is found or if the parent directory is the same as the current directory + if ( string.IsNullOrEmpty( parentDirectory ) || parentDirectory == currentDir ) + break; + + currentDir = parentDirectory; + } + + if ( !Directory.Exists( currentDir ) ) + continue; + + var currentDirInfo = new DirectoryInfo( currentDir ); + + IEnumerable checkFiles = currentDirInfo.EnumerateFilesSafely( + searchPattern: "*", + includeSubFolders + ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly + ); + + result.AddRange( + from thisFile in checkFiles + where thisFile != null + && WildcardPathMatch( thisFile.FullName, formattedPath ) + select thisFile.FullName + ); + + if ( Environment.OSVersion.Platform != PlatformID.Win32NT ) + { + // Handle non-Windows platforms + IEnumerable duplicates = FindCaseInsensitiveDuplicates( + currentDir, + includeSubFolders: true, + isFile: false + ); + + foreach ( FileSystemInfo thisDuplicateFolder in duplicates ) + { + // Get all files in the parent directory. + if ( !(thisDuplicateFolder is DirectoryInfo dirInfo) ) + throw new NullReferenceException(nameof( dirInfo )); + + checkFiles = dirInfo.EnumerateFilesSafely( + searchPattern: "*", + includeSubFolders + ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly + ); + + result.AddRange( + from thisFile in checkFiles + where thisFile != null + && WildcardPathMatch( thisFile.FullName, formattedPath ) + select thisFile.FullName + ); + } + } + } + catch ( Exception ex ) + { + // Handle or log the exception as required + Console.WriteLine( $"An error occurred while processing path '{path}': {ex.Message}" ); + } + } + + return result; + } + + + private static bool ContainsWildcards( string path ) => path.Contains( '*' ) || path.Contains( '?' ); + + + public static bool WildcardPathMatch( string input, string patternInput ) + { + if ( input is null ) + throw new ArgumentNullException( nameof( input ) ); + if ( patternInput is null ) + throw new ArgumentNullException( nameof( patternInput ) ); + + // Fix path formatting + input = FixPathFormatting( input ); + patternInput = FixPathFormatting( patternInput ); + + // Split the input and patternInput into directory levels + string[] inputLevels = input.Split( Path.DirectorySeparatorChar ); + string[] patternLevels = patternInput.Split( Path.DirectorySeparatorChar ); + + // Ensure the number of levels match + if ( inputLevels.Length != patternLevels.Length ) + return false; + + // Iterate over each level and perform wildcard matching + for ( int i = 0; i < inputLevels.Length; i++ ) + { + string inputLevel = inputLevels[i]; + string patternLevel = patternLevels[i]; + + if ( patternLevel is "*" ) + continue; + + // Check if the current level matches the pattern + if ( !WildcardMatch( inputLevel, patternLevel ) ) + return false; + } + + return true; + } + + // Most end users don't know Regex, this function will convert basic wildcards to regex patterns. + private static bool WildcardMatch( string input, string patternInput ) + { + if ( input is null ) + throw new ArgumentNullException( nameof( input ) ); + if ( patternInput is null ) + throw new ArgumentNullException( nameof( patternInput ) ); + + // Escape special characters in the pattern + patternInput = Regex.Escape( patternInput ); + + // Replace * with .* and ? with . in the pattern + patternInput = patternInput + .Replace( oldValue: @"\*", newValue: ".*" ) + .Replace( oldValue: @"\?", newValue: "." ); + + // Use regex to perform the wildcard matching + return Regex.IsMatch( input, $"^{patternInput}$" ); + } + + + public static string FixPathFormatting( string path ) + { + if ( path is null ) + throw new ArgumentNullException( nameof( path ) ); + + if ( string.IsNullOrWhiteSpace( path ) ) + return path; + + // Replace all slashes with the operating system's path separator + string formattedPath = path + .Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ) + .Replace( oldChar: '\\', Path.DirectorySeparatorChar ) + .Replace( oldChar: '/', Path.DirectorySeparatorChar ); + + // Fix repeated slashes + formattedPath = Regex.Replace( + formattedPath, + $"(? 1 ) + formattedPath = formattedPath.TrimEnd( Path.DirectorySeparatorChar ); + + return formattedPath; + } + + public static IEnumerable FindCaseInsensitiveDuplicates( DirectoryInfo dirInfo, bool includeSubFolders = true ) + { + // ReSharper disable once AssignNullToNotNullAttribute - no point duplicating the null check + return FindCaseInsensitiveDuplicates( dirInfo?.FullName, includeSubFolders, isFile: false ); + } + + public static IEnumerable FindCaseInsensitiveDuplicates( FileInfo fileInfo ) + { + // ReSharper disable once AssignNullToNotNullAttribute - no point duplicating the null check + return FindCaseInsensitiveDuplicates( fileInfo?.FullName, isFile: true ); + } + + // Finds all duplicates of a path + public static IEnumerable FindCaseInsensitiveDuplicates( string path, bool includeSubFolders = true, bool? isFile = null ) + { + if ( path is null ) + throw new ArgumentNullException( nameof( path ) ); + + string formattedPath = FixPathFormatting( path ); + if ( !PathValidator.IsValidPath( formattedPath ) ) + throw new ArgumentException( $"'{path}' is not a valid path string" ); + + if ( Environment.OSVersion.Platform == PlatformID.Win32NT ) + yield break; + + // determine if path is a folder or a file. + // validate the arg's case-insensitive existence here. + DirectoryInfo? dirInfo = null; + string fileName = Path.GetFileName( formattedPath ); + switch ( isFile ) + { + case false: + { + dirInfo = new DirectoryInfo( formattedPath ); + if ( !dirInfo.Exists ) + { + dirInfo = new DirectoryInfo( + GetCaseSensitivePath( formattedPath ).Item1 + ); + } + + break; + } + case true: + { + string? parentDir = Path.GetDirectoryName( formattedPath ); + if ( !string.IsNullOrEmpty(parentDir) && !( dirInfo = new DirectoryInfo( parentDir ) ).Exists ) + { + dirInfo = new DirectoryInfo( + GetCaseSensitivePath( parentDir ).Item1 + ); + } + + break; + } + default: + { + dirInfo = new DirectoryInfo( formattedPath ); + string caseSensitivePath = formattedPath; + if ( !dirInfo.Exists ) + { + caseSensitivePath = GetCaseSensitivePath( formattedPath ).Item1; + dirInfo = new DirectoryInfo( caseSensitivePath ); + } + + if ( !dirInfo.Exists ) + { + string folderPath = Path.GetDirectoryName( caseSensitivePath ); + isFile = true; + if ( !( folderPath is null ) ) + dirInfo = new DirectoryInfo( folderPath ); + } + + break; + } + } + + if ( !dirInfo?.Exists ?? false ) + throw new ArgumentException( $"Path item doesn't exist on disk: '{formattedPath}'" ); + + // build duplicate files/folders list + var fileList = new Dictionary>( StringComparer.OrdinalIgnoreCase ); + var folderList = new Dictionary>( StringComparer.OrdinalIgnoreCase ); + foreach ( FileInfo file in dirInfo.GetFilesSafely() ) + { + if ( !file.Exists ) + continue; + if (isFile == true && !file.Name.Equals( fileName, StringComparison.OrdinalIgnoreCase )) + continue; + + string filePath = file.FullName.ToLowerInvariant(); + if ( !fileList.TryGetValue( filePath, out List? files ) ) + { + files = new List(); + fileList.Add( filePath, files ); + } + files.Add( file ); + } + + foreach ( List files in fileList.Values ) + { + if ( files.Count <= 1 ) + continue; + + foreach ( FileSystemInfo duplicate in files ) + { + yield return duplicate; + } + } + + // don't iterate folders in the parent folder if original path is a file. + if ( isFile == true ) + yield break; + + foreach ( DirectoryInfo subDirectory in dirInfo.EnumerateDirectoriesSafely() ) + { + if ( !subDirectory.Exists ) + continue; + + if ( !folderList.TryGetValue( + subDirectory.FullName.ToLowerInvariant(), + out List? folders + ) ) + { + folders = new List(); + folderList.Add( subDirectory.FullName.ToLowerInvariant(), folders ); + } + + folders.Add( subDirectory ); + + if ( includeSubFolders ) + { + foreach ( FileSystemInfo duplicate in FindCaseInsensitiveDuplicates( subDirectory ) ) + { + yield return duplicate; + } + } + } + + foreach ( List foldersInCurrentDir in folderList.Values ) + { + if ( foldersInCurrentDir.Count <= 1 ) + continue; + + foreach ( FileSystemInfo duplicate in foldersInCurrentDir ) + { + yield return duplicate; + } + } + } + } +} diff --git a/KotorDotNET/FileSystemPathing/PathValidator.cs b/KotorDotNET/FileSystemPathing/PathValidator.cs new file mode 100644 index 0000000..21a10e0 --- /dev/null +++ b/KotorDotNET/FileSystemPathing/PathValidator.cs @@ -0,0 +1,174 @@ + +using System.Runtime.InteropServices; + +namespace KotorDotNET.FileSystemPathing +{ + public static class PathValidator + { + // Characters not allowed in Windows file and directory names + // we don't check colon or any slashes here, because we aren't validating file/folder names, only a full path string. + private static readonly char[] s_invalidPathCharsWindows = { + '\0', '\a', '\b', '\t', '\n', '\v', '\f', '\r', '!', '"', '$', '%', '&', '*', '+', + '<', '=', '>', '?', '@', '{', '}', '`', ',', '^', + }; + + + // Characters not allowed in Unix file and directory names + private static readonly char[] s_invalidPathCharsUnix = { + '\0', + }; + + // Reserved file names in Windows + private static readonly string[] s_reservedFileNamesWindows = { + "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + // Checks if the path is valid on running platform, or optionally (default) enforce for all platforms. + public static bool IsValidPath( + string? path, + bool enforceAllPlatforms = true, + bool ignoreWildcards = false + ) + { + try + { + if ( string.IsNullOrWhiteSpace( path ) ) + return false; + + if ( HasMixedSlashes( path ) ) + return false; + + if ( HasRepeatedSlashes( path ) ) + return false; + + // Check for forbidden os-specific ASCII characters + char[] invalidChars = enforceAllPlatforms + ? s_invalidPathCharsWindows // already contains the unix ones + : GetInvalidCharsForPlatform(); + + // should we ignore wildcards? + invalidChars = ignoreWildcards + ? invalidChars.Where( c => c != '*' && c != '?' ).ToArray() + : invalidChars; + + if ( path.IndexOfAny( invalidChars ) >= 0 ) + return false; + + // Check for non-printable characters + if ( ContainsNonPrintableChars( path ) ) + return false; + + // Check for reserved file names in Windows + // ReSharper disable once InvertIf + if ( enforceAllPlatforms || RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) + { + if ( HasColonOutsideOfPathRoot( path ) ) + return false; + + if ( IsReservedFileNameWindows( path ) ) + return false; + + // Check for invalid filename parts + // ReSharper disable once ConvertIfStatementToReturnStatement + if ( HasInvalidWindowsFileNameParts( path ) ) + return false; + } + + return true; + } + catch ( Exception e ) + { + Console.WriteLine( e ); + return false; + } + } + + + public static bool HasColonOutsideOfPathRoot( string? path ) + { + if ( string.IsNullOrWhiteSpace(path) ) return false; + + string[] parts = path.Split( '/', '\\' ); + for (int i = 1; i < parts.Length; i++) + { + if ( !parts[i].Contains( ":" ) ) + continue; + + return true; // Found a colon in a non-root part + } + + return false; + } + + + public static bool HasRepeatedSlashes( string? input ) + { + if ( string.IsNullOrWhiteSpace(input) ) return false; + + for (int i = 0; i < input.Length - 1; i++) + { + if ( (input[i] == '\\' || input[i] == '/') && (input[i+1] == '\\' || input[i+1] == '/') ) + return true; + } + return false; + } + + + public static char[] GetInvalidCharsForPlatform() => + Environment.OSVersion.Platform == PlatformID.Unix + ? s_invalidPathCharsUnix + : s_invalidPathCharsWindows; + + + public static bool HasMixedSlashes( string? input ) => ( input?.Contains('/') ?? false ) && input.Contains('\\'); + + + // This method checks whether any character's ascii code in the path is less than a space (ASCII code 32). + public static bool ContainsNonPrintableChars( string? path ) => path?.Any( c => c < ' ' ) ?? false; + + + public static bool IsReservedFileNameWindows( string? path ) + { + if ( string.IsNullOrWhiteSpace(path) ) return false; + + string[] pathParts = path.Split( new[] { '\\', '/', Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries ); + + return pathParts + .Select( Path.GetFileNameWithoutExtension ) + .Any( fileName => + s_reservedFileNamesWindows.Any( + reservedName => string.Equals( + reservedName, + fileName, + StringComparison.OrdinalIgnoreCase + ) + ) + ); + } + + + public static bool HasInvalidWindowsFileNameParts( string? path ) + { + if ( string.IsNullOrEmpty(path) ) return false; + + string[] pathParts = path.Split( new[] { '\\', '/', Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries ); + foreach ( string part in pathParts ) + { + // Check for a filename ending with a period or space + if ( part.EndsWith( " " ) || part.EndsWith( "." ) ) + return true; + + // Check for consecutive periods in the filename + for ( int i = 0; i < part.Length - 1; i++ ) + { + if ( part[i] == '.' && part[i + 1] == '.' ) + return true; + } + } + + return false; + } + } +} diff --git a/KotorDotNET/Utility/PathHelper.cs b/KotorDotNET/Utility/PathHelper.cs deleted file mode 100644 index 772de08..0000000 --- a/KotorDotNET/Utility/PathHelper.cs +++ /dev/null @@ -1,734 +0,0 @@ -// Copyright 2021-2023 KOTORModSync -// Licensed under the GNU General Public License v3.0 (GPLv3). -// See LICENSE.txt file in the project root for full license information. - -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; - -namespace KotorDotNET.Utility -{ - public static class PathValidator - { - // Characters not allowed in Windows file and directory names - // we don't check colon or any slashes because we aren't validating file/folder names, only a full path string. - private static readonly char[] s_invalidPathCharsWindows = { - '<', '>', '"', '|', '?', '*', - '\0', '\n', '\r', '\t', '\b', '\a', '\v', '\f', - }; - - // Characters not allowed in Unix file and directory names - private static readonly char[] s_invalidPathCharsUnix = { - '\0', - }; - - // Reserved file names in Windows - private static readonly string[] s_reservedFileNamesWindows = { - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - }; - - // Checks if the path is valid on running platform, or optionally (default) enforce for all platforms. - public static bool IsValidPath( string path, bool enforceAllPlatforms=true) - { - if ( string.IsNullOrWhiteSpace( path ) ) - return false; - if ( path == string.Empty ) - return false; - - try - { - // Check for forbidden printable ASCII characters - char[] invalidChars = enforceAllPlatforms - ? s_invalidPathCharsWindows // already contains the unix ones - : GetInvalidCharsForPlatform(); - - if ( path.IndexOfAny( invalidChars ) >= 0 ) - return false; - - // Check for non-printable characters - if ( ContainsNonPrintableChars(path) ) - return false; - - // Check for reserved file names in Windows - if ( enforceAllPlatforms || RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) - { - if ( IsReservedFileNameWindows(path) ) - return false; - - // Check for invalid filename parts - // ReSharper disable once ConvertIfStatementToReturnStatement - if ( HasInvalidWindowsFileNameParts(path) ) - return false; - } - - // double-check - try - { - var _ = new FileInfo(path); - var __ = new DirectoryInfo(path); - return true; - } - catch (ArgumentException) - { - return false; - } - catch (Exception e) - { - Console.WriteLine(e.Message); - return false; - } - } - catch ( Exception e ) - { - Console.WriteLine( e ); - return false; - } - } - - private static char[] GetInvalidCharsForPlatform() - { - return Environment.OSVersion.Platform == PlatformID.Unix - ? s_invalidPathCharsUnix - : s_invalidPathCharsWindows; - } - - private static bool ContainsNonPrintableChars( string path) => path?.Any( c => c < ' ' && c != '\t' ) ?? false; - private static bool IsReservedFileNameWindows(string path) - { - string fileName = Path.GetFileNameWithoutExtension(path); - - // Check if any reserved filename matches the filename (case-insensitive) - return s_reservedFileNamesWindows.Any(reservedName => string.Equals(reservedName, fileName, StringComparison.OrdinalIgnoreCase)); - } - - private static bool HasInvalidWindowsFileNameParts(string path) - { - string fileName = Path.GetFileNameWithoutExtension(path); - - // Check for a filename ending with a period or space - if (fileName.EndsWith(" ") || fileName.EndsWith(".")) - return true; - - // Check for consecutive periods in the filename - for (int i = 0; i < fileName.Length - 1; i++) - { - if (fileName[i] == '.' && fileName[i + 1] == '.') - return true; - } - - return false; - } - } - - public static class PathHelper - { - // if it's a folder, return path as is, if it's a file get the parent dir. - - public static string GetFolderName( string filePath ) - { - return Path.HasExtension( filePath ) - ? Path.GetDirectoryName( filePath ) - : filePath; - } - - - public static DirectoryInfo TryGetValidDirectoryInfo( string folderPath) - { - string formattedPath = FixPathFormatting(folderPath); - if ( formattedPath is null || PathValidator.IsValidPath(formattedPath) ) - return null; - - try - { - return new DirectoryInfo(formattedPath); - } - catch (Exception) - { - // In .NET Framework 4.6.2 and earlier, the DirectoryInfo constructor throws an exception - // when the path is invalid. We catch the exception and return null instead for a unified experience. - return null; - } - } - - - public static FileInfo TryGetValidFileInfo( string filePath) - { - string formattedPath = FixPathFormatting(filePath); - if ( PathValidator.IsValidPath(formattedPath) ) - return null; - - try - { - return new FileInfo(formattedPath); - } - catch (Exception) - { - // In .NET Framework 4.6.2 and earlier, the FileInfo constructor throws an exception - // when the path is invalid. We catch the exception and return null instead for a unified experience. - return null; - } - } - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern int GetLongPathName(string shortPath, StringBuilder longPath, int bufferSize); - - public static string ConvertWindowsPathToCaseSensitive(string path) - { - if (Environment.OSVersion.Platform != PlatformID.Win32NT) - return path; - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); - if (!PathValidator.IsValidPath(path)) - throw new ArgumentException($"{path} is not a valid path!"); - - - const uint FILE_SHARE_READ = 1; - const uint OPEN_EXISTING = 3; - const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; - const uint VOLUME_NAME_DOS = 0; - - IntPtr handle = CreateFile( - path, - 0, - FILE_SHARE_READ, - IntPtr.Zero, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - IntPtr.Zero); - - if (handle == IntPtr.Zero) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - - try - { - var buffer = new StringBuilder(4096); - uint result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS); - - if (result == 0) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - - // The result may be prefixed with "\\?\" - string finalPath = buffer.ToString(); - const string prefix = @"\\?\"; - if (finalPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - finalPath = finalPath[prefix.Length..]; - } - - return finalPath; - } - finally - { - _ = CloseHandle( handle ); - } - } - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] - public static extern uint GetFinalPathNameByHandle(IntPtr hFile, StringBuilder lpszFilePath, uint cchFilePath, uint dwFlags); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool CloseHandle(IntPtr hObject); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] - public static extern IntPtr CreateFile( - string lpFileName, - uint dwDesiredAccess, - uint dwShareMode, - IntPtr lpSecurityAttributes, - uint dwCreationDisposition, - uint dwFlagsAndAttributes, - IntPtr hTemplateFile); - - public static string GetCaseSensitivePath( FileInfo file ) => GetCaseSensitivePath( file?.FullName ); - public static string GetCaseSensitivePath( DirectoryInfo directory ) => GetCaseSensitivePath( directory?.FullName ); - - public static string GetCaseSensitivePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); - if (!PathValidator.IsValidPath(path)) - throw new ArgumentException($"{path} is not a valid path!"); - - string formattedPath = FixPathFormatting(Path.GetFullPath(path)); - if (File.Exists(formattedPath) || Directory.Exists(formattedPath)) - return ConvertWindowsPathToCaseSensitive(formattedPath); - - var parts = formattedPath.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries).ToList(); - - string currentPath = Path.GetPathRoot( formattedPath ); - if ( currentPath != parts[0] ) - { - parts.Insert(index: 0, currentPath); - } - - int largestExistingPathPartsIndex = -1; - string caseSensitiveCurrentPath = string.Empty; - for (int i = 1; i < parts.Count; i++) - { - string parentDir = i == 1 - ? parts[0] - : Path.Combine(parts.Take(i).ToArray()); - - if ( Directory.Exists( parentDir ) ) - { - foreach ( string childFolder in Directory.EnumerateFileSystemEntries(parentDir) ) - { - string childFolderActual = Path.GetFileName(childFolder); - if ( !childFolderActual.Equals( parts[i], StringComparison.OrdinalIgnoreCase ) ) - continue; - - parts[i] = childFolderActual; - break; - } - } - - currentPath = Path.Combine(currentPath, parts[i]); - - if ( !File.Exists( currentPath ) - && !Directory.Exists( currentPath ) - && string.IsNullOrEmpty( caseSensitiveCurrentPath ) ) - { - // Get the case-sensitive path based on the existing parts we've determined. - largestExistingPathPartsIndex = i-1; - string currentExistingPath = Path.Combine(parts.Take(largestExistingPathPartsIndex).ToArray()); - caseSensitiveCurrentPath = ConvertWindowsPathToCaseSensitive(currentExistingPath); - } - - } - - if (largestExistingPathPartsIndex > -1) - { - return Path.Combine( - caseSensitiveCurrentPath, - Path.Combine(parts.Skip(largestExistingPathPartsIndex).ToArray()) - ); - } - - return Path.Combine( parts.ToArray() ); - } - - - public static async Task MoveFileAsync( string sourcePath, string destinationPath ) - { - if ( sourcePath is null ) - throw new ArgumentNullException( nameof( sourcePath ) ); - if ( destinationPath is null ) - throw new ArgumentNullException( nameof( destinationPath ) ); - - using ( var sourceStream = new FileStream( - sourcePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 4096, - useAsync: true - ) ) - { - using ( var destinationStream = new FileStream( - destinationPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 4096, - useAsync: true - ) ) - { - await sourceStream.CopyToAsync( destinationStream ); - } - } - - // The file is closed at this point, so it can be safely deleted - File.Delete( sourcePath ); - } - - public static List EnumerateFilesWithWildcards( - IEnumerable filesAndFolders, - bool includeSubFolders = true - ) - { - if ( filesAndFolders is null ) - throw new ArgumentNullException( nameof( filesAndFolders ) ); - - var result = new List(); - var uniquePaths = new HashSet( filesAndFolders ); - - foreach (string path in uniquePaths) - { - if (string.IsNullOrEmpty(path)) - continue; - - try - { - string formattedPath = FixPathFormatting(path); - if ( path.IndexOfAny( Path.GetInvalidPathChars() ) >= 0 && path.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 ) - throw new ArgumentException($"Not a valid path: '{path}'"); - - // ReSharper disable once AssignNullToNotNullAttribute - if (!ContainsWildcards(formattedPath)) - { - // Handle non-wildcard paths - if (File.Exists(formattedPath)) - { - result.Add(formattedPath); - } - else if (Directory.Exists(formattedPath)) - { - IEnumerable matchingFiles = Directory.EnumerateFiles( - formattedPath, - searchPattern: "*", - includeSubFolders - ? SearchOption.AllDirectories - : SearchOption.TopDirectoryOnly - ); - - result.AddRange(matchingFiles); - } - - continue; - } - - // Handle simple wildcard paths - if (PathValidator.IsValidPath(formattedPath)) - { - string parentDir = Path.GetDirectoryName(formattedPath); - if ( !(parentDir is null) && Directory.Exists(parentDir) ) - { - IEnumerable matchingFiles = Directory.EnumerateFiles( - parentDir, - Path.GetFileName(formattedPath), - includeSubFolders - ? SearchOption.AllDirectories - : SearchOption.TopDirectoryOnly - ); - - result.AddRange(matchingFiles); - continue; - } - } - - // Handle wildcard paths - // - // determine the closest parent folder in hierarchy that doesn't have wildcards - // then wildcard match them all by hierarchy level. - string currentDir = formattedPath; - while (ContainsWildcards(currentDir)) - { - string? parentDirectory = Path.GetDirectoryName(currentDir); - - // Exit the loop if no parent directory is found or if the parent directory is the same as the current directory - if (string.IsNullOrEmpty(parentDirectory) || parentDirectory == currentDir) - break; - - currentDir = parentDirectory; - } - - if (!Directory.Exists(currentDir)) - continue; - - // Get all files in the parent directory. - IEnumerable checkFiles = Directory.EnumerateFiles( - currentDir, - searchPattern: "*", - includeSubFolders - ? SearchOption.AllDirectories - : SearchOption.TopDirectoryOnly - ); - - // wildcard match them all with WildcardPatchMatch and add to result - result.AddRange(checkFiles.Where(thisFile => WildcardPathMatch(thisFile, formattedPath))); - } - catch (Exception ex) - { - // Handle or log the exception as required - Console.WriteLine($"An error occurred while processing path '{path}': {ex.Message}"); - } - } - - return result; - } - - - public static bool ContainsWildcards( [NotNull] string path ) => path.Contains( '*' ) || path.Contains( '?' ); - - - public static bool WildcardPathMatch( string input, string patternInput ) - { - if ( input is null ) - throw new ArgumentNullException( nameof( input ) ); - if ( patternInput is null ) - throw new ArgumentNullException( nameof( patternInput ) ); - - // Fix path formatting - input = FixPathFormatting( input ); - patternInput = FixPathFormatting( patternInput ); - - // Split the input and patternInput into directory levels - string[] inputLevels = input.Split( Path.DirectorySeparatorChar ); - string[] patternLevels = patternInput.Split( Path.DirectorySeparatorChar ); - - // Ensure the number of levels match - if ( inputLevels.Length != patternLevels.Length ) - return false; - - // Iterate over each level and perform wildcard matching - for ( int i = 0; i < inputLevels?.Length; i++ ) - { - string inputLevel = inputLevels[i]; - string patternLevel = patternLevels[i]; - - if (patternLevel is "*") - continue; - - // Check if the current level matches the pattern - if ( !WildcardMatch( inputLevel, patternLevel ) ) - return false; - } - - return true; - } - - // Most end users don't know Regex, this function will convert basic wildcards to regex patterns. - public static bool WildcardMatch( string input, string patternInput ) - { - if ( input is null ) - throw new ArgumentNullException( nameof( input ) ); - if ( patternInput is null ) - throw new ArgumentNullException( nameof( patternInput ) ); - - // Escape special characters in the pattern - patternInput = Regex.Escape( patternInput ); - - // Replace * with .* and ? with . in the pattern - patternInput = patternInput - .Replace( oldValue: @"\*", newValue: ".*" ) - .Replace( oldValue: @"\?", newValue: "." ); - - // Use regex to perform the wildcard matching - return Regex.IsMatch( input, $"^{patternInput}$" ); - } - - - - public static string FixPathFormatting( string path ) - { - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - // Replace all slashes with the operating system's path separator - string formattedPath = path - .Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ) - .Replace( oldChar: '\\', Path.DirectorySeparatorChar ) - .Replace( oldChar: '/', Path.DirectorySeparatorChar ); - - // Fix repeated slashes - formattedPath = Regex.Replace( - formattedPath, - $"(? FindCaseInsensitiveDuplicates(DirectoryInfo dirInfo, bool includeSubFolders=true) - { - return FindCaseInsensitiveDuplicates(dirInfo?.FullName, includeSubFolders, isFile: false); - } - - public static IEnumerable FindCaseInsensitiveDuplicates(FileInfo fileInfo) - { - return FindCaseInsensitiveDuplicates(fileInfo?.FullName, isFile: true); - } - - // Finds all duplicate items in a path. - public static IEnumerable FindCaseInsensitiveDuplicates( [NotNull] string path, bool includeSubFolders=true, bool? isFile=null) - { - if ( path is null ) - throw new ArgumentNullException( nameof( path ) ); - - string formattedPath = FixPathFormatting(path); - if (!PathValidator.IsValidPath(formattedPath)) - throw new ArgumentException( $"'{path}' is not a valid path string" ); - - // determine if path is a folder or a file. - DirectoryInfo dirInfo; - if (isFile == false) - { - dirInfo = new DirectoryInfo( formattedPath ); - } - else if (isFile == true) - { - dirInfo = new DirectoryInfo(Path.GetDirectoryName(formattedPath)); - } - else - { - dirInfo = new DirectoryInfo( formattedPath ); - if (!dirInfo.Exists) - { - string folderPath = Path.GetDirectoryName(formattedPath); - isFile = true; - if ( !(folderPath is null ) ) - dirInfo = new DirectoryInfo(folderPath); - } - } - - if (!dirInfo.Exists) - throw new ArgumentException($"Path item doesn't exist on disk: '{formattedPath}'"); - - // build duplicate files/folders list - var fileList = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var folderList = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (FileInfo file in dirInfo.GetFiles()) - { - if (!file.Exists) - continue; - - string filePath = file.FullName.ToLowerInvariant(); - if (!fileList.TryGetValue(filePath, out List files)) - { - files = new List(); - fileList.Add(filePath, files); - } - files.Add(file); - } - - foreach (KeyValuePair> fileListEntry in fileList) - { - List files = fileListEntry.Value; - if (files.Count <= 1) - continue; - - foreach (FileSystemInfo duplicate in files) - { - yield return duplicate; - } - } - - // don't iterate folders in the parent folder if original path is a file. - if (isFile == true) - yield break; - - foreach ( DirectoryInfo subDirectory in dirInfo.GetDirectories() ) - { - if ( !subDirectory.Exists ) - continue; - - if ( !folderList.TryGetValue( - subDirectory.FullName.ToLowerInvariant(), - out List folders - ) ) - { - folders = new List(); - folderList.Add( subDirectory.FullName.ToLowerInvariant(), folders ); - } - - folders.Add( subDirectory ); - - if ( includeSubFolders ) - { - foreach ( FileSystemInfo duplicate in FindCaseInsensitiveDuplicates( subDirectory ) ) - { - yield return duplicate; - } - } - } - - foreach (KeyValuePair> folderListEntry in folderList) - { - List foldersInCurrentDir = folderListEntry.Value; - if (foldersInCurrentDir.Count <= 1) - continue; - - foreach (FileSystemInfo duplicate in foldersInCurrentDir) - { - yield return duplicate; - } - } - } - - public static (FileSystemInfo, List) GetClosestMatchingEntry( string path ) - { - if ( !PathValidator.IsValidPath( path ) ) - throw new ArgumentException( nameof( path ) + " is not a valid path string" ); - - path = FixPathFormatting(path); - - string directoryName = Path.GetDirectoryName( path ); - if ( string.IsNullOrEmpty(directoryName) ) - { - return ( null, new List() ); - } - - string searchPattern = Path.GetFileName( path ); - - FileSystemInfo closestMatch = null; - int maxMatchingCharacters = -1; - var duplicatePaths = new List(); - - var directory = new DirectoryInfo( directoryName ); - foreach (FileSystemInfo entry in directory.EnumerateFileSystemInfos(searchPattern, SearchOption.TopDirectoryOnly)) - { - if (string.IsNullOrWhiteSpace(entry?.FullName)) - continue; - - int matchingCharacters = GetMatchingCharactersCount(entry.FullName, path); - if (matchingCharacters > maxMatchingCharacters) - { - if (closestMatch != null) - { - duplicatePaths.Add(closestMatch.FullName); - } - - closestMatch = entry; - maxMatchingCharacters = matchingCharacters; - } - else if (matchingCharacters != 0) - { - duplicatePaths.Add(entry.FullName); - } - } - - return ( closestMatch, duplicatePaths ); - } - - private static int GetMatchingCharactersCount( string str1, string str2 ) - { - if ( string.IsNullOrEmpty( str1 ) ) - throw new ArgumentException( message: "Value cannot be null or empty.", nameof( str1 ) ); - if ( string.IsNullOrEmpty( str2 ) ) - throw new ArgumentException( message: "Value cannot be null or empty.", nameof( str2 ) ); - - int matchingCount = 0; - for ( - int i = 0; - i < str1.Length && i < str2.Length; - i++ - ) - { - // don't consider a match if any char in the paths are not case-insensitive matches. - if (char.ToLowerInvariant(str1[i]) != char.ToLowerInvariant(str2[i])) - return 0; - - // increment matching count if case-sensitive match at this char index succeeds - if ( str1[i] == str2[i] ) - matchingCount++; - } - - return matchingCount; - } - } -} From 3eafa7c2ed431a3ce4c83346a319ed1e7722270c Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Thu, 7 Sep 2023 12:48:21 -0500 Subject: [PATCH 08/11] Update PathHelper.cs --- KotorDotNET/FileSystemPathing/PathHelper.cs | 216 ++++++++++---------- 1 file changed, 111 insertions(+), 105 deletions(-) diff --git a/KotorDotNET/FileSystemPathing/PathHelper.cs b/KotorDotNET/FileSystemPathing/PathHelper.cs index 37d9419..b2b935d 100644 --- a/KotorDotNET/FileSystemPathing/PathHelper.cs +++ b/KotorDotNET/FileSystemPathing/PathHelper.cs @@ -263,113 +263,119 @@ public static DirectoryInfo GetCaseSensitivePath( DirectoryInfo file ) return new DirectoryInfo( thisFilePath ); } - public static (string, bool?) GetCaseSensitivePath(string path, bool? isFile = null) - { - if (string.IsNullOrWhiteSpace(path)) - throw new ArgumentException($"'{nameof(path)}' cannot be null or whitespace.", nameof(path)); - - string formattedPath = Path.GetFullPath(FixPathFormatting(path)); - - // quick lookup - bool fileExists = File.Exists(formattedPath); - bool folderExists = Directory.Exists(formattedPath); - if (fileExists && (isFile == true || !folderExists)) return (ConvertWindowsPathToCaseSensitive(formattedPath), true); - if (folderExists && (isFile == false || !fileExists)) return (ConvertWindowsPathToCaseSensitive(formattedPath), false); - - string[] parts = formattedPath.Split(new [] {Path.DirectorySeparatorChar}, StringSplitOptions.RemoveEmptyEntries); - - // no path parts available (no separators found). Maybe it's a file/folder that exists in cur directory. - if (parts.Length == 0) - parts = new[] { formattedPath }; - - // insert the root into the list (will be / on unix, and drive name (e.g. C:\\ on windows) - string? currentPath = Path.GetPathRoot(formattedPath); - if (!string.IsNullOrEmpty(currentPath) && !Path.IsPathRooted(parts[0])) - parts = new[] { currentPath }.Concat( parts ).ToArray(); - // append directory separator to drive roots - if (parts[0].EndsWith(":")) - parts[0] += Path.DirectorySeparatorChar; - - int largestExistingPathPartsIndex = -1; - string? caseSensitiveCurrentPath = null; - for (int i = 1; i < parts.Length; i++) - { - // find the closest matching file/folder in the current path for unix, useful for duplicates. - string previousCurrentPath = Path.Combine(parts.Take(i).ToArray()); - currentPath = Path.Combine(previousCurrentPath, parts[i]); - if (Environment.OSVersion.Platform != PlatformID.Win32NT && Directory.Exists(previousCurrentPath)) - { - int maxMatchingCharacters = -1; - string closestMatch = parts[i]; - - foreach ( - FileSystemInfo folderOrFileInfo - in new DirectoryInfo( previousCurrentPath ) - .EnumerateFileSystemInfosSafely( searchPattern: "*", SearchOption.TopDirectoryOnly ) - ) - { - if (folderOrFileInfo is null || !folderOrFileInfo.Exists) - continue; - int matchingCharacters = GetMatchingCharactersCount(folderOrFileInfo.Name, parts[i]); - if ( matchingCharacters > maxMatchingCharacters ) - { - maxMatchingCharacters = matchingCharacters; - closestMatch = folderOrFileInfo.Name; - if ( i == parts.Length ) - isFile = folderOrFileInfo is FileInfo; - } - } - - parts[i] = closestMatch; - } - // resolve case-sensitive pathing. largestExistingPathPartsIndex determines the largest index of the existing path parts. + public static (string, bool?) GetCaseSensitivePath(string path, bool? isFile = null) + { + if ( string.IsNullOrWhiteSpace(path) ) + throw new ArgumentException($"'{nameof( path )}' cannot be null or whitespace.", nameof( path )); + + string formattedPath = Path.GetFullPath(FixPathFormatting(path)); + + // quick lookup + bool fileExists = File.Exists(formattedPath); + bool folderExists = Directory.Exists(formattedPath); + if ( fileExists && (isFile == true || !folderExists) ) + return (ConvertWindowsPathToCaseSensitive(formattedPath), true); + if ( folderExists && (isFile == false || !fileExists) ) + return (ConvertWindowsPathToCaseSensitive(formattedPath), false); + + string[] parts = formattedPath.Split( + new[] { Path.DirectorySeparatorChar, }, + StringSplitOptions.RemoveEmptyEntries + ); + + // no path parts available (no separators found). Maybe it's a file/folder that exists in cur directory. + if ( parts.Length == 0 ) + parts = new[]{ formattedPath, }; + + // insert the root into the list (will be / on unix, and drive name (e.g. C:\\ on windows) + string currentPath = Path.GetPathRoot(formattedPath); + if ( !string.IsNullOrEmpty(currentPath) && !Path.IsPathRooted(parts[0]) ) + parts = new[] { currentPath, }.Concat(parts).ToArray(); + // append directory separator to drive roots + if ( parts[0].EndsWith(":") ) + parts[0] += Path.DirectorySeparatorChar; + + int largestExistingPathPartsIndex = -1; + string caseSensitiveCurrentPath = null; + for ( int i = 1; i < parts.Length; i++ ) + { + // find the closest matching file/folder in the current path for unix, useful for duplicates. + string previousCurrentPath = Path.Combine(parts.Take(i).ToArray()); + currentPath = Path.Combine(previousCurrentPath, parts[i]); + if ( Environment.OSVersion.Platform != PlatformID.Win32NT + && !Directory.Exists(currentPath) + && Directory.Exists(previousCurrentPath) ) + { + int maxMatchingCharacters = -1; + string closestMatch = parts[i]; + + foreach ( FileSystemInfo folderOrFileInfo in new DirectoryInfo(previousCurrentPath) + .EnumerateFileSystemInfosSafely(searchPattern: "*") ) + { + if ( folderOrFileInfo is null || !folderOrFileInfo.Exists ) + continue; + if ( folderOrFileInfo is FileInfo && i < parts.Length - 1 ) + continue; + + int matchingCharacters = GetMatchingCharactersCount(folderOrFileInfo.Name, parts[i]); + if ( matchingCharacters > maxMatchingCharacters ) + { + maxMatchingCharacters = matchingCharacters; + closestMatch = folderOrFileInfo.Name; + if ( i == parts.Length - 1 ) + isFile = folderOrFileInfo is FileInfo; + } + } + + parts[i] = closestMatch; + } + // resolve case-sensitive pathing. largestExistingPathPartsIndex determines the largest index of the existing path parts. // todo: check if it's the last part of the path, then conditionally call directory.exists OR file.exists based on isFile. - else if ( string.IsNullOrEmpty(caseSensitiveCurrentPath) - && !File.Exists(currentPath) - && !Directory.Exists(currentPath) ) - { - // Get the case-sensitive path based on the existing parts we've determined. - largestExistingPathPartsIndex = i; - caseSensitiveCurrentPath = ConvertWindowsPathToCaseSensitive(previousCurrentPath); - } - } - - if ( caseSensitiveCurrentPath is null ) - return ( Path.Combine( parts ), isFile ); - - string combinedPath = largestExistingPathPartsIndex > -1 - ? Path.Combine( - caseSensitiveCurrentPath, - Path.Combine( parts.Skip( largestExistingPathPartsIndex ).ToArray() ) - ) - : Path.Combine( parts ); - - return ( combinedPath, isFile ); - } - - private static int GetMatchingCharactersCount(string str1, string str2) - { - if (string.IsNullOrEmpty(str1)) - throw new ArgumentException("Value cannot be null or empty.", nameof(str1)); - if (string.IsNullOrEmpty(str2)) - throw new ArgumentException("Value cannot be null or empty.", nameof(str2)); - - int matchingCount = 0; - for (int i = 0; i < str1.Length && i < str2.Length; i++) - { - // don't consider a match if any char in the paths are not case-insensitive matches. - if (char.ToLowerInvariant(str1[i]) != char.ToLowerInvariant(str2[i])) - return -1; - - // increment matching count if case-sensitive match at this char index succeeds - if (str1[i] == str2[i]) - matchingCount++; - } - - return matchingCount; - } - + else if ( string.IsNullOrEmpty(caseSensitiveCurrentPath) + && !File.Exists(currentPath) + && !Directory.Exists(currentPath) ) + { + // Get the case-sensitive path based on the existing parts we've determined. + largestExistingPathPartsIndex = i; + caseSensitiveCurrentPath = ConvertWindowsPathToCaseSensitive(previousCurrentPath); + } + } + + if ( caseSensitiveCurrentPath is null ) + return (Path.Combine(parts), isFile); + + string combinedPath = largestExistingPathPartsIndex > -1 + ? Path.Combine( + caseSensitiveCurrentPath, + Path.Combine(parts.Skip(largestExistingPathPartsIndex).ToArray()) + ) + : Path.Combine(parts); + + return (combinedPath, isFile); + } + + private static int GetMatchingCharactersCount(string str1, string str2) + { + if ( string.IsNullOrEmpty(str1) ) + throw new ArgumentException(message: "Value cannot be null or empty.", nameof( str1 )); + if ( string.IsNullOrEmpty(str2) ) + throw new ArgumentException(message: "Value cannot be null or empty.", nameof( str2 )); + + // don't consider a match if any char in the paths are not case-insensitive matches. + if ( !str1.Equals(str2, StringComparison.OrdinalIgnoreCase) ) + return -1; + + int matchingCount = 0; + for ( int i = 0; i < str1.Length && i < str2.Length; i++ ) + { + // increment matching count if case-sensitive match at this char index succeeds + if ( str1[i] == str2[i] ) + matchingCount++; + } + + return matchingCount; + } public static async Task MoveFileAsync( string sourcePath, string destinationPath ) { From 73ab5d3ce99162d0b99de7d6194102da747e4300 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Thu, 7 Sep 2023 12:52:54 -0500 Subject: [PATCH 09/11] Update PathHelper.cs --- KotorDotNET/FileSystemPathing/PathHelper.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/KotorDotNET/FileSystemPathing/PathHelper.cs b/KotorDotNET/FileSystemPathing/PathHelper.cs index b2b935d..3d1b4a9 100644 --- a/KotorDotNET/FileSystemPathing/PathHelper.cs +++ b/KotorDotNET/FileSystemPathing/PathHelper.cs @@ -289,7 +289,7 @@ public static (string, bool?) GetCaseSensitivePath(string path, bool? isFile = n parts = new[]{ formattedPath, }; // insert the root into the list (will be / on unix, and drive name (e.g. C:\\ on windows) - string currentPath = Path.GetPathRoot(formattedPath); + string? currentPath = Path.GetPathRoot(formattedPath); if ( !string.IsNullOrEmpty(currentPath) && !Path.IsPathRooted(parts[0]) ) parts = new[] { currentPath, }.Concat(parts).ToArray(); // append directory separator to drive roots @@ -297,7 +297,7 @@ public static (string, bool?) GetCaseSensitivePath(string path, bool? isFile = n parts[0] += Path.DirectorySeparatorChar; int largestExistingPathPartsIndex = -1; - string caseSensitiveCurrentPath = null; + string? caseSensitiveCurrentPath = null; for ( int i = 1; i < parts.Length; i++ ) { // find the closest matching file/folder in the current path for unix, useful for duplicates. @@ -585,11 +585,11 @@ public static string FixPathFormatting( string path ) if ( string.IsNullOrWhiteSpace( path ) ) return path; - // Replace all slashes with the operating system's path separator - string formattedPath = path - .Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ) - .Replace( oldChar: '\\', Path.DirectorySeparatorChar ) - .Replace( oldChar: '/', Path.DirectorySeparatorChar ); + // Replace all slashes with the operating system's path separator + string formattedPath = path.TrimStart('\\') + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) + .Replace(oldChar: '\\', Path.DirectorySeparatorChar) + .Replace(oldChar: '/', Path.DirectorySeparatorChar); // Fix repeated slashes formattedPath = Regex.Replace( @@ -672,7 +672,7 @@ public static IEnumerable FindCaseInsensitiveDuplicates( string if ( !dirInfo.Exists ) { - string folderPath = Path.GetDirectoryName( caseSensitivePath ); + string? folderPath = Path.GetDirectoryName( caseSensitivePath ); isFile = true; if ( !( folderPath is null ) ) dirInfo = new DirectoryInfo( folderPath ); From 394d1c54ce6a964441d0edf2353a804a015e6537 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Thu, 26 Oct 2023 18:50:06 -0500 Subject: [PATCH 10/11] implement KotorPath --- KotorDotNET/Common/Data/KotorPath.cs | 71 ++++++++++++++++++++++++---- KotorDotNET/Common/Installation.cs | 5 +- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/KotorDotNET/Common/Data/KotorPath.cs b/KotorDotNET/Common/Data/KotorPath.cs index 1e16b37..47a591d 100644 --- a/KotorDotNET/Common/Data/KotorPath.cs +++ b/KotorDotNET/Common/Data/KotorPath.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; @@ -15,33 +16,85 @@ public class KotorPath /// /// The stored path. /// - public string Value { get; private set; } + public string Value { get; } public KotorPath(string path) { - // TODO - check casing on unix systems Value = path; } /// - /// Returns a new KotorPath instance with the specificied path adjoined to + /// Returns a new KotorPath instance with the specified path adjoined to /// the end of the current instance. /// /// The extra path to add to the end. /// A new KotorPath instance with the old and new paths joined. - public KotorPath Join(string path) + public KotorPath Join(string path) => new(Path.Join(Value, path)); + + [DllImport("libc", SetLastError = true)] + private static extern IntPtr opendir(string name); + + [DllImport("libc", SetLastError = true)] + private static extern IntPtr readdir(IntPtr dirp); + + [DllImport("libc", SetLastError = true)] + private static extern int closedir(IntPtr dirp); + + [StructLayout(LayoutKind.Sequential)] + private struct Dirent { - return Path.Join(Value, path); + public IntPtr d_ino; + public IntPtr d_off; + public ushort d_reclen; + public byte d_type; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string d_name; } - public static implicit operator KotorPath(string path) + private bool ShouldResolveCase() => !OperatingSystem.IsWindows() && Path.IsPathRooted(Value); + + private string NormalizePath() { - return new KotorPath(path); + if (!ShouldResolveCase()) + return Value; + + StringBuilder currentPath = new("/"); + foreach (string segment in Value.Split('/')) + { + string? resolvedSegment = ResolveCase(currentPath.ToString(), segment); + if (string.IsNullOrEmpty(resolvedSegment)) + return Value; // Path not resolved, return original value + + currentPath.Append(resolvedSegment); + currentPath.Append('/'); + } + + return currentPath.ToString().TrimEnd('/'); } - public static implicit operator string(KotorPath path) + private string? ResolveCase(string currentPath, string segment) { - return path.Value; + IntPtr dirPtr = opendir(currentPath); + if (dirPtr == IntPtr.Zero) + return null; // Cannot open directory + + IntPtr direntPtr; + while ((direntPtr = readdir(dirPtr)) != IntPtr.Zero) + { + Dirent dirent = Marshal.PtrToStructure(direntPtr); + if (segment.Equals(dirent.d_name, StringComparison.OrdinalIgnoreCase)) + { + closedir(dirPtr); + return dirent.d_name; + } + } + + closedir(dirPtr); + return null; // Segment not found } + + public static implicit operator KotorPath(string strPath) => new(strPath); + public static implicit operator string(KotorPath kPath) => kPath.NormalizePath(); } + } diff --git a/KotorDotNET/Common/Installation.cs b/KotorDotNET/Common/Installation.cs index 519e0d8..f57ed46 100644 --- a/KotorDotNET/Common/Installation.cs +++ b/KotorDotNET/Common/Installation.cs @@ -41,8 +41,7 @@ public class Installation public Installation(string gameDirectory) { - // TODO - path handling - GamePath = gameDirectory; + GamePath = new KotorPath(gameDirectory); ChitinPath = GamePath.Join("chitin.key"); Chitin = new Chitin(gameDirectory); @@ -75,6 +74,8 @@ public Installation(string gameDirectory) Sounds = new ResourceFolder(SoundsPath); VoicesPath = GamePath.Join("streamwaves"); + if (!Directory.Exists(VoicesPath)) + VoicesPath = GamePath.Join("streamvoice"); Voices = new ResourceFolder(VoicesPath); } } From a3202aa785681395f78cc9dbd4a84443cdc98c13 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Fri, 27 Oct 2023 09:57:30 -0500 Subject: [PATCH 11/11] use managed libraries instead of system calls --- KotorDotNET.Tests/PathCaseSensitivityTests.cs | 2 +- KotorDotNET/Common/Data/KotorPath.cs | 133 ++++++++---------- KotorDotNET/Patching/Patcher.cs | 47 +++---- 3 files changed, 77 insertions(+), 105 deletions(-) diff --git a/KotorDotNET.Tests/PathCaseSensitivityTests.cs b/KotorDotNET.Tests/PathCaseSensitivityTests.cs index 3c739d8..c3760fe 100644 --- a/KotorDotNET.Tests/PathCaseSensitivityTests.cs +++ b/KotorDotNET.Tests/PathCaseSensitivityTests.cs @@ -325,7 +325,7 @@ public void GetCaseSensitivePath_NullOrWhiteSpacePath_ThrowsArgumentException() const string whiteSpacePath = " "; // Act & Assert - _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath ) ); + _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( nullPath! ) ); _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( emptyPath ) ); _ = Assert.Throws( () => PathHelper.GetCaseSensitivePath( whiteSpacePath ) ); } diff --git a/KotorDotNET/Common/Data/KotorPath.cs b/KotorDotNET/Common/Data/KotorPath.cs index 47a591d..e9e2b70 100644 --- a/KotorDotNET/Common/Data/KotorPath.cs +++ b/KotorDotNET/Common/Data/KotorPath.cs @@ -1,100 +1,77 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; +using System.Text; +using KotorDotNET.FileSystemPathing; -namespace KotorDotNET.Common.Data +namespace KotorDotNET.Common.Data; + +/// +/// A special class designed to store a path that accounts for the case-sensitivity +/// on unix-like systems. +/// +public class KotorPath { + public KotorPath(string path) => + Value = path; + /// - /// A special class designed to store a path that accounts for the case-sensitivity - /// on unix-like systems. + /// The stored path. /// - public class KotorPath - { - /// - /// The stored path. - /// - public string Value { get; } - - public KotorPath(string path) - { - Value = path; - } + public string Value { get; } - /// - /// Returns a new KotorPath instance with the specified path adjoined to - /// the end of the current instance. - /// - /// The extra path to add to the end. - /// A new KotorPath instance with the old and new paths joined. - public KotorPath Join(string path) => new(Path.Join(Value, path)); - - [DllImport("libc", SetLastError = true)] - private static extern IntPtr opendir(string name); + /// + /// Returns a new KotorPath instance with the specified path adjoined to + /// the end of the current instance. + /// + /// The extra path to add to the end. + /// A new KotorPath instance with the old and new paths joined. + public KotorPath Join(string path) => new(Path.Join(Value, path)); + public bool Exists() => File.Exists(Value) || Directory.Exists(Value); - [DllImport("libc", SetLastError = true)] - private static extern IntPtr readdir(IntPtr dirp); + private bool ShouldResolveCase() => !OperatingSystem.IsWindows() && Path.IsPathRooted(Value) && !Exists(); - [DllImport("libc", SetLastError = true)] - private static extern int closedir(IntPtr dirp); + private string NormalizePath() + { + if (!ShouldResolveCase()) + return Value; - [StructLayout(LayoutKind.Sequential)] - private struct Dirent + StringBuilder currentPath = new(Path.DirectorySeparatorChar.ToString()); + foreach (string segment in Value.Split(Path.DirectorySeparatorChar)) { - public IntPtr d_ino; - public IntPtr d_off; - public ushort d_reclen; - public byte d_type; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] - public string d_name; + string? resolvedSegment = ResolveCase(currentPath.ToString(), segment); + if (string.IsNullOrEmpty(resolvedSegment)) + return Value; // Path not resolved, return original value + + currentPath.Append(resolvedSegment); + currentPath.Append(Path.DirectorySeparatorChar); } - private bool ShouldResolveCase() => !OperatingSystem.IsWindows() && Path.IsPathRooted(Value); + return currentPath.ToString().TrimEnd(Path.DirectorySeparatorChar); + } - private string NormalizePath() + private static string? ResolveCase(string currentPath, string segment) + { + try { - if (!ShouldResolveCase()) - return Value; - - StringBuilder currentPath = new("/"); - foreach (string segment in Value.Split('/')) + DirectoryInfo dirInfo = new(currentPath); + foreach (FileSystemInfo fsInfo in dirInfo.EnumerateFileSystemInfosSafely()) { - string? resolvedSegment = ResolveCase(currentPath.ToString(), segment); - if (string.IsNullOrEmpty(resolvedSegment)) - return Value; // Path not resolved, return original value - - currentPath.Append(resolvedSegment); - currentPath.Append('/'); + if (segment.Equals(fsInfo.Name, StringComparison.OrdinalIgnoreCase)) + return fsInfo.Name; } - - return currentPath.ToString().TrimEnd('/'); } - - private string? ResolveCase(string currentPath, string segment) + catch (Exception) { - IntPtr dirPtr = opendir(currentPath); - if (dirPtr == IntPtr.Zero) - return null; // Cannot open directory - - IntPtr direntPtr; - while ((direntPtr = readdir(dirPtr)) != IntPtr.Zero) - { - Dirent dirent = Marshal.PtrToStructure(direntPtr); - if (segment.Equals(dirent.d_name, StringComparison.OrdinalIgnoreCase)) - { - closedir(dirPtr); - return dirent.d_name; - } - } - - closedir(dirPtr); - return null; // Segment not found + return null; // Cannot open directory } - public static implicit operator KotorPath(string strPath) => new(strPath); - public static implicit operator string(KotorPath kPath) => kPath.NormalizePath(); + return null; // Segment not found } + public static implicit operator string(KotorPath kPath) => kPath.NormalizePath(); + public static implicit operator KotorPath(string strPath) => new(strPath); + + public static KotorPath operator +(KotorPath a, KotorPath b) => + new(a.Value + b.Value); + + public static KotorPath operator /(KotorPath a, KotorPath b) => + new(Path.Combine(a.Value, b.Value)); } diff --git a/KotorDotNET/Patching/Patcher.cs b/KotorDotNET/Patching/Patcher.cs index 93e83c0..94c7af7 100644 --- a/KotorDotNET/Patching/Patcher.cs +++ b/KotorDotNET/Patching/Patcher.cs @@ -1,32 +1,27 @@ -using KotorDotNET.Common.Data; -using KotorDotNET.FileFormats.Kotor2DA; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using KotorDotNET.FileFormats.Kotor2DA; +using KotorDotNET.FileFormats.KotorGFF; +using KotorDotNET.ResourceContainers; -namespace KotorDotNET.Patching +namespace KotorDotNET.Patching; + +public class Patcher { - public class Patcher + public Patcher(IMemory memory, ILogger logger, PatcherData data) + { + Memory = memory; + Logger = logger; + PatcherData = data; + } + + public IMemory Memory { get; set; } + public ILogger Logger { get; set; } + public PatcherData PatcherData { get; set; } + + /// + /// Execute the patcher data to the game files. + /// + public void Run() { - public IMemory Memory { get; set; } - public ILogger Logger { get; set; } - public PatcherData PatcherData { get; set; } - public Patcher(IMemory memory, ILogger logger, PatcherData data) - { - Memory = memory; - Logger = logger; - PatcherData = data; - } - - /// - /// Execute the patcher data to the game files. - /// - public void Run() - { - - } } }