Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
August 5, 2025

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, and ComputerName 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.

PSMetadata class formatting
figure 1

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.

(c) 2022-2025 JDH Information Technology Solutions, Inc. - all rights reserved
Don't miss what's next. Subscribe to Behind the PowerShell Pipeline:
Start the conversation:
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.