Skip to content
221 changes: 145 additions & 76 deletions src/libraries/System.IO.FileSystem/tests/Enumeration/AttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,106 +9,175 @@ namespace System.IO.Tests.Enumeration
{
public class AttributeTests : FileSystemTest
{
private class DefaultFileAttributes : FileSystemEnumerator<string>
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<FileSystemEntryProperties>
{
public GetPropertiesEnumerator(string directory, EnumerationOptions options)
: base(directory, options)
{
}
{ }

protected override bool ContinueOnError(int error)
{
Assert.False(true, $"Should not have errored {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<string>
{
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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace System.IO.Enumeration
/// </summary>
public unsafe ref partial struct FileSystemEntry
{
internal Interop.Sys.DirectoryEntry _directoryEntry;
private Interop.Sys.DirectoryEntry _directoryEntry;
private FileStatus _status;
private Span<char> _pathBuffer;
private ReadOnlySpan<char> _fullPath;
Expand All @@ -32,38 +32,34 @@ internal static FileAttributes Initialize(
entry._pathBuffer = pathBuffer;
entry._fullPath = ReadOnlySpan<char>.Empty;
entry._fileName = ReadOnlySpan<char>.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;
Expand Down Expand Up @@ -119,15 +115,41 @@ public ReadOnlySpan<char> 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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ internal bool IsHidden(ReadOnlySpan<char> path, ReadOnlySpan<char> fileName, boo
return HasHiddenFlag;
}

internal bool IsNameHidden(ReadOnlySpan<char> fileName) => fileName.Length > 0 && fileName[0] == '.';
internal static bool IsNameHidden(ReadOnlySpan<char> 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
Expand All @@ -139,9 +139,9 @@ internal bool IsSymbolicLink(ReadOnlySpan<char> path, bool continueOnError = fal
return HasSymbolicLinkFlag;
}

internal FileAttributes GetAttributes(ReadOnlySpan<char> path, ReadOnlySpan<char> fileName)
internal FileAttributes GetAttributes(ReadOnlySpan<char> path, ReadOnlySpan<char> fileName, bool continueOnError = false)
{
EnsureCachesInitialized(path);
EnsureCachesInitialized(path, continueOnError);

if (!_exists)
return (FileAttributes)(-1);
Expand Down Expand Up @@ -326,8 +326,11 @@ private unsafe void SetAccessOrWriteTime(string path, DateTimeOffset time, bool

internal long GetLength(ReadOnlySpan<char> 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).
Expand Down