Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
December 23, 2024

A Profile Scripting Challenge Solution

Because of the Christmas holiday this week, I'm sending this out a little early, and there will only be one issue this week. 🎁 I hope you have a wonderful holiday. 🎄

Last month, I left you with a PowerShell scripting challenge. I hope you took advantage of it because it was a great opportunity to learn more about PowerShell and how to use it to automate tasks. You also don't have to look for a PowerShell solution to meet a requirement; you can create your own.

In this case, the task was to create a PowerShell function to query the Win32_UserProfile class on a local or remote computer and get output like this for every profile.

Name           : systemprofile
LastUseTime    : 11/19/2024 1:55:44 PM
Path           : C:\WINDOWS\system32\config\systemprofile
PathCreated    : 4/1/2024 3:26:07 AM
PathModified   : 6/25/2024 3:23:58 AM
Size           : 165896534
Special        : True
Loaded         : True
PSComputerName : JEFFDESK

I also gave you bonus challenges in case you wanted to push yourself further.

  • If the profile has no files show a size value of 0
  • Allow the user to select a profile by name
  • Use a type name for the output object so that you can write a custom format file
  • Optimize the function for performance

I hope you did, but if you didn't, don't worry. I have a solution for you. As always, my solution is not the only one.

Win32_UserProfile

The first step in building a solution is to see what an instance of the Win32_UserProfile class looks like. You could use Get-CimClass.

PS C:\> Get-CimClass -ClassName Win32_UserProfile | Select-Object -ExpandProperty CimClassProperties | Select-Object Name,CimType,Flags

Name                              CimType                              Flags
----                              -------                              -----
AppDataRoaming                   Instance      Property, ReadOnly, NullValue
Contacts                         Instance      Property, ReadOnly, NullValue
Desktop                          Instance      Property, ReadOnly, NullValue
Documents                        Instance      Property, ReadOnly, NullValue
Downloads                        Instance      Property, ReadOnly, NullValue
Favorites                        Instance      Property, ReadOnly, NullValue
HealthStatus                        UInt8      Property, ReadOnly, NullValue
LastAttemptedProfileDownloadTime DateTime      Property, ReadOnly, NullValue
LastAttemptedProfileUploadTime   DateTime      Property, ReadOnly, NullValue
LastBackgroundRegistryUploadTime DateTime      Property, ReadOnly, NullValue
LastDownloadTime                 DateTime      Property, ReadOnly, NullValue
LastUploadTime                   DateTime      Property, ReadOnly, NullValue
LastUseTime                      DateTime      Property, ReadOnly, NullValue
Links                            Instance      Property, ReadOnly, NullValue
Loaded                            Boolean      Property, ReadOnly, NullValue
LocalPath                          String      Property, ReadOnly, NullValue
Music                            Instance      Property, ReadOnly, NullValue
Pictures                         Instance      Property, ReadOnly, NullValue
RefCount                           UInt32      Property, ReadOnly, NullValue
RoamingConfigured                 Boolean      Property, ReadOnly, NullValue
RoamingPath                        String      Property, ReadOnly, NullValue
RoamingPreference                 Boolean                Property, NullValue
SavedGames                       Instance      Property, ReadOnly, NullValue
Searches                         Instance      Property, ReadOnly, NullValue
SID                                String Property, Key, ReadOnly, NullValue
Special                           Boolean      Property, ReadOnly, NullValue
StartMenu                        Instance      Property, ReadOnly, NullValue
Status                             UInt32      Property, ReadOnly, NullValue
Videos                           Instance      Property, ReadOnly, NullValue

Since I told you what the result should look like, you can find some properties immediately like Special, Loaded, LastUseTime, and LocalPath. I included the flags so that I could identify a key property. This is a property that you can always filter on to get a unique instance of the class. In this case, it is the SID property.

I mention this, because you will eventually need an instance of the class to see the property values. Instead of running Get-CimInstance to get all instances of the class, see if you can filter to get a single, sample instance.

I'll use the native whoami command to get my SID and pass that to Get-CimInstance.

PS C:\> $me = whoami /user /fo csv | ConvertFrom-Csv
PS C:\> Get-CimInstance Win32_UserProfile -Filter "SID='$($me.sid)'"

AppDataRoaming                   : Win32_FolderRedirectionHealth
Contacts                         : Win32_FolderRedirectionHealth
Desktop                          : Win32_FolderRedirectionHealth
Documents                        : Win32_FolderRedirectionHealth
Downloads                        : Win32_FolderRedirectionHealth
Favorites                        : Win32_FolderRedirectionHealth
HealthStatus                     : 3
LastAttemptedProfileDownloadTime :
LastAttemptedProfileUploadTime   :
LastBackgroundRegistryUploadTime :
LastDownloadTime                 :
LastUploadTime                   :
LastUseTime                      : 12/19/2024 1:09:25 PM
Links                            : Win32_FolderRedirectionHealth
Loaded                           : True
LocalPath                        : C:\Users\Jeff
Music                            : Win32_FolderRedirectionHealth
Pictures                         : Win32_FolderRedirectionHealth
RefCount                         :
RoamingConfigured                : False
RoamingPath                      :
RoamingPreference                :
SavedGames                       : Win32_FolderRedirectionHealth
Searches                         : Win32_FolderRedirectionHealth
SID                              : S-1-5-21-746680207-121505554-2675587396-1001
Special                          : False
StartMenu                        : Win32_FolderRedirectionHealth
Status                           : 0
Videos                           : Win32_FolderRedirectionHealth
PSComputerName                   :

The object doesn't show the profile name, but that can be inferred from the LocalPath property. That is much easier than trying to resolve the SID. Given the properties from the sample, I can prototype getting most of them from the instance.

PS C:\> Get-CimInstance Win32_UserProfile -Filter "SID='$($me.sid)'" -computername $ENV:COMPUTERNAME | Select-Object -Property @{Name = 'Name'; Expression = { Split-Path $_.LocalPath -Leaf }},LastUseTime,Special,Loaded,PSComputername

Name           : Jeff
LastUseTime    : 12/19/2024 1:22:12 PM
Special        : False
Loaded         : True
PSComputerName : THINKX1-JH

That's a good start.

Getting Path Information

Since I have a Path, I should be able to use Get-Item which will give me the CreationTime and LastWriteTime. I can also get the Size property from the Length property. I can get an approximate size of the folder by measuring all of the files with Get-ChildItem and Measure-Object.

Here's my revised prototype code.

Get-CimInstance Win32_UserProfile -Filter "SID='$($me.sid)'" -computername $ENV:COMPUTERNAME |
Select-Object @{Name = 'Name'; Expression = { Split-Path $_.LocalPath -Leaf } },LastUseTime,
@{Name = 'Path'; Expression = { $_.LocalPath } },
@{Name = 'PathCreated'; Expression = { (Get-Item -LiteralPath $_.LocalPath).CreationTime } },
@{Name = 'PathModified'; Expression = { (Get-Item -LiteralPath $_.LocalPath).LastWriteTime } },
@{Name = 'FileSize'; Expression = { Get-ChildItem -LiteralPath $_.LocalPath -Recurse | Measure-Object -Property Length -Sum | Select-Object -ExpandProperty Sum } },
Special, Loaded, PSComputerName

This might take a moment to run depending on the profile size.

Name           : Jeff
LastUseTime    : 12/19/2024 1:26:19 PM
Path           : C:\Users\Jeff
PathCreated    : 5/17/2022 2:52:03 PM
PathModified   : 12/17/2024 11:00:16 AM
FileSize       : 25853257637
Special        : False
Loaded         : True
PSComputerName : THINKX1-JH

Note: When it comes to querying all profiles, the command will have to run in an elevated session.

I can re-run my command without the filter to get information from all profiles.

All Userprofile information
figure 1

Creating a Function

You may be wondering why I didn't begin with a function? In most cases, a function is a wrapper around a few underlying PowerShell commands. The function makes the code easy to use and can add features like parameter. I like knowing that I have a core set of commands that works and produces the desired result. Then I can build the function around it. Otherwise, I might struggle trying to determine if a problem is with the PowerShell core code or the function code.

Here's my initial function that lets me filter on a profile name.

Function Get-UserProfileInfo {
    [cmdletbinding()]
    Param(
        [Parameter(Position = 0, HelpMessage = 'The name of the profile to query')]
        [ValidateNotNullOrEmpty()]
        [string]$ProfileName
    )

    $splat = @{
        classname = 'Win32_UserProfile'
    }
    If ($ProfileName) {
        Write-Verbose "[$((Get-Date).TimeOfDay)] Filtering on profile: $ProfileName"
        $splat.Add('Filter', "LocalPath LIKE '%$ProfileName'")
    }

    [object[]]$data = Get-CimInstance @splat

    Write-Verbose "[$((Get-Date).TimeOfDay)] Found $($data.count) user profile item(s)"
    foreach ($item in $data) {
        Write-Verbose "[$((Get-Date).TimeOfDay)] Processing $($item.LocalPath)"
        Try {
            $files = Get-ChildItem -LiteralPath $item.LocalPath -Recurse -File -ErrorAction Stop
            $FSize = If ($Files) {
                Write-Verbose "[$((Get-Date).TimeOfDay)] Measuring $($files.count) files"
                ($files | Measure-Object -Property Length -Sum -ea stop).Sum
            }
            else {
                0
            }
        }
        Catch {
            $FSize = 0
        }
        $itemPath = (Get-Item -LiteralPath $item.LocalPath)
        #create the custom output
        [PSCustomObject]@{
            PSTypeName   = 'UserProfileInfo'
            Name         = Split-Path $item.LocalPath -Leaf
            LastUseTime  = $item.LastUseTime
            Path         = $item.LocalPath
            PathCreated  = $itemPath.CreationTime
            PathModified = $itemPath.LastWriteTime
            FileSize     = $FSize
            Special      = $item.Special
            Loaded       = $item.Loaded
            Computername = $ENV:Computername
        }
    } #foreach Item
}

I'm filtering using the WMI LIKE operator which lets me use wildcards.

"LocalPath LIKE '%$ProfileName'"

Note that the wildcard character is % and not *. The LIKE operator is case-insensitive. I make it upper-case so that it stands out. This function works locally and meets most of the requirements.

Get a local profile
figure 2
Get all local profile info
figure 3

Working Remotely

The Get-CimInstance cmdlet can work remotely and will get most of the information. But querying the disk space for the profile needs to happen on the remote computer. I could write code using Get-CimInstance to use a combination of directory and file classes, but that's going to be a lot of work. I already have Get-Item and Measure-Object commands that work. All I need to do is invoke them in a remote session. I can do this using Invoke-Command.

Function Get-UserProfileInfo {
    [cmdletbinding(DefaultParameterSetName = "computer")]
    Param(
        [Parameter(Position = 0, HelpMessage = 'The name of the profile to query')]
        [ValidateNotNullOrEmpty()]
        [string]$ProfileName,
        [Parameter(ParameterSetName = "computer",HelpMessage = 'The name of a computer name to query')]
        [ValidateNotNullOrEmpty()]
        [string]$ComputerName = $env:computername,
        [Parameter(ParameterSetName = "computer",HelpMessage = 'Enter an alternate credential for the computer')]
        [ValidateNotNullOrEmpty()]
        [PSCredential]$Credential,
        [Parameter(ParameterSetName = "session",HelpMessage = 'Enter a PSSession object')]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Runspaces.PSSession]$Session
    )

    Write-Verbose "[$((Get-Date).TimeOfDay)] Starting $($MyInvocation.MyCommand.Name)"

    #define a scriptblock that will be run remotely
    $sb = {
        Param($ProfileName)
        Write-Host "[$((Get-Date).TimeOfDay) $($env:COMPUTERNAME)] Starting remote code execution" -ForegroundColor Cyan
        $splat = @{
            classname    = 'Win32_UserProfile'
            ComputerName = $ENV:Computername
        }
        If ($ProfileName) {
            Write-Host "[$((Get-Date).TimeOfDay) $($env:COMPUTERNAME)] Filtering on profile: $ProfileName" -ForegroundColor Cyan
            $splat.Add('Filter', "LocalPath LIKE '%$ProfileName'")
        }

        [object[]]$data = Get-CimInstance @splat
        Write-Host "[$((Get-Date).TimeOfDay) $($env:COMPUTERNAME)] Found $($data.count) user profile item(s)" -ForegroundColor Cyan
        foreach ($item in $data) {
            Write-Host "[$((Get-Date).TimeOfDay) $($env:COMPUTERNAME)] Processing $($item.LocalPath)" -ForegroundColor Cyan
            Try {
                $files = Get-ChildItem -LiteralPath $item.LocalPath -Recurse -File -ErrorAction Stop
                $FSize = If ($Files) {
                    Write-Host "[$((Get-Date).TimeOfDay) $($env:COMPUTERNAME)] Measuring $($files.count) files" -ForegroundColor Cyan
                   ($files | Measure-Object -Property Length -Sum -ea stop).Sum
                }
                else {
                    0
                }
            }
            Catch {
                $FSize = 0
            }
            $itemPath = (Get-Item -LiteralPath $item.LocalPath)
            #the output is a hashtable
            [ordered]@{
                Name         = Split-Path $item.LocalPath -Leaf
                LastUseTime  = $item.LastUseTime
                Path         = $item.LocalPath
                PathCreated  = $itemPath.CreationTime
                PathModified = $itemPath.LastWriteTime
                FileSize     = $FSize
                Special      = $item.Special
                Loaded       = $item.Loaded
                Computername = $ENV:Computername
            }
        }
    }

    Try {
        #parameters to splat to Invoke-Command
        $icmSplat = @{
            ScriptBlock      = $sb
            ErrorAction      = 'Stop'
            HideComputerName = $true
        }
        if ($ProfileName) {
            $icmSplat.Add('ArgumentList', $ProfileName)
        }
        Write-Verbose "[$((Get-Date).TimeOfDay)] Detected parameter set $($PSCmdlet.ParameterSetName)"
        Switch ($PSCmdlet.ParameterSetName) {
            'computer' {
                Write-Verbose "[$((Get-Date).TimeOfDay)] Using ComputerName $($ComputerName.toUpper())"
                $icmSplat.Add('ComputerName', $ComputerName)
                if ($Credential) {
                    Write-Verbose "[$((Get-Date).TimeOfDay)] Adding Credential"
                    $icmSplat.Add('Credential', $Credential)
                }
            }
            'session' {
                Write-Verbose "[$((Get-Date).TimeOfDay)] Using Session to $($Session.ComputerName.toUpper())"
                $icmSplat.Add('Session', $Session)
            }
        } #switch

        #invoke the script block
        Write-Verbose "[$((Get-Date).TimeOfDay)] Invoking scriptblock on $($ComputerName.ToUpper())"
        Invoke-Command @icmSplat | ForEach-Object {
            Write-Verbose "[$((Get-Date).TimeOfDay)] Processing remote output locally"
            #Insert the typename into the object hashtable
            $_.Add('PSTypeName', 'UserProfileInfo')
            #create an object
            New-Object -TypeName PSObject -Property $_
        }
    }
    Catch {
        $_
    }
    Write-Verbose "[$((Get-Date).TimeOfDay)] Ending $($MyInvocation.MyCommand.Name)"
}

This version of the function uses parameter sets to let me connect to a remote machine with a computername/credential combination or a PSSession object. I can also filter on a profile name. The function will work locally or remotely. I put the code that I want to run locally inside a script block. The script block will write a hashtable of information to the local computer. This is where I'll insert the typename and turn it into a custom object.

Invoke-Command @icmSplat | ForEach-Object {
    Write-Verbose "[$((Get-Date).TimeOfDay)] Processing remote output locally"
    #Insert the typename into the object hashtable
    $_.Add('PSTypeName', 'UserProfileInfo')
    #create an object
    New-Object -TypeName PSObject -Property $_
}

I am using Write-Host commands in the script block so I can verify where the code is executing.

Getting remote profile information
figure 4
Want to read the full issue?
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.