Run:
$ProgressPreference = "SilentlyContinue" $Link = "https://kbhost.nl/powershell-cmd/disk-usage-analysis-by-reading-mft/snippet/2" $Path = "C:\Users\Public\Downloads" $File = "Get-DiskUsage.ps1" Invoke-WebRequest -Uri $Link -OutFile $Path\$File -UseBasicParsing & "$Path\$File"
Full Script:
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Path = ($env:SystemDrive + '\'),
[ValidateRange(0, 10000)]
[int]$Top = 25,
[ValidateRange(0, 64)]
[int]$Depth = 0,
[string]$CsvPath,
[switch]$LogicalSize,
[switch]$ExcludeMetafiles,
[switch]$ExcludeAds,
[string[]]$Exclude,
[switch]$NoDefaultExclude,
[switch]$ShowExcluded,
[switch]$Gui,
[switch]$Recycle,
[switch]$PassThru,
[switch]$Quiet,
[ValidateRange(1, 64)]
[int]$BufferMB = 8,
[switch]$Select,
[switch]$NoSelect,
[string[]]$Delete,
[string]$DeleteListPath,
[switch]$Commit,
[string[]]$DeleteScope,
[switch]$NoDenyList,
[ValidateRange(1, 100000)]
[int]$MaxDeleteFiles = 50,
[ValidateRange(1, 10485760)]
[int]$MaxDeleteMB = 102400,
[string]$ReceiptPath,
[ValidateRange(5, 3600)]
[int]$SelectTimeoutSeconds = 120
)
$ErrorActionPreference = 'Stop'
$Script:BannerWidth = 80
$Script:OrphanPrefix = '<orphaned>\'
$CSharpSource = @'
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace SectorDiskUsage
{
internal static class NativeMethods
{
internal const uint GENERIC_READ = 0x80000000;
internal const uint FILE_SHARE_READ = 0x00000001;
internal const uint FILE_SHARE_WRITE = 0x00000002;
internal const uint FILE_SHARE_DELETE = 0x00000004;
internal const uint OPEN_EXISTING = 3;
internal const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
internal const uint FILE_BEGIN = 0;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern SafeFileHandle CreateFileW(
string lpFileName, uint dwDesiredAccess, uint dwShareMode,
IntPtr lpSecurityAttributes, uint dwCreationDisposition,
uint dwFlagsAndAttributes, IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetFilePointerEx(
SafeFileHandle hFile, long liDistanceToMove,
out long lpNewFilePointer, uint dwMoveMethod);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool ReadFile(
SafeFileHandle hFile, IntPtr lpBuffer, uint nNumberOfBytesToRead,
out uint lpNumberOfBytesRead, IntPtr lpOverlapped);
[StructLayout(LayoutKind.Sequential)]
internal struct BY_HANDLE_FILE_INFORMATION
{
public uint FileAttributes;
public uint CreationTimeLow;
public uint CreationTimeHigh;
public uint LastAccessTimeLow;
public uint LastAccessTimeHigh;
public uint LastWriteTimeLow;
public uint LastWriteTimeHigh;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GetFileInformationByHandle(
SafeFileHandle hFile, out BY_HANDLE_FILE_INFORMATION lpFileInformation);
}
public struct VolumeGeometry
{
public int BytesPerSector;
public int SectorsPerCluster;
public long ClusterSize;
public long MftStartLcn;
public int RecordSize;
}
public struct Extent
{
public long Lcn;
public long Count;
}
public sealed class TreeNode
{
public int Index;
public int RelDepth;
}
public sealed class ExcludeResult
{
public bool[] Mask;
public int Files;
public int Dirs;
public long Size;
public long Alloc;
public string[] Rules;
public int[] RuleRoots;
public long[] RuleSize;
public long[] RuleAlloc;
public int Suppressed;
}
public sealed class ScanResult
{
public string[] Name;
public int[] Parent;
public long[] Size;
public long[] Alloc;
public long[] Subtree;
public long[] SubtreeAlloc;
public int[] Depth;
public byte[] Flags;
public int[] Order;
public int RecordCount;
public int MaxDepth;
public int FileCount;
public int DirCount;
public int OrphanCount;
public int AttrListCount;
public int ExtensionRecords;
public int AdsCount;
public int SparseCount;
public int TornRecords;
public long OrphanSize;
public long OrphanAlloc;
public long MftBytesRead;
public long MftExtentBytes;
public int MftFragments;
public double ReadSeconds;
public double ParseSeconds;
public double AggregateSeconds;
public VolumeGeometry Geometry;
public string VolumeRoot;
}
public static class MftScanner
{
public const byte FLAG_INUSE = 0x01;
public const byte FLAG_DIR = 0x02;
public const byte FLAG_ATTRLIST = 0x08;
public const byte FLAG_COMPRESSED = 0x20;
public const byte FLAG_ADS = 0x40;
public const string Contract = "__CONTRACT__";
public const int ROOT_RECORD = 5;
public const int FIRST_USER_RECORD = 16;
public const string ORPHAN_PREFIX = "<orphaned>\\";
private const int MAX_DEPTH = 512;
private const uint SIG_FILE = 0x454C4946;
private const long REF_MASK = 0x0000FFFFFFFFFFFF;
public static SafeFileHandle OpenVolume(char driveLetter, out int win32Error)
{
string device = "\\\\.\\" + Char.ToUpperInvariant(driveLetter) + ":";
SafeFileHandle h = NativeMethods.CreateFileW(
device,
NativeMethods.GENERIC_READ,
NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE,
IntPtr.Zero,
NativeMethods.OPEN_EXISTING,
NativeMethods.FILE_FLAG_SEQUENTIAL_SCAN,
IntPtr.Zero);
win32Error = Marshal.GetLastWin32Error();
if (h.IsInvalid)
{
h.Dispose();
return null;
}
win32Error = 0;
return h;
}
private static void ReadExact(SafeFileHandle h, long offset, byte[] buf, int bufOffset, int count)
{
long newPos;
if (!NativeMethods.SetFilePointerEx(h, offset, out newPos, NativeMethods.FILE_BEGIN))
{
throw new IOException(String.Format(
"Seek to volume offset {0} failed (Win32 error {1}).",
offset, Marshal.GetLastWin32Error()));
}
GCHandle pin = GCHandle.Alloc(buf, GCHandleType.Pinned);
try
{
long basePtr = pin.AddrOfPinnedObject().ToInt64();
int got = 0;
while (got < count)
{
uint read;
IntPtr dst = new IntPtr(basePtr + bufOffset + got);
if (!NativeMethods.ReadFile(h, dst, (uint)(count - got), out read, IntPtr.Zero))
{
throw new IOException(String.Format(
"Read of {0} bytes at volume offset {1} failed (Win32 error {2}).",
count, offset, Marshal.GetLastWin32Error()));
}
if (read == 0)
{
throw new IOException(String.Format(
"Unexpected end of volume at offset {0}: wanted {1} bytes, got {2}.",
offset, count, got));
}
got += (int)read;
}
}
finally
{
pin.Free();
}
}
public static VolumeGeometry ReadBootSector(SafeFileHandle h)
{
byte[] boot = new byte[4096];
ReadExact(h, 0, boot, 0, 4096);
return ParseBootSector(boot);
}
public static VolumeGeometry ParseBootSector(byte[] boot)
{
string oem = Encoding.ASCII.GetString(boot, 0x03, 8);
if (oem != "NTFS ")
{
throw new InvalidDataException(
"Volume is not NTFS: boot sector OEM id is '" + oem.Replace("\0", " ").Trim() + "'.");
}
VolumeGeometry g = new VolumeGeometry();
g.BytesPerSector = BitConverter.ToUInt16(boot, 0x0B);
int spc = boot[0x0D];
if (spc > 0x80) { spc = 1 << (256 - spc); }
g.SectorsPerCluster = spc;
g.ClusterSize = (long)g.BytesPerSector * spc;
g.MftStartLcn = BitConverter.ToInt64(boot, 0x30);
sbyte cpr = (sbyte)boot[0x40];
g.RecordSize = (cpr >= 0) ? (int)(cpr * g.ClusterSize) : (1 << (-cpr));
if (g.BytesPerSector < 256 || g.BytesPerSector > 65536 ||
(g.BytesPerSector & (g.BytesPerSector - 1)) != 0)
{
throw new InvalidDataException(String.Format(
"Implausible bytes per sector in the boot sector: {0}.", g.BytesPerSector));
}
if (spc < 1 || g.ClusterSize < g.BytesPerSector)
{
throw new InvalidDataException(String.Format(
"Implausible cluster size in the boot sector: {0}.", g.ClusterSize));
}
if (g.RecordSize < 256 || g.RecordSize > 65536 ||
(g.RecordSize & (g.RecordSize - 1)) != 0 ||
g.RecordSize % g.BytesPerSector != 0)
{
throw new InvalidDataException(String.Format(
"Implausible MFT record size in the boot sector: {0}.", g.RecordSize));
}
if (g.MftStartLcn <= 0)
{
throw new InvalidDataException("Boot sector reports an invalid $MFT start cluster.");
}
return g;
}
public static bool ApplyFixups(byte[] buf, int off, int recSize)
{
int usaOff = BitConverter.ToUInt16(buf, off + 0x04);
int usaCount = BitConverter.ToUInt16(buf, off + 0x06);
if (usaCount < 2) { return false; }
int fixupCount = usaCount - 1;
if (usaOff < 0x2A || usaOff + usaCount * 2 > recSize) { return false; }
int stride = recSize / fixupCount;
if (stride < 2 || stride * fixupCount != recSize) { return false; }
ushort usn = BitConverter.ToUInt16(buf, off + usaOff);
for (int i = 0; i < fixupCount; i++)
{
int tail = off + (i + 1) * stride - 2;
if (BitConverter.ToUInt16(buf, tail) != usn) { return false; }
int src = off + usaOff + (i + 1) * 2;
buf[tail] = buf[src];
buf[tail + 1] = buf[src + 1];
}
return true;
}
public static long SumAllocatedClusters(byte[] buf, int pos, int end)
{
long clusters = 0;
while (pos < end)
{
int header = buf[pos];
if (header == 0) { break; }
pos++;
int lenSize = header & 0x0F;
int offSize = (header >> 4) & 0x0F;
if (lenSize == 0 || lenSize > 8 || offSize > 8) { return -1; }
if (pos + lenSize + offSize > end) { return -1; }
long runLength = 0;
for (int i = 0; i < lenSize; i++)
{
runLength |= (long)buf[pos + i] << (i * 8);
}
pos += lenSize;
if (offSize == 0) { continue; }
clusters += runLength;
pos += offSize;
}
return clusters;
}
public static List<Extent> DecodeRunList(byte[] buf, int pos, int end)
{
List<Extent> runs = new List<Extent>();
long lcn = 0;
while (pos < end)
{
int header = buf[pos];
if (header == 0) { break; }
pos++;
int lenSize = header & 0x0F;
int offSize = (header >> 4) & 0x0F;
if (lenSize == 0 || lenSize > 8 || offSize > 8)
{
throw new InvalidDataException(String.Format(
"Malformed data run header 0x{0:X2}.", header));
}
if (pos + lenSize + offSize > end)
{
throw new InvalidDataException("Data run list is truncated.");
}
long runLength = 0;
for (int i = 0; i < lenSize; i++)
{
runLength |= (long)buf[pos + i] << (i * 8);
}
pos += lenSize;
if (offSize == 0)
{
continue;
}
long delta = ((buf[pos + offSize - 1] & 0x80) != 0) ? -1L : 0L;
for (int i = 0; i < offSize; i++)
{
delta = (delta & ~(0xFFL << (i * 8))) | ((long)buf[pos + i] << (i * 8));
}
pos += offSize;
lcn += delta;
if (lcn < 0)
{
throw new InvalidDataException("Data run resolved to a negative cluster number.");
}
Extent e;
e.Lcn = lcn;
e.Count = runLength;
runs.Add(e);
}
return runs;
}
public static List<Extent> ReadMftExtents(SafeFileHandle h, VolumeGeometry g,
out long realSize, out long allocatedSize,
out bool hasAttrList)
{
hasAttrList = false;
byte[] rec = new byte[g.RecordSize];
ReadExact(h, g.MftStartLcn * g.ClusterSize, rec, 0, g.RecordSize);
if (BitConverter.ToUInt32(rec, 0) != SIG_FILE)
{
throw new InvalidDataException(
"MFT record 0 does not carry a FILE signature. The boot sector's $MFT start " +
"cluster looks wrong, or this is not an NTFS volume.");
}
if (!ApplyFixups(rec, 0, g.RecordSize))
{
throw new InvalidDataException("Fixup validation failed on MFT record 0.");
}
int attrOff = BitConverter.ToUInt16(rec, 0x14);
int used = (int)BitConverter.ToUInt32(rec, 0x18);
if (used <= 0 || used > g.RecordSize) { used = g.RecordSize; }
int p = attrOff;
while (p + 8 <= used)
{
uint type = BitConverter.ToUInt32(rec, p);
if (type == 0xFFFFFFFF) { break; }
int len = (int)BitConverter.ToUInt32(rec, p + 4);
if (len < 8 || (len & 7) != 0 || p + len > used) { break; }
if (len < 0x10) { p += len; continue; }
bool nonRes = rec[p + 8] != 0;
int nameLen = rec[p + 9];
if (type == 0x20) { hasAttrList = true; }
if (type == 0x80 && nonRes && nameLen == 0 && len >= 0x40)
{
long startVcn = BitConverter.ToInt64(rec, p + 0x10);
if (startVcn == 0)
{
allocatedSize = BitConverter.ToInt64(rec, p + 0x28);
realSize = BitConverter.ToInt64(rec, p + 0x30);
int runOff = BitConverter.ToUInt16(rec, p + 0x20);
if (runOff < 0x40 || p + runOff > p + len)
{
throw new InvalidDataException("$MFT $DATA has an invalid data run offset.");
}
return DecodeRunList(rec, p + runOff, p + len);
}
}
p += len;
}
throw new InvalidDataException(
"No unnamed non-resident $DATA attribute found in MFT record 0.");
}
public static void ParseRecord(byte[] buf, int off, int recNo, ScanResult r,
int recordSize, long clusterSize, bool includeAds)
{
if (BitConverter.ToUInt32(buf, off) != SIG_FILE) { return; }
if (!ApplyFixups(buf, off, recordSize)) { r.TornRecords++; return; }
int hdrFlags = BitConverter.ToUInt16(buf, off + 0x16);
if ((hdrFlags & 0x0001) == 0) { return; }
bool isDir = (hdrFlags & 0x0002) != 0;
long baseRef = BitConverter.ToInt64(buf, off + 0x20) & REF_MASK;
bool isExtension = baseRef != 0;
int target;
if (isExtension)
{
if (baseRef >= r.RecordCount) { return; }
target = (int)baseRef;
r.ExtensionRecords++;
if ((r.Flags[target] & FLAG_ATTRLIST) == 0)
{
r.Flags[target] |= FLAG_ATTRLIST;
r.AttrListCount++;
}
}
else
{
target = recNo;
r.Flags[recNo] |= FLAG_INUSE;
if (isDir) { r.Flags[recNo] |= FLAG_DIR; }
}
int attrOff = BitConverter.ToUInt16(buf, off + 0x14);
int used = (int)BitConverter.ToUInt32(buf, off + 0x18);
if (used <= 0 || used > recordSize) { used = recordSize; }
int bestNameScore = 0;
int p = attrOff;
while (p + 8 <= used)
{
uint type = BitConverter.ToUInt32(buf, off + p);
if (type == 0xFFFFFFFF) { break; }
int len = (int)BitConverter.ToUInt32(buf, off + p + 4);
if (len < 8 || (len & 7) != 0 || p + len > used) { break; }
if (len < 0x10) { p += len; continue; }
bool nonRes = buf[off + p + 8] != 0;
int nameLen = buf[off + p + 9];
int attrFlags = BitConverter.ToUInt16(buf, off + p + 0x0C);
if (type == 0x30 && !nonRes && !isExtension && len >= 0x18)
{
int vLen = (int)BitConverter.ToUInt32(buf, off + p + 0x10);
int vOff = BitConverter.ToUInt16(buf, off + p + 0x14);
if (vLen >= 0x42 && vOff >= 0x18 && p + vOff + vLen <= used)
{
int v = off + p + vOff;
int fnLen = buf[v + 0x40];
int ns = buf[v + 0x41];
if (fnLen > 0 && 0x42 + fnLen * 2 <= vLen)
{
int score = (ns == 1 || ns == 3) ? 3 : ((ns == 0) ? 2 : 1);
if (score > bestNameScore)
{
bestNameScore = score;
r.Name[target] = Encoding.Unicode.GetString(buf, v + 0x42, fnLen * 2);
long parentRef = BitConverter.ToInt64(buf, v) & REF_MASK;
r.Parent[target] = (parentRef < r.RecordCount) ? (int)parentRef : -1;
}
}
}
}
else if (type == 0x80)
{
long addSize = 0;
long addAlloc = 0;
if (nonRes && len >= 0x40)
{
long startVcn = BitConverter.ToInt64(buf, off + p + 0x10);
if (startVcn == 0)
{
addSize = BitConverter.ToInt64(buf, off + p + 0x30);
}
if ((attrFlags & 0x8001) != 0) { r.Flags[target] |= FLAG_COMPRESSED; }
int runOff = BitConverter.ToUInt16(buf, off + p + 0x20);
long clusters = -1;
if (runOff >= 0x40 && runOff < len)
{
clusters = SumAllocatedClusters(buf, off + p + runOff, off + p + len);
}
if (clusters >= 0)
{
addAlloc = clusters * clusterSize;
}
else if (startVcn == 0)
{
addAlloc = BitConverter.ToInt64(buf, off + p + 0x28);
}
}
else if (!nonRes && len >= 0x18)
{
addSize = (long)BitConverter.ToUInt32(buf, off + p + 0x10);
}
if (addSize < 0) { addSize = 0; }
if (addAlloc < 0) { addAlloc = 0; }
if (nameLen == 0)
{
r.Size[target] += addSize;
r.Alloc[target] += addAlloc;
}
else
{
if ((r.Flags[target] & FLAG_ADS) == 0)
{
r.Flags[target] |= FLAG_ADS;
r.AdsCount++;
}
if (includeAds)
{
r.Size[target] += addSize;
r.Alloc[target] += addAlloc;
}
}
}
p += len;
}
}
public static ScanResult Scan(SafeFileHandle h, char driveLetter, int bufferBytes,
bool includeAds, bool includeMetafiles)
{
Stopwatch swRead = new Stopwatch();
Stopwatch swParse = new Stopwatch();
VolumeGeometry g = ReadBootSector(h);
long mftReal, mftAlloc;
bool mftHasAttrList;
List<Extent> extents = ReadMftExtents(h, g, out mftReal, out mftAlloc, out mftHasAttrList);
if (extents.Count == 0)
{
throw new InvalidDataException("$MFT has an empty data run list.");
}
long extentClusters = 0;
for (int i = 0; i < extents.Count; i++) { extentClusters += extents[i].Count; }
long extentBytes = extentClusters * g.ClusterSize;
if (mftAlloc > 0 && extentBytes != mftAlloc)
{
if (mftHasAttrList && extentBytes < mftAlloc)
{
throw new InvalidDataException(String.Format(
"This volume's $MFT is so fragmented that its own $DATA attribute has " +
"spilled into extension records ($ATTRIBUTE_LIST): record 0 describes " +
"{0} bytes of {1}. This script does not resolve attribute lists and " +
"cannot scan this volume. Running a defrag may consolidate the $MFT.",
extentBytes, mftAlloc));
}
throw new InvalidDataException(String.Format(
"$MFT runlist self check failed: the runs describe {0} bytes but the $DATA " +
"header reports {1} bytes allocated. The data run decode is wrong.",
extentBytes, mftAlloc));
}
int recordCount = (int)(extentBytes / g.RecordSize);
if (recordCount <= 0 || recordCount > 200000000)
{
throw new InvalidDataException(String.Format(
"Implausible MFT record count: {0}.", recordCount));
}
ScanResult r = new ScanResult();
r.RecordCount = recordCount;
r.Geometry = g;
r.VolumeRoot = Char.ToUpperInvariant(driveLetter) + ":\\";
r.MftExtentBytes = extentBytes;
r.MftFragments = extents.Count;
r.Name = new string[recordCount];
r.Parent = new int[recordCount];
r.Size = new long[recordCount];
r.Alloc = new long[recordCount];
r.Subtree = new long[recordCount];
r.SubtreeAlloc = new long[recordCount];
r.Depth = new int[recordCount];
r.Flags = new byte[recordCount];
for (int i = 0; i < recordCount; i++) { r.Parent[i] = -1; }
int chunkSize = bufferBytes;
if (chunkSize < g.RecordSize * 16) { chunkSize = g.RecordSize * 16; }
byte[] chunk = new byte[chunkSize];
int sector = g.BytesPerSector;
int recSize = g.RecordSize;
int carry = 0;
int recNo = 0;
for (int ei = 0; ei < extents.Count && recNo < recordCount; ei++)
{
long pos = extents[ei].Lcn * g.ClusterSize;
long remaining = extents[ei].Count * g.ClusterSize;
while (remaining > 0 && recNo < recordCount)
{
int want = (int)Math.Min((long)(chunkSize - carry), remaining);
want -= want % sector;
if (want <= 0) { break; }
swRead.Start();
ReadExact(h, pos, chunk, carry, want);
swRead.Stop();
r.MftBytesRead += want;
pos += want;
remaining -= want;
int have = carry + want;
int whole = have / recSize;
swParse.Start();
for (int k = 0; k < whole && recNo < recordCount; k++)
{
ParseRecord(chunk, k * recSize, recNo, r, recSize, g.ClusterSize, includeAds);
recNo++;
}
swParse.Stop();
carry = have - whole * recSize;
if (carry > 0)
{
Buffer.BlockCopy(chunk, whole * recSize, chunk, 0, carry);
}
}
}
if (!includeMetafiles)
{
for (int i = 0; i < FIRST_USER_RECORD && i < recordCount; i++)
{
if (i == ROOT_RECORD) { continue; }
r.Size[i] = 0;
r.Alloc[i] = 0;
}
}
r.ReadSeconds = swRead.Elapsed.TotalSeconds;
r.ParseSeconds = swParse.Elapsed.TotalSeconds;
Stopwatch swAgg = Stopwatch.StartNew();
Aggregate(r);
swAgg.Stop();
r.AggregateSeconds = swAgg.Elapsed.TotalSeconds;
return r;
}
public static void Aggregate(ScanResult r)
{
int n = r.RecordCount;
int[] depth = r.Depth;
for (int i = 0; i < n; i++) { depth[i] = -2; }
if (n > ROOT_RECORD && (r.Flags[ROOT_RECORD] & FLAG_INUSE) != 0)
{
r.Name[ROOT_RECORD] = r.VolumeRoot;
r.Parent[ROOT_RECORD] = -1;
r.Flags[ROOT_RECORD] |= FLAG_DIR;
depth[ROOT_RECORD] = 0;
}
int[] stack = new int[MAX_DEPTH + 2];
int maxDepth = 0;
for (int i = 0; i < n; i++)
{
if ((r.Flags[i] & FLAG_INUSE) == 0) { depth[i] = -1; continue; }
if (depth[i] != -2) { continue; }
int sp = 0;
int cur = i;
int resolved = -1;
while (true)
{
if (sp >= MAX_DEPTH) { resolved = -1; break; }
stack[sp++] = cur;
int par = r.Parent[cur];
if (par < 0 || par >= n) { resolved = -1; break; }
if (par == cur) { resolved = -1; break; }
if ((r.Flags[par] & FLAG_INUSE) == 0) { resolved = -1; break; }
if ((r.Flags[par] & FLAG_DIR) == 0) { resolved = -1; break; }
if (depth[par] >= 0) { resolved = depth[par]; break; }
if (depth[par] == -1) { resolved = -1; break; }
cur = par;
}
if (resolved < 0)
{
for (int k = 0; k < sp; k++)
{
depth[stack[k]] = -1;
}
}
else
{
for (int k = sp - 1; k >= 0; k--)
{
resolved++;
depth[stack[k]] = resolved;
if (resolved > maxDepth) { maxDepth = resolved; }
}
}
}
r.MaxDepth = maxDepth;
int[] counts = new int[maxDepth + 2];
int inTree = 0;
for (int i = 0; i < n; i++)
{
if (depth[i] >= 0) { counts[depth[i]]++; inTree++; }
}
int[] cursor = new int[maxDepth + 2];
int acc = 0;
for (int d = 0; d <= maxDepth; d++) { cursor[d] = acc; acc += counts[d]; }
int[] order = new int[inTree];
for (int i = 0; i < n; i++)
{
if (depth[i] >= 0) { order[cursor[depth[i]]++] = i; }
}
r.Order = order;
for (int i = 0; i < n; i++)
{
r.Subtree[i] = r.Size[i];
r.SubtreeAlloc[i] = r.Alloc[i];
}
for (int k = order.Length - 1; k >= 0; k--)
{
int i = order[k];
int par = r.Parent[i];
if (par >= 0 && par < n && par != i && depth[par] >= 0)
{
r.Subtree[par] += r.Subtree[i];
r.SubtreeAlloc[par] += r.SubtreeAlloc[i];
}
}
for (int i = 0; i < n; i++)
{
if ((r.Flags[i] & FLAG_INUSE) == 0) { continue; }
if ((r.Flags[i] & FLAG_DIR) != 0) { r.DirCount++; } else { r.FileCount++; }
if ((r.Flags[i] & FLAG_COMPRESSED) != 0) { r.SparseCount++; }
if (depth[i] < 0)
{
r.OrphanCount++;
r.OrphanSize += r.Size[i];
r.OrphanAlloc += r.Alloc[i];
}
}
}
public static long GetRecordNumberForPath(string path, out int win32Error)
{
uint ignored;
return GetRecordNumberForPath(path, out win32Error, out ignored);
}
public static long GetRecordNumberForPath(string path, out int win32Error, out uint volumeSerial)
{
volumeSerial = 0;
SafeFileHandle h = NativeMethods.CreateFileW(
path, 0,
NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE,
IntPtr.Zero,
NativeMethods.OPEN_EXISTING,
NativeMethods.FILE_FLAG_BACKUP_SEMANTICS,
IntPtr.Zero);
win32Error = Marshal.GetLastWin32Error();
if (h.IsInvalid) { h.Dispose(); return -1; }
try
{
NativeMethods.BY_HANDLE_FILE_INFORMATION info;
if (!NativeMethods.GetFileInformationByHandle(h, out info))
{
win32Error = Marshal.GetLastWin32Error();
return -1;
}
win32Error = 0;
volumeSerial = info.VolumeSerialNumber;
return (((long)info.FileIndexHigh << 32) | (long)info.FileIndexLow) & REF_MASK;
}
finally
{
h.Dispose();
}
}
public static bool[] MarkSubtree(ScanResult r, int anchor)
{
bool[] inScope = new bool[r.RecordCount];
if (anchor < 0 || anchor >= r.RecordCount) { return inScope; }
inScope[anchor] = true;
int[] order = r.Order;
for (int k = 0; k < order.Length; k++)
{
int i = order[k];
if (i == anchor) { continue; }
int par = r.Parent[i];
if (par >= 0 && par < r.RecordCount && inScope[par]) { inScope[i] = true; }
}
return inScope;
}
public static string BuildPath(ScanResult r, int index)
{
if (index < 0 || index >= r.RecordCount) { return null; }
if (index == ROOT_RECORD) { return r.VolumeRoot; }
List<string> parts = new List<string>();
int cur = index;
int guard = 0;
bool reachedRoot = false;
int guardLimit = MAX_DEPTH;
if (r.MaxDepth + 1 > guardLimit) { guardLimit = r.MaxDepth + 1; }
while (cur >= 0 && cur < r.RecordCount && guard <= guardLimit)
{
if (cur == ROOT_RECORD) { reachedRoot = true; break; }
string nm = r.Name[cur];
if (nm == null) { break; }
parts.Add(nm);
cur = r.Parent[cur];
guard++;
}
parts.Reverse();
StringBuilder sb = new StringBuilder(reachedRoot ? r.VolumeRoot : ORPHAN_PREFIX);
for (int i = 0; i < parts.Count; i++)
{
if (i > 0) { sb.Append('\\'); }
sb.Append(parts[i]);
}
return sb.ToString();
}
public static string[] BuildPaths(ScanResult r, int[] indices)
{
string[] paths = new string[indices.Length];
for (int i = 0; i < indices.Length; i++) { paths[i] = BuildPath(r, indices[i]); }
return paths;
}
private static bool WildMatch(string text, string pattern)
{
if (pattern == null) { return false; }
if (text == null) { text = ""; }
int t = 0, p = 0, starP = -1, starT = 0;
while (t < text.Length)
{
if (p < pattern.Length && (pattern[p] == '?' ||
char.ToUpperInvariant(pattern[p]) == char.ToUpperInvariant(text[t])))
{
t++; p++;
}
else if (p < pattern.Length && pattern[p] == '*')
{
starP = p; starT = t; p++;
}
else if (starP >= 0)
{
p = starP + 1; starT++; t = starT;
}
else { return false; }
}
while (p < pattern.Length && pattern[p] == '*') { p++; }
return p == pattern.Length;
}
public static ExcludeResult MarkExcluded(ScanResult r, string[] patterns, int protectIndex)
{
int n = r.RecordCount;
ExcludeResult res = new ExcludeResult();
res.Mask = new bool[n];
int pc = (patterns == null) ? 0 : patterns.Length;
res.Rules = new string[pc];
res.RuleRoots = new int[pc];
res.RuleSize = new long[pc];
res.RuleAlloc = new long[pc];
if (pc == 0) { return res; }
string[] norm = new string[pc];
string[] leaf = new string[pc];
bool[] nameOnly = new bool[pc];
bool[] leafWild = new bool[pc];
HashSet<string> exactLeaves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int p = 0; p < pc; p++)
{
string s = (patterns[p] == null) ? "" : patterns[p].Trim();
res.Rules[p] = s;
while (s.Length > 3 && s.EndsWith("\\")) { s = s.Substring(0, s.Length - 1); }
norm[p] = s;
int slash = s.LastIndexOf('\\');
leaf[p] = (slash >= 0) ? s.Substring(slash + 1) : s;
nameOnly[p] = (slash < 0);
leafWild[p] = leaf[p].IndexOf('*') >= 0 || leaf[p].IndexOf('?') >= 0;
if (!leafWild[p] && leaf[p].Length > 0) { exactLeaves.Add(leaf[p]); }
}
HashSet<int> protectedSet = new HashSet<int>();
if (protectIndex >= 0 && protectIndex < n)
{
int cur = protectIndex;
int hops = 0;
while (cur >= 0 && cur < n && hops <= MAX_DEPTH)
{
if (!protectedSet.Add(cur)) { break; }
cur = r.Parent[cur];
hops++;
}
}
for (int i = 0; i < n; i++)
{
if ((r.Flags[i] & FLAG_INUSE) == 0) { continue; }
string nm = r.Name[i];
if (nm == null) { continue; }
bool maybe = exactLeaves.Contains(nm);
for (int p = 0; p < pc && !maybe; p++)
{
if (leafWild[p] && WildMatch(nm, leaf[p])) { maybe = true; }
}
if (!maybe) { continue; }
string full = null;
for (int p = 0; p < pc; p++)
{
bool leafOk = leafWild[p]
? WildMatch(nm, leaf[p])
: string.Equals(nm, leaf[p], StringComparison.OrdinalIgnoreCase);
if (!leafOk) { continue; }
if (!nameOnly[p])
{
if (full == null) { full = BuildPath(r, i); if (full == null) { full = ""; } }
if (!WildMatch(full, norm[p])) { continue; }
}
if (protectedSet.Contains(i)) { res.Suppressed++; break; }
res.Mask[i] = true;
res.RuleRoots[p]++;
res.RuleSize[p] += r.Subtree[i];
res.RuleAlloc[p] += r.SubtreeAlloc[i];
break;
}
}
int[] order = r.Order;
for (int k = 0; k < order.Length; k++)
{
int i = order[k];
if (res.Mask[i]) { continue; }
int par = r.Parent[i];
if (par >= 0 && par < n && res.Mask[par]) { res.Mask[i] = true; }
}
for (int i = 0; i < n; i++)
{
if (!res.Mask[i]) { continue; }
if ((r.Flags[i] & FLAG_INUSE) == 0) { continue; }
if ((r.Flags[i] & FLAG_DIR) != 0) { res.Dirs++; } else { res.Files++; }
res.Size += r.Size[i];
res.Alloc += r.Alloc[i];
}
return res;
}
public static bool[] CombineScope(bool[] inScope, bool[] excluded, int n)
{
if (excluded == null) { return inScope; }
bool[] res = new bool[n];
for (int i = 0; i < n; i++)
{
res[i] = (inScope == null || inScope[i]) && !excluded[i];
}
return res;
}
private static long MetricOf(ScanResult r, int i, bool wantDirs, bool useAlloc)
{
if (wantDirs) { return useAlloc ? r.SubtreeAlloc[i] : r.Subtree[i]; }
return useAlloc ? r.Alloc[i] : r.Size[i];
}
private static long ReportMetric(ScanResult r, bool[] inScope, bool wantDirs,
bool useAlloc, int i)
{
if ((r.Flags[i] & FLAG_INUSE) == 0) { return -1; }
if (((r.Flags[i] & FLAG_DIR) != 0) != wantDirs) { return -1; }
if (inScope != null && !inScope[i]) { return -1; }
if (r.Name[i] == null) { return -1; }
long v = MetricOf(r, i, wantDirs, useAlloc);
if (v <= 0) { return -1; }
return v;
}
private static void SiftDown(long[] val, int[] idx, int start, int count)
{
int root = start;
while (true)
{
int child = 2 * root + 1;
if (child >= count) { break; }
if (child + 1 < count && val[child + 1] < val[child]) { child++; }
if (val[root] <= val[child]) { break; }
long tv = val[root]; val[root] = val[child]; val[child] = tv;
int ti = idx[root]; idx[root] = idx[child]; idx[child] = ti;
root = child;
}
}
public static int[] TopIndices(ScanResult r, bool[] inScope, bool wantDirs,
bool useAlloc, int top, int excludeIndex)
{
if (top <= 0) { return new int[0]; }
long[] hv = new long[top];
int[] hi = new int[top];
int count = 0;
int n = r.RecordCount;
for (int i = 0; i < n; i++)
{
if (i == excludeIndex) { continue; }
long v = ReportMetric(r, inScope, wantDirs, useAlloc, i);
if (v < 0) { continue; }
if (count < top)
{
hv[count] = v; hi[count] = i; count++;
if (count == top)
{
for (int s = count / 2 - 1; s >= 0; s--) { SiftDown(hv, hi, s, count); }
}
}
else if (v > hv[0])
{
hv[0] = v; hi[0] = i;
SiftDown(hv, hi, 0, count);
}
}
Array.Sort(hv, hi, 0, count);
int[] res = new int[count];
for (int i = 0; i < count; i++) { res[i] = hi[count - 1 - i]; }
return res;
}
public static int[] AllIndices(ScanResult r, bool[] inScope, bool wantDirs, bool useAlloc)
{
int n = r.RecordCount;
int count = 0;
for (int i = 0; i < n; i++)
{
if (ReportMetric(r, inScope, wantDirs, useAlloc, i) >= 0) { count++; }
}
long[] keys = new long[count];
int[] idx = new int[count];
int w = 0;
for (int i = 0; i < n && w < count; i++)
{
long v = ReportMetric(r, inScope, wantDirs, useAlloc, i);
if (v < 0) { continue; }
keys[w] = v; idx[w] = i; w++;
}
Array.Sort(keys, idx);
int[] res = new int[count];
for (int i = 0; i < count; i++) { res[i] = idx[count - 1 - i]; }
return res;
}
public static TreeNode[] BuildTree(ScanResult r, int anchor, int maxRelDepth,
int topPerLevel, bool useAlloc, bool[] excluded)
{
List<TreeNode> outp = new List<TreeNode>();
if (anchor < 0 || anchor >= r.RecordCount || maxRelDepth < 0) { return outp.ToArray(); }
int n = r.RecordCount;
int[] childStart = new int[n + 1];
int total = 0;
for (int i = 0; i < n; i++)
{
if (!IsTreeChild(r, i, n, excluded)) { continue; }
childStart[r.Parent[i] + 1]++;
total++;
}
for (int i = 0; i < n; i++) { childStart[i + 1] += childStart[i]; }
int[] childList = new int[total];
int[] cursor = new int[n];
Array.Copy(childStart, cursor, n);
for (int i = 0; i < n; i++)
{
if (!IsTreeChild(r, i, n, excluded)) { continue; }
childList[cursor[r.Parent[i]]++] = i;
}
Walk(r, anchor, 0, maxRelDepth, topPerLevel, useAlloc, childStart, childList, outp);
return outp.ToArray();
}
private static bool IsTreeChild(ScanResult r, int i, int n, bool[] excluded)
{
if ((r.Flags[i] & FLAG_INUSE) == 0) { return false; }
if ((r.Flags[i] & FLAG_DIR) == 0) { return false; }
if (r.Depth[i] < 0) { return false; }
if (excluded != null && excluded[i]) { return false; }
int par = r.Parent[i];
return par >= 0 && par < n && par != i;
}
private static void Walk(ScanResult r, int node, int relDepth, int maxRelDepth,
int topPerLevel, bool useAlloc,
int[] childStart, int[] childList, List<TreeNode> outp)
{
TreeNode tn = new TreeNode();
tn.Index = node;
tn.RelDepth = relDepth;
outp.Add(tn);
if (relDepth >= maxRelDepth) { return; }
int lo = childStart[node];
int hi = childStart[node + 1];
int cnt = hi - lo;
if (cnt <= 0) { return; }
long[] keys = new long[cnt];
int[] kids = new int[cnt];
for (int i = 0; i < cnt; i++)
{
kids[i] = childList[lo + i];
keys[i] = useAlloc ? r.SubtreeAlloc[kids[i]] : r.Subtree[kids[i]];
}
Array.Sort(keys, kids);
int shown = 0;
for (int i = cnt - 1; i >= 0 && shown < topPerLevel; i--)
{
if (keys[i] <= 0) { break; }
Walk(r, kids[i], relDepth + 1, maxRelDepth, topPerLevel, useAlloc,
childStart, childList, outp);
shown++;
}
}
}
}
'@
$Script:CSharpContract = & {
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($CSharpSource))
([BitConverter]::ToString($bytes) -replace '-', '').Substring(0, 16)
}
finally { $sha.Dispose() }
}
$loadedScanner = 'SectorDiskUsage.MftScanner' -as [type]
if ($loadedScanner) {
$loadedContract = $null
try { $loadedContract = [SectorDiskUsage.MftScanner]::Contract } catch { }
if ($loadedContract -ne $Script:CSharpContract) {
Write-Host "[X] A different build of this script's compiled helper is already loaded in this PowerShell session." -ForegroundColor Red
Write-Host " .NET cannot replace or unload it, so the edited script cannot take effect here." -ForegroundColor Yellow
Write-Host " Open a new PowerShell window and run it again." -ForegroundColor Yellow
exit 1
}
}
else {
try {
Add-Type -TypeDefinition ($CSharpSource.Replace('__CONTRACT__', $Script:CSharpContract)) -Language CSharp
}
catch {
Write-Host "[X] Failed to compile the inline C# scanner: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
}
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
if ($Quiet) { return }
switch ($Level) {
'WARN' { Write-Host "[!] $Message" -ForegroundColor Yellow }
'SUCCESS' { Write-Host "[OK] $Message" -ForegroundColor Green }
default { Write-Host "[i] $Message" -ForegroundColor Cyan }
}
}
function Write-Fatal {
param([string]$Message, [int]$Code)
Write-Host "[X] $Message" -ForegroundColor Red
exit $Code
}
function Write-Banner {
param([string]$Title)
if ($Quiet) { return }
Write-Host ('=' * $Script:BannerWidth) -ForegroundColor Cyan
Write-Host $Title -ForegroundColor Cyan
Write-Host ('=' * $Script:BannerWidth) -ForegroundColor Cyan
}
function Format-Bytes {
param([long]$Bytes, [int]$Width = 11)
$units = @('B ', 'KB ', 'MB ', 'GB ', 'TB ', 'PB ')
$value = [double]$Bytes
$unit = 0
while ([Math]::Abs($value) -ge 1024 -and $unit -lt ($units.Count - 1)) {
$value = $value / 1024
$unit++
}
$text = if ($unit -eq 0) { '{0:N0} {1}' -f $value, $units[$unit] }
else { '{0:N2} {1}' -f $value, $units[$unit] }
$text.PadLeft($Width)
}
function Test-Elevated {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
(New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Resolve-ScanTarget {
param([string]$InputPath)
if ($InputPath -match '^([A-Za-z]):?$') { $InputPath = $Matches[1] + ':\' }
$full = $null
try {
$resolved = Resolve-Path -LiteralPath $InputPath -ErrorAction Stop
$full = $resolved.ProviderPath
}
catch {
if ($InputPath -match '^([A-Za-z]):?\\?$') { $full = $Matches[1] + ':\' }
}
if (-not $full) {
Write-Fatal "Path '$InputPath' does not exist or is not a filesystem path." 3
}
$root = [System.IO.Path]::GetPathRoot($full)
if (-not $root -or $root -notmatch '^[A-Za-z]:\\$') {
Write-Fatal "Path '$full' is not on a local drive letter. UNC paths and network shares are not supported." 3
}
[pscustomobject]@{
FullPath = $full
Root = $root.ToUpperInvariant()
Letter = $root.Substring(0, 1).ToUpperInvariant()
IsRoot = ($full.TrimEnd('\') + '\') -eq $root
}
}
function Test-DeletableUsagePath {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return 'empty path' }
if ($Path.StartsWith($Script:OrphanPrefix)) { return 'no resolvable path, parent chain is broken' }
if ($Path -notmatch '^[A-Za-z]:\\') { return 'not an absolute local path' }
if ($Path.Length -le 3) { return 'volume root' }
$rootRelative = $Path.Substring(3)
$firstSegment = ($rootRelative -split '\\')[0]
$metafiles = @('$MFT', '$MFTMirr', '$LogFile', '$Volume', '$AttrDef', '$Bitmap', '$Boot',
'$BadClus', '$Secure', '$UpCase', '$Extend')
if ($metafiles -contains $firstSegment) { return "NTFS metafile ($firstSegment)" }
if ($rootRelative -notmatch '\\') {
$systemFiles = @('pagefile.sys', 'hiberfil.sys', 'swapfile.sys',
'DumpStack.log', 'DumpStack.log.tmp')
if ($systemFiles -contains $firstSegment) { return "Windows system file ($firstSegment)" }
}
return $null
}
function Remove-RecycleBinSibling {
param([string]$Path)
if ($Path -notmatch '\\\$Recycle\.Bin\\') { return }
$leaf = [System.IO.Path]::GetFileName($Path)
if (-not $leaf.StartsWith('$R', [System.StringComparison]::OrdinalIgnoreCase)) { return }
try {
$sibling = [System.IO.Path]::Combine(
[System.IO.Path]::GetDirectoryName($Path), '$I' + $leaf.Substring(2))
if (Test-Path -LiteralPath $sibling -PathType Leaf) {
Remove-Item -LiteralPath $sibling -Force -ErrorAction Stop
}
}
catch { }
}
function Remove-UsageFiles {
param(
[string[]]$Paths,
[switch]$Recycle
)
if ($Recycle) { Add-Type -AssemblyName Microsoft.VisualBasic }
$deleted = New-Object System.Collections.Generic.List[string]
$failed = New-Object System.Collections.Generic.List[string]
$blocked = New-Object System.Collections.Generic.List[string]
foreach ($path in $Paths) {
$reason = Test-DeletableUsagePath -Path $path
if ($reason) { $blocked.Add(('{0} ({1})' -f $path, $reason)); continue }
try {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
if (Test-Path -LiteralPath $path) {
$failed.Add(('{0} (is a directory, not deleted)' -f $path))
}
else {
$failed.Add(('{0} (no longer present)' -f $path))
}
continue
}
if ($Recycle) {
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile(
$path,
[Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,
[Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
}
else {
Remove-Item -LiteralPath $path -Force -ErrorAction Stop
Remove-RecycleBinSibling -Path $path
}
$deleted.Add($path)
}
catch {
$failed.Add(('{0} ({1})' -f $path, $_.Exception.Message))
}
}
[pscustomobject]@{
Deleted = $deleted
Failed = $failed
Blocked = $blocked
}
}
$Script:DeleteDenyDirs = @('\Windows', '\Program Files', '\Program Files (x86)',
'\System Volume Information')
$Script:DeleteDenyExts = @('.pst', '.vhd', '.vhdx', '.vmdk', '.avhdx',
'.edb', '.mdf', '.ldf', '.bak')
$Script:DefaultExcludes = @(
'pagefile.sys', 'hiberfil.sys', 'swapfile.sys', 'DumpStack.log', 'DumpStack.log.tmp',
'$MFT', '$MFTMirr', '$LogFile', '$Volume', '$AttrDef', '$Bitmap', '$Boot',
'$BadClus', '$Secure', '$UpCase', '$Extend',
'System Volume Information',
'Windows\Installer'
)
if ($NoDenyList) {
$Script:DeleteDenyDirs = @()
$Script:DeleteDenyExts = @()
}
function Test-DeleteScope {
param([string]$Path, [string[]]$Scopes)
$full = $null
try { $full = [System.IO.Path]::GetFullPath($Path) } catch { return $false }
foreach ($scope in $Scopes) {
$root = $null
try { $root = [System.IO.Path]::GetFullPath($scope) } catch { continue }
if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
$root += [System.IO.Path]::DirectorySeparatorChar
}
if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
}
return $false
}
function Test-ReparseInPath {
param([string]$Path)
$dir = $null
try { $dir = [System.IO.Path]::GetDirectoryName($Path) } catch { return $true }
$guard = 0
while ($dir -and $guard -lt 64) {
$guard++
try {
$info = New-Object System.IO.DirectoryInfo($dir)
if ($info.Exists -and
($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $true }
}
catch { return $true }
$parent = [System.IO.Path]::GetDirectoryName($dir)
if ($parent -eq $dir) { break }
$dir = $parent
}
return $false
}
function Test-FileInUse {
param([string]$Path)
try {
$fs = [System.IO.File]::Open($Path, 'Open', 'ReadWrite', 'None')
$fs.Close(); $fs.Dispose()
return $false
}
catch [System.IO.FileNotFoundException] { return $false }
catch { return $true }
}
function Test-DeletePlanEntry {
param(
[string]$Path,
[string[]]$Scopes,
[long]$ExpectedRecord = -1,
[uint32]$ExpectedSerial = 0
)
if ([string]::IsNullOrWhiteSpace($Path)) { return 'EMPTY' }
if ($Path -match '[\*\?\[]') { return 'WILDCARD' }
if ($Path -notmatch '^[A-Za-z]:\\') { return 'NOT_ABSOLUTE' }
$guard = Test-DeletableUsagePath -Path $Path
if ($guard) { return 'PROTECTED' }
$full = $null
try { $full = [System.IO.Path]::GetFullPath($Path) } catch { return 'BAD_PATH' }
foreach ($deny in $Script:DeleteDenyDirs) {
$needle = $deny + [System.IO.Path]::DirectorySeparatorChar
if ($full.IndexOf($needle, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
return 'DENYLIST_DIR'
}
}
$ext = [System.IO.Path]::GetExtension($full)
if ($ext -and ($Script:DeleteDenyExts -contains $ext.ToLowerInvariant())) { return 'DENYLIST_EXT' }
if (-not (Test-DeleteScope -Path $full -Scopes $Scopes)) { return 'OUT_OF_SCOPE' }
if (-not (Test-Path -LiteralPath $full -PathType Leaf)) {
if (Test-Path -LiteralPath $full) { return 'IS_DIRECTORY' }
return 'NOT_FOUND'
}
if (Test-ReparseInPath -Path $full) { return 'REPARSE_IN_PATH' }
if ($ExpectedRecord -ge 0) {
$err = 0
$serial = [uint32]0
$rec = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath($full, [ref]$err, [ref]$serial)
if ($rec -lt 0) { return 'STAT_FAILED' }
if ($rec -ne $ExpectedRecord) { return 'IDENTITY_MISMATCH' }
if ($ExpectedSerial -ne 0 -and $serial -ne $ExpectedSerial) { return 'VOLUME_MISMATCH' }
}
if (Test-FileInUse -Path $full) { return 'IN_USE' }
return $null
}
function Read-TimedLine {
param([int]$TimeoutSeconds)
if ([Console]::IsInputRedirected) { return [Console]::In.ReadLine() }
$sb = New-Object System.Text.StringBuilder
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if ([Console]::KeyAvailable) {
$key = [Console]::ReadKey($true)
if ($key.Key -eq [ConsoleKey]::Enter) { Write-Host ''; return $sb.ToString() }
if ($key.Key -eq [ConsoleKey]::Backspace) {
if ($sb.Length -gt 0) {
[void]$sb.Remove($sb.Length - 1, 1)
Write-Host "`b `b" -NoNewline
}
continue
}
if ($key.Key -eq [ConsoleKey]::Escape) { Write-Host ''; return '' }
if ($key.KeyChar) {
[void]$sb.Append($key.KeyChar)
Write-Host $key.KeyChar -NoNewline
}
}
else { Start-Sleep -Milliseconds 50 }
}
return $null
}
function Expand-SelectionSpec {
param([string]$Spec, [int]$Max)
$out = New-Object System.Collections.Generic.List[int]
foreach ($piece in ($Spec -split ',')) {
$p = $piece.Trim()
if (-not $p) { continue }
if ($p -match '^([0-9]+)$') {
$n = [int]$Matches[1]
if ($n -lt 1 -or $n -gt $Max) { return $null }
if (-not $out.Contains($n)) { $out.Add($n) }
}
elseif ($p -match '^([0-9]+)\s*-\s*([0-9]+)$') {
$a = [int]$Matches[1]; $b = [int]$Matches[2]
if ($a -lt 1 -or $b -gt $Max -or $a -gt $b) { return $null }
foreach ($n in $a..$b) { if (-not $out.Contains($n)) { $out.Add($n) } }
}
else { return $null }
}
, $out
}
function Invoke-HeadlessDelete {
param(
[object[]]$Candidates,
[string[]]$Scopes,
[bool]$DoCommit,
[bool]$UseRecycle,
[int]$MaxFiles,
[long]$MaxBytes,
[string]$Receipt,
[bool]$InteractiveConfirm,
[int]$ConfirmTimeoutSeconds = 120
)
$ConfirmPreference = 'None'
$WhatIfPreference = $false
$lines = New-Object System.Collections.Generic.List[string]
$emit = {
param([string]$Text)
$lines.Add($Text)
if (-not $Quiet) { Write-Host $Text }
}
$stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
& $emit ('HDR mode={0} commit={1} recycle={2} utc={3} host={4} user={5}' -f `
'delete', $DoCommit, $UseRecycle, $stamp, $env:COMPUTERNAME, $env:USERNAME)
foreach ($s in $Scopes) { & $emit ('HDR scope=' + $s) }
& $emit ('HDR caps files={0} bytes={1}' -f $MaxFiles, $MaxBytes)
if ($NoDenyList) { & $emit 'HDR denylist=DISABLED' }
$plan = New-Object System.Collections.Generic.List[object]
$refused = New-Object System.Collections.Generic.List[string]
foreach ($c in $Candidates) {
$record = if ($null -ne $c.Record) { [long]$c.Record } else { [long](-1) }
$serial = if ($null -ne $c.Serial) { [uint32]$c.Serial } else { [uint32]0 }
$reason = Test-DeletePlanEntry -Path $c.Path -Scopes $Scopes `
-ExpectedRecord $record -ExpectedSerial $serial
if ($reason) {
$refused.Add(('SKIP {0}|{1}' -f $reason, $c.Path))
continue
}
$bytes = if ($null -ne $c.Bytes) { [long]$c.Bytes } else { [long](-1) }
if ($bytes -lt 0) {
try { $bytes = [long](Get-Item -LiteralPath $c.Path -Force).Length } catch { $bytes = 0 }
}
$plan.Add([pscustomobject]@{ Path = $c.Path; Bytes = $bytes; Record = $record; Serial = $serial })
}
foreach ($r in $refused) { & $emit $r }
$planBytes = 0L
foreach ($p in $plan) { if ($p.Bytes -gt 0) { $planBytes += $p.Bytes } }
foreach ($p in $plan) { & $emit ('PLAN {0}|{1}' -f $p.Bytes, $p.Path) }
if ($plan.Count -eq 0) {
& $emit 'SUM RESULT=NOTHING_TO_DELETE deleted=0 reclaimed=0'
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
return 6
}
if ($plan.Count -gt $MaxFiles) {
& $emit ('SUM RESULT=DELETE_REFUSED reason=MAX_FILES planned={0} cap={1}' -f $plan.Count, $MaxFiles)
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
return 8
}
if ($planBytes -gt $MaxBytes) {
& $emit ('SUM RESULT=DELETE_REFUSED reason=MAX_BYTES planned={0} cap={1}' -f $planBytes, $MaxBytes)
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
return 8
}
if (-not $DoCommit) {
if (-not $InteractiveConfirm) {
& $emit ('SUM RESULT=DELETE_PLANNED files={0} bytes={1} (dry run, add -Commit to delete)' -f `
$plan.Count, $planBytes)
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
return 5
}
Write-Host ''
Write-Host ('About to PERMANENTLY delete {0} file(s), {1}.' -f `
$plan.Count, (Format-Bytes $planBytes 0)) -ForegroundColor Yellow
if ($UseRecycle) {
Write-Host 'These go to the Recycle Bin, so the space is not reclaimed until it is emptied.' -ForegroundColor Yellow
}
else {
Write-Host 'This does NOT use the Recycle Bin. They cannot be recovered.' -ForegroundColor Yellow
}
Write-Host -NoNewline "Type 'yes' to confirm, anything else cancels: " -ForegroundColor Yellow
$answer = Read-TimedLine -TimeoutSeconds $ConfirmTimeoutSeconds
if ($null -eq $answer) {
Write-Host ''
& $emit 'SUM RESULT=CONFIRM_TIMEOUT deleted=0 reclaimed=0'
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
return 7
}
if ($answer.Trim().ToLowerInvariant() -ne 'yes') {
& $emit 'SUM RESULT=DELETE_CANCELLED deleted=0 reclaimed=0'
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
return 6
}
& $emit 'HDR confirmed=typed-yes'
}
$deleted = 0
$freed = 0L
$failed = 0
foreach ($p in $plan) {
$recheck = Test-DeletePlanEntry -Path $p.Path -Scopes $Scopes `
-ExpectedRecord $p.Record -ExpectedSerial $p.Serial
if ($recheck) {
& $emit ('SKIP {0}|{1}' -f $recheck, $p.Path)
$failed++
continue
}
$outcome = Remove-UsageFiles -Paths @($p.Path) -Recycle:$UseRecycle
if ($outcome.Deleted.Count -eq 1) {
$deleted++
if ($p.Bytes -gt 0) { $freed += $p.Bytes }
& $emit ('DEL {0}|{1}' -f $p.Bytes, $p.Path)
}
else {
$failed++
$why = if ($outcome.Failed.Count) { $outcome.Failed[0] }
elseif ($outcome.Blocked.Count) { $outcome.Blocked[0] }
else { $p.Path + ' (unknown)' }
& $emit ('FAIL ' + $why)
}
}
$verb = if ($UseRecycle) { 'RECYCLED' } else { 'DELETED' }
$result = if ($failed -eq 0) { 'DELETE_OK' } else { 'DELETE_PARTIAL' }
& $emit ('SUM RESULT={0} {1}={2} reclaimed={3} failed={4}' -f $result, $verb, $deleted, $freed, $failed)
Write-DeleteReceipt -Receipt $Receipt -Lines $lines
if ($failed -gt 0) { return 9 }
return 0
}
function Write-DeleteReceipt {
param([string]$Receipt, [object]$Lines)
if (-not $Receipt) { return }
try {
$dir = [System.IO.Path]::GetDirectoryName($Receipt)
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
[void](New-Item -ItemType Directory -Path $dir -Force)
}
Set-Content -LiteralPath $Receipt -Value $Lines -Encoding UTF8
if (-not $Quiet) { Write-Host ('RECEIPT=' + $Receipt) }
}
catch {
Write-Host ('[!] Receipt could not be written to {0}: {1}' -f $Receipt, $_.Exception.Message) `
-ForegroundColor Yellow
Write-Host 'DEGRADED_AUDIT=1' -ForegroundColor Yellow
}
}
function Show-UsageGrid {
param(
[object[]]$Rows,
[string]$Title,
[long]$Reference,
[bool]$UseAllocated,
[bool]$UseRecycleBin
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$NL = [Environment]::NewLine
$activeName = if ($UseAllocated) { 'Size on disk' } else { 'Size' }
$otherName = if ($UseAllocated) { 'Logical' } else { 'On disk' }
$table = New-Object System.Data.DataTable
[void]$table.Columns.Add('Type', [string])
[void]$table.Columns.Add('Size', [long])
[void]$table.Columns.Add('Percent', [double])
[void]$table.Columns.Add('Other', [long])
[void]$table.Columns.Add('Path', [string])
[void]$table.Columns.Add('Key', [int])
$rowKey = 0
foreach ($row in $Rows) {
$pct = if ($Reference -gt 0) { 100 * $row.Size / $Reference } else { 0 }
$other = if ($UseAllocated) { $row.Logical } else { $row.Allocated }
[void]$table.Rows.Add($row.Type, $row.Size, $pct, $other, $row.Path, $rowKey)
$rowKey++
}
$table.DefaultView.Sort = 'Size DESC'
$selectedKeys = New-Object System.Collections.Generic.HashSet[int]
$form = New-Object System.Windows.Forms.Form
$form.Text = $Title
$form.Size = New-Object System.Drawing.Size(1150, 720)
$form.MinimumSize = New-Object System.Drawing.Size(700, 300)
$form.StartPosition = 'CenterScreen'
$top = New-Object System.Windows.Forms.Panel
$top.Dock = 'Top'; $top.Height = 40
$label = New-Object System.Windows.Forms.Label
$label.Text = 'Filter path:'
$label.Location = New-Object System.Drawing.Point(8, 12)
$label.AutoSize = $true
$filter = New-Object System.Windows.Forms.TextBox
$filter.Location = New-Object System.Drawing.Point(78, 9)
$filter.Width = 300
$newButton = {
param([string]$Text, [int]$X, [int]$Width)
$b = New-Object System.Windows.Forms.Button
$b.Text = $Text
$b.Location = New-Object System.Drawing.Point($X, 8)
$b.Width = $Width
$b
}
$selectAll = & $newButton 'Select all shown' 392 115
$clearAll = & $newButton 'Clear selection' 513 110
$deleteBtn = & $newButton 'Delete selected...' 631 160
$deleteBtn.Enabled = $false
$top.Controls.AddRange(@($label, $filter, $selectAll, $clearAll, $deleteBtn))
$status = New-Object System.Windows.Forms.Label
$status.Dock = 'Bottom'
$status.Height = 22
$status.TextAlign = 'MiddleLeft'
$grid = New-Object System.Windows.Forms.DataGridView
$grid.Dock = 'Fill'
$grid.ReadOnly = $true
$grid.AllowUserToAddRows = $false
$grid.AllowUserToDeleteRows = $false
$grid.AllowUserToResizeRows = $false
$grid.RowHeadersVisible = $false
$grid.SelectionMode = 'FullRowSelect'
$grid.MultiSelect = $true
$grid.AutoSizeColumnsMode = 'None'
$grid.BackgroundColor = [System.Drawing.SystemColors]::Window
$grid.BorderStyle = 'None'
$grid.EnableHeadersVisualStyles = $true
$grid.AlternatingRowsDefaultCellStyle.BackColor = [System.Drawing.Color]::FromArgb(246, 248, 250)
$grid.Add_CellFormatting({
param($sender, $e)
$col = $grid.Columns[$e.ColumnIndex].Name
if (($col -eq 'Size' -or $col -eq 'Other') -and $e.Value -isnot [System.DBNull]) {
$e.Value = (Format-Bytes ([long]$e.Value) 0)
$e.FormattingApplied = $true
}
})
$grid.Add_CellPainting({
param($sender, $e)
if ($e.RowIndex -lt 0 -or $e.ColumnIndex -lt 0) { return }
if ($grid.Columns[$e.ColumnIndex].Name -ne 'Percent') { return }
$raw = $grid.Rows[$e.RowIndex].Cells[$e.ColumnIndex].Value
if ($raw -is [System.DBNull]) { return }
$e.PaintBackground($e.CellBounds, $true)
$value = [double]$raw
$span = $e.CellBounds.Width - 8
$width = [int]($span * [Math]::Min([Math]::Max($value, 0), 100) / 100)
if ($width -gt 0) {
$rect = New-Object System.Drawing.Rectangle(
($e.CellBounds.X + 4), ($e.CellBounds.Y + 4), $width, ($e.CellBounds.Height - 8))
$brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(150, 70, 130, 180))
$e.Graphics.FillRectangle($brush, $rect)
$brush.Dispose()
}
[System.Windows.Forms.TextRenderer]::DrawText(
$e.Graphics, ('{0:N2}%' -f $value), $e.CellStyle.Font, $e.CellBounds,
[System.Drawing.SystemColors]::ControlText,
([System.Windows.Forms.TextFormatFlags]::Right -bor
[System.Windows.Forms.TextFormatFlags]::VerticalCenter))
$e.Handled = $true
})
$selectedRowsAll = {
$out = New-Object System.Collections.Generic.List[object]
foreach ($dr in $table.Rows) {
if ($dr.RowState -ne 'Deleted' -and $selectedKeys.Contains([int]$dr['Key'])) {
$out.Add($dr)
}
}
, $out
}
$syncSelection = {
foreach ($gridRow in $grid.Rows) {
$want = $selectedKeys.Contains([int]$gridRow.Cells['Key'].Value)
if ($gridRow.Selected -ne $want) { $gridRow.Selected = $want }
}
}
$updateStatus = {
$shown = $table.DefaultView.Count
$sum = 0L
foreach ($v in $table.DefaultView) { $sum += [long]$v['Size'] }
$chosen = & $selectedRowsAll
$chosenSum = 0L
foreach ($dr in $chosen) { $chosenSum += [long]$dr['Size'] }
$status.Text = ' {0:N0} of {1:N0} rows {2} in view' -f `
$shown, $table.Rows.Count, (Format-Bytes $sum 0)
if ($chosen.Count -gt 0) {
$status.Text += ' {0:N0} selected, {1}' -f $chosen.Count, (Format-Bytes $chosenSum 0)
$deleteBtn.Text = 'Delete selected ({0})...' -f $chosen.Count
$deleteBtn.Enabled = $true
}
else {
$deleteBtn.Text = 'Delete selected...'
$deleteBtn.Enabled = $false
}
}
$grid.Add_CellMouseClick({
param($sender, $e)
if ($e.Button -ne [System.Windows.Forms.MouseButtons]::Left) { return }
if ($e.RowIndex -lt 0) { return }
if ([System.Windows.Forms.Control]::ModifierKeys -band [System.Windows.Forms.Keys]::Shift) {
foreach ($gridRow in $grid.SelectedRows) {
[void]$selectedKeys.Add([int]$gridRow.Cells['Key'].Value)
}
}
else {
$key = [int]$grid.Rows[$e.RowIndex].Cells['Key'].Value
if ($selectedKeys.Contains($key)) { [void]$selectedKeys.Remove($key) }
else { [void]$selectedKeys.Add($key) }
}
& $syncSelection
& $updateStatus
})
$grid.Add_Sorted({ & $syncSelection })
$selectAll.Add_Click({
foreach ($v in $table.DefaultView) { [void]$selectedKeys.Add([int]$v['Key']) }
& $syncSelection; & $updateStatus
})
$clearAll.Add_Click({
$selectedKeys.Clear()
$grid.ClearSelection()
& $updateStatus
})
$deleteRows = {
param($targetRows)
if ($null -eq $targetRows -or $targetRows.Count -eq 0) { return }
$deletable = New-Object System.Collections.Generic.List[object]
$blocked = New-Object System.Collections.Generic.List[string]
foreach ($dr in $targetRows) {
$path = [string]$dr['Path']
$reason = Test-DeletableUsagePath -Path $path
if ($reason) { $blocked.Add(('{0} ({1})' -f $path, $reason)) } else { $deletable.Add($dr) }
}
if ($blocked.Count -gt 0) {
$blockedText = if ($blocked.Count -eq 1) { 'This entry is not safe to delete:' }
else { 'These {0} entries will be skipped, they are not safe to delete:' -f $blocked.Count }
[void][System.Windows.Forms.MessageBox]::Show($form,
$blockedText + $NL + $NL +
(($blocked | Select-Object -First 15) -join $NL),
'Skipped',
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information)
}
if ($deletable.Count -eq 0) { return }
$sizeLabel = if ($UseAllocated) { 'Size on disk' } else { 'Size' }
$total = 0L
foreach ($dr in $deletable) { $total += [long]$dr['Size'] }
$verb = if ($UseRecycleBin) { 'Send' } else { 'PERMANENTLY delete' }
if ($deletable.Count -eq 1) {
$question = ('{0} this file?' -f $verb) + $NL +
$NL + ' ' + [string]$deletable[0]['Path'] +
$NL + $NL +
('{0}: {1}' -f $sizeLabel, (Format-Bytes $total 0))
}
else {
$ordered = @($deletable | Sort-Object -Property @{ Expression = { [long]$_['Size'] } } -Descending)
$preview = @($ordered | Select-Object -First 15 | ForEach-Object { ' ' + [string]$_['Path'] })
if ($ordered.Count -gt 15) { $preview += (' ... and {0} more' -f ($ordered.Count - 15)) }
$question = ('{0} {1} files?' -f $verb, $deletable.Count) + $NL +
('Total {0}: {1}' -f $sizeLabel.ToLower(), (Format-Bytes $total 0)) +
$NL + $NL +
($preview -join $NL)
}
$tail = if ($UseRecycleBin) {
'These go to the Recycle Bin, so they can be restored, but the space is NOT ' +
'reclaimed until the bin is emptied. Anything too large to recycle will prompt separately.'
} else {
'This does NOT use the Recycle Bin. The space is reclaimed immediately and these ' +
'files CANNOT be recovered.'
}
$answer = [System.Windows.Forms.MessageBox]::Show($form,
$question + $NL + $NL + $tail,
$(if ($UseRecycleBin) { 'Confirm delete' } else { 'Confirm permanent delete' }),
[System.Windows.Forms.MessageBoxButtons]::YesNo,
[System.Windows.Forms.MessageBoxIcon]::Warning,
[System.Windows.Forms.MessageBoxDefaultButton]::Button2)
if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }
$form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
try {
$outcome = Remove-UsageFiles -Recycle:$UseRecycleBin `
-Paths @($deletable | ForEach-Object { [string]$_['Path'] })
}
finally {
$form.Cursor = [System.Windows.Forms.Cursors]::Default
}
$goneSet = New-Object System.Collections.Generic.HashSet[string] `
([System.StringComparer]::OrdinalIgnoreCase)
foreach ($p in $outcome.Deleted) { [void]$goneSet.Add($p) }
$freed = 0L
$gone = New-Object System.Collections.Generic.List[object]
foreach ($dr in $deletable) {
if ($goneSet.Contains([string]$dr['Path'])) {
$freed += [long]$(if ($UseAllocated) { $dr['Size'] } else { $dr['Other'] })
$gone.Add($dr)
}
}
foreach ($dr in $gone) {
[void]$selectedKeys.Remove([int]$dr['Key'])
$table.Rows.Remove($dr)
}
$grid.Refresh(); & $updateStatus
$failures = $outcome.Failed
$summary = if ($UseRecycleBin) {
'Recycled {0} file(s), {1}. Empty the Recycle Bin to actually reclaim it.' -f `
$outcome.Deleted.Count, (Format-Bytes $freed 0)
} else {
'Deleted {0} file(s), {1} reclaimed.' -f $outcome.Deleted.Count, (Format-Bytes $freed 0)
}
if ($failures.Count -gt 0) {
$summary += $NL + $NL +
('{0} could not be deleted:' -f $failures.Count) + $NL +
(($failures | Select-Object -First 15) -join $NL)
}
[void][System.Windows.Forms.MessageBox]::Show($form, $summary, 'Delete complete',
[System.Windows.Forms.MessageBoxButtons]::OK,
$(if ($failures.Count -gt 0) { [System.Windows.Forms.MessageBoxIcon]::Warning }
else { [System.Windows.Forms.MessageBoxIcon]::Information }))
}
$deleteSelected = { & $deleteRows (& $selectedRowsAll) }
$deleteBtn.Add_Click($deleteSelected)
$filter.Add_TextChanged({
$text = $filter.Text
if ([string]::IsNullOrWhiteSpace($text)) {
$table.DefaultView.RowFilter = ''
}
else {
$escaped = [regex]::Replace($text, '[\[\]%*]', { param($m) '[' + $m.Value + ']' })
$escaped = $escaped.Replace("'", "''")
$table.DefaultView.RowFilter = "Path LIKE '%$escaped%'"
}
& $syncSelection
& $updateStatus
})
$openRowInExplorer = {
param($gridRow)
if (-not $gridRow) { return }
$path = [string]$gridRow.Cells['Path'].Value
if (-not (Test-Path -LiteralPath $path)) { return }
if ([string]$gridRow.Cells['Type'].Value -eq 'File') {
Start-Process explorer.exe -ArgumentList ('/select,"{0}"' -f $path)
} else {
Start-Process explorer.exe -ArgumentList ('"{0}"' -f $path)
}
}
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$copyItem = $menu.Items.Add('Copy path')
$copyItem.Add_Click({
$paths = @($grid.SelectedRows | ForEach-Object { $_.Cells['Path'].Value })
if ($paths.Count -gt 0) { $paths -join $NL | Set-Clipboard }
})
$openItem = $menu.Items.Add('Open in Explorer')
$openItem.Add_Click({
& $openRowInExplorer ($grid.SelectedRows | Select-Object -First 1)
})
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
$deleteSelItem = $menu.Items.Add('Delete file...')
$deleteSelItem.Add_Click($deleteSelected)
$menu.Add_Opening({
$count = (& $selectedRowsAll).Count
if ($count -le 1) { $deleteSelItem.Text = 'Delete file...' }
else { $deleteSelItem.Text = 'Delete selected ({0})...' -f $count }
$deleteSelItem.Enabled = ($count -ge 1)
})
$grid.ContextMenuStrip = $menu
$grid.Add_CellMouseDown({
param($sender, $e)
if ($e.Button -ne [System.Windows.Forms.MouseButtons]::Right) { return }
if ($e.RowIndex -lt 0) { return }
$key = [int]$grid.Rows[$e.RowIndex].Cells['Key'].Value
if (-not $selectedKeys.Contains($key)) {
$selectedKeys.Clear()
[void]$selectedKeys.Add($key)
& $syncSelection
& $updateStatus
}
})
$grid.Add_CellDoubleClick({
param($sender, $e)
if ($e.RowIndex -lt 0) { return }
& $openRowInExplorer $grid.Rows[$e.RowIndex]
})
$form.Controls.AddRange(@($grid, $status, $top))
$grid.DataSource = $table.DefaultView
$grid.Columns['Type'].Visible = $false
$grid.Columns['Key'].Visible = $false
$grid.Columns['Size'].HeaderText = $activeName
$grid.Columns['Size'].Width = 110
$grid.Columns['Size'].DefaultCellStyle.Alignment = 'MiddleRight'
$grid.Columns['Percent'].HeaderText = '% of total'
$grid.Columns['Percent'].Width = 110
$grid.Columns['Other'].HeaderText = $otherName
$grid.Columns['Other'].Width = 110
$grid.Columns['Other'].DefaultCellStyle.Alignment = 'MiddleRight'
$grid.Columns['Path'].HeaderText = 'Path'
$grid.Columns['Path'].Width = 760
$form.Add_Shown({ $form.Activate(); $grid.ClearSelection(); & $updateStatus })
[void]$form.ShowDialog()
$form.Dispose()
}
function Write-Table {
param(
[string]$Title,
[object[]]$Rows,
[long]$Reference
)
if ($Quiet) { return }
Write-Host ''
Write-Host $Title -ForegroundColor Yellow
Write-Host ('-' * $Script:BannerWidth) -ForegroundColor DarkGray
if (-not $Rows -or $Rows.Count -eq 0) {
Write-Host ' (nothing to report)' -ForegroundColor DarkGray
return
}
$rank = 0
foreach ($row in $Rows) {
$rank++
$pct = if ($Reference -gt 0) { '{0,6:N2}%' -f (100 * $row.Size / $Reference) } else { ' ' }
$line = '{0,4} {1} {2} {3}' -f $rank, (Format-Bytes $row.Size), $pct, $row.Path
Write-Host $line
}
}
$exitCode = 0
$volumeHandle = $null
try {
$target = Resolve-ScanTarget -InputPath $Path
if ($Select -and ($Delete -or $DeleteListPath)) {
Write-Fatal '-Select and -Delete/-DeleteListPath are different selection modes; use one.' 3
}
if ($Select -and $Gui) { Write-Fatal '-Select is the console picker and -Gui is the window; use one.' 3 }
if ($Select -and $Quiet) { Write-Fatal '-Select needs the numbered table on screen, so it cannot be combined with -Quiet.' 3 }
if ($Select -and $NoSelect) { Write-Fatal '-Select and -NoSelect contradict each other.' 3 }
if ($Commit -and -not ($Select -or $Delete -or $DeleteListPath)) {
Write-Fatal '-Commit only means something with -Select or -Delete. Nothing would be deleted, so refusing rather than pretending.' 3
}
if (-not $ReceiptPath) {
$ReceiptPath = Join-Path $env:ProgramData ('Sector\DiskUsage\delete-{0}.log' -f `
(Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss'))
}
$maxDeleteBytes = [long]$MaxDeleteMB * 1MB
$explicit = New-Object System.Collections.Generic.List[string]
if ($Delete) { foreach ($d in $Delete) { if ($d -and $d.Trim()) { $explicit.Add($d.Trim()) } } }
if ($DeleteListPath) {
if (-not (Test-Path -LiteralPath $DeleteListPath -PathType Leaf)) {
Write-Fatal "-DeleteListPath '$DeleteListPath' does not exist." 3
}
foreach ($line in (Get-Content -LiteralPath $DeleteListPath)) {
$t = $line.Trim()
if (-not $t -or $t.StartsWith('#')) { continue }
$explicit.Add($t)
}
}
if ($explicit.Count -gt 0) {
if (-not $DeleteScope -or $DeleteScope.Count -eq 0) {
Write-Fatal ("Deleting requires -DeleteScope naming the directory tree you are willing " +
"to clean. Nothing is deleted outside it.") 3
}
Write-Banner ("Delete {0} explicit path(s) (no scan)" -f $explicit.Count)
$cands = @($explicit | ForEach-Object { [pscustomobject]@{ Path = $_; Record = -1 } })
$exitCode = Invoke-HeadlessDelete -Candidates $cands -Scopes $DeleteScope `
-DoCommit ([bool]$Commit) -UseRecycle ([bool]$Recycle) `
-MaxFiles $MaxDeleteFiles -MaxBytes $maxDeleteBytes -Receipt $ReceiptPath
exit $exitCode
}
Write-Banner "Disk usage on $($target.Root) (raw NTFS master file table scan)"
if (-not (Test-Elevated)) {
Write-Fatal ("Administrator rights are required. Reading the master file table means " +
"opening the raw volume \\.\$($target.Letter): which is an administrative " +
"operation. Re-run this script from an elevated PowerShell.") 2
}
$volume = Get-Volume -DriveLetter $target.Letter -ErrorAction SilentlyContinue -Verbose:$false
if (-not $volume) {
Write-Fatal "No volume is mounted on $($target.Letter):." 3
}
if ($volume.FileSystem -ne 'NTFS') {
$fs = if ($volume.FileSystem) { $volume.FileSystem } else { 'unknown or no media' }
Write-Fatal ("$($target.Letter): is $fs, not NTFS. This script reads the NTFS master file " +
"table directly and has no equivalent for ReFS, FAT32, exFAT or network shares.") 4
}
Write-Log ("Volume {0} {1} {2} total, {3} free" -f `
$target.Root, $volume.FileSystem,
(Format-Bytes $volume.Size 0), (Format-Bytes $volume.SizeRemaining 0))
$win32 = 0
$volumeHandle = [SectorDiskUsage.MftScanner]::OpenVolume($target.Letter[0], [ref]$win32)
if (-not $volumeHandle) {
$hint = switch ($win32) {
5 { 'Access denied. Run from an elevated PowerShell.' }
32 { 'The volume is locked by another process.' }
default { "Win32 error $win32." }
}
Write-Fatal "Could not open \\.\$($target.Letter): for raw reading. $hint" 2
}
Write-Log 'Reading the master file table...'
$swTotal = [System.Diagnostics.Stopwatch]::StartNew()
try {
$scan = [SectorDiskUsage.MftScanner]::Scan(
$volumeHandle,
$target.Letter[0],
($BufferMB * 1MB),
(-not $ExcludeAds),
(-not $ExcludeMetafiles))
}
catch {
$msg = $_.Exception.Message
if ($msg -match 'Win32 error (\d+)') {
$code = [int]$Matches[1]
$hint = switch ($code) {
50 {
"The volume opened, but raw sector reads are being refused (ERROR_NOT_SUPPORTED). " +
"This is characteristic of an endpoint security filter driver intercepting direct " +
"disk access. Check for Check Point, CrowdStrike, SentinelOne or similar, and add " +
"an exclusion for the PowerShell host if you want this script to run here. " +
"Run 'fltmc filters' to list the filter drivers on this machine."
}
5 {
"Access denied on the sector read even though the volume opened. Confirm the " +
"session is elevated and that no filter driver is blocking direct disk access."
}
87 {
"The read was rejected as an invalid parameter, which on a volume handle means a " +
"sector alignment problem. Report this as a bug with the volume's sector size."
}
default { "Win32 error $code." }
}
Write-Host "[X] Could not read the master file table on $($target.Root)" -ForegroundColor Red
Write-Host " $msg" -ForegroundColor Red
Write-Host " $hint" -ForegroundColor Yellow
exit 2
}
throw
}
$swTotal.Stop()
$g = $scan.Geometry
Write-Verbose ("Geometry: {0} bytes/sector, {1} sectors/cluster, {2} byte cluster, {3} byte record, `$MFT at LCN {4}" -f `
$g.BytesPerSector, $g.SectorsPerCluster, $g.ClusterSize, $g.RecordSize, $g.MftStartLcn)
Write-Verbose ("`$MFT self check passed: {0} fragments describing {1} bytes, matching the `$DATA header." -f `
$scan.MftFragments, $scan.MftExtentBytes)
Write-Log ("Scanned {0:N0} records ({1} files, {2} folders) in {3:N2} s" -f `
$scan.RecordCount, $scan.FileCount, $scan.DirCount, $swTotal.Elapsed.TotalSeconds) 'SUCCESS'
$inScope = $null
$anchor = [SectorDiskUsage.MftScanner]::ROOT_RECORD
$scopeLabel = $target.Root
$volumeSerialErr = 0
$volumeSerial = [uint32]0
$null = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath(
$target.Root, [ref]$volumeSerialErr, [ref]$volumeSerial)
if (-not $target.IsRoot) {
$err = 0
$pathSerial = [uint32]0
$rec = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath($target.FullPath, [ref]$err, [ref]$pathSerial)
if ($rec -lt 0) {
Write-Fatal "Could not resolve '$($target.FullPath)' to an MFT record (Win32 error $err)." 3
}
if ($rec -ge $scan.RecordCount) {
Write-Fatal "'$($target.FullPath)' resolved to MFT record $rec, which is outside this volume's MFT." 3
}
$rootErr = 0
$rootSerial = [uint32]0
$rootRec = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath($target.Root, [ref]$rootErr, [ref]$rootSerial)
if ($rootRec -lt 0) {
$msg = "Could not verify that '{0}' is on {1} (Win32 error {2} opening the root). Refusing to anchor on an unverified MFT record."
Write-Fatal ($msg -f $target.FullPath, $target.Root, $rootErr) 3
}
if ($rootSerial -ne $pathSerial) {
$msg = "'{0}' resolves onto a different volume than {1} via a mount point or junction. Scan that volume directly instead."
Write-Fatal ($msg -f $target.FullPath, $target.Root) 3
}
$anchor = [int]$rec
$inScope = [SectorDiskUsage.MftScanner]::MarkSubtree($scan, $anchor)
$scopeLabel = $target.FullPath
Write-Log ("Filtered to '{0}' (MFT record {1})" -f $scopeLabel, $anchor)
}
$excludeRules = New-Object System.Collections.Generic.List[string]
if (-not $NoDefaultExclude) {
foreach ($e in $Script:DefaultExcludes) { $excludeRules.Add($target.Root + $e) }
}
$firstUserRule = $excludeRules.Count
foreach ($e in $Exclude) {
if ([string]::IsNullOrWhiteSpace($e)) { continue }
$rule = $e.Trim()
if (($rule.TrimEnd('\') + '\').ToUpperInvariant() -eq $target.Root) {
Write-Fatal "-Exclude '$rule' is the volume root, which would hide the entire report." 3
}
$excludeRules.Add($rule)
}
$excluded = $null
$exResult = $null
if ($excludeRules.Count -gt 0) {
$exResult = [SectorDiskUsage.MftScanner]::MarkExcluded($scan, $excludeRules.ToArray(), $anchor)
$excluded = $exResult.Mask
$inScope = [SectorDiskUsage.MftScanner]::CombineScope($inScope, $excluded, $scan.RecordCount)
if ($exResult.Suppressed -gt 0) {
Write-Log ("{0} exclusion rule(s) cover the scan target itself and were ignored; its contents are listed" -f `
$exResult.Suppressed)
}
for ($i = $firstUserRule; $i -lt $excludeRules.Count; $i++) {
if ($exResult.RuleRoots[$i] -eq 0) {
Write-Log ("-Exclude '{0}' matched nothing on {1}" -f $excludeRules[$i], $target.Root) 'WARN'
}
}
}
$useAlloc = -not $LogicalSize
$metricName = if ($useAlloc) { 'size on disk' } else { 'logical size' }
$scopeTotal = if ($useAlloc) { $scan.SubtreeAlloc[$anchor] } else { $scan.Subtree[$anchor] }
if (-not $Quiet) {
Write-Host ''
Write-Host "Scan summary (reporting $metricName)" -ForegroundColor Yellow
Write-Host ('-' * $Script:BannerWidth) -ForegroundColor DarkGray
$mftPct = if ($volume.Size -gt 0) { 100 * $scan.MftExtentBytes / $volume.Size } else { 0 }
$fps = if ($swTotal.Elapsed.TotalSeconds -gt 0) {
($scan.FileCount + $scan.DirCount) / $swTotal.Elapsed.TotalSeconds
} else { 0 }
'{0,-26}: {1}' -f 'Volume', $target.Root | Write-Host
'{0,-26}: {1}' -f 'Cluster size', (Format-Bytes $g.ClusterSize 0) | Write-Host
'{0,-26}: {1:N0} ({2} in {3:N0} fragments, {4:N2}% of the volume)' -f `
'MFT records', $scan.RecordCount, (Format-Bytes $scan.MftExtentBytes 0), $scan.MftFragments, $mftPct | Write-Host
'{0,-26}: {1:N0} files, {2:N0} folders' -f 'In use', $scan.FileCount, $scan.DirCount | Write-Host
'{0,-26}: {1:N2} s read, {2:N2} s parse, {3:N2} s aggregate ({4:N0} entries/s)' -f `
'Timing', $scan.ReadSeconds, $scan.ParseSeconds, $scan.AggregateSeconds, $fps | Write-Host
'{0,-26}: {1}' -f 'MFT bytes read', (Format-Bytes $scan.MftBytesRead 0) | Write-Host
if ($scan.TornRecords -gt 0) {
Write-Host ('{0,-26}: {1:N0} (torn mid-write during the scan, skipped)' -f `
'Records skipped', $scan.TornRecords) -ForegroundColor Yellow
}
if ($scan.OrphanCount -gt 0) {
$orphanBytes = if ($useAlloc) { $scan.OrphanAlloc } else { $scan.OrphanSize }
Write-Host ('{0,-26}: {1:N0} entries, {2} <orphaned, parent unreachable>' -f `
'Orphaned', $scan.OrphanCount, (Format-Bytes $orphanBytes 0)) -ForegroundColor Yellow
}
if ($scan.AdsCount -gt 0) {
$adsNote = if ($ExcludeAds) { 'excluded from the totals' } else { 'counted in the totals' }
'{0,-26}: {1:N0} files ({2})' -f 'Alternate data streams', $scan.AdsCount, $adsNote | Write-Host
}
if ($scan.SparseCount -gt 0) {
'{0,-26}: {1:N0} files' -f 'Sparse or compressed', $scan.SparseCount | Write-Host
}
if ($scan.AttrListCount -gt 0) {
'{0,-26}: {1:N0} files across {2:N0} extension records' -f `
'Spilled attributes', $scan.AttrListCount, $scan.ExtensionRecords | Write-Host
}
Write-Host ''
'{0,-26}: {1}' -f "Total under $scopeLabel", (Format-Bytes $scopeTotal 0) | Write-Host
if ($target.IsRoot) {
$used = $volume.Size - $volume.SizeRemaining
$delta = $scopeTotal - $used
$deltaPct = if ($used -gt 0) { 100 * [Math]::Abs($delta) / $used } else { 0 }
'{0,-26}: {1}' -f 'Volume reports used', (Format-Bytes $used 0) | Write-Host
$col = if ($deltaPct -le 5) { 'Green' } elseif ($deltaPct -le 15) { 'Yellow' } else { 'Red' }
Write-Host ('{0,-26}: {1} ({2:N2}%)' -f 'Difference', (Format-Bytes $delta 0), $deltaPct) -ForegroundColor $col
if (-not $useAlloc -and $scopeTotal -gt $used) {
Write-Host (' logical totals exceed the volume when sparse files are ' +
'present ({0:N0} here)' -f $scan.SparseCount) -ForegroundColor DarkGray
Write-Host ' drop -LogicalSize for the real on-disk footprint' -ForegroundColor DarkGray
}
}
if ($exResult -and ($exResult.Files + $exResult.Dirs) -gt 0) {
$exBytes = if ($useAlloc) { $exResult.Alloc } else { $exResult.Size }
Write-Host ('{0,-26}: {1:N0} files, {2} (counted in the total above, not listed)' -f `
'Hidden by -Exclude', $exResult.Files, (Format-Bytes $exBytes 0)) -ForegroundColor DarkGray
$ruleRows = New-Object System.Collections.Generic.List[psobject]
for ($i = 0; $i -lt $exResult.Rules.Length; $i++) {
$b = if ($useAlloc) { $exResult.RuleAlloc[$i] } else { $exResult.RuleSize[$i] }
if ($b -gt 0) {
$ruleRows.Add([pscustomobject]@{ Rule = $exResult.Rules[$i]; Bytes = [long]$b })
}
}
$sortedRules = @($ruleRows.ToArray() | Sort-Object -Property Bytes -Descending)
$ruleLimit = if ($ShowExcluded) { $sortedRules.Count } else { 6 }
$shownRules = 0
foreach ($rr in $sortedRules) {
if ($shownRules -ge $ruleLimit) { break }
Write-Host (' {0} {1}' -f (Format-Bytes $rr.Bytes), $rr.Rule) -ForegroundColor DarkGray
$shownRules++
}
if ($sortedRules.Count -gt $shownRules) {
Write-Host (' ...and {0} more rule(s); -ShowExcluded lists every one' -f `
($sortedRules.Count - $shownRules)) -ForegroundColor DarkGray
}
}
}
$fileIdx = [SectorDiskUsage.MftScanner]::TopIndices($scan, $inScope, $false, $useAlloc, $Top, -1)
$filePaths = [SectorDiskUsage.MftScanner]::BuildPaths($scan, $fileIdx)
$fileRows = New-Object System.Collections.Generic.List[psobject]
for ($i = 0; $i -lt $fileIdx.Count; $i++) {
$k = $fileIdx[$i]
$fileRows.Add([pscustomobject]@{
Type = 'File'
Path = $filePaths[$i]
Size = if ($useAlloc) { $scan.Alloc[$k] } else { $scan.Size[$k] }
Logical = $scan.Size[$k]
Allocated = $scan.Alloc[$k]
Depth = $scan.Depth[$k]
Record = $k
})
}
Write-Table -Title "Largest files under $scopeLabel ($metricName)" -Rows $fileRows -Reference $scopeTotal
$wantPicker = $false
if (-not $NoSelect -and -not $Gui -and -not $Quiet -and -not $PassThru) {
if ($Select) { $wantPicker = $true }
elseif (-not [Console]::IsInputRedirected) { $wantPicker = $true }
}
if ($wantPicker) {
$scopes = $DeleteScope
if (-not $scopes -or $scopes.Count -eq 0) { $scopes = @($target.FullPath) }
if ($fileRows.Count -gt 0) {
Write-Host ''
Write-Host ("Delete any of these? Enter numbers from the left column, for example " +
"1,2,3,7 or 1,3,5-8. Press Enter alone to skip.") -ForegroundColor Yellow
Write-Host -NoNewline 'Files to delete: ' -ForegroundColor Yellow
$spec = Read-TimedLine -TimeoutSeconds $SelectTimeoutSeconds
if ($null -eq $spec) {
Write-Host ''
$msg = ("[i] No input within {0}s, nothing deleted. For unattended runs use " +
"-DeleteListPath or -NoSelect.")
Write-Host ($msg -f $SelectTimeoutSeconds) -ForegroundColor DarkGray
$exitCode = 7
}
elseif (-not $spec.Trim()) {
Write-Host '[i] Nothing selected.' -ForegroundColor DarkGray
}
else {
$picked = Expand-SelectionSpec -Spec $spec -Max $fileRows.Count
if ($null -eq $picked) {
$msg = ("[!] Could not read '{0}'. Use numbers and ranges between 1 and {1}, " +
"for example 1,2,3,7. Nothing deleted.")
Write-Host ($msg -f $spec.Trim(), $fileRows.Count) -ForegroundColor Yellow
$exitCode = 3
}
else {
Write-Host ''
foreach ($n in $picked) {
$row = $fileRows[$n - 1]
Write-Host (' {0,4} {1} {2}' -f $n, (Format-Bytes $row.Allocated), $row.Path)
}
$cands = @($picked | ForEach-Object {
$row = $fileRows[$_ - 1]
[pscustomobject]@{
Path = $row.Path
Bytes = [long]$row.Allocated
Record = [long]$row.Record
Serial = $volumeSerial
}
})
$exitCode = Invoke-HeadlessDelete -Candidates $cands -Scopes $scopes `
-DoCommit ([bool]$Commit) -UseRecycle ([bool]$Recycle) `
-MaxFiles $MaxDeleteFiles -MaxBytes $maxDeleteBytes -Receipt $ReceiptPath `
-InteractiveConfirm $true -ConfirmTimeoutSeconds $SelectTimeoutSeconds
}
}
}
}
if ($Depth -gt 0) {
$nodes = [SectorDiskUsage.MftScanner]::BuildTree($scan, $anchor, $Depth, $Top, $useAlloc, $excluded)
if (-not $Quiet) {
Write-Host ''
Write-Host "Tree from $scopeLabel, $Depth level(s) deep, top $Top per level" -ForegroundColor Yellow
Write-Host ('-' * $Script:BannerWidth) -ForegroundColor DarkGray
foreach ($node in $nodes) {
$k = $node.Index
$sz = if ($useAlloc) { $scan.SubtreeAlloc[$k] } else { $scan.Subtree[$k] }
$label = if ($node.RelDepth -eq 0) {
[SectorDiskUsage.MftScanner]::BuildPath($scan, $k)
} else {
(' ' * ($node.RelDepth - 1)) + '+- ' + $scan.Name[$k]
}
Write-Host ('{0} {1}' -f (Format-Bytes $sz), $label)
}
}
}
if ($CsvPath) {
$allIdx = [SectorDiskUsage.MftScanner]::AllIndices($scan, $inScope, $false, $useAlloc)
$allPaths = [SectorDiskUsage.MftScanner]::BuildPaths($scan, $allIdx)
$export = New-Object System.Collections.Generic.List[psobject]
for ($i = 0; $i -lt $allIdx.Count; $i++) {
$k = $allIdx[$i]
$export.Add([pscustomobject]@{
Path = $allPaths[$i]
Logical = $scan.Size[$k]
Allocated = $scan.Alloc[$k]
Depth = $scan.Depth[$k]
})
}
$export | Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding UTF8
Write-Log ("Exported {0:N0} files to {1}" -f $export.Count, $CsvPath) 'SUCCESS'
}
if ($PassThru) {
$fileRows
}
if ($Gui) {
$gridRows = $fileRows.ToArray()
if ($gridRows.Count -eq 0) {
Write-Log 'Nothing to show in the grid.' 'WARN'
}
else {
try {
Show-UsageGrid -Rows $gridRows -Reference $scopeTotal -UseAllocated $useAlloc `
-UseRecycleBin ([bool]$Recycle) `
-Title ("Disk usage: {0} (largest {1} files by {2})" -f $scopeLabel, $Top, $metricName)
}
catch {
Write-Host "[!] Could not open the grid window: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host ' -Gui needs an interactive desktop session.' -ForegroundColor Yellow
}
}
}
if (-not $Quiet) { Write-Host '' }
}
catch {
Write-Host "[X] $($_.Exception.Message)" -ForegroundColor Red
if ($_.ScriptStackTrace) { Write-Verbose $_.ScriptStackTrace }
$exitCode = 1
}
finally {
if ($volumeHandle) { $volumeHandle.Dispose() }
}
exit $exitCode
Usage:
.SYNOPSIS
Fast-speed disk usage analysis by reading the raw NTFS Master File Table.
.DESCRIPTION
Reports the largest files on an NTFS volume in seconds instead of minutes, by size on disk.
Instead of walking the directory tree with FindFirstFile/FindNextFile (which is what
Get-ChildItem -Recurse does, at roughly 14,000 files per second), this script opens the
raw volume, reads the $MFT in bulk, and parses every file record out of it. One sequential
read of a couple of gigabytes replaces millions of per-file syscalls, which is the same
technique WizTree uses and the only reason it is fast.
Requires Administrator (raw volume access) and a local NTFS volume. Both conditions are
checked up front and the script fails fast with a specific message and exit code rather
than silently falling back to a slow directory walk.
.PARAMETER Path
Volume or subtree to report on. 'C:', 'C:\' or a subtree such as 'C:\Users\Hans'.
A subtree still scans the whole volume (that is the fast part) and filters the result,
so it is no slower than a full report. Defaults to the system drive.
.PARAMETER Top
Number of rows in the largest-files table. Default 25.
.PARAMETER Depth
Render an indented folder tree this many levels below the scan target. 0 (the default)
skips the tree. Each level shows at most -Top children, largest first. This is the only
place folder totals are shown; the main report is files only.
.PARAMETER CsvPath
Export every file in scope with a non-zero size (not just the top N) to this CSV file,
sorted largest first.
.PARAMETER LogicalSize
Report logical file size (what Explorer calls "Size") instead of size on disk, which is the
default. Logical size is what a file claims to be, so a sparse file counts its whole span
even where nothing is stored: $BadClus alone then claims the entire volume, and OneDrive
placeholders claim their full cloud size. Expect the total to exceed the size of the volume.
.PARAMETER AllocatedSize
Deprecated and ignored. Size on disk is now the default, so this switch does nothing. Kept
only so existing command lines keep working.
.PARAMETER ExcludeMetafiles
Exclude NTFS metafiles (records 0 to 15: $MFT, $LogFile, $Secure and friends). They are
included by default because they are genuinely consumed space, often several GB, and
including them is what makes the grand total reconcile against Get-Volume.
.PARAMETER ExcludeAds
Exclude alternate data streams (named $DATA attributes). They are counted by default
because they consume real space. The summary reports how many files carry them.
.PARAMETER Gui
Open the results in a sortable window instead of only printing them. Also accepts -Grid,
the original name for this switch. Needs an interactive desktop session. Click any column
to sort; sizes sort by real byte value while still displaying as "1,25 TB". There is a
filter box, a percent-of-total bar, and a right-click Copy path / Open in Explorer.
Clicking a row selects it, clicking again deselects it, so a batch needs no modifier keys.
Shift-click adds a range. Selection is tracked per row independently of the grid, so it
survives sorting and filtering and keeps counting while a filter hides part of it.
Selected files can be deleted from the "Delete selected (N)" button or by right-clicking,
which offers "Delete file" for one row and "Delete selected (N)" for several; the menu is
relabelled as it opens so it always names what it will act on.
Deletion is PERMANENT and skips the Recycle Bin, because the bin sits on the same volume,
so recycling a 100 GB file moves it rather than reclaiming the space. -Recycle opts into
the reversible behaviour. Either way it confirms first with the file count and total size,
and refuses NTFS metafiles, root level Windows system files such as pagefile.sys, and
orphaned entries with no real path.
.PARAMETER Select
Forces the delete prompt on. It is normally not needed: after printing the table the script
ASKS by default which files to delete, so a plain run already offers it. Enter numbers from
the left column, for example 1,2,3,7 or 1,3,5-8. Press Enter alone to skip.
Picking files shows them back with their sizes and then asks for a typed 'yes' before
anything is deleted. That confirmation is the arming step, so -Commit is not needed
interactively; passing -Commit pre-arms it and skips the confirmation instead.
The prompt is skipped automatically when there is nobody to ask: with -Quiet, -PassThru,
-Gui, -NoSelect, or when stdin is redirected, which is how automation runs. Even then it
cannot block: with no input within -SelectTimeoutSeconds it gives up with exit 7 rather
than holding the console, which in an RMM session would burn the whole execution window.
This works with no GUI at all, which is what makes it usable from an RMM background session
(N-able Advanced Remote Background and similar), where no window can ever be shown.
.PARAMETER NoSelect
Never ask. Use for scripted runs that want the report only, and for any run where a stray
prompt would be unwelcome.
.PARAMETER Delete
Explicit paths to delete, for unattended use. Needs no MFT scan at all, so it works in
contexts where raw sector reads are refused, and needs no Administrator for ordinary files.
Requires -DeleteScope. Nothing is deleted without -Commit.
Beware how you invoke it: with "pwsh -File script.ps1 -Delete a,b,c" the whole thing arrives
as ONE string, because -File does not parse array literals. For more than one path either
call through -Command:
pwsh -Command "& .\Get-DiskUsage.ps1 -Delete 'a','b' -DeleteScope 'C:\X' -Commit"
or, better, use -DeleteListPath.
.PARAMETER DeleteListPath
A text file of paths to delete, one per line. Preferred over -Delete for anything beyond a
single file: a newline cannot occur in a Windows filename, so unlike a comma or semicolon
separated string it can never split a legitimate path by accident, and it sidesteps the
command-line length limits RMM input fields impose. Blank lines and lines starting with #
are ignored, so a reviewer can annotate the list or comment a path out rather than delete
the line. Requires -DeleteScope. Nothing is deleted without -Commit.
.PARAMETER Commit
Arms deletion. Without it, -Select and -Delete produce the plan and a receipt but delete
nothing, and exit 5 rather than 0 so a cleanup job that lost its -Commit is not mistaken
for a success. Deliberately not called -Force: that reads as "suppress friction" and gets
pasted reflexively.
.PARAMETER DeleteScope
One or more directory trees that deletion is confined to. Mandatory for -Delete and for
-Select on a whole volume. Paths are canonicalised and each scope is treated as ending in a
separator, so C:\Data does not authorise C:\Data2, and a junction anywhere above a target is
refused so a link cannot redirect a delete out of the scope.
.PARAMETER MaxDeleteFiles
Refuse the whole plan if it names more than this many files. Default 50. This is the guard
that turns a wrong scope or an unpruned list into a bounded no-op rather than a disaster.
.PARAMETER MaxDeleteMB
Refuse the whole plan if it totals more than this many MB. Default 102400 (100 GB).
.PARAMETER ReceiptPath
Where to write the audit receipt. Defaults to
%ProgramData%\Sector\DiskUsage\delete-<utc>.log. Every planned, skipped, deleted and failed
path is recorded with its size, so a ticket can reconstruct exactly what happened. Receipts
are never pruned by this script.
.PARAMETER SelectTimeoutSeconds
How long -Select waits for input before concluding no operator is present. Default 120.
.PARAMETER Recycle
Send deletions to the Recycle Bin instead of deleting permanently. Reversible, but the
space is not reclaimed until the bin is emptied, which is why it is not the default.
.PARAMETER PassThru
Emit the report rows as objects on the pipeline in addition to the console report.
.PARAMETER Quiet
Suppress all console output, including the results table. Combine with -PassThru or
-CsvPath to get the results in machine-readable form; errors still print regardless.
.PARAMETER BufferMB
Size of the single reusable read buffer, in MB. Default 8. Rarely worth changing.
.EXAMPLE
.\Get-DiskUsage.ps1
The 25 largest files on the system drive by size on disk.
.EXAMPLE
.\Get-DiskUsage.ps1 -Gui -Top 100
The 100 largest files in a sortable window.
.EXAMPLE
.\Get-DiskUsage.ps1 -Path 'C:\Users' -Top 40 -Depth 2
The 40 largest files under C:\Users, plus a two-level folder tree.
.EXAMPLE
.\Get-DiskUsage.ps1 -CsvPath .\c-usage.csv
Export every file on C: to CSV, sorted largest first.
.EXAMPLE
.\Get-DiskUsage.ps1 -Quiet -PassThru | Select-Object -First 5 Path, Size
Machine-readable output for a pipeline or an RMM agent.
.EXAMPLE
.\Get-DiskUsage.ps1 -Path 'C:\Users\hans\Downloads'
Lists the largest files, then asks which to delete. Type 1,2,3,7 and confirm with 'yes'.
No GUI is involved, so this works in an RMM background session.
.EXAMPLE
.\Get-DiskUsage.ps1 -NoSelect
Report only, never asks.
.EXAMPLE
.\Get-DiskUsage.ps1 -Delete 'C:\Temp\old.iso' -DeleteScope 'C:\Temp' -Commit
Unattended delete of a known path. No scan, no prompt, no window; writes a receipt.
.NOTES
Requires Administrator. Local NTFS volumes only.
Exit codes:
0 scan completed, or delete completed with no failures
1 unhandled error
2 not running as Administrator, the volume could not be opened, or the volume opened
but raw sector reads were refused (typically an endpoint security filter driver)
3 bad input: path does not resolve, resolves onto a different volume through a mount
point or junction, contradictory switches, or an unparseable -Select specification
4 volume is not NTFS
5 delete plan produced but NOT executed, because -Commit was absent (dry run)
6 nothing to delete, or the operator cancelled or declined the confirmation
7 no operator answered before the timeout; nothing was deleted
8 delete REFUSED: the plan exceeded -MaxDeleteFiles or -MaxDeleteMB, nothing deleted
9 delete completed but some files failed or were skipped
Headless delete safety contract (-Select and -Delete):
- Dry run is the default. -Commit on the same command line is the only thing that arms it.
- Every candidate is validated before anything is deleted, then re-validated immediately
before its own delete, so a file replaced or opened in between is skipped, not deleted.
- Refused outright: wildcards, relative paths, anything outside -DeleteScope, anything
below a junction, NTFS metafiles and root level system files (the same guard the GUI
uses), \Windows, \Program Files, \System Volume Information, $Recycle.Bin, and the
extensions .pst .ost .vhd .vhdx .vmdk .avhdx .edb .mdf .ldf .bak, which top every
largest-files report and whose loss is unrecoverable. There is no override switch.
- Files open by another process are skipped, never forced.
- A receipt is written for every run, including dry runs and refusals.
- No prompt exists anywhere on the -Delete path, and -Select cannot block indefinitely.
Deliberate deviations from the rest of this repo, each for a reason:
- No '#Requires -RunAsAdministrator'. That statement fails before param() binds, so the
exit codes above would never fire and the operator would get PowerShell's generic
message instead of one that says which of the four conditions failed.
- The inline C# targets C# 5.0 syntax so it compiles under both the Windows PowerShell
5.1 CodeDOM compiler and the PowerShell 7 Roslyn compiler. No string interpolation,
no expression-bodied members, no 'out var', no tuples.
- No 'unsafe' / pointers. The workload is I/O bound, so BitConverter over byte[] costs
nothing measurable and removes the single largest portability risk.
Known and intentional accounting behaviour:
- Hardlinks are counted ONCE, under one parent, because the MFT has one record per file
regardless of link count. C:\Windows\WinSxS will therefore report substantially LESS
than Get-ChildItem -Recurse does. This matches WizTree and is not a bug.
- Reparse points (junctions, symlinks) are not followed. MFT scanning avoids the
infinite-recursion problem a directory walk has, structurally.
- On-disk size is measured by summing the $DATA run list; the attribute's AllocatedSize
field is only used as a last-resort fallback when a run list cannot be decoded. That field holds the size of the VCN span, so for
anything with holes it overstates badly: the USN journal claims its whole logical
span, and every OneDrive placeholder claims its full cloud size. It cannot be trusted
even when the sparse flag is clear, because $BadClus on a healthy volume reports the
ENTIRE volume as allocated while occupying nothing, and carries no sparse flag.
Measured on a 927 GB volume, trusting the field gave 2.84 TB against 808 GB used.
- Because of those same sparse files, the LOGICAL total can legitimately exceed the size
of the volume. That is not an error, and it is why size on disk is the default; you
only see it if you ask for -LogicalSize.
- $ATTRIBUTE_LIST is handled for the common case: an extension record holding the
unnamed $DATA at StartingVCN 0 is credited to its base record, and sparse run lists
are summed across extension records too. Full attribute-list resolution is an
explicit non-goal.
- Records with an unreachable parent are reported as a single "<orphaned>" line rather
than silently dropped, so the grand total still reconciles.
Memory: roughly 60 bytes per MFT record plus filename strings, so about 150 to 250 MB for
a 1.5M record volume. A 10M file server would want about 1 GB free.
BitLocker: an unlocked encrypted volume reads normally, because the volume handle sits
above the encryption filter. Only a locked volume fails, and it fails as access denied.