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.
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.
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.
Performance Optimization
At this point, I've met almost of the requirements. Optimizing for performance is a little tricky. The only potential bottleneck is measure the directory size since that is done sequentially. On a desktop, I expect you have one primary profile and then assorted system profiles. The system profiles tend to be smaller. But I can imagine on a server, you might have multiple user profiles with a decent file size.
One solution I came up with is to use the ThreadJob
module, which you can install from the PowerShell Gallery. Here's a proof-of-concept version of my function. This version lacks parameter sets.
Function Get-UserProfileInfo {
[cmdletbinding()]
Param(
[Parameter(Position = 0, HelpMessage = 'The name of the profile to query')]
[ValidateNotNullOrEmpty()]
[string]$ProfileName,
[Parameter(HelpMessage = 'The name of a computer name to query')]
[ValidateNotNullOrEmpty()]
[string]$ComputerName = $env:computername
)
#Define the scriptblock that will be invoked remotely using Invoke-Command
$sb = {
Param($ProfileName)
Write-Host "[$((Get-Date).TimeOfDay)] Starting job on $($env:COMPUTERNAME)" -ForegroundColor Green
$jobs = @()
$splat = @{
classname = 'Win32_UserProfile'
ComputerName = $ENV:Computername
}
If ($ProfileName) {
Write-Host "[$((Get-Date).TimeOfDay)] Filtering on profile: $ProfileName" -ForegroundColor Green
$splat.Add('Filter', "LocalPath LIKE '%$ProfileName'")
}
[object[]]$data = Get-CimInstance @splat
Write-Host "[$((Get-Date).TimeOfDay)] Found $($data.count) user profile item(s)" -ForegroundColor Green
foreach ($item in $data) {
#define the ThreadJob scriptblock
$job = {
Param($item)
Write-Host "[$((Get-Date).TimeOfDay)] Processing $($item.LocalPath)" -ForegroundColor Cyan
Try {
$files = Get-ChildItem -LiteralPath $item.LocalPath -Recurse -File -ErrorAction Stop
$FSize = If ($Files) {
Write-Host "[$((Get-Date).TimeOfDay)] Measuring $($files.count) files in $($item.LocalPath) " -ForegroundColor Cyan
($files | Measure-Object -Property Length -Sum -ea stop).Sum
}
else {
0
}
}
Catch {
$FSize = 0
}
$itemPath = (Get-Item -LiteralPath $item.LocalPath)
[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
}
}
#run each scriptblock in a thread job on the remote computer
$jobs += Start-ThreadJob -ScriptBlock $job -StreamingHost $Host -ArgumentList $item
} #foreach Item
#wait for all jobs to complete and get the results
$jobs | Wait-Job | Receive-Job
Write-Host "[$((Get-Date).TimeOfDay)] Ending job on $($env:COMPUTERNAME)" -ForegroundColor Green
} #sb
Try {
$icmSplat = @{
ScriptBlock = $sb
ComputerName = $ComputerName
ErrorAction = 'Stop'
HideComputerName = $true
}
if ($ProfileName) {
$icmSplat.Add('ArgumentList', $ProfileName)
}
Invoke-Command @icmSplat | ForEach-Object {
$_.Add('PSTypeName', 'UserProfileInfo')
#create an object
New-Object -TypeName PSObject -Property $_
}
}
Catch {
$_
}
}
The core script block is still being invoked remotely using Invoke-Command
. But in the script block, I am spinning off thread jobs to get the profile information.
foreach ($item in $data) {
#define the ThreadJob scriptblock
$job = {...}
#run each scriptblock in a thread job on the remote computer
$jobs += Start-ThreadJob -ScriptBlock $job -StreamingHost $Host -ArgumentList $item
} #foreach Item
#wait for all jobs to complete and get the results
$jobs | Wait-Job | Receive-Job
I'm also taking advantage of streaming so I can see what is happening inside the thread job.
On my laptop, the performance gain isn't earth-shattering. The non-threadjob version took 12.6 seconds to process 4 files. The threadjob version took 11.11 seconds. On a system with many large profiles, this might be more noticeable.
Summary
I hope you found this exercise useful. Remember, there is as much to learn from the process as there is from the result. The more you work on challenges like this, the more you'll recognize patterns that you can repeat in other coding projects. The best way to learn is by doing. If you didn't work on a solution, I encourage you to try my code and see first-hand how it works.
Look for your next scripting challenge, coming soon!