Every contractor, intern and one-off RDP login leaves a C:\Users\<name> folder behind, and on shared hosts that adds up to tens of gigabytes. This script talks to WMI’s Win32_UserProfile class through win32ole, ranks profiles by age and size, flags orphaned SIDs, and only deletes when you say so twice.
Full script + README on GitHub: ruby-devops-toolkit/win-profile-cleanup
Step through the build below:
The Group Policy setting “Delete user profiles older than a specified number of days on system restart” sounds like it should solve this. In practice it keys off NTUSER.DAT’s modified time, which antivirus, backup agents and Windows Search all touch constantly, so nothing is ever old enough to delete. Meanwhile the disk on your RDS host or build agent fills up.
WMI’s Win32_UserProfile class exposes exactly the fields you need: LastUseTime, Loaded, Special (for SYSTEM and service accounts), and a Status bitmask for temporary/roaming/corrupted profiles. Its Delete() method removes the folder and the ProfileList registry entry, the same as System Properties does, so you never end up with the dreaded .bak SID keys and temp-profile logons.
This script wraps that in a report-first workflow: audit, sort by reclaimable size, then --delete --dry-run, then --delete --yes.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_profile_cleanup.rb - Find (and optionally remove) stale Windows user
# profiles via WMI's Win32_UserProfile class.
#
# Every user who has ever logged on to a shared workstation, RDS host or
# jump box leaves a C:\Users\<name> folder behind. On a busy box that is
# tens of gigabytes of stale roaming data, and the built-in GPO
# ("Delete user profiles older than N days") is blunt and frequently
# broken by apps that touch NTUSER.DAT. This script gives you the report
# first and the delete second, with safeguards.
#
# Usage (Windows, run as Administrator for delete):
# ruby win_profile_cleanup.rb # report profiles unused > 90 days
# ruby win_profile_cleanup.rb --days 30 --min-size-mb 500
# ruby win_profile_cleanup.rb --days 90 --delete --dry-run
# ruby win_profile_cleanup.rb --days 90 --delete --yes # actually delete
# ruby win_profile_cleanup.rb --json > profiles.json
#
# Testing off-Windows: WIN_PROFILE_MOCK=1 ruby win_profile_cleanup.rb
# (uses an in-memory fake WMI provider - no win32ole required)
#
# Exit codes: 0 = no stale profiles, 1 = stale profiles found, 2 = runtime error.
# Tested with Ruby 3.x. Uses only stdlib (win32ole ships with Ruby on Windows).
require 'optparse'
require 'json'
require 'time'
opts = { days: 90, min_size_mb: 0, delete: false, dry_run: false, yes: false,
json: false, size: true, exclude: [] }
OptionParser.new do |o|
o.banner = 'Usage: win_profile_cleanup.rb [options]'
o.on('--days N', Integer, 'Profiles not used for N days are stale (default 90)') { |v| opts[:days] = v }
o.on('--min-size-mb N', Integer, 'Only report profiles at least N MB (default 0)') { |v| opts[:min_size_mb] = v }
o.on('--exclude LIST', Array, 'Comma-separated account names to never touch') { |v| opts[:exclude] = v.map(&:downcase) }
o.on('--no-size', 'Skip the (slow) folder size walk') { opts[:size] = false }
o.on('--delete', 'Delete stale profiles (needs --yes, or --dry-run)') { opts[:delete] = true }
o.on('--dry-run', 'Show what --delete would do without doing it') { opts[:dry_run] = true }
o.on('--yes', 'Confirm deletion non-interactively') { opts[:yes] = true }
o.on('--json', 'Emit JSON') { opts[:json] = true }
o.on('-h', '--help') { puts o; exit 0 }
end.parse!
# ---------------------------------------------------------------------------
# WMI access, isolated behind one tiny interface so it can be mocked.
# ---------------------------------------------------------------------------
# Win32_UserProfile fields we use:
# SID, LocalPath, LastUseTime (CIM_DATETIME string), Loaded, Special,
# RoamingConfigured, Status (bitmask: 1=Temporary 2=Roaming 4=Mandatory 8=Corrupted)
class WmiProfiles
def initialize
require 'win32ole'
@wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
rescue LoadError
raise 'win32ole is only available on Windows. Set WIN_PROFILE_MOCK=1 to test elsewhere.'
end
def each_profile
@wmi.ExecQuery('SELECT * FROM Win32_UserProfile').each do |p|
yield({
sid: p.SID, path: p.LocalPath, last_use: p.LastUseTime,
loaded: p.Loaded, special: p.Special, roaming: p.RoamingConfigured,
status: p.Status.to_i, _obj: p
})
end
end
# Win32_UserProfile.Delete() removes the folder AND the registry ProfileList
# entry - the same thing "System Properties > User Profiles > Delete" does.
def delete(profile)
profile[:_obj].Delete_
end
def account_name(sid)
acct = @wmi.Get("Win32_SID.SID='#{sid}'")
domain = acct.ReferencedDomainName.to_s
name = acct.AccountName.to_s
name.empty? ? sid : (domain.empty? ? name : "#{domain}\\#{name}")
rescue WIN32OLERuntimeError
sid # orphaned SID (user deleted from AD/local SAM) - very common for stale profiles
end
end
# In-memory stand-in with the same three methods, so the whole decision
# path can be exercised on Linux/macOS CI.
class MockProfiles
def initialize
now = Time.now
cim = ->(t) { t.strftime('%Y%m%d%H%M%S.000000-000') }
@rows = [
{ sid: 'S-1-5-18', path: 'C:\\Windows\\system32\\config\\systemprofile', last_use: cim.call(now), loaded: true, special: true, roaming: false, status: 0, name: 'NT AUTHORITY\\SYSTEM' },
{ sid: 'S-1-5-21-1-1001', path: 'C:\\Users\\jsmith', last_use: cim.call(now - 3600), loaded: true, special: false, roaming: false, status: 0, name: 'CORP\\jsmith' },
{ sid: 'S-1-5-21-1-1002', path: 'C:\\Users\\contractor.old', last_use: cim.call(now - 210 * 86_400), loaded: false, special: false, roaming: false, status: 0, name: 'CORP\\contractor.old', size_mb: 4120 },
{ sid: 'S-1-5-21-1-1003', path: 'C:\\Users\\svc_backup', last_use: cim.call(now - 400 * 86_400), loaded: false, special: false, roaming: false, status: 0, name: 'CORP\\svc_backup', size_mb: 35 },
{ sid: 'S-1-5-21-1-1004', path: 'C:\\Users\\tmp.LAB', last_use: cim.call(now - 120 * 86_400), loaded: false, special: false, roaming: false, status: 1, name: 'S-1-5-21-1-1004', size_mb: 12 },
{ sid: 'S-1-5-21-1-1005', path: 'C:\\Users\\amartinez', last_use: cim.call(now - 95 * 86_400), loaded: false, special: false, roaming: true, status: 2, name: 'CORP\\amartinez', size_mb: 1890 },
{ sid: 'S-1-5-21-1-1006', path: 'C:\\Users\\ci-runner', last_use: cim.call(now - 20 * 86_400), loaded: false, special: false, roaming: false, status: 0, name: 'CORP\\ci-runner', size_mb: 22_400 }
]
@deleted = []
end
attr_reader :deleted
def each_profile
@rows.each { |r| yield r.merge(_obj: r) }
end
def delete(profile)
@deleted << profile[:path]
end
def account_name(sid)
@rows.find { |r| r[:sid] == sid }[:name]
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# CIM_DATETIME looks like 20260901143022.000000-300 (UTC offset in minutes).
def parse_cim_datetime(s)
return nil if s.nil? || s.to_s.empty?
m = s.to_s.match(/\A(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{3})\z/)
return nil unless m
offset_min = m[7].to_i
Time.new(m[1].to_i, m[2].to_i, m[3].to_i, m[4].to_i, m[5].to_i, m[6].to_i,
format('%s%02d:%02d', offset_min.negative? ? '-' : '+', offset_min.abs / 60, offset_min.abs % 60))
end
def folder_size_mb(path)
return nil unless File.directory?(path)
total = 0
Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH) do |f|
total += File.size(f) if File.file?(f)
rescue SystemCallError
next # locked NTUSER.DAT, junctions, ACL denials - keep walking
end
(total / 1024.0 / 1024.0).round
end
STATUS_FLAGS = { 1 => 'temporary', 2 => 'roaming', 4 => 'mandatory', 8 => 'corrupted' }.freeze
def status_words(bits)
words = STATUS_FLAGS.select { |bit, _| bits & bit != 0 }.values
words.empty? ? 'local' : words.join('+')
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
begin
provider = ENV['WIN_PROFILE_MOCK'] ? MockProfiles.new : WmiProfiles.new
rescue StandardError => e
warn "error: #{e.message}"
exit 2
end
cutoff = Time.now - opts[:days] * 86_400
rows = []
provider.each_profile do |p|
next if p[:special] # SYSTEM, LocalService, NetworkService...
name = provider.account_name(p[:sid])
# --exclude matches the bare account name (no DOMAIN\), the full name, or the folder name
short = name.split('\\').last.downcase
folder = p[:path].to_s.split(/[\\\/]/).last.to_s.downcase
next if (opts[:exclude] & [name.downcase, short, folder]).any?
last = parse_cim_datetime(p[:last_use])
age_days = last ? ((Time.now - last) / 86_400).floor : nil
size = if !opts[:size] then nil
elsif p[:size_mb] then p[:size_mb] # mock
else folder_size_mb(p[:path])
end
stale = !p[:loaded] && (age_days.nil? || age_days >= opts[:days])
orphaned = name == p[:sid] # SID no longer resolves to an account
next if size && size < opts[:min_size_mb]
rows << { account: name, path: p[:path], sid: p[:sid], loaded: p[:loaded],
last_use: last&.iso8601, age_days: age_days, size_mb: size,
status: status_words(p[:status]), orphaned: orphaned, stale: stale,
_raw: p }
end
stale_rows = rows.select { |r| r[:stale] }.sort_by { |r| -(r[:size_mb] || 0) }
if opts[:json]
puts JSON.pretty_generate(host: ENV['COMPUTERNAME'] || 'localhost', cutoff_days: opts[:days],
profiles: rows.map { |r| r.reject { |k, _| k == :_raw } })
else
puts "Windows user-profile audit host=#{ENV['COMPUTERNAME'] || 'localhost'} stale after #{opts[:days]} days"
puts '=' * 92
puts format(' %-22s %-28s %-8s %-9s %-10s %-9s %s', 'ACCOUNT', 'PATH', 'AGE(d)', 'SIZE(MB)', 'TYPE', 'LOADED', 'FLAGS')
rows.sort_by { |r| [r[:stale] ? 0 : 1, -(r[:size_mb] || 0)] }.each do |r|
flags = []
flags << 'STALE' if r[:stale]
flags << 'ORPHANED-SID' if r[:orphaned]
puts format(' %-22s %-28s %-8s %-9s %-10s %-9s %s',
r[:account][0, 22], r[:path][0, 28], r[:age_days] || '?', r[:size_mb] || '-',
r[:status], r[:loaded] ? 'yes' : 'no', flags.join(','))
end
puts
reclaim = stale_rows.sum { |r| r[:size_mb] || 0 }
puts "Stale profiles: #{stale_rows.size} / #{rows.size} reclaimable: #{reclaim} MB (#{(reclaim / 1024.0).round(1)} GB)"
end
# ---------------------------------------------------------------------------
# Deletion (guarded)
# ---------------------------------------------------------------------------
if opts[:delete] && !stale_rows.empty?
unless opts[:dry_run] || opts[:yes]
warn 'Refusing to delete without --yes (or use --dry-run).'
exit 2
end
puts
stale_rows.each do |r|
if r[:loaded]
puts " skip #{r[:path]} (profile is loaded)"
next
end
if opts[:dry_run]
puts " would delete #{r[:path]} (#{r[:account]}, #{r[:size_mb] || '?'} MB, #{r[:age_days]}d)"
else
begin
provider.delete(r[:_raw])
puts " deleted #{r[:path]}"
rescue StandardError => e
puts " FAILED #{r[:path]}: #{e.message}"
end
end
end
end
exit(stale_rows.empty? ? 0 : 1)
WMI behind a three-method interface. WmiProfiles exposes each_profile, delete and account_name. Everything else in the script only talks to those three methods, which is what makes MockProfiles possible: an in-memory provider with the same interface and seven realistic fake profiles.
Honest testing note. The Linux sandbox this was tested in has no win32ole, so the captured output is from WIN_PROFILE_MOCK=1. The mock exercises every decision path (special-account skip, exclusions, CIM date parsing, stale logic, orphaned-SID detection, the delete guard) but does not exercise the real COM calls. The WMI class and property names are taken from Microsoft’s documentation, linked in the sources.
CIM_DATETIME parsing. WMI returns timestamps as 20260901143022.000000-300: the offset at the end is minutes from UTC, not hours. parse_cim_datetime converts that to a proper Time with the right zone so age-in-days is correct across DST.
Orphaned SIDs. When an account is deleted from AD or the local SAM, its profile stays. Win32_SID lookup fails, account_name returns the raw SID, and the row is flagged ORPHANED-SID. Those are your safest deletions.
Two switches to delete. --delete alone refuses (exit 2). You must add --dry-run to preview or --yes to commit, and loaded profiles are skipped regardless.
$ WIN_PROFILE_MOCK=1 ruby win_profile_cleanup.rb --days 90 Windows user-profile audit host=localhost stale after 90 days ============================================================================================ ACCOUNT PATH AGE(d) SIZE(MB) TYPE LOADED FLAGS CORP\contractor.old C:\Users\contractor.old 210 4120 local no STALE CORP\amartinez C:\Users\amartinez 95 1890 roaming no STALE CORP\svc_backup C:\Users\svc_backup 400 35 local no STALE S-1-5-21-1-1004 C:\Users\tmp.LAB 120 12 temporary no STALE,ORPHANED-SID CORP\ci-runner C:\Users\ci-runner 20 22400 local no CORP\jsmith C:\Users\jsmith 0 - local yes Stale profiles: 4 / 6 reclaimable: 6057 MB (5.9 GB) exit=1 (stale profiles found) $ WIN_PROFILE_MOCK=1 ruby win_profile_cleanup.rb --days 90 --exclude svc_backup --delete --dry-run would delete C:\Users\contractor.old (CORP\contractor.old, 4120 MB, 210d) would delete C:\Users\amartinez (CORP\amartinez, 1890 MB, 95d) would delete C:\Users\tmp.LAB (S-1-5-21-1-1004, 12 MB, 120d) $ ruby win_profile_cleanup.rb --delete # no --yes, no --dry-run Refusing to delete without --yes (or use --dry-run). exit=2 $ ruby win_profile_cleanup.rb # on Linux, no mock error: win32ole is only available on Windows. Set WIN_PROFILE_MOCK=1 to test elsewhere. exit=2
Prerequisites
- Ruby 3.x for Windows (RubyInstaller).
win32oleis part of the standard library on Windows; no gems required. - Windows 10/11 or Server 2016+ with WMI running (it always is). Run from an elevated prompt for
--delete; the audit works as a normal admin user. - For testing on Linux/macOS/CI: set
WIN_PROFILE_MOCK=1to use the built-in fake provider.
Full source for reference
Usage:
ruby win_profile_cleanup.rb # report profiles unused > 90 days
ruby win_profile_cleanup.rb --days 30 --min-size-mb 500
ruby win_profile_cleanup.rb --days 90 --exclude svc_backup --delete --dry-run
ruby win_profile_cleanup.rb --days 90 --delete --yes # actually delete (elevated prompt)
ruby win_profile_cleanup.rb --json > profiles.json
WIN_PROFILE_MOCK=1 ruby win_profile_cleanup.rb # test anywhere without win32ole
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_profile_cleanup.rb - Find (and optionally remove) stale Windows user
# profiles via WMI's Win32_UserProfile class.
#
# Every user who has ever logged on to a shared workstation, RDS host or
# jump box leaves a C:\Users\<name> folder behind. On a busy box that is
# tens of gigabytes of stale roaming data, and the built-in GPO
# ("Delete user profiles older than N days") is blunt and frequently
# broken by apps that touch NTUSER.DAT. This script gives you the report
# first and the delete second, with safeguards.
#
# Usage (Windows, run as Administrator for delete):
# ruby win_profile_cleanup.rb # report profiles unused > 90 days
# ruby win_profile_cleanup.rb --days 30 --min-size-mb 500
# ruby win_profile_cleanup.rb --days 90 --delete --dry-run
# ruby win_profile_cleanup.rb --days 90 --delete --yes # actually delete
# ruby win_profile_cleanup.rb --json > profiles.json
#
# Testing off-Windows: WIN_PROFILE_MOCK=1 ruby win_profile_cleanup.rb
# (uses an in-memory fake WMI provider - no win32ole required)
#
# Exit codes: 0 = no stale profiles, 1 = stale profiles found, 2 = runtime error.
# Tested with Ruby 3.x. Uses only stdlib (win32ole ships with Ruby on Windows).
require 'optparse'
require 'json'
require 'time'
opts = { days: 90, min_size_mb: 0, delete: false, dry_run: false, yes: false,
json: false, size: true, exclude: [] }
OptionParser.new do |o|
o.banner = 'Usage: win_profile_cleanup.rb [options]'
o.on('--days N', Integer, 'Profiles not used for N days are stale (default 90)') { |v| opts[:days] = v }
o.on('--min-size-mb N', Integer, 'Only report profiles at least N MB (default 0)') { |v| opts[:min_size_mb] = v }
o.on('--exclude LIST', Array, 'Comma-separated account names to never touch') { |v| opts[:exclude] = v.map(&:downcase) }
o.on('--no-size', 'Skip the (slow) folder size walk') { opts[:size] = false }
o.on('--delete', 'Delete stale profiles (needs --yes, or --dry-run)') { opts[:delete] = true }
o.on('--dry-run', 'Show what --delete would do without doing it') { opts[:dry_run] = true }
o.on('--yes', 'Confirm deletion non-interactively') { opts[:yes] = true }
o.on('--json', 'Emit JSON') { opts[:json] = true }
o.on('-h', '--help') { puts o; exit 0 }
end.parse!
# ---------------------------------------------------------------------------
# WMI access, isolated behind one tiny interface so it can be mocked.
# ---------------------------------------------------------------------------
# Win32_UserProfile fields we use:
# SID, LocalPath, LastUseTime (CIM_DATETIME string), Loaded, Special,
# RoamingConfigured, Status (bitmask: 1=Temporary 2=Roaming 4=Mandatory 8=Corrupted)
class WmiProfiles
def initialize
require 'win32ole'
@wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
rescue LoadError
raise 'win32ole is only available on Windows. Set WIN_PROFILE_MOCK=1 to test elsewhere.'
end
def each_profile
@wmi.ExecQuery('SELECT * FROM Win32_UserProfile').each do |p|
yield({
sid: p.SID, path: p.LocalPath, last_use: p.LastUseTime,
loaded: p.Loaded, special: p.Special, roaming: p.RoamingConfigured,
status: p.Status.to_i, _obj: p
})
end
end
# Win32_UserProfile.Delete() removes the folder AND the registry ProfileList
# entry - the same thing "System Properties > User Profiles > Delete" does.
def delete(profile)
profile[:_obj].Delete_
end
def account_name(sid)
acct = @wmi.Get("Win32_SID.SID='#{sid}'")
domain = acct.ReferencedDomainName.to_s
name = acct.AccountName.to_s
name.empty? ? sid : (domain.empty? ? name : "#{domain}\\#{name}")
rescue WIN32OLERuntimeError
sid # orphaned SID (user deleted from AD/local SAM) - very common for stale profiles
end
end
# In-memory stand-in with the same three methods, so the whole decision
# path can be exercised on Linux/macOS CI.
class MockProfiles
def initialize
now = Time.now
cim = ->(t) { t.strftime('%Y%m%d%H%M%S.000000-000') }
@rows = [
{ sid: 'S-1-5-18', path: 'C:\\Windows\\system32\\config\\systemprofile', last_use: cim.call(now), loaded: true, special: true, roaming: false, status: 0, name: 'NT AUTHORITY\\SYSTEM' },
{ sid: 'S-1-5-21-1-1001', path: 'C:\\Users\\jsmith', last_use: cim.call(now - 3600), loaded: true, special: false, roaming: false, status: 0, name: 'CORP\\jsmith' },
{ sid: 'S-1-5-21-1-1002', path: 'C:\\Users\\contractor.old', last_use: cim.call(now - 210 * 86_400), loaded: false, special: false, roaming: false, status: 0, name: 'CORP\\contractor.old', size_mb: 4120 },
{ sid: 'S-1-5-21-1-1003', path: 'C:\\Users\\svc_backup', last_use: cim.call(now - 400 * 86_400), loaded: false, special: false, roaming: false, status: 0, name: 'CORP\\svc_backup', size_mb: 35 },
{ sid: 'S-1-5-21-1-1004', path: 'C:\\Users\\tmp.LAB', last_use: cim.call(now - 120 * 86_400), loaded: false, special: false, roaming: false, status: 1, name: 'S-1-5-21-1-1004', size_mb: 12 },
{ sid: 'S-1-5-21-1-1005', path: 'C:\\Users\\amartinez', last_use: cim.call(now - 95 * 86_400), loaded: false, special: false, roaming: true, status: 2, name: 'CORP\\amartinez', size_mb: 1890 },
{ sid: 'S-1-5-21-1-1006', path: 'C:\\Users\\ci-runner', last_use: cim.call(now - 20 * 86_400), loaded: false, special: false, roaming: false, status: 0, name: 'CORP\\ci-runner', size_mb: 22_400 }
]
@deleted = []
end
attr_reader :deleted
def each_profile
@rows.each { |r| yield r.merge(_obj: r) }
end
def delete(profile)
@deleted << profile[:path]
end
def account_name(sid)
@rows.find { |r| r[:sid] == sid }[:name]
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# CIM_DATETIME looks like 20260901143022.000000-300 (UTC offset in minutes).
def parse_cim_datetime(s)
return nil if s.nil? || s.to_s.empty?
m = s.to_s.match(/\A(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{3})\z/)
return nil unless m
offset_min = m[7].to_i
Time.new(m[1].to_i, m[2].to_i, m[3].to_i, m[4].to_i, m[5].to_i, m[6].to_i,
format('%s%02d:%02d', offset_min.negative? ? '-' : '+', offset_min.abs / 60, offset_min.abs % 60))
end
def folder_size_mb(path)
return nil unless File.directory?(path)
total = 0
Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH) do |f|
total += File.size(f) if File.file?(f)
rescue SystemCallError
next # locked NTUSER.DAT, junctions, ACL denials - keep walking
end
(total / 1024.0 / 1024.0).round
end
STATUS_FLAGS = { 1 => 'temporary', 2 => 'roaming', 4 => 'mandatory', 8 => 'corrupted' }.freeze
def status_words(bits)
words = STATUS_FLAGS.select { |bit, _| bits & bit != 0 }.values
words.empty? ? 'local' : words.join('+')
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
begin
provider = ENV['WIN_PROFILE_MOCK'] ? MockProfiles.new : WmiProfiles.new
rescue StandardError => e
warn "error: #{e.message}"
exit 2
end
cutoff = Time.now - opts[:days] * 86_400
rows = []
provider.each_profile do |p|
next if p[:special] # SYSTEM, LocalService, NetworkService...
name = provider.account_name(p[:sid])
# --exclude matches the bare account name (no DOMAIN\), the full name, or the folder name
short = name.split('\\').last.downcase
folder = p[:path].to_s.split(/[\\\/]/).last.to_s.downcase
next if (opts[:exclude] & [name.downcase, short, folder]).any?
last = parse_cim_datetime(p[:last_use])
age_days = last ? ((Time.now - last) / 86_400).floor : nil
size = if !opts[:size] then nil
elsif p[:size_mb] then p[:size_mb] # mock
else folder_size_mb(p[:path])
end
stale = !p[:loaded] && (age_days.nil? || age_days >= opts[:days])
orphaned = name == p[:sid] # SID no longer resolves to an account
next if size && size < opts[:min_size_mb]
rows << { account: name, path: p[:path], sid: p[:sid], loaded: p[:loaded],
last_use: last&.iso8601, age_days: age_days, size_mb: size,
status: status_words(p[:status]), orphaned: orphaned, stale: stale,
_raw: p }
end
stale_rows = rows.select { |r| r[:stale] }.sort_by { |r| -(r[:size_mb] || 0) }
if opts[:json]
puts JSON.pretty_generate(host: ENV['COMPUTERNAME'] || 'localhost', cutoff_days: opts[:days],
profiles: rows.map { |r| r.reject { |k, _| k == :_raw } })
else
puts "Windows user-profile audit host=#{ENV['COMPUTERNAME'] || 'localhost'} stale after #{opts[:days]} days"
puts '=' * 92
puts format(' %-22s %-28s %-8s %-9s %-10s %-9s %s', 'ACCOUNT', 'PATH', 'AGE(d)', 'SIZE(MB)', 'TYPE', 'LOADED', 'FLAGS')
rows.sort_by { |r| [r[:stale] ? 0 : 1, -(r[:size_mb] || 0)] }.each do |r|
flags = []
flags << 'STALE' if r[:stale]
flags << 'ORPHANED-SID' if r[:orphaned]
puts format(' %-22s %-28s %-8s %-9s %-10s %-9s %s',
r[:account][0, 22], r[:path][0, 28], r[:age_days] || '?', r[:size_mb] || '-',
r[:status], r[:loaded] ? 'yes' : 'no', flags.join(','))
end
puts
reclaim = stale_rows.sum { |r| r[:size_mb] || 0 }
puts "Stale profiles: #{stale_rows.size} / #{rows.size} reclaimable: #{reclaim} MB (#{(reclaim / 1024.0).round(1)} GB)"
end
# ---------------------------------------------------------------------------
# Deletion (guarded)
# ---------------------------------------------------------------------------
if opts[:delete] && !stale_rows.empty?
unless opts[:dry_run] || opts[:yes]
warn 'Refusing to delete without --yes (or use --dry-run).'
exit 2
end
puts
stale_rows.each do |r|
if r[:loaded]
puts " skip #{r[:path]} (profile is loaded)"
next
end
if opts[:dry_run]
puts " would delete #{r[:path]} (#{r[:account]}, #{r[:size_mb] || '?'} MB, #{r[:age_days]}d)"
else
begin
provider.delete(r[:_raw])
puts " deleted #{r[:path]}"
rescue StandardError => e
puts " FAILED #{r[:path]}: #{e.message}"
end
end
end
end
exit(stale_rows.empty? ? 0 : 1)
Step-by-step walkthrough
1. Connect to WMI
WIN32OLE.connect('winmgmts://./root/cimv2') attaches to the local CIM repository. ExecQuery('SELECT * FROM Win32_UserProfile') returns one COM object per profile; each is converted to a plain Ruby hash so the rest of the script never touches COM directly.
2. Skip what must never be touched
Profiles with Special = true (SYSTEM, LocalService, NetworkService, DefaultAppPool) are skipped outright. --exclude matches the bare account name, the DOMAIN\name form, or the folder name, so --exclude svc_backup,ci-runner works however you think about the account.
3. Resolve SID to account
@wmi.Get("Win32_SID.SID='S-1-5-21-...'") gives ReferencedDomainName and AccountName. A WIN32OLERuntimeError here means the account no longer exists; the script keeps the SID as the name and flags the row as orphaned.
4. Decide staleness
A profile is stale when it is not loaded and either its age in days is at least --days or LastUseTime is missing entirely (which happens on profiles migrated from older Windows versions). Folder size is walked with Dir.glob and File::FNM_DOTMATCH, rescuing per-file errors so a locked NTUSER.DAT doesn’t abort the walk; --no-size skips it on huge hosts.
5. Report, then guard the delete
Rows are sorted stale-first then by size, with total reclaimable MB at the bottom. --delete without --yes or --dry-run exits 2. With --yes, Win32_UserProfile.Delete_() is called per stale profile (the trailing underscore is how win32ole exposes a method whose name collides with a Ruby keyword) inside a rescue so one failure doesn’t stop the run.
What a run looks like
Troubleshooting
- “win32ole is only available on Windows” (exit 2): you ran it on Linux/macOS without
WIN_PROFILE_MOCK=1. That is the intended behaviour and is what the sandbox test returned. - The mock is not the real thing. As stated in the walkthrough, COM calls were verified against Microsoft’s documented
Win32_UserProfileandWin32_SIDmembers, not executed. First run on a real host should be an audit only (no--delete), then--dry-run. - Delete fails with “Access denied” (0x80070005). Not elevated, or the profile is loaded by a disconnected RDP session. Check
query userand log the session off first; the script skips profiles WMI reports as loaded but a half-torn-down session can still hold files. - Delete fails with 0x80041001 (generic failure). Usually a file inside the profile is open by a service (an updater, OneDrive). Stop the service or reboot, then re-run.
- Every profile shows age
?.LastUseTimeis null on some upgraded systems; those profiles are treated as stale only if not loaded. Cross-check with the folder’s modified date before deleting. - Folder size walk takes forever. Profiles with AppData caches can have millions of files. Use
--no-sizefor a quick audit, then run the size walk on the shortlist.
Extending the script
- Fleet mode: wrap it in a PowerShell remoting or WinRM loop and collect
--jsonper host into a CSV of reclaimable space by machine. - Roaming-profile awareness: rows with
roamingin the TYPE column live on a file server too; add a switch to also report the server-side folder. - Scheduled task: run the audit weekly and email the report; only run
--delete --yesfrom a change-controlled job with--excludepinned to your service accounts. - Add
--older-than-logoffusingWin32_NetworkLoginProfile.LastLogoffas a second opinion on age. - Push reclaimable MB into a monitoring system as a gauge so you can see profile bloat trending before the disk alert fires.