A Registry Scripting Challenge Solution
In the September 2024 round-up, I gave you a PowerShell scripting challenge. The best way to learn PowerShell and improve your skills is to do it. The methodology you use to derive a solution is just as important as the solution itself. Yes, it is important to learn PowerShell techniques, but how you approach a problem is just as important. The emphasis is not on the destination but the journey.
In this article, I will walk you through my solution to the challenge, which is by no means the only solution, or even the best solution for you.
The Challenge
For those of you who missed it, here was the challenge I presented.
Create a key under HKCU:
called PSProfile
. Under this key, create a key for each of the 4 PowerShell profile scripts, e.g. AllUsersAllHosts
. Under each key, add values for the file path, the file size, the last modified date, and an MD5 file hash of the file. You will need to account for profiles that may not exist.
Store the file size as a QWord. If your profile is using a symbolic link, you may need to resolve the link to get the actual file path and size.
Bonus: Store the script contents as a binary value in the registry and write a function or script to view the binary key value.
If you want to take some time to work on a solution, please do. You can always return to see my solution.
Using CoPilot
I want to point out that I didn't start with a solution. I gave myself the challenge as well. Normally, I would have opened VS Code and started scripting. But this is 2024 and AI-assisted coding is now a thing. I decided to use GitHub CoPilot to see what it would suggest. I wanted to see if it would help me come up with a solution or at least give me some ideas. In VS Code, I used the challenge above as a prompt. Here is what CoPilot suggested:
# Define the profile paths
$profiles = @{
AllUsersAllHosts = $PROFILE.AllUsersAllHosts
AllUsersCurrentHost = $PROFILE.AllUsersCurrentHost
CurrentUserAllHosts = $PROFILE.CurrentUserAllHosts
CurrentUserCurrentHost = $PROFILE.CurrentUserCurrentHost
}
# Create the main registry key
$mainKey = 'HKCU:\PSProfile'
if (-not (Test-Path $mainKey)) {
New-Item -Path $mainKey -Force
}
# Function to calculate MD5 hash
function Get-FileHashMD5 {
param (
[string]$filePath
)
if (Test-Path $filePath) {
$hash = Get-FileHash -Path $filePath -Algorithm MD5
return $hash.Hash
}
return $null
}
# Iterate over each profile and create subkeys with values
foreach ($profile in $profiles.GetEnumerator()) {
$subKey = "$mainKey\$($profile.Key)"
if (-not (Test-Path $subKey)) {
New-Item -Path $subKey -Force
}
$filePath = $profile.Value
if (Test-Path $filePath) {
$fileInfo = Get-Item $filePath
$fileSize = $fileInfo.Length
$lastModified = $fileInfo.LastWriteTime
$md5Hash = Get-FileHashMD5 -filePath $filePath
Set-ItemProperty -Path $subKey -Name 'FilePath' -Value $filePath
Set-ItemProperty -Path $subKey -Name 'FileSize' -Value $fileSize
Set-ItemProperty -Path $subKey -Name 'LastModified' -Value $lastModified
Set-ItemProperty -Path $subKey -Name 'MD5Hash' -Value $md5Hash
}
}
I like that CoPilot included comments. The code also tests for the existence of the registry key and only creates it if it doesn't exist. To be honest, I don't have many concerns with the suggested code. It is definitely a good starting point, and it works.
But let's see what I can do with it. I deleted the registry entries and started with a clean slate.
My Improvements
My solution, and most likely yours is a PowerShell script file. CoPilot took a hard-coded approach to getting the profile paths. I prefer a more dynamic approach. I know the paths are stored as NoteProperties in the $PROFILE
variable. I can use this to get the profile paths by querying the underlying PSObject
:
$profile.PSObject.properties |
Where-Object MemberType -eq NoteProperty |
ForEach-Object -Begin {
#initialize an empty hashtable to store the profile paths
$profiles = @{}
} -Process {
#add each profile name and path to the hashtable
$profiles.Add($_.Name, $_.Value)
}
I added each profile name and path to a new hashtable.
PS C:\> $profiles
Name Value
---- -----
CurrentUserCurrentHost C:\Users\Jeff\Documents\PowerShell\Microsoft.PowerShell_profile.ps1
AllUsersAllHosts C:\Program Files\PowerShell\7\profile.ps1
CurrentUserAllHosts C:\Users\Jeff\Documents\PowerShell\profile.ps1
AllUsersCurrentHost C:\Program Files\PowerShell\7\Microsoft.PowerShell_profile.ps1
Next, I need to create the registry key if it doesn't exist.
# Create the main registry key
$mainKey = 'HKCU:\PSProfile'
#create the registry key if it doesn't exist
if (-not (Test-Path $mainKey)) {
[void](New-Item -Path $mainKey -Force)
}
This is similar to what CoPilot suggested. I used the [void]
cast to suppress the output of New-Item
. This is preferred over piping to Out-Null
.
Now I can follow CoPilot's lead and create child registry keys for each profile, based on items in the $profiles
hashtable.
foreach ($profile in $profiles.GetEnumerator()) {
Write-Host "Processing $($profile.Key)" -ForegroundColor Green
$subKey = Join-Path -Path $mainKey -childPath $profile.Key
#create the registry subkey if it doesn't exist
if (-not (Test-Path $subKey)) {
[void](New-Item -Path $subKey -Force)
}
#assign the file path to a variable for clarity
$filePath = $profile.Value
if (Test-Path $filePath) {
#get the profile script if it exists
$fileInfo = Get-Item $filePath
$data = [PSCustomObject]@{
FilePath = $filePath
FileSize = $fileInfo.Length
LastModified = $fileInfo.LastWriteTime
MD5Hash = (Get-FileHash -Algorithm MD5 -Path $filePath).hash
Content = (Get-Content -Path $filePath -AsByteStream) -as [byte[]]
}
If ($fileInfo.LinkTarget) {
$data | Add-Member -MemberType NoteProperty -Name 'LinkTarget' -Value $fileInfo.LinkTarget -Force
$data.FileSize = (Get-Item -Path $fileInfo.LinkTarget).Length
}
$data | Set-ItemProperty -Path $SubKey
}
} #foreach profile
Let me point out a few things. First, I'm using Join-Path
to define the registry subkey. This is a best practice whenever defining paths. Don't try to concatenate strings. Use Join-Path
.
As I enumerate the profile hashtable, I get each profile file if it exists and create a custom object with the properties I need.
$fileInfo = Get-Item $filePath
$data = [PSCustomObject]@{
FilePath = $filePath
FileSize = $fileInfo.Length
LastModified = $fileInfo.LastWriteTime
MD5Hash = (Get-FileHash -Algorithm MD5 -Path $filePath).hash
Content = (Get-Content -Path $filePath -AsByteStream) -as [byte[]]
}
I don't need a function to get the file hash. I can call Get-FileHash
directly. I'm also getting the file contents as a byte array for the bonus.
I happen to use links, so my code checks for symbolic links. If the profile script is a symbolic link, I resolve it and update the file size accordingly.
If ($fileInfo.LinkTarget) {
$data | Add-Member -MemberType NoteProperty -Name 'LinkTarget' -Value $fileInfo.LinkTarget -Force
$data.FileSize = (Get-Item -Path $fileInfo.LinkTarget).Length
}
I now have a custom object for each profile script.
PS C:\> $data
FilePath : C:\Users\Jeff\Documents\PowerShell\profile.ps1
FileSize : 1808
LastModified : 12/22/2021 11:08:55 AM
MD5Hash : F8F364D4AA488E5A7E216AA4B739B08F
Content : {35, 114, 101, 113…}
LinkTarget : C:\Users\Jeff\OneDrive\PSProfiles\PowerShell7\profile.ps1
Here's what I think is the elegant part. CoPilot set each registry value individually with Set-ItemProperty
. But you can set them all at once with the pipeline.
$data | Set-ItemProperty -Path $SubKey
This process repeats for each PowerShell profile script.
I didn't ask CoPilot to resolve target links or store the file contents, but take a few minutes to compare the two solutions. CoPilot accelerated the development process, but by reading command help and based on my experience, I was able to improve the solution. That's the key: experience. The more PowerShell you write and the more experience you have, the better you'll be to evaluate AI assisted code.
Bonus
The bonus was to retrieve the binary content of the profile scripts. First, I need to be able to run a command that will convert binary data to a string. I'll create a test file and get the content as a byte array. This parameter was added in PowerShell 7.
$Content = (Get-Content -Path c:\temp\sample.txt -AsByteStream) -as [byte[]]
The alternative is to use the GetBytes()
method from the System.Text.Encoding
class.
$bytes = [System.Text.Encoding]::UTF8.GetBytes((Get-Content C:\temp\sample.txt))
To convert the byte array back to a string, I can use the Get-String()
method.
PS C:\> [System.Text.Encoding]::UTF8.GetString($content)
I am the walrus
Now that I have the commands to use, I can wrap them in a function.
Function Read-PSProfileRegistryContent {
[CmdletBinding()]
Param(
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
$profile.PSObject.properties | Where-Object { $_.Name -ne 'length' -AND $_.Name -match $wordToComplete } |
ForEach-Object { $_.Name }
})]
[ValidateNotNullOrEmpty()]
[string]$Name = 'CurrentUserCurrentHost'
)
$reg = Get-PSProfileRegistry -Name $Name
#get the registry content as a byte array
$content = $reg | Get-ItemPropertyValue -Name Content
#convert back into a string
[System.Text.Encoding]::UTF8.GetString($content)
}
This function uses another function to get the registry item.
Function Get-PSProfileRegistry {
[cmdletbinding(DefaultParameterSetName = 'Name')]
Param(
[Parameter(Position = 0, ParameterSetName = 'Name')]
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
$profile.PSObject.properties | Where-Object { $_.Name -ne 'length' -AND $_.Name -match $wordToComplete } |
ForEach-Object { $_.Name }
})]
[ValidateNotNullOrEmpty()]
[string]$Name = 'CurrentUserCurrentHost',
[Parameter(ParameterSetName = 'All')]
[switch]$All
)
$base = 'HKCU:\PSProfile'
If ($PSCmdlet.ParameterSetName -eq 'Name') {
$regPath = Join-Path $Base -ChildPath $Name
Get-Item $regPath
}
else {
Get-ChildItem -Path $Base
}
}
With these functions, I can now retrieve the binary content of the profile scripts.
Summary
I hope you found this educational and even a little fun. I wanted to show you that there is more than one way to solve a problem. I also wanted to show you that AI-assisted coding can be a valuable tool. But it is not a replacement for experience. The more you write PowerShell, the better you will be at evaluating the suggestions. I encourage you to take the time to read the help and understand the commands you are using. This will make you a better scripter.
If there is anything in my solution that you'd like to know more about, please don't hesitate to leave a comment. You probably aren't the only one wondering. In the meantime, I hope you are working on the scripting challenge I gave you at the end of last month. Good luck.