DRJones.psm1

function Assert-ARKElevated {
    <#
    .SYNOPSIS
    Throws if the current session is not elevated.
 
    .DESCRIPTION
    Throws a terminating error naming the operation when the current session is not running as Administrator.
 
    .PARAMETER Operation
    The operation that requires elevation, used in the error message.
 
    .EXAMPLE
    Assert-ARKElevated -Operation 'CAVaultManager RestoreDB'
 
    Throws: CAVaultManager RestoreDB must run in an elevated session. Run PowerShell as Administrator.
    #>

    [CmdletBinding()]
    param(

        [Parameter(
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Operation

    )

    if (-not (Test-ARKElevated)) {

        throw "$Operation must run in an elevated session. Run PowerShell as Administrator."

    }

}

function ConvertTo-ARKArgument {
    <#
    .SYNOPSIS
    Converts values into an argument string for a recovery utility command line.
 
    .DESCRIPTION
    Joins values with spaces, wrapping a value in double quotes only when it is empty or contains whitespace or
    double quotes, so switches such as /RestoreSafe are passed as typed.
 
    In a quoted value, embedded double quotes are escaped, and trailing backslashes are doubled so a folder path
    such as 'C:\Vault Keys\' is not read as an escaped closing quote when the utility parses its command line.
 
    .PARAMETER Value
    The values to convert, in command line order.
 
    .EXAMPLE
    ConvertTo-ARKArgument -Value 'C:\Vault\Data\*', 'D:\Restored', 'C:\Vault Keys\'
 
    Returns: C:\Vault\Data\* D:\Restored "C:\Vault Keys\\"
    #>

    [CmdletBinding()]
    [OutputType('System.String')]
    param(

        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true
        )]
        [AllowEmptyString()]
        [string[]]$Value

    )

    begin {

        $Arguments = [System.Collections.Generic.List[string]]::new()

    }

    process {

        foreach ($Item in $Value) {

            if ($Item -eq '' -or $Item -match '[\s"]') {

                $Item = ($Item -replace '(\\*)"', '$1$1\"') -replace '(\\+)$', '$1$1'
                $Item = '"{0}"' -f $Item

            }

            $Arguments.Add($Item)

        }

    }

    end {

        $Arguments -join ' '

    }

}

function Get-ARKBackupArgument {
    <#
    .SYNOPSIS
    Builds the leading PARestore and PAReplicate arguments: Vault file, user logon and backup pool.
 
    .DESCRIPTION
    Returns the arguments common to every PARestore and PAReplicate operation, in command line order:
 
    <VaultFile> <User>/<Password> | /LogonFromFile <LogonFile>, then /BackupPoolName <BackupPoolName>.
 
    VaultFile defaults to Vault.ini, which the utility reads from its own folder.
    Relative paths provided for VaultFile and LogonFile are resolved against the current PowerShell location.
 
    .PARAMETER VaultFile
    The Vault parameter file.
 
    .PARAMETER Credential
    The Vault user and password.
 
    .PARAMETER LogonFile
    The user credentials file.
 
    .PARAMETER BackupPoolName
    The backup pool name.
 
    .EXAMPLE
    Get-ARKBackupArgument -LogonFile C:\Creds\Operator.cred
 
    Returns: Vault.ini, /LogonFromFile, C:\Creds\Operator.cred
    #>

    [CmdletBinding()]
    [OutputType('System.String')]
    param(

        [Parameter(
            Mandatory = $false
        )]
        [string]$VaultFile,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Credential'
        )]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LogonFile'
        )]
        [string]$LogonFile,

        [Parameter(
            Mandatory = $false
        )]
        [string]$BackupPoolName

    )

    if ($VaultFile) {
        $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($VaultFile)
    } else {
        'Vault.ini'
    }

    switch ($PSCmdlet.ParameterSetName) {

        'Credential' {
            '{0}/{1}' -f $Credential.UserName, $Credential.GetNetworkCredential().Password
        }

        'LogonFile' {
            '/LogonFromFile'
            $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($LogonFile)
        }

    }

    if ($BackupPoolName) {

        '/BackupPoolName'
        $BackupPoolName

    }

}

function Get-ARKUtilityPath {
    <#
    .SYNOPSIS
    Gets the path to a CyberArk utility.
 
    .DESCRIPTION
    Returns the path to the utility executable in the first folder set with Set-ARKConfiguration -UtilityPath that
    contains it.
 
    Throws if no utility folder is set, or if the utility is not found in any of them.
 
    .PARAMETER Utility
    The utility to locate: Recover, SafeRecover, PARestore, PAReplicate or CAVaultManager.
 
    .EXAMPLE
    Get-ARKUtilityPath -Utility PAReplicate
 
    Returns: C:\Program Files (x86)\PrivateArk\Replicate\PAReplicate.exe
    #>

    [CmdletBinding()]
    [OutputType('System.String')]
    param(

        [Parameter(
            Mandatory = $true
        )]
        [ValidateSet('Recover', 'SafeRecover', 'PARestore', 'PAReplicate', 'CAVaultManager')]
        [string]$Utility

    )

    $UtilityPath = @($Script:ARKConfig.UtilityPath | Where-Object { -not [string]::IsNullOrEmpty($_) })

    if ($UtilityPath.Count -eq 0) {

        throw "Recovery utility folder not set. Run Set-ARKConfiguration -UtilityPath <folder containing $Utility.exe>"

    }

    $FilePath = @(
        $UtilityPath |
            ForEach-Object { Join-Path -Path $_ -ChildPath "$Utility.exe" } |
            Where-Object { Test-Path -Path $_ -PathType Leaf }
    )[0]

    if (-not $FilePath) {

        throw "$Utility.exe not found in $($UtilityPath -join ', '). Run Set-ARKConfiguration -UtilityPath <folder containing $Utility.exe>"

    }

    $FilePath

}

function Invoke-ARKUtility {
    <#
    .SYNOPSIS
    Runs a CyberArk recovery utility.
 
    .DESCRIPTION
    Runs Recover.exe, SafeRecover.exe, PARestore.exe, PAReplicate.exe or CAVaultManager.exe from the first folder set
    with Set-ARKConfiguration -UtilityPath that contains it, passing the supplied arguments in order.
 
    The utility runs with its own folder as the working directory.
 
    A non-zero exit code, or a PARestore cancellation (PARST032E) after a confirmation prompt, is reported as an error;
    otherwise the exit code and output are returned.
    An exit code of 0xC0000135 (STATUS_DLL_NOT_FOUND) is reported as a missing DLL.
    Known PARestore errors (ITATS633E, ITATS004E, ITATS654E, a missing original Safe, CASCF034E) include a hint to the likely cause.
 
    .PARAMETER Utility
    The utility to run: Recover, SafeRecover, PARestore, PAReplicate or CAVaultManager.
 
    .PARAMETER ArgumentList
    The utility arguments, in command line order.
 
    .PARAMETER SensitiveValue
    Values, such as passwords, to mask in debug output and error records.
 
    .PARAMETER Interactive
    Runs the utility in the current console without redirecting its input or output, so the user sees and answers its
    prompts.
    The output is not captured.
 
    .EXAMPLE
    Invoke-ARKUtility -Utility SafeRecover -ArgumentList 'Research', 'C:\Research', 'C:\MasterKey'
 
    Runs: SafeRecover.exe Research C:\Research C:\MasterKey
    #>

    [CmdletBinding()]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $true
        )]
        [ValidateSet('Recover', 'SafeRecover', 'PARestore', 'PAReplicate', 'CAVaultManager')]
        [string]$Utility,

        [Parameter(
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$ArgumentList,

        [Parameter(
            Mandatory = $false
        )]
        [string[]]$SensitiveValue,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$Interactive

    )

    #STATUS_DLL_NOT_FOUND (0xC0000135)
    $DllNotFound = -1073741515

    $FilePath = Get-ARKUtilityPath -Utility $Utility

    $WorkingDirectory = Split-Path -Path $FilePath -Parent

    $Arguments = ConvertTo-ARKArgument -Value $ArgumentList

    $MaskedArguments = ConvertTo-ARKArgument -Value @(
        foreach ($Argument in $ArgumentList) {

            foreach ($Value in $SensitiveValue | Where-Object { $_ }) {

                $Argument = $Argument.Replace($Value, '********')

            }

            $Argument

        }
    )

    Write-Debug "Command: $FilePath $MaskedArguments"

    $Process = [System.Diagnostics.Process]::new()

    try {

        $Process.StartInfo.FileName = $FilePath
        $Process.StartInfo.Arguments = $Arguments
        $Process.StartInfo.WorkingDirectory = $WorkingDirectory
        $Process.StartInfo.RedirectStandardInput = -not $Interactive
        $Process.StartInfo.RedirectStandardOutput = -not $Interactive
        $Process.StartInfo.RedirectStandardError = -not $Interactive
        $Process.StartInfo.UseShellExecute = $false
        $Process.StartInfo.CreateNoWindow = -not $Interactive

        $Result = Start-ARKUtilityProcess -Process $Process

    } finally {

        $Process.Dispose()

    }

    $Cancelled = [regex]::Match([string]$Result.StdOut, 'PARST032E.*')

    if ($Result.ExitCode -ne 0 -or $Cancelled.Success) {

        $Message = if ($Result.ExitCode -eq $DllNotFound) {

            "$Utility.exe could not start: a required DLL was not found. Copy the DLLs supplied with $Utility.exe in the Vault Server folder to $WorkingDirectory, and install the Visual C++ redistributable it requires."

        } elseif ($Cancelled.Success) {

            $Cancelled.Value

        } else {

            @(@($Result.StdErr, $Result.StdOut) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })[0]

        }

        if (-not $Message) { $Message = "$Utility.exe exited with code $($Result.ExitCode)" }

        $Message = $Message.Trim()

        $Hints = [ordered]@{
            'ITATS633E'                                      = 'Check that the backup files are in the SafesDirectory set in tsparm.ini, that the Metadata folder holds every metadata file, and that BackupPoolName matches the pool used by PAReplicate. With LoadOnly, the files must already be in the Restored Safes folder on the Vault server.'
            'ITATS654E'                                      = 'The target Safe must not exist: restore into a new Safe name.'
            'ITATS004E'                                      = 'Check the password or credentials file. A user created to run PARestore or PAReplicate must have the CABACKUP user type.'
            'corresponding safe does not exis|Code: 929\b' = 'The Safe being restored must exist in the Vault, with the restoring user as an owner. If it was deleted, create an empty Safe with the same name and its original owners.'
            'CASCF034E'                                      = 'The credentials file is restricted to another OS user, executable path or machine. A file restricted to SYSTEM works only from a scheduled task, such as one registered by Register-ARKBackupTask.'
        }

        foreach ($Pattern in $Hints.Keys) {

            if ($Message -match $Pattern) { $Message = "$Message`n$($Hints[$Pattern])" }

        }

        Write-Error -Message $Message -ErrorId "$Utility.ExitCode.$($Result.ExitCode)" -Category InvalidResult -TargetObject $MaskedArguments

    } else {

        [PSCustomObject]@{
            PSTypeName = 'DRJones.UtilityResult'
            Utility    = $Utility
            ExitCode   = $Result.ExitCode
            StdOut     = $Result.StdOut
            StdErr     = $Result.StdErr
        }

    }

}

function Read-ARKIniFile {
    <#
    .SYNOPSIS
    Reads a CyberArk parameter file.
 
    .DESCRIPTION
    Reads a parameter file, such as DBParm.ini or Vault.ini, returning its lines, its encoding, and the parameters
    found in it.
 
    Files with a UTF-8 or Unicode byte order mark are read with that encoding; other files are read byte for byte,
    so that unchanged lines are written back unaltered.
 
    Lines starting with ; or # are comments.
 
    .PARAMETER Path
    The parameter file.
 
    .EXAMPLE
    Read-ARKIniFile -Path 'C:\Program Files (x86)\PrivateArk\Server\Conf\DBParm.ini'
 
    Returns the lines, encoding and parameters of DBParm.ini.
    #>

    [CmdletBinding()]
    param(

        [Parameter(
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Path

    )

    $Bytes = [System.IO.File]::ReadAllBytes($Path)

    $Encoding = switch ($true) {
        { $Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF } { [System.Text.UTF8Encoding]::new($true); break }
        { $Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE } { [System.Text.UnicodeEncoding]::new($false, $true); break }
        default { [System.Text.Encoding]::GetEncoding(28591) }
    }

    $Preamble = $Encoding.GetPreamble().Length

    $Lines = $Encoding.GetString($Bytes, $Preamble, $Bytes.Length - $Preamble) -split '\r?\n'

    $Section = $null

    $Parameters = for ($Index = 0; $Index -lt $Lines.Count; $Index++) {

        if ($Lines[$Index] -match '^\s*\[(?<Section>[^\]]+)\]\s*$') {

            $Section = $Matches['Section'].Trim()

        } elseif ($Lines[$Index] -match '^(?<Prefix>\s*(?<Name>[^\s=;#\[][^=]*?)\s*=\s*)(?<Value>.*?)\s*$') {

            [PSCustomObject]@{
                Index   = $Index
                Section = $Section
                Name    = $Matches['Name']
                Value   = $Matches['Value']
                Prefix  = $Matches['Prefix']
            }

        }

    }

    [PSCustomObject]@{
        Encoding   = $Encoding
        Lines      = [System.Collections.Generic.List[string]]$Lines
        Parameters = @($Parameters)
        Sections   = @($Lines | Where-Object { $_ -match '^\s*\[[^\]]+\]\s*$' }).Count
    }

}

function Start-ARKUtilityProcess {
    <#
    .SYNOPSIS
    Starts a recovery utility process.
 
    .DESCRIPTION
    Starts the process described by a System.Diagnostics.Process object prepared by Invoke-ARKUtility,
    waits for it to exit, and returns its exit code and output.
 
    When input and output are redirected, standard input is closed as soon as the process starts, so a utility
    prompting for input reads end of input rather than waiting indefinitely.
    Otherwise the process runs in the current console and its output is not captured.
 
    .PARAMETER Process
    System.Diagnostics.Process object containing the utility path and arguments.
 
    .EXAMPLE
    Start-ARKUtilityProcess -Process $Process
 
    Starts the process and returns an object with ExitCode, StdOut and StdErr properties.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Justification = 'ShouldProcess handling is in the calling public function')]
    [OutputType('System.Management.Automation.PSCustomObject')]
    param(

        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [System.Diagnostics.Process]$Process

    )

    process {

        $null = $Process.Start()

        $StdOut = $null
        $StdErr = $null

        if ($Process.StartInfo.RedirectStandardInput) {

            $Process.StandardInput.Close()

            #Read output streams before waiting, otherwise a full output buffer blocks the process from exiting.
            $StdOut = $Process.StandardOutput.ReadToEnd()
            $StdErr = $Process.StandardError.ReadToEnd()

        }

        $Process.WaitForExit()

        [PSCustomObject]@{
            ExitCode = $Process.ExitCode
            StdOut   = $StdOut
            StdErr   = $StdErr
        }

    }

}

function Test-ARKElevated {
    <#
    .SYNOPSIS
    Tests whether the current session is elevated.
 
    .DESCRIPTION
    Returns true when the current Windows identity is running as a member of the local Administrators group.
 
    .EXAMPLE
    Test-ARKElevated
 
    Returns true in an elevated session.
    #>

    [CmdletBinding()]
    [OutputType('System.Boolean')]
    param( )

    $Principal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())
    $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

}

# .ExternalHelp DRJones-help.xml
function Backup-ARKVault {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'LogonFile')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Credential'
        )]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LogonFile'
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$LogonFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$VaultFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$Incremental

    )

    $LogonParameters = @{ }
    foreach ($Key in 'VaultFile', 'Credential', 'LogonFile', 'BackupPoolName') {
        if ($PSBoundParameters.ContainsKey($Key)) { $LogonParameters[$Key] = $PSBoundParameters[$Key] }
    }

    $ArgumentList = @(
        Get-ARKBackupArgument @LogonParameters
        if (-not $Incremental) { '/FullBackup' }
    )

    $BackupType = if ($Incremental) { 'incremental' } else { 'full' }

    if ($PSCmdlet.ShouldProcess('Vault', "Run $BackupType backup")) {

        Invoke-ARKUtility -Utility PAReplicate -ArgumentList $ArgumentList -SensitiveValue $(
            if ($Credential) { $Credential.GetNetworkCredential().Password }
        )

    }

}

# .ExternalHelp DRJones-help.xml
function Get-ARKConfiguration {
    [CmdletBinding()]
    [OutputType('System.Management.Automation.PSCustomObject')]
    param( )

    $Script:ARKConfig | Select-Object -Property UtilityPath, KeyPath

}

# .ExternalHelp DRJones-help.xml
function Get-ARKVaultDatabaseReport {
    [CmdletBinding()]
    [OutputType('DRJones.UtilityResult')]
    param( )

    Assert-ARKElevated -Operation 'CAVaultManager DiagnoseDBReport'

    Invoke-ARKUtility -Utility CAVaultManager -ArgumentList 'DiagnoseDBReport'

}

# .ExternalHelp DRJones-help.xml
function Get-ARKVaultParameter {
    [CmdletBinding()]
    [OutputType('DRJones.VaultParameter')]
    param(

        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('FullName')]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$Path,

        [Parameter(
            Mandatory = $false,
            Position = 0
        )]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string[]]$Name = '*',

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Section

    )

    process {

        $FullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)

        $Ini = Read-ARKIniFile -Path $FullPath

        foreach ($Parameter in $Ini.Parameters) {

            if ($Section -and $Parameter.Section -ne $Section) { continue }

            if (-not ($Name | Where-Object { $Parameter.Name -like $_ })) { continue }

            [PSCustomObject]@{
                PSTypeName = 'DRJones.VaultParameter'
                Section    = $Parameter.Section
                Name       = $Parameter.Name
                Value      = $Parameter.Value
                Path       = $FullPath
            }

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Register-ARKBackupTask {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('Microsoft.Management.Infrastructure.CimInstance#MSFT_ScheduledTask')]
    param(

        [Parameter(
            Mandatory = $true
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$LogonFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$VaultFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName,

        [Parameter(
            Mandatory = $false
        )]
        [datetime]$At = '01:00',

        [Parameter(
            Mandatory = $false
        )]
        [System.DayOfWeek]$FullBackupDay = 'Sunday',

        [Parameter(
            Mandatory = $false
        )]
        [ValidatePattern('^\\(.+\\)?$')]
        [string]$TaskPath = '\DRJones\'

    )

    Assert-ARKElevated -Operation 'Registering a SYSTEM scheduled task'

    $FilePath = Get-ARKUtilityPath -Utility PAReplicate

    $LogonParameters = @{ }
    foreach ($Key in 'VaultFile', 'LogonFile', 'BackupPoolName') {
        if ($PSBoundParameters.ContainsKey($Key)) { $LogonParameters[$Key] = $PSBoundParameters[$Key] }
    }

    $ArgumentList = @(Get-ARKBackupArgument @LogonParameters)

    $Principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
    $Settings = New-ScheduledTaskSettingsSet -StartWhenAvailable

    $Tasks = @(
        @{
            TaskName = 'PAReplicate Incremental Backup'
            Argument = ConvertTo-ARKArgument -Value $ArgumentList
            Days     = [System.DayOfWeek].GetEnumValues() | Where-Object { $_ -ne $FullBackupDay }
        }
        @{
            TaskName = 'PAReplicate Full Backup'
            Argument = ConvertTo-ARKArgument -Value ($ArgumentList + '/FullBackup')
            Days     = $FullBackupDay
        }
    )

    foreach ($Task in $Tasks) {

        if ($PSCmdlet.ShouldProcess("$TaskPath$($Task.TaskName)", 'Register scheduled task')) {

            $Action = New-ScheduledTaskAction -Execute $FilePath -Argument $Task.Argument -WorkingDirectory (Split-Path -Path $FilePath -Parent)
            $Trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $Task.Days -At $At
            #Local time, so the task keeps its time of day across daylight saving changes
            $Trigger.StartBoundary = $At.ToString('s')

            Register-ScheduledTask -TaskName $Task.TaskName -TaskPath $TaskPath -Action $Action -Trigger $Trigger `
                -Principal $Principal -Settings $Settings -Description 'CyberArk Vault backup, registered by DRJones.' -Force

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Repair-ARKVaultBackup {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName

    )

    Assert-ARKElevated -Operation 'CAVaultManager RecoverBackupFiles'

    $ArgumentList = @(
        'RecoverBackupFiles'
        if ($BackupPoolName) {
            '/BackupPoolName'
            $BackupPoolName
        }
    )

    $Target = if ($BackupPoolName) { "Backup pool $BackupPoolName" } else { 'Restored Safes backup files' }

    if ($PSCmdlet.ShouldProcess($Target, 'Recover and re-encrypt with a new backup key')) {

        Invoke-ARKUtility -Utility CAVaultManager -ArgumentList $ArgumentList

    }

}

# .ExternalHelp DRJones-help.xml
function Restore-ARKFile {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('DRJones.RecoveryResult')]
    param(

        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('FullName')]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$OutputPath,

        [Parameter(
            Mandatory = $false,
            Position = 2,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Container })]
        [string]$KeyPath = $Script:ARKConfig.KeyPath,

        [Parameter(
            Mandatory = $false,
            Position = 3,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$SafeRecoveryKeyPath

    )

    process {

        if ([string]::IsNullOrEmpty($KeyPath)) {

            throw 'Recovery key folder not specified. Provide -KeyPath, or set a default with Set-ARKConfiguration -KeyPath'

        }

        $InFile = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path)
        $OutFolder = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($OutputPath)

        $ArgumentList = @(
            $InFile
            $OutFolder
            $PSCmdlet.GetUnresolvedProviderPathFromPSPath($KeyPath)
        )

        if ($PSBoundParameters.ContainsKey('SafeRecoveryKeyPath')) {

            $ArgumentList += $PSCmdlet.GetUnresolvedProviderPathFromPSPath($SafeRecoveryKeyPath)

        }

        if ($PSCmdlet.ShouldProcess($OutFolder, "Recover $InFile")) {

            if (-not (Test-Path -Path $OutFolder -PathType Container)) {

                $null = New-Item -Path $OutFolder -ItemType Directory -Force

            }

            $Result = Invoke-ARKUtility -Utility Recover -ArgumentList $ArgumentList

            if ($Result) {

                [PSCustomObject]@{
                    PSTypeName = 'DRJones.RecoveryResult'
                    Source     = $InFile
                    OutputPath = $OutFolder
                    Utility    = $Result.Utility
                    ExitCode   = $Result.ExitCode
                    Output     = $Result.StdOut
                }

            }

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Restore-ARKRecordingSafe {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Credential')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('SafeName')]
        [ValidateNotNullOrEmpty()]
        [string]$Safe,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$TargetSafe,

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateScript( { $_ -notmatch '\\' })]
        [string[]]$Owners,

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateRange(1, 90)]
        [int]$Retention,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Credential'
        )]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LogonFile'
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$LogonFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$VaultFile,

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName

    )

    process {

        $LogonParameters = @{ }
        foreach ($Key in 'VaultFile', 'Credential', 'LogonFile', 'BackupPoolName') {
            if ($PSBoundParameters.ContainsKey($Key)) { $LogonParameters[$Key] = $PSBoundParameters[$Key] }
        }

        $ArgumentList = @(
            Get-ARKBackupArgument @LogonParameters
            '/RestoreRecordings'
            '/RestoreSafe'
            $Safe
            '/TargetSafe'
            $TargetSafe
            if ($PSBoundParameters.ContainsKey('Owners')) {
                '/Owners'
                $Owners -join '\'
            }
            if ($PSBoundParameters.ContainsKey('Retention')) {
                '/Retention'
                $Retention
            }
        )

        if ($PSCmdlet.ShouldProcess($TargetSafe, "Restore recordings Safe $Safe")) {

            Invoke-ARKUtility -Utility PARestore -ArgumentList $ArgumentList -Interactive -SensitiveValue $(
                if ($Credential) { $Credential.GetNetworkCredential().Password }
            )

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Restore-ARKSafe {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('DRJones.RecoveryResult')]
    param(

        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('SafeName')]
        [ValidateNotNullOrEmpty()]
        [string]$Safe,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$OutputPath,

        [Parameter(
            Mandatory = $false,
            Position = 2,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Container })]
        [string]$KeyPath = $Script:ARKConfig.KeyPath

    )

    begin {

        Assert-ARKElevated -Operation 'SafeRecover'

    }

    process {

        if ([string]::IsNullOrEmpty($KeyPath)) {

            throw 'Recovery key folder not specified. Provide -KeyPath, or set a default with Set-ARKConfiguration -KeyPath'

        }

        $OutFolder = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($OutputPath)

        $ArgumentList = @(
            $Safe
            $OutFolder
            $PSCmdlet.GetUnresolvedProviderPathFromPSPath($KeyPath)
        )

        if ($PSCmdlet.ShouldProcess($OutFolder, "Recover Safe $Safe")) {

            if ((Get-Service -Name 'PrivateArk Server' -ErrorAction Ignore).Status -eq 'Running') {

                throw 'The PrivateArk Server service is running. Stop it before recovering a Safe; SafeRecover.exe cannot run while the Vault is running'

            }

            if (-not (Test-Path -Path $OutFolder -PathType Container)) {

                $null = New-Item -Path $OutFolder -ItemType Directory -Force

            }

            $Result = Invoke-ARKUtility -Utility SafeRecover -ArgumentList $ArgumentList

            if ($Result) {

                [PSCustomObject]@{
                    PSTypeName = 'DRJones.RecoveryResult'
                    Source     = $Safe
                    OutputPath = $OutFolder
                    Utility    = $Result.Utility
                    ExitCode   = $Result.ExitCode
                    Output     = $Result.StdOut
                }

            }

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Restore-ARKVault {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Credential')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Credential'
        )]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LogonFile'
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$LogonFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$VaultFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$FromRestored

    )

    Assert-ARKElevated -Operation 'PARestore /FullVaultRestore'

    $LogonParameters = @{ }
    foreach ($Key in 'VaultFile', 'Credential', 'LogonFile', 'BackupPoolName') {
        if ($PSBoundParameters.ContainsKey($Key)) { $LogonParameters[$Key] = $PSBoundParameters[$Key] }
    }

    $ArgumentList = @(
        Get-ARKBackupArgument @LogonParameters
        '/FullVaultRestore'
        if ($FromRestored) { '/FromRestored' }
    )

    if ($PSCmdlet.ShouldProcess('Vault Restored Safes folder', 'Upload full Vault backup')) {

        Invoke-ARKUtility -Utility PARestore -ArgumentList $ArgumentList -SensitiveValue $(
            if ($Credential) { $Credential.GetNetworkCredential().Password }
        )

    }

}

# .ExternalHelp DRJones-help.xml
function Restore-ARKVaultDatabase {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$NoSynchronize

    )

    Assert-ARKElevated -Operation 'CAVaultManager RestoreDB'

    $ArgumentList = @(
        'RestoreDB'
        if ($BackupPoolName) {
            '/BackupPoolName'
            $BackupPoolName
        }
        if ($NoSynchronize) { '/NoSynchronize' }
        '/Force'
    )

    if ($PSCmdlet.ShouldProcess('Vault database', 'Restore from Restored Safes folder')) {

        Invoke-ARKUtility -Utility CAVaultManager -ArgumentList $ArgumentList

    }

}

# .ExternalHelp DRJones-help.xml
function Restore-ARKVaultSafe {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Credential')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('SafeName')]
        [ValidateNotNullOrEmpty()]
        [string]$Safe,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$TargetSafe,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Credential'
        )]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LogonFile'
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$LogonFile,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$VaultFile,

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$BackupPoolName,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$DataOnly,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$MetadataOnly,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$LoadOnly

    )

    process {

        if ($DataOnly -and $MetadataOnly) {

            throw 'DataOnly and MetadataOnly cannot be used together'

        }

        $LogonParameters = @{ }
        foreach ($Key in 'VaultFile', 'Credential', 'LogonFile', 'BackupPoolName') {
            if ($PSBoundParameters.ContainsKey($Key)) { $LogonParameters[$Key] = $PSBoundParameters[$Key] }
        }

        $ArgumentList = @(
            Get-ARKBackupArgument @LogonParameters
            if ($LoadOnly) { '/LoadOnly' }
            '/RestoreSafe'
            $Safe
            '/TargetSafe'
            $TargetSafe
            if ($DataOnly) { '/DataOnly' }
            if ($MetadataOnly) { '/MetadataOnly' }
        )

        if ($PSCmdlet.ShouldProcess($TargetSafe, "Restore Safe $Safe")) {

            $Result = Invoke-ARKUtility -Utility PARestore -ArgumentList $ArgumentList -SensitiveValue $(
                if ($Credential) { $Credential.GetNetworkCredential().Password }
            )

            #ITATS658W: a backed-up owner not matched in the Vault is removed from the target Safe
            $Result.StdOut -split '\r?\n' | Where-Object { $_ -match 'ITATS658W' } | ForEach-Object {
                Write-Warning $_.Trim()
            }

            $Result

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Set-ARKConfiguration {
    [CmdletBinding(SupportsShouldProcess)]
    param(

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Container })]
        [string[]]$UtilityPath,

        [Parameter(
            Mandatory = $false,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateScript( { Test-Path -Path $_ -PathType Container })]
        [string]$KeyPath

    )

    process {

        if ($PSCmdlet.ShouldProcess($Script:ARKConfigFile, 'Save DRJones Configuration')) {

            switch ($PSBoundParameters.Keys) {

                'UtilityPath' { $Script:ARKConfig.UtilityPath = [string[]]@($UtilityPath | ForEach-Object { $PSCmdlet.GetUnresolvedProviderPathFromPSPath($_) }) }

                'KeyPath' { $Script:ARKConfig.KeyPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($KeyPath) }

            }

            $Script:ARKConfig | Export-Clixml -Path $Script:ARKConfigFile -Force

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Set-ARKVaultParameter {
    [CmdletBinding(SupportsShouldProcess)]
    param(

        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('FullName')]
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })]
        [string]$Path,

        [Parameter(
            Mandatory = $true,
            Position = 0
        )]
        [ValidatePattern('^[\w.-]+$')]
        [string]$Name,

        [Parameter(
            Mandatory = $true,
            Position = 1
        )]
        [AllowEmptyString()]
        [ValidatePattern('^[^\r\n]*$')]
        [string]$Value,

        [Parameter(
            Mandatory = $false
        )]
        [ValidatePattern('^[^\[\]\r\n]+$')]
        [string]$Section

    )

    process {

        $FullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)

        $Ini = Read-ARKIniFile -Path $FullPath
        $Lines = $Ini.Lines

        $Existing = @($Ini.Parameters | Where-Object { $_.Name -eq $Name })

        $End = $Lines.Count
        if ($Lines[$End - 1] -eq '') { $End-- }

        if ($Existing.Count -gt 0) {

            foreach ($Parameter in $Existing) { $Lines[$Parameter.Index] = $Parameter.Prefix + $Value }

        } elseif ($Section) {

            $Header = -1
            for ($Index = 0; $Index -lt $End; $Index++) {
                if ($Lines[$Index] -match "^\s*\[\s*$([regex]::Escape($Section))\s*\]\s*$") { $Header = $Index; break }
            }

            if ($Header -lt 0) {

                $Lines.InsertRange($End, [string[]]@("[$Section]", "$Name=$Value"))

            } else {

                $Next = $End
                for ($Index = $Header + 1; $Index -lt $End; $Index++) {
                    if ($Lines[$Index] -match '^\s*\[[^\]]+\]\s*$') { $Next = $Index; break }
                }

                $Insert = $Next
                while ($Insert -gt $Header + 1 -and [string]::IsNullOrWhiteSpace($Lines[$Insert - 1])) { $Insert-- }

                $Lines.Insert($Insert, "$Name=$Value")

            }

        } elseif ($Ini.Sections -gt 0) {

            throw "$Name not found in $FullPath. Specify -Section to add it."

        } else {

            $Lines.Insert($End, "$Name=$Value")

        }

        if ($PSCmdlet.ShouldProcess($FullPath, "Set $Name=$Value")) {

            $Bytes = [byte[]]($Ini.Encoding.GetPreamble() + $Ini.Encoding.GetBytes($Lines -join "`r`n"))

            [System.IO.File]::WriteAllBytes($FullPath, $Bytes)

        }

    }

}

# .ExternalHelp DRJones-help.xml
function Sync-ARKVaultDatabase {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('DRJones.UtilityResult')]
    param(

        [Parameter(
            Mandatory = $false,
            Position = 0
        )]
        [ValidateNotNullOrEmpty()]
        [string]$SafePattern,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$FilesSyncOnly,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$QuotaSyncOnly,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$Update

    )

    $ArgumentList = @(
        'SynchronizeDB'
        if ($SafePattern) {
            '/SafePattern'
            $SafePattern
        }
        if ($FilesSyncOnly) { '/FilesSyncOnly' }
        if ($QuotaSyncOnly) { '/QuotaSyncOnly' }
        if ($Update) { '/Update' }
        '/Force'
    )

    $Target = if ($SafePattern) { "Safes matching $SafePattern" } else { 'All Safes' }

    if ((-not $Update) -or $PSCmdlet.ShouldProcess($Target, 'Synchronize Vault database with restored files')) {

        Invoke-ARKUtility -Utility CAVaultManager -ArgumentList $ArgumentList

    }

}

# Module scope code below the Loader region is appended to the built module after the function definitions.

# Script scope configuration object, persisted by Set-ARKConfiguration
$Script:ARKConfigFile = Join-Path $HOME 'DRJonesConfiguration.xml'

$Script:ARKConfig = [PSCustomObject]@{
    UtilityPath = $null
    KeyPath     = $null
}

if (Test-Path -Path $Script:ARKConfigFile -PathType Leaf) {

    Write-Verbose "Importing Settings: $Script:ARKConfigFile"
    $Script:ARKConfig = Import-Clixml -Path $Script:ARKConfigFile

}