From 72acc90bc113778e3804ece891a170420911d1ff Mon Sep 17 00:00:00 2001 From: FalconTechDev Date: Mon, 19 Oct 2026 07:33:52 +0300 Subject: [PATCH 1/5] Return the pooled reparse buffer on every path in TryGetReparseDataBuffer (#240) --- CHANGELOG.md | 1 + TruePath.Tests/DiskUtilsTests.cs | 19 +++++++++++++++++ TruePath/DiskUtils.cs | 36 +++++++++++++++++++------------- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd36ee..b7f9a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - [#239](https://github.com/ForNeVeR/TruePath/issues/239): on Unix, `AbsolutePath.Canonicalize()` no longer closes the file descriptor 0 when the path doesn't exist. This used to close the standard input of the process, or any file or socket that later took this descriptor number. Also, it no longer leaks the memory allocated for the resolved path. +- [#240](https://github.com/ForNeVeR/TruePath/issues/240): On Windows, `AbsolutePath.ReadKind` no longer leaks the pooled 16 KiB reparse-data buffer when the path is not a reparse point (i.e. for almost every directory): the buffer is now always returned to `ArrayPool`, so reading the kind of a directory no longer allocates a fresh 16 KiB array per call. - [#226](https://github.com/ForNeVeR/TruePath/issues/226): `LocalPath.IsPrefixOf` and `StartsWith` now take leading `..` references of relative paths into account instead of comparing them as strings. For example, `new LocalPath("..").IsPrefixOf(new LocalPath("foo"))` is now `true`, and `new LocalPath("..").IsPrefixOf(new LocalPath("../.."))` is now `false`. - [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality. - `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one. diff --git a/TruePath.Tests/DiskUtilsTests.cs b/TruePath.Tests/DiskUtilsTests.cs index acd735d..62dc5ef 100644 --- a/TruePath.Tests/DiskUtilsTests.cs +++ b/TruePath.Tests/DiskUtilsTests.cs @@ -10,6 +10,25 @@ namespace TruePath.Tests; public class DiskUtilsTests { + [Fact] + public void ReadKind_OnDirectory_DoesNotAllocateTheReparseBufferOnEveryCall() + { + // Arrange + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var directory = new AbsolutePath(Path.GetTempPath()); + const int iterations = 1000; + for (var i = 0; i < 100; i++) directory.ReadKind(); // warm up the pool + + // Act + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < iterations; i++) directory.ReadKind(); + var perCall = (GC.GetAllocatedBytesForCurrentThread() - before) / iterations; + + // Assert: a rented buffer is 16 KiB, so a leak shows up as at least that much per call. + Assert.True(perCall < 1024, $"ReadKind allocated {perCall} bytes per call on a directory."); + } + [Fact] public void DiskUtils_PassBackPath_ReturnCanonicalPath() { diff --git a/TruePath/DiskUtils.cs b/TruePath/DiskUtils.cs index fe0d5d3..b49b7cd 100644 --- a/TruePath/DiskUtils.cs +++ b/TruePath/DiskUtils.cs @@ -60,24 +60,30 @@ public static bool IsJunction(string path) const int bufferSize = (int)PInvoke.MAXIMUM_REPARSE_DATA_BUFFER_SIZE; var rentedBuffer = ArrayPool.Shared.Rent(bufferSize); - var buffer = rentedBuffer.AsSpan(0, bufferSize); - bool success = PInvoke.DeviceIoControl( - output, - PInvoke.FSCTL_GET_REPARSE_POINT, - lpInBuffer: default, - lpOutBuffer: buffer, - out _, - lpOverlapped: null); - - if (!success) + try { - return null; - } + var buffer = rentedBuffer.AsSpan(0, bufferSize); + bool success = PInvoke.DeviceIoControl( + output, + PInvoke.FSCTL_GET_REPARSE_POINT, + lpInBuffer: default, + lpOutBuffer: buffer, + out _, + lpOverlapped: null); + + if (!success) + { + return null; + } - success = MemoryMarshal.TryRead(buffer, out REPARSE_DATA_BUFFER reparseDataBuffer); - ArrayPool.Shared.Return(rentedBuffer); + success = MemoryMarshal.TryRead(buffer, out REPARSE_DATA_BUFFER reparseDataBuffer); - return success ? reparseDataBuffer.ReparseTag : null; + return success ? reparseDataBuffer.ReparseTag : null; + } + finally + { + ArrayPool.Shared.Return(rentedBuffer); + } } /// From f572ca89dc42886de93f4dacc55fe63bc49bc7cb Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 24 Oct 2026 23:04:46 +0200 Subject: [PATCH 2/5] (#240) AbsolutePath: only check for Windows junction if we already see the reparse point flag --- CHANGELOG.md | 2 +- TruePath/AbsolutePath.cs | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f9a34..97dd24a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. - **Breaking:** path normalization now represents the current directory as `.` instead of an empty string: `PathStrings.Normalize` returns `"."` for `""`, `"."` and `"a/.."`, and so do `LocalPath.Value` and `ToString()` (and `LocalPath.Parent` of a single-segment relative path is `.`). As a consequence, `LocalPath.FileName` of such a path is `.`, the same way it is `..` for `..`, and `GetExtensionWithDot` returns `.` for it. - `LocalPath.IsPrefixOf` now treats the current directory path `.` — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every path of kind `PathKind.Relative` that does not begin with a `..` reference. +- [#240](https://github.com/ForNeVeR/TruePath/issues/240): more performant `AbsolutePath.ReadKind` on Windows (less allocations and IO in cases not concerning reparse points). - The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime. ### Fixed - [#239](https://github.com/ForNeVeR/TruePath/issues/239): on Unix, `AbsolutePath.Canonicalize()` no longer closes the file descriptor 0 when the path doesn't exist. This used to close the standard input of the process, or any file or socket that later took this descriptor number. Also, it no longer leaks the memory allocated for the resolved path. -- [#240](https://github.com/ForNeVeR/TruePath/issues/240): On Windows, `AbsolutePath.ReadKind` no longer leaks the pooled 16 KiB reparse-data buffer when the path is not a reparse point (i.e. for almost every directory): the buffer is now always returned to `ArrayPool`, so reading the kind of a directory no longer allocates a fresh 16 KiB array per call. - [#226](https://github.com/ForNeVeR/TruePath/issues/226): `LocalPath.IsPrefixOf` and `StartsWith` now take leading `..` references of relative paths into account instead of comparing them as strings. For example, `new LocalPath("..").IsPrefixOf(new LocalPath("foo"))` is now `true`, and `new LocalPath("..").IsPrefixOf(new LocalPath("../.."))` is now `false`. - [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality. - `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one. diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index e9b5ac9..5a395dc 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -257,17 +257,10 @@ public override bool Equals(object? obj) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (DiskUtils.IsJunction(Value)) + if (attributes.HasFlag(FileAttributes.ReparsePoint) && DiskUtils.IsJunction(Value)) { return FileEntryKind.Junction; } - - if (attributes.HasFlag(FileAttributes.ReparsePoint)) - { - return FileEntryKind.Symlink; - } - - return FileEntryKind.Directory; } if (attributes.HasFlag(FileAttributes.ReparsePoint)) From 540503b067e5e6809a4f428b05890974371c3448 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 24 Oct 2026 23:25:32 +0200 Subject: [PATCH 3/5] (#240) AbsolutePath.ReadKind: even less IO, more correct behavior w.r.t. the exceptions Only return null when the target is not found, not in any other cases. --- CHANGELOG.md | 4 ++- TruePath.Tests/AbsolutePathTests.cs | 41 ++++++++++++++++++++++++----- TruePath/AbsolutePath.cs | 13 ++++++--- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dd24a..b11ca54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. - **Breaking:** path normalization now represents the current directory as `.` instead of an empty string: `PathStrings.Normalize` returns `"."` for `""`, `"."` and `"a/.."`, and so do `LocalPath.Value` and `ToString()` (and `LocalPath.Parent` of a single-segment relative path is `.`). As a consequence, `LocalPath.FileName` of such a path is `.`, the same way it is `..` for `..`, and `GetExtensionWithDot` returns `.` for it. - `LocalPath.IsPrefixOf` now treats the current directory path `.` — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every path of kind `PathKind.Relative` that does not begin with a `..` reference. -- [#240](https://github.com/ForNeVeR/TruePath/issues/240): more performant `AbsolutePath.ReadKind` on Windows (less allocations and IO in cases not concerning reparse points). +- [#240](https://github.com/ForNeVeR/TruePath/issues/240): more performant `AbsolutePath.ReadKind`: it now queries the entry attributes only once, and on Windows it no longer allocates or performs additional IO in cases not concerning reparse points. +- `AbsolutePath.ReadKind` now only returns `null` if the entry doesn't exist, and throws an exception if its attributes cannot be read for another reason: e.g. an `UnauthorizedAccessException` if the access is denied, or an `IOException` if the path contains a name rejected by the file system (on .NET Framework, also `ArgumentException` or `PathTooLongException`). Previously, it returned `null` in these cases. - The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime. ### Fixed +- `AbsolutePath.ReadKind` no longer throws an exception if the entry gets deleted during the call. - [#239](https://github.com/ForNeVeR/TruePath/issues/239): on Unix, `AbsolutePath.Canonicalize()` no longer closes the file descriptor 0 when the path doesn't exist. This used to close the standard input of the process, or any file or socket that later took this descriptor number. Also, it no longer leaks the memory allocated for the resolved path. - [#226](https://github.com/ForNeVeR/TruePath/issues/226): `LocalPath.IsPrefixOf` and `StartsWith` now take leading `..` references of relative paths into account instead of comparing them as strings. For example, `new LocalPath("..").IsPrefixOf(new LocalPath("foo"))` is now `true`, and `new LocalPath("..").IsPrefixOf(new LocalPath("../.."))` is now `false`. - [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality. diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index b8302d1..78c3e01 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT using System.Diagnostics; +using TruePath.SystemIo; namespace TruePath.Tests; @@ -36,17 +37,43 @@ public void PathRootOfRootReturnsItself() [Fact] public void ReadKind_NonExistent() { - // Arrange - var currentDirectory = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); - var localPath = new AbsolutePath(currentDirectory); - - // Act - var kind = localPath.ReadKind(); + var path = AbsolutePath.CurrentWorkingDirectory / Guid.NewGuid().ToString(); + var kind = path.ReadKind(); + Assert.Null(kind); + } - // Assert + [Fact] + public void ReadKind_NonExistentParent() + { + var path = AbsolutePath.CurrentWorkingDirectory / Guid.NewGuid().ToString() / "child"; + var kind = path.ReadKind(); Assert.Null(kind); } + [Fact] + public void ReadKind_ChildOfFile() + { + var tempFile = Temporary.CreateTempFile(); + try + { + var kind = (tempFile / "child").ReadKind(); + Assert.Null(kind); + } + finally + { + tempFile.Delete(); + } + } + + [Fact] + public void OnWindows_ReadKind_InvalidName_Throws() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var localPath = AbsolutePath.CurrentWorkingDirectory / "a|b"; + Assert.ThrowsAny(() => localPath.ReadKind()); + } + [Fact] public void ReadKind_IsDirectory() { diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index 5a395dc..afb58da 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -241,15 +241,22 @@ public override bool Equals(object? obj) /// /// This method checks if the specified path represents a file, directory, symbolic link, or junction. On Windows, it uses to identify junctions and checks for the flag to identify symbolic links. /// + /// The caller doesn't have the permission to read the entry attributes. + /// + /// The entry attributes cannot be read for a reason other than the entry not existing, e.g. the path contains a name rejected by the file system. + /// public FileEntryKind? ReadKind() { - if (!File.Exists(Value) && !Directory.Exists(Value)) + FileAttributes attributes; + try + { + attributes = File.GetAttributes(Value); + } + catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) { return null; } - var attributes = File.GetAttributes(Value); - if (!attributes.HasFlag(FileAttributes.Directory)) { return FileEntryKind.File; From f7e7b1cb9b62cd8bb98777d993b950410b2de0b6 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 24 Oct 2026 23:41:48 +0200 Subject: [PATCH 4/5] (#240) DiskUtils: drop usage of array pool for junction detection --- TruePath.Tests/DiskUtilsTests.cs | 19 ----------------- TruePath/DiskUtils.cs | 36 ++++++++++++-------------------- TruePath/NativeMethods.txt | 6 ++---- 3 files changed, 15 insertions(+), 46 deletions(-) diff --git a/TruePath.Tests/DiskUtilsTests.cs b/TruePath.Tests/DiskUtilsTests.cs index 62dc5ef..acd735d 100644 --- a/TruePath.Tests/DiskUtilsTests.cs +++ b/TruePath.Tests/DiskUtilsTests.cs @@ -10,25 +10,6 @@ namespace TruePath.Tests; public class DiskUtilsTests { - [Fact] - public void ReadKind_OnDirectory_DoesNotAllocateTheReparseBufferOnEveryCall() - { - // Arrange - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; - - var directory = new AbsolutePath(Path.GetTempPath()); - const int iterations = 1000; - for (var i = 0; i < 100; i++) directory.ReadKind(); // warm up the pool - - // Act - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < iterations; i++) directory.ReadKind(); - var perCall = (GC.GetAllocatedBytesForCurrentThread() - before) / iterations; - - // Assert: a rented buffer is 16 KiB, so a leak shows up as at least that much per call. - Assert.True(perCall < 1024, $"ReadKind allocated {perCall} bytes per call on a directory."); - } - [Fact] public void DiskUtils_PassBackPath_ReturnCanonicalPath() { diff --git a/TruePath/DiskUtils.cs b/TruePath/DiskUtils.cs index b49b7cd..9f9a181 100644 --- a/TruePath/DiskUtils.cs +++ b/TruePath/DiskUtils.cs @@ -5,7 +5,6 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; -using Windows.Wdk.Storage.FileSystem; using Windows.Win32; using Windows.Win32.Storage.FileSystem; using Microsoft.Win32.SafeHandles; @@ -58,32 +57,23 @@ public static bool IsJunction(string path) return null; } - const int bufferSize = (int)PInvoke.MAXIMUM_REPARSE_DATA_BUFFER_SIZE; - var rentedBuffer = ArrayPool.Shared.Rent(bufferSize); - try - { - var buffer = rentedBuffer.AsSpan(0, bufferSize); - bool success = PInvoke.DeviceIoControl( - output, - PInvoke.FSCTL_GET_REPARSE_POINT, - lpInBuffer: default, - lpOutBuffer: buffer, - out _, - lpOverlapped: null); - - if (!success) - { - return null; - } - - success = MemoryMarshal.TryRead(buffer, out REPARSE_DATA_BUFFER reparseDataBuffer); + FILE_ATTRIBUTE_TAG_INFO info; + bool success = PInvoke.GetFileInformationByHandleEx( + output, + FILE_INFO_BY_HANDLE_CLASS.FileAttributeTagInfo, + new Span(&info, sizeof(FILE_ATTRIBUTE_TAG_INFO))); - return success ? reparseDataBuffer.ReparseTag : null; + if (!success) + { + return null; } - finally + + if ((info.FileAttributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_REPARSE_POINT) == 0) { - ArrayPool.Shared.Return(rentedBuffer); + return null; } + + return info.ReparseTag; } /// diff --git a/TruePath/NativeMethods.txt b/TruePath/NativeMethods.txt index 1bf1173..f638895 100644 --- a/TruePath/NativeMethods.txt +++ b/TruePath/NativeMethods.txt @@ -3,10 +3,8 @@ // SPDX-License-Identifier: MIT CreateFile -DeviceIoControl FILE_ACCESS_RIGHTS -FSCTL_GET_REPARSE_POINT +FILE_ATTRIBUTE_TAG_INFO +GetFileInformationByHandleEx GetFinalPathNameByHandle IO_REPARSE_TAG_MOUNT_POINT -MAXIMUM_REPARSE_DATA_BUFFER_SIZE -REPARSE_DATA_BUFFER From e33aeabd257b141e110a0c42e1d4bee4e0135275 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 24 Oct 2026 23:42:44 +0200 Subject: [PATCH 5/5] (#240) Docs: mention the contributor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11ca54..4d51538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. - **Breaking:** path normalization now represents the current directory as `.` instead of an empty string: `PathStrings.Normalize` returns `"."` for `""`, `"."` and `"a/.."`, and so do `LocalPath.Value` and `ToString()` (and `LocalPath.Parent` of a single-segment relative path is `.`). As a consequence, `LocalPath.FileName` of such a path is `.`, the same way it is `..` for `..`, and `GetExtensionWithDot` returns `.` for it. - `LocalPath.IsPrefixOf` now treats the current directory path `.` — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every path of kind `PathKind.Relative` that does not begin with a `..` reference. -- [#240](https://github.com/ForNeVeR/TruePath/issues/240): more performant `AbsolutePath.ReadKind`: it now queries the entry attributes only once, and on Windows it no longer allocates or performs additional IO in cases not concerning reparse points. +- [#240](https://github.com/ForNeVeR/TruePath/issues/240): more performant `AbsolutePath.ReadKind`: it now queries the entry attributes only once, and on Windows it no longer allocates or performs additional IO in cases not concerning reparse points. Thanks to @FalconTechDev! - `AbsolutePath.ReadKind` now only returns `null` if the entry doesn't exist, and throws an exception if its attributes cannot be read for another reason: e.g. an `UnauthorizedAccessException` if the access is denied, or an `IOException` if the path contains a name rejected by the file system (on .NET Framework, also `ArgumentException` or `PathTooLongException`). Previously, it returned `null` in these cases. - The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime.