Public/Workstation_Fixes/Repair-VBUserShellFolderRedirects.ps1

# ============================================================
# FUNCTION : Repair-VBUserShellFolderRedirects
# MODULE : VB.WorkstationReport
# VERSION : 2.0.1
# CHANGED : 20-05-2026 -- v2.0.1: Null guard added after Get-ItemProperty on Shell Folders
# and User Shell Folders -- prevents null-reference throw when
# a key exists but the read returns nothing (ACL/locked hive)
# v2.0.0: Full rewrite: canonical folder mapping tables, broader fix
# scope, -Username filter, -Force mode, per-entry error objects,
# ShouldProcess on registry write and folder creation,
# O(1) reverse lookup, unknown key warnings
# AUTHOR : Vibhu Bhatnagar
# PURPOSE : Repairs broken Shell Folder registry paths for all loaded user hives
# ENCODING : UTF-8 with BOM
# ============================================================
function Repair-VBUserShellFolderRedirects {
    <#
    .SYNOPSIS
        Repairs broken Shell Folder registry paths for all loaded user hives.
 
    .DESCRIPTION
        Repair-VBUserShellFolderRedirects enumerates all standard user hives
        (S-1-5-21-*) currently loaded in HKEY_USERS and checks both Shell Folders
        and User Shell Folders registry keys for broken path values.
 
        Without -Force, fixes only paths that are unambiguously broken:
          -- UNC paths (\\server\...)
          -- Paths under the Windows directory
          -- Paths under ProgramData
          -- Absolute paths outside the user's own profile path
 
        With -Force, resets every key to its canonical Windows default, regardless
        of the current value. Use this when a profile has been partially corrupted
        or intentionally redirected and needs a full reset to local defaults.
 
        Shell Folders values are written as expanded local paths:
          e.g. C:\Users\jsmith\Documents
 
        User Shell Folders values are written as unexpanded env-var paths:
          e.g. %USERPROFILE%\Documents
 
        Registry keys not present in the known folder mapping table are skipped
        with a warning rather than receiving a guessed path.
 
        Supports -WhatIf to preview all changes without touching the registry.
        Call Get-VBUserProfile | Mount-VBUserHive first to load offline profiles.
 
    .PARAMETER Username
        One or more usernames to target. Accepts plain username or DOMAIN\username.
        If omitted, all loaded standard user hives are processed.
 
    .PARAMETER Force
        Resets all Shell Folder keys to canonical Windows defaults, not just
        those that are clearly broken. Does not affect -WhatIf behaviour.
 
    .EXAMPLE
        Repair-VBUserShellFolderRedirects -WhatIf
        Previews all broken paths that would be fixed without making any changes.
 
    .EXAMPLE
        Repair-VBUserShellFolderRedirects | Where-Object Changed
        Runs the repair and returns only entries where a change was made.
 
    .EXAMPLE
        Repair-VBUserShellFolderRedirects -Username 'jsmith' -Force -WhatIf
        Previews a full reset to Windows defaults for user jsmith only.
 
    .EXAMPLE
        Get-VBUserProfile | Mount-VBUserHive
        Repair-VBUserShellFolderRedirects | Export-Csv C:\Realtime\repairs.csv -NoTypeInformation -Encoding UTF8
        Loads all offline profiles first, runs the repair, and exports the change log.
 
    .OUTPUTS
        PSCustomObject
        Returns one object per matched registry value with:
          - ComputerName : Local computer name
          - Status : 'Success' or 'Failed'
          - SID : User SID
          - Username : Resolved username
          - RegistryKey : 'Shell Folders' or 'User Shell Folders'
          - PropertyName : Registry value name (e.g. 'Personal', 'Desktop')
          - FolderName : Human-readable folder name (e.g. 'Documents', 'Desktop')
          - OldValue : Value before repair
          - NewValue : Value after repair (or intended value on -WhatIf)
          - FolderCreated : True if the local folder did not exist and was created
          - Changed : True if the registry value was written
          - Error : Error message ($null on success)
          - CollectionTime : Timestamp of data collection
 
    .NOTES
        Version : 2.0.1
        Author : Vibhu Bhatnagar
        Category : Workstation Fixes
        Requirements :
          - PowerShell 5.1 or higher
          - Must run as Administrator (registry write access to HKEY_USERS)
          - Target user hives must be loaded in HKEY_USERS
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string[]]$Username,

        [Parameter()]
        [switch]$Force
    )

    begin {
        # Maps human-readable folder name -> registry key name used in Shell Folders
        $folderMappings = @{
            'Desktop'        = 'Desktop'
            'Documents'      = 'Personal'
            'Pictures'       = 'My Pictures'
            'Music'          = 'My Music'
            'Videos'         = 'My Video'
            'Favorites'      = 'Favorites'
            'Downloads'      = '{374DE290-123F-4565-9164-39C4925E467B}'
            'Start Menu'     = 'Start Menu'
            'Programs'       = 'Programs'
            'Startup'        = 'Startup'
            'Recent'         = 'Recent'
            'SendTo'         = 'SendTo'
            'Templates'      = 'Templates'
            'NetHood'        = 'NetHood'
            'PrintHood'      = 'PrintHood'
            'History'        = 'History'
            'Cookies'        = 'Cookies'
            'Cache'          = 'Cache'
            'AppData'        = 'AppData'
            'Local AppData'  = 'Local AppData'
            'Contacts'       = '{56784854-C6CB-462B-8169-88E350ACB882}'
            'Links'          = '{BFB9D5E0-C6A9-404C-B2B2-AE6DB6AF4968}'
            'Saved Games'    = '{4C5C32FF-BB9D-43B0-B5B4-2D72E54EAAA4}'
            'Searches'       = '{7D1D3A04-DEBB-4115-95CF-2F29DA2920DA}'
        }

        # Maps human-readable folder name -> relative path under the user profile
        $defaultPaths = @{
            'Desktop'        = 'Desktop'
            'Documents'      = 'Documents'
            'Pictures'       = 'Pictures'
            'Music'          = 'Music'
            'Videos'         = 'Videos'
            'Favorites'      = 'Favorites'
            'Downloads'      = 'Downloads'
            'Start Menu'     = 'AppData\Roaming\Microsoft\Windows\Start Menu'
            'Programs'       = 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs'
            'Startup'        = 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
            'Recent'         = 'AppData\Roaming\Microsoft\Windows\Recent'
            'SendTo'         = 'AppData\Roaming\Microsoft\Windows\SendTo'
            'Templates'      = 'AppData\Roaming\Microsoft\Windows\Templates'
            'NetHood'        = 'AppData\Roaming\Microsoft\Windows\Network Shortcuts'
            'PrintHood'      = 'AppData\Roaming\Microsoft\Windows\Printer Shortcuts'
            'History'        = 'AppData\Local\Microsoft\Windows\History'
            'Cookies'        = 'AppData\Local\Microsoft\Windows\INetCookies'
            'Cache'          = 'AppData\Local\Microsoft\Windows\INetCache'
            'AppData'        = 'AppData\Roaming'
            'Local AppData'  = 'AppData\Local'
            'Contacts'       = 'Contacts'
            'Links'          = 'Links'
            'Saved Games'    = 'Saved Games'
            'Searches'       = 'Searches'
        }

        # Build reverse lookup O(1): registry key name -> human-readable folder name
        $keyToFolder = @{}
        foreach ($entry in $folderMappings.GetEnumerator()) {
            $keyToFolder[$entry.Value] = $entry.Key
        }

        # PS* properties present on every Get-ItemProperty result -- always excluded
        $psProps = @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')

        # Normalise Username filter: strip domain prefix so both 'jsmith' and 'DOMAIN\jsmith' match
        $normalizedFilter = @()
        if ($Username) {
            $normalizedFilter = $Username | ForEach-Object {
                if ($_ -match '\\') { ($_ -split '\\')[1] } else { $_ }
            }
        }
    }

    process {
        $collectionTime = (Get-Date).ToString('dd-MM-yyyy HH:mm:ss')
        $found          = $false

        try {
            $userHives = Get-ChildItem -Path 'Registry::HKEY_USERS' |
                         Where-Object { $_.Name -match 'S-1-5-21-\d+-\d+-\d+-\d+$' }

            foreach ($hive in $userHives) {
                $sid = $hive.PSChildName

                # Step 1 -- Resolve profile path from ProfileList
                $profilePath = $null
                try {
                    $profilePath = (Get-ItemProperty `
                        -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$sid" `
                        -ErrorAction Stop).ProfileImagePath
                }
                catch {
                    Write-Warning "[$sid] Could not read ProfileList entry -- skipping"
                    continue
                }

                if (-not $profilePath) {
                    Write-Warning "[$sid] ProfileImagePath is empty -- skipping"
                    continue
                }

                if (-not (Test-Path $profilePath)) {
                    Write-Warning "[$sid] Profile path '$profilePath' does not exist on disk -- skipping"
                    continue
                }

                # Step 2 -- Resolve username (SID translation, fall back to profile folder leaf)
                $resolvedUsername = $null
                try {
                    $ntAccount       = (New-Object System.Security.Principal.SecurityIdentifier($sid)).Translate([System.Security.Principal.NTAccount]).Value
                    $resolvedUsername = if ($ntAccount -match '\\') { ($ntAccount -split '\\')[1] } else { $ntAccount }
                }
                catch {
                    $resolvedUsername = Split-Path -Path $profilePath -Leaf
                }

                # Step 3 -- Apply Username filter
                if ($normalizedFilter.Count -gt 0 -and $resolvedUsername -notin $normalizedFilter) {
                    continue
                }

                $shellFoldersPath     = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
                $userShellFoldersPath = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders"

                # Step 4 -- Process Shell Folders (expanded local paths)
                Write-Verbose "[$resolvedUsername] Checking Shell Folders"

                if (Test-Path $shellFoldersPath) {
                    $shellFolders = Get-ItemProperty -Path $shellFoldersPath -ErrorAction SilentlyContinue

                    if ($shellFolders) {
                    foreach ($property in ($shellFolders.PSObject.Properties | Where-Object { $_.Name -notin $psProps })) {
                        $currentValue = $property.Value

                        # Reverse lookup -- skip unknown keys with a warning
                        $folderDisplayName = $keyToFolder[$property.Name]
                        if (-not $folderDisplayName) {
                            Write-Warning "[$resolvedUsername] Shell Folders: Unknown key '$($property.Name)' -- skipping"
                            continue
                        }

                        $expectedPath = Join-Path $profilePath $defaultPaths[$folderDisplayName]

                        # Determine whether this entry needs fixing
                        $needsFix = if ($Force) {
                            $currentValue -ne $expectedPath
                        }
                        else {
                            $currentValue -like '\\*' -or
                            $currentValue -like "$env:SystemRoot\*" -or
                            $currentValue -like "$env:ProgramData\*" -or
                            ($currentValue -match '^[A-Za-z]:\\' -and $currentValue -notlike "$profilePath\*")
                        }

                        if (-not $needsFix) { continue }

                        $found = $true

                        try {
                            $folderCreated = $false
                            $changesMade   = $false

                            if ($PSCmdlet.ShouldProcess(
                                    "$shellFoldersPath [$($property.Name)]",
                                    "Replace '$currentValue' with '$expectedPath'")) {

                                if (-not (Test-Path $expectedPath)) {
                                    New-Item -Path $expectedPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
                                    $folderCreated = $true
                                    Write-Verbose "[$resolvedUsername] Created folder: $expectedPath"
                                }

                                Set-ItemProperty -Path $shellFoldersPath -Name $property.Name -Value $expectedPath -ErrorAction Stop
                                $changesMade = $true
                                Write-Verbose "[$resolvedUsername] Shell Folders: $($property.Name) -> $expectedPath"
                            }

                            [PSCustomObject]@{
                                ComputerName   = $env:COMPUTERNAME
                                Status         = 'Success'
                                SID            = $sid
                                Username       = $resolvedUsername
                                RegistryKey    = 'Shell Folders'
                                PropertyName   = $property.Name
                                FolderName     = $folderDisplayName
                                OldValue       = $currentValue
                                NewValue       = $expectedPath
                                FolderCreated  = $folderCreated
                                Changed        = $changesMade
                                Error          = $null
                                CollectionTime = $collectionTime
                            }
                        }
                        catch {
                            [PSCustomObject]@{
                                ComputerName   = $env:COMPUTERNAME
                                Status         = 'Failed'
                                SID            = $sid
                                Username       = $resolvedUsername
                                RegistryKey    = 'Shell Folders'
                                PropertyName   = $property.Name
                                FolderName     = $folderDisplayName
                                OldValue       = $currentValue
                                NewValue       = $expectedPath
                                FolderCreated  = $false
                                Changed        = $false
                                Error          = $_.Exception.Message
                                CollectionTime = $collectionTime
                            }
                        }
                    }
                    } # end if ($shellFolders)
                }

                # Step 5 -- Process User Shell Folders (unexpanded env-var paths)
                Write-Verbose "[$resolvedUsername] Checking User Shell Folders"

                if (Test-Path $userShellFoldersPath) {
                    $userShellFolders = Get-ItemProperty -Path $userShellFoldersPath -ErrorAction SilentlyContinue

                    if ($userShellFolders) {
                    foreach ($property in ($userShellFolders.PSObject.Properties | Where-Object { $_.Name -notin $psProps })) {
                        $currentValue = $property.Value

                        # Reverse lookup -- skip unknown keys with a warning
                        $folderDisplayName = $keyToFolder[$property.Name]
                        if (-not $folderDisplayName) {
                            Write-Warning "[$resolvedUsername] User Shell Folders: Unknown key '$($property.Name)' -- skipping"
                            continue
                        }

                        $expectedPath    = "%USERPROFILE%\$($defaultPaths[$folderDisplayName])"
                        $expandedForDisk = Join-Path $profilePath $defaultPaths[$folderDisplayName]

                        # Determine whether this entry needs fixing
                        $needsFix = if ($Force) {
                            $currentValue -ne $expectedPath
                        }
                        else {
                            $currentValue -like '\\*' -or
                            $currentValue -like "$env:SystemRoot\*" -or
                            $currentValue -like "$env:ProgramData\*" -or
                            ($currentValue -match '^[A-Za-z]:\\' -and $currentValue -notlike "$profilePath\*")
                        }

                        if (-not $needsFix) { continue }

                        $found = $true

                        try {
                            $folderCreated = $false
                            $changesMade   = $false

                            if ($PSCmdlet.ShouldProcess(
                                    "$userShellFoldersPath [$($property.Name)]",
                                    "Replace '$currentValue' with '$expectedPath'")) {

                                if (-not (Test-Path $expandedForDisk)) {
                                    New-Item -Path $expandedForDisk -ItemType Directory -Force -ErrorAction Stop | Out-Null
                                    $folderCreated = $true
                                    Write-Verbose "[$resolvedUsername] Created folder: $expandedForDisk"
                                }

                                Set-ItemProperty -Path $userShellFoldersPath -Name $property.Name -Value $expectedPath -ErrorAction Stop
                                $changesMade = $true
                                Write-Verbose "[$resolvedUsername] User Shell Folders: $($property.Name) -> $expectedPath"
                            }

                            [PSCustomObject]@{
                                ComputerName   = $env:COMPUTERNAME
                                Status         = 'Success'
                                SID            = $sid
                                Username       = $resolvedUsername
                                RegistryKey    = 'User Shell Folders'
                                PropertyName   = $property.Name
                                FolderName     = $folderDisplayName
                                OldValue       = $currentValue
                                NewValue       = $expectedPath
                                FolderCreated  = $folderCreated
                                Changed        = $changesMade
                                Error          = $null
                                CollectionTime = $collectionTime
                            }
                        }
                        catch {
                            [PSCustomObject]@{
                                ComputerName   = $env:COMPUTERNAME
                                Status         = 'Failed'
                                SID            = $sid
                                Username       = $resolvedUsername
                                RegistryKey    = 'User Shell Folders'
                                PropertyName   = $property.Name
                                FolderName     = $folderDisplayName
                                OldValue       = $currentValue
                                NewValue       = $expectedPath
                                FolderCreated  = $false
                                Changed        = $false
                                Error          = $_.Exception.Message
                                CollectionTime = $collectionTime
                            }
                        }
                    }
                    } # end if ($userShellFolders)
                }
            }

            if (-not $found) {
                [PSCustomObject]@{
                    ComputerName   = $env:COMPUTERNAME
                    Status         = 'Success'
                    SID            = 'N/A'
                    Username       = 'N/A'
                    RegistryKey    = 'N/A'
                    PropertyName   = 'N/A'
                    FolderName     = 'N/A'
                    OldValue       = 'N/A'
                    NewValue       = 'N/A'
                    FolderCreated  = $false
                    Changed        = $false
                    Error          = $null
                    CollectionTime = $collectionTime
                }
            }
        }
        catch {
            [PSCustomObject]@{
                ComputerName   = $env:COMPUTERNAME
                Status         = 'Failed'
                SID            = 'N/A'
                Username       = 'N/A'
                RegistryKey    = 'N/A'
                PropertyName   = 'N/A'
                FolderName     = 'N/A'
                OldValue       = 'N/A'
                NewValue       = 'N/A'
                FolderCreated  = $false
                Changed        = $false
                Error          = $_.Exception.Message
                CollectionTime = $collectionTime
            }
        }
    }
}