I Stream of PowerShell - Part 3
Let's pick up where we left off in exploring how to take advantage of NTFS alternate data streams in PowerShell. If you need a refresher, take a look at Part 1 and Part 2.
At the end of last year, I left you with a scripting challenge. I shared the code for my approach in January. My approach was to use the Abstract Syntax Tree (AST) to parse the script file and create metadata which I then inserted as a comment at the end of the script file. Instead of inserting the metadata directly into the script file, I could use an alternate data stream to store the metadata. Let's see how we might accomplish that.
New Script Metadata Class
I took my original solution and revised and refactored it to use a PowerShell class to represent the metadata. The class is called PSScriptMetadata
.
class PSScriptMetadata {
[string]$Path
[string]$Name
[datetime]$LastModified
[version]$RequiredVersion
[bool]$RequiresAdmin
[string[]]$RequiredModules
[string[]]$Commands
#these could be saved as separate and individual properties
[string]$Author
[string]$Comment
#Using .NET to make this cross platform
[string]$ComputerName = [System.Environment]::MachineName
#Object export methods
[string[]]ToJson() {
$json = $this | ConvertTo-Json -Depth 5
return $json
}
[string]ToXml() {
$xml = $this | ConvertTo-Xml -As String
return $xml
}
[string[]]ToClixml () {
$clixml = $this | ConvertTo-CliXml -Depth 5
return $clixml
}
PSScriptMetadata([string]$Path) {
$this.Path = $Path
$this.Name = Split-Path -Path $Path -Leaf
$this.LastModified = (Get-Item $Path).LastWriteTime
}
}
An argument could be made to save the
Author
,Comment
, andComputerName
properties as separate and alternate data streams. If you use this code, feel free to adapt it to your needs.
This also meant a revised function to employ the class. This PowerShell 7 version of the function:
- removes Tags property - I will tackle that later.
- adds an option to add an author
- adds an option for a general comment
- adds the computer name
- store last file update
function New-ScriptMetadata {
[cmdletbinding()]
[OutputType('PSScriptMetadata')]
[alias('nsm')]
param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
HelpMessage = 'The path to the .PS1 script file'
)]
[ValidatePattern('.*\.ps1$')]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[Parameter(HelpMessage = 'Enter optional author information.')]
[string]$Author,
[Parameter(HelpMessage = 'Enter a brief general comment about the script file.')]
[string]$Comment
)
begin {
#the command exclusion file is a list of commands that should not be included in the metadata
$CmdExclude = Get-Content -Path C:\scripts\CmdExclude.txt |
Where-Object { $_ -NotMatch '^#' -and $_ -match '\w+' }
#initialize required AST variables
New-Variable astTokens -Force
New-Variable astErr -Force
} #begin
process {
#convert to a file system path
$Path = Convert-Path $Path
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
$RequiredVersion = $AST.ScriptRequirements.RequiredPSVersion
$RequiresAdmin = $AST.ScriptRequirements.IsElevationRequired
$RequiredModules = $AST.ScriptRequirements.RequiredModules.Name
$cmdGroup = $astTokens.where({ $_.TokenFlags -eq 'CommandName' -and $_.Value -notin $CmdExclude }) |
Group-Object Text
$commands = $cmdGroup.Name
$meta = [PSScriptMetadata]::new($Path)
#Update the object
$meta.RequiredVersion = $RequiredVersion ? $RequiredVersion : $Null
$meta.RequiresAdmin = $RequiresAdmin
$meta.RequiredModules = $RequiredModules ? $RequiredModules : @()
$meta.Commands = $commands ? $commands : @()
$meta.Author = $Author ? $Author : $Null
$meta.Comment = $Comment ? $Comment : $Null
$meta
} #process
end {
#not used
} #end
}
Inserting Metadata
Let's see how this works.
PS C:\> $f = Get-Item c:\scripts\get-qotd.ps1
PS C:\> New-ScriptMetadata C:\scripts\Get-QOTD.ps1 -Author "Jeff Hicks" -Comment "profile function" | Tee-Object -Variable m
Path : C:\scripts\Get-QOTD.ps1
Name : Get-QOTD.ps1
LastModified : 3/19/2025 9:30:04 AM
RequiredVersion : 4.0
RequiresAdmin : False
RequiredModules : {}
Commands : {Invoke-RestMethod, Update-FormatData, Update-TypeData, Write-Warning}
Author : Jeff Hicks
Comment : profile function
ComputerName : PROSPERO
This is the information I would like to store in an alternate data stream for the script file. I know I want to retain the original timestamps, so I'll save the current file.
We've already seen how is it is to create an alternate data stream for a file.
Set-Content -Stream 'metadata' -Value $m -Path c:\scripts\Get-QOTD.ps1
But there's more to PowerShell than syntax. I need to consider how I want to retrieve this metadata and how I might want to consume it. The command I just ran almost works, but it has to treat the metadata object as a string.
PS C:\> Get-FileStreamInfo C:\scripts\Get-QOTD.ps1 | Get-FileStreamData
Path Stream Data
---- ------ ----
C:\scripts\Get-QOTD.ps1 metadata PSScriptMetadata
PS C:\> Get-FileStreamInfo C:\scripts\Get-QOTD.ps1 | Get-FileStreamData | Select-Object -ExpandProperty Data
PSScriptMetadata
PS C:\>
I'm using a function I shared in Part 2 to get the alternate data streams.
Set-Content
has a AsByteStream
parameter but I would need to convert the metadata object into a byte array and then convert it back to an object. That's not practical. I think my choices are to convert the metadata object to a string using ConvertTo-Json
or ConvertTo-Clixml
. I wrote the class definition to implement the conversion as a class method.
PS C:\> $m.ToJson() | ConvertFrom-Json
Path : C:\scripts\Get-QOTD.ps1
Name : Get-QOTD.ps1
LastModified : 3/19/2025 9:30:04 AM
RequiredVersion : @{Major=4; Minor=0; Build=-1; Revision=-1; MajorRevision=-1; MinorRevision=-1}
RequiresAdmin : False
RequiredModules : {}
Commands : {Invoke-RestMethod, Update-FormatData, Update-TypeData, Write-Warning}
Author : Jeff Hicks
Comment : profile function
ComputerName : PROSPERO
PS C:\> $m.ToClixml() | ConvertFrom-Clixml
Path : C:\scripts\Get-QOTD.ps1
Name : Get-QOTD.ps1
LastModified : 3/19/2025 9:30:04 AM
RequiredVersion : 4.0
RequiresAdmin : False
RequiredModules : {}
Commands : {Invoke-RestMethod, Update-FormatData, Update-TypeData, Write-Warning}
Author : Jeff Hicks
Comment : profile function
ComputerName : PROSPERO
Even though they look the same, these are two different object types.
PS C:\> $m.ToJson() | ConvertFrom-Json | Get-Member
TypeName: System.Management.Automation.PSCustomObject
Name MemberType Definition
---- ---------- ----------
Equals Method bool Equals(System.Object obj)
GetHashCode Method int GetHashCode()
GetType Method type GetType()
ToString Method string ToString()
Author NoteProperty string Author=Jeff Hicks
Commands NoteProperty Object[] Commands=System.Object[]
Comment NoteProperty string Comment=profile function
ComputerName NoteProperty string ComputerName=PROSPERO
LastModified NoteProperty datetime LastModified=3/19/2025 9:30:04 AM
Name NoteProperty string Name=Get-QOTD.ps1
Path NoteProperty string Path=C:\scripts\Get-QOTD.ps1
RequiredModules NoteProperty Object[] RequiredModules=System.Object[]
RequiredVersion NoteProperty System.Management.Automation.PSCustomObject RequiredVersion=@{Major=4; Minor=0; Build=-1; Revision=-1; MajorR…
RequiresAdmin NoteProperty bool RequiresAdmin=False
PS C:\> $m.ToClixml() | ConvertFrom-Clixml | Get-Member
TypeName: Deserialized.PSScriptMetadata
Name MemberType Definition
---- ---------- ----------
GetType Method type GetType()
ToString Method string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.T…
Author Property System.String {get;set;}
Commands Property Deserialized.System.String[] {get;set;}
Comment Property System.String {get;set;}
ComputerName Property System.String {get;set;}
LastModified Property System.DateTime {get;set;}
Name Property System.String {get;set;}
Path Property System.String {get;set;}
RequiredModules Property Deserialized.System.String[] {get;set;}
RequiredVersion Property System.Version {get;set;}
RequiresAdmin Property System.Boolean {get;set;}
Where this might make a difference is when it comes time to extract the metadata. My revised function also has a custom format file for the new class.

The easiest way to restore the metadata is to use the Clixml
format.
With this information in hand, I should be able to insert metadata into the alternate data stream.
$item= "C:\Scripts\Get-QOTD.ps1"
$save = Get-Item -Path $item
$meta = New-ScriptMetadata C:\scripts\Get-QOTD.ps1 -Author "Jeff Hicks" -Comment "profile function"
Set-Content -Stream 'metadata' -Value $meta.ToClixml() -Path $item
Set-ItemProperty -Path $item -Name LastWriteTime -Value $save.LastWriteTime
Set-ItemProperty -Path $item -Name LastAccessTime -Value $save.LastAccessTime
To retrieve the metadata, I need to treat the alternate data stream as a string.
(Get-FileStreamData -Path $item -Streams 'metadata').data | Out-String | ConvertFrom-Clixml
Path: [PROSPERO] C:\scripts\Get-QOTD.ps1
ReqVersion ReqAdmin ReqModules Author Comment
---------- -------- ---------- ------ -------
4.0 False {} Jeff Hicks profile function
Function-ize It
Now that I can do this for one file, it is time to scale and create functions.
function Set-ScriptMetadata {
[CmdletBinding(SupportsShouldProcess)]
param (
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'The path to the PowerShell file.')]
[ValidateScript({ Test-Path $_ })]
[ValidatePattern('\.ps(m)?1$')]
[ValidateNotNullOrEmpty()]
[string]$Path,
[string]$Author,
[string]$Comment
)
begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
if ($PSBoundParameters.ContainsKey('WhatIf')) {
$PSBoundParameters.Remove('WhatIf')
}
if ($PSBoundParameters.ContainsKey('Confirm')) {
$PSBoundParameters.Remove('Confirm')
}
} #begin
process {
#convert to a file system path
$Path = Convert-Path -Path $Path
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Setting script metadata on $Path"
$save = Get-Item -Path $Path
#splat bound parameters
$meta = New-ScriptMetadata @PSBoundParameters
Write-Information $meta
#the stream could be passed as a parameter value
Set-Content -Stream 'metadata' -Value $meta.ToClixml() -Path $Path
Set-ItemProperty -Path $Path -Name LastWriteTime -Value $save.LastWriteTime
Set-ItemProperty -Path $Path -Name LastAccessTime -Value $save.LastAccessTime
}
end {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
}
function Get-ScriptMetadata {
[CmdletBinding()]
param (
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'The path to the PowerShell file.')]
[ValidateScript({ Test-Path $_ })]
[ValidatePattern('\.ps(m)?1$')]
[ValidateNotNullOrEmpty()]
[string]$Path,
[ValidateNotNullOrEmpty()]
[string]$Stream = 'metadata'
)
begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
} #begin
process {
$Path = Convert-Path $Path
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
Try {
[string]$alternate = Get-Content -Path $Path -Stream $stream -ErrorAction Stop
}
Catch {
Write-Verbose "Failed to find an alternate stream called $stream in $Path"
}
If ($alternate) {
ConvertFrom-CliXml -InputObject $alternate
Remove-Variable -Name alternate
}
} #process
end {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
}
This makes it easy to update multiple files at once.
dir c:\work\demo*.ps1 | Set-ScriptMetadata -Author "Jeff Hicks" -Comment "added $(Get-Date)"
Extracting the metadata is just as easy.
PS C:\> dir c:\work\demo*.ps1 | Get-ScriptMetadata | select *
Path : C:\work\demo.ps1
Name : demo.ps1h
LastModified : 7/29/2025 3:04:11 PM
RequiredVersion : 7.5
RequiresAdmin : True
RequiredModules : {}
Commands : {ConvertTo-Csv, DL, Get-PSReleaseAsset, GetData…}
Author : Jeff Hicks
Comment : added 07/29/2025 15:05:52
ComputerName : PROSPERO
Path : C:\work\demo2.ps1
Name : demo2.ps1
LastModified : 3/23/2023 6:24:38 PM
RequiredVersion :
RequiresAdmin : False
RequiredModules : {}
Commands : {ConvertTo-Csv, DL, Get-PSReleaseAsset, GetData…}
Author : Jeff Hicks
Comment : added 07/29/2025 15:05:52
ComputerName : PROSPERO
Path : C:\work\demo3.ps1
Name : demo3.ps1
LastModified : 7/29/2025 3:05:39 PM
RequiredVersion : 5.1
RequiresAdmin : False
RequiredModules : {}
Commands : {ConvertTo-Csv, DL, Get-PSReleaseAsset, GetData…}
Author : Jeff Hicks
Comment : added 07/29/2025 15:05:52
ComputerName : PROSPERO
And if I am using my custom format file, the metadata will be properly formatted.
Summary
Be careful with the name of your alternate stream, especially if using anything other than alphanumeric values. During my testing, I found I couldn't copy files with alternate streams to my NAS is the stream name contained a ?
. I believe I may have made a comment in an earlier article that you could use any character in the stream name. I was over thinking the stream structure. I started using the streams.exe
utility from SysInternals.
C:\> D:\OneDrive\Tools\streams.exe -nobanner c:\temp\Show-LatestPSPodcast.ps1
c:\temp\Show-LatestPSPodcast.ps1:
:Author:$DATA 12
:authorInfo:$DATA 12
$DATA
is an indication of the data in the alternate stream, not the name of a stream. All of this is to remind you to be careful and test thoroughly before you get too far.