the shed // windows // wmi

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.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/win-profile-cleanup

Step through the build below:

win_profile_cleanup.rb

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
4 / 6
stale profiles found
5.9 GB
reclaimable (mock)
2 flags
to actually delete
win-profile-cleanup architecture diagram

How win_profile_cleanup.rb fits together
before you start

Prerequisites

you will need
  • Ruby 3.x for Windows (RubyInstaller). win32ole is 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=1 to use the built-in fake provider.
the script

Full source for reference

Usage:

usagebash
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
win_profile_cleanup.rbruby
#!/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)
how it works

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.

example output

What a run looks like

win_profile_cleanup.rb — captured output
$ 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
when it goes wrong

Troubleshooting

common issues
  • “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_UserProfile and Win32_SID members, 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 user and 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 ?. LastUseTime is 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-size for a quick audit, then run the size walk on the shortlist.
next steps

Extending the script

ideas
  • Fleet mode: wrap it in a PowerShell remoting or WinRM loop and collect --json per host into a CSV of reclaimable space by machine.
  • Roaming-profile awareness: rows with roaming in 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 --yes from a change-controlled job with --exclude pinned to your service accounts.
  • Add --older-than-logoff using Win32_NetworkLoginProfile.LastLogoff as 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.