loader.ps1


# this is used only to load the module and its attributes

function Register-ParameterCache
{
  <#
    .SYNOPSIS
    Loads the parameter attributes
 
    .DESCRIPTION
    Once you run this command, or manually import module "ParameterCache" (Import-Module -Name ParameterCache),
    you have access to the following attributes:
 
    [AutoLearn('user')] : caches the values for a parameter in list "user"
    [AutoComplete('user')] : autocompletes the previously entered values from list "user"
    [AutoLearnCredential('user')] : same for credentials (to secure storage)
    [AutocompleteCredential('user')] : same for credentials (from secure storage)
 
    .EXAMPLE
    Register-ParameterCache
    Call this before using the attributes, or make the module "ParameterCache" a prerequisite for your own modules.
 
    .LINK
    URLs to related sites
  #>

}


class AutoLearnAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'

    # define prefix character used to delete the hint list
    [char]$ClearKey = '!'

    # define parameterless constructor:
    AutoLearnAttribute() : base()
    {}

    # define constructor with parameter for id:
    AutoLearnAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    # Transform() is called whenever there is a variable or parameter assignment, and returns the value
    # that is actually assigned:
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.hint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # add to hashtable
              ForEach-Object {
                # value is not used, set it to $true:
                $hints[$_] = $true
              }
        }

        # does the user input start with the clearing key?
        if ($inputData.StartsWith($this.ClearKey))
        {
            # remove the prefix:
            $inputData = $inputData.SubString(1)

            # clear the hint list:
            $hints.Clear()
        }

        # add new value to hint list
        if(![string]::IsNullOrWhiteSpace($inputData))
        {
            $hints[$inputData] = $true
        }
        # save hints list
        $hints.Keys | Sort-Object | Set-Content -Path $hintPath -Encoding Default 
        
        # return the user input (if there was a clearing key at its start,
        # it is now stripped):
        return $inputData
    }
}


class AutoCompleteAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'
  
    # define parameterless constructor:
    AutoCompleteAttribute() : base([AutoCompleteAttribute]::_createScriptBlock($this)) 
    {}

    # define constructor with parameter for id:
    AutoCompleteAttribute([string]$Id) : base([AutoCompleteAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    # create a static helper method that creates the scriptblock that the base constructor needs
    # this is necessary to be able to access the argument(s) submitted to the constructor
    # the method needs a reference to the object instance to (later) access its optional parameters:
    hidden static [ScriptBlock] _createScriptBlock([AutoCompleteAttribute] $instance)
    {
    $scriptblock = {
        # receive information about current state:
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        # create filename for hint list
        $filename = '{0}.hint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # filter completion items based on existing text:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              # create argument completion results
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}


class AutoLearnCredentialAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
    [char]$ClearKey = '!'

    AutoLearnCredentialAttribute() : base()
    {}

    AutoLearnCredentialAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    # calculates md5 hash for usernames
    # hashes are used as keys for the serialized hashtable
    # declared as "static" because it has no relation to the attribute instance
    # and is simply a generic helper method:
    static [string] GetHash([string]$UserName)
    {
        $md5 = [System.Security.Cryptography.MD5CryptoServiceProvider]::new()
        $utf8 = [System.Text.UTF8Encoding]::new()
        return [System.BitConverter]::ToString($md5.ComputeHash($utf8.GetBytes($UserName.ToLower()))).Replace('-','')
    }

    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.xmlhint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # hint list is xml data
            # it is a serialized hashtable and can be
            # deserialized via Import-CliXml if it exists
            # result is a hashtable:
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath
        }

        # if the argument is a string...
        if ($inputData -is [string])
        {
            # does username start with "!"?
            [bool]$promptAlways = $inputData.StartsWith($this.ClearKey)

            # if not,...
            if (!$promptAlways)
            {
                # get the md5 key for the entered username
                $key = [AutoLearnCredentialAttribute]::GetHash($inputData)
                # ...check to see if the username has been used before,
                # and re-use its credential (no need to enter password again)
                if ($hints.ContainsKey($key))
                {
                    # the hashtable contains username and password, so
                    # create a credential from this:

                    # convert username from securestring to plaintext:
                    $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($hints[$key].UserName)
                    $username = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

                    # construct the credential object:
                    $credential = [System.Management.Automation.PSCredential]::new($username, $hints[$key].Password) 
                    return $credential
                }
            }
            else
            {
                # ...else, remove the "!" at the beginning and prompt
                # again for the password (this way, passwords can be updated)
                $inputData = $inputData.SubString(1)
                # delete the cached credentials
                $hints.Clear()
            }
            # ask for a credential:
            $cred = $engineIntrinsics.Host.UI.PromptForCredential("Enter password", "Please enter user account and password", $inputData, "")
            # get the md5 key for the entered username
            $key = [AutoLearnCredentialAttribute]::GetHash($cred.UserName)
            

            # add username and password to the hashtable:
            $hints[$key] = @{
                # save username as securestring to make sure it gets encrypted too:
                UserName = $cred.UserName | ConvertTo-SecureString -AsPlainText -Force
                Password = $cred.Password
            }
            # update the hashtable and write it to file
            # passwords are automatically safely encrypted:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $cred
        }
        # if a credential was submitted...
        elseif ($inputData -is [PSCredential])
        {
            # get the encrypted key for the entered username
            $key = [AutoLearnCredentialAttribute]::GetHash($inputData.UserName)
            
            # save it to the hashtable:
            $hints[$key] = @{
                UserName = $inputData.UserName | ConvertTo-SecureString -AsPlainText -Force
                Password = $inputData.Password
            }
            # update the hashtable and write it to file:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $inputData
        }
        throw [System.InvalidOperationException]::new('Unexpected error.')
    }
}


class AutocompleteCredentialAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
  
    AutocompleteCredentialAttribute() : base([AutocompleteCredentialAttribute]::_createScriptBlock($this)) 
    {}

    AutocompleteCredentialAttribute([string]$Id) : base([AutocompleteCredentialAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    hidden static [ScriptBlock] _createScriptBlock([AutocompleteCredentialAttribute] $instance)
    {
    $scriptblock = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        $filename = '{0}.xmlhint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # read serialized hint hashtable if it exists...
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath

            # hint the sorted list of cached user names

            # take the serialized hashtables. We can no longer use the hashtable keys
            # because they are just MD5 hashes:
            $hints.Values |
              ForEach-Object {
                # decrypt encrypted username
                $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($_.UserName)
                [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
              } |
              Where-Object { $_ } |
              # ...that still match the current user input:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              Sort-Object |
              # return completion results:
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}