diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEnumerableFactory.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEnumerableFactory.cs index cd2f9fb2f35387..7693dbe99d0b1a 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEnumerableFactory.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEnumerableFactory.cs @@ -35,6 +35,54 @@ internal static bool NormalizeInputs(ref string directory, ref string expression if (directory.Contains('\0')) throw new ArgumentException(SR.Argument_NullCharInPath, directory); +#if WINDOWS + // Trim trailing whitespace and periods from directory path, but preserve directory separators. + // Windows normalizes trailing spaces and periods away when resolving paths, but if we don't + // trim them here, the returned file paths will contain trailing spaces/periods which causes + // issues with File.Exists and other file operations. + // + // Examples: + // "C:\test " → "C:\test" (remove trailing space) + // "C:\test." → "C:\test" (remove trailing period) + // "C:\test\ " → "C:\test\" (preserve separator, remove space) + // "C:\test\. " → "C:\test\" (preserve separator, remove period and space) + // "C:\test\\ " → "C:\test\\" (preserve separators, remove space) + // + // Special cases we don't trim: + // "." → "." (relative path reference) + // ".." → ".." (parent directory reference) + // " " → " " (only spaces - would result in empty) + // "\\?\C:\test." → "\\?\C:\test." (extended path syntax - no normalization) + // + // Algorithm: Trim trailing spaces/periods, but only if: + // 1. Result is non-empty + // 2. Path does not use extended syntax (\\?\ or \\.\) + + // Don't trim paths using extended syntax (\\?\ or \\.\) as they explicitly disable normalization + // Check for extended path syntax on Windows + // Extended paths are paths like \\?\C:\ or \\.\device + // While paths like "//?/C:/" will work, they're treated the same as "\\.\" paths. + // Skipping of normalization will *only* occur if back slashes ('\') are used. + ReadOnlySpan path = directory.AsSpan(); + const int DevicePrefixLength = 4; + bool isExtended = path.Length >= DevicePrefixLength + && path[0] == '\\' + && (path[1] == '\\' || path[1] == '?') + && path[2] == '?' + && path[3] == '\\'; + + if (!isExtended) + { + string trimmed = directory.TrimEnd(' ', '.'); + + // Only apply the trim if it results in a non-empty string + if (trimmed.Length > 0) + { + directory = trimmed; + } + } +#endif + // We always allowed breaking the passed ref directory and filter to be separated // any way the user wanted. Looking for "C:\foo\*.cs" could be passed as "C:\" and // "foo\*.cs" or "C:\foo" and "*.cs", for example. As such we need to combine and diff --git a/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Directory/GetFiles.cs b/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Directory/GetFiles.cs index 279fb28a558256..bb2bf3436dd345 100644 --- a/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Directory/GetFiles.cs +++ b/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Directory/GetFiles.cs @@ -222,17 +222,16 @@ public void WindowsEnumerateFilesWithTrailingSpacePeriod(string fileName) } [Theory] - [MemberData(nameof(TestData.WindowsTrailingProblematicFileNames), MemberType = typeof(TestData))] [PlatformSpecific(TestPlatforms.Windows)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/113120")] - public void WindowsEnumerateDirectoryWithTrailingSpacePeriod(string dirName) + [MemberData(nameof(TestData.WindowsTrailingProblematicFileNames), MemberType = typeof(TestData))] + public void EnumerateDirectoryWithTrailingSpacePeriod(string dirName) { DirectoryInfo parentDir = Directory.CreateDirectory(GetTestFilePath()); string problematicDirPath = Path.Combine(parentDir.FullName, dirName); - Directory.CreateDirectory(@"\\?\" + problematicDirPath); + Directory.CreateDirectory(problematicDirPath); string normalFileName = "normalfile.txt"; - string filePath = Path.Combine(problematicDirPath, normalFileName); + string filePath = Path.Combine(Path.GetFullPath(problematicDirPath), normalFileName); File.Create(filePath).Dispose(); string[] files = GetEntries(problematicDirPath); diff --git a/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Enumeration/RootTests.cs b/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Enumeration/RootTests.cs index 501e2011efd3ad..e5a9eef764e3fb 100644 --- a/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Enumeration/RootTests.cs +++ b/src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Enumeration/RootTests.cs @@ -30,6 +30,24 @@ protected override bool ShouldRecurseIntoEntry(ref FileSystemEntry entry) } } + private class OriginalRootDirectoryEnumerator : FileSystemEnumerator + { + public string CapturedOriginalRootDirectory { get; private set; } + + public OriginalRootDirectoryEnumerator(string directory, EnumerationOptions options) + : base(directory, options) + { + } + + protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) => true; + + protected override string TransformEntry(ref FileSystemEntry entry) + { + CapturedOriginalRootDirectory = new string(entry.OriginalRootDirectory); + return entry.ToFullPath(); + } + } + [Fact] [SkipOnPlatform(TestPlatforms.Android, "Test could not work on android since accessing '/' isn't allowed.")] public void CanRecurseFromRoot() @@ -55,5 +73,43 @@ public void CanRecurseFromRoot() Assert.NotNull(recursed.LastDirectory); } } + + [Theory] + [InlineData("/")] + [InlineData("//")] + [InlineData("///")] + public void OriginalRootDirectoryPreservesInput(string trailingSeparators) + { + // OriginalRootDirectory should preserve the exact input path provided by the user, + // including trailing directory separators. This is important for backward compatibility with + // code that relies on the exact format of the original path when using FileSystemEnumerator directly. + // Note: This tests direct FileSystemEnumerator usage, not Directory.GetFiles which goes through + // NormalizeInputs and trims trailing spaces/periods. + + DirectoryInfo testDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + try + { + // Create a test file + string testFile = Path.Combine(testDir.FullName, "test.txt"); + File.WriteAllText(testFile, "test"); + + string pathWithTrailingSeparators = testDir.FullName + trailingSeparators; + + using (var enumerator = new OriginalRootDirectoryEnumerator( + pathWithTrailingSeparators, + new EnumerationOptions { RecurseSubdirectories = false })) + { + if (enumerator.MoveNext()) + { + // OriginalRootDirectory should match the input path exactly + Assert.Equal(pathWithTrailingSeparators, enumerator.CapturedOriginalRootDirectory); + } + } + } + finally + { + testDir.Delete(true); + } + } } }