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
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.
tmds marked this conversation as resolved.
Show resolved Hide resolved
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.
tmds marked this conversation as resolved.
Show resolved Hide resolved
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.
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
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.
tmds marked this conversation as resolved.
Show resolved Hide resolved
// On Unix, it returns the length of the path stored in the link.

EnsureCachesInitialized(path, continueOnError);
return _fileCache.Size;
return IsFileCacheInitialized ? _fileCache.Size : 0;
tmds marked this conversation as resolved.
Show resolved Hide resolved
}

// Tries to refresh the lstat cache (_fileCache).
Expand Down