I Stream of PowerShell - Part 2
Let's back into alternate data streams and experiment with ways to leverage them using PowerShell. What I like is the fact that you can add information to a file without changing the file itself, other than the last write time property. For a PowerShell script file, something that isn't part of a module, it might be useful to use the alternate data stream to store a file version value.
Set-Content -path C:\temp\Get-PSFoo.ps1 -Stream "!Version" -Value "1.0.0"
The alternate data stream is named !Version
. I don't think there are any restrictions on the name. I suppose it is possible some other application might define a Version
data stream. By using the !
character, I am hoping to avoid any conflicts with other applications. But I don't think it ultimately matters.
PS C:\> Get-Item -Path C:\temp\Get-PSFoo.ps1 -Stream '!Version'
PSPath : Microsoft.PowerShell.Core\FileSystem::C:\temp\Get-PSFoo.ps1:!Version
PSParentPath : Microsoft.PowerShell.Core\FileSystem::C:\temp
PSChildName : Get-PSFoo.ps1:!Version
PSDrive : C
PSProvider : Microsoft.PowerShell.Core\FileSystem
PSIsContainer : False
FileName : C:\temp\Get-PSFoo.ps1
Stream : !Version
Length : 7
PS C:\> Get-Content -Path C:\temp\Get-PSFoo.ps1 -Stream '!Version'
1.0.0
However, be careful with the stream values. You might try storing a [System.Version]
object in the stream.
[Version]$ver = '1.1.123'
Set-Content -path C:\temp\Get-PSFoo.ps1 -Stream "!Version" -Value $ver
However the content will be stored as a string.
PS C:\> Get-Content -path C:\temp\Get-PSFoo.ps1 -Stream "!Version" | Tee -Variable v
1.1.123
PS C:\> $v -is [version]
False
PS C:\> $v -is [string]
If the value is not already a string, it appears that PowerShell will convert it to a string using the ubiquitous ToString()
method.
PS C:\> set-Content -Path C:\temp\Get-PSFoo.ps1 -Stream "psVer" -Value $PSVersionTable
PS C:\> Get-Content -path C:\temp\Get-PSFoo.ps1 -Stream "psVer"
System.Management.Automation.PSVersionHashTable
PS C:\> $PSVersionTable.ToString()
System.Management.Automation.PSVersionHashTable
This shouldn't be that much of problem, since you are likely to be storing simple string values like a script author.
Set-Content -path C:\temp\Get-PSFoo.ps1 -Stream Author -Value "Jeff Hicks"
Set-Content -path C:\temp\Get-PSFoo.ps1 -Stream Computername -Value $env:Computername
One allowed variant is that you can store an array of strings in the stream.
PowerShell Tooling
Before we get too far, let's add some PowerShell tooling to identify alternate data streams and their values. I could write one function to do everything. But if you think about it, there are really two different tasks. One is to identify the streams for a file and the other is to read the stream values. So I will create two functions. But write them so that they can be used together in the PowerShell pipeline.
The first function will identify alternate data streams other than the default $DATA
stream.
function Get-FileStreamInfo {
[cmdletbinding()]
[OutputType('PSFileStreamInfo')]
param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline)]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ })]
[string]$Path
)
process {
$streams = Get-Item -Path $Path -Stream * | Where-Object { $_.Stream -ne ':$DATA' }
[PSCustomObject]@{
PSTypeName = 'PSFileStreamInfo'
Path = Convert-Path $Path
Streams = $streams.Stream
}
}
}
The second function will read the stream values. I will use a custom object type for each function so that I can easily identify the output.
function Get-FileStreamData {
[cmdletbinding()]
[OutputType('PSFileStreamData','HashTable')]
param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'The path to the file'
)]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[Parameter(
Position = 1,
Mandatory,
ValueFromPipelineByPropertyName,
HelpMessage = 'The stream names to retrieve data from.'
)]
[string[]]$Streams,
[Parameter(HelpMessage = 'Return the data as a hash table instead of a custom object for each stream.')]
[switch]$AsHashTable
)
process {
$Path = Convert-Path $Path
if ($Streams.Count -gt 0) {
Write-Verbose "Processing $($Streams.Count) stream(s) in $($Path)"
if ($AsHashTable) {
$data = [ordered]@{Path = $Path}
foreach ($stream in $Streams) {
Write-Verbose "Processing stream: $stream"
$data[$stream] = Get-Content -Path $Path -Stream $stream
}`
$data
}
else {
foreach ($stream in $Streams) {
Write-Verbose $stream
[PSCustomObject]@{
PSTypeName = 'PSFileStreamData'
Path = $Path
Stream = $stream
Data = Get-Content -Path $Path -Stream $stream
}
} #foreach
}
}
else {
Write-Verbose "No streams found in $($Path)"
}
}
}
Let's try them out.
PS C:\> Get-FileStreamInfo C:\temp\Get-PSFoo.ps1
Path Streams
---- -------
C:\temp\Get-PSFoo.ps1 {!Version, Author, Computername}
I wrote the function to take pipeline input so I can easily identify the streams for multiple files.
PS C:\> dir c:\temp -file | Get-FileStreamInfo | Where Streams
Path Streams
---- -------
C:\temp\2112.mp3 01APIC_00.jpg
C:\temp\Get-PSFoo.ps1 {!Version, Author, Computername}
C:\temp\png.zip Demo
C:\temp\RollChanges.mp3 01APIC_00.jpg
C:\temp\walrus.txt {jeff, ps}
Once I know the stream name, I can get the stream data.
PS C:\> Get-FileStreamData C:\temp\Get-PSFoo.ps1 -Streams author,computername
Path Stream Data
---- ------ ----
C:\temp\Get-PSFoo.ps1 author Jeff Hicks
C:\temp\Get-PSFoo.ps1 computername CADENZA
By default this function writes custom objects for each stream. This design best follows the practice of having a function write one type of object to the pipeline. However, I thought there might be situations where I might want to have a single object with all the stream data. For this scenario, I can use a hashtable. This is ideal when joining the two functions together in the pipeline.
PS C:\> Get-FileStreamInfo C:\temp\Get-PSFoo.ps1 | Get-FileStreamData -AsHashTable
Name Value
---- -----
Path C:\temp\Get-PSFoo.ps1
!Version 1.1.123
Author Jeff Hicks
Computername CADENZA
I have a feeling we will be revisiting this output later.
Metadata at Scale
To test this at scale, I copy 25 random PowerShell script files to my TEMP folder. I can quickly write author information.
dir c:\temp\*.ps1 |Foreach-Object {
#save last write time values so they can be restored later
$lwt = $_.LastWriteTime
$lwtUTC = $_.LastWriteTimeUtc
Set-Content -Path $_.FullName -Stream Author -Value "Jeff Hicks"
$_.LastWriteTime = $lwt
$_.LastWriteTimeUtc = $lwtUTC
}
This code snippet saves the last write time values so they can be restored after updating the stream. This might have an impact on version control or backup, so take this into consideration. this code snippet can easily be turned into a PowerShell function that accepts pipeline input.
I can verify the change with my Get-FileStreamData
function.
PS C:\> dir c:\temp\*.ps1 | Select -first 10 | Get-FileStreamData -Streams author
Path Stream Data
---- ------ ----
C:\temp\Blog-ScanningNotes.ps1 author Jeff Hicks
C:\temp\Create-AllProfiles.ps1 author Jeff Hicks
C:\temp\Demo-Information.ps1 author Jeff Hicks
C:\temp\Demo-PowerShellHTML.ps1 author Jeff Hicks
C:\temp\demopullconfig-change.ps1 author Jeff Hicks
C:\temp\dev-windowtime.ps1 author Jeff Hicks
C:\temp\Download-2016Eval.ps1 author Jeff Hicks
C:\temp\DSC-BeNothing.ps1 author Jeff Hicks
C:\temp\Get-LocalAdministrator.ps1 author Jeff Hicks
C:\temp\Get-PSFoo.ps1 author Jeff Hicks
Summary
This is a good place to take a break. I want to give you time to look at my code samples and try them out. When we pick up this topic again, we'll combine this concept with the PowerShell AST.