From 067547a7e5a0b3ed8aa4368f1b9b5a88b15db7b3 Mon Sep 17 00:00:00 2001 From: Chris Dent Date: Sun, 30 Dec 2018 15:23:16 +0000 Subject: [PATCH] Adds Measure-ChildItem command --- Public/Measure-ChildItem.ps1 | 175 ++++++++++++++++++++++++++ Test/Unit/Measure-ChildItem.tests.ps1 | 93 ++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 Public/Measure-ChildItem.ps1 create mode 100644 Test/Unit/Measure-ChildItem.tests.ps1 diff --git a/Public/Measure-ChildItem.ps1 b/Public/Measure-ChildItem.ps1 new file mode 100644 index 0000000..17e526f --- /dev/null +++ b/Public/Measure-ChildItem.ps1 @@ -0,0 +1,175 @@ +function Measure-ChildItem { + <# + .SYNOPSIS + Recursively measures the size of a directory. + .DESCRIPTION + Recursively measures the size of a directory. + + Measure-ChildItem uses win32 functions, returning a minimal amount of information to gain speed. Once started, the operation cannot be interrupted by using Control and C. The more items present in a directory structure the longer this command will take. + + This command supports paths longer than 260 characters. + .EXAMPLE + Measure-ChildItem + + Get the size of all items within the current directory. + .EXAMPLE + Get-ChildItem c:\users | Measure-ChildItem -Unit MB + + Get the size of all child items of c:\users. + .EXAMPLE + Measure-ChildItem c:\windows -ValueOnly -Unit GB + + Return the size of the c:\windows directory and return only the size in GB. + .EXAMPLE + Get-ChildItem \\server\share -Directory | Measure-ChildItem -Unit TB -Digits 5 + + Return the size of all items in a share. + #> + + [CmdletBinding()] + param ( + # The path to measure the size of. Accepts pipeline input. By default the size of the current working directory is measured. + [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('FullName')] + [String]$Path = $pwd, + + # The units sizes should be displayed in. By default, sizes are displayed in Bytes. + [ValidateSet('B', 'KB', 'MB', 'GB', 'TB')] + [String]$Unit = 'B', + + # When rounding, the number of digits to display after a decimal point. By defaut sizes are rounded to two decimal places. + [ValidateRange(0, 28)] + [Int32]$Digits = 2, + + # Return the size value only, discards file, and directory counts and path information. + [Switch]$ValueOnly + ) + + begin { + if (-not ('SC.IO.FileSearcher' -as [Type])) { + Add-Type ' + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.InteropServices; + + namespace SC.IO + { + [StructLayout(LayoutKind.Sequential)] + public struct FILETIME + { + public uint dwLowDateTime; + public uint dwHighDateTime; + }; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct WIN32_FIND_DATA + { + public FileAttributes dwFileAttributes; + public FILETIME ftCreationTime; + public FILETIME ftLastAccessTime; + public FILETIME ftLastWriteTime; + public int nFileSizeHigh; + public int nFileSizeLow; + public int dwReserved0; + public int dwReserved1; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string cFileName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] + public string cAlternate; + } + + public class UnsafeNativeMethods + { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr FindFirstFile(string lpFileName, out WIN32_FIND_DATA lpFindFileData); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr FindFirstFileExW( + string lpFileName, + int fInfoLevelId, + out WIN32_FIND_DATA lpFindFileData, + int fSearchOp, + IntPtr lpSearchFilter, + int dwAdditionalFlags + ); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + public static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool FindClose(IntPtr hFindFile); + } + + public class FileSearcher + { + public static long[] MeasureItem(string path, bool recurse, long[] itemData) + { + if (itemData == null) + { + itemData = new long[]{ 0, 0, 0 }; + } + + string searchPath; + if (path.StartsWith(@"\\")) + { + searchPath = String.Format(@"\\?\UNC\{0}\*", path.Substring(2)); + } + else + { + searchPath = String.Format(@"\\?\{0}\*", path); + } + + WIN32_FIND_DATA findData = new WIN32_FIND_DATA(); + IntPtr findHandle = UnsafeNativeMethods.FindFirstFileExW(searchPath, 1, out findData, 0, IntPtr.Zero, 0); + do + { + if (findData.dwFileAttributes.HasFlag(FileAttributes.Directory)) + { + if (recurse && findData.cFileName != "." && findData.cFileName != "..") + { + itemData[2]++; + itemData = MeasureItem( + Path.Combine(path, findData.cFileName), + recurse, + itemData + ); + } + } + else + { + itemData[0] += ((long)findData.nFileSizeHigh * UInt32.MaxValue) + (long)findData.nFileSizeLow; + itemData[1]++; + } + } while (UnsafeNativeMethods.FindNextFile(findHandle, out findData)); + UnsafeNativeMethods.FindClose(findHandle); + + return itemData; + } + } + } + ' + } + + $power = ('B', 'KB', 'MB', 'GB', 'TB').IndexOf($Unit.ToUpper()) + $denominator = [Math]::Pow(1024, $power) + } + + process { + $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path).TrimEnd('\') + + $itemData = [SC.IO.FileSearcher]::MeasureItem($Path, $true, $null) + + if ($ValueOnly) { + [Math]::Round(($itemData[0] / $denominator), $Digits) + } else { + [PSCustomObject]@{ + Path = $Path + Size = [Math]::Round(($itemData[0] / $denominator), $Digits) + FileCount = $itemData[1] + DirectoryCount = $itemData[2] + } + } + } +} \ No newline at end of file diff --git a/Test/Unit/Measure-ChildItem.tests.ps1 b/Test/Unit/Measure-ChildItem.tests.ps1 new file mode 100644 index 0000000..26799ea --- /dev/null +++ b/Test/Unit/Measure-ChildItem.tests.ps1 @@ -0,0 +1,93 @@ +. $psscriptroot\..\..\Public\Measure-ChildItem.ps1 + +Describe Measure-ChildItem { + BeforeAll { + Push-Location TestDrive:\ + + $basePath = (Get-Item TestDrive:\).FullName + } + + AfterAll { + Pop-Location + } + + Context 'Short paths' { + BeforeAll { + New-Item 1\1\1\1 -ItemType Directory -Force + New-Item 2\2\2\2 -ItemType Directory -Force + New-Item 3\3\3\3 -ItemType Directory -Force + New-Item 4\4\4\4 -ItemType Directory -Force + + $byte = [Byte[]]::new(43MB) + [System.IO.File]::WriteAllBytes( + (Join-Path $basePath '1\1\1.bin'), + $byte + ) + + $byte = [Byte[]]::new(102MB) + [System.IO.File]::WriteAllBytes( + (Join-Path $basePath '2\2.bin'), + $byte + ) + + $byte = [Byte[]]::new(1MB) + [System.IO.File]::WriteAllBytes( + (Join-Path $basePath '3\3\3.bin'), + $byte + ) + + $byte = [Byte[]]::new(354MB) + [System.IO.File]::WriteAllBytes( + (Join-Path $basePath '4\4\4\4\4.bin'), + $byte + ) + } + + It 'When no parameters are supplied, measures the size and item counts for the current directory' { + $sizeInfo = Measure-ChildItem + + $sizeInfo.DirectoryCount | Should -Be 16 + $sizeInfo.FileCount | Should -Be 4 + $sizeInfo.Size | Should -Be (500MB) + } + + It 'When ValueOnly is set, returns the size value only' { + Measure-ChildItem -Path 4 -ValueOnly | Should -Be 354MB + } + + It 'When a Unit is defined, converts the size value' { + (Measure-ChildItem -Path . -Unit MB).Size | Should -Be 500 + } + + It 'When pipelined from Get-ChildItem, counts children' { + $sizeInfo = Get-ChildItem | Measure-ChildItem + + $sizeInfo.Count | Should -Be 4 + $sizeInfo[0].Size | Should -Be 43MB + $sizeInfo[1].Size | Should -Be 102MB + $sizeInfo[2].Size | Should -Be 1MB + $sizeInfo[3].Size | Should -Be 354MB + } + } + + Context 'Long paths' { + BeforeAll { + $byte = [Byte[]]::new(1MB) + [System.IO.File]::WriteAllBytes( + (Join-Path $basePath '5.bin'), + $byte + ) + + $longName = '5' * 261 + robocopy 5.bin "5\$longName.bin" + Remove-Item 5.bin + } + + It 'When the path length exceeds 260 characters, correctly returns the size' { + $sizeInfo = Measure-ChildItem -Path "5\$longName.bin" + + $sizeInfo.Path.Length | Should -BeGreaterThan 260 + $sizeInfo.Size | Should -Be 0 + } + } +} \ No newline at end of file