diff --git a/src/libraries/System.IO.FileSystem/tests/Enumeration/AttributeTests.cs b/src/libraries/System.IO.FileSystem/tests/Enumeration/AttributeTests.cs index 92f9a829d76d25..ea9d91345ff902 100644 --- a/src/libraries/System.IO.FileSystem/tests/Enumeration/AttributeTests.cs +++ b/src/libraries/System.IO.FileSystem/tests/Enumeration/AttributeTests.cs @@ -9,12 +9,26 @@ namespace System.IO.Tests.Enumeration { public class AttributeTests : FileSystemTest { - private class DefaultFileAttributes : FileSystemEnumerator + private class FileSystemEntryProperties { - public DefaultFileAttributes(string directory, EnumerationOptions options) + public string FileName { get; init; } + public FileAttributes Attributes { get; init; } + public DateTimeOffset CreationTimeUtc { get; init; } + public bool IsDirectory { get; init; } + public bool IsHidden { get; init; } + public DateTimeOffset LastAccessTimeUtc { get; init; } + public DateTimeOffset LastWriteTimeUtc { get; init; } + public long Length { get; init; } + public string Directory { get; init; } + public string FullPath { get; init; } + public string SpecifiedFullPath { get; init; } + } + + private class GetPropertiesEnumerator : FileSystemEnumerator + { + public GetPropertiesEnumerator(string directory, EnumerationOptions options) : base(directory, options) - { - } + { } protected override bool ContinueOnError(int error) { @@ -22,93 +36,148 @@ protected override bool ContinueOnError(int error) return false; } - protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) - => !entry.IsDirectory; - - protected override string TransformEntry(ref FileSystemEntry entry) + protected override FileSystemEntryProperties TransformEntry(ref FileSystemEntry entry) { - string path = entry.ToFullPath(); - File.Delete(path); - - // Attributes require a stat call on Unix- ensure that we have the right attributes - // even if the returned file is deleted. - Assert.Equal(FileAttributes.Normal, entry.Attributes); - Assert.Equal(path, entry.ToFullPath()); - return new string(entry.FileName); + return new FileSystemEntryProperties + { + FileName = new string(entry.FileName), + Attributes = entry.Attributes, + CreationTimeUtc = entry.CreationTimeUtc, + IsDirectory = entry.IsDirectory, + IsHidden = entry.IsHidden, + LastAccessTimeUtc = entry.LastAccessTimeUtc, + LastWriteTimeUtc = entry.LastWriteTimeUtc, + Length = entry.Length, + Directory = new string(entry.Directory), + FullPath = entry.ToFullPath(), + SpecifiedFullPath = entry.ToSpecifiedFullPath() + }; } } - [Fact] - public void FileAttributesAreExpected() + // The test is performed using two items with different properties (file/dir, file length) + // to check cached values from the previous entry don't leak into the non-existing entry. + [InlineData("dir1", "dir2")] + [InlineData("dir1", "file2")] + [InlineData("dir1", "link2")] + [InlineData("file1", "file2")] + [InlineData("file1", "dir2")] + [InlineData("file1", "link2")] + [InlineData("link1", "file2")] + [InlineData("link1", "dir2")] + [InlineData("link1", "link2")] + [Theory] + public void PropertiesWhenItemNoLongerExists(string item1, string item2) { DirectoryInfo testDirectory = Directory.CreateDirectory(GetTestFilePath()); - FileInfo fileOne = new FileInfo(Path.Combine(testDirectory.FullName, GetTestFileName())); - - fileOne.Create().Dispose(); - - if (PlatformDetection.IsWindows) - { - // Archive should always be set on a new file. Clear it and other expected flags to - // see that we get "Normal" as the default when enumerating. - - Assert.True((fileOne.Attributes & FileAttributes.Archive) != 0); - fileOne.Attributes &= ~(FileAttributes.Archive | FileAttributes.NotContentIndexed); - } - - using (var enumerator = new DefaultFileAttributes(testDirectory.FullName, new EnumerationOptions())) - { - Assert.True(enumerator.MoveNext()); - Assert.Equal(fileOne.Name, enumerator.Current); - Assert.False(enumerator.MoveNext()); - } - } - private class DefaultDirectoryAttributes : FileSystemEnumerator - { - public DefaultDirectoryAttributes(string directory, EnumerationOptions options) - : base(directory, options) - { - } + FileSystemInfo item1Info = CreateItem(testDirectory, item1); + FileSystemInfo item2Info = CreateItem(testDirectory, item2); - protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) - => entry.IsDirectory; - - protected override bool ContinueOnError(int error) + using (var enumerator = new GetPropertiesEnumerator(testDirectory.FullName, new EnumerationOptions() { AttributesToSkip = 0 })) { - Assert.False(true, $"Should not have errored {error}"); - return false; + // Move to the first item. + Assert.True(enumerator.MoveNext(), "Move first"); + FileSystemEntryProperties entry = enumerator.Current; + + Assert.True(entry.FileName == item1 || entry.FileName == item2, "Unexpected item"); + + // Delete both items. + DeleteItem(testDirectory, item1); + DeleteItem(testDirectory, item2); + + // Move to the second item. + FileSystemInfo expected = entry.FileName == item1 ? item2Info : item1Info; + Assert.True(enumerator.MoveNext(), "Move second"); + entry = enumerator.Current; + + // Names and paths. + Assert.Equal(expected.Name, entry.FileName); + Assert.Equal(testDirectory.FullName, entry.Directory); + Assert.Equal(expected.FullName, entry.FullPath); + Assert.Equal(expected.FullName, entry.SpecifiedFullPath); + + // Values determined during enumeration. + if (PlatformDetection.IsBrowser) + { + // For Browser, all items are typed as DT_UNKNOWN. + Assert.False(entry.IsDirectory); + Assert.Equal(entry.FileName.StartsWith('.') ? FileAttributes.Hidden : FileAttributes.Normal, entry.Attributes); + } + else + { + Assert.Equal(expected is DirectoryInfo, entry.IsDirectory); + Assert.Equal(expected.Attributes, entry.Attributes); + } + + if (PlatformDetection.IsWindows) + { + Assert.Equal((expected.Attributes & FileAttributes.Hidden) != 0, entry.IsHidden); + Assert.Equal(expected.CreationTimeUtc, entry.CreationTimeUtc); + Assert.Equal(expected.LastAccessTimeUtc, entry.LastAccessTimeUtc); + Assert.Equal(expected.LastWriteTimeUtc, entry.LastWriteTimeUtc); + if (expected is FileInfo fileInfo) + { + Assert.Equal(fileInfo.Length, entry.Length); + } + } + else + { + // On Unix, these values were not determined during enumeration. + // Because the file was deleted, the values can no longer be retrieved and sensible defaults are returned. + Assert.Equal(entry.FileName.StartsWith('.'), entry.IsHidden); + DateTimeOffset defaultTime = new DateTimeOffset(DateTime.FromFileTimeUtc(0)); + Assert.Equal(defaultTime, entry.CreationTimeUtc); + Assert.Equal(defaultTime, entry.LastAccessTimeUtc); + Assert.Equal(defaultTime, entry.LastWriteTimeUtc); + Assert.Equal(0, entry.Length); + } + + Assert.False(enumerator.MoveNext(), "Move final"); } - protected override string TransformEntry(ref FileSystemEntry entry) - { - string path = entry.ToFullPath(); - Directory.Delete(path); - - // Attributes require a stat call on Unix- ensure that we have the right attributes - // even if the returned directory is deleted. - Assert.Equal(FileAttributes.Directory, entry.Attributes); - Assert.Equal(path, entry.ToFullPath()); - return new string(entry.FileName); - } - } - - [Fact] - public void DirectoryAttributesAreExpected() - { - DirectoryInfo testDirectory = Directory.CreateDirectory(GetTestFilePath()); - DirectoryInfo subDirectory = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, GetTestFileName())); - - if (PlatformDetection.IsWindows) + static FileSystemInfo CreateItem(DirectoryInfo testDirectory, string item) { - // Clear possible extra flags to see that we get Directory - subDirectory.Attributes &= ~FileAttributes.NotContentIndexed; + string fullPath = Path.Combine(testDirectory.FullName, item); + + // use the last char to have different lengths for different files. + Assert.True(item.EndsWith('1') || item.EndsWith('2')); + int length = (int)item[item.Length - 1]; + + if (item.StartsWith("dir")) + { + Directory.CreateDirectory(fullPath); + var info = new DirectoryInfo(fullPath); + info.Refresh(); + return info; + } + else if (item.StartsWith("link")) + { + File.CreateSymbolicLink(fullPath, new string('_', length)); + var info = new FileInfo(fullPath); + info.Refresh(); + return info; + } + else + { + File.WriteAllBytes(fullPath, new byte[length]); + var info = new FileInfo(fullPath); + info.Refresh(); + return info; + } } - using (var enumerator = new DefaultDirectoryAttributes(testDirectory.FullName, new EnumerationOptions())) + static void DeleteItem(DirectoryInfo testDirectory, string item) { - Assert.True(enumerator.MoveNext()); - Assert.Equal(subDirectory.Name, enumerator.Current); - Assert.False(enumerator.MoveNext()); + string fullPath = Path.Combine(testDirectory.FullName, item); + if (item.StartsWith("dir")) + { + Directory.Delete(fullPath); + } + else + { + File.Delete(fullPath); + } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEntry.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEntry.Unix.cs index 61ee454c6b3d03..1be3942011b2fd 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEntry.Unix.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEntry.Unix.cs @@ -10,7 +10,7 @@ namespace System.IO.Enumeration /// public unsafe ref partial struct FileSystemEntry { - internal Interop.Sys.DirectoryEntry _directoryEntry; + private Interop.Sys.DirectoryEntry _directoryEntry; private FileStatus _status; private Span _pathBuffer; private ReadOnlySpan _fullPath; @@ -32,38 +32,34 @@ internal static FileAttributes Initialize( entry._pathBuffer = pathBuffer; entry._fullPath = ReadOnlySpan.Empty; entry._fileName = ReadOnlySpan.Empty; - entry._status.InvalidateCaches(); + entry._status.InitiallyDirectory = false; bool isDirectory = directoryEntry.InodeType == Interop.Sys.NodeType.DT_DIR; bool isSymlink = directoryEntry.InodeType == Interop.Sys.NodeType.DT_LNK; bool isUnknown = directoryEntry.InodeType == Interop.Sys.NodeType.DT_UNKNOWN; - // Some operating systems don't have the inode type in the dirent structure, - // so we use DT_UNKNOWN as a sentinel value. As such, check if the dirent is a - // symlink or a directory. - if (isUnknown) + if (isDirectory) { - isSymlink = entry.IsSymbolicLink; - // Need to fail silently in case we are enumerating - isDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true); + entry._status.InitiallyDirectory = true; } - // Same idea as the directory check, just repeated for (and tweaked due to the - // nature of) symlinks. - // Whether we had the dirent structure or not, we treat a symlink to a directory as a directory, - // so we need to reflect that in our isDirectory variable. else if (isSymlink) { - // Need to fail silently in case we are enumerating - isDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true); + entry._status.InitiallyDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true); + } + else if (isUnknown) + { + entry._status.InitiallyDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true); + if (entry._status.IsSymbolicLink(entry.FullPath, continueOnError: true)) + { + entry._directoryEntry.InodeType = Interop.Sys.NodeType.DT_LNK; + } } - - entry._status.InitiallyDirectory = isDirectory; FileAttributes attributes = default; - if (isSymlink) + if (entry.IsSymbolicLink) attributes |= FileAttributes.ReparsePoint; - if (isDirectory) + if (entry.IsDirectory) attributes |= FileAttributes.Directory; return attributes; @@ -119,15 +115,41 @@ public ReadOnlySpan FileName // Windows never fails getting attributes, length, or time as that information comes back // with the native enumeration struct. As such we must not throw here. - public FileAttributes Attributes => _status.GetAttributes(FullPath, FileName); + public FileAttributes Attributes + { + get + { + FileAttributes attributes = _status.GetAttributes(FullPath, FileName, continueOnError: true); + if (attributes != (FileAttributes)(-1)) + { + return attributes; + } + + // File was removed before we retrieved attributes. + // Return what we know. + attributes = default; + + if (IsSymbolicLink) + attributes |= FileAttributes.ReparsePoint; + + if (IsDirectory) + attributes |= FileAttributes.Directory; + + if (FileStatus.IsNameHidden(FileName)) + attributes |= FileAttributes.Hidden; + + return attributes != default ? attributes : FileAttributes.Normal; + } + } public long Length => _status.GetLength(FullPath, continueOnError: true); public DateTimeOffset CreationTimeUtc => _status.GetCreationTime(FullPath, continueOnError: true); public DateTimeOffset LastAccessTimeUtc => _status.GetLastAccessTime(FullPath, continueOnError: true); public DateTimeOffset LastWriteTimeUtc => _status.GetLastWriteTime(FullPath, continueOnError: true); + public bool IsHidden => _status.IsHidden(FullPath, FileName, continueOnError: true); + internal bool IsReadOnly => _status.IsReadOnly(FullPath, continueOnError: true); + public bool IsDirectory => _status.InitiallyDirectory; - public bool IsHidden => _status.IsHidden(FullPath, FileName); - internal bool IsReadOnly => _status.IsReadOnly(FullPath); - internal bool IsSymbolicLink => _status.IsSymbolicLink(FullPath); + internal bool IsSymbolicLink => _directoryEntry.InodeType == Interop.Sys.NodeType.DT_LNK; public FileSystemInfo ToFileSystemInfo() { diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/FileStatus.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/IO/FileStatus.Unix.cs index ff4d8a86cb18c0..df2175b38ccee4 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/FileStatus.Unix.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/FileStatus.Unix.cs @@ -123,7 +123,7 @@ internal bool IsHidden(ReadOnlySpan path, ReadOnlySpan fileName, boo return HasHiddenFlag; } - internal bool IsNameHidden(ReadOnlySpan fileName) => fileName.Length > 0 && fileName[0] == '.'; + internal static bool IsNameHidden(ReadOnlySpan fileName) => fileName.Length > 0 && fileName[0] == '.'; // Returns true if the path points to a directory, or if the path is a symbolic link // that points to a directory @@ -139,9 +139,9 @@ internal bool IsSymbolicLink(ReadOnlySpan path, bool continueOnError = fal return HasSymbolicLinkFlag; } - internal FileAttributes GetAttributes(ReadOnlySpan path, ReadOnlySpan fileName) + internal FileAttributes GetAttributes(ReadOnlySpan path, ReadOnlySpan fileName, bool continueOnError = false) { - EnsureCachesInitialized(path); + EnsureCachesInitialized(path, continueOnError); if (!_exists) return (FileAttributes)(-1); @@ -326,8 +326,11 @@ private unsafe void SetAccessOrWriteTime(string path, DateTimeOffset time, bool internal long GetLength(ReadOnlySpan path, bool continueOnError = false) { + // For symbolic links, on Windows, Length returns zero and not the target file size. + // On Unix, it returns the length of the path stored in the link. + EnsureCachesInitialized(path, continueOnError); - return _fileCache.Size; + return IsFileCacheInitialized ? _fileCache.Size : 0; } // Tries to refresh the lstat cache (_fileCache).