Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/ManageUsers/Models/StaleProfileInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace ManageUsers.Models;

/// <summary>
/// A profile folder in C:\Users with no corresponding local user account.
/// Typically an Entra ID cached profile left behind after the user stopped logging in.
/// </summary>
public sealed record StaleProfileInfo
{
public required string FolderName { get; init; }
public required string ProfilePath { get; init; }
public string? Sid { get; init; }
public DateTime CreationDate { get; init; }
public DateTime? LastUseTime { get; init; }
public bool HasRegistryEntry { get; init; }
}
70 changes: 64 additions & 6 deletions src/ManageUsers/Services/ManageUsersEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,6 @@ public int Run()
var users = _enum.GetUserSessions(exclusions);
_log.Info($"Found {users.Count} non-excluded user(s) to evaluate");

if (users.Count == 0)
{
_log.Info("No users to process — exiting");
return 0;
}

// Repair user states
_repair.RepairUserStates(users);

Expand Down Expand Up @@ -94,6 +88,21 @@ public int Run()
deletedCount += orphans.Count;
}

// Clean up stale Entra/cached profiles (no local account)
var staleProfiles = _enum.GetStaleProfiles(exclusions);
if (staleProfiles.Count > 0)
{
_log.Info($"Found {staleProfiles.Count} stale profile(s) with no local account");
foreach (var profile in staleProfiles)
{
if (EvaluateStaleProfile(profile, policy, now))
{
if (_delete.RemoveStaleProfile(profile))
deletedCount++;
}
}
}

// Update hidden users on login screen
_repair.UpdateHiddenUsers(exclusions);

Expand Down Expand Up @@ -164,4 +173,53 @@ private bool EvaluateUser(UserSessionInfo user, DeletionPolicy policy, DateTime
return false;
}
}

private bool EvaluateStaleProfile(StaleProfileInfo profile, DeletionPolicy policy, DateTime now)
{
if (policy.ForceTermDeletion)
{
_log.Info($"End-of-term force deletion: stale profile {profile.FolderName}");
return true;
}

if (policy.DurationDays < 0)
{
_log.Info($"Never-delete policy: stale profile {profile.FolderName} — keep");
return false;
}

var threshold = TimeSpan.FromDays(policy.DurationDays);

switch (policy.Strategy)
{
case DeletionStrategy.CreationOnly:
{
var age = now - profile.CreationDate;
if (age >= threshold)
{
_log.Info($"CreationOnly: stale profile {profile.FolderName} created {age.Days}d ago (threshold {policy.DurationDays}d) — DELETE");
return true;
}
_log.Info($"CreationOnly: stale profile {profile.FolderName} created {age.Days}d ago (threshold {policy.DurationDays}d) — keep");
return false;
}

case DeletionStrategy.LoginAndCreation:
{
var creationAge = now - profile.CreationDate;
var lastUseAge = profile.LastUseTime.HasValue ? now - profile.LastUseTime.Value : TimeSpan.MaxValue;

if (creationAge >= threshold && lastUseAge >= threshold)
{
_log.Info($"LoginAndCreation: stale profile {profile.FolderName} created {creationAge.Days}d ago, last use {(profile.LastUseTime.HasValue ? $"{lastUseAge.Days}d ago" : "never")} (threshold {policy.DurationDays}d) — DELETE");
return true;
}
_log.Info($"LoginAndCreation: stale profile {profile.FolderName} created {creationAge.Days}d ago, last use {(profile.LastUseTime.HasValue ? $"{lastUseAge.Days}d ago" : "never")} (threshold {policy.DurationDays}d) — keep");
return false;
}

default:
return false;
}
}
}
58 changes: 58 additions & 0 deletions src/ManageUsers/Services/UserDeletionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,64 @@ public void RemoveOrphanedUsers(List<string> orphans, SessionsData sessions)
}
}

/// <summary>
/// Remove a stale Entra/cached profile — no local account exists, just registry + folder.
/// </summary>
public bool RemoveStaleProfile(StaleProfileInfo profile)
{
if (_simulate)
{
_log.Info($"[SIMULATE] Would remove stale profile: {profile.FolderName}");
return true;
}

_log.Info($"Removing stale profile: {profile.FolderName}");

// Kill any lingering processes owned by this user
KillUserProcesses(profile.FolderName);

// Remove ProfileList registry entry if present
if (profile.HasRegistryEntry && profile.Sid != null)
{
try
{
using var profileList = Registry.LocalMachine.OpenSubKey(
@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList", writable: true);
if (profileList != null)
{
profileList.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false);
_log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})");
}
else
{
_log.Warning($"Could not open ProfileList registry key; registry entry was not removed for {profile.FolderName} (SID: {profile.Sid})");
}
}
catch (Exception ex)
{
_log.Warning($"Failed to remove registry entry for {profile.FolderName}: {ex.Message}");
}
}

// Delete profile folder
if (Directory.Exists(profile.ProfilePath))
{
try
{
Directory.Delete(profile.ProfilePath, recursive: true);
_log.Info($"Profile directory removed: {profile.ProfilePath}");
}
catch (Exception ex)
{
_log.Warning($"Failed to remove profile directory {profile.ProfilePath}: {ex.Message}");
return false;
}
}

_log.Info($"Successfully removed stale profile: {profile.FolderName}");
return true;
}

private void DeferDelete(string username, SessionsData sessions)
{
if (!sessions.DeferredDeletes.Contains(username, StringComparer.OrdinalIgnoreCase))
Expand Down
158 changes: 158 additions & 0 deletions src/ManageUsers/Services/UserEnumerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,141 @@ public List<UserSessionInfo> GetUserSessions(HashSet<string> exclusions)
return results;
}

/// <summary>
/// Finds profile folders in C:\Users that have no corresponding local user account.
/// These are typically Entra ID cached profiles that accumulate on shared devices.
///
/// Matching strategy: build the set of SIDs for every local account, then walk
/// ProfileList and collect the LocalPath of any entry whose SID is in that set.
/// Anything in C:\Users not in that path set (and not a system folder / exclusion)
/// is a stale candidate. This avoids the naive foldername-equals-username match
/// which would misclassify collision-suffixed profile directories (e.g.
/// "jsmith.ECU" for local account "jsmith").
/// </summary>
public List<StaleProfileInfo> GetStaleProfiles(HashSet<string> exclusions)
{
var results = new List<StaleProfileInfo>();

// SIDs of every local account (enabled or disabled — we don't want to delete
// a disabled local user's profile by accident).
var localUsers = EnumerateLocalUsers();
var localUserSids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var localUserNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, _) in localUsers)
{
localUserNames.Add(name);
var sid = ResolveSid(name);
if (!string.IsNullOrEmpty(sid))
localUserSids.Add(sid);
}

// Pre-index ProfileList by normalized LocalPath so the join below is O(1)
// and tolerant of env-var / trailing-separator variation in ProfileImagePath.
var profiles = LoadProfiles();
var profilesByNormalizedPath = new Dictionary<string, ProfileInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var profile in profiles.Values)
{
var key = NormalizeProfilePath(profile.LocalPath);
if (!string.IsNullOrEmpty(key))
profilesByNormalizedPath[key] = profile;
}

// Paths owned by real local accounts — these are NOT stale, even if the
// folder name differs from the account name (collision suffixes, casing, etc).
var localUserProfilePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var profile in profiles.Values)
{
if (localUserSids.Contains(profile.Sid))
{
var key = NormalizeProfilePath(profile.LocalPath);
if (!string.IsNullOrEmpty(key))
localUserProfilePaths.Add(key);
}
}

var usersDir = @"C:\Users";
if (!Directory.Exists(usersDir)) return results;

foreach (var dir in Directory.GetDirectories(usersDir))
{
var folderName = Path.GetFileName(dir);
if (folderName == null) continue;

// Skip system folders
if (SystemProfileFolders.Contains(folderName)) continue;

// Skip excluded users
if (exclusions.Contains(folderName)) continue;

var normalizedDir = NormalizeProfilePath(dir);

// Skip if this path is owned by a real local account (via ProfileList).
if (localUserProfilePaths.Contains(normalizedDir)) continue;

// Fallback: folder-name match for local users that have no ProfileList
// entry yet (brand-new accounts that haven't logged in). Safer than
// deleting someone's home dir due to a missing registry join.
if (localUserNames.Contains(folderName)) continue;

Comment on lines +130 to +142
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale-profile detection currently assumes the profile directory name must exactly match a local username (localUserNames.Contains(folderName)). Windows profile folders can legitimately differ from the account name (e.g., suffixes added on name collisions), which would cause a real local account’s profile to be misclassified as stale and eligible for deletion. Consider determining staleness via ProfileList (SID/LocalPath) and verifying whether that SID corresponds to an existing local account (or comparing against local user SIDs), rather than relying on folder name equality.

Copilot uses AI. Check for mistakes.
// Look up any ProfileList entry for this directory (helps surface the SID
// for registry cleanup even when no local user owns it).
profilesByNormalizedPath.TryGetValue(normalizedDir, out var profileEntry);

DateTime creationDate;
try
{
creationDate = Directory.GetCreationTime(dir);
}
catch (Exception ex)
{
_log.Warning($"Could not get creation time for profile folder '{dir}': {ex.Message}. Falling back to last write time.");
try
{
creationDate = Directory.GetLastWriteTime(dir);
}
catch (Exception fallbackEx)
{
_log.Warning($"Could not get last write time for '{dir}' either: {fallbackEx.Message}. Using DateTime.Now — policy evaluation may be inaccurate.");
creationDate = DateTime.Now;
}
}

var lastUseTime = profileEntry?.LastUseTime ?? GetFolderLastActivity(dir);

results.Add(new StaleProfileInfo
{
FolderName = folderName,
ProfilePath = dir,
Sid = profileEntry?.Sid,
CreationDate = creationDate,
LastUseTime = lastUseTime,
HasRegistryEntry = profileEntry != null
});

_log.Info($"Stale profile: {folderName} | Created: {creationDate:yyyy-MM-dd} | LastUse: {lastUseTime?.ToString("yyyy-MM-dd") ?? "unknown"} | Registry: {(profileEntry != null ? "yes" : "no")}");
}

return results;
}

/// <summary>
/// Normalize a profile path for comparison: expand env vars, resolve to a
/// canonical full path, and trim trailing separators. Returns empty on failure.
/// </summary>
private static string NormalizeProfilePath(string path)
{
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
try
{
var expanded = Environment.ExpandEnvironmentVariables(path);
return Path.GetFullPath(expanded).TrimEnd('\\', '/');
}
catch
{
return string.Empty;
}
}

public List<string> FindOrphanedUsers(HashSet<string> exclusions)
{
var orphans = new List<string>();
Expand Down Expand Up @@ -270,4 +405,27 @@ private sealed class ProfileInfo
public required string LocalPath { get; init; }
public DateTime? LastUseTime { get; init; }
}

#region Stale Profile Helpers

private static readonly HashSet<string> SystemProfileFolders = new(StringComparer.OrdinalIgnoreCase)
{
"Public", "Default", "Default User", "All Users"
};

private static DateTime? GetFolderLastActivity(string path)
{
try
{
// NTUSER.DAT last write time is the best proxy for last interactive use
var ntuserDat = Path.Combine(path, "NTUSER.DAT");
if (File.Exists(ntuserDat))
return File.GetLastWriteTime(ntuserDat);

return Directory.GetLastWriteTime(path);
}
catch { return null; }
}

#endregion
}