Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FileSystemEntry.Unix: ensure properties are available when file is deleted. #60214

Merged
merged 12 commits into from
Nov 18, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,28 @@ public static void Contains(string value, string substring)
Assert.Contains(substring, value, StringComparison.Ordinal);
}

/// <summary>
/// Validate that a given value is equal to another value.
/// </summary>
/// <param name="expected">The expected value.</param>
/// <param name="actual">The value to be compared against.</param>
public static void EqualTo<T>(T expected, T actual, string userMessage = null)
{
if (expected == null && actual == null)
return;

bool equal = expected != null && expected switch
{
IEquatable<T> equatable => equatable.Equals(actual),
IComparable<T> comparableT => comparableT.CompareTo(actual) == 0,
IComparable comparable => comparable.CompareTo(actual) == 0,
_ => throw new NotSupportedException($"{nameof(EqualTo)} is not supported for {typeof(T)}.")
};

if (!equal)
throw new XunitException(AddOptionalUserMessage($"Expected: {expected} is not equal to {actual}", userMessage));
}

tmds marked this conversation as resolved.
Show resolved Hide resolved
/// <summary>
/// Validate that a given value is greater than another value.
/// </summary>
Expand Down
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.
AssertExtensions.EqualTo(expected.Name, entry.FileName, "Name");
AssertExtensions.EqualTo(testDirectory.FullName, entry.Directory, "Directory");
AssertExtensions.EqualTo(expected.FullName, entry.FullPath, "FullPath");
AssertExtensions.EqualTo(expected.FullName, entry.SpecifiedFullPath, "SpecifiedFullPath");
tmds marked this conversation as resolved.
Show resolved Hide resolved

// Values determined during enumeration.
if (PlatformDetection.IsBrowser)
{
// For Browser, all items are typed as DT_UNKNOWN.
tmds marked this conversation as resolved.
Show resolved Hide resolved
AssertExtensions.EqualTo(false, entry.IsDirectory, "IsDirectory");
AssertExtensions.EqualTo(entry.FileName.StartsWith('.') ? FileAttributes.Hidden : FileAttributes.Normal, entry.Attributes, "Attributes");
}
else
{
AssertExtensions.EqualTo(expected is DirectoryInfo, entry.IsDirectory, "IsDirectory");
AssertExtensions.EqualTo(expected.Attributes, entry.Attributes, "Attributes");
tmds marked this conversation as resolved.
Show resolved Hide resolved
}

if (PlatformDetection.IsWindows)
{
AssertExtensions.EqualTo((expected.Attributes & FileAttributes.Hidden) != 0, entry.IsHidden, "IsHidden");
AssertExtensions.EqualTo(expected.CreationTimeUtc, entry.CreationTimeUtc, "CreationTimeUtc");
AssertExtensions.EqualTo(expected.LastAccessTimeUtc, entry.LastAccessTimeUtc, "LastAccessTimeUtc");
AssertExtensions.EqualTo(expected.LastWriteTimeUtc, entry.LastWriteTimeUtc, "LastWriteTimeUtc");
if (expected is FileInfo fileInfo)
{
AssertExtensions.EqualTo(fileInfo.Length, entry.Length, "Length");
tmds marked this conversation as resolved.
Show resolved Hide resolved
}
}
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.
tmds marked this conversation as resolved.
Show resolved Hide resolved
AssertExtensions.EqualTo(entry.FileName.StartsWith('.'), entry.IsHidden, "IsHidden");
DateTimeOffset defaultTime = new DateTimeOffset(DateTime.FromFileTimeUtc(0));
AssertExtensions.EqualTo(defaultTime, entry.CreationTimeUtc, "CreationTimeUtc");
AssertExtensions.EqualTo(defaultTime, entry.LastAccessTimeUtc, "LastAccessTimeUtc");
AssertExtensions.EqualTo(defaultTime, entry.LastWriteTimeUtc, "LastWriteTimeUtc");
AssertExtensions.EqualTo(0, entry.Length, "Length");
tmds marked this conversation as resolved.
Show resolved Hide resolved
}

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.
carlossanlop marked this conversation as resolved.
Show resolved Hide resolved
if (isUnknown)
if (isDirectory)
{
isSymlink = entry.IsSymbolicLink;
// Need to fail silently in case we are enumerating
isDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true);
carlossanlop marked this conversation as resolved.
Show resolved Hide resolved
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;
}
carlossanlop marked this conversation as resolved.
Show resolved Hide resolved
}

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
tmds marked this conversation as resolved.
Show resolved Hide resolved
{
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.
tmds marked this conversation as resolved.
Show resolved Hide resolved
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
Loading