Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions TruePath.Tests/DiskUtilsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,83 @@ public void DiskUtils_PassNonExistentPath_KeepFileDescriptorZeroOpen()
}
}

[Fact]
public void ParseReparseDataBuffer_EmptyBuffer_ReturnsNull()
{
Assert.Null(DiskUtils.ParseReparseDataBuffer(ReadOnlySpan<byte>.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<Kernel32.ReparseDataBufferHeader>()];
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<Kernel32.ReparseDataBufferHeader>();
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.

Expand Down
73 changes: 50 additions & 23 deletions TruePath/DiskUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ public static bool IsJunction(string path)
}

/// <summary>
/// Tries to get the reparse data buffer for the specified path.
/// Tries to get the reparse data buffer header for the specified path.
/// </summary>
/// <param name="path">The path to check.</param>
/// <returns>
/// A <see cref="SymbolicLinkReparseBuffer"/> containing the reparse data if successful; otherwise, <see langword="null"/>.
/// A <see cref="ReparseDataBufferHeader"/> containing the common reparse data header if successful; otherwise,
/// <see langword="null"/>.
/// </returns>
internal static unsafe SymbolicLinkReparseBuffer? TryGetReparseDataBuffer(string path)
/// <remarks>
/// 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 <see cref="ReparseDataBufferHeader.ReparseTag"/> first.
/// </remarks>
internal static unsafe ReparseDataBufferHeader? TryGetReparseDataBuffer(string path)
{
using SafeFileHandle output = CreateFile(
path,
Expand All @@ -61,37 +66,59 @@ public static bool IsJunction(string path)
}

byte[] buffer = ArrayPool<byte>.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<byte> bufferSpan = new(buffer);
success = MemoryMarshal.TryRead(bufferSpan, out SymbolicLinkReparseBuffer reparseDataBuffer);

ArrayPool<byte>.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<byte>(buffer, 0, (int)bytesReturned));
}
finally
{
return reparseDataBuffer;
ArrayPool<byte>.Shared.Return(buffer);
}
}

return null;
/// <summary>
/// Parses the common header of a reparse data buffer.
/// </summary>
/// <param name="buffer">The bytes returned by the driver.</param>
/// <returns>
/// The parsed header, or <see langword="null"/> if <paramref name="buffer"/> is shorter than the header.
/// </returns>
internal static ReparseDataBufferHeader? ParseReparseDataBuffer(ReadOnlySpan<byte> buffer)
{
return MemoryMarshal.TryRead(buffer, out ReparseDataBufferHeader reparseDataBuffer)
? reparseDataBuffer
: null;
}

/// <summary>
Expand Down
38 changes: 11 additions & 27 deletions TruePath/Kernel32.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,22 @@ internal static class IOReparseOptions
}

/// <summary>
/// Represents a symbolic link reparse buffer.
/// The header of a reparse data buffer, common to all reparse tags.
/// </summary>
/// <remarks>
/// <para>
/// The data following this header depends on <see cref="ReparseTag"/>. For example, symbolic links
/// (<see cref="IOReparseOptions.IO_REPARSE_TAG_SYMLINK"/>) are followed by the symbolic link data, which includes a
/// <c>Flags</c> field, while mount points (<see cref="IOReparseOptions.IO_REPARSE_TAG_MOUNT_POINT"/>) are followed by
/// the mount point data, which has no <c>Flags</c> 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.
/// </para>
/// <para>
/// For more information, see <see href="https://msdn.microsoft.com/library/windows/hardware/ff552012.aspx"/>.
/// </para>
/// </remarks>
[StructLayout(LayoutKind.Sequential)]
internal struct SymbolicLinkReparseBuffer
internal struct ReparseDataBufferHeader
{
/// <summary>
/// The reparse tag.
Expand All @@ -312,31 +321,6 @@ internal struct SymbolicLinkReparseBuffer
/// Reserved; do not use.
/// </summary>
internal ushort Reserved;

/// <summary>
/// The offset, in bytes, to the substitute name string in the PathBuffer array.
/// </summary>
internal ushort SubstituteNameOffset;

/// <summary>
/// The length, in bytes, of the substitute name string. If this string is null-terminated, SubstituteNameLength does not include space for the null character.
/// </summary>
internal ushort SubstituteNameLength;

/// <summary>
/// The offset, in bytes, to the print name string in the PathBuffer array.
/// </summary>
internal ushort PrintNameOffset;

/// <summary>
/// The length, in bytes, of the print name string. If this string is null-terminated, PrintNameLength does not include space for the null character.
/// </summary>
internal ushort PrintNameLength;

/// <summary>
/// Flags that control the behavior of the symbolic link.
/// </summary>
internal uint Flags;
}

}