From 107df2bda27a421aa35b4b3c1c48e8a55903c5b9 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Sat, 24 Sep 2022 18:01:52 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 13 + .gitattributes | 1 + .github/workflows/main.yaml | 18 ++ .gitignore | 5 + Directory.Build.props | 16 + Handle2.sln | 52 ++++ LICENSE | 21 ++ README.md | 47 +++ build.ps1 | 74 +++++ src/Handle2/FodyWeavers.xml | 5 + src/Handle2/FodyWeavers.xsd | 141 +++++++++ src/Handle2/Handle2.csproj | 41 +++ src/Handle2/HandleInfo/HandleInfoRetriever.cs | 188 ++++++++++++ src/Handle2/HandleInfo/Interop/NtDll.cs | 284 ++++++++++++++++++ src/Handle2/HandleInfo/Interop/WinApi.cs | 179 +++++++++++ src/Handle2/HandleInfo/Utils/FileUtils.cs | 25 ++ src/Handle2/HandleInfo/Utils/LinqUtils.cs | 10 + src/Handle2/HandleInfo/Utils/ProcessUtils.cs | 43 +++ src/Handle2/Program.cs | 95 ++++++ src/test/HandleInfo/Utils/FileUtilsTest.cs | 30 ++ src/test/ProgramTest.cs | 136 +++++++++ src/test/Test.csproj | 23 ++ 22 files changed, 1447 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/main.yaml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Handle2.sln create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.ps1 create mode 100644 src/Handle2/FodyWeavers.xml create mode 100644 src/Handle2/FodyWeavers.xsd create mode 100644 src/Handle2/Handle2.csproj create mode 100644 src/Handle2/HandleInfo/HandleInfoRetriever.cs create mode 100644 src/Handle2/HandleInfo/Interop/NtDll.cs create mode 100644 src/Handle2/HandleInfo/Interop/WinApi.cs create mode 100644 src/Handle2/HandleInfo/Utils/FileUtils.cs create mode 100644 src/Handle2/HandleInfo/Utils/LinqUtils.cs create mode 100644 src/Handle2/HandleInfo/Utils/ProcessUtils.cs create mode 100644 src/Handle2/Program.cs create mode 100644 src/test/HandleInfo/Utils/FileUtilsTest.cs create mode 100644 src/test/ProgramTest.cs create mode 100644 src/test/Test.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0db9a30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +max_line_length = 150 + +[*.{xml,wxs,csproj,yaml,props,config,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..e6b12a2 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,18 @@ +on: push + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - run: ./build.ps1 + - uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + draft: true + files: build/publish/*.zip + fail_on_unmatched_files: true + - uses: actions/upload-artifact@v4 + with: + name: Build artifacts + path: build/publish/*.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0977fd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/build/ +.vs/ +.idea/ +CMakeSettings.json +launchSettings.json diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..4c2199a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,16 @@ + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)')) + $(ProjectRoot)build\ + $(BuildFolder)$(Configuration)\$(MSBuildProjectName) + $(BuildFolder)obj\$(MSBuildProjectName)\$(Configuration)\ + net462 + latest + x64 + enable + enable + 5 + true + 0.0-dev + + diff --git a/Handle2.sln b/Handle2.sln new file mode 100644 index 0000000..2680d65 --- /dev/null +++ b/Handle2.sln @@ -0,0 +1,52 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34330.188 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Test", "src\Test\Test.csproj", "{D57C5C4B-3FE1-415E-92F8-A345DF5679AC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Files", "Files", "{ECE2889E-C492-446E-82C2-022D6029A2BB}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + build.ps1 = build.ps1 + Directory.Build.props = Directory.Build.props + .github\workflows\main.yaml = .github\workflows\main.yaml + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Handle2", "src\Handle2\Handle2.csproj", "{FABFDFC5-D060-4A71-B0E7-DC9D339F0856}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Debug|x64.ActiveCfg = Debug|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Debug|x64.Build.0 = Debug|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Debug|x86.ActiveCfg = Debug|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Debug|x86.Build.0 = Debug|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Release|x64.ActiveCfg = Release|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Release|x64.Build.0 = Release|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Release|x86.ActiveCfg = Release|x64 + {D57C5C4B-3FE1-415E-92F8-A345DF5679AC}.Release|x86.Build.0 = Release|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Debug|x64.ActiveCfg = Debug|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Debug|x64.Build.0 = Debug|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Debug|x86.ActiveCfg = Debug|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Debug|x86.Build.0 = Debug|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Release|x64.ActiveCfg = Release|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Release|x64.Build.0 = Release|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Release|x86.ActiveCfg = Release|x64 + {FABFDFC5-D060-4A71-B0E7-DC9D339F0856}.Release|x86.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {729412BC-986E-47AD-B486-9798934F437F} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a501e9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 PolarGoose + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..29ece8a --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Handle2 + +An open-source alternative to the [Sysinternals Handle](https://learn.microsoft.com/en-us/sysinternals/downloads/handle) +* Identifies processes that are locking specific files or folders +* Shows information about all handles in the system +* Supports JSON output +* Full Unicode support + * Note: `Sysinternals Handle` doesn't support Unicode file names ([more details](https://superuser.com/questions/1761951/sysinternals-handle-prints-question-marks-instead-of-non-ascii-symbols)) + +## System requirements + +* Windows 7 x64 and higher. + +## Usage + +``` +> Handle2.exe --help + +Handle2 1.0 +A console utility that displays information about system handles and identifies the processes locking a specific file or folder. +https://github.com/PolarGoose/Handle2 + +Usage: + Handle2.exe [--json] [--path FILE_OR_FOLDER_FULL_NAME|--dump-all-handles] +Examples: + Handle2.exe --path C:\Windows\System32 + Handle2.exe --json --path C:\Windows\System32\ntdll.dll + Handle2.exe --json --path C:\Windows\explorer.exe + Handle2.exe --json --dump-all-handles + +Command-line options: + + --json (Default: false) Json output + + --path Required. (Default: false) path + + --dump-all-handles Required. (Default: false) Displays information about all system handles. + + --help Display this help screen. + + --version Display version information. +``` + +## How to build + +* To work with the codebase, `Visual Studio 2022` can be used. +* To build the project, run `build.ps` script (`git.exe` should be in the PATH) diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..01d326b --- /dev/null +++ b/build.ps1 @@ -0,0 +1,74 @@ +Function Info($msg) { + Write-Host -ForegroundColor DarkGreen "`nINFO: $msg`n" +} + +Function Error($msg) { + Write-Host `n`n + Write-Error $msg + exit 1 +} + +Function CheckReturnCodeOfPreviousCommand($msg) { + if(-Not $?) { + Error "${msg}. Error code: $LastExitCode" + } +} + +Function CreateZipArchive($file, $archiveFile) { + Info "Create a zip archive from `n '$file' `n to `n '$archiveFile'" + Compress-Archive -Force -Path $file -DestinationPath $archiveFile +} + +Function GetVersion() { + $gitCommand = Get-Command -Name git + + $tag = & $gitCommand describe --exact-match --tags HEAD + if(-Not $?) { + Info "The commit is not tagged. Use 'v0.0-dev' as a tag instead" + $tag = "v0.0-dev" + } + + $commitHash = & $gitCommand rev-parse --short HEAD + CheckReturnCodeOfPreviousCommand "Failed to get git commit hash" + + return "$($tag.Substring(1))-$commitHash" +} + +Function ForceCopy($srcFile, $dstFile) { + Info "Copy `n '$srcFile' `n to `n '$dstFile'" + New-Item $dstFile -Force -ItemType File > $null + Copy-Item $srcFile -Destination $dstFile -Force +} + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$root = Resolve-Path $PSScriptRoot +$buildDir = "$root/build" +$publishDir = "$buildDir/publish" +$version = GetVersion + +Info "version=$version" + +Info "Build the project" +dotnet build ` + --nologo ` + --configuration Release ` + -verbosity:minimal ` + /property:DebugType=None ` + /property:Version=$version ` + "$root/Handle2.sln" +CheckReturnCodeOfPreviousCommand "Cmake generation phase failed" + +Info "Run tests" +dotnet test ` + --nologo ` + --no-build ` + --configuration Release ` + -verbosity:minimal ` + --logger:"console;verbosity=normal" ` + "$root/Handle2.sln" +CheckReturnCodeOfPreviousCommand "Tests failed" + +ForceCopy "$buildDir/Release/Handle2/net462/Handle2.exe" "$publishDir/Handle2.exe" +CreateZipArchive "$publishDir/Handle2.exe" "$publishDir/Handle2.zip" diff --git a/src/Handle2/FodyWeavers.xml b/src/Handle2/FodyWeavers.xml new file mode 100644 index 0000000..b5a0e18 --- /dev/null +++ b/src/Handle2/FodyWeavers.xml @@ -0,0 +1,5 @@ + + + Interop + + diff --git a/src/Handle2/FodyWeavers.xsd b/src/Handle2/FodyWeavers.xsd new file mode 100644 index 0000000..05e92c1 --- /dev/null +++ b/src/Handle2/FodyWeavers.xsd @@ -0,0 +1,141 @@ + + + + + + + + + + + + A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks + + + + + A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. + + + + + A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks + + + + + A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. + + + + + A list of unmanaged 32 bit assembly names to include, delimited with line breaks. + + + + + A list of unmanaged 64 bit assembly names to include, delimited with line breaks. + + + + + The order of preloaded assemblies, delimited with line breaks. + + + + + + This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file. + + + + + Controls if .pdbs for reference assemblies are also embedded. + + + + + Controls if runtime assemblies are also embedded. + + + + + Controls whether the runtime assemblies are embedded with their full path or only with their assembly name. + + + + + Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option. + + + + + As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off. + + + + + Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code. + + + + + Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior. + + + + + A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with | + + + + + A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |. + + + + + A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with | + + + + + A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |. + + + + + A list of unmanaged 32 bit assembly names to include, delimited with |. + + + + + A list of unmanaged 64 bit assembly names to include, delimited with |. + + + + + The order of preloaded assemblies, delimited with |. + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/src/Handle2/Handle2.csproj b/src/Handle2/Handle2.csproj new file mode 100644 index 0000000..7a96ee1 --- /dev/null +++ b/src/Handle2/Handle2.csproj @@ -0,0 +1,41 @@ + + + + Exe + A console utility that displays information about system handles and identifies the processes locking a specific file or folder. +https://github.com/PolarGoose/Handle2 + +Usage: + Handle2.exe [--json] [--path FILE_OR_FOLDER_FULL_NAME|--dump-all-handles] +Examples: + Handle2.exe --path C:\Windows\System32 + Handle2.exe --json --path C:\Windows\System32\ntdll.dll + Handle2.exe --json --path C:\Windows\explorer.exe + Handle2.exe --json --dump-all-handles + +Command-line options: + + + + + + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Handle2/HandleInfo/HandleInfoRetriever.cs b/src/Handle2/HandleInfo/HandleInfoRetriever.cs new file mode 100644 index 0000000..8963504 --- /dev/null +++ b/src/Handle2/HandleInfo/HandleInfoRetriever.cs @@ -0,0 +1,188 @@ +using Handle2.HandleInfo.Interop; +using Handle2.HandleInfo.Utils; +using Microsoft.Win32.SafeHandles; + +namespace Handle2.HandleInfo; + +public record struct HandleInfo( + // The result of NtDll.NtQueryObject(OBJECT_INFORMATION_CLASS.ObjectTypeInformation). + // Examples: + // * File + // * Event + string? HandleType, + + // the result ot WinApi.GetFileType(). + WinApi.FileType? FileType, + + // The result of NtDll.NtQueryObject(OBJECT_INFORMATION_CLASS.ObjectNameInformation). + // Examples: + // * \\REGISTRY\\MACHINE\\SYSTEM\\ControlSet001\\Control\\Session Manager + // * \\Device\\HarddiskVolume3\\Windows\\System32 + string? Name, + + // If the handle is a file or folder this field contains its full path. + // Examples: + // * "C:\Users\user\file.txt" + // * "C:\Users\user\" + string? FullNameIfItIsAFileOrAFolder, + + // The content of the SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.GrantedAccess field. + uint GrantedAccess, + + // The content of the SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.HandleAttributes field. + uint Attributes, + + // The content of the SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.Object field. + ulong AddressInTheKernelMemory); + +public record struct ProcessInfo( + ulong Pid, + string? ProcessExecutablePath, + string? UserName, + string? DomainName, + List Handles); + +public static class HandleInfoRetriever +{ + private static IEnumerable> QuerySystemHandleInformation() + { + return NtDll.QuerySystemHandleInformation().GroupBy(handle => handle.UniqueProcessId); + } + + private static void AddHandleTypeAndNameInfo(SafeFileHandle handle, ref HandleInfo handleInfo) + { + handleInfo.FileType = WinApi.GetFileType(handle); + handleInfo.Name = NtDll.GetHandleName(handle); + if (handleInfo.FileType == WinApi.FileType.FILE_TYPE_DISK) + { + handleInfo.FullNameIfItIsAFileOrAFolder = WinApi.GetFinalPathNameByHandle(handle); + if (handleInfo.FullNameIfItIsAFileOrAFolder is not null) + { + handleInfo.FullNameIfItIsAFileOrAFolder = FileUtils.AddTrailingSeparatorIfItIsAFolder(handleInfo.FullNameIfItIsAFileOrAFolder); + } + } + } + + public static IEnumerable GetAllProcInfos() + { + var currentProcess = WinApi.GetCurrentProcess(); + var result = new List(); + + foreach (var (pid, handles) in QuerySystemHandleInformation()) + { + using var openedProcess = ProcessUtils.OpenProcessToDuplicateHandle(pid); + if (openedProcess is null) + { + continue; + } + + var processInfo = new ProcessInfo + { + Pid = pid.ToUInt64(), + Handles = [] + }; + + foreach (var h in handles) + { + using var dupHandle = WinApi.DuplicateHandle(currentProcess, openedProcess, h); + if (dupHandle.IsInvalid) + { + continue; + } + + var handle = new HandleInfo + { + GrantedAccess = h.GrantedAccess, + Attributes = h.HandleAttributes, + AddressInTheKernelMemory = (ulong)h.Object.ToInt64(), + HandleType = NtDll.GetHandleType(dupHandle) + }; + + if (handle.HandleType is not null) + { + var task = Task.Run(() => + { + AddHandleTypeAndNameInfo(dupHandle, ref handle); + }); + task.Wait(TimeSpan.FromMilliseconds(300)); + } + + processInfo.Handles.Add(handle); + } + + if (processInfo.Handles.Any()) + { + (processInfo.DomainName, processInfo.UserName) = ProcessUtils.GetOwnerDomainAndUserNames(openedProcess); + processInfo.ProcessExecutablePath = ProcessUtils.GetExecutablePath(pid); + result.Add(processInfo); + } + } + + return result; + } + + public static IEnumerable GetProcInfosLockingPath(string path) + { + var currentProcess = WinApi.GetCurrentProcess(); + var result = new List(); + path = FileUtils.ToCanonicalPath(path); + + foreach (var (pid, handles) in QuerySystemHandleInformation()) + { + using var openedProcess = ProcessUtils.OpenProcessToDuplicateHandle(pid); + if (openedProcess is null) + { + continue; + } + + var processInfo = new ProcessInfo + { + Pid = pid.ToUInt64(), + Handles = [] + }; + + foreach (var h in handles) + { + using var dupHandle = WinApi.DuplicateHandle(currentProcess, openedProcess, h); + if (dupHandle.IsInvalid) + { + continue; + } + + using var reopenedHandle = WinApi.ReOpenFile(dupHandle, WinApi.FileDesiredAccess.None, FileShare.None, WinApi.FileFlagsAndAttributes.None); + if (reopenedHandle.IsInvalid) + { + continue; + } + + var handle = new HandleInfo + { + GrantedAccess = h.GrantedAccess, + Attributes = h.HandleAttributes, + AddressInTheKernelMemory = (ulong)h.Object.ToInt64(), + HandleType = NtDll.GetHandleType(reopenedHandle) + }; + + if (handle.HandleType is not null) + { + AddHandleTypeAndNameInfo(reopenedHandle, ref handle); + } + + if (handle.FullNameIfItIsAFileOrAFolder?.StartsWith(path, StringComparison.InvariantCultureIgnoreCase) == true) + { + processInfo.Handles.Add(handle); + } + } + + if (processInfo.Handles.Any()) + { + (processInfo.DomainName, processInfo.UserName) = ProcessUtils.GetOwnerDomainAndUserNames(openedProcess); + processInfo.ProcessExecutablePath = ProcessUtils.GetExecutablePath(pid); + + result.Add(processInfo); + } + } + + return result; + } +} diff --git a/src/Handle2/HandleInfo/Interop/NtDll.cs b/src/Handle2/HandleInfo/Interop/NtDll.cs new file mode 100644 index 0000000..5fa8776 --- /dev/null +++ b/src/Handle2/HandleInfo/Interop/NtDll.cs @@ -0,0 +1,284 @@ +using Microsoft.Win32.SafeHandles; +using System.Runtime.InteropServices; + +namespace Handle2.HandleInfo.Interop; + +public static class NtDll +{ + [StructLayout(LayoutKind.Sequential)] + public struct SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX + { + // Pointer to the handle in the kernel virtual address space. + public IntPtr Object; + + // PID that owns the handle + public UIntPtr UniqueProcessId; + + // Handle value in the process that owns the handle. + public IntPtr HandleValue; + + // Access rights associated with the handle. + // Bit mask consisting of the fields defined in the winnt.h + // For example: READ_CONTROL|DELETE|SYNCHRONIZE|WRITE_DAC|WRITE_OWNER|EVENT_ALL_ACCESS + // The exact information that this field contain depends on the type of the handle. + public uint GrantedAccess; + + // This filed is reserved for debugging purposes + // For instance, it can store an index to a stack trace that was captured when the handle was created. + public ushort CreatorBackTraceIndex; + + // Type of object a handle refers to. + // For instance: file, thread, or process + public ushort ObjectTypeIndex; + + // Bit mask that provides additional information about the handle. + // For example: OBJ_INHERIT, OBJ_EXCLUSIVE + // The attributes are defined in the winternl.h + public uint HandleAttributes; + + public uint Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SYSTEM_HANDLE_INFORMATION_EX + { + public IntPtr NumberOfHandles; + public IntPtr Reserved; + public SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles; // Single element + } + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct GENERIC_MAPPING + { + public int GenericRead; + public int GenericWrite; + public int GenericExecute; + public int GenericAll; + } + + [StructLayout(LayoutKind.Sequential)] + private struct OBJECT_TYPE_INFORMATION + { + public UNICODE_STRING Name; + public uint TotalNumberOfObjects; + public uint TotalNumberOfHandles; + public uint TotalPagedPoolUsage; + public uint TotalNonPagedPoolUsage; + public uint TotalNamePoolUsage; + public uint TotalHandleTableUsage; + public uint HighWaterNumberOfObjects; + public uint HighWaterNumberOfHandles; + public uint HighWaterPagedPoolUsage; + public uint HighWaterNonPagedPoolUsage; + public uint HighWaterNamePoolUsage; + public uint HighWaterHandleTableUsage; + public uint InvalidAttributes; + public GENERIC_MAPPING GenericMapping; + public uint ValidAccess; + public byte SecurityRequired; + public byte MaintainHandleCount; + public ushort MaintainTypeList; + public int PoolType; + public int PagedPoolUsage; + public int NonPagedPoolUsage; + } + + private enum OBJECT_INFORMATION_CLASS + { + ObjectBasicInformation = 0, + ObjectNameInformation = 1, + ObjectTypeInformation = 2, + ObjectAllTypesInformation = 3, + ObjectHandleInformation = 4 + } + + private enum SYSTEM_INFORMATION_CLASS + { + SystemExtendedHandleInformation = 64 + } + + [Flags] + public enum DuplicateObjectOptions + { + None = 0, + CloseSource = 1, + SameAccess = 2, + SameAttributes = 4, + NoRightsUpgrade = 8 + } + + private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004; + private const int NT_SUCCESS = 0; + + [DllImport("ntdll.dll")] + private static extern uint NtQuerySystemInformation( + SYSTEM_INFORMATION_CLASS systemInformationClass, + IntPtr systemInformation, + int systemInformationLength, + out int returnLength); + + [DllImport("ntdll.dll", EntryPoint = "NtQueryObject")] + private static extern int NtQueryObject( + IntPtr handle, + OBJECT_INFORMATION_CLASS objectInformationClass, + IntPtr buf, + int bufLength, + ref int returnLength); + + public static SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX[] QuerySystemHandleInformation() + { + for (var bufSize = 32 * 1024 * 1024 /* 32Mb */; bufSize <= 1024 * 1024 * 1024 /* 1Gb */; bufSize *= 2) + { + var buffer = Marshal.AllocHGlobal(bufSize); + try + { + var status = NtQuerySystemInformation(SYSTEM_INFORMATION_CLASS.SystemExtendedHandleInformation, buffer, bufSize, out _); + + if (status == NT_SUCCESS) + { + var handleInfo = Marshal.PtrToStructure(buffer); + return GetHandleEntries(handleInfo, buffer); + } + + if (status != STATUS_INFO_LENGTH_MISMATCH) + { + throw new InvalidOperationException("NtQuerySystemInformation failed with status " + status); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + throw new InvalidOperationException("NtQuerySystemInformation buffer size is not enough"); + } + + public static string? GetHandleName(SafeFileHandle handle) + { + var buffer = QueryObjectInformation(handle, OBJECT_INFORMATION_CLASS.ObjectNameInformation, out _); + if (buffer == IntPtr.Zero) + { + return null; + } + + try + { + var name = (UNICODE_STRING)Marshal.PtrToStructure(buffer, typeof(UNICODE_STRING)); + return name.Length > 0 ? Marshal.PtrToStringUni(name.Buffer, name.Length / 2) : null; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + // Handle types: + // * ALPC Port + // * Composition + // * CoreMessaging + // * DebugObject + // * Desktop + // * Directory + // * DxgkCompositionObject + // * DxgkDisplayManagerObject + // * DxgkSharedResource + // * DxgkSharedSyncObject + // * EnergyTracker + // * EtwConsumer + // * EtwRegistration + // * Event + // * File + // * FilterCommunicationPort + // * FilterConnectionPort + // * IRTimer + // * IoCompletion + // * IoCompletionReserve + // * Job + // * Key + // * Mutant + // * Partition + // * PcwObject + // * PowerRequest + // * Process + // * RawInputManager + // * Section + // * Semaphore + // * Session + // * SymbolicLink + // * Thread + // * Timer + // * TmEn + // * TmRm + // * TmTm + // * Token + // * TpWorkerFactory + // * UserApcReserve + // * WaitCompletionPacket + // * WindowStation + // * WmiGuid + public static string? GetHandleType(SafeFileHandle handle) + { + var buffer = QueryObjectInformation(handle, OBJECT_INFORMATION_CLASS.ObjectTypeInformation, out _); + if (buffer == IntPtr.Zero) + { + return null; + } + + try + { + var info = (OBJECT_TYPE_INFORMATION)Marshal.PtrToStructure(buffer, typeof(OBJECT_TYPE_INFORMATION)); + return info.Name.Length > 0 ? Marshal.PtrToStringUni(info.Name.Buffer, info.Name.Length / 2) : string.Empty; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static IntPtr QueryObjectInformation(SafeFileHandle handle, OBJECT_INFORMATION_CLASS informationClass, out int length) + { + length = 0; + NtQueryObject(handle.DangerousGetHandle(), informationClass, IntPtr.Zero, 0, ref length); + + if (length == 0) + { + return IntPtr.Zero; + } + + var buffer = Marshal.AllocHGlobal(length); + if (NtQueryObject(handle.DangerousGetHandle(), informationClass, buffer, length, ref length) != 0) + { + Marshal.FreeHGlobal(buffer); + return IntPtr.Zero; + } + + return buffer; + } + + private static SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX[] GetHandleEntries(SYSTEM_HANDLE_INFORMATION_EX handleInfo, IntPtr buffer) + { + var numberOfHandles = handleInfo.NumberOfHandles.ToInt64(); + var handleEntries = new SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX[numberOfHandles]; + + long handleSize = Marshal.SizeOf(typeof(SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX)); + var current = new IntPtr(buffer.ToInt64() + Marshal.SizeOf(typeof(SYSTEM_HANDLE_INFORMATION_EX)) - handleSize); + + for (long i = 0; i < numberOfHandles; i++) + { + var handleEntry = Marshal.PtrToStructure(current); + handleEntries[i] = handleEntry; + current = new IntPtr(current.ToInt64() + handleSize); + } + + return handleEntries; + } +} diff --git a/src/Handle2/HandleInfo/Interop/WinApi.cs b/src/Handle2/HandleInfo/Interop/WinApi.cs new file mode 100644 index 0000000..b1f29d9 --- /dev/null +++ b/src/Handle2/HandleInfo/Interop/WinApi.cs @@ -0,0 +1,179 @@ +using Microsoft.Win32.SafeHandles; +using System.Runtime.InteropServices; +using System.Text; + +namespace Handle2.HandleInfo.Interop; + +public class WinApi +{ + [Flags] + private enum StandardAccessRights : long + { + DELETE = 0x00010000L, + READ_CONTROL = 0x00020000L, + WRITE_DAC = 0x00040000L, + WRITE_OWNER = 0x00080000L, + SYNCHRONIZE = 0x00100000L, + STANDARD_RIGHTS_REQUIRED = 0x000F0000L + } + + [Flags] + public enum ProcessAccessRights : ulong + { + PROCESS_ALL_ACCESS = StandardAccessRights.STANDARD_RIGHTS_REQUIRED | StandardAccessRights.SYNCHRONIZE | 0xFFFF, + PROCESS_TERMINATE = 0x0001, + PROCESS_CREATE_THREAD = 0x0002, + PROCESS_SET_SESSIONID = 0x0004, + PROCESS_VM_OPERATION = 0x0008, + PROCESS_VM_READ = 0x0010, + PROCESS_VM_WRITE = 0x0020, + PROCESS_DUP_HANDLE = 0x0040, + PROCESS_CREATE_PROCESS = 0x0080, + PROCESS_SET_QUOTA = 0x0100, + PROCESS_SET_INFORMATION = 0x0200, + PROCESS_QUERY_INFORMATION = 0x0400, + PROCESS_SUSPEND_RESUME = 0x0800, + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000, + PROCESS_SET_LIMITED_INFORMATION = 0x2000 + } + + [Flags] + private enum DuplicateObjectOptions : uint + { + DUPLICATE_CLOSE_SOURCE = 0x00000001, + DUPLICATE_SAME_ACCESS = 0x00000002 + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern SafeProcessHandle OpenProcess( + ProcessAccessRights dwDesiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, + UIntPtr dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + SafeProcessHandle hSourceProcessHandle, + IntPtr hSourceHandle, + SafeProcessHandle hTargetProcessHandle, + out SafeFileHandle lpTargetHandle, + uint dwDesiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, + uint dwOptions); + + public static SafeFileHandle DuplicateHandle( + SafeProcessHandle currentProcess, + SafeProcessHandle handleOwnerProcess, + NtDll.SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX handle) + { + var res = DuplicateHandle( + handleOwnerProcess, + handle.HandleValue, + currentProcess, + out var dupHandle, + 0, + false, + 0); + return res ? dupHandle : new SafeFileHandle(IntPtr.Zero, true); + } + + [DllImport("Kernel32.dll", SetLastError = true, EntryPoint = "GetCurrentProcess")] + private static extern IntPtr GetCurrentProcessPrivate(); + + public static SafeProcessHandle GetCurrentProcess() + { + var handle = GetCurrentProcessPrivate(); + return new SafeProcessHandle(handle, false); + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern int GetFinalPathNameByHandleW(SafeFileHandle hFile, [Out] StringBuilder filePathBuffer, int filePathBufferSize, int flags); + + public static string? GetFinalPathNameByHandle(SafeFileHandle hFile) + { + var buf = new StringBuilder(); + var result = GetFinalPathNameByHandleW(hFile, buf, buf.Capacity, 0); + if (result == 0) + { + return null; + } + + buf.EnsureCapacity(result); + result = GetFinalPathNameByHandleW(hFile, buf, buf.Capacity, 0); + if (result == 0) + { + return null; + } + + var str = buf.ToString(); + return str.StartsWith(@"\\?\") ? str[4..] : str; + } + + [Flags] + public enum TokenAccessRights : ulong + { + TOKEN_QUERY = 0x0008 + } + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool OpenProcessToken(SafeProcessHandle processHandle, TokenAccessRights desiredAccess, out SafeFileHandle tokenHandle); + + public enum FileType : uint + { + FILE_TYPE_UNKNOWN = 0x0000, + FILE_TYPE_DISK = 0x0001, + FILE_TYPE_CHAR = 0x0002, + FILE_TYPE_PIPE = 0x0003, + FILE_TYPE_REMOTE = 0x8000 + } + + [DllImport("Kernel32.dll", SetLastError = true)] + public static extern FileType GetFileType(SafeFileHandle hFile); + + [Flags] + public enum FileDesiredAccess : uint + { + None = 0, + Synchronize = 0x00100000, + Delete = 0x00010000, + GenericRead = 0x80000000, + GenericWrite = 0x40000000, + FileReadAttributes = 0x0080, + FileWriteAttributes = 0x00100 + } + + [Flags] + public enum FileFlagsAndAttributes : uint + { + None = 0, + FileAttributeArchive = 0x20, + FileAttributeEncrypted = 0x4000, + FileAttributeHidden = 0x2, + FileAttributeNormal = 0x80, + FileAttributeOffline = 0x1000, + FileAttributeReadOnly = 0x1, + FileAttributeSystem = 0x4, + FileAttributeTemporary = 0x100, + FileFlagBackupSemantics = 0x02000000, + FileFlagDeleteOnClose = 0x04000000, + FileFlagNoBuffering = 0x20000000, + FileFlagOpenNoRecall = 0x00100000, + FileFlagOpenReparsePoint = 0x00200000, + FileFlagOverlapped = 0x40000000, + FileFlagPosixSemantics = 0x01000000, + FileFlagRandomAccess = 0x10000000, + FileFlagSessionAware = 0x00800000, + FileFlagSequentialScan = 0x08000000, + FileFlagWriteThrough = 0x80000000, + SecurityAnonymous = 0x00100000 + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern SafeFileHandle ReOpenFile( + SafeFileHandle hOriginalFile, + FileDesiredAccess dwDesiredAccess, + FileShare dwShareMode, + FileFlagsAndAttributes dwFlagsAndAttributes); +} diff --git a/src/Handle2/HandleInfo/Utils/FileUtils.cs b/src/Handle2/HandleInfo/Utils/FileUtils.cs new file mode 100644 index 0000000..3089d3a --- /dev/null +++ b/src/Handle2/HandleInfo/Utils/FileUtils.cs @@ -0,0 +1,25 @@ +namespace Handle2.HandleInfo.Utils; + +public static class FileUtils +{ + public static string AddTrailingSeparatorIfItIsAFolder(string fileOrFolderPath) + { + return Directory.Exists(fileOrFolderPath) ? fileOrFolderPath + '\\' : fileOrFolderPath; + } + + public static string ToCanonicalPath(string fileOrFolderPath) + { + var p = Path.GetFullPath(fileOrFolderPath); + if (!p.EndsWith('\\') && Directory.Exists(p)) + { + p += '\\'; + } + + return p; + } + + public static bool Exists(string path) + { + return File.Exists(path) || Directory.Exists(path); + } +} diff --git a/src/Handle2/HandleInfo/Utils/LinqUtils.cs b/src/Handle2/HandleInfo/Utils/LinqUtils.cs new file mode 100644 index 0000000..8cb8d89 --- /dev/null +++ b/src/Handle2/HandleInfo/Utils/LinqUtils.cs @@ -0,0 +1,10 @@ +namespace Handle2.HandleInfo.Utils; + +internal static class LinqUtils +{ + public static void Deconstruct(this IGrouping grouping, out TKey key, out IEnumerable elements) + { + key = grouping.Key; + elements = grouping; + } +} diff --git a/src/Handle2/HandleInfo/Utils/ProcessUtils.cs b/src/Handle2/HandleInfo/Utils/ProcessUtils.cs new file mode 100644 index 0000000..0859b66 --- /dev/null +++ b/src/Handle2/HandleInfo/Utils/ProcessUtils.cs @@ -0,0 +1,43 @@ +using Handle2.HandleInfo.Interop; +using Microsoft.Win32.SafeHandles; +using System.Diagnostics; +using System.Security.Principal; + +namespace Handle2.HandleInfo.Utils; + +public static class ProcessUtils +{ + public static (string domain, string user) GetOwnerDomainAndUserNames(SafeProcessHandle openedProcess) + { + if (!WinApi.OpenProcessToken(openedProcess, WinApi.TokenAccessRights.TOKEN_QUERY, out var tokenHandle)) + { + return ("", ""); + } + + using (tokenHandle) + { + using var wi = new WindowsIdentity(tokenHandle.DangerousGetHandle()); + var domainAndUser = wi.Name.Split('\\'); + return (domainAndUser[0], domainAndUser[1]); + } + } + + public static string? GetExecutablePath(UIntPtr procId) + { + try + { + using var process = Process.GetProcessById((int)procId); + return process.MainModule.FileName; + } + catch (Exception) + { + return null; + } + } + + public static SafeProcessHandle? OpenProcessToDuplicateHandle(UIntPtr pid) + { + var p = WinApi.OpenProcess(WinApi.ProcessAccessRights.PROCESS_DUP_HANDLE | WinApi.ProcessAccessRights.PROCESS_QUERY_INFORMATION, false, pid); + return p.IsInvalid ? null : p; + } +} diff --git a/src/Handle2/Program.cs b/src/Handle2/Program.cs new file mode 100644 index 0000000..514ed27 --- /dev/null +++ b/src/Handle2/Program.cs @@ -0,0 +1,95 @@ +using CommandLine; +using Handle2.HandleInfo; +using Handle2.HandleInfo.Utils; +using System.Text.Json; +using System.Text.Json.Serialization; + +if (args.Length == 0) + args = ["--help"]; + +string? errorMessage = null; + +Parser.Default.ParseArguments(args) + .WithParsed(o => + { + if (o.DumpAllHandles && o.Json) + { + Console.Write( + JsonSerializer.Serialize( + HandleInfoRetriever.GetAllProcInfos(), + new JsonSerializerOptions { WriteIndented = true, Converters = { new JsonStringEnumConverter() } })); + } + + if (o.DumpAllHandles && !o.Json) + { + foreach (var procWithHandles in HandleInfoRetriever.GetAllProcInfos()) + { + Console.WriteLine($"{procWithHandles.ProcessExecutablePath} pid: {procWithHandles.Pid} {procWithHandles.DomainName}/{procWithHandles.UserName}"); + + foreach (var handle in procWithHandles.Handles) + { + if (handle.Name == null) + { + continue; + } + + var name = handle.FullNameIfItIsAFileOrAFolder ?? handle.Name; + Console.WriteLine($" {handle.HandleType,-25} {name}"); + } + + Console.WriteLine("------------------------------------------------------------------------------"); + } + } + + if (o.Path != null && !FileUtils.Exists(o.Path)) + { + errorMessage = $"Error: {o.Path} does not exist."; + return; + } + + if (o.Path != null && !o.Json) + { + foreach (var procWithHandles in HandleInfoRetriever.GetProcInfosLockingPath(o.Path)) + { + Console.WriteLine($"{procWithHandles.ProcessExecutablePath} pid: {procWithHandles.Pid} {procWithHandles.DomainName}/{procWithHandles.UserName}"); + + foreach (var handle in procWithHandles.Handles) + { + Console.WriteLine($" {handle.FullNameIfItIsAFileOrAFolder}"); + } + + Console.WriteLine("------------------------------------------------------------------------------"); + } + } + + if (o.Path != null && o.Json) + { + var processesLockingPath = HandleInfoRetriever.GetProcInfosLockingPath(o.Path); + + Console.Write( + JsonSerializer.Serialize( + processesLockingPath, + new JsonSerializerOptions { WriteIndented = true, Converters = { new JsonStringEnumConverter() } })); + } + }); + +if (errorMessage != null) +{ + Console.WriteLine(errorMessage); + return 1; +} + +return 0; + +public sealed class Options +{ + [Option("json", Required = false, Default = false, + HelpText = "JSON output. For details on the meanings of the fields provided, please consult the HandleInfo and SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX structures in the source code.")] + public bool Json { get; set; } + + [Option("path", Required = true, SetName = "path", HelpText = "Displays the processes locking the path")] + public string? Path { get; set; } + + [Option("dump-all-handles", Required = true, Default = false, SetName = "dump-all-handles", HelpText = "Displays information about all system handles.")] + public bool DumpAllHandles { get; set; } +} diff --git a/src/test/HandleInfo/Utils/FileUtilsTest.cs b/src/test/HandleInfo/Utils/FileUtilsTest.cs new file mode 100644 index 0000000..7d7a7c5 --- /dev/null +++ b/src/test/HandleInfo/Utils/FileUtilsTest.cs @@ -0,0 +1,30 @@ +using Handle2.HandleInfo.Utils; +using NUnit.Framework; + +namespace Test.HandleInfo.Utils; + +[TestFixture] +internal class FileUtilsTest +{ + [Test] + public void AddTrailingSeparatorIfItIsAFolderTest() + { + Assert.That(FileUtils.AddTrailingSeparatorIfItIsAFolder(@"C:\Windows\System32"), Is.EqualTo(@"C:\Windows\System32\")); + Assert.That(FileUtils.AddTrailingSeparatorIfItIsAFolder(@"C:\Windows\System32\"), Is.EqualTo(@"C:\Windows\System32\\")); + Assert.That(FileUtils.ToCanonicalPath(@"C:\Windows\System32\ntdll.dll"), Is.EqualTo(@"C:\Windows\System32\ntdll.dll")); + } + + [Test] + public void CanonicalPathTest() + { + Assert.That(FileUtils.ToCanonicalPath(@"C:\Windows\System32"), Is.EqualTo(@"C:\Windows\System32\")); + Assert.That(FileUtils.ToCanonicalPath(@"C:\Windows\System32\"), Is.EqualTo(@"C:\Windows\System32\")); + Assert.That(FileUtils.ToCanonicalPath(@"C:\Windows\System32\\"), Is.EqualTo(@"C:\Windows\System32\")); + Assert.That(FileUtils.ToCanonicalPath(@"C:\Windows\\System32/"), Is.EqualTo(@"C:\Windows\System32\")); + Assert.That(FileUtils.ToCanonicalPath(@"C:/Windows/System32"), Is.EqualTo(@"C:\Windows\System32\")); + + Assert.That(FileUtils.ToCanonicalPath(@"C:/Windows/System32/ntdll.dll"), Is.EqualTo(@"C:\Windows\System32\ntdll.dll")); + Assert.That(FileUtils.ToCanonicalPath(@"C:/Windows/System32//ntdll.dll"), Is.EqualTo(@"C:\Windows\System32\ntdll.dll")); + Assert.That(FileUtils.ToCanonicalPath(@"C:\Windows\\System32\ntdll.dll"), Is.EqualTo(@"C:\Windows\System32\ntdll.dll")); + } +} diff --git a/src/test/ProgramTest.cs b/src/test/ProgramTest.cs new file mode 100644 index 0000000..6eb98e8 --- /dev/null +++ b/src/test/ProgramTest.cs @@ -0,0 +1,136 @@ +using CliWrap; +using CliWrap.Buffered; +using NUnit.Framework; +using System.Text; + +namespace Test; + +[TestFixture] +public class ProgramTest +{ + [Test] + public void Prints_help_when_no_arguments_are_passed() + { + var res = RunHandle2([]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardError, Contains.Substring("console utility that displays")); + } + + [Test] + public void Prints_help_when_help_parameter_is_passed() + { + var res = RunHandle2(["--help"]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardError, Contains.Substring("console utility that displays")); + } + + [TestCase(["non existing parameter"])] + [TestCase(["--path", "123", "--dump-all-handles"])] + [TestCase(["--path"])] + [TestCase(["--path --json"])] + [TestCase(["--dump-all-handles param"])] + public void Prints_help_and_an_error_message_when_wrong_parameters_are_passed(object[] args) + { + var res = RunHandle2(args.Select(a => a.ToString())); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardError, Contains.Substring("console utility that displays")); + Assert.That(res.StandardError, Contains.Substring("ERROR(S):")); + } + + [Test] + public void Dump_all_handle_information() + { + var res = RunHandle2(["--dump-all-handles"]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\Windows\system32\sihost.exe")); + Assert.That(res.StandardOutput, Contains.Substring(@"Desktop")); + Assert.That(res.StandardOutput, Contains.Substring(@"\Default")); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\Windows\System32\")); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\Windows\System32\en-US\KernelBase.dll.mui")); + } + + [Test] + public void Dump_all_handle_information_json() + { + var res = RunHandle2(["--dump-all-handles", "--json"]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardOutput, Contains.Substring(@"GrantedAccess")); + Assert.That(res.StandardOutput, Contains.Substring(@"FILE_TYPE_UNKNOWN")); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\\Windows\\System32\\en-US\\propsys.dll.mui")); + Assert.That(res.StandardOutput, Contains.Substring(@"\\Device\\HarddiskVolume")); + Assert.That(res.StandardOutput, Contains.Substring(@" ""HandleType"": ""Section"",")); + } + + [Test] + public void Path_non_locked_folder() + { + var res = RunHandle2(["--path", @"C:\Windows\system.ini"]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardOutput, Is.Empty); + } + + [Test] + public void Path_that_does_not_exist() + { + var res = RunHandle2(["--path", @"C:\non_existing_path"]); + + Assert.That(res.ExitCode, Is.EqualTo(1)); + Assert.That(res.StandardOutput, Contains.Substring("Error:")); + Assert.That(res.StandardOutput, Contains.Substring("does not exist.")); + } + + [Test] + public void Path_json_that_does_not_exist() + { + var res = RunHandle2(["--path", @"C:\non_existing_path", "--json"]); + + Assert.That(res.ExitCode, Is.EqualTo(1)); + Assert.That(res.StandardOutput, Contains.Substring("Error:")); + Assert.That(res.StandardOutput, Contains.Substring("does not exist.")); + } + + [TestCase(@"C:\Windows")] + [TestCase(@"C:/Windows")] + [TestCase(@"C:/windows")] + public void Path_locked_folder(string path) + { + var res = RunHandle2(["--path", path]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\Windows\system32\svchost.exe")); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\Windows\System32\")); + Assert.That(res.StandardOutput, Contains.Substring(@"C:\Windows\System32\en-US\KernelBase.dll.mui")); + Assert.That(res.StandardOutput, Does.Not.Contain(@"\\Device\\HarddiskVolume")); + } + + [TestCase(@"C:\Windows")] + [TestCase(@"C:/Windows")] + [TestCase(@"C:/windows")] + public void Path_json_locked_folder(string path) + { + var res = RunHandle2(["--path", path, "--json"]); + + Assert.That(res.ExitCode, Is.EqualTo(0)); + Assert.That(res.StandardOutput, Contains.Substring(@" ""HandleType"": ""File"",")); + Assert.That(res.StandardOutput, Contains.Substring(@"FILE_TYPE_DISK")); + Assert.That(res.StandardOutput, Contains.Substring(@"\\Device\\HarddiskVolume")); + Assert.That(res.StandardOutput, Does.Not.Contain(@"FILE_TYPE_UNKNOWN")); + } + + private static BufferedCommandResult RunHandle2(IEnumerable args) + { + return Cli + .Wrap(Path.Combine(AppContext.BaseDirectory, "Handle2.exe")) + .WithValidation(CommandResultValidation.None) + .WithArguments(args) + .ExecuteBufferedAsync(Encoding.UTF8) + .GetAwaiter() + .GetResult(); + } +} diff --git a/src/test/Test.csproj b/src/test/Test.csproj new file mode 100644 index 0000000..19ca653 --- /dev/null +++ b/src/test/Test.csproj @@ -0,0 +1,23 @@ + + + + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +