Skip to content

Commit dc580c1

Browse files
jozkeejkotas
andauthored
Remove readdir_r entirely and handle filenames > 255 bytes (#116639)
* Increase d_name size * Handle filenames that wouldn't fit in a 256 decoding buffer * Remove readdir_r * Address feedback * Apply suggestions from code review --------- Co-authored-by: Jan Kotas <jkotas@microsoft.com>
1 parent 528b402 commit dc580c1

File tree

13 files changed

+145
-175
lines changed

13 files changed

+145
-175
lines changed

src/libraries/Common/src/Interop/Unix/System.Native/Interop.ReadDir.cs

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,39 +29,32 @@ internal unsafe struct DirectoryEntry
2929
internal byte* Name;
3030
internal int NameLength;
3131
internal NodeType InodeType;
32-
internal const int NameBufferSize = 256; // sizeof(dirent->d_name) == NAME_MAX + 1
3332

3433
internal ReadOnlySpan<char> GetName(Span<char> buffer)
3534
{
36-
// -1 for null terminator (buffer will not include one),
37-
// and -1 because GetMaxCharCount pessimistically assumes the buffer may start with a partial surrogate
38-
Debug.Assert(buffer.Length >= Encoding.UTF8.GetMaxCharCount(NameBufferSize - 1 - 1));
39-
4035
Debug.Assert(Name != null, "should not have a null name");
4136

4237
ReadOnlySpan<byte> nameBytes = (NameLength == -1)
43-
// In this case the struct was allocated via struct dirent *readdir(DIR *dirp);
44-
? new ReadOnlySpan<byte>(Name, new ReadOnlySpan<byte>(Name, NameBufferSize).IndexOf<byte>(0))
38+
? MemoryMarshal.CreateReadOnlySpanFromNullTerminated(Name)
4539
: new ReadOnlySpan<byte>(Name, NameLength);
4640

4741
Debug.Assert(nameBytes.Length > 0, "we shouldn't have gotten a garbage value from the OS");
4842

49-
int charCount = Encoding.UTF8.GetChars(nameBytes, buffer);
50-
ReadOnlySpan<char> value = buffer.Slice(0, charCount);
51-
Debug.Assert(NameLength != -1 || !value.Contains('\0'), "should not have embedded nulls if we parsed the end of string");
52-
return value;
43+
ReadOnlySpan<char> result = !Encoding.UTF8.TryGetChars(nameBytes, buffer, out int charsWritten)
44+
? Encoding.UTF8.GetString(nameBytes) // Fallback to allocation since this is a rare case
45+
: buffer.Slice(0, charsWritten);
46+
47+
Debug.Assert(!result.Contains('\0'), "should not have embedded nulls");
48+
49+
return result;
5350
}
5451
}
5552

5653
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_OpenDir", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)]
5754
internal static partial IntPtr OpenDir(string path);
5855

59-
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_GetReadDirRBufferSize", SetLastError = false)]
60-
[SuppressGCTransition]
61-
internal static partial int GetReadDirRBufferSize();
62-
63-
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_ReadDirR")]
64-
internal static unsafe partial int ReadDirR(IntPtr dir, byte* buffer, int bufferSize, DirectoryEntry* outputEntry);
56+
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_ReadDir")]
57+
internal static unsafe partial int ReadDir(IntPtr dir, DirectoryEntry* outputEntry);
6558

6659
[LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_CloseDir", SetLastError = true)]
6760
internal static partial int CloseDir(IntPtr dir);

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Diagnostics;
55
using System.Runtime.InteropServices;
6+
using System.Runtime.CompilerServices;
67

78
namespace System.IO.Enumeration
89
{
@@ -11,6 +12,9 @@ namespace System.IO.Enumeration
1112
/// </summary>
1213
public unsafe ref partial struct FileSystemEntry
1314
{
15+
// A reasonable size for a decoding buffer. If it's not enough, we fall back to allocating a string.
16+
// Some filesystem can have filenames larger than that.
17+
private const int DecodedNameBufferLength = 256;
1418
private Interop.Sys.DirectoryEntry _directoryEntry;
1519
private bool _isDirectory;
1620
private FileStatus _status;
@@ -19,10 +23,10 @@ public unsafe ref partial struct FileSystemEntry
1923
private ReadOnlySpan<char> _fileName;
2024
private FileNameBuffer _fileNameBuffer;
2125

22-
// Wrap the fixed buffer to workaround visibility issues in api compat verification
26+
[InlineArray(DecodedNameBufferLength)]
2327
private struct FileNameBuffer
2428
{
25-
internal fixed char _buffer[Interop.Sys.DirectoryEntry.NameBufferSize];
29+
internal char _char0;
2630
}
2731

2832
internal static FileAttributes Initialize(
@@ -95,7 +99,9 @@ public ReadOnlySpan<char> FileName
9599
{
96100
if (_directoryEntry.NameLength != 0 && _fileName.Length == 0)
97101
{
98-
Span<char> buffer = MemoryMarshal.CreateSpan(ref _fileNameBuffer._buffer[0], Interop.Sys.DirectoryEntry.NameBufferSize);
102+
// Use unsafe API to create the Span to allow it to escape. It is safe as long as
103+
// the whole FileSystemEntry is never copied.
104+
Span<char> buffer = MemoryMarshal.CreateSpan(ref _fileNameBuffer._char0, DecodedNameBufferLength);
99105
_fileName = _directoryEntry.GetName(buffer);
100106
}
101107

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

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ public abstract unsafe partial class FileSystemEnumerator<TResult> : CriticalFin
2929

3030
// Used for creating full paths
3131
private char[]? _pathBuffer;
32-
// Used to get the raw entry data
33-
private byte[]? _entryBuffer;
3432

3533
private void Init()
3634
{
@@ -45,8 +43,6 @@ private void Init()
4543
try
4644
{
4745
_pathBuffer = ArrayPool<char>.Shared.Rent(StandardBufferSize);
48-
int size = Interop.Sys.GetReadDirRBufferSize();
49-
_entryBuffer = size > 0 ? ArrayPool<byte>.Shared.Rent(size) : null;
5046
}
5147
catch
5248
{
@@ -103,13 +99,11 @@ public bool MoveNext()
10399
// If HAVE_READDIR_R is defined for the platform FindNextEntry depends on _entryBuffer being fixed since
104100
// _entry will point to a string in the middle of the array. If the array is not fixed GC can move it after
105101
// the native call and _entry will point to a bogus file name.
106-
fixed (byte* entryBufferPtr = _entryBuffer)
102+
do
107103
{
108-
do
109-
{
110-
FindNextEntry(entryBufferPtr, _entryBuffer == null ? 0 : _entryBuffer.Length);
111-
if (_lastEntryFound)
112-
return false;
104+
FindNextEntry();
105+
if (_lastEntryFound)
106+
return false;
113107

114108
FileAttributes attributes = FileSystemEntry.Initialize(
115109
ref entry, _entry, _currentPath, _rootDirectory, _originalRootDirectory, new Span<char>(_pathBuffer));
@@ -152,32 +146,23 @@ public bool MoveNext()
152146
}
153147
}
154148

155-
if (ShouldIncludeEntry(ref entry))
156-
{
157-
_current = TransformEntry(ref entry);
158-
return true;
159-
}
160-
} while (true);
161-
}
149+
if (ShouldIncludeEntry(ref entry))
150+
{
151+
_current = TransformEntry(ref entry);
152+
return true;
153+
}
154+
} while (true);
162155
}
163156

164157
bool ShouldSkip(FileAttributes attributeToSkip) => (_options.AttributesToSkip & attributeToSkip) != 0;
165158
}
166159

167160
private unsafe void FindNextEntry()
168-
{
169-
fixed (byte* entryBufferPtr = _entryBuffer)
170-
{
171-
FindNextEntry(entryBufferPtr, _entryBuffer == null ? 0 : _entryBuffer.Length);
172-
}
173-
}
174-
175-
private unsafe void FindNextEntry(byte* entryBufferPtr, int bufferLength)
176161
{
177162
int result;
178163
fixed (Interop.Sys.DirectoryEntry* e = &_entry)
179164
{
180-
result = Interop.Sys.ReadDirR(_directoryHandle, entryBufferPtr, bufferLength, e);
165+
result = Interop.Sys.ReadDir(_directoryHandle, e);
181166
}
182167

183168
switch (result)
@@ -245,12 +230,6 @@ private void InternalDispose(bool disposing)
245230
_pathBuffer = null;
246231
ArrayPool<char>.Shared.Return(pathBuffer);
247232
}
248-
249-
if (_entryBuffer is byte[] entryBuffer)
250-
{
251-
_entryBuffer = null;
252-
ArrayPool<byte>.Shared.Return(entryBuffer);
253-
}
254233
}
255234
}
256235

src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Unix.NonAndroid.cs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -260,17 +260,6 @@ private static List<string> ParseTimeZoneIds(StreamReader reader)
260260
return id;
261261
}
262262

263-
private static string? GetDirectoryEntryFullPath(ref Interop.Sys.DirectoryEntry dirent, string currentPath)
264-
{
265-
ReadOnlySpan<char> direntName = dirent.GetName(stackalloc char[Interop.Sys.DirectoryEntry.NameBufferSize]);
266-
267-
if ((direntName.Length == 1 && direntName[0] == '.') ||
268-
(direntName.Length == 2 && direntName[0] == '.' && direntName[1] == '.'))
269-
return null;
270-
271-
return Path.Join(currentPath.AsSpan(), direntName);
272-
}
273-
274263
private static bool CompareTimeZoneFile(string filePath, byte[] buffer, byte[] rawData)
275264
{
276265
try

src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/Directory/EnumerableTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections.Concurrent;
45
using System.Collections.Generic;
56
using System.Threading;
67
using System.Threading.Tasks;
@@ -34,6 +35,26 @@ public void FileEnumeratorIsThreadSafe()
3435
}
3536
}
3637

38+
[Fact]
39+
public void FileEnumeratorIsThreadSafe_ParallelForEach()
40+
{
41+
List<string> expected = [];
42+
string directory = Directory.CreateDirectory(GetTestFilePath()).FullName;
43+
for (int i = 0; i < 100; i++)
44+
{
45+
string file = Path.Join(directory, GetTestFileName());
46+
File.Create(file).Dispose();
47+
expected.Add(file);
48+
}
49+
50+
for (int i = 0; i < 100; i++) // test multiple times to ensure thread safety.
51+
{
52+
ConcurrentBag<string> result = [];
53+
ParallelLoopResult parallelResult = Parallel.ForEach(Directory.EnumerateFiles(directory), f => result.Add(f));
54+
AssertExtensions.CollectionEqual(expected, result, StringComparer.Ordinal);
55+
}
56+
}
57+
3758
[Fact]
3859
public void EnumerateDirectories_NonBreakingSpace()
3960
{
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using Xunit.Sdk;
6+
7+
namespace System.IO.ManualTests
8+
{
9+
public class NtfsOnLinuxSetup : IDisposable
10+
{
11+
public NtfsOnLinuxSetup()
12+
{
13+
if (!NtfsOnLinuxTests.IsManualTestsEnabledAndElevated)
14+
throw new XunitException("Set MANUAL_TESTS envvar and run as elevated to execute this test setup.");
15+
16+
ExecuteShell("""
17+
dd if=/dev/zero of=my_loop_device.img bs=1M count=100
18+
losetup /dev/loop99 my_loop_device.img
19+
mkfs -t ntfs /dev/loop99
20+
mkdir -p /mnt/ntfs
21+
mount /dev/loop99 /mnt/ntfs
22+
""");
23+
}
24+
25+
public void Dispose()
26+
{
27+
ExecuteShell("""
28+
umount /mnt/ntfs
29+
losetup -d /dev/loop99
30+
rm my_loop_device.img
31+
""");
32+
}
33+
34+
private static void ExecuteShell(string command)
35+
{
36+
using Process process = new Process
37+
{
38+
StartInfo = new ProcessStartInfo
39+
{
40+
FileName = "/bin/sh",
41+
ArgumentList = { "-c", command },
42+
RedirectStandardOutput = true,
43+
RedirectStandardError = true,
44+
UseShellExecute = false
45+
}
46+
};
47+
process.OutputDataReceived += (sender, e) => Console.WriteLine($"[OUTPUT] {e.Data}");
48+
process.ErrorDataReceived += (sender, e) => Console.WriteLine($"[ERROR] {e.Data}");
49+
50+
process.Start();
51+
process.BeginOutputReadLine();
52+
process.BeginErrorReadLine();
53+
process.WaitForExit();
54+
}
55+
}
56+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Linq;
5+
using System.Text;
6+
using Xunit;
7+
8+
namespace System.IO.ManualTests
9+
{
10+
public class NtfsOnLinuxTests : IClassFixture<NtfsOnLinuxSetup>
11+
{
12+
internal static bool IsManualTestsEnabledAndElevated => FileSystemManualTests.ManualTestsEnabled && AdminHelpers.IsProcessElevated();
13+
14+
[ConditionalTheory(nameof(IsManualTestsEnabledAndElevated))]
15+
[PlatformSpecific(TestPlatforms.Linux)]
16+
[InlineData("Ω", 255)]
17+
[InlineData("あ", 255)]
18+
[InlineData("😀", 127)]
19+
public void NtfsOnLinux_FilenamesLongerThan255Bytes_FileEnumerationSucceeds(string codePoint, int maxAllowedLength)
20+
{
21+
string filename = string.Concat(Enumerable.Repeat(codePoint, maxAllowedLength));
22+
Assert.True(Encoding.UTF8.GetByteCount(filename) > 255);
23+
24+
string filePath = $"/mnt/ntfs/{filename}";
25+
File.Create(filePath).Dispose();
26+
Assert.Contains(filePath, Directory.GetFiles("/mnt/ntfs"));
27+
}
28+
}
29+
}

src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests/ManualTests/System.IO.FileSystem.Manual.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
</PropertyGroup>
55
<ItemGroup>
66
<Compile Include="ManualTests.cs" />
7+
<Compile Include="NtfsOnLinuxSetup.cs" />
8+
<Compile Include="NtfsOnLinuxTests.cs" />
79
</ItemGroup>
810
</Project>

src/native/libs/Common/pal_config.h.in

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
#cmakedefine01 HAVE_STAT_FLAGS
2828
#cmakedefine01 HAVE_LCHFLAGS
2929
#cmakedefine01 HAVE_GNU_STRERROR_R
30-
#cmakedefine01 HAVE_READDIR_R
3130
#cmakedefine01 HAVE_DIRENT_NAME_LEN
3231
#cmakedefine01 HAVE_MNTINFO
3332
#cmakedefine01 HAVE_STATFS_FSTYPENAME

src/native/libs/System.Native/entrypoints.c

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ static const Entry s_sysNative[] =
6666
DllImportEntry(SystemNative_MemfdCreate)
6767
DllImportEntry(SystemNative_ShmOpen)
6868
DllImportEntry(SystemNative_ShmUnlink)
69-
DllImportEntry(SystemNative_GetReadDirRBufferSize)
70-
DllImportEntry(SystemNative_ReadDirR)
69+
DllImportEntry(SystemNative_ReadDir)
7170
DllImportEntry(SystemNative_OpenDir)
7271
DllImportEntry(SystemNative_CloseDir)
7372
DllImportEntry(SystemNative_Pipe)

0 commit comments

Comments
 (0)