diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd36ee..4d51538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +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`: 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. ### 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 e9b5ac9..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; @@ -257,17 +264,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)) diff --git a/TruePath/DiskUtils.cs b/TruePath/DiskUtils.cs index fe0d5d3..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,26 +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); - var buffer = rentedBuffer.AsSpan(0, bufferSize); - bool success = PInvoke.DeviceIoControl( + FILE_ATTRIBUTE_TAG_INFO info; + bool success = PInvoke.GetFileInformationByHandleEx( output, - PInvoke.FSCTL_GET_REPARSE_POINT, - lpInBuffer: default, - lpOutBuffer: buffer, - out _, - lpOverlapped: null); + FILE_INFO_BY_HANDLE_CLASS.FileAttributeTagInfo, + new Span(&info, sizeof(FILE_ATTRIBUTE_TAG_INFO))); if (!success) { return null; } - success = MemoryMarshal.TryRead(buffer, out REPARSE_DATA_BUFFER reparseDataBuffer); - ArrayPool.Shared.Return(rentedBuffer); + if ((info.FileAttributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_REPARSE_POINT) == 0) + { + return null; + } - return success ? reparseDataBuffer.ReparseTag : 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