Skip to content

Commit

Permalink
FileSystemEntry.Unix: ensure properties are available when file is de…
Browse files Browse the repository at this point in the history
…leted. (#60214)

* FileSystemEntry.Unix: ensure attributes are available when file is deleted.

When the file no longer exists, we create attributes based on what we know.

The test for this was passing because it cached the attributes before the
item was deleted due to enumerating with skipping FileAttributes.Hidden.

* GetLength: fix reading from uninitialized cache.
  • Loading branch information
tmds authored Nov 18, 2021
1 parent 37bf145 commit 5181d12
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 103 deletions.
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 @@ -342,8 +342,11 @@ private unsafe void SetAccessOrWriteTimeCore(string path, DateTimeOffset time, b

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

0 comments on commit 5181d12

Please sign in to comment.