Add Path.Exists to replace inefficient File.Exists || Directory.Exists (#64347)
authorTarun047 <32017154+Tarun047@users.noreply.github.com>
Mon, 31 Jan 2022 09:02:25 +0000 (14:32 +0530)
committerGitHub <noreply@github.com>
Mon, 31 Jan 2022 09:02:25 +0000 (10:02 +0100)
Fixes #21678

Co-authored-by: Adam Sitnik <adam.sitnik@gmail.com>
src/libraries/System.IO.FileSystem/tests/Directory/Exists.cs
src/libraries/System.IO.FileSystem/tests/File/Exists.cs
src/libraries/System.IO.FileSystem/tests/Path/Exists_Directory.cs [new file with mode: 0644]
src/libraries/System.IO.FileSystem/tests/Path/Exists_File.cs [new file with mode: 0644]
src/libraries/System.IO.FileSystem/tests/System.IO.FileSystem.Tests.csproj
src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs
src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs
src/libraries/System.Private.CoreLib/src/System/IO/Path.cs
src/libraries/System.Runtime/ref/System.Runtime.cs

index 983acfc..feaad06 100644 (file)
@@ -10,7 +10,7 @@ namespace System.IO.Tests
     {
         #region Utilities
 
-        public bool Exists(string path)
+        public virtual bool Exists(string path)
         {
             return Directory.Exists(path);
         }
@@ -58,17 +58,6 @@ namespace System.IO.Tests
         }
 
         [Fact]
-        public void PathAlreadyExistsAsFile()
-        {
-            string path = GetTestFilePath();
-            File.Create(path).Dispose();
-
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(path)));
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
-        }
-
-        [Fact]
         public void PathAlreadyExistsAsDirectory()
         {
             string path = GetTestFilePath();
@@ -181,18 +170,6 @@ namespace System.IO.Tests
         }
 
         [ConditionalFact(nameof(UsingNewNormalization))]
-        [PlatformSpecific(TestPlatforms.Windows)]  // Extended path already exists as file
-        public void ExtendedPathAlreadyExistsAsFile()
-        {
-            string path = IOInputs.ExtendedPrefix + GetTestFilePath();
-            File.Create(path).Dispose();
-
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(path)));
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
-        }
-
-        [ConditionalFact(nameof(UsingNewNormalization))]
         [PlatformSpecific(TestPlatforms.Windows)]  // Extended path already exists as directory
         public void ExtendedPathAlreadyExistsAsDirectory()
         {
@@ -387,6 +364,34 @@ namespace System.IO.Tests
             Assert.False(Exists(Path.Combine(IOServices.GetNonExistentDrive(), "nonexistentsubdir")));
         }
 
+        #endregion
+    }
+
+    public class Directory_ExistsAsFile : FileSystemTest
+    {
+        [Fact]
+        public void PathAlreadyExistsAsFile()
+        {
+            string path = GetTestFilePath();
+            File.Create(path).Dispose();
+
+            Assert.False(Directory.Exists(IOServices.RemoveTrailingSlash(path)));
+            Assert.False(Directory.Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
+            Assert.False(Directory.Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
+        }
+
+        [ConditionalFact(nameof(UsingNewNormalization))]
+        [PlatformSpecific(TestPlatforms.Windows)]  // Extended path already exists as file
+        public void ExtendedPathAlreadyExistsAsFile()
+        {
+            string path = IOInputs.ExtendedPrefix + GetTestFilePath();
+            File.Create(path).Dispose();
+
+            Assert.False(Directory.Exists(IOServices.RemoveTrailingSlash(path)));
+            Assert.False(Directory.Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
+            Assert.False(Directory.Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
+        }
+
         [Fact]
         [PlatformSpecific(TestPlatforms.AnyUnix & ~TestPlatforms.Browser)]  // Makes call to native code (libc)
         public void FalseForNonRegularFile()
@@ -395,7 +400,5 @@ namespace System.IO.Tests
             Assert.Equal(0, mkfifo(fileName, 0));
             Assert.False(Directory.Exists(fileName));
         }
-
-        #endregion
     }
 }
index 1fde8ae..ff2c884 100644 (file)
@@ -52,9 +52,6 @@ namespace System.IO.Tests
         {
             // Checks that errors aren't thrown when calling Exists() on paths with impossible to create characters
             Assert.False(Exists(invalidPath));
-
-            Assert.False(Exists(".."));
-            Assert.False(Exists("."));
         }
 
         [Fact]
@@ -101,17 +98,6 @@ namespace System.IO.Tests
         }
 
         [Fact]
-        public void PathAlreadyExistsAsDirectory()
-        {
-            string path = GetTestFilePath();
-            Directory.CreateDirectory(path);
-
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(path)));
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
-            Assert.False(Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
-        }
-
-        [Fact]
         public void DirectoryLongerThanMaxDirectoryAsPath_DoesntThrow()
         {
             Assert.All((IOInputs.GetPathsLongerThanMaxDirectory(GetTestFilePath())), (path) =>
@@ -244,6 +230,29 @@ namespace System.IO.Tests
             Assert.False(Exists(component));
         }
 
+        #endregion
+    }
+
+    public class File_ExistsAsDirectory : FileSystemTest
+    {
+        [Fact]
+        public void DotAsPathReturnsFalse()
+        {
+            Assert.False(File.Exists("."));
+            Assert.False(File.Exists(".."));
+        }
+
+        [Fact]
+        public void PathAlreadyExistsAsDirectory()
+        {
+            string path = GetTestFilePath();
+            Directory.CreateDirectory(path);
+
+            Assert.False(File.Exists(IOServices.RemoveTrailingSlash(path)));
+            Assert.False(File.Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
+            Assert.False(File.Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
+        }
+
         [Fact]
         [PlatformSpecific(TestPlatforms.AnyUnix & ~TestPlatforms.Browser)]  // Uses P/Invokes
         public void FalseForNonRegularFile()
@@ -252,7 +261,5 @@ namespace System.IO.Tests
             Assert.Equal(0, mkfifo(fileName, 0));
             Assert.True(File.Exists(fileName));
         }
-
-        #endregion
     }
 }
diff --git a/src/libraries/System.IO.FileSystem/tests/Path/Exists_Directory.cs b/src/libraries/System.IO.FileSystem/tests/Path/Exists_Directory.cs
new file mode 100644 (file)
index 0000000..9c4831d
--- /dev/null
@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Xunit;
+
+namespace System.IO.Tests
+{
+    public class PathDirectory_Exists : Directory_Exists
+    {
+        public override bool Exists(string path) => Path.Exists(path);
+
+        [Fact]
+        public void PathAlreadyExistsAsFile()
+        {
+            string path = GetTestFilePath();
+            File.Create(path).Dispose();
+
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(path)));
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
+        }
+
+        [ConditionalFact(nameof(UsingNewNormalization))]
+        [PlatformSpecific(TestPlatforms.Windows)]  // Extended path already exists as file
+        public void ExtendedPathAlreadyExistsAsFile()
+        {
+            string path = IOInputs.ExtendedPrefix + GetTestFilePath();
+            File.Create(path).Dispose();
+
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(path)));
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
+        }
+    }
+}
diff --git a/src/libraries/System.IO.FileSystem/tests/Path/Exists_File.cs b/src/libraries/System.IO.FileSystem/tests/Path/Exists_File.cs
new file mode 100644 (file)
index 0000000..64af8e8
--- /dev/null
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Xunit;
+
+namespace System.IO.Tests
+{
+    public class PathFile_Exists : File_Exists
+    {
+        public override bool Exists(string path) => Path.Exists(path);
+
+        [Fact]
+        public void PathAlreadyExistsAsDirectory()
+        {
+            string path = GetTestFilePath();
+            Directory.CreateDirectory(path);
+
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(path)));
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(IOServices.RemoveTrailingSlash(path))));
+            Assert.True(Exists(IOServices.RemoveTrailingSlash(IOServices.AddTrailingSlashIfNeeded(path))));
+        }
+
+        [Fact]
+        [PlatformSpecific(TestPlatforms.AnyUnix & ~TestPlatforms.Browser)]  // Uses P/Invokes
+        public void TrueForNonRegularFile()
+        {
+            string fileName = GetTestFilePath();
+            Assert.Equal(0, mkfifo(fileName, 0));
+            Assert.True(Exists(fileName));
+        }
+    }
+}
index 83f7bf4..9673385 100644 (file)
@@ -58,6 +58,8 @@
     <Compile Include="Enumeration\SymbolicLinksTests.cs" />
     <Compile Include="LargeFileTests.cs" />
     <Compile Include="PathInternalTests.cs" />
+    <Compile Include="Path\Exists_Directory.cs" />
+    <Compile Include="Path\Exists_File.cs" />
     <Compile Include="RandomAccess\Base.cs" />
     <Compile Include="RandomAccess\GetLength.cs" />
     <Compile Include="RandomAccess\Read.cs" />
index 772440a..f8ead35 100644 (file)
@@ -13,6 +13,22 @@ namespace System.IO
 
         public static char[] GetInvalidPathChars() => new char[] { '\0' };
 
+        // Checks if the given path is available for use.
+        private static bool ExistsCore(string fullPath)
+        {
+            bool result = Interop.Sys.LStat(fullPath, out Interop.Sys.FileStatus fileInfo) == Interop.Errors.ERROR_SUCCESS;
+            if (PathInternal.IsDirectorySeparator(fullPath[fullPath.Length - 1]))
+            {
+                // If the path ends with trailing slash, we want to make sure it's a directory.
+                // Although Lstat returns the correct result on desktop platforms,
+                // on browser WASM, it seems to strip trailing slash, leading to false positives for files.
+                // This check prevents it from doing so.
+                result = result && (fileInfo.Mode & Interop.Sys.FileTypes.S_IFMT) == Interop.Sys.FileTypes.S_IFDIR;
+            }
+
+            return result;
+        }
+
         // Expands the given path to a fully qualified path.
         public static string GetFullPath(string path)
         {
index b10cedd..2c8a635 100644 (file)
@@ -27,6 +27,23 @@ namespace System.IO
             (char)31
         };
 
+        private static bool ExistsCore(string fullPath)
+        {
+            Interop.Kernel32.WIN32_FILE_ATTRIBUTE_DATA data = default;
+            int errorCode = FileSystem.FillAttributeInfo(fullPath, ref data, returnErrorOnNotFound: true);
+            bool result = (errorCode == Interop.Errors.ERROR_SUCCESS) && (data.dwFileAttributes != -1);
+
+            if (PathInternal.IsDirectorySeparator(fullPath[fullPath.Length - 1]))
+            {
+                // We want to make sure that if the path ends in a trailing slash, it's truly a directory
+                // because FillAttributeInfo syscall removes any trailing slashes and may give false positives
+                // for existing files.
+                result = result && (data.dwFileAttributes & Interop.Kernel32.FileAttributes.FILE_ATTRIBUTE_DIRECTORY) != 0;
+            }
+
+            return result;
+        }
+
         // Expands the given path to a fully qualified path.
         public static string GetFullPath(string path)
         {
index b288738..0595cab 100644 (file)
@@ -76,6 +76,41 @@ namespace System.IO
         }
 
         /// <summary>
+        /// Determines whether the specified file or directory exists.
+        /// </summary>
+        /// <remarks>
+        /// Unlike <see cref="File.Exists(string?)"/> it returns true for existing, non-regular files like pipes.
+        /// If the path targets an existing link, but the target of the link does not exist, it returns true.
+        /// </remarks>
+        /// <param name="path">The path to check</param>
+        /// <returns>
+        /// <see langword="true" /> if the caller has the required permissions and <paramref name="path" /> contains
+        /// the name of an existing file or directory; otherwise, <see langword="false" />.
+        /// This method also returns <see langword="false" /> if <paramref name="path" /> is <see langword="null" />,
+        /// an invalid path, or a zero-length string. If the caller does not have sufficient permissions to read the specified path,
+        /// no exception is thrown and the method returns <see langword="false" /> regardless of the existence of <paramref name="path" />.
+        /// </returns>
+        public static bool Exists([NotNullWhen(true)] string? path)
+        {
+            if (string.IsNullOrEmpty(path))
+            {
+                return false;
+            }
+
+            string? fullPath;
+            try
+            {
+                fullPath = GetFullPath(path);
+            }
+            catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException)
+            {
+                return false;
+            }
+
+            return ExistsCore(fullPath);
+        }
+
+        /// <summary>
         /// Returns the directory portion of a file path. This method effectively
         /// removes the last segment of the given file path, i.e. it returns a
         /// string consisting of all characters up to but not including the last
index cf8690c..4bd98a4 100644 (file)
@@ -10725,6 +10725,7 @@ namespace System.IO
         public static string Combine(params string[] paths) { throw null; }
         public static bool EndsInDirectorySeparator(System.ReadOnlySpan<char> path) { throw null; }
         public static bool EndsInDirectorySeparator(string path) { throw null; }
+        public static bool Exists([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? path) { throw null; }
         public static System.ReadOnlySpan<char> GetDirectoryName(System.ReadOnlySpan<char> path) { throw null; }
         public static string? GetDirectoryName(string? path) { throw null; }
         public static System.ReadOnlySpan<char> GetExtension(System.ReadOnlySpan<char> path) { throw null; }