Public/Get-VBOneDriveFolderBackupStatus.ps1

# ============================================================
# FUNCTION : Get-VBOneDriveFolderBackupStatus
# MODULE : VB.WorkstationReport
# VERSION : 1.1.1
# CHANGED : 16-04-2026 -- Fix hashtable Int64 type coercion; fix Invoke-Command credential
# and ErrorAction; move scriptblock to begin block
# 16-04-2026 -- Fix OneDrive type detection: folder name check is now primary
# signal; custom-domain Business accounts (e.g. @itbd.net) now
# correctly identified as Business
# 16-04-2026 -- Rename Get-VBOneDriveKFMStatus -> Get-VBOneDriveFolderBackupStatus
# 16-04-2026 -- Fix ghost registry stubs (no email, generic/unexpanded folder path)
# misclassified as Personal; now correctly reported as Not Configured
# 16-04-2026 -- Fix SID regex: added S-1-12-1-* pattern for Azure AD / Entra ID
# joined devices (S-1-5-21-* only covers traditional domain/local accounts)
# 16-04-2026 -- Replaced HKEY_USERS enumeration with Win32_UserProfile; embedded
# hive mount/unmount logic (from Mount-VBUserHive) so all user profiles
# are scanned regardless of whether their hive is currently loaded
# AUTHOR : Vibhu Bhatnagar
# PURPOSE : Reports OneDrive folder backup (KFM) status for all user profiles
# ENCODING : UTF-8 with BOM
# ============================================================
function Get-VBOneDriveFolderBackupStatus {
    <#
    .SYNOPSIS
        Reports OneDrive folder backup status (Known Folder Move) for all user profiles
        on local or remote computers.
 
    .DESCRIPTION
        Get-VBOneDriveFolderBackupStatus enumerates all user profiles via Win32_UserProfile,
        mounts any hives that are not currently loaded in HKEY_USERS (using reg.exe load),
        reads OneDrive Known Folder Move (KFM) registry data for each user, then unloads
        any hives it mounted. This ensures all profiles are scanned regardless of whether
        the user is currently logged in.
 
        KFM is the OneDrive feature that automatically backs up Desktop, Documents, and
        Pictures to the cloud. For each user the function reports the OneDrive account type
        (Business, Personal, or Not Configured), email address, local sync folder, and a
        per-folder breakdown of KFM migration state and scan eligibility.
 
        Supported account types:
          - S-1-5-21-* : Traditional domain and local accounts
          - S-1-12-1-* : Azure AD / Entra ID joined devices
 
        Hive mounting requires local admin rights on the target computer.
        The same scriptblock runs locally or via Invoke-Command without code duplication.
 
    .PARAMETER ComputerName
        Computer names to query. Accepts pipeline input. Defaults to the local computer.
 
    .PARAMETER Credential
        Credentials for remote computer access. Not required for local execution.
 
    .EXAMPLE
        Get-VBOneDriveFolderBackupStatus
        Returns backup status for all OneDrive users on the local computer, including
        profiles whose hives are not currently loaded.
 
    .EXAMPLE
        Get-VBOneDriveFolderBackupStatus -ComputerName 'WS001','WS002' -Credential (Get-Credential)
        Queries two remote workstations using alternate credentials.
 
    .EXAMPLE
        'WS001','WS002' | Get-VBOneDriveFolderBackupStatus -Credential $cred |
            Where-Object { $_.KFMStatus -ne '3 of 3 folders backed up' }
        Finds workstations where folder backup is not fully configured.
 
    .EXAMPLE
        Get-VBOneDriveFolderBackupStatus | Select-Object UserName, OneDriveType, KFMStatus, KFMFolders
        Returns a summary with per-folder detail for all local users.
 
    .OUTPUTS
        PSCustomObject
        Returns one object per OneDrive user with:
          - ComputerName : Target computer
          - UserName : Windows username
          - SID : User SID
          - OneDriveType : 'Business', 'Personal', 'Not Configured', or 'Unknown'
          - UserEmail : OneDrive account email address
          - UserFolder : Local OneDrive sync folder path
          - KFMStatus : Summary string, e.g. '3 of 3 folders backed up'
          - KFMFolders : Array of per-folder objects (Folder, Status, ScanStatus, AccountType)
          - CollectionTime: Timestamp of data collection
          - Status : 'Success' or 'Failed'
          - Error : Error message (only present on failure)
 
    .NOTES
        Version : 1.1.1
        Author : Vibhu Bhatnagar
        Category : User Profile Management
        Requirements :
          - PowerShell 5.1 or higher
          - Local admin rights on the target computer (required for hive mounting)
          - PowerShell Remoting enabled for remote targets
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Name', 'Server')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [PSCredential]$Credential
    )

    begin {
        $stateLookup = @{
            0 = 'Not attempted'
            1 = 'Opted out / Not applicable'
            2 = 'Failed'
            3 = 'Succeeded but disabled later'
            4 = 'Already redirected'
            5 = 'Backed up to OneDrive'
        }

        $scanLookup = @{
            0 = 'Not scanned'
            1 = 'Eligible'
            2 = 'Scan failed'
        }

        # Scriptblock defined once in begin.
        # Hive mount/unmount logic is embedded (not a call to Mount-VBUserHive) so it
        # serialises cleanly over Invoke-Command without requiring the function to exist
        # on the remote machine.
        $scriptBlock = {
            param($stateLookup, $scanLookup)

            $computerName   = $env:COMPUTERNAME
            $collectionTime = (Get-Date).ToString('dd-MM-yyyy HH:mm:ss')
            $results        = [System.Collections.Generic.List[object]]::new()
            $mountedHives   = [System.Collections.Generic.List[string]]::new()

            try {
                # ── 1. Enumerate all non-special user profiles via WMI ─────────────
                # This replaces HKEY_USERS enumeration -- Win32_UserProfile returns every
                # profile regardless of whether its hive is currently loaded, and it
                # handles S-1-5-21-* (domain/local) and S-1-12-1-* (Entra ID) alike.
                try {
                    $allProfiles = Get-CimInstance -ClassName Win32_UserProfile `
                                       -Filter "Special = 'False'" `
                                       -ErrorAction Stop
                }
                catch {
                    $results.Add([PSCustomObject]@{
                        ComputerName   = $computerName
                        Error          = "Failed to enumerate user profiles: $($_.Exception.Message)"
                        Status         = 'Failed'
                        CollectionTime = $collectionTime
                    })
                    return $results
                }

                if (-not $allProfiles) {
                    $results.Add([PSCustomObject]@{
                        ComputerName   = $computerName
                        UserName       = 'No Users Found'
                        KFMStatus      = 'No user profiles detected'
                        Status         = 'Success'
                        CollectionTime = $collectionTime
                    })
                    return $results
                }

                # ── 2. Identify which hives are already loaded ─────────────────────
                $loadedSIDs = Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue |
                              Select-Object -ExpandProperty PSChildName

                # ── 3. Mount any unloaded hives ────────────────────────────────────
                # Requires local admin. Failures are non-fatal -- that profile is simply
                # skipped when its OneDrive path is checked later.
                foreach ($profile in $allProfiles) {
                    if ($loadedSIDs -notcontains $profile.SID) {
                        $ntuserPath = Join-Path $profile.LocalPath 'NTUSER.DAT'
                        if (Test-Path $ntuserPath -ErrorAction SilentlyContinue) {
                            $regResult = reg.exe load "HKU\$($profile.SID)" "$ntuserPath" 2>&1
                            if ($LASTEXITCODE -eq 0) {
                                $mountedHives.Add($profile.SID)
                                Write-Verbose "Mounted hive: $($profile.SID)"
                            }
                            else {
                                Write-Verbose "Could not mount hive for $($profile.SID): $regResult"
                            }
                        }
                    }
                }

                # ── 4. Scan OneDrive data for every profile ────────────────────────
                foreach ($profile in $allProfiles) {
                    $sid = $profile.SID

                    # Resolve username -- SID.Translate() fails for Entra ID (S-1-12-1-*)
                    # accounts; fall back to the profile folder name in that case
                    $userName = try {
                        (New-Object System.Security.Principal.SecurityIdentifier($sid)).
                            Translate([System.Security.Principal.NTAccount]).Value.Split('\')[-1]
                    }
                    catch {
                        Split-Path $profile.LocalPath -Leaf
                    }

                    $basePath = "Registry::HKEY_USERS\$sid\Software\Microsoft\OneDrive\Accounts"

                    # Skip profiles with no OneDrive installation
                    if (-not (Test-Path $basePath -ErrorAction SilentlyContinue)) { continue }

                    try {
                        $accounts      = Get-ChildItem -Path $basePath -ErrorAction Stop
                        $allKfmFolders = [System.Collections.Generic.List[object]]::new()
                        $accountTypes  = [System.Collections.Generic.List[string]]::new()
                        $userEmails    = [System.Collections.Generic.List[string]]::new()
                        $userFolders   = [System.Collections.Generic.List[string]]::new()

                        foreach ($account in $accounts) {
                            $props = Get-ItemProperty -Path $account.PSPath -ErrorAction Stop

                            # ── Type detection (priority order) ──────────────────────
                            # 0. No email + generic folder → Not Configured (stub)
                            # 1. Folder "OneDrive - CompanyName" → Business (definitive)
                            # 2. SharePoint URL / onmicrosoft.com → Business
                            # 3. live.com URL / personal email → Personal
                            # 4. Plain "\OneDrive" folder → Personal
                            # 5. Any other custom email domain → Business (fallback)
                            $oneDriveType = 'Unknown'

                            if (-not $props.UserEmail -and
                                ($props.UserFolder -like '*%UserProfile%*' -or
                                 ($props.UserFolder -like '*\OneDrive' -and
                                  $props.UserFolder -notlike '*OneDrive - *'))) {
                                $oneDriveType = 'Not Configured'
                            }
                            elseif ($props.UserFolder -like '*OneDrive - *') {
                                $oneDriveType = 'Business'
                            }
                            elseif ($props.WebServiceUrl -like '*-my.sharepoint.com*' -or
                                    $props.UserEmail   -like '*@*.onmicrosoft.com') {
                                $oneDriveType = 'Business'
                            }
                            elseif ($props.WebServiceUrl -like '*onedrive.live.com*' -or
                                    $props.UserEmail   -like '*@outlook.com' -or
                                    $props.UserEmail   -like '*@hotmail.com' -or
                                    $props.UserEmail   -like '*@live.com') {
                                $oneDriveType = 'Personal'
                            }
                            elseif ($props.UserFolder -like '*\OneDrive' -and
                                    $props.UserFolder -notlike '*OneDrive - *') {
                                $oneDriveType = 'Personal'
                            }
                            elseif ($props.UserEmail -match '@[^@]+\.[^@]+$' -and
                                    $props.UserEmail -notlike '*@outlook.com' -and
                                    $props.UserEmail -notlike '*@hotmail.com' -and
                                    $props.UserEmail -notlike '*@live.com') {
                                $oneDriveType = 'Business'
                            }

                            if ($props.UserEmail)  { $userEmails.Add($props.UserEmail) }
                            if ($props.UserFolder) { $userFolders.Add($props.UserFolder) }
                            $accountTypes.Add($oneDriveType)

                            # ── Parse KFM migration JSON ──────────────────────────────
                            if ($props.LastKnownFolderMigrationState -and
                                $props.LastPerFolderMigrationScanResult) {
                                try {
                                    $migration = $props.LastKnownFolderMigrationState   | ConvertFrom-Json
                                    $scan      = $props.LastPerFolderMigrationScanResult | ConvertFrom-Json

                                    foreach ($folder in $migration.PSObject.Properties.Name) {
                                        # [int] cast required: ConvertFrom-Json returns Int64 in PS 5.1;
                                        # hashtable keys are Int32 -- ContainsKey() fails without cast
                                        $stateCode = [int]$migration.$folder
                                        $scanCode  = [int]$scan.$folder

                                        $allKfmFolders.Add([PSCustomObject]@{
                                            Folder      = $folder
                                            StatusCode  = $stateCode
                                            Status      = if ($stateLookup.ContainsKey($stateCode)) { $stateLookup[$stateCode] } else { "Unknown ($stateCode)" }
                                            ScanCode    = $scanCode
                                            ScanStatus  = if ($scanLookup.ContainsKey($scanCode))  { $scanLookup[$scanCode]  } else { "Unknown ($scanCode)"  }
                                            AccountType = $oneDriveType
                                        })
                                    }
                                }
                                catch { <# JSON parse failed for this account -- continue #> }
                            }
                        }

                        # Business takes precedence when a user has both account types
                        $finalType = if ($accountTypes -contains 'Business')        { 'Business' }
                                     elseif ($accountTypes -contains 'Personal')    { 'Personal' }
                                     elseif ($accountTypes -contains 'Not Configured') { 'Not Configured' }
                                     else                                            { 'Unknown' }

                        $finalEmail  = $userEmails  | Where-Object { $_ } | Select-Object -First 1
                        $finalFolder = $userFolders | Where-Object { $_ } | Select-Object -First 1

                        $kfmSummary = if ($allKfmFolders.Count -gt 0) {
                            $backed = ($allKfmFolders | Where-Object { $_.StatusCode -eq 5 }).Count
                            "$backed of $($allKfmFolders.Count) folders backed up"
                        }
                        else { 'No KFM data available' }

                        $results.Add([PSCustomObject]@{
                            ComputerName   = $computerName
                            UserName       = $userName
                            SID            = $sid
                            OneDriveType   = $finalType
                            UserEmail      = $finalEmail
                            UserFolder     = $finalFolder
                            KFMStatus      = $kfmSummary
                            KFMFolders     = $allKfmFolders
                            CollectionTime = $collectionTime
                            Status         = 'Success'
                        })
                    }
                    catch {
                        $results.Add([PSCustomObject]@{
                            ComputerName   = $computerName
                            UserName       = $userName
                            SID            = $sid
                            Error          = "Failed to read OneDrive registry data: $($_.Exception.Message)"
                            CollectionTime = $collectionTime
                            Status         = 'Failed'
                        })
                    }
                }

                if ($results.Count -eq 0) {
                    $results.Add([PSCustomObject]@{
                        ComputerName   = $computerName
                        UserName       = 'No OneDrive Users'
                        KFMStatus      = 'No OneDrive installations found'
                        CollectionTime = $collectionTime
                        Status         = 'Success'
                    })
                }

                return $results
            }
            finally {
                # ── 5. Unload any hives we mounted ─────────────────────────────────
                # GC collect first to release any open registry handles from PowerShell;
                # reg.exe unload fails if any handle to the hive is still open.
                if ($mountedHives.Count -gt 0) {
                    [System.GC]::Collect()
                    [System.GC]::WaitForPendingFinalizers()
                    foreach ($hiveSID in $mountedHives) {
                        reg.exe unload "HKU\$hiveSID" 2>&1 | Out-Null
                        Write-Verbose "Unloaded hive: $hiveSID"
                    }
                }
            }
        }
    }

    process {
        foreach ($computer in $ComputerName) {
            try {
                Write-Verbose "Querying OneDrive folder backup status on: $computer"

                if ($computer -eq $env:COMPUTERNAME) {
                    & $scriptBlock $stateLookup $scanLookup
                }
                else {
                    $invokeParams = @{
                        ComputerName = $computer
                        ScriptBlock  = $scriptBlock
                        ArgumentList = $stateLookup, $scanLookup
                        ErrorAction  = 'Stop'
                    }
                    if ($Credential) { $invokeParams['Credential'] = $Credential }

                    Invoke-Command @invokeParams
                }
            }
            catch {
                Write-Error -Message "Failed to connect to '$computer': $($_.Exception.Message)" -ErrorAction Continue
                [PSCustomObject]@{
                    ComputerName   = $computer
                    Error          = $_.Exception.Message
                    Status         = 'Failed'
                    CollectionTime = (Get-Date).ToString('dd-MM-yyyy HH:mm:ss')
                }
            }
        }
    }
}