The last Iron Scripter challenge of 2020 was a big one. If you didn't get a chance to work on it, see what you can come up with then come back to see my approach. As with many of the challenges, the goal isn't to produce a production-ready PowerShell tool, but rather to push the limits of your PowerShell scripting and tool-making ability. This is a terrific challenge in that regard.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
The goal was to get a directory listing of files and folders using WMI/CIM . This could be a potential alternative to a traditional filesystem listing. As you'll see, it is possible to accomplish this task. However, not every file and/or folder is registered and I haven't discovered a consistent reason. This means there's no guarantee that when I run my code that I am getting a definitive answer. But the goal is to learn so I'm OK with that. Let's dig in.
Win32_Directory
The basis of a solution begins with the Win32_Directory WMI class. I'll filter for a single folder to get an idea of what it looks like.
Get-CimInstance win32_directory -Filter "Name='C:\\Work\\samples'" | Tee-Object -Variable d
You can also use Get-CimClass to examine the class properties. Remember that with WMI/CIM filters to use the legacy operators like the = sign. The \ is a special character and needs to be escaped.
Now for the fun part. In WMI, most everything is related. Use the Get-CimAssociatedInstance command to retrieve related objects. You can limit what type of results by filtering on a class name.
For my purpose, l only want to see immediate child directories, so I'll modify the code to filter out the parent.
$d | Get-CimAssociatedInstance -ResultClassName Win32_Directory |
Where-Object { (Split-Path $_.name) -eq $d.name } |
Select-Object Hidden, Archive, System, Compressed, Writeable, Encrypted, LastModified, Name, Path
CIM_DataFile
Now that I have a way to get folders, I need to find files. This will use the CIM_DataFile class. I can use the same technique.
$d | Get-CimAssociatedInstance -ResultClassName CIM_DATAFile | Select-Object -First 1 -Property *
Again, I can also use Get-CimClass to discover properties, but often it helps to see a real-world example.
Part of the challenge is to duplicate the output from Get-ChildItem so something like this is a good proof-of-concept.
$d | Get-CimAssociatedInstance -ResultClassName CIM_DATAFile |
Format-Table -GroupBy @{Name = "Path"; Expression = { Resolve-Path (Join-Path -Path $_.drive -ChildPath $_.Path) } } -Property Hidden, Archive, System, LastModified, FileSize, Name
Creating a Custom Class
Now that I know how to retrieve files and folders, I want an easier way to display them, without having to rely on cumbersome Select-Object syntax. My answer is to create PowerShell classes.
Class cimFolder {
[string]$FullName
[string]$Name
[bool]$Archive
[bool]$Compressed
[DateTime]$CreationDate
[string]$Computername
[string]$Drive
[bool]$Encrypted
[bool]$Hidden
[DateTime]$LastAccessed
[DateTime]$LastModified
[string]$Path
[bool]$Readable
[bool]$System
[bool]$Writeable
[string]$Mode
}
Class cimFile {
[string]$FullName
[string]$Name
[bool]$Archive
[bool]$Compressed
[DateTime]$CreationDate
[string]$Computername
[string]$Drive
[bool]$Encrypted
[bool]$Hidden
[int64]$FileSize
[DateTime]$LastAccessed
[DateTime]$LastModified
[string]$Path
[bool]$Readable
[bool]$System
[bool]$Writeable
[string]$Mode
}
Essentially, these are clones of the original WMI classes. Instead of defining a constructor, I'll write a PowerShell function that will "convert" the original WMI class into my custom class.
Function New-CimFile {
[cmdletbinding()]
Param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline)]
[object]$CimObject
)
Begin {
$properties = [CimFile].DeclaredProperties.Name
}
Process {
$file = [CimFile]::New()
foreach ($item in $properties) {
$file.$item = $CimObject.$item
}
$file.name = Split-Path -Path $CimObject.caption -Leaf
$file.fullname = $CimObject.Caption
$file.computername = $CimObject.CSName
$file.mode = Get-Mode $CimObject
$file
}
End {
#not used
}
}
I also wrote a helper function to get the file mode.
Function Get-Mode {
[cmdletbinding()]
param([object]$CimObject)
# use the ternary operator to simplify the code,
# although this will require PowerShell 7.x
$dir = $CimObject.CimClass.Cimclassname -match 'Directory' ? "d" : "-"
$archive = $CimObject.archive ? "a" : "-"
$ro = $CimObject.writeable ? "-" : "r"
$system = $CimObject.System ? "s" : "-"
$hidden = $CimObject.Hidden ? "h" : "-"
"{0}{1}{2}{3}{4}" -f $Dir, $Archive, $RO, $Hidden, $System
}
Note that this function uses the ternary operator so it will only run in PowerShell 7. There's no real reason to use the operator other than I wanted an excuse to try it out. With these elements loaded into my PowerShell session, I can verify the concept.
$d | Get-CimAssociatedInstance -ResultClassName CIM_DATAFile | New-CimFile
I can do a similar process for directory objects. Finally, I can put it all together in a single command.
Function Get-CimFolder {
[cmdletbinding(DefaultParameterSetName = "computer")]
[alias("cdir")]
[OutputType("cimFolder", "cimFile")]
Param(
[Parameter(Position = 0, HelpMessage = "Enter the folder path. Don't include the trailing \.")]
[ValidateNotNullorEmpty()]
[string]$Path = ".",
[switch]$Recurse,
[Parameter(ParameterSetName = "computer")]
[ValidateNotNullOrEmpty()]
[alias("cn")]
[string]$Computername = $ENV:Computername,
[Parameter(ParameterSetName = "session")]
[ValidateNotNullOrEmpty()]
[Microsoft.Management.Infrastructure.CimSession]$CimSession
)
Begin {
Write-Verbose "Starting $($myinvocation.MyCommand)"
$cimParams = @{
Classname = "win32_directory"
Filter = ""
CimSession = ""
}
} #begin
Process {
#convert Path to a file system path
if ($path -match '\\$') {
Write-Verbose "Stripping off a trailing slash"
$path = $path -replace "\\$", ""
}
Try {
$cpath = Convert-Path -Path $path -ErrorAction Stop
#escape any \ in the path
$rPath = $cpath.replace("\", "\\")
$cimParams.Filter = "Name='$rpath'"
Write-Verbose "Using query $($cimparams.filter)"
}
Catch {
Write-Warning "Can't validate the path $path. $($_.Exception.Message)"
#bail out
return
}
if ($pscmdlet.ParameterSetName -eq 'computer') {
Try {
$cimSession = New-CimSession -ComputerName $computername -ErrorAction Stop
$tmpSession = $True
}
Catch {
Throw $_
}
}
Else {
Write-Verbose "Using an existing CIMSession to $($cimsession.computername)"
$tmpSession = $False
}
$cimParams.Cimsession = $CimSession
Write-Verbose "Getting $cpath on $($cimsession.computername)"
$main = Get-CimInstance @cimParams
#filter out the parent folder
$main | Get-CimAssociatedInstance -ResultClassName Win32_Directory |
Where-Object { (Split-Path $_.name) -eq $main.name } | New-CimFolder -outvariable cf
$main | Get-CimAssociatedInstance -ResultClassName CIM_DATAFile | New-Cimfile
if ($cf -AND $recurse) {
Write-Verbose "Recursing..."
foreach ($fldr in $cf.fullname) {
Write-Verbose $fldr
Get-CimFolder -path $Fldr -CimSession $cimSession
}
}
if ($cimSession -AND $tmpSession) {
Write-Verbose "Remove the temporary session"
Remove-CimSession $cimSession
}
} #Process
End {
Write-Verbose "Ending $($myinvocation.MyCommand)"
}
}
Because I'm using the CIM cmdlets, I have an option to query a folder on a remote machine, which was also part of the challenge. My function uses parameter sets to support connecting by computer name and by CIMSession.
Customizing the Results
If you try the code, you'll see that the results are the full object with all the properties. Not necessarily easy to read and certainly not meeting the challenge objectives. One of the reasons for creating my own unique class is that I can define custom type extensions, such as a set of default properties.
Update-TypeData -TypeName cimFolder -DefaultDisplayPropertySet Mode, LastModified, Size, Name -Force
Update-TypeData -TypeName cimFile -MemberType AliasProperty -MemberName Size -Value FileSize -Force
Update-TypeData -TypeName cimFile -DefaultDisplayPropertySet Mode, LastModified, Size, Name -Force
This cleans up the output considerably.
But defined objects can also have custom formatting files. I created one using my trusty New-PSFormatXML command.
<?xml version="1.0" encoding="UTF-8"?>
<!--
Format type data generated 10/21/2020 17:19:32 by PROSPERO\Jeff
This file was created using the New-PSFormatXML command that is part
of the PSScriptTools module.
https://github.com/jdhitsolutions/PSScriptTools
This file will only work properly in PowerShell 7
-->
<Configuration>
<SelectionSets>
<SelectionSet>
<Name>cimFileTypes</Name>
<Types>
<TypeName>cimFolder</TypeName>
<TypeName>cimFile</TypeName>
</Types>
</SelectionSet>
</SelectionSets>
<ViewDefinitions>
<View>
<Name>default</Name>
<ViewSelectedBy>
<SelectionSetName>cimFileTypes</SelectionSetName>
</ViewSelectedBy>
<GroupBy>
<!--
You can also use a scriptblock to define a custom property name.
You must have a Label tag.
<ScriptBlock>$_.machinename.toUpper()</ScriptBlock>
<Label>Computername</Label>
Use <Label> to set the displayed value.
-->
<Label>Path</Label>
<ScriptBlock>Join-Path -path $_.drive -childpath $_.path </ScriptBlock>
</GroupBy>
<TableControl>
<!--Delete the AutoSize node if you want to use the defined widths.
<AutoSize />-->
<TableHeaders>
<TableColumnHeader>
<Label>Mode</Label>
<Width>6</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>LastModified</Label>
<Width>24</Width>
<Alignment>right</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>Size</Label>
<Width>14</Width>
<Alignment>right</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>Name</Label>
<!-- <Width>10</Width> -->
<Alignment>left</Alignment>
</TableColumnHeader>
</TableHeaders>
<TableRowEntries>
<TableRowEntry>
<TableColumnItems>
<!--
By default the entries use property names, but you can replace them with scriptblocks.
<ScriptBlock>$_.foo /1mb -as [int]</ScriptBlock>
-->
<TableColumnItem>
<PropertyName>Mode</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>LastModified</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>Size</PropertyName>
</TableColumnItem>
<TableColumnItem>
<ScriptBlock>
<!-- show Directories, hidden, compressed or encrypted files in color using ANSI-->
if ($_.GetType().Name -eq 'cimFolder') {
"`e[38;5;228m$($_.name)`e[0m"
}
elseif ($_.Encrypted -OR $_.compressed) {
"`e[38;5;201m$($_.name)`e[0m"
}
elseif ($_.Hidden) {
"`e[38;5;105m$($_.name)`e[0m"
}
else {
$_.name
}
</ScriptBlock>
</TableColumnItem>
</TableColumnItems>
</TableRowEntry>
</TableRowEntries>
</TableControl>
</View>
</ViewDefinitions>
</Configuration>
The file uses scriptblocks to display certain files in color, which was yet another challenge task. My code uses ANSI escape sequences for PowerShell 7. Once I load the file with Update-FormatData, look at the amazing difference.
Creating a Module
As you can see, there are a lot of moving parts to this solution. I decided to take the extra step and pull everything together into a module called CimFolder. You can find the module on GitHub at https://github.com/jdhitsolutions/CimFolder. The module is a proof-of-concept and not necessarily production-ready code so I have no plans to publish it to the PowerShell Gallery.
There is also a potential for incomplete results. Not every file and folder is registered with WMI. In my testing I have come across folders that my command fails to enumerate, yet Get-ChildItem works just fine. I have yet to discover a consistent explanation or fix which is why I wouldn't rely on my code for production. However, I hope how and why I put things together has been educational.
The module has the latest updates to the code samples in this blog post. But I have to say, I am quite happy with the results.
I hope you'll browse through the code for ideas and inspiration.
Expand Your Skills
I hope you'll keep an eye on the Iron Scripter site and try your hand at the challenges. The challenges are open-ended so I encourage you to browse the site and tackle a few of them.
If you like learning from challenges, I suggest getting a copy of The PowerShell Practice Primer. This book contains 100 practice problems that begin on a very easy level and increase in difficulty. Answers should be no more than a few PowerShell expressions you would run in the console. These are not scripting challenges.
Or to take your scripting skills to the next level, take a look at The PowerShell Scripting and Toolmaking Book which is also on LeanPub.
In any event, the best way to improve your PowerShell skills is to simply continue to use them every day.
Since I originally posted this, I have pushed a new version of the module to GitHub which fixes a critical recursion bug. The bug explains why I wasn’t seeing files and folders I expected. If you are testing out the module, be sure you have at least version 0.5.0.