ACGCore.psm1

$script:__RNG = New-Object System.Random
# CreateShortcut.ps1

function Create-Shortcut(){
    param(
        [parameter(Mandatory=$true, position=1)][String]$ShortcutPath,
        [parameter(Mandatory=$true, position=2)][String]$TargetPath,
        [parameter(Mandatory=$false, position=3)][String]$Arguments,
        [parameter(Mandatory=$false, position=4)][String]$IconLocation
    )

    if ($ShortcutPath -match '^(?<directory>([A-Z]:|\.)[\\/]([^\\/]+[\\/])*)(?<filename>.*\.lnk)$'){
        $shortcutDir  = $Matches.directory
        $shortcutFile = $Matches.filename
    } else {
        shoutOut "Invalid path: " Error -NoNewline
        shoutOut "$shortcutPath" Error
        return $false
    }

    $WSShell = New-Object -ComObject WScript.shell
    $shortcut = $WSShell.CreateShortcut($ShortcutPath)
    $shortcut.TargetPath = $TargetPath
    if ($Arguments) { $shortcut.Arguments = $Arguments }
    if ($IconLocation) { $shortcut.IconLocation = $IconLocation }
    $shortcut.Save()
    return $true

}
# Generate new random SecureString to use as a password.
function New-RandomString {
    [CmdletBinding()]
    param(
        [int]$Length=8,
        [string]$Characters="abcdefghijklmnopqrstuvwxyz0123456789-_",
        [switch]$AsSecureString
    )

    $rng = $script:__RNG

    if ($AsSecureString) {
        $password = New-Object securestring
        for ($i = 0; $i -lt $Length; $i++) {
            $c = $Characters[$rng.Next($Characters.Length)]
            if ($rng.Next(10) -gt 4) {
                $c = "$c".ToUpper()
            }

            $password.AppendChar($c)
        }
    } else {
        $password = ""
        for ($i = 0; $i -lt $Length; $i++) {
            $c = $Characters[$rng.Next($Characters.Length)]
            if ($rng.Next(10) -gt 4) {
                $c = "$c".ToUpper()
            }

            $password += $c
        }
    }

    return $password
}
<#
.SYNOPSIS
Parsing function used for ACGroup-style .ini configuration files.
 
.DESCRIPTION
Used to parse ACGroup-style .ini files.
 
Grammar:
    file -> <lines>
    lines -> <line> | <line><lines>
    line -> <include> | <section header> | <declaration> | <comment> | <empty>
    include -> is<comment>
    section header -> sh<comment>
    declarations -> sd<comment>
    comment -> c
    empty -> e
 
Terminals:
    is: Include Statement
        ^#include\s[^\s#]+
 
    sh: Section Header
        ^\s*\[[^\]]+\]
 
    sd: Setting Declaration
        ^\s*[^\s=#]+\s*(=\s*([^#]|\\#)+|`"[^`"]*`"|'[^']*')?
 
    c: Comment
        (?<![\\])#.*
 
    e: Empty line
        \s*
 
Additional Rules:
    - The first declaration of the file must be preceeded by a section header.
    - If more than one value is declared for a setting, they will be collected
      into an array.
    - All values will be read as strings and the application using the
      configuration must determine how to interpret the values.
 
.PARAMETER Path
The path to the configuration file.
 
.PARAMETER Content
Alternatively content to be parsed can be provided as a string.
 
.PARAMETER Config
Pre-populated configuration hashtable. If provided, the parser will add new settings to the hashtable.
The default behavior is to generate a new hashtable.
 
.PARAMETER NoInclude
Causes the parser skip include statements.
 
.PARAMETER NotStrict
Stops the parser from throwing an exceptions when errors are encountered.
 
.PARAMETER Silent
Stops the parser from outputting anything to the console.
 
.PARAMETER MetaData
Hashtable used to record MetaData while parsing.
Presently only records Includes and errors.
 
.PARAMETER Cache
Hashtable used to cache the results of each file parsed. Useful to minimize
reads from disk when parsing multiple job files using the common includes.
 
.PARAMETER Loud
Causes the parser to output extra information to the console.
 
.PARAMETER duplicatesAllowed
Names of settings for which duplicate values are allowed.
 
By default, if there are two declarations of the same setting with the same value,
the second occurence of the value will be discarded. When a setting name is
specified here, the second occurrence will instead be appended to the list of
values for the setting.
 
.PARAMETER IncludeRootPath
The root path to use when resolving includes. If this value isn't provided
then it will default to the directory part of $Path.
 
Include-paths that start with '\' or '/' will use this value when resolving
where to look for the included file.
 
Paths that do not start with either '\' or '/' will use the directory of the
file currently being processed.
 
If the command is called using the "String" parameter set, then this value will
default to $pwd (current working directory).
 
All included files will be parsed using the same IncludeRootPath.
 
.EXAMPLE
Normal Read:
    $conf = Parse-Config "C:\Config.ini"
 
Accumulating information into a configuration hashtable:
    $conf = Parse-Config "C:\Config2.ini" $config
 
Skipping #include statements:
    $conf = Parse-Config "C:\Config.ini" -NoInclude
 
Stop the parser from throwing an exception on error (use MetaData object to record errors):
    $metadata = @{}
    $conf = Parse-Config "C:\Config.ini" -NotStrict -MetaData $metadata
    # Echo out the errors:
    $metadata.Errors | % { Write-Host $_ }
 
.NOTES
General notes
#>

function Parse-ConfigFile {
    [CmdletBinding(DefaultParameterSetName="File")]
    param (
        [parameter(
            Mandatory=$true,
            Position=1,
            ParameterSetName="File",
            HelpMessage="Path to the file."
        )] [String] $Path,              # Name of the job-file to parse (including extension)
        [parameter(
            Mandatory=$true,
            Position=1,
            ParameterSetName="String",
            HelpMessage="Content to be parsed instead of reading from the file path. If this option is used and the path is not an actual file path, then 'IncludeRootPath' MUST be specified. Path must be specified regardless."
        )] [string]$Content,
        [parameter(
            Mandatory=$false,
            Position=2,
            HelpMessage="Pre-populated configuration hashtable. If provided, any options read from the given file will be appended."
        )] [Hashtable] $Config = @{},  # Pre-existing configuration, if given we'll simply add to this one.
        [parameter(
            Mandatory=$false,
            HelpMessage="Tells the parser to skip include stetements."
        )] [Switch] $NoInclude,                   # Tells the parser to skip any include statements
        [Parameter(
            Mandatory=$false,
            HelpMessage="Tells the parser not to throw an exception on parsing errors."
        )] [Switch] $NotStrict,                   # Tells the parser to not generate any exceptions.
        [Parameter(
            Mandatory=$false,
            HelpMessage="Suppresses all command-line output from the parser."
        )] [Switch] $Silent,                      # Supresses all commandline-output from the parser.
        [parameter(
            Mandatory=$false,
            HelpMessage='Hashtable used to record MetaData. Includes will be recorded in $MetaData.Includes.'
        )] [Hashtable] $MetaData,                 # Hashtable used to capture MetaData while parsing.
                                                  # This will record Includes as '$MetaData.includes'.
        [Parameter(
            Mandatory=$false,
            HelpMessage='Hashtable used to cache includes to minimize reads from disk when rapidly parsing multiple files using common includes.'
        )][Hashtable] $Cache,
        [parameter(
            Mandatory=$false,
            HelpMessage="Causes the Parser to output extra information to the console."
        )] [Switch] $Loud,                        # Equivalent of $Verbose
        [parameter(
            Mandatory=$false,
            HelpMessage="Array of settings for which values can be duplicated."
        )] [array]
        $duplicatesAllowed = @("Operation","Pre","Post"),                    # Declarations for which duplicate values are allowed.
        [parameter(
            Mandatory=$false,
            HelpMessage="The root directory used to resolve includes. Defaults to the directory of the config file."
        )] [string]$IncludeRootPath               # The root directory used to resolve includes.
    )
    
    # Error-handling specified here for reusability.
    $handleError = {
        param(
            [parameter(Mandatory=$true)] [String] $Message
        )
        if ($MetaData) {
            $MetaData.Errors = $Message
        }
        
        if ($NotStrict) {
            if (!$Silent) { write-host $Message -ForegroundColor Red }
        } else {
            throw $Message
        }
    }

    $Verbose = if (($Verbose -or $Loud) -and !$Silent) { $true } else { $false }
    

    switch ($PSCmdlet.ParameterSetName) {
    
        "File" {
            if( $Path -and ([System.IO.File]::Exists($Path)) ) {
                $lines = [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::UTF8)
            } else {
                . $handleError -Message "<InvalidPath>The given path doesn't lead to an existing file: '$Path'"
                return
            }

            $currentDir = [System.IO.Directory]::GetParent($Path)

        }

        "String" {
            $lines = $Content -split "`n"
            $currentDir = "$pwd"
        }
    }

    if (!$PSBoundParameters.ContainsKey("IncludeRootPath")) {
        $IncludeRootPath = $currentDir
    }

    $conf = @{}
    if ($Config) { # Protect against NULL-values.
        $conf = $Config
    }

    if ($MetaData) {
        if (!$MetaData.Includes) { $MetaData.Includes = @() }
        if (!$MetaData.Errors)   { $MetaData.Errors = @() }
    }
    
    $regex = @{ }
    $regex.Comment = "(?<![\\])#(?<comment>.*)"
    $regex.Include = "^#include\s+(?<include>[^\s#]+)\s*($($regex.Comment))?$"
    $regex.Heading = "^\s*\[(?<heading>[^\]]+)\]\s*($($regex.Comment))?$"
    $regex.Setting = "^\s*(?<name>[^\s=#]+)\s*(=\s*(?<value>([^#]|\\#)+|`"[^`"]*`"|'[^']*'))?\s*($($regex.Comment))?$"
    $regex.Entry   = "^\s*(?<entry>.+)\s*"
    $regex.Empty   = "^\s*($($regex.Comment))?$"  

    $linenum = 0
    $CurrentSection = $null
    foreach($line in $lines) {
        $linenum++
        switch -Regex ($line) {
            $regex.Include {
                if ($Verbose) {
                    write-host -ForegroundColor Green "Include: '$line'";
                    Write-Host "------[Start:$($Matches.include)]".PadRight(80, "-")
                }
                if ($NoInclude) { continue }
                if ($MetaData) { $MetaData.includes += $Matches.include }
                
                $includePath = $Matches.include
   
                $parseArgs = @{
                    Config=$conf;
                    MetaData=$MetaData;
                    Cache=$Cache
                    IncludeRootPath=$IncludeRootPath;
                }

                if ($includePath -match "^[/\\]") {
                    $parseArgs.Path = "$IncludeRootPath${includePath}.ini" # Absolute path.
                } else {
                    $parseArgs.Path = "$currentDir\${includePath}.ini"; # Relative path.
                }

                if ($PSBoundParameters.ContainsKey("Verbose")) { $parseArgs.Verbose = $Verbose }
                if ($PSBoundParameters.ContainsKey("NotStrict")) { $parseArgs.NotStrict = $NotStrict }
                if ($PSBoundParameters.ContainsKey("Silent"))  { $parseArgs.Silent = $Silent }

                try {

                    if ($Cache) {
                        $parseArgs.Remove("Config")
                        if ($Cache.ContainsKey($parseArgs.Path)) {
                            if ($Loud) { Write-Host "Found include file in the cache!" -ForegroundColor Green }
                            $ic = $Cache[$parseArgs.Path]
                        } else {
                            if ($Loud) { Write-Host "include file not found in the cache, parsing file..." -ForegroundColor Yellow }
                            $ic = Parse-ConfigFile @parseArgs
                            $Cache[$parseArgs.Path] = $ic
                        }
                        $conf = Merge-Configs $conf $ic -duplicatesAllowed $duplicatesAllowed
                    } else {
                        Parse-ConfigFile @parseArgs | Out-Null
                    }
                    
                } catch {
                    if ($_.Exception -like "<InvalidPath>*") {
                        . $handleError -Message $_
                    } else {
                        . $handleError "An unknown exception occurred while parsing the include file at '$($parseArgs.Path)' (in root file '$Path'): $_"
                    }
                }

                if ($Verbose) {
                    Write-Host "------[End:$includePath]".PadRight(80, "-")
                }
                break;
            }
            $regex.Heading {
                if ($Verbose) {  write-host -ForegroundColor Green "Heading: '$line'"; }
                $CurrentSection = $Matches.Heading
                if (!$conf[$Matches.Heading]) {
                    $conf[$Matches.Heading] = @{ }
                } 
                break;
            }
            $regex.Setting {
                if (!$CurrentSection) {
                    . $handleError -Message "<OrphanSetting>Ecountered a setting before any headings were declared (line $linenum in '$Path'): '$line'"
                }

                if ($Verbose) { Write-Host -ForegroundColor Green "Setting: '$line'"; }
                $value = $Matches.Value -replace "\\#","#" # Strip escape character from literal '#'s
                if ($conf[$CurrentSection][$Matches.Name]) {
                    if ($conf[$CurrentSection][$Matches.Name] -is [Array]) {
                        if ( ($Matches.Name -in $duplicatesAllowed) -or (-not $conf[$CurrentSection][$Matches.Name].Contains($value)) ) {
                            $conf[$CurrentSection][$Matches.Name] += $value
                        }
                    } else {
                        $conf[$CurrentSection][$Matches.Name] = @( $conf[$CurrentSection][$Matches.Name], $value )
                    }
                } else {
                    $v = if ($null -eq $value) { "" } else { $value } # Convertion to match the behaviour of Read-Conf
                    $conf[$CurrentSection][$Matches.Name] = $v
                }
                break;
            }
            $regex.Empty   {
                if ($Verbose) {  Write-Host -ForegroundColor Green "Empty: '$line'"; }
                break;
            }
            default {
                . $handleError "<MalformedLine>Found an unrecognizable line (line $linenum in $path): $line"
                break;
            }
        }
    }

    if ($Cache) {
        $Cache[$Path] = $conf
    }

    return $conf
}

function Merge-Configs {
    param(
        [Parameter(Mandatory=$true,  HelpMessage="Configuration 1, values from this object will appear first in the cases where values overlap.")]
        [ValidateNotNull()][hashtable]$C1,
        [Parameter(Mandatory=$true,  HelpMessage="Configuration 2, values from this object will appear last in the cases where values overlap.")]
        [ValidateNotNull()][hashtable]$C2,
        [parameter(Mandatory=$false, HelpMessage="Array of settings for which values can be duplicated.")]
        [array] $duplicatesAllowed = @("Operation","Pre","Post")
    )

    $combineValues = {
        param($n, $v1, $v2)

        $da = $n -in $duplicatesAllowed

        if ($v1 -is [array]) {
            if ($v2 -isnot [array]) {
                if (!$da -and ($v2 -in $v1)) {
                    return $v1
                }
                return $v1 + $v2
            } else {
                $v = $v1
                $v2 | Where-Object {
                    $da -or $_ -notin $v
                } | ForEach-Object {
                    $v += $_
                }
                return $v
            }
        } else {
            if ($v2 -isnot [Array] ) {
                if (!$da -and $v1 -eq $v2) {
                    return $v1
                }
                return @($v1, $v2)
            } else {
                $v = @($v1)
                $v2 | Where-Object {
                    $da -or $_ -notin $v
                } | ForEach-Object { $v += $_ }
                return $v
            }
        } 
    }

    $NC = @{}

    $C1.Keys | Where-Object {
        $_ -and ($C1[$_] -is [hashtable])
    } | ForEach-Object {
        $s = $_
        $NC[$s] = @{}
        $C1[$s].GetEnumerator() | ForEach-Object {
            $NC[$s][$_.Name] = $C1[$s][$_.Name]
        }
    }
    $C2.Keys | Where-Object {
        $_ -ne $null -and ($C2[$_] -is [hashtable])
    } | ForEach-Object {
        $s = $_
        if (!$NC.ContainsKey($s)) {
            $NC[$s] = @{}
        }
        $C2[$s].GetEnumerator() | ForEach-Object {
            $n = $_.Name
            $v = $_.Value

            if (!$NC[$s].ContainsKey($n)) {
                $NC[$s][$n] = $v
                return
            }

            $NC[$s][$n] = . $combineValues $n $NC[$s][$n] $v
        }
    }
    
    return $NC
}
$Script:RegexPatterns = @{ }

$Script:RegexPatterns.IPv4AddressByte = "(25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[0-9])" # A byte in an IPv4 Address
$IPv4AB = $Script:RegexPatterns.IPv4AddressByte
$Script:RegexPatterns.IPv4NetMaskByte = "(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[1-9])" # A non-full byte in a IPv4 Netmask
$IPv4NMB = $Script:RegexPatterns.IPv4NetMaskByte

$ItemChars = "[^\\/:*`"|<>]"
$Script:RegexPatterns.Directory = '(?<directory>(?<root>[A-Z]+:|\.|\\.*)[\\/]({0}+[\\/]?)*)' -f $ItemChars
$Script:RegexPatterns.File = ( '(?<file>(?<directory>((?<root>[A-Z]+:|\.|\\.*)[\\/])?({0}+[\\/])*)(?<filename>([^\\/]+)+(\.(?<extension>[^\\/.]+)?))' + ")" ) -f $ItemChars
$Script:RegexPatterns.IPv4Address = "($IPv4AB\.){3}$($IPv4AB)"
$Script:RegexPatterns.IPv4Netmask = "((255\.){3}$IPv4NMB)|((255\.){2}($IPv4NMB\.)0)|((255\.){1}($IPv4NMB\.)0\.0)|(($IPv4NMB\.)0\.0\.0)|0\.0\.0\.0"
$Script:RegexPatterns.GUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{10}"


<#
.SYNOPSIS
Returns the ACGCore regular expression with the given name.
#>

function Get-ACGCoreRegexPattern {
    param([string]$PatternName)

    if ($Script:RegexPatterns.ContainsKey($PatternName)) {
        return $Script:RegexPatterns[$PatternName]
    } else {
        throw "Invalid pattern name provided"
    }
}

<#
.SYNOPSIS
Rreturns the name of all standard regular expressions used in ACGCore.
#>

function Get-ACGCoreRegexPatternNames {

    return $Script:RegexPatterns.Keys
}


<#
.SYNOPSIS
Matches ACGCore regular expressions against a string.
.DESCRIPTION
Tries to match the given string $value against the pattern named $PatternName.
 
Returns a record of the match if the regex matches the given value (equivalent
to $matches), otherwise returns $false.
 
By default the this function assumes that the entire string should match the
given pattern. This behavior can be overriden by using the AllowPartialMatches
switch, in which case the function will attempt to match any part of the given
string.
#>

function Test-ACGCoreRegexPattern {
    param([string]$Value, [string]$PatternName, [switch]$AllowPartialMatch)

    try {
        $pattern = Get-ACGCoreRegexPattern $PatternName
        
        if (!$AllowPartialMatch) {
            $pattern = "^$pattern$"
        }

        if ($value -match $pattern) {
            return $matches.Clone()
        }

        return $false
    } catch {
        return $false
    }

}
<#
.SYNOPSIS
Renders a template file.
 
.DESCRIPTION
Renders a template file of any type (HTML, CSS, RDP, etc..) using powershell expressions
written between '<<' and '>>' markers to interpolate dynamic values.
 
Files may also be included into the template by using <<(<path to file>)>>, if the file is
a .ps1 file it will be interpreted as an expression to be executed, otherwise it will be
treated as a template file and rendered using the same Values.
 
.PARAMETER templatePath
The path to the template file that should be rendered (relative or fully qualified,
UNC paths not supported).
 
.PARAMETER values
A hashtable of values that should be used when resolving powershell expressions.
The keys in this hashtable will introduced as variables into the resolution context.
 
The $values variable itself is available as well.
 
.PARAMETER Cache
A hashtable used to cache the results of loading template files.
 
Passing this parameter allows you to retain the cache between calls to Render-Template,
otherwise a new hashtable will be generated for each call to Render-Template.
 
Recursive calls to Render-Template will attempt to reuse the same cache object.
 
During rendering the cache is available as '$__RenderCache'.
 
.PARAMETER StartTag
Tag used to indicate the start of a section in the text that should be interpolated.
 
This string will be treated as a regular expression, so any special characters
('*', '+', '[', ']', '(', ')', '\', '?', '{', '}', etc) should be escaped with a '\'.
 
The default start tag is '<<'.
 
 
.PARAMETER EndTag
Tag used to indicate the end of a section in the text that should be interpolated.
 
This string will be treated as a regular expression, so any special characters
('*', '+', '[', ']', '(', ')', '\', '?', '{', '}', etc) should be escaped with a '\'.
 
The default end tag is '>>'.
 
.EXAMPLE
Contents of .\page.template.html:
    <h1><<$Title>></he1>
    <h2><<$values.Chapter1>></h2>
     
    <<(.\pages\1.html)>>
 
Contents of .\pages\1.html:
 
    It was the best of times, it was the worst of times.
 
Running:
    $details = @{
        Title = "A tale of two cities"
        Chapter1 = "The Period"
    }
    Render-Template .\page.template.html $details
 
Will yield:
    <h1>A tale of two cities</h1>
    <h2>The Period</h2>
     
    It was the best of times, it was the worst of times.
 
 
.NOTES
The markup using the default '<<' and '>>' tags to denote the start and end of an interpolated
expression precludes the use of the '>>' output operator in the expressions. This is considered
acceptable, since the intention of the expressions is to introduce values into the text,
rather than writing to the disk.
 
Any expression that is so complicated that you might need to write to the disk should
probably be handled as a closure or a function passed in via the $values parameter, or
a file included using a <<()>> expression.
 
Alternatively, you can use the the EndTag parameter top provide another acceptable end tag (e.g. '!>>').
 
#>

function Render-Template{
    [CmdletBinding()]    
    param(
        [parameter(
            Mandatory=$true,
            HelpMessage="Path to the template file that should be rendered. Available when rendering."
        )]
        [String]$TemplatePath,
        [parameter(
            Mandatory=$true,
            HelpMessage="Hashtable with values used when interpolating expressions in the template. Available when rendering."
        )]
        [hashtable]$values,
        [Parameter(
            Mandatory=$false,
            HelpMessage='Optional Hashtable used to cache the content of files once they are loaded. Pass in a hashtable to retain cache between calls. Available as $__RenderCache when rendering.'
        )]
        [hashtable]$Cache = $null,
        [Parameter(
            Mandatory=$false,
            HelpMessage='Tag used to open interpolation sections. Regular Expression.'
        )]
        [string]$StartTag = '<<',
        [Parameter(
            Mandatory=$false,
            HelpMessage='Tag used to close interpolation sections. Regular expression.'
        )]
        [string]$EndTag = '>>'
    )


    $EndTagStart = $EndTag[0]
    if ($EndTagStart -eq '\') {
        $EndTagStart.Substring(0, 2)
    }
    $EndTagRemainder = $EndTag.Substring($EndTagStart.Length)
    $InterpolationRegex = "{0}(\((?<path>.+)\)|(?<command>([^{1}]|{1}(?!{2}))+)){3}" -f $StartTag, $EndTagStart, $EndTagRemainder, $EndTag

    if ($Cache) {
        Write-Debug "Cache provided by caller, updating global."
        $script:__RenderCache = $Cache
    }

    if ($null -eq $Cache) { 
        
        Write-Debug "Looking for cache..."
        
        if ($Cache = $script:__RenderCache) {
            Write-Debug "Using global cache."
        } elseif ($cacheVar = $PSCmdlet.SessionState.PSVariable.Get("__RenderCache")) {
            # This is a recursive call, we can reuse the cache from parent.
            $Cache = $cacheVar.Value
            Write-Debug "Found cache in parent context."
        }

    }

    if ($null -eq $cache) {
        Write-Debug "Failed to get cache from parent. Creating new cache."
        $Cache = @{}
        $script:__RenderCache = $Cache
    }

    $templatePath = Resolve-Path $templatePath

    Write-Debug "Path resolved to '$templatePath'"

    $template = $null

    if ($Cache.ContainsKey($templatePath)) {
        Write-Debug "Found path in cache..."
        try {
            $item = Get-Item $TemplatePath
            if ($item.LastWriteTime.Ticks -gt $Cache[$templatePath].LoadTime.Ticks) {
                Write-Debug "Cache is out-of-date, reloading..."
                $t = [System.IO.File]::ReadAllText($templatePath)
                $Cache[$templatePath] = @{ Value = $t; LoadTime = [datetime]::now }
            }
        } catch { <# Do nothing for now #> }
        $template = $Cache[$templatePath].Value
    } else {
        Write-Debug "Not in cache, loading..."
        $template = [System.IO.File]::ReadAllText($templatePath)
        $Cache[$templatePath] = @{ Value = $template; LoadTime = [datetime]::now }
    }

    # Move Cache out of the of possible user-space values.
    $__RenderCache = $Cache
    Remove-Variable "Cache"

    # Defining TemplateDir here to make it accessible when evaluating scriptblocks.
    $TemplateDir = $templatePath | Split-Path -Parent
    
    if (!$__RenderCache[$templatePath].ContainsKey("Digest")) {
        $__buildDigest = {
            param($templateCache)
            
            Write-Debug "Building digest..."
            $__c__ = $templateCache
            $__c__.Digest = @()
            
            $__regex__ = New-Object regex ($InterpolationRegex, [System.Text.RegularExpressions.RegexOptions]::Multiline)
            $__meta__ = @{ LastIndex = 0 }
            
            $__regex__.Replace(
                $template,
                {
                    param($match)
                    # Isolate information about the expression.
                    $__li__ = $__meta__.LastIndex
                    $__g0__ = $match.Groups[0]
                    $__path__    = $match.Groups["path"]
                    $__command__= $match.Groups["command"]
                    
                    # Collect string literal preceeding this expression and add it to the digest.
                    $__ls__ = $template.Substring($__li__, ($__g0__.index - $__li__))
                    $__meta__.LastIndex = $__g0__.index + $__g0__.length
                    $__c__.Digest += $__ls__
                    
                    # Process the expression:
                    if ($__command__.Success) {
                        # Expression is a command: turn it into a script block and add it to the digest.
                        $__c__.Digest += [scriptblock]::create($__command__.value)
                    } elseif ($__path__.Success){
                        # Expand any variables in the path and add the expanded path to digest:
                        $p = $ExecutionContext.InvokeCommand.ExpandString($__path__.Value)
                        $__c__.Digest += @{ path=$p }                        
                    }
                    
                    $__meta__ | Out-String | Write-Debug
                    
                }
            ) | Out-Null
            
            
            if ($__meta__.LastIndex -lt $template.length) {
                $__c__.Digest += $template.substring($__meta__.LastIndex)
            }
        }

        & $__buildDigest $__RenderCache[$templatePath]
    }
    
    # Expand values into user-space to make them more accessible during render.
    $values.GetEnumerator() | % {
        New-Variable $_.Name $_.Value
    }
    
    Write-Debug "Starting Render..."
    $__parts__ = $__RenderCache[$templatePath].Digest | % {
        $__part__ = $_
        switch ($__part__.GetType()) {
            "hashtable" {
                if ($__part__.path) {
                    Write-Debug "Including path..." 
                    $__c__ = Render-Template $__part__.path $Values

                    if ($__part__.path -like "*.ps1") {
                        $__s__ = [scriptblock]::create($__c__)
                        try {
                            $__s__.Invoke()
                        } catch {
                            $msg = "An unexpected exception occurred while Invoking '{0}' as part of '{1}'." -f $__part__.path, $templatePath
                            $e = New-Object System.Exception $msg, $_.Exception

                            throw $e
                        }
                    } else {
                        $__c__
                    }
                }
            }

            "scriptblock" {
                try {
                    $__part__.invoke()
                } catch {
                    $msg = "An unexpected exception occurred while rendering an expression in '{0}': {1}" -f $templatePath, $__part__
                    $e = New-Object System.Exception $msg, $_.Exception

                    throw $e
                }
            }

            default {
                $__part__
            }
        }
    }
    
    $__parts__ -join ""

    
}
function Reset-Module {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Name
    )

    if ($module = Get-Module $Name) {
        Remove-Module $module -Force
    }

    Import-Module $Name -Global
}

<#
.WISHLIST
    - Update so that that output is fed to shoutOut as it is generated rather than using the result output.
      The goal is to generate logging data continuously so that it's clear whether the script has hung or not.
      [Done]
.SYNOPSIS
    Helper function to execute commands (strings or blocks) with error-handling/reporting.
.DESCRIPTION
    Helper function to execute commands (strings or blocks) with error-handling/reporting.
If a scriptblock is passed as the operation, the function will attempt make any variables referenced by the
scriptblock available to the scriptblock when it is resolved (using variables available in the scope that
called Run-Operation).
 
The variables used in the command are identified using [scriptblock].Ast.FindAll method, and are imported
from the parent scope using $PSCmdlet.SessionState.PSVariable.Get.
 
The following variable-names are restricted and may cause errors if they are used in the operation:
 - $__thisOperation: The operation being run.
 - $__inputVariables: List of the variables being imported to run the operation.
 
.NOTES
   - Transforms ScriptBlocks to Strings prior to execution because of a quirk in iex where it will not allow the
     evaluation of ScriptBlocks without an input (a 'param' statement in the block). iex is used because it yields
     output as each line is evaluated, rather than waiting for the entire $OPeration to complete as would be the
     case with <ScriptBlock>.Invoke().
#>

function Run-Operation {
    param(
        [parameter(ValueFromPipeline=$true, position=1)] $Operation,
        [parameter()][Switch] $OutNull,
        [parameter()][Switch] $NotStrict,
        [parameter()][Switch] $LogErrorsOnly
    )
    $color = "Result"
    
    if (!$NotStrict) {
        # Switch error action preference to catch any errors that might pop up.
        # Works so long as the internal operation doesn't also change the preference.
        $OldErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop
    }

    if ($Operation -is [string]) {
        $OPeration = [scriptblock]::create($Operation)
    }

    if (!$LogErrorsOnly) {
        $msg = "Running '$Operation'..."
        $msg | shoutOut -MsgType Info -ContextLevel 1
    }

    $r = try {
        
        # Step 1: Get any variables in the parent scope that are referenced by the operation.
        $localVarNames = Get-variable -Scope 0 | % Name

        if ($Operation -is [scriptblock]) {
            $variableNames = $Operation.Ast.FindAll(
                {param($o) $o -is [System.Management.Automation.Language.VariableExpressionAst]},
                $true
            ) | % {
                $_.VariablePath.UserPath
            } | ? {
                $_ -notin $localVarNames
            }

            $variables = foreach ($vn in $variableNames) {
                $PSCmdlet.SessionState.PSVariable.Get($vn)
            }
        }

        # Step 2: Convert the scriptblock if necessary.
        if ($Operation -is [scriptblock]) {
            # Under certain circumstances the iex cmdlet will not allow
            # the evaluation of ScriptBlocks without an input. However it will evaluate strings
            # just fine so we perform the transformation before evaluation.
            $Operation = $Operation.ToString()
        }

        # Step 3: inject the operation and the variables into a new isolated scope and resolve
        # the operation there.
        & {
            param(
                $thisOperation,
                $inputVariables
            )

            $__thisOperation = $thisOperation
            $__inputVariables = $inputVariables

            Remove-Variable "thisOperation"
            Remove-Variable "inputVariables"

            $__ = $null

            foreach ( $__ in $__inputVariables ) {
                if ($null -eq $__) { continue }
                Set-Variable $__.Name $__.Value
            }

            Remove-Variable "__"

            # Invoke-Expression allows us to receive
            # and handle output as it is generated,
            # rather than wait for the operation to finish
            # as opposed to <[scriptblock]>.invoke().
            Invoke-Expression $__thisOperation | ForEach-Object {
                if (!$LogErrorsOnly) {
                    shoutOut "`t| $_" "Result" -ContextLevel 2;
                }
                return $_
            }
        } $Operation $variables

    } catch {
        $color = "Error"
        "An error occured while executing the operation:" | shoutOUt -MsgType Error -ContextLevel 1

        $_.Exception, $_.CategoryInfo, $_.InvocationInfo, $_.ScriptStackTrace | Out-string | % {
            $_.Split("`n`r", [System.StringSplitOptions]::RemoveEmptyEntries).TrimEnd("`n`r")
        } | % {
            shoutOut "`t| $_" $color -ContextLevel 2
        }

        $_
    }

    if (!$NotStrict) {
        $ErrorActionPreference = $OldErrorActionPreference
    }

    if ($OutNull) {
        return
    }
    return $r
}
function Test-Condition{
    param(
        [Parameter(Mandatory=$true,  Position=1)][scriptblock]$Test,
        [Parameter(Mandatory=$false, Position=2)][scriptblock]$OnPass=$null,
        [Parameter(Mandatory=$false, Position=3)][scriptblock]$OnFail=$null,
        [Parameter(Mandatory=$false, Position=4)][scriptblock]$Evaluate = { param($v) $true -eq $v }
    )

    $r = & $Test
    $pass = & $Evaluate $r

    if ($pass) {
        if ($OnPass) { & $OnPass }
    } else {
        if ($OnFail) { & $OnFail }
    }

    return $pass

}
<#
.SYNOPSIS
Transforms a SecureString back into a plain string. Must the run by the same user, on the same computer where it was produced.
#>

function Unlock-SecureString {
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [SecureString]$SecString
    )
    $Marshal = [Runtime.InteropServices.Marshal]
    $bstr = $Marshal::SecureStringToBSTR($SecString)
    $r = $Marshal::ptrToStringAuto($bstr)
    $Marshal::ZeroFreeBSTR($bstr)
    return $r
}
function Wait-Condition{
    param(
        [Parameter(Mandatory=$true,  Position=1)][scriptblock]$Test,
        [Parameter(Mandatory=$false, Position=2)][scriptblock]$OnPass=$null,
        [Parameter(Mandatory=$false, Position=3)][scriptblock]$OnFail=$null,
        [Parameter(Mandatory=$false, Position=4)][scriptblock]$Evaluate = { param($v) $true -eq $v },
        [Parameter(Mandatory=$false, Position=5)][int]$IntervalMS=200,
        [Parameter(Mandatory=$false, Position=6)][int]$TimeoutMS=0
    )

    $__waitStart__ = [datetime]::Now
    do {
        if ($TimeoutMS -gt 0) {
            $t = ([datetime]::Now - $__waitStart__).TotalMilliSeconds
            if ($t -gt $TimeOutMS) {
                if ($OnFail) { & $OnFail }
                return $false
            }
        }

        Start-Sleep -MilliSeconds $IntervalMS
        $r = Test-Condition -Test $Test -Evaluate $Evaluate
    } while(!$r)

    if ($OnPass) { & $OnPass }

    return $true

}
function Write-ConfigFile {
    param(
        [hashtable]$Config,
        [string]$Path
    )

    [string[]]$output = @()
    $keys = $Config.keys

    $keys = $Keys | Sort-Object

    foreach ($key in $keys) {
        $output += "[$key]"
        foreach ($item in $config[$key].keys) {
            foreach ($value in $config[$key][$item]) {
                if ($null, "" -contains $value) {
                    # Entry, just append it to the output
                    $output += $item
                    continue
                }

                # Setting, Append <item>=<value> to output for each value.
                if ($value -is [string]) {
                    $value = $value.Replace("#", "\#").trimend()
                }
                $output += "{0}={1}" -f $item, $value
            }
        }
        $output += "" # Empty line between each section to make output more readable.
    }


    if ($PSBoundParameters.ContainsKey('Path')) {
        if (!(test-path $Path)) {
            new-item -itemtype file -force -Path $Path | out-null
        }
        [System.IO.File]::WriteAllLines($Path, $output, [System.Text.Encoding]::UTF8)
        #$output | out-file -force -filepath $Path
    }
    else {
        $output
    }

}
# ACGCore.credentials
function Load-Credential {
    [CmdletBinding()]
    param(
        $Path,
        $Key
    )

    $Path = Resolve-Path $path

    $credStr = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
    $u, $p = $credStr.split(":")
    
    $ConvertArgs = @{
        String=$p
    }

    if ($key) {
        $keyBytes = [System.Convert]::FromBase64String($key)
        $ConvertArgs.Key = $keyBytes
    }

    New-Object PScredential $u, (ConvertTo-SecureString @ConvertArgs)
}
<#
    .SYNOPSIS
    Creates a PSCredential.
 
    .DESCRIPTION
    Takes a Username and Password to create a PSCredential.
#>

function New-PSCredential{
    [CmdletBinding(DefaultParameterSetName="ClearText")]
    param(
        [parameter(Mandatory=$true, position=1)][string] $Username,
        [parameter(Mandatory=$true, position=2, ParameterSetName="ClearText")][string] $Password,
        [parameter(Mandatory=$true, position=2, ParameterSetName="SecureString")][securestring]$SecurePassword
    )

    if ($PSCmdlet.ParameterSetName -eq "ClearText") {
        $SecurePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
    }

    $cred = New-Object System.Management.Automation.PSCredential($username, $SecurePassword)

    return $cred
}
function Save-Credential(
    [PSCredential] $Credential,
    [string] $Path,
    [switch] $UseKey,
    [string] $Key
) {

    $convertArgs = @{
        SecureString = $Credential.Password
    }

    if ($UseKey) {
        if ($Key) {
            $bytes = [System.Convert]::FromBase64String($Key)
            if ($bytes.count -ne 32) {
                throw "Invalid key provided for Save-Credential (expected a Base64 string convertable to a 32 byte array)."
            }
        } else {
            $r = [System.Random]::new()
            $bytes = [byte[]]( 0..31 | % { $r.next(0, 255) } )
        }
        $convertArgs.Key = $bytes
    }

    $credStr = "{0}:{1}" -f $Credential.Username, (ConvertFrom-SecureString @convertArgs)
    $credStr | Out-File -FilePath $Path -Encoding utf8

    if ($UseKey) {
        return [System.Convert]::ToBase64String($convertArgs.Key)
    }

}
# ACGCore.os
function Add-LogonOp{
    param(
        [string]$Name,
        [string]$Operation,
        [Switch]$RunOnce,
        [Switch]$Details
    )

    $path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\"

    if ($PSBoundParameters.ContainsKey("RunOnce")) {
        $path += "RunOnce"
    } else {
        $path += "Run"
    }

    try {
        $value = "Powershell -WindowStyle Hidden -Command $Operation"
        $r =  New-ItemProperty -Path $path -Name $Name -Value $value -Force -ErrorAction Stop
        if ($Details) {
            return $r
        } else {
            $true
        }
    } catch {
        if ($Details) {
            return $_
        } else {
            $false
        }
    }
}
# Utility to acquire registry values using reg.exe (uses Run-Operation)
function Query-RegValue($key, $name){
    $regValueQVregex = "\s+{0}\s+(?<type>REG_[A-Z]+)\s+(?<value>.*)"
    { reg query $key /v $name } | Run-Operation | ? {  $_ -match ($regValueQVregex -f $name) } | % {
        $v = $Matches.value
        switch($Matches.type) {
            REG_QWORD {
                $i64c = New-Object System.ComponentModel.Int64Converter
                $v = $i64c.ConvertFrom($v)
            }
            REG_DWORD {
                $i32c = New-Object System.ComponentModel.Int32Converter
                $v = $i32c.ConvertFrom($v)
            }
        }

        $v
    }
}
function Remove-LogonOp {
    param(
        [string]$name,
        [Switch]$RunOnce,
        [Switch]$Details
    )

    $path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\"

    if ($PSBoundParameters.ContainsKey("RunOnce")) {
        $path += "RunOnce"
    } else {
        $path += "Run"
    }

    try {
        Remove-ItemProperty -Path $path -Name $name -Force -ErrorAction Stop | Out-Null
        return $true
    } catch {
        if ($Details) {
            return $_
        } else {
            return $false
        }
    }
}
# Found as part of a script at:
# https://social.technet.microsoft.com/Forums/windowsserver/en-US/e718a560-2908-4b91-ad42-d392e7f8f1ad/take-ownership-of-a-registry-key-and-change-permissions?forum=winserverpowershell
# and cleaned up, to be more presentable.


if (! (Get-TypeData -TypeName "ProcessPrivilegeAdjustor") ) {
    Add-Type -Path "$PSScriptRoot\.assets\ProcessPrivilegeAdjustor.cs"
}

function Set-ProcessPrivilege {
    param(
        ## The privilege to adjust. This set is taken from
        ## http://msdn.microsoft.com/en-us/library/bb530716(VS.85).aspx
        [ValidateSet(
        "SeAssignPrimaryTokenPrivilege", "SeAuditPrivilege", "SeBackupPrivilege",
        "SeChangeNotifyPrivilege", "SeCreateGlobalPrivilege", "SeCreatePagefilePrivilege",
        "SeCreatePermanentPrivilege", "SeCreateSymbolicLinkPrivilege", "SeCreateTokenPrivilege",
        "SeDebugPrivilege", "SeEnableDelegationPrivilege", "SeImpersonatePrivilege", "SeIncreaseBasePriorityPrivilege",
        "SeIncreaseQuotaPrivilege", "SeIncreaseWorkingSetPrivilege", "SeLoadDriverPrivilege",
        "SeLockMemoryPrivilege", "SeMachineAccountPrivilege", "SeManageVolumePrivilege",
        "SeProfileSingleProcessPrivilege", "SeRelabelPrivilege", "SeRemoteShutdownPrivilege",
        "SeRestorePrivilege", "SeSecurityPrivilege", "SeShutdownPrivilege", "SeSyncAgentPrivilege",
        "SeSystemEnvironmentPrivilege", "SeSystemProfilePrivilege", "SeSystemtimePrivilege",
        "SeTakeOwnershipPrivilege", "SeTcbPrivilege", "SeTimeZonePrivilege", "SeTrustedCredManAccessPrivilege",
        "SeUndockPrivilege", "SeUnsolicitedInputPrivilege")]
        $Privilege,
        ## The process on which to adjust the privilege. Defaults to the current process.
        $ProcessId = $pid,
        ## Switch to disable the privilege, rather than enable it.
        [Switch] $Disable
    )

    $processHandle = (Get-Process -id $ProcessId).Handle
    [ProcessPrivilegeAdjustor]::SetPrivilege($processHandle, $Privilege, $Disable)
}
function Set-RegValue($key, $name, $value, $type=$null) {
    if (!$type) {
        if ($value -is [int16] -or $value -is [int32]) {
            $type = "REG_DWORD"
        } elseif ($value -is [int64]) {
            $type = "REG_QWORD"
        } else {
            $type = "REG_SZ"
        }
    }
    switch($type) {
        "REG_SZ" {
            { reg add $key /f /v $name /t $type /d "$value" } | Run-Operation
        }
        default {
            { reg add $key /f /v $name /t $type /d $value } | Run-Operation
        }
    }
}
#Set-WinAutoLogon.ps1

function Set-WinAutoLogon {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=1, ParameterSetName="Credential")]
        [pscredential]$LogonCredential,
        [Parameter(Mandatory=$true, Position=1, ParameterSetName="Params")]
        [String]$Username,
        [Parameter(Mandatory=$true, Position=2, ParameterSetName="Params")]
        [SecureString]$Password,
        [Parameter(Mandatory=$false, Position=3, ParameterSetName="Params")]
        [String]$Domain=".",
        [Parameter(Mandatory=$false)]
        [int]$AutoLogonLimit=100000
    )

    $templatePath = "$PSScripRoot\.assets\templates\winlogon.tmplt.reg"
    $Values = $null

    switch ($PSCmdlet.ParameterSetName) {

        "Params" {
            $values = @{
                Username    = $Username
                Password    = Unlock-SecureString $Password
                Domain      = $Domain
            }
        }

        "Credential" {
            $values = @{
                Domain      = "."
                Password    = Unlock-SecureString $LogonCredential.Password
            }
            $LogonCredential.UserName -match "((?<domain>.+)\\)?(?<username>.+)"
            if ($matches.domain) {
                $v.domain = $matches.domain
            }
            $v.Username = $matches.Username
        }

    }
    
    $valeus.AutoLogonLimit = $AutoLogonLimit

    $tmpFile = [System.IO.Path]::GetTempFileName()

    Rendter-Template $templatePath $values > $tmpFile

    reg import $tmpFile
    Remove-Item $tmpFile
}
<#
.SYNOPSIS
    Grants ownership of the given registry key to the designated user (default is the current user).
.PARAMETER RegKey
    The registry key to steal, can be specified with or without a root key (HKLM, HKCU, HKU, etc.).
    if no root key is specified then the key is presumed to be under HKLM.
 
    Root keys can be designated in their short form (e.g. HKLM, HKCU) or their full-length
    form (e.g. HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER).
     
    Separating the root key by a colon (:) is optional. Both "HKLM\" and "HKLM:\" are valid
    ways of designating the HKEY_LOCAL_MACHINE root key.
.PARAMETER User
    The name of the user that should become the owner of the given registry key.
#>

function Steal-RegKey {
    param(
        [parameter(Mandatory=$true,  Position=1)][String]$RegKey,
        [parameter(Mandatory=$false, position=2)][String]$User=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name
    )

    Set-ProcessPrivilege SeTakeOwnershipPrivilege

    $OriginalRegKey = $RegKey 
    $registry = $null

    switch -regex ($RegKey) {
        "^(HKEY_LOCAL_MACHINE|HKLM)(:)?[\\/]" {
            $registry = [Microsoft.Win32.Registry]::LocalMachine
            $RegKey = $RegKey -replace "^[^\\/]+[\\/]",""
        }
        "^(HKEY_CURRENT_USER|HKCU)(:)?[\\/]" {
            $registry = [Microsoft.Win32.Registry]::CurrentUser
            $RegKey = $RegKey -replace "^[^\\/]+[\\/]",""
        }
        "^(HKEY_USERS|HKU)(:)?[\\/]" {
            $registry = [Microsoft.Win32.Registry]::Users
            $RegKey = $RegKey -replace "^[^\\/]+[\\/]",""
        }
        "^(HKEY_CURRENT_CONFIG|HKCC)(:)?[\\/]" {
            $registry = [Microsoft.Win32.Registry]::Users
            $RegKey = $RegKey -replace "^[^\\/]+[\\/]",""
        }
        "^(HKEY_CLASSES_ROOT|HKCR)(:)?[\\/]" {
            $registry = [Microsoft.Win32.Registry]::Users
            $RegKey = $RegKey -replace "^[^\\/]+[\\/]",""
        }
        default {
            $registry = [Microsoft.Win32.Registry]::LocalMachine
        }
    }

    $key = { $registry.OpenSubKey(
        $RegKey,
        [Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
        [System.Security.AccessControl.RegistryRights]::takeownership
    ) } |Run-Operation

    if (!$key) {
        shoutOut "Unable to find '$OriginalRegKey'" Red
        return 
    }

    # You must get a blank acl for the key b/c you do not currently have access
    $acl = { $key.GetAccessControl([System.Security.AccessControl.AccessControlSections]::None) } | Run-Operation
    $me = [System.Security.Principal.NTAccount]$user
    $acl.SetOwner($me)
    { $key.SetAccessControl($acl) } | Run-Operation | Out-Null

    # After you have set owner you need to get the acl with the perms so you can modify it.
    $acl = { $key.GetAccessControl() } | Run-Operation
    $rule = New-Object System.Security.AccessControl.RegistryAccessRule ("BuiltIn\Administrators","FullControl","Allow")
    { $acl.SetAccessRule($rule) } | Run-Operation | Out-Null
    { $key.SetAccessControl($acl) } | Run-Operation | Out-Null

    $key.Close()
    shoutOut "Done!" Green
}
# ACGCore.text
#ConvertFrom-UnicodeEscapedString.ps1

function ConvertFrom-UnicodeEscapedString {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [string]$InString
    )

    return [System.Text.RegularExpressions.Regex]::Unescape($InString)
}
#ConvertTo-UnicodeEscapedString.ps1

function ConvertTo-UnicodeEscapedString {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [String]$inString
    )

    $sb = New-Object System.Text.StringBuilder

    $inChars = [char[]]$inString

    foreach ($c in $inChars) {
        $encV = if ($c -gt 127) {
            "\u"+([int]$c).ToString("X4")
        } else {
            $c
        }
        $sb.Append($encV) | Out-Null
    }

    return $sb.ToString()
}