From 5aba8662992ef8c00bce058d384a7513e59cea0e Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:03:06 +0800 Subject: [PATCH] DiskUtils: read the reparse buffer the driver returned TryGetReparseDataBuffer ignored the byte count returned by DeviceIoControl and read a SymbolicLinkReparseBuffer from the whole rented ArrayPool buffer. Pooled arrays are not zeroed, and the span was larger than the actual response, so MemoryMarshal.TryRead always succeeded: when the driver returned fewer bytes than the struct size, the remaining fields contained stale data from whatever previously used the array. The method also interpreted SymbolicLinkReparseBuffer for every reparse tag, although its fields (including Flags) are only valid for IO_REPARSE_TAG_SYMLINK; for mount points, the same bytes belong to a different layout. Capture lpBytesReturned and parse only the returned prefix of the buffer, so a response shorter than the common header is rejected instead of reading stale memory. Clear the header area of the pooled array before the call as well. Replace the symlink-specific struct with a ReparseDataBufferHeader containing only the fields common to all reparse tags; tag-specific data must be parsed per tag after checking ReparseTag. Return the rented buffer to the pool from a finally block on every path. IsJunction still reads only ReparseTag, so its behavior is unchanged. Added tests for empty and too-short buffers, header parsing, ignoring tag-specific bytes, and a non-reparse-point path. Signed-off-by: bfs2021 <101915083+bfs2021@users.noreply.github.com> --- TruePath.Tests/DiskUtilsTests.cs | 77 ++++++++++++++++++++++++++++++++ TruePath/DiskUtils.cs | 73 ++++++++++++++++++++---------- TruePath/Kernel32.cs | 38 +++++----------- 3 files changed, 138 insertions(+), 50 deletions(-) diff --git a/TruePath.Tests/DiskUtilsTests.cs b/TruePath.Tests/DiskUtilsTests.cs index acd735d..19f79f9 100644 --- a/TruePath.Tests/DiskUtilsTests.cs +++ b/TruePath.Tests/DiskUtilsTests.cs @@ -72,6 +72,83 @@ public void DiskUtils_PassNonExistentPath_KeepFileDescriptorZeroOpen() } } + [Fact] + public void ParseReparseDataBuffer_EmptyBuffer_ReturnsNull() + { + Assert.Null(DiskUtils.ParseReparseDataBuffer(ReadOnlySpan.Empty)); + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + [InlineData(7)] + public void ParseReparseDataBuffer_BufferShorterThanHeader_ReturnsNull(int length) + { + Assert.Null(DiskUtils.ParseReparseDataBuffer(new byte[length])); + } + + [Fact] + public void ParseReparseDataBuffer_ReturnsCommonHeaderFields() + { + // Arrange + var expected = new Kernel32.ReparseDataBufferHeader + { + ReparseTag = Kernel32.IOReparseOptions.IO_REPARSE_TAG_MOUNT_POINT, + ReparseDataLength = 0x18, + Reserved = 0 + }; + var buffer = new byte[Marshal.SizeOf()]; + MemoryMarshal.Write(buffer, in expected); + + // Act + var actual = DiskUtils.ParseReparseDataBuffer(buffer); + + // Assert + Assert.NotNull(actual); + Assert.Equal(expected.ReparseTag, actual.Value.ReparseTag); + Assert.Equal(expected.ReparseDataLength, actual.Value.ReparseDataLength); + Assert.Equal(expected.Reserved, actual.Value.Reserved); + } + + [Fact] + public void ParseReparseDataBuffer_IgnoresTagSpecificDataAfterHeader() + { + // Arrange: a mount point response has no symbolic-link fields, only the common header is valid. + var headerSize = Marshal.SizeOf(); + var expectedTag = Kernel32.IOReparseOptions.IO_REPARSE_TAG_MOUNT_POINT; + var buffer = new byte[headerSize + 12]; + MemoryMarshal.Write(buffer.AsSpan(0, sizeof(uint)), in expectedTag); + buffer.AsSpan(headerSize).Fill(0xFF); + + // Act + var actual = DiskUtils.ParseReparseDataBuffer(buffer); + + // Assert + Assert.NotNull(actual); + Assert.Equal(expectedTag, actual.Value.ReparseTag); + } + + [Fact] + public void TryGetReparseDataBuffer_OnNonReparsePoint_ReturnsNull() + { + // Arrange + if (!OperatingSystem.IsWindows()) return; + + var directory = Temporary.CreateTempFolder(); + try + { + // Act + var actual = DiskUtils.TryGetReparseDataBuffer(directory.Value); + + // Assert + Assert.Null(actual); + } + finally + { + directory.DeleteEmptyDirectory(); + } + } + // ReSharper disable once InconsistentNaming private const int F_GETFD = 1; // The same on Linux and macOS. diff --git a/TruePath/DiskUtils.cs b/TruePath/DiskUtils.cs index a605a1e..19f9f29 100644 --- a/TruePath/DiskUtils.cs +++ b/TruePath/DiskUtils.cs @@ -38,13 +38,18 @@ public static bool IsJunction(string path) } /// - /// Tries to get the reparse data buffer for the specified path. + /// Tries to get the reparse data buffer header for the specified path. /// /// The path to check. /// - /// A containing the reparse data if successful; otherwise, . + /// A containing the common reparse data header if successful; otherwise, + /// . /// - internal static unsafe SymbolicLinkReparseBuffer? TryGetReparseDataBuffer(string path) + /// + /// Only the header common to all reparse tags is returned. The layout of the data following it depends on the tag, + /// so it must not be interpreted without checking first. + /// + internal static unsafe ReparseDataBufferHeader? TryGetReparseDataBuffer(string path) { using SafeFileHandle output = CreateFile( path, @@ -61,37 +66,59 @@ public static bool IsJunction(string path) } byte[] buffer = ArrayPool.Shared.Rent(MAXIMUM_REPARSE_DATA_BUFFER_SIZE); - - bool success; - fixed (byte* pBuffer = buffer) + try { - success = DeviceIoControl( - output, - dwIoControlCode: FSCTL_GET_REPARSE_POINT, - lpInBuffer: null, - nInBufferSize: 0, - lpOutBuffer: pBuffer, - nOutBufferSize: MAXIMUM_REPARSE_DATA_BUFFER_SIZE, - out _, - IntPtr.Zero); + // Pooled arrays are not zeroed, so clear the part of the buffer we may read. The driver may return no data + // at all (e.g. when the path is not a reparse point), and the response may also be shorter than the header. + buffer.AsSpan(0, sizeof(ReparseDataBufferHeader)).Clear(); + + bool success; + uint bytesReturned; + fixed (byte* pBuffer = buffer) + { + success = DeviceIoControl( + output, + dwIoControlCode: FSCTL_GET_REPARSE_POINT, + lpInBuffer: null, + nInBufferSize: 0, + lpOutBuffer: pBuffer, + nOutBufferSize: MAXIMUM_REPARSE_DATA_BUFFER_SIZE, + out bytesReturned, + IntPtr.Zero); + } if (!success) { return null; } - } - - Span bufferSpan = new(buffer); - success = MemoryMarshal.TryRead(bufferSpan, out SymbolicLinkReparseBuffer reparseDataBuffer); - ArrayPool.Shared.Return(buffer); + // The rented array may be larger than the requested buffer, and the driver may return less data than the + // buffer can hold: never inspect bytes the driver did not write. + if (bytesReturned > (uint)buffer.Length) + { + return null; + } - if (success) + return ParseReparseDataBuffer(new ReadOnlySpan(buffer, 0, (int)bytesReturned)); + } + finally { - return reparseDataBuffer; + ArrayPool.Shared.Return(buffer); } + } - return null; + /// + /// Parses the common header of a reparse data buffer. + /// + /// The bytes returned by the driver. + /// + /// The parsed header, or if is shorter than the header. + /// + internal static ReparseDataBufferHeader? ParseReparseDataBuffer(ReadOnlySpan buffer) + { + return MemoryMarshal.TryRead(buffer, out ReparseDataBufferHeader reparseDataBuffer) + ? reparseDataBuffer + : null; } /// diff --git a/TruePath/Kernel32.cs b/TruePath/Kernel32.cs index dcab9c6..ea5d26b 100644 --- a/TruePath/Kernel32.cs +++ b/TruePath/Kernel32.cs @@ -290,13 +290,22 @@ internal static class IOReparseOptions } /// - /// Represents a symbolic link reparse buffer. + /// The header of a reparse data buffer, common to all reparse tags. /// /// + /// + /// The data following this header depends on . For example, symbolic links + /// () are followed by the symbolic link data, which includes a + /// Flags field, while mount points () are followed by + /// the mount point data, which has no Flags field. Do not interpret the tag-specific part without checking the + /// tag, and do not read past the number of bytes returned by the driver. + /// + /// /// For more information, see . + /// /// [StructLayout(LayoutKind.Sequential)] - internal struct SymbolicLinkReparseBuffer + internal struct ReparseDataBufferHeader { /// /// The reparse tag. @@ -312,31 +321,6 @@ internal struct SymbolicLinkReparseBuffer /// Reserved; do not use. /// internal ushort Reserved; - - /// - /// The offset, in bytes, to the substitute name string in the PathBuffer array. - /// - internal ushort SubstituteNameOffset; - - /// - /// The length, in bytes, of the substitute name string. If this string is null-terminated, SubstituteNameLength does not include space for the null character. - /// - internal ushort SubstituteNameLength; - - /// - /// The offset, in bytes, to the print name string in the PathBuffer array. - /// - internal ushort PrintNameOffset; - - /// - /// The length, in bytes, of the print name string. If this string is null-terminated, PrintNameLength does not include space for the null character. - /// - internal ushort PrintNameLength; - - /// - /// Flags that control the behavior of the symbolic link. - /// - internal uint Flags; } }