diff --git a/src/ManageUsers/Models/StaleProfileInfo.cs b/src/ManageUsers/Models/StaleProfileInfo.cs
new file mode 100644
index 0000000..fb8ed9c
--- /dev/null
+++ b/src/ManageUsers/Models/StaleProfileInfo.cs
@@ -0,0 +1,15 @@
+namespace ManageUsers.Models;
+
+///
+/// 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.
+///
+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; }
+}
diff --git a/src/ManageUsers/Services/ManageUsersEngine.cs b/src/ManageUsers/Services/ManageUsersEngine.cs
index 445ea09..e884d09 100644
--- a/src/ManageUsers/Services/ManageUsersEngine.cs
+++ b/src/ManageUsers/Services/ManageUsersEngine.cs
@@ -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);
@@ -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);
@@ -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;
+ }
+ }
}
diff --git a/src/ManageUsers/Services/UserDeletionService.cs b/src/ManageUsers/Services/UserDeletionService.cs
index ed2a86e..5f8ff26 100644
--- a/src/ManageUsers/Services/UserDeletionService.cs
+++ b/src/ManageUsers/Services/UserDeletionService.cs
@@ -106,6 +106,64 @@ public void RemoveOrphanedUsers(List orphans, SessionsData sessions)
}
}
+ ///
+ /// Remove a stale Entra/cached profile — no local account exists, just registry + folder.
+ ///
+ 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))
diff --git a/src/ManageUsers/Services/UserEnumerationService.cs b/src/ManageUsers/Services/UserEnumerationService.cs
index 3834fe6..5f27c87 100644
--- a/src/ManageUsers/Services/UserEnumerationService.cs
+++ b/src/ManageUsers/Services/UserEnumerationService.cs
@@ -64,6 +64,141 @@ public List GetUserSessions(HashSet exclusions)
return results;
}
+ ///
+ /// 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").
+ ///
+ public List GetStaleProfiles(HashSet exclusions)
+ {
+ var results = new List();
+
+ // 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(StringComparer.OrdinalIgnoreCase);
+ var localUserNames = new HashSet(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(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(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;
+
+ // 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;
+ }
+
+ ///
+ /// Normalize a profile path for comparison: expand env vars, resolve to a
+ /// canonical full path, and trim trailing separators. Returns empty on failure.
+ ///
+ 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 FindOrphanedUsers(HashSet exclusions)
{
var orphans = new List();
@@ -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 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
}