mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-07-20 21:10:20 +08:00
971 lines
30 KiB
PowerShell
971 lines
30 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Normalize a user-name string for lookup and comparison.
|
|
|
|
.DESCRIPTION
|
|
Strips zero-width chars and collapses whitespace so cosmetic input
|
|
differences don't break downstream lookups. Returns '' for blank input.
|
|
|
|
.PARAMETER Value
|
|
Raw user-supplied name to normalize.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Normalize-UserLookupValue {
|
|
param(
|
|
[string]$Value
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Value)) {
|
|
return ''
|
|
}
|
|
|
|
$normalized = $Value -replace '[\u200B-\u200D\uFEFF]', ''
|
|
$normalized = $normalized.Trim() -replace '\s+', ' '
|
|
return $normalized
|
|
}
|
|
|
|
if (-not $script:ResolvedUserSidCache) {
|
|
$script:ResolvedUserSidCache = @{}
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Build a form-agnostic cache key for a user name.
|
|
|
|
.DESCRIPTION
|
|
Normalized + lower-cased so the same identity hits regardless of
|
|
case, whitespace, or qualifier form. Returns '' for blank input.
|
|
|
|
.PARAMETER Value
|
|
User name to derive a key from.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Get-UserLookupCacheKey {
|
|
param(
|
|
[string]$Value
|
|
)
|
|
|
|
$normalizedValue = Normalize-UserLookupValue -Value $Value
|
|
if ([string]::IsNullOrWhiteSpace($normalizedValue)) {
|
|
return ''
|
|
}
|
|
|
|
return $normalizedValue.ToLowerInvariant()
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Normalize and de-duplicate a set of user-name candidates.
|
|
|
|
.DESCRIPTION
|
|
Centralizes the normalize/filter/dedupe step shared by the SID
|
|
resolution fallbacks so the dedupe semantic lives in one place.
|
|
Returns @() for empty or all-blank input.
|
|
|
|
.PARAMETER Candidates
|
|
Equivalent name forms to normalize.
|
|
|
|
.OUTPUTS
|
|
System.String[]
|
|
#>
|
|
function Get-NormalizedLookupCandidates {
|
|
param(
|
|
[string[]]$Candidates
|
|
)
|
|
|
|
$normalized = @($Candidates) |
|
|
ForEach-Object { Normalize-UserLookupValue -Value $_ } |
|
|
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
|
Select-Object -Unique
|
|
|
|
# The unary comma prevents PowerShell from unwrapping a single-element array.
|
|
return ,@($normalized)
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Escape a string for safe embedding in a WQL single-quoted literal.
|
|
|
|
.DESCRIPTION
|
|
Doubles embedded single quotes; without this a user name containing
|
|
an apostrophe could break the WQL filter.
|
|
|
|
.PARAMETER Value
|
|
String to escape.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Escape-WqlString {
|
|
param(
|
|
[string]$Value
|
|
)
|
|
|
|
if ($null -eq $Value) {
|
|
return ''
|
|
}
|
|
|
|
return $Value -replace "'", "''"
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Extract the local name segment from a possibly domain-qualified identity.
|
|
|
|
.DESCRIPTION
|
|
Reduces DOMAIN\user or user@domain to the bare leaf, since profile
|
|
folder leafs never carry the domain prefix. Returns '' for blank input.
|
|
|
|
.PARAMETER UserName
|
|
User name that may be domain-qualified.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Get-LocalUserNameSegment {
|
|
param(
|
|
[string]$UserName
|
|
)
|
|
|
|
$normalizedName = Normalize-UserLookupValue -Value $UserName
|
|
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
|
return ''
|
|
}
|
|
|
|
if ($normalizedName.Contains('\')) {
|
|
return Normalize-UserLookupValue -Value (($normalizedName -split '\\')[-1])
|
|
}
|
|
|
|
if ($normalizedName.Contains('@')) {
|
|
return Normalize-UserLookupValue -Value (($normalizedName -split '@')[0])
|
|
}
|
|
|
|
return $normalizedName
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Reduce a Win32_ComputerSystem.Domain value to a NetBIOS label.
|
|
|
|
.DESCRIPTION
|
|
Win32_ComputerSystem.Domain may be DNS-style (e.g. contoso.com);
|
|
prefer Win32_NTDomain.DomainName and fall back to the first DNS label
|
|
so cached suffixes stay single-label (user.CONTOSO, not
|
|
user.contoso.com). Returns the trimmed value as-is when already a
|
|
single label. Falls back safely (no match) for FQDNs like
|
|
corp.contoso.com whose NetBIOS label is CONTOSO.
|
|
|
|
.PARAMETER RawDomain
|
|
Value reported by Win32_ComputerSystem.Domain.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Resolve-NetBiosDomainName {
|
|
param(
|
|
[string]$RawDomain
|
|
)
|
|
|
|
$trimmed = Normalize-UserLookupValue -Value $RawDomain
|
|
if ([string]::IsNullOrWhiteSpace($trimmed)) {
|
|
return ''
|
|
}
|
|
|
|
try {
|
|
# Prefer the joined-domain instance over the local SAM shadow
|
|
# (DomainName == COMPUTERNAME); DomainControllerName may be $null.
|
|
$computerName = $env:COMPUTERNAME
|
|
$ntDomainInstances = @(Get-CimInstance -ClassName Win32_NTDomain -OperationTimeoutSec 5 -ErrorAction Stop |
|
|
Where-Object {
|
|
-not [string]::IsNullOrWhiteSpace($_.DomainName) -and
|
|
$_.DomainName -ine 'WORKGROUP' -and
|
|
$_.DomainName -ine $computerName
|
|
})
|
|
|
|
$ntDomainInstance = $ntDomainInstances |
|
|
Where-Object { -not [string]::IsNullOrWhiteSpace($_.DomainControllerName) } |
|
|
Select-Object -First 1
|
|
if (-not $ntDomainInstance -and $ntDomainInstances.Count -gt 0) {
|
|
$ntDomainInstance = $ntDomainInstances | Select-Object -First 1
|
|
}
|
|
|
|
if ($ntDomainInstance -and -not [string]::IsNullOrWhiteSpace($ntDomainInstance.DomainName)) {
|
|
$fromNtDomain = Normalize-UserLookupValue -Value $ntDomainInstance.DomainName
|
|
if (-not [string]::IsNullOrWhiteSpace($fromNtDomain)) {
|
|
return $fromNtDomain
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
# Fall through to DNS-label derivation.
|
|
}
|
|
|
|
if ($trimmed.Contains('.')) {
|
|
$leaf = Normalize-UserLookupValue -Value (($trimmed -split '\.')[0])
|
|
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
|
|
return $leaf
|
|
}
|
|
}
|
|
|
|
return $trimmed
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Determine whether the local machine is joined to a domain.
|
|
|
|
.DESCRIPTION
|
|
Cached in script scope for the process lifetime. Returns $false on
|
|
error or workgroup. When joined, also caches the NetBIOS domain label
|
|
(Resolve-NetBiosDomainName) for use as a profile-folder suffix.
|
|
|
|
.OUTPUTS
|
|
System.Boolean
|
|
#>
|
|
function Test-MachineIsDomainJoined {
|
|
if ($null -ne $script:MachineDomainJoinStateKnown) {
|
|
return [bool]$script:MachineIsDomainJoined
|
|
}
|
|
|
|
$script:MachineDomainJoinStateKnown = $true
|
|
$script:MachineIsDomainJoined = $false
|
|
$script:MachineNetBiosDomain = ''
|
|
|
|
try {
|
|
$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
|
if ($null -ne $computerSystem -and $computerSystem.PartOfDomain) {
|
|
$script:MachineIsDomainJoined = $true
|
|
$script:MachineNetBiosDomain = Resolve-NetBiosDomainName -RawDomain ([string]$computerSystem.Domain)
|
|
}
|
|
}
|
|
catch {
|
|
# Leave as $false; callers fall back to legacy matching.
|
|
}
|
|
|
|
return [bool]$script:MachineIsDomainJoined
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Return the NetBIOS domain suffix Windows appends to profile folders.
|
|
|
|
.DESCRIPTION
|
|
On domain-joined machines Windows writes profile folders as
|
|
user.CONTOSO; knowing the suffix lets a bare name match that folder.
|
|
Excluded on workgroup (USERDOMAIN == COMPUTERNAME) to avoid false
|
|
matches. Returns '' when not applicable.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Get-ProfileFolderDomainSuffix {
|
|
if (-not (Test-MachineIsDomainJoined)) {
|
|
return ''
|
|
}
|
|
|
|
$domain = $env:USERDOMAIN
|
|
if ([string]::IsNullOrWhiteSpace($domain)) {
|
|
# USERDOMAIN can be empty in restricted contexts; fall back to the
|
|
# cached NetBIOS label (single-label, safe as a folder suffix).
|
|
$domain = $script:MachineNetBiosDomain
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($domain)) {
|
|
return ''
|
|
}
|
|
|
|
# USERDOMAIN == COMPUTERNAME means effectively standalone; a suffix here
|
|
# would produce false matches instead of disambiguation.
|
|
if ($domain -ieq $env:COMPUTERNAME) {
|
|
return ''
|
|
}
|
|
|
|
return $domain.Trim()
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Enumerate the name forms equivalent to a given identity.
|
|
|
|
.DESCRIPTION
|
|
One identity surfaces in different forms (bare, qualified,
|
|
domain-suffixed); enumerating equivalents lets cross-source matching
|
|
succeed without broadening workgroup validation. Returns @() for blank.
|
|
|
|
.PARAMETER Value
|
|
User name to expand into equivalent forms.
|
|
|
|
.OUTPUTS
|
|
System.String[]
|
|
#>
|
|
function Get-UserNameMatchCandidates {
|
|
param(
|
|
[string]$Value
|
|
)
|
|
|
|
$normalized = Normalize-UserLookupValue -Value $Value
|
|
if ([string]::IsNullOrWhiteSpace($normalized)) {
|
|
return @()
|
|
}
|
|
|
|
$candidates = New-Object 'System.Collections.Generic.List[string]'
|
|
[void]$candidates.Add($normalized)
|
|
|
|
$localSegment = Get-LocalUserNameSegment -UserName $normalized
|
|
if (-not [string]::IsNullOrWhiteSpace($localSegment) -and ($localSegment -ine $normalized)) {
|
|
[void]$candidates.Add($localSegment)
|
|
}
|
|
|
|
# Domain-suffixed forms only apply where Windows writes them.
|
|
$domainSuffix = Get-ProfileFolderDomainSuffix
|
|
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
|
# Prefer the local segment as the stem so DOMAIN\user still yields
|
|
# user.CONTOSO rather than the fully qualified string.
|
|
$stem = if (-not [string]::IsNullOrWhiteSpace($localSegment)) { $localSegment } else { $normalized }
|
|
if (-not [string]::IsNullOrWhiteSpace($stem)) {
|
|
$suffixedForm = "$stem.$domainSuffix"
|
|
$alreadyPresent = $false
|
|
foreach ($existing in $candidates) {
|
|
if ($existing -ieq $suffixedForm) { $alreadyPresent = $true; break }
|
|
}
|
|
if (-not $alreadyPresent) {
|
|
[void]$candidates.Add($suffixedForm)
|
|
}
|
|
}
|
|
|
|
# A suffixed input can also be referenced by its bare stem elsewhere
|
|
# (registry, backup metadata), so add that form too.
|
|
$suffixWithDot = ".$domainSuffix"
|
|
if ($normalized.Length -gt $suffixWithDot.Length -and $normalized.EndsWith($suffixWithDot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
$bareStem = Normalize-UserLookupValue -Value ($normalized.Substring(0, $normalized.Length - $suffixWithDot.Length))
|
|
if (-not [string]::IsNullOrWhiteSpace($bareStem)) {
|
|
$alreadyPresent = $false
|
|
foreach ($existing in $candidates) {
|
|
if ($existing -ieq $bareStem) { $alreadyPresent = $true; break }
|
|
}
|
|
if (-not $alreadyPresent) {
|
|
[void]$candidates.Add($bareStem)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $candidates.ToArray() | Select-Object -Unique
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Test whether a user name and a profile folder leaf share an account.
|
|
|
|
.DESCRIPTION
|
|
Compares candidate sets (via Get-UserNameMatchCandidates) instead of
|
|
raw strings, so different forms of the same account still match.
|
|
|
|
.PARAMETER UserName
|
|
User-supplied name to compare.
|
|
|
|
.PARAMETER ProfileLeaf
|
|
On-disk profile folder leaf name to compare.
|
|
|
|
.OUTPUTS
|
|
System.Boolean
|
|
#>
|
|
function Test-UserNameMatchesProfileLeaf {
|
|
param(
|
|
[string]$UserName,
|
|
[string]$ProfileLeaf
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($UserName) -or [string]::IsNullOrWhiteSpace($ProfileLeaf)) {
|
|
return $false
|
|
}
|
|
|
|
$leafCandidates = @(Get-UserNameMatchCandidates -Value $ProfileLeaf)
|
|
$userCandidates = @(Get-UserNameMatchCandidates -Value $UserName)
|
|
|
|
foreach ($leaf in $leafCandidates) {
|
|
foreach ($user in $userCandidates) {
|
|
if ($leaf -ieq $user) {
|
|
return $true
|
|
}
|
|
}
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Test whether two user-name strings refer to the same account.
|
|
|
|
.DESCRIPTION
|
|
Accepts any equivalent candidate form so restore works across
|
|
sessions and join states. Workgroup stays a strict normalized equality
|
|
check to avoid broadening validation where suffixes aren't meaningful.
|
|
|
|
.PARAMETER UserNameA
|
|
First user name to compare.
|
|
|
|
.PARAMETER UserNameB
|
|
Second user name to compare.
|
|
|
|
.OUTPUTS
|
|
System.Boolean
|
|
#>
|
|
function Test-UserNameMatch {
|
|
param(
|
|
[string]$UserNameA,
|
|
[string]$UserNameB
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($UserNameA) -and [string]::IsNullOrWhiteSpace($UserNameB)) {
|
|
return $true
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($UserNameA) -or [string]::IsNullOrWhiteSpace($UserNameB)) {
|
|
return $false
|
|
}
|
|
|
|
# Workgroup: strict equality (no suffix disambiguation available).
|
|
if (-not (Test-MachineIsDomainJoined)) {
|
|
$normalizedA = Normalize-UserLookupValue -Value $UserNameA
|
|
$normalizedB = Normalize-UserLookupValue -Value $UserNameB
|
|
return ($normalizedA -ieq $normalizedB)
|
|
}
|
|
|
|
$candidatesA = @(Get-UserNameMatchCandidates -Value $UserNameA)
|
|
$candidatesB = @(Get-UserNameMatchCandidates -Value $UserNameB)
|
|
|
|
foreach ($a in $candidatesA) {
|
|
foreach ($b in $candidatesB) {
|
|
if ($a -ieq $b) {
|
|
return $true
|
|
}
|
|
}
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Memoize a resolved SID under every equivalent name form.
|
|
|
|
.DESCRIPTION
|
|
Keyed under all equivalent forms so later lookups short-circuit
|
|
regardless of which variant the caller holds. No-op on blank SID.
|
|
|
|
.PARAMETER Candidates
|
|
Equivalent name forms to key the cache entry under.
|
|
|
|
.PARAMETER Sid
|
|
Resolved SID to cache.
|
|
#>
|
|
function Set-ResolvedUserSidCache {
|
|
param(
|
|
[string[]]$Candidates,
|
|
[string]$Sid
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Sid)) {
|
|
return
|
|
}
|
|
|
|
foreach ($candidate in @($Candidates)) {
|
|
$cacheKey = Get-UserLookupCacheKey -Value $candidate
|
|
if ($cacheKey) {
|
|
$script:ResolvedUserSidCache[$cacheKey] = $Sid
|
|
}
|
|
}
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Retrieve a previously cached resolved SID.
|
|
|
|
.DESCRIPTION
|
|
Probes every equivalent form; returns $null on a miss.
|
|
|
|
.PARAMETER Candidates
|
|
Equivalent name forms to probe the cache with.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Get-CachedResolvedUserSid {
|
|
param(
|
|
[string[]]$Candidates
|
|
)
|
|
|
|
foreach ($candidate in @($Candidates)) {
|
|
$cacheKey = Get-UserLookupCacheKey -Value $candidate
|
|
if ($cacheKey -and $script:ResolvedUserSidCache.ContainsKey($cacheKey)) {
|
|
return $script:ResolvedUserSidCache[$cacheKey]
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Attempt SID resolution via NTAccount translation.
|
|
|
|
.DESCRIPTION
|
|
Most authoritative name->SID source; only resolves names reachable
|
|
through standard security APIs. Returns $null on failure.
|
|
|
|
.PARAMETER UserName
|
|
Name to translate.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Try-ResolveSidByNtAccount {
|
|
param(
|
|
[string]$UserName
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($UserName)) {
|
|
return $null
|
|
}
|
|
|
|
try {
|
|
$ntAccount = [System.Security.Principal.NTAccount]::new($UserName)
|
|
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
|
|
if ($sid) {
|
|
return $sid.Value
|
|
}
|
|
}
|
|
catch {
|
|
# Fallback handled by caller.
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Attempt SID resolution against the local account database.
|
|
|
|
.DESCRIPTION
|
|
Prefers Get-LocalUser (typed, fast), falls back to Win32_UserAccount
|
|
CIM for older hosts or unavailable cmdlet. Returns $null if no match.
|
|
|
|
.PARAMETER Candidates
|
|
Equivalent name forms to try.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Try-ResolveSidByLocalLookup {
|
|
param(
|
|
[string[]]$Candidates
|
|
)
|
|
|
|
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
|
|
if ($lookupCandidates.Count -eq 0) {
|
|
return $null
|
|
}
|
|
|
|
if (Get-Command -Name Get-LocalUser -ErrorAction SilentlyContinue) {
|
|
foreach ($candidate in $lookupCandidates) {
|
|
try {
|
|
$matchingLocalUser = Get-LocalUser -Name $candidate -ErrorAction Stop | Select-Object -First 1
|
|
if ($matchingLocalUser -and $matchingLocalUser.SID) {
|
|
return $matchingLocalUser.SID.Value
|
|
}
|
|
}
|
|
catch {
|
|
# Continue to next lookup strategy.
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($candidate in $lookupCandidates) {
|
|
try {
|
|
$escapedCandidate = Escape-WqlString -Value $candidate
|
|
$escapedComputerName = Escape-WqlString -Value $env:COMPUTERNAME
|
|
$filter = "LocalAccount=True AND (Name='$escapedCandidate' OR FullName='$escapedCandidate' OR Caption='$escapedComputerName\$escapedCandidate')"
|
|
$matchingAccount = Get-CimInstance -ClassName Win32_UserAccount -Filter $filter -ErrorAction Stop | Select-Object -First 1
|
|
|
|
if ($matchingAccount -and $matchingAccount.SID) {
|
|
return $matchingAccount.SID
|
|
}
|
|
}
|
|
catch {
|
|
# Continue to next lookup strategy.
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Recover a SID from the ProfileList registry hive.
|
|
|
|
.DESCRIPTION
|
|
Last-resort heuristic: matches the profile folder leaf to recover a
|
|
SID when name-resolution APIs fail. Returns $null on failure.
|
|
|
|
.PARAMETER Candidates
|
|
Equivalent name forms to match against profile folder leafs.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Try-ResolveSidFromProfileList {
|
|
param(
|
|
[string[]]$Candidates
|
|
)
|
|
|
|
$lookupCandidates = Get-NormalizedLookupCandidates -Candidates $Candidates
|
|
if ($lookupCandidates.Count -eq 0) {
|
|
return $null
|
|
}
|
|
|
|
try {
|
|
$profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
|
|
foreach ($sidKey in @(Get-ChildItem -LiteralPath $profileListPath -ErrorAction Stop)) {
|
|
try {
|
|
$imagePath = Get-ItemPropertyValue -LiteralPath $sidKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
|
|
if ([string]::IsNullOrWhiteSpace($imagePath)) { continue }
|
|
|
|
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($imagePath)
|
|
$leafName = Normalize-UserLookupValue -Value (Split-Path -Leaf $expandedPath)
|
|
|
|
foreach ($candidate in $lookupCandidates) {
|
|
if (Test-MachineIsDomainJoined) {
|
|
if (Test-UserNameMatchesProfileLeaf -UserName $candidate -ProfileLeaf $leafName) {
|
|
return $sidKey.PSChildName
|
|
}
|
|
}
|
|
elseif ($leafName -ieq $candidate) {
|
|
return $sidKey.PSChildName
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
# Fallback handled by caller.
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Construct a resolved user context object.
|
|
|
|
.DESCRIPTION
|
|
Bundles UserName/UserSid/ProfilePath so callers don't re-derive or
|
|
thread three loose values.
|
|
|
|
.PARAMETER UserName
|
|
Normalized user name.
|
|
|
|
.PARAMETER UserSid
|
|
Resolved SID, if available.
|
|
|
|
.PARAMETER ProfilePath
|
|
Resolved profile folder path.
|
|
|
|
.OUTPUTS
|
|
System.Management.Automation.PSCustomObject
|
|
#>
|
|
function New-ResolvedUserContext {
|
|
param(
|
|
[string]$UserName,
|
|
[string]$UserSid,
|
|
[string]$ProfilePath
|
|
)
|
|
|
|
return [PSCustomObject]@{
|
|
UserName = $UserName
|
|
UserSid = $UserSid
|
|
ProfilePath = $ProfilePath
|
|
}
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Return the qualified (DOMAIN\user) name of the current process when it matches the input.
|
|
|
|
.DESCRIPTION
|
|
Used to qualify a bare name on domain-joined boxes; returns $null when
|
|
the input doesn't match the current process identity.
|
|
|
|
.PARAMETER Candidate
|
|
Bare user name to compare against the current process identity.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Get-QualifiedProcessIdentityName {
|
|
param(
|
|
[string]$Candidate
|
|
)
|
|
|
|
$normalizedCandidate = Normalize-UserLookupValue -Value $Candidate
|
|
if ([string]::IsNullOrWhiteSpace($normalizedCandidate)) {
|
|
return $null
|
|
}
|
|
|
|
try {
|
|
$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
if ($null -eq $currentIdentity) {
|
|
return $null
|
|
}
|
|
|
|
# Skip service/SYSTEM identities (no user profile).
|
|
$currentSidString = [string]$currentIdentity.User.Value
|
|
if ($currentSidString -in @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')) {
|
|
return $null
|
|
}
|
|
|
|
$currentName = [string]$currentIdentity.Name
|
|
if ([string]::IsNullOrWhiteSpace($currentName)) {
|
|
return $null
|
|
}
|
|
|
|
$currentLocalSegment = Get-LocalUserNameSegment -UserName $currentName
|
|
if (-not [string]::IsNullOrWhiteSpace($currentLocalSegment) -and $currentLocalSegment -ieq $normalizedCandidate) {
|
|
return $currentName
|
|
}
|
|
}
|
|
catch {
|
|
# Fall through to name-based resolution.
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Resolve a user name to its SID.
|
|
|
|
.DESCRIPTION
|
|
Always qualifies the input first (DOMAIN\user) and resolves that form;
|
|
never guesses from a bare name on domain-joined boxes to avoid
|
|
same-named local SAM shadowing.
|
|
|
|
.PARAMETER UserName
|
|
User name to resolve. May be bare, DOMAIN\user, or user@domain.
|
|
|
|
.OUTPUTS
|
|
System.String
|
|
#>
|
|
function Resolve-UserSid {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string]$UserName
|
|
)
|
|
|
|
$candidateUserName = Normalize-UserLookupValue -Value $UserName
|
|
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
|
return $null
|
|
}
|
|
|
|
$hasQualifiedIdentity = $candidateUserName.Contains('\') -or $candidateUserName.Contains('@')
|
|
$localNameSegment = Get-LocalUserNameSegment -UserName $candidateUserName
|
|
$leafNameCandidates = @()
|
|
if ($hasQualifiedIdentity -and -not [string]::IsNullOrWhiteSpace($localNameSegment) -and $localNameSegment -ine $candidateUserName) {
|
|
$leafNameCandidates = @($localNameSegment)
|
|
}
|
|
|
|
# Unqualified inputs probe both the bare name and (for qualified inputs) the
|
|
# local leaf segment; qualified inputs pin to the caller's full form only.
|
|
$lookupCandidates = if ($hasQualifiedIdentity) {
|
|
@($candidateUserName)
|
|
}
|
|
else {
|
|
@($candidateUserName) + $leafNameCandidates | Select-Object -Unique
|
|
}
|
|
|
|
$profileHeuristicCandidates = if ($leafNameCandidates.Count -gt 0) {
|
|
$leafNameCandidates
|
|
}
|
|
else {
|
|
@($candidateUserName)
|
|
}
|
|
|
|
$cachedSid = Get-CachedResolvedUserSid -Candidates $lookupCandidates
|
|
if ($cachedSid) {
|
|
return $cachedSid
|
|
}
|
|
|
|
# Step 1: derive the qualified form(s) to resolve; never guess from a bare
|
|
# name on domain-joined boxes (local SAM nameshare risk).
|
|
$qualifiedNamesToTry = New-Object 'System.Collections.Generic.List[string]'
|
|
|
|
if ($hasQualifiedIdentity) {
|
|
# Caller already qualified; honor verbatim.
|
|
[void]$qualifiedNamesToTry.Add($candidateUserName)
|
|
}
|
|
elseif (Test-MachineIsDomainJoined) {
|
|
# Prefer process identity (authoritative), then USERDOMAIN\input.
|
|
$processQualifiedName = Get-QualifiedProcessIdentityName -Candidate $candidateUserName
|
|
if (-not [string]::IsNullOrWhiteSpace($processQualifiedName)) {
|
|
[void]$qualifiedNamesToTry.Add($processQualifiedName)
|
|
}
|
|
|
|
$domainSuffix = Get-ProfileFolderDomainSuffix
|
|
if (-not [string]::IsNullOrWhiteSpace($domainSuffix)) {
|
|
$domainQualifiedName = "$domainSuffix\$candidateUserName"
|
|
if (-not ($qualifiedNamesToTry -contains $domainQualifiedName)) {
|
|
[void]$qualifiedNamesToTry.Add($domainQualifiedName)
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
# Workgroup: bare name is unambiguous.
|
|
[void]$qualifiedNamesToTry.Add($candidateUserName)
|
|
}
|
|
|
|
# Step 2: resolve qualified form(s) via NTAccount.Translate.
|
|
foreach ($qualifiedName in $qualifiedNamesToTry) {
|
|
$resolvedSid = Try-ResolveSidByNtAccount -UserName $qualifiedName
|
|
if ($resolvedSid) {
|
|
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
|
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
|
return $resolvedSid
|
|
}
|
|
}
|
|
|
|
# Step 3: local SAM fallback (workgroup only; skipped on domain to avoid
|
|
# nameshare shadowing).
|
|
if (-not (Test-MachineIsDomainJoined)) {
|
|
$resolvedSid = Try-ResolveSidByLocalLookup -Candidates $lookupCandidates
|
|
if ($resolvedSid) {
|
|
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
|
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
|
return $resolvedSid
|
|
}
|
|
}
|
|
|
|
# Step 4: ProfileList leaf heuristic (last resort; disambiguates by
|
|
# on-disk folder name, suffix-aware on domain boxes).
|
|
$resolvedSid = Try-ResolveSidFromProfileList -Candidates $profileHeuristicCandidates
|
|
if ($resolvedSid) {
|
|
$allCacheKeys = @($candidateUserName) + $qualifiedNamesToTry | Select-Object -Unique
|
|
Set-ResolvedUserSidCache -Candidates $allCacheKeys -Sid $resolvedSid
|
|
return $resolvedSid
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Resolve a user name to a full profile context (name, SID, path).
|
|
|
|
.DESCRIPTION
|
|
SID-keyed registry data first (authoritative), then on-disk path
|
|
probing so resolution still succeeds when the account or SID can't be
|
|
looked up (deleted account, restricted context). Returns $null if not
|
|
found.
|
|
|
|
.PARAMETER UserName
|
|
User name whose profile context is required.
|
|
|
|
.OUTPUTS
|
|
System.Management.Automation.PSCustomObject
|
|
#>
|
|
function Resolve-UserProfileContext {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string]$UserName
|
|
)
|
|
|
|
$candidateUserName = Normalize-UserLookupValue -Value $UserName
|
|
if ([string]::IsNullOrWhiteSpace($candidateUserName)) {
|
|
return $null
|
|
}
|
|
|
|
$rootPaths = @(
|
|
(Join-Path $env:SystemDrive 'Users')
|
|
(Split-Path -Path $env:USERPROFILE -Parent)
|
|
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
|
|
|
if ($candidateUserName -ieq 'Default') {
|
|
foreach ($rootPath in $rootPaths) {
|
|
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
|
|
continue
|
|
}
|
|
|
|
$defaultProfilePath = Join-Path $rootPath 'Default'
|
|
if (Test-Path -LiteralPath $defaultProfilePath -PathType Container) {
|
|
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $null -ProfilePath $defaultProfilePath)
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
$userSid = Resolve-UserSid -UserName $candidateUserName
|
|
|
|
if ($userSid) {
|
|
$sidRegistryPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$userSid"
|
|
try {
|
|
if (Test-Path -LiteralPath $sidRegistryPath) {
|
|
$registryImagePath = Get-ItemPropertyValue -LiteralPath $sidRegistryPath -Name 'ProfileImagePath' -ErrorAction Stop
|
|
if (-not [string]::IsNullOrWhiteSpace($registryImagePath)) {
|
|
$expandedPath = [System.Environment]::ExpandEnvironmentVariables($registryImagePath)
|
|
if (Test-Path -LiteralPath $expandedPath -PathType Container) {
|
|
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $expandedPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
# Try Win32_UserProfile fallback.
|
|
}
|
|
|
|
try {
|
|
$matchingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$userSid'" -ErrorAction Stop)
|
|
$resolvedProfile = $matchingProfiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_.LocalPath) } | Select-Object -First 1
|
|
if ($resolvedProfile -and (Test-Path -LiteralPath $resolvedProfile.LocalPath -PathType Container)) {
|
|
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $resolvedProfile.LocalPath)
|
|
}
|
|
}
|
|
catch {
|
|
# Fall through to legacy path probing.
|
|
}
|
|
}
|
|
|
|
foreach ($rootPath in $rootPaths) {
|
|
if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) {
|
|
continue
|
|
}
|
|
|
|
# Exact leaf match first (common case; avoids an unnecessary scan).
|
|
$candidateUserPath = Join-Path $rootPath $candidateUserName
|
|
if (Test-Path -LiteralPath $candidateUserPath -PathType Container) {
|
|
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $candidateUserPath)
|
|
}
|
|
|
|
# Only domain-joined boxes write suffixed folders; scanning workgroup
|
|
# roots would risk matching the wrong account.
|
|
if (Test-MachineIsDomainJoined) {
|
|
try {
|
|
foreach ($child in @(Get-ChildItem -LiteralPath $rootPath -Directory -ErrorAction SilentlyContinue)) {
|
|
if (Test-UserNameMatchesProfileLeaf -UserName $candidateUserName -ProfileLeaf $child.Name) {
|
|
return (New-ResolvedUserContext -UserName $candidateUserName -UserSid $userSid -ProfilePath $child.FullName)
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
# Fall through to the next root path.
|
|
}
|
|
}
|
|
}
|
|
|
|
return $null
|
|
} |