Modules/Common.ps1

# Script: Common.ps1
# Synopsis: Part of EDCA (Exchange Deployment & Compliance Assessment)
# https://github.com/michelderooij/EDCA
# Author: Michel de Rooij
# Website: https://eightwone.com

Set-StrictMode -Version Latest

function Resolve-EDCAPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,
        [Parameter(Mandatory = $true)]
        [string]$BasePath
    )

    if ([System.IO.Path]::IsPathRooted($Path)) {
        return $Path
    }

    return [System.IO.Path]::GetFullPath((Join-Path -Path $BasePath -ChildPath $Path))
}

function New-EDCADirectoryIfMissing {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -Path $Path)) {
        $null = New-Item -Path $Path -ItemType Directory -Force
    }
}

function Write-EDCALog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR')]
        [string]$Level = 'INFO'
    )

    $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    # Collapse embedded newlines so every log entry stays on a single line.
    $normalizedMessage = ($Message -replace '\r?\n\s*', ' | ').Trim()
    Write-Host ('[{0}] [{1}] {2}' -f $timestamp, $Level, $normalizedMessage)
}

function Get-EDCAExceptionMessage {
    <#
    .SYNOPSIS
        Returns a single-line diagnostic string by walking the full exception chain.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $parts = [System.Collections.Generic.List[string]]::new()
    $ex = $ErrorRecord.Exception
    while ($null -ne $ex) {
        $msg = ($ex.Message -replace '\r?\n\s*', ' ').Trim()
        if (-not [string]::IsNullOrWhiteSpace($msg) -and -not $parts.Contains($msg)) {
            $parts.Add($msg)
        }
        $ex = $ex.InnerException
    }
    return ($parts -join ' -> ')
}

function Invoke-EDCAServerCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Server,
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,
        [object[]]$ArgumentList = @()
    )

    $localAliases = @($env:COMPUTERNAME, $env:COMPUTERNAME.ToLowerInvariant(), 'localhost', '.')

    if ($localAliases -contains $Server -or $Server.Equals($env:COMPUTERNAME, [System.StringComparison]::OrdinalIgnoreCase)) {
        Write-Verbose ('Executing script block locally on {0}.' -f $env:COMPUTERNAME)
        return & $ScriptBlock @ArgumentList
    }

    Write-Verbose ('Executing script block remotely on {0} via WinRM.' -f $Server)
    return Invoke-Command -ComputerName $Server -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
}

function Test-EDCAServerRemoteConnectivity {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Server
    )

    $localAliases = @($env:COMPUTERNAME, $env:COMPUTERNAME.ToLowerInvariant(), 'localhost', '.')
    if ($localAliases -contains $Server -or $Server.Equals($env:COMPUTERNAME, [System.StringComparison]::OrdinalIgnoreCase)) {
        Write-Verbose ('Connectivity precheck for {0}: local target, remoting check skipped.' -f $Server)
        return [pscustomobject]@{
            Server                = $Server
            IsLocal               = $true
            CanReachPort          = $true
            CanConnect            = $true
            CanReadRemoteRegistry = $true
            Details               = 'Local execution target.'
        }
    }

    $details = @()
    $canConnect = $true
    $canReachPort = $false
    $canReadRemoteRegistry = $false

    # Fast TCP port 80 reachability check — if this port is unreachable the
    # remote endpoint is guaranteed to be unreachable, so we short-circuit immediately.
    try {
        Write-Verbose ('Connectivity precheck for {0}: TCP port 80 reachability check.' -f $Server)
        $tcpClient = [System.Net.Sockets.TcpClient]::new()
        try {
            $connectTask = $tcpClient.ConnectAsync($Server, 80)
            if (-not $connectTask.Wait(5000)) {
                $canConnect = $false
                $details += 'TCP port 80 reachability check timed out after 5 seconds.'
            }
            elseif ($connectTask.IsFaulted) {
                $canConnect = $false
                $innerMsg = if ($null -ne $connectTask.Exception -and $null -ne $connectTask.Exception.InnerException) {
                    $connectTask.Exception.InnerException.Message
                }
                else { [string]$connectTask.Exception }
                $details += ('TCP port 80 reachability check failed: {0}' -f $innerMsg)
            }
            else {
                $canReachPort = $true
                $details += 'TCP port 80 reachability check passed.'
            }
        }
        finally {
            $tcpClient.Dispose()
        }
    }
    catch {
        $canConnect = $false
        $details += ('TCP port 80 reachability check error: {0}' -f $_.Exception.Message)
    }

    if ($canConnect) {
        try {
            Write-Verbose ('Connectivity precheck for {0}: validating remote command and registry access.' -f $Server)
            $probe = Invoke-Command -ComputerName $Server -ScriptBlock {
                [pscustomobject]@{
                    ComputerName    = $env:COMPUTERNAME
                    CanReadRegistry = (Test-Path -Path 'HKLM:\SOFTWARE')
                }
            } -ErrorAction Stop

            $canReadRemoteRegistry = [bool]$probe.CanReadRegistry
            if (-not $canReadRemoteRegistry) {
                $details += 'Remote session succeeded, but registry access test failed.'
            }
            else {
                $details += ('Remote session established to {0} and registry probe succeeded.' -f [string]$probe.ComputerName)
            }
        }
        catch {
            $canConnect = $false
            $details += ('Remote command execution failed: {0}' -f $_.Exception.Message)
        }
    }

    return [pscustomobject]@{
        Server                = $Server
        IsLocal               = $false
        CanReachPort          = $canReachPort
        CanConnect            = $canConnect
        CanReadRemoteRegistry = $canReadRemoteRegistry
        Details               = ($details -join ' ')
    }
}

function ConvertTo-EDCAJson {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$InputObject
    )

    return ($InputObject | ConvertTo-Json -Depth 12)
}

function ConvertFrom-EDCAJson {
    # Wrapper around ConvertFrom-Json that handles JSON files containing keys with different
    # casing (e.g. EnhancedTimeSpan serialises with both a "value" field and a "Value" property).
    # When PS7's strict parser rejects such a file, falls back to -AsHashTable and recursively
    # converts the resulting hashtable tree back to PSCustomObjects so all downstream code that
    # uses .PSObject.Properties continues to work without modification.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$InputObject
    )

    try {
        return $InputObject | ConvertFrom-Json
    }
    catch {
        if ($_.Exception.Message -notmatch 'duplicated keys|keys with different casing') { throw }
        if ($PSVersionTable.PSVersion.Major -ge 6) {
            # PS 6.2+ supports -AsHashtable, which tolerates duplicate case-variant keys.
            return ConvertFrom-EDCAJsonHashTableNode ($InputObject | ConvertFrom-Json -AsHashtable)
        }
        # PS 5.1 fallback: JavaScriptSerializer (always present in .NET 4.x) absorbs
        # duplicate case-variant keys via last-write-wins without throwing.
        $null = [System.Reflection.Assembly]::LoadWithPartialName('System.Web.Extensions')
        $jss = New-Object System.Web.Script.Serialization.JavaScriptSerializer
        $jss.MaxJsonLength = [int]::MaxValue
        return ConvertFrom-EDCAJsonHashTableNode ($jss.DeserializeObject($InputObject))
    }
}

function ConvertFrom-EDCAJsonHashTableNode {
    param([object]$Node)
    if ($Node -is [System.Collections.IDictionary]) {
        $obj = [pscustomobject]@{}
        foreach ($k in $Node.Keys) {
            $obj | Add-Member -MemberType NoteProperty -Name ([string]$k) -Value (ConvertFrom-EDCAJsonHashTableNode $Node[$k]) -Force
        }
        return $obj
    }
    if ($Node -is [System.Collections.IList] -and $Node -isnot [string]) {
        return , @($Node | ForEach-Object { ConvertFrom-EDCAJsonHashTableNode $_ })
    }
    return $Node
}