Skip to content

Commit 5181d12

Browse files
authored
FileSystemEntry.Unix: ensure properties are available when file is deleted. (#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.
1 parent 37bf145 commit 5181d12

File tree

3 files changed

+197
-103
lines changed

3 files changed

+197
-103
lines changed

src/libraries/System.IO.FileSystem/tests/Enumeration/AttributeTests.cs

Lines changed: 145 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,106 +9,175 @@ namespace System.IO.Tests.Enumeration
99
{
1010
public class AttributeTests : FileSystemTest
1111
{
12-
private class DefaultFileAttributes : FileSystemEnumerator<string>
12+
private class FileSystemEntryProperties
1313
{
14-
public DefaultFileAttributes(string directory, EnumerationOptions options)
14+
public string FileName { get; init; }
15+
public FileAttributes Attributes { get; init; }
16+
public DateTimeOffset CreationTimeUtc { get; init; }
17+
public bool IsDirectory { get; init; }
18+
public bool IsHidden { get; init; }
19+
public DateTimeOffset LastAccessTimeUtc { get; init; }
20+
public DateTimeOffset LastWriteTimeUtc { get; init; }
21+
public long Length { get; init; }
22+
public string Directory { get; init; }
23+
public string FullPath { get; init; }
24+
public string SpecifiedFullPath { get; init; }
25+
}
26+
27+
private class GetPropertiesEnumerator : FileSystemEnumerator<FileSystemEntryProperties>
28+
{
29+
public GetPropertiesEnumerator(string directory, EnumerationOptions options)
1530
: base(directory, options)
16-
{
17-
}
31+
{ }
1832

1933
protected override bool ContinueOnError(int error)
2034
{
2135
Assert.False(true, $"Should not have errored {error}");
2236
return false;
2337
}
2438

25-
protected override bool ShouldIncludeEntry(ref FileSystemEntry entry)
26-
=> !entry.IsDirectory;
27-
28-
protected override string TransformEntry(ref FileSystemEntry entry)
39+
protected override FileSystemEntryProperties TransformEntry(ref FileSystemEntry entry)
2940
{
30-
string path = entry.ToFullPath();
31-
File.Delete(path);
32-
33-
// Attributes require a stat call on Unix- ensure that we have the right attributes
34-
// even if the returned file is deleted.
35-
Assert.Equal(FileAttributes.Normal, entry.Attributes);
36-
Assert.Equal(path, entry.ToFullPath());
37-
return new string(entry.FileName);
41+
return new FileSystemEntryProperties
42+
{
43+
FileName = new string(entry.FileName),
44+
Attributes = entry.Attributes,
45+
CreationTimeUtc = entry.CreationTimeUtc,
46+
IsDirectory = entry.IsDirectory,
47+
IsHidden = entry.IsHidden,
48+
LastAccessTimeUtc = entry.LastAccessTimeUtc,
49+
LastWriteTimeUtc = entry.LastWriteTimeUtc,
50+
Length = entry.Length,
51+
Directory = new string(entry.Directory),
52+
FullPath = entry.ToFullPath(),
53+
SpecifiedFullPath = entry.ToSpecifiedFullPath()
54+
};
3855
}
3956
}
4057

41-
[Fact]
42-
public void FileAttributesAreExpected()
58+
// The test is performed using two items with different properties (file/dir, file length)
59+
// to check cached values from the previous entry don't leak into the non-existing entry.
60+
[InlineData("dir1", "dir2")]
61+
[InlineData("dir1", "file2")]
62+
[InlineData("dir1", "link2")]
63+
[InlineData("file1", "file2")]
64+
[InlineData("file1", "dir2")]
65+
[InlineData("file1", "link2")]
66+
[InlineData("link1", "file2")]
67+
[InlineData("link1", "dir2")]
68+
[InlineData("link1", "link2")]
69+
[Theory]
70+
public void PropertiesWhenItemNoLongerExists(string item1, string item2)
4371
{
4472
DirectoryInfo testDirectory = Directory.CreateDirectory(GetTestFilePath());
45-
FileInfo fileOne = new FileInfo(Path.Combine(testDirectory.FullName, GetTestFileName()));
46-
47-
fileOne.Create().Dispose();
48-
49-
if (PlatformDetection.IsWindows)
50-
{
51-
// Archive should always be set on a new file. Clear it and other expected flags to
52-
// see that we get "Normal" as the default when enumerating.
53-
54-
Assert.True((fileOne.Attributes & FileAttributes.Archive) != 0);
55-
fileOne.Attributes &= ~(FileAttributes.Archive | FileAttributes.NotContentIndexed);
56-
}
57-
58-
using (var enumerator = new DefaultFileAttributes(testDirectory.FullName, new EnumerationOptions()))
59-
{
60-
Assert.True(enumerator.MoveNext());
61-
Assert.Equal(fileOne.Name, enumerator.Current);
62-
Assert.False(enumerator.MoveNext());
63-
}
64-
}
6573

66-
private class DefaultDirectoryAttributes : FileSystemEnumerator<string>
67-
{
68-
public DefaultDirectoryAttributes(string directory, EnumerationOptions options)
69-
: base(directory, options)
70-
{
71-
}
74+
FileSystemInfo item1Info = CreateItem(testDirectory, item1);
75+
FileSystemInfo item2Info = CreateItem(testDirectory, item2);
7276

73-
protected override bool ShouldIncludeEntry(ref FileSystemEntry entry)
74-
=> entry.IsDirectory;
75-
76-
protected override bool ContinueOnError(int error)
77+
using (var enumerator = new GetPropertiesEnumerator(testDirectory.FullName, new EnumerationOptions() { AttributesToSkip = 0 }))
7778
{
78-
Assert.False(true, $"Should not have errored {error}");
79-
return false;
79+
// Move to the first item.
80+
Assert.True(enumerator.MoveNext(), "Move first");
81+
FileSystemEntryProperties entry = enumerator.Current;
82+
83+
Assert.True(entry.FileName == item1 || entry.FileName == item2, "Unexpected item");
84+
85+
// Delete both items.
86+
DeleteItem(testDirectory, item1);
87+
DeleteItem(testDirectory, item2);
88+
89+
// Move to the second item.
90+
FileSystemInfo expected = entry.FileName == item1 ? item2Info : item1Info;
91+
Assert.True(enumerator.MoveNext(), "Move second");
92+
entry = enumerator.Current;
93+
94+
// Names and paths.
95+
Assert.Equal(expected.Name, entry.FileName);
96+
Assert.Equal(testDirectory.FullName, entry.Directory);
97+
Assert.Equal(expected.FullName, entry.FullPath);
98+
Assert.Equal(expected.FullName, entry.SpecifiedFullPath);
99+
100+
// Values determined during enumeration.
101+
if (PlatformDetection.IsBrowser)
102+
{
103+
// For Browser, all items are typed as DT_UNKNOWN.
104+
Assert.False(entry.IsDirectory);
105+
Assert.Equal(entry.FileName.StartsWith('.') ? FileAttributes.Hidden : FileAttributes.Normal, entry.Attributes);
106+
}
107+
else
108+
{
109+
Assert.Equal(expected is DirectoryInfo, entry.IsDirectory);
110+
Assert.Equal(expected.Attributes, entry.Attributes);
111+
}
112+
113+
if (PlatformDetection.IsWindows)
114+
{
115+
Assert.Equal((expected.Attributes & FileAttributes.Hidden) != 0, entry.IsHidden);
116+
Assert.Equal(expected.CreationTimeUtc, entry.CreationTimeUtc);
117+
Assert.Equal(expected.LastAccessTimeUtc, entry.LastAccessTimeUtc);
118+
Assert.Equal(expected.LastWriteTimeUtc, entry.LastWriteTimeUtc);
119+
if (expected is FileInfo fileInfo)
120+
{
121+
Assert.Equal(fileInfo.Length, entry.Length);
122+
}
123+
}
124+
else
125+
{
126+
// On Unix, these values were not determined during enumeration.
127+
// Because the file was deleted, the values can no longer be retrieved and sensible defaults are returned.
128+
Assert.Equal(entry.FileName.StartsWith('.'), entry.IsHidden);
129+
DateTimeOffset defaultTime = new DateTimeOffset(DateTime.FromFileTimeUtc(0));
130+
Assert.Equal(defaultTime, entry.CreationTimeUtc);
131+
Assert.Equal(defaultTime, entry.LastAccessTimeUtc);
132+
Assert.Equal(defaultTime, entry.LastWriteTimeUtc);
133+
Assert.Equal(0, entry.Length);
134+
}
135+
136+
Assert.False(enumerator.MoveNext(), "Move final");
80137
}
81138

82-
protected override string TransformEntry(ref FileSystemEntry entry)
83-
{
84-
string path = entry.ToFullPath();
85-
Directory.Delete(path);
86-
87-
// Attributes require a stat call on Unix- ensure that we have the right attributes
88-
// even if the returned directory is deleted.
89-
Assert.Equal(FileAttributes.Directory, entry.Attributes);
90-
Assert.Equal(path, entry.ToFullPath());
91-
return new string(entry.FileName);
92-
}
93-
}
94-
95-
[Fact]
96-
public void DirectoryAttributesAreExpected()
97-
{
98-
DirectoryInfo testDirectory = Directory.CreateDirectory(GetTestFilePath());
99-
DirectoryInfo subDirectory = Directory.CreateDirectory(Path.Combine(testDirectory.FullName, GetTestFileName()));
100-
101-
if (PlatformDetection.IsWindows)
139+
static FileSystemInfo CreateItem(DirectoryInfo testDirectory, string item)
102140
{
103-
// Clear possible extra flags to see that we get Directory
104-
subDirectory.Attributes &= ~FileAttributes.NotContentIndexed;
141+
string fullPath = Path.Combine(testDirectory.FullName, item);
142+
143+
// use the last char to have different lengths for different files.
144+
Assert.True(item.EndsWith('1') || item.EndsWith('2'));
145+
int length = (int)item[item.Length - 1];
146+
147+
if (item.StartsWith("dir"))
148+
{
149+
Directory.CreateDirectory(fullPath);
150+
var info = new DirectoryInfo(fullPath);
151+
info.Refresh();
152+
return info;
153+
}
154+
else if (item.StartsWith("link"))
155+
{
156+
File.CreateSymbolicLink(fullPath, new string('_', length));
157+
var info = new FileInfo(fullPath);
158+
info.Refresh();
159+
return info;
160+
}
161+
else
162+
{
163+
File.WriteAllBytes(fullPath, new byte[length]);
164+
var info = new FileInfo(fullPath);
165+
info.Refresh();
166+
return info;
167+
}
105168
}
106169

107-
using (var enumerator = new DefaultDirectoryAttributes(testDirectory.FullName, new EnumerationOptions()))
170+
static void DeleteItem(DirectoryInfo testDirectory, string item)
108171
{
109-
Assert.True(enumerator.MoveNext());
110-
Assert.Equal(subDirectory.Name, enumerator.Current);
111-
Assert.False(enumerator.MoveNext());
172+
string fullPath = Path.Combine(testDirectory.FullName, item);
173+
if (item.StartsWith("dir"))
174+
{
175+
Directory.Delete(fullPath);
176+
}
177+
else
178+
{
179+
File.Delete(fullPath);
180+
}
112181
}
113182
}
114183

src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEntry.Unix.cs

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace System.IO.Enumeration
1010
/// </summary>
1111
public unsafe ref partial struct FileSystemEntry
1212
{
13-
internal Interop.Sys.DirectoryEntry _directoryEntry;
13+
private Interop.Sys.DirectoryEntry _directoryEntry;
1414
private FileStatus _status;
1515
private Span<char> _pathBuffer;
1616
private ReadOnlySpan<char> _fullPath;
@@ -32,38 +32,34 @@ internal static FileAttributes Initialize(
3232
entry._pathBuffer = pathBuffer;
3333
entry._fullPath = ReadOnlySpan<char>.Empty;
3434
entry._fileName = ReadOnlySpan<char>.Empty;
35-
3635
entry._status.InvalidateCaches();
36+
entry._status.InitiallyDirectory = false;
3737

3838
bool isDirectory = directoryEntry.InodeType == Interop.Sys.NodeType.DT_DIR;
3939
bool isSymlink = directoryEntry.InodeType == Interop.Sys.NodeType.DT_LNK;
4040
bool isUnknown = directoryEntry.InodeType == Interop.Sys.NodeType.DT_UNKNOWN;
4141

42-
// Some operating systems don't have the inode type in the dirent structure,
43-
// so we use DT_UNKNOWN as a sentinel value. As such, check if the dirent is a
44-
// symlink or a directory.
45-
if (isUnknown)
42+
if (isDirectory)
4643
{
47-
isSymlink = entry.IsSymbolicLink;
48-
// Need to fail silently in case we are enumerating
49-
isDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true);
44+
entry._status.InitiallyDirectory = true;
5045
}
51-
// Same idea as the directory check, just repeated for (and tweaked due to the
52-
// nature of) symlinks.
53-
// Whether we had the dirent structure or not, we treat a symlink to a directory as a directory,
54-
// so we need to reflect that in our isDirectory variable.
5546
else if (isSymlink)
5647
{
57-
// Need to fail silently in case we are enumerating
58-
isDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true);
48+
entry._status.InitiallyDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true);
49+
}
50+
else if (isUnknown)
51+
{
52+
entry._status.InitiallyDirectory = entry._status.IsDirectory(entry.FullPath, continueOnError: true);
53+
if (entry._status.IsSymbolicLink(entry.FullPath, continueOnError: true))
54+
{
55+
entry._directoryEntry.InodeType = Interop.Sys.NodeType.DT_LNK;
56+
}
5957
}
60-
61-
entry._status.InitiallyDirectory = isDirectory;
6258

6359
FileAttributes attributes = default;
64-
if (isSymlink)
60+
if (entry.IsSymbolicLink)
6561
attributes |= FileAttributes.ReparsePoint;
66-
if (isDirectory)
62+
if (entry.IsDirectory)
6763
attributes |= FileAttributes.Directory;
6864

6965
return attributes;
@@ -119,15 +115,41 @@ public ReadOnlySpan<char> FileName
119115

120116
// Windows never fails getting attributes, length, or time as that information comes back
121117
// with the native enumeration struct. As such we must not throw here.
122-
public FileAttributes Attributes => _status.GetAttributes(FullPath, FileName);
118+
public FileAttributes Attributes
119+
{
120+
get
121+
{
122+
FileAttributes attributes = _status.GetAttributes(FullPath, FileName, continueOnError: true);
123+
if (attributes != (FileAttributes)(-1))
124+
{
125+
return attributes;
126+
}
127+
128+
// File was removed before we retrieved attributes.
129+
// Return what we know.
130+
attributes = default;
131+
132+
if (IsSymbolicLink)
133+
attributes |= FileAttributes.ReparsePoint;
134+
135+
if (IsDirectory)
136+
attributes |= FileAttributes.Directory;
137+
138+
if (FileStatus.IsNameHidden(FileName))
139+
attributes |= FileAttributes.Hidden;
140+
141+
return attributes != default ? attributes : FileAttributes.Normal;
142+
}
143+
}
123144
public long Length => _status.GetLength(FullPath, continueOnError: true);
124145
public DateTimeOffset CreationTimeUtc => _status.GetCreationTime(FullPath, continueOnError: true);
125146
public DateTimeOffset LastAccessTimeUtc => _status.GetLastAccessTime(FullPath, continueOnError: true);
126147
public DateTimeOffset LastWriteTimeUtc => _status.GetLastWriteTime(FullPath, continueOnError: true);
148+
public bool IsHidden => _status.IsHidden(FullPath, FileName, continueOnError: true);
149+
internal bool IsReadOnly => _status.IsReadOnly(FullPath, continueOnError: true);
150+
127151
public bool IsDirectory => _status.InitiallyDirectory;
128-
public bool IsHidden => _status.IsHidden(FullPath, FileName);
129-
internal bool IsReadOnly => _status.IsReadOnly(FullPath);
130-
internal bool IsSymbolicLink => _status.IsSymbolicLink(FullPath);
152+
internal bool IsSymbolicLink => _directoryEntry.InodeType == Interop.Sys.NodeType.DT_LNK;
131153

132154
public FileSystemInfo ToFileSystemInfo()
133155
{

src/libraries/System.Private.CoreLib/src/System/IO/FileStatus.Unix.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ internal bool IsHidden(ReadOnlySpan<char> path, ReadOnlySpan<char> fileName, boo
123123
return HasHiddenFlag;
124124
}
125125

126-
internal bool IsNameHidden(ReadOnlySpan<char> fileName) => fileName.Length > 0 && fileName[0] == '.';
126+
internal static bool IsNameHidden(ReadOnlySpan<char> fileName) => fileName.Length > 0 && fileName[0] == '.';
127127

128128
// Returns true if the path points to a directory, or if the path is a symbolic link
129129
// that points to a directory
@@ -139,9 +139,9 @@ internal bool IsSymbolicLink(ReadOnlySpan<char> path, bool continueOnError = fal
139139
return HasSymbolicLinkFlag;
140140
}
141141

142-
internal FileAttributes GetAttributes(ReadOnlySpan<char> path, ReadOnlySpan<char> fileName)
142+
internal FileAttributes GetAttributes(ReadOnlySpan<char> path, ReadOnlySpan<char> fileName, bool continueOnError = false)
143143
{
144-
EnsureCachesInitialized(path);
144+
EnsureCachesInitialized(path, continueOnError);
145145

146146
if (!_exists)
147147
return (FileAttributes)(-1);
@@ -342,8 +342,11 @@ private unsafe void SetAccessOrWriteTimeCore(string path, DateTimeOffset time, b
342342

343343
internal long GetLength(ReadOnlySpan<char> path, bool continueOnError = false)
344344
{
345+
// For symbolic links, on Windows, Length returns zero and not the target file size.
346+
// On Unix, it returns the length of the path stored in the link.
347+
345348
EnsureCachesInitialized(path, continueOnError);
346-
return _fileCache.Size;
349+
return IsFileCacheInitialized ? _fileCache.Size : 0;
347350
}
348351

349352
// Tries to refresh the lstat cache (_fileCache).

0 commit comments

Comments
 (0)