PSNative.psm1

function Resolve-PathEx {
    <#
    .SYNOPSIS
        Resolve a path.
     
    .DESCRIPTION
        Resolve a path.
        Allows specifying success criteria, as well as selecting files or folders only.
     
    .PARAMETER Path
        The input path to resolve.
     
    .PARAMETER Type
        What kind of item to resolve to.
        Supported types:
        - Any: Can be whatever, so long as it exists.
        - File: Must be a file/leaf object and exist
        - Directory: Must be a container/directory object and exist
        - NewFile: The parent path must exist and be a container/directory. The item itself needs not exist, but if it exists, it must be a leaf/file
        Defaults to Any.
     
    .PARAMETER SingleItem
        Whether resolving to more than one item should cause an error.
     
    .PARAMETER Mode
        How results should be handled:
        - Any: At least one single successful path must be resolved, any errors are ignored as long as at least one is valid.
        - All: All items resolved must be valid.
        - AnyWarning: At least one single successful path must be resolved, any errors are filed as warning.
 
    .PARAMETER Provider
        What provider the item must be from.
        Defaults to FileSystem.
     
    .EXAMPLE
        PS C:\> Resolve-PathEx -Path .
         
        Resolves the current path.
 
    .EXAMPLE
        PS C:\> Resolve-PathEx -Path .\test\report.csv -Type NewFile -SingleItem
 
        Must resolve the full path of the file "report.csv" in the folder "test" under the current path.
        The file need not exist, but the folder must be present.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,

        [ValidateSet('File', 'Directory', 'Any', 'NewFile')]
        [string]
        $Type = 'Any',

        [switch]
        $SingleItem,

        [ValidateSet('Any', 'All', 'AnyWarning')]
        [string]
        $Mode = 'Any',

        [string]
        $Provider = 'FileSystem'
    )

    process {
        foreach ($pathEntry in $Path) {
            $data = [PSCustomObject]@{
                Input   = $pathEntry
                Path    = $null
                Success = $false
                Message = ''
                Error   = $null
            }
    
            $basePath = $pathEntry
            if ('NewFile' -eq $Type) {
                $basePath = Split-Path -Path $pathEntry -Parent
                $leaf = Split-Path -Path $pathEntry -Leaf
            }
            try { $resolved = (Resolve-Path -Path $basePath -ErrorAction Stop).ProviderPath }
            catch {
                $data.Error = $_
                $data.Message = "Path cannot be resolved: $pathEntry"
                return $data
            }
    
            if (@($resolved).Count -gt 1 -and $SingleItem) {
                $data.Message = "More than one item found: $pathEntry"
                return $data
            }
    
            $paths = [System.Collections.ArrayList]@()
            $messages = [System.Collections.ArrayList]@()
            $success = $false
            $failed = $false
    
            foreach ($resolvedPath in $resolved) {
                $item = Get-Item -LiteralPath $resolvedPath
    
                if ($Provider -ne $item.PSProvider.Name) {
                    $failed = $true
                    $null = $messages.Add("Not a $Provider path: $($resolvedPath)")
                    continue
                }
    
                if ('File' -eq $Type -and $item.PSIsContainer) {
                    $failed = $true
                    $null = $messages.Add("Not a file: $($resolvedPath)")
                    continue
                }
    
                if ('Directory' -eq $Type -and -not $item.PSIsContainer) {
                    $failed = $true
                    $null = $messages.Add("Not a directory: $($resolvedPath)")
                    continue
                }
    
                if ('NewFile' -eq $Type) {
                    if (-not $item.PSIsContainer) {
                        $failed = $true
                        $null = $messages.Add("Parent of $($pathEntry) is not a container: $($resolvedPath)")
                        continue
                    }
    
                    $newFilePath = Join-Path -Path $resolvedPath -ChildPath $leaf
                    if (Test-Path -LiteralPath $newFilePath -PathType Container) {
                        $failed = $true
                        $null = $messages.Add("Target path $($newFilePath) must not be a directory!")
                        continue
                    }
    
                    $null = $paths.Add($newFilePath)
                    $success = $true
                    continue
                }
    
                $null = $paths.Add($resolvedPath)
                $success = $true
            }
    
            $data.Path = $($paths)
            $data.Message = $($messages)
            foreach ($pathItem in $data.Path) {
                Write-Verbose "Resolved $pathEntry to $pathItem"
            }
    
            switch ($Mode) {
                'Any' {
                    $data.Success = $success
                    foreach ($message in $data.Message) {
                        Write-Verbose $message
                    }
                }
                'All' {
                    $data.Success = -not $failed
                    foreach ($message in $data.Message) {
                        Write-Verbose $message
                    }
                }
                'AnyWarning' {
                    $data.Success = $success
                    foreach ($message in $data.Message) {
                        Write-Warning $message
                    }
                }
            }
    
            $data
        }
    }
}

function Invoke-NativeCommand {
    <#
    .SYNOPSIS
        Execute an external application and wait for it to conclude.
     
    .DESCRIPTION
        Execute an external application and wait for it to conclude.
        Provides convenient parameterization options and output processing.
     
    .PARAMETER Name
        Name of or path to the process to run.
     
    .PARAMETER ArgumentList
        Parameters to provide to the process launched.
     
    .PARAMETER WorkingDirectory
        Directory from which to launch the process.
        Defaults to the current filesystem path.
     
    .PARAMETER Timeout
        How long to wait for the process to complete.
        Defaults to 15 minutes.
     
    .PARAMETER Credential
        Credentials to use when launching the process.
     
    .PARAMETER Environment
        Environment variables to add to the launched process
     
    .EXAMPLE
        PS C:\> Invoke-NativeCommand -Name nslookup 'wikipedia.org' '1.1.1.1'
         
        Launches nslookup, resolving wikipedia.org against 1.1.1.1.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding(PositionalBinding = $false)]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [Alias('FilePath')]
        [string]
        $Name,

        [Parameter(ValueFromRemainingArguments = $true)]
        [string[]]
        $ArgumentList,

        [string]
        $WorkingDirectory = (Get-Location -PSProvider FileSystem).ProviderPath,

        [Timespan]
        $Timeout = '00:15:00',

        [PSCredential]
        $Credential,

        [hashtable]
        $Environment = @{ }
    )

    begin {
        $info = [System.Diagnostics.ProcessStartInfo]::new()
        $info.FileName = $Name
        if ($WorkingDirectory) {
            $resolved = Resolve-PathEx -Path $WorkingDirectory -SingleItem -Type Directory
            if (-not $resolved.Success) {
                throw ($resolved.Message -join "`n")
            }
            $info.WorkingDirectory = $resolved.Path
        }
        foreach ($entry in $ArgumentList) { $info.ArgumentList.Add($entry) }

        $info.RedirectStandardError = $true
        $info.RedirectStandardOutput = $true

        if ($Credential) {
            $info.Password = $Credential.Password
            $networkCred = $Credential.GetNetworkCredential()
            $domain = $networkCred.Domain
            $user = $networkCred.UserName
            if (-not $domain -and $networkCred.UserName -like '*@*') {
                $domain = ($networkCred.UserName -split '@')[-1]
                $user = ($networkCred.UserName -split '@')[0]
            }
            $info.UserName = $user
            $info.Domain = $domain
        }
        foreach ($pair in $Environment.GetEnumerator()) {
            $info.EnvironmentVariables[$pair.Key] = $pair.Value
        }

        $proc = [System.Diagnostics.Process]::new()
        $proc.StartInfo = $info
        $started = $false
    }
    process {
        if (-not $started) {
            $start = Get-Date
            $null = $proc.Start()
        }
    }
    end {
        $failed = $false

        $limit = $start.Add($Timeout)
        while (-not $proc.HasExited) {
            Start-Sleep -Milliseconds 250
            if ($limit -lt (Get-Date)) {
                $failed = $true
                $proc.Close()
                break
            }
        }

        if ($proc.ExitCode -ne 0) { $failed = $true }

        [PSCustomObject]@{
            File     = $Name
            Success  = -not $failed
            Output   = $(try { $proc.StandardOutput.ReadToEnd() } catch { })
            Error    = $(try { $proc.StandardError.ReadToEnd() } catch { })
            ExitCode = $proc.ExitCode
        }
    }
}

function Start-NativeProcess {
    <#
    .SYNOPSIS
        Create a native process that allows for ongoing interaction.
     
    .DESCRIPTION
        Create a native process that allows for ongoing interaction.
        This can be use to wrap interactive console applications, then repeatedly send commands and receive output.
 
        The returned object has a few relevant methods:
        + Stop(): Kill the process.
        + Start(): Start the process again.
        + Send([string]): Send a line of command to the process
        + ReadOutput(): Read the lines of output from the proceess
        + ReadError(): Read the lines of errors from the process
 
        The two read commands will retrieve all the data written so far (and then discard them from the object,
        so calling them a second time without new output will not return anything).
        There is no indication whether the wrapped process is done doing what it is intending to do,
        making it necessary to understand the expected output.
 
        Sending multiple lines of input will queue up commands, which will be processed sequentially.
     
    .PARAMETER Name
        Name of the application to start.
        Can be the full path or the simple name, if the application is in the PATH environment variable.
     
    .PARAMETER ArgumentList
        Arguments to send to the application.
        These basically are the parameters needed BEFORE running the command.
        Use the .Send([string]) method to send commands to the process after starting it.
     
    .PARAMETER WorkingDirectory
        Path to run the program in.
        Defaults to the current file system path.
     
    .PARAMETER Credential
        Credentials under which to run the process.
     
    .PARAMETER Environment
        Any environment variables to inject into the process being started.
 
    .PARAMETER NoStart
        Do not automatically start th process after preparing the starting information.
        By default, the wrapped process is launched immediately instead.
         
    .EXAMPLE
        PS C:\ $cmd = Start-NativeProcess -Name cmd
        PS C:\> $cmd.Send('dir C:\')
        PS C:\> $cmd.ReadOutput()
         
        Starts a persistent cmd process, then sends the dir command to it and reads the response.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [string[]]
        $ArgumentList = @(),

        [string]
        $WorkingDirectory = (Get-Location -PSProvider FileSystem).ProviderPath,

        [PSCredential]
        $Credential,

        [hashtable]
        $Environment = @{ },

        [switch]
        $NoStart
    )

    $process = [NativeProcess]::New($Name, $ArgumentList)
    if ($WorkingDirectory) {
        $resolved = Resolve-PathEx -Path $WorkingDirectory -SingleItem -Type Directory
        if (-not $resolved.Success) {
            throw ($resolved.Message -join "`n")
        }
        $process.WorkingDirectory = $resolved.Path
    }
    if ($Credential) {
        $process.Credential = $Credential
    }
    $process.EnvironmentVariables = $Environment
    if (-not $NoStart) { $process.Start() }
    $process
}

class NativeProcess {
    [string]$Name
    [object[]]$ArgumentList
    [string]$WorkingDirectory
    [hashtable]$EnvironmentVariables = @{ }
    [datetime]$Started
    [datetime]$Stopped
    [PSCredential]$Credential

    [System.Diagnostics.Process]$Process

    hidden [object] $OutputTask
    hidden [object] $ErrorTask

    NativeProcess([string]$Name) {
        $this.Name = $Name
    }

    NativeProcess([string]$Name, [object[]]$ArgumentList) {
        $this.Name = $Name
        $this.ArgumentList = $ArgumentList
    }

    [void] Start() {
        if ($this.Process -and -not $this.Process.HasExited) {
            $this.Stop()
        }

        $this.OutputTask = $null
        $this.ErrorTask = $null

        #region Process
        $info = [System.Diagnostics.ProcessStartInfo]::new()
        $info.FileName = $this.Name
        if ($this.WorkingDirectory) { $info.WorkingDirectory = $this.WorkingDirectory }
        foreach ($entry in $this.ArgumentList) { $info.ArgumentList.Add($entry) }
    
        if ($global:PSVersionTable.PSVersion.Major -lt 6) { $info.UseShellExecute = $false }
        $info.RedirectStandardInput = $true
        $info.RedirectStandardError = $true
        $info.RedirectStandardOutput = $true

        if ($this.Credential) {
            $info.Password = $this.Credential.Password
            $networkCred = $this.Credential.GetNetworkCredential()
            $domain = $networkCred.Domain
            $user = $networkCred.UserName
            if (-not $domain -and $networkCred.UserName -like '*@*') {
                $domain = ($networkCred.UserName -split '@')[-1]
                $user = ($networkCred.UserName -split '@')[0]
            }
            $info.UserName = $user
            $info.Domain = $domain
        }
        foreach ($pair in $this.EnvironmentVariables.GetEnumerator()) {
            $info.EnvironmentVariables[$pair.Key] = $pair.Value
        }
    
        $proc = [System.Diagnostics.Process]::new()
        $proc.StartInfo = $info
    
        # Start
        $null = $proc.Start()

        $this.Process = $proc
        #endregion Process

        $this.Started = Get-Date
    }

    [void] Stop() {
        $this.Process.Kill()
        $this.Process = $null
        $this.OutputTask = $null
        $this.ErrorTask = $null

        $this.Stopped = Get-Date
    }

    [string[]] ReadOutput() {
        if (-not $this.Process) {return @() }
        
        $lastStart = [DateTime]::Now
        $lines = while ($true) {
            if ($this.OutputTask -and $this.OutputTask.Status -eq 'WaitingForActivation') {
                if ($lastStart -gt [DateTime]::Now.AddMilliseconds(-100)) {
                    continue
                }
                break
            }

            if ($this.OutputTask) {
                $this.OutputTask.Result
            }
            $this.OutputTask = $this.Process.StandardOutput.ReadLineAsync()
            $lastStart = [DateTime]::Now

            if ($this.Process.HasExited) {
                if ($this.OutputTask) {
                    $this.OutputTask.Result
                }
                $this.Process.StandardOutput.ReadToEnd() -split "`n"
            }
        }
        if (-not $lines) {
            return @()
        }

        return $lines
    }

    [string[]] ReadError() {
        if (-not $this.Process) {return @() }

        $lastStart = [DateTime]::Now
        $lines = while ($true) {
            if ($this.ErrorTask -and $this.ErrorTask.Status -eq 'WaitingForActivation') {
                if ($lastStart -gt [DateTime]::Now.AddMilliseconds(-100)) {
                    continue
                }
                break
            }

            if ($this.ErrorTask) {
                $this.ErrorTask.Result
            }
            $this.ErrorTask = $this.Process.StandardError.ReadLineAsync()
            $lastStart = [DateTime]::Now

            if ($this.Process.HasExited) {
                if ($this.ErrorTask) {
                    $this.ErrorTask.Result
                }
                $this.Process.StandardOutput.ReadToEnd() -split "`n"
            }
        }
        if (-not $lines) {
            return @()
        }

        return $lines
    }

    [void] Send([string[]]$Lines) {
        if ($null -eq $this.Process) {
            throw "Process not running! Use Start() first."
        }
        if ($this.Process.HasExited) {
            throw "Process has stopped already! Use Start() to restart it first."
        }

        foreach ($line in $Lines) {
            $this.Process.StandardInput.WriteLine($line)
        }
    }
}