To assist customers in analyzing SMB File Share Workloads for Virus Scanning, customers can run the below script:
& .\EnumerateWorkload.ps1 -Path "\\AzVsOnPremTest2\smbshare5"
Customers can save the below script as EnumerateWorkload.ps1 which will produce an output file file-histograms.csv with the result data:
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $false)]
[string]$OutputCsv = ".\file-histograms.csv"
)
# ============================================================
# FILE HISTOGRAM GENERATOR
#
# Compatible with:
# C:\Data
# D:\Files\SomeFolder
# \\server\share
# \\server\share\folder
#
# Generates histograms for:
# - File extension
# - File size
# - Folder nesting depth
# - Modification hour (0-23)
# - Files per folder
#
# Folder depth:
# 0 = file is directly in the specified root folder
# 1 = one folder below the root
# 2 = two folders below the root
# etc.
#
# Modification hour is based on LastWriteTime.
# ============================================================
# ============================================================
# VALIDATE INPUT PATH
# ============================================================
try {
$rootItem = Get-Item -LiteralPath $Path -ErrorAction Stop
}
catch {
throw "Unable to access path '$Path'. Error: $($_.Exception.Message)"
}
if (-not $rootItem.PSIsContainer) {
throw "The specified path must be a directory: $Path"
}
$rootFullPath = $rootItem.FullName
# Normalize the root so it always has exactly one trailing backslash
# for relative-path/depth comparisons. This also works correctly for
# a drive root such as C:\ as well as UNC paths.
$rootPrefix = $rootFullPath.TrimEnd('\') + '\'
# ============================================================
# PREPARE OUTPUT PATH
# ============================================================
$OutputCsvFullPath = [System.IO.Path]::GetFullPath($OutputCsv)
$outputDirectory = [System.IO.Path]::GetDirectoryName($OutputCsvFullPath)
if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) {
if (-not (Test-Path -LiteralPath $outputDirectory)) {
New-Item `
-ItemType Directory `
-Path $outputDirectory `
-Force | Out-Null
}
}
# ============================================================
# HISTOGRAM STORAGE
# ============================================================
$extensionCounts = @{}
$depthCounts = @{}
# Tracks the number of files directly contained in every folder.
# The specified root folder itself is included.
$filesByFolder = @{}
$filesByFolder[$rootFullPath] = [long]0
# Initialize every hour so that hours with zero files still appear.
$hourCounts = @{}
for ($hour = 0; $hour -le 23; $hour++) {
$hourCounts[$hour] = [long]0
}
# ============================================================
# FILE SIZE HISTOGRAM
#
# Bucket indexes:
#
# 0 = 0 bytes
# 1 = 1 B - <1 KB
# 2 = 1 KB - <10 KB
# 3 = 10 KB - <100 KB
# 4 = 100 KB - <1 MB
# 5 = 1 MB - <10 MB
# 6 = 10 MB - <100 MB
# 7 = 100 MB - <1 GB
# 8 = 1 GB - <10 GB
# 9 = >=10 GB
# ============================================================
$sizeBucketLabels = @(
"0 B",
"1 B - <1 KB",
"1 KB - <10 KB",
"10 KB - <100 KB",
"100 KB - <1 MB",
"1 MB - <10 MB",
"10 MB - <100 MB",
"100 MB - <1 GB",
"1 GB - <10 GB",
">=10 GB"
)
$sizeCounts = New-Object 'long[]' $sizeBucketLabels.Count
function Get-SizeBucketIndex {
param(
[long]$Size
)
if ($Size -eq 0) {
return 0
}
if ($Size -lt 1KB) {
return 1
}
if ($Size -lt 10KB) {
return 2
}
if ($Size -lt 100KB) {
return 3
}
if ($Size -lt 1MB) {
return 4
}
if ($Size -lt 10MB) {
return 5
}
if ($Size -lt 100MB) {
return 6
}
if ($Size -lt 1GB) {
return 7
}
if ($Size -lt 10GB) {
return 8
}
return 9
}
# ============================================================
# FILES PER FOLDER HISTOGRAM
#
# Count = number of folders whose DIRECT file count falls into
# the bucket. Subfolders do not contribute to their parent's count.
# The root folder itself is included.
# ============================================================
$folderFileBucketLabels = @(
"0 - 10 files",
"11 - 100 files",
"101 - 1,000 files",
"1,001 - 2,500 files",
"2,501 - 5,000 files",
"5,001 - 10,000 files",
"10,001 - 25,000 files",
"25,001 - 50,000 files",
"50,001 - 100,000 files",
"100,001 - 250,000 files",
"250,001 - 500,000 files",
"500,001 - 1,000,000 files",
">1,000,000 files"
)
$folderFileBucketCounts = New-Object 'long[]' $folderFileBucketLabels.Count
function Get-FolderFileBucketIndex {
param(
[long]$Count
)
if ($Count -le 10) { return 0 }
if ($Count -le 100) { return 1 }
if ($Count -le 1000) { return 2 }
if ($Count -le 2500) { return 3 }
if ($Count -le 5000) { return 4 }
if ($Count -le 10000) { return 5 }
if ($Count -le 25000) { return 6 }
if ($Count -le 50000) { return 7 }
if ($Count -le 100000) { return 8 }
if ($Count -le 250000) { return 9 }
if ($Count -le 500000) { return 10 }
if ($Count -le 1000000) { return 11 }
return 12
}
# ============================================================
# ENUMERATE FOLDERS
#
# This pass makes sure empty folders are included in the histogram.
# ============================================================
$folderEnumerationErrors = @()
Get-ChildItem `
-LiteralPath $rootFullPath `
-Directory `
-Recurse `
-Force `
-ErrorAction SilentlyContinue `
-ErrorVariable +folderEnumerationErrors |
ForEach-Object {
$folderFullPath = $_.FullName
if (-not $filesByFolder.ContainsKey($folderFullPath)) {
$filesByFolder[$folderFullPath] = [long]0
}
}
# ============================================================
# ENUMERATE FILES
#
# This processes files as they are enumerated rather than
# loading the complete file list into memory.
# ============================================================
$totalFiles = [long]0
$enumerationErrors = @()
Write-Host ""
Write-Host "Scanning:"
Write-Host " $rootFullPath"
Write-Host ""
Get-ChildItem `
-LiteralPath $rootFullPath `
-File `
-Recurse `
-Force `
-ErrorAction SilentlyContinue `
-ErrorVariable +enumerationErrors |
ForEach-Object {
$file = $_
# If an existing output CSV happens to be inside the scanned
# directory, do not include it in the statistics.
if ($file.FullName -ieq $OutputCsvFullPath) {
return
}
$totalFiles++
# ----
# FILE COUNT IN CONTAINING FOLDER
# ----
$containingFolder = $file.DirectoryName
if ($filesByFolder.ContainsKey($containingFolder)) {
$filesByFolder[$containingFolder]++
}
else {
# Defensive fallback if a folder could not be returned by
# the directory enumeration but its file was still visible.
$filesByFolder[$containingFolder] = [long]1
}
# ----
# EXTENSION
# ----
$extension = $file.Extension
if ([string]::IsNullOrWhiteSpace($extension)) {
$extension = "[no extension]"
}
else {
$extension = $extension.ToLowerInvariant()
}
if ($extensionCounts.ContainsKey($extension)) {
$extensionCounts[$extension]++
}
else {
$extensionCounts[$extension] = [long]1
}
# ----
# FILE SIZE
# ----
$sizeBucket = Get-SizeBucketIndex -Size $file.Length
$sizeCounts[$sizeBucket]++
# ----
# FOLDER NESTING DEPTH
#
# Root:
# \\server\share\data
#
# File:
# \\server\share\data\customer\2026\file.pdf
#
# Depth = 2
# ----
$directoryPath = $file.DirectoryName
# Normalize the file's containing directory to the same trailing-
# backslash form as $rootPrefix. This avoids the C:\ -> C: bug
# caused by blindly trimming a drive-root backslash.
$directoryPrefix = $directoryPath.TrimEnd('\') + '\'
if ($directoryPrefix -ieq $rootPrefix) {
$depth = 0
}
elseif ($directoryPrefix.StartsWith(
$rootPrefix,
[System.StringComparison]::OrdinalIgnoreCase
)) {
$relativeDirectory = $directoryPrefix.Substring($rootPrefix.Length).TrimEnd('\')
if ([string]::IsNullOrWhiteSpace($relativeDirectory)) {
$depth = 0
}
else {
$depth = ($relativeDirectory -split '\\').Count
}
}
else {
# This normally should not happen, but fall back to
# parent traversal if path normalization behaves oddly
# on a particular filesystem.
$depth = 0
$currentDirectory = $file.Directory
while ($null -ne $currentDirectory) {
$currentPrefix = $currentDirectory.FullName.TrimEnd('\') + '\'
if ($currentPrefix -ieq $rootPrefix) {
break
}
$depth++
$currentDirectory = $currentDirectory.Parent
}
}
if ($depthCounts.ContainsKey($depth)) {
$depthCounts[$depth]++
}
else {
$depthCounts[$depth] = [long]1
}
# ----
# MODIFICATION HOUR
# ----
$modificationHour = $file.LastWriteTime.Hour
$hourCounts[$modificationHour]++
}
# ============================================================
# BUILD FILES-PER-FOLDER COUNTS
# ============================================================
[long]$totalFolders = $filesByFolder.Count
foreach ($folderFileCount in $filesByFolder.Values) {
$folderBucket = Get-FolderFileBucketIndex -Count ([long]$folderFileCount)
$folderFileBucketCounts[$folderBucket]++
}
# ============================================================
# HELPER FOR PERCENTAGE
# ============================================================
function Get-Percent {
param(
[long]$Count
)
if ($totalFiles -eq 0) {
return 0
}
return [Math]::Round(
(($Count / [double]$totalFiles) * 100),
4
)
}
function Get-FolderPercent {
param(
[long]$Count
)
if ($totalFolders -eq 0) {
return 0
}
return [Math]::Round(
(($Count / [double]$totalFolders) * 100),
4
)
}
# ============================================================
# BUILD CSV ROWS
# ============================================================
$rows = New-Object System.Collections.Generic.List[object]
# --------
# EXTENSION HISTOGRAM
#
# Sorted largest count first.
# --------
$extensionCounts.GetEnumerator() |
Sort-Object -Property @{ Expression = { $_.Value }; Descending = $true }, @{ Expression = { $_.Key }; Descending = $false } |
ForEach-Object {
$count = [long]$_.Value
$rows.Add(
[PSCustomObject]@{
Histogram = "FileExtension"
Bucket = $_.Key
Count = $count
Percent = Get-Percent -Count $count
}
)
}
# --------
# FILE SIZE HISTOGRAM
# --------
for ($i = 0; $i -lt $sizeBucketLabels.Count; $i++) {
$count = [long]$sizeCounts[$i]
$rows.Add(
[PSCustomObject]@{
Histogram = "FileSize"
Bucket = $sizeBucketLabels[$i]
Count = $count
Percent = Get-Percent -Count $count
}
)
}
# --------
# FOLDER DEPTH HISTOGRAM
#
# Fill in missing depths with zero counts so the histogram
# remains contiguous.
# --------
if ($depthCounts.Count -gt 0) {
$maxDepth = ($depthCounts.Keys | Measure-Object -Maximum).Maximum
for ($depth = 0; $depth -le $maxDepth; $depth++) {
if ($depthCounts.ContainsKey($depth)) {
$count = [long]$depthCounts[$depth]
}
else {
$count = [long]0
}
$rows.Add(
[PSCustomObject]@{
Histogram = "FolderDepth"
Bucket = $depth
Count = $count
Percent = Get-Percent -Count $count
}
)
}
}
# --------
# MODIFICATION HOUR HISTOGRAM
#
# Hours are 0-23.
# For example:
#
# 0 = midnight through 00:59
# 13 = 1:00 PM through 1:59 PM
# 23 = 11:00 PM through 11:59 PM
# --------
for ($hour = 0; $hour -le 23; $hour++) {
$count = [long]$hourCounts[$hour]
$rows.Add(
[PSCustomObject]@{
Histogram = "ModificationHour"
Bucket = $hour
Count = $count
Percent = Get-Percent -Count $count
}
)
}
# --------
# FILES PER FOLDER HISTOGRAM
#
# This is intentionally the final histogram in the CSV.
# Count = number of folders in the bucket.
# Percent = percentage of all folders in the bucket.
# --------
for ($i = 0; $i -lt $folderFileBucketLabels.Count; $i++) {
$count = [long]$folderFileBucketCounts[$i]
$rows.Add(
[PSCustomObject]@{
Histogram = "FilesPerFolder"
Bucket = $folderFileBucketLabels[$i]
Count = $count
Percent = Get-FolderPercent -Count $count
}
)
}
# ============================================================
# WRITE CSV
# ============================================================
$rows |
Export-Csv `
-LiteralPath $OutputCsvFullPath `
-NoTypeInformation `
-Encoding UTF8
# ============================================================
# SUMMARY / CONSOLE OUTPUT
# ============================================================
# Everything below is written through Console.Out in strict sequence.
# This avoids mixing Write-Host, the success pipeline, and Console.Out.
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("Finished.")
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("Files scanned: {0}" -f $totalFiles)
[Console]::Out.WriteLine("Folders scanned: {0}" -f $totalFolders)
[Console]::Out.WriteLine("FilesPerFolder buckets written: {0}" -f $folderFileBucketLabels.Count)
if ($folderEnumerationErrors.Count -gt 0) {
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("WARNING: {0} folder enumeration errors occurred." -f $folderEnumerationErrors.Count)
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("Examples:")
$folderEnumerationErrors |
Select-Object -First 10 |
ForEach-Object {
[Console]::Out.WriteLine(" {0}" -f $_.Exception.Message)
}
}
if ($enumerationErrors.Count -gt 0) {
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("WARNING: {0} enumeration errors occurred." -f $enumerationErrors.Count)
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("Examples:")
$enumerationErrors |
Select-Object -First 10 |
ForEach-Object {
[Console]::Out.WriteLine(" {0}" -f $_.Exception.Message)
}
}
[Console]::Out.WriteLine("")
[Console]::Out.WriteLine("CSV contents:")
[Console]::Out.WriteLine("")
foreach ($csvLine in [System.IO.File]::ReadLines($OutputCsvFullPath)) {
[Console]::Out.WriteLine($csvLine)
}
[Console]::Out.WriteLine("")
# DO NOT PUT ANY OUTPUT AFTER THIS LINE.
# The absolute CSV path is intentionally the final line emitted by the script.
[Console]::Out.WriteLine($OutputCsvFullPath)