Private/Transforms.ps1

# Catalog transforms and the helpers that place a value at a property path.
#
# idOf:<type> a name as that type's id, through POST /deployments/resources/read
# idsOf:<type> names as ids
# relationOf:<kind> names as [{id, type: <kind>}], resolved through VirtualMetric/<kind>s
# secure a SecureString, kept as one until the body is serialized
# merge a hashtable deep-merged into the object at the target
# status enable/disable: properties.status active or passive

function Resolve-VMetricResourceId {
    # The ids of resources of one type, by name, in the order given. A name nothing answers to is an error.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [string] $Type,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]] $Name,

        [AllowNull()]
        [System.Collections.IDictionary] $Parent
    )

    if ($Name.Count -eq 0) {
        return , ([string[]]@())
    }
    $resources = foreach ($item in $Name) {
        $lookup = [ordered]@{ type = $Type; name = $item }
        if ($Parent) {
            $lookup['parent'] = $Parent
        }
        $lookup
    }
    $answer = Invoke-VMetricRequest -Method POST -Path '/deployments/resources/read' -Body ([ordered]@{ resources = @($resources) })
    $found = ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $answer -Name 'resources')

    $ids = [System.Collections.Generic.List[string]]::new()
    for ($i = 0; $i -lt $Name.Count; $i++) {
        $match = $null
        foreach ($candidate in $found) {
            if ([string](Get-VMetricMember -InputObject $candidate -Name 'name') -ceq $Name[$i]) {
                $match = $candidate
                break
            }
        }
        if (-not $match -and $i -lt $found.Count) {
            $match = $found[$i]
        }
        if (-not $match -or -not (Get-VMetricMember -InputObject $match -Name 'exists')) {
            throw (New-VMetricException -Message "No $Type named '$(ConvertTo-VMetricSafeText -Text $Name[$i])' was found." -Code 'ResourceNotFound' -Category ObjectNotFound)
        }
        $ids.Add([string](Get-VMetricMember -InputObject $match -Name 'id'))
    }
    return , $ids.ToArray()
}

function Resolve-VMetricParamValue {
    # A bound value as the catalog param's transform makes it. Switches become booleans.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Param,

        [AllowNull()]
        [object] $Value
    )

    if ($Value -is [System.Management.Automation.SwitchParameter]) {
        $Value = $Value.IsPresent
    }
    $transform = [string](Get-VMetricMember -InputObject $Param -Name 'transform')
    if (-not $transform -or $transform -eq 'secure' -or $transform -eq 'merge') {
        if ($Value -is [array]) {
            return , $Value
        }
        return $Value
    }
    if ($transform -eq 'status') {
        if ($Value -is [bool]) {
            return $(if ($Value) { 'active' } else { 'passive' })
        }
        return [string]$Value
    }

    $separator = $transform.IndexOf(':')
    $name = if ($separator -gt 0) { $transform.Substring(0, $separator) } else { $transform }
    $argument = if ($separator -gt 0) { $transform.Substring($separator + 1) } else { '' }
    if ($name -eq 'idOf') {
        if ($null -eq $Value -or [string]$Value -eq '') {
            return ''
        }
        return (Resolve-VMetricResourceId -Type $argument -Name @([string]$Value))[0]
    }
    if ($name -eq 'idsOf' -or $name -eq 'relationOf') {
        $names = [System.Collections.Generic.List[string]]::new()
        foreach ($item in (ConvertTo-VMetricArray -InputObject $Value)) {
            if ([string]$item -ne '') {
                $names.Add([string]$item)
            }
        }
        if ($name -eq 'idsOf') {
            return , (Resolve-VMetricResourceId -Type $argument -Name $names.ToArray())
        }
        $relations = [System.Collections.Generic.List[object]]::new()
        foreach ($id in (Resolve-VMetricResourceId -Type "VirtualMetric/$($argument)s" -Name $names.ToArray())) {
            $relations.Add([ordered]@{ id = $id; type = $argument })
        }
        return , $relations.ToArray()
    }
    throw (New-VMetricException -Message "The cmdlet catalog uses a transform this module does not know ($transform). Update the VirtualMetric module." `
            -Code 'UnknownTransform' -Category NotImplemented -Terminating)
}

function Get-VMetricEntryList {
    # The name/value pairs of a dictionary or a PSCustomObject.
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $entries = [System.Collections.Generic.List[object]]::new()
    if ($InputObject -is [System.Collections.IDictionary]) {
        foreach ($key in $InputObject.Keys) {
            $entries.Add([pscustomobject]@{ Name = [string]$key; Value = $InputObject[$key] })
        }
    }
    elseif (Test-VMetricObjectLike -InputObject $InputObject) {
        foreach ($property in $InputObject.psobject.Properties) {
            if ($property.MemberType -eq 'NoteProperty') {
                $entries.Add([pscustomobject]@{ Name = $property.Name; Value = $property.Value })
            }
        }
    }
    return , $entries.ToArray()
}

function Get-VMetricChildDictionary {
    # The dictionary under a key, created (or converted from a PSCustomObject) when needed. The existing key's
    # spelling is kept: the engine reads property names case-sensitively.
    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Parent,

        [Parameter(Mandatory)]
        [string] $Name
    )

    $key = Get-VMetricDictionaryKey -Dictionary $Parent -Name $Name
    if ($null -eq $key) {
        $key = $Name
        $Parent[$key] = [ordered]@{}
    }
    elseif ($Parent[$key] -isnot [System.Collections.IDictionary]) {
        if (Test-VMetricObjectLike -InputObject $Parent[$key]) {
            $Parent[$key] = Copy-VMetricData -InputObject $Parent[$key]
        }
        else {
            $Parent[$key] = [ordered]@{}
        }
    }
    return $Parent[$key]
}

function Set-VMetricPathValue {
    # Sets the value at a dotted path (properties.properties.port), creating the objects on the way.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes an in-memory object only.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path,

        [AllowNull()]
        [object] $Value
    )

    $segments = $Path.Split('.')
    $node = $Target
    for ($i = 0; $i -lt $segments.Length - 1; $i++) {
        $node = Get-VMetricChildDictionary -Parent $node -Name $segments[$i]
    }
    $last = $segments[$segments.Length - 1]
    $key = Get-VMetricDictionaryKey -Dictionary $node -Name $last
    if ($null -eq $key) {
        $key = $last
    }
    $node[$key] = $Value
}

function Get-VMetricPathValue {
    # The value at a dotted path, matching keys ignoring case; $null when a step is missing.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path
    )

    $node = $Target
    foreach ($segment in $Path.Split('.')) {
        $node = Get-VMetricMember -InputObject $node -Name $segment
        if ($null -eq $node) {
            return $null
        }
    }
    if ($node -is [array]) {
        return , $node
    }
    return $node
}

function Set-VMetricRelationValue {
    # relationOf: the relations of its own kind (director or cluster) are replaced and the others kept, so
    # -Director and -Cluster combine, and Set -Director keeps a device's clusters.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes an in-memory object only.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Kind,

        [AllowNull()]
        [object] $Value
    )

    $relations = [System.Collections.Generic.List[object]]::new()
    foreach ($relation in (ConvertTo-VMetricArray -InputObject (Get-VMetricPathValue -Target $Target -Path $Path))) {
        if ([string](Get-VMetricMember -InputObject $relation -Name 'type') -ne $Kind) {
            $relations.Add($relation)
        }
    }
    foreach ($relation in (ConvertTo-VMetricArray -InputObject $Value)) {
        $relations.Add($relation)
    }
    Set-VMetricPathValue -Target $Target -Path $Path -Value $relations.ToArray()
}

function Merge-VMetricDictionary {
    # Deep-merges Source into Target: objects merge key by key, anything else (a list included) replaces.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [object] $Source
    )

    foreach ($entry in (Get-VMetricEntryList -InputObject $Source)) {
        $key = Get-VMetricDictionaryKey -Dictionary $Target -Name $entry.Name
        if ($null -ne $key -and (Test-VMetricObjectLike -InputObject $entry.Value) -and (Test-VMetricObjectLike -InputObject $Target[$key])) {
            $child = Get-VMetricChildDictionary -Parent $Target -Name $key
            Merge-VMetricDictionary -Target $child -Source $entry.Value
            continue
        }
        if ($null -eq $key) {
            $key = $entry.Name
        }
        $Target[$key] = Copy-VMetricData -InputObject $entry.Value
    }
}

function Merge-VMetricPathValue {
    # The merge transform: deep-merges a hashtable into the object at a dotted path.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Target,

        [Parameter(Mandatory)]
        [string] $Path,

        [AllowNull()]
        [object] $Value,

        [string] $ParameterName = 'Property'
    )

    if ($null -eq $Value) {
        return
    }
    if (-not (Test-VMetricObjectLike -InputObject $Value)) {
        throw (New-VMetricException -Message "-$ParameterName takes a hashtable of the properties to change, such as @{ name = 'value' }." `
                -Code 'InvalidMergeValue' -Category InvalidArgument)
    }
    $node = $Target
    foreach ($segment in $Path.Split('.')) {
        $node = Get-VMetricChildDictionary -Parent $node -Name $segment
    }
    Merge-VMetricDictionary -Target $node -Source $Value
}

function Get-VMetricSecretText {
    # The text of every SecureString in a value, whatever its length: what a preview must never print, whatever
    # the server echoes.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $found = [System.Collections.Generic.List[string]]::new()
    $walk = {
        param($Value)

        if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType]) {
            return
        }
        if ($Value -is [System.Security.SecureString]) {
            $text = [System.Net.NetworkCredential]::new('', $Value).Password
            if ($text.Length -gt 0) {
                $found.Add($text)
            }
            return
        }
        foreach ($entry in (Get-VMetricEntryList -InputObject $Value)) {
            & $walk $entry.Value
        }
        if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [System.Collections.IDictionary]) {
            foreach ($item in $Value) {
                & $walk $item
            }
        }
    }
    & $walk $InputObject
    return , $found.ToArray()
}

function Hide-VMetricSecretInObject {
    # Masks, in place, every string of an emitted object (a server's diagnostic, a what-if delta) that carries
    # the text of a secret the request held.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject,

        [AllowNull()]
        [string[]] $Secret
    )

    if (-not $Secret -or $null -eq $InputObject) {
        return
    }
    $walk = {
        param($Value)

        if ($Value -is [System.Collections.IList]) {
            for ($i = 0; $i -lt $Value.Count; $i++) {
                if ($Value[$i] -is [string]) {
                    $Value[$i] = Hide-VMetricSecretText -Text $Value[$i] -Secret $Secret
                }
                else {
                    & $walk $Value[$i]
                }
            }
            return
        }
        if (-not (Test-VMetricObjectLike -InputObject $Value)) {
            return
        }
        if ($Value -is [System.Collections.IDictionary]) {
            foreach ($key in @($Value.Keys)) {
                if ($Value[$key] -is [string]) {
                    $Value[$key] = Hide-VMetricSecretText -Text $Value[$key] -Secret $Secret
                }
                else {
                    & $walk $Value[$key]
                }
            }
            return
        }
        foreach ($property in $Value.psobject.Properties) {
            if ($property.MemberType -ne 'NoteProperty') {
                continue
            }
            if ($property.Value -is [string]) {
                $property.Value = Hide-VMetricSecretText -Text $property.Value -Secret $Secret
            }
            else {
                & $walk $property.Value
            }
        }
    }
    & $walk $InputObject
}

function ConvertTo-VMetricJsonStringText {
    # A string as it reads between the quotes of a JSON string: as ConvertTo-Json writes it, or with -Html as
    # Go's encoding/json does (<, > and & escaped too), which is how the API echoes one inside JSON text.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Text,

        [switch] $Html
    )

    $escape = '\' + 'u'
    $builder = [System.Text.StringBuilder]::new()
    foreach ($character in $Text.ToCharArray()) {
        $code = [int]$character
        switch ($code) {
            0x22 { [void]$builder.Append('\"') }
            0x5C { [void]$builder.Append('\\') }
            0x0A { [void]$builder.Append('\n') }
            0x0D { [void]$builder.Append('\r') }
            0x09 { [void]$builder.Append('\t') }
            0x08 { [void]$builder.Append('\b') }
            0x0C { [void]$builder.Append('\f') }
            default {
                if ($code -lt 0x20 -or ($Html -and ($code -in @(0x3C, 0x3E, 0x26, 0x2028, 0x2029)))) {
                    [void]$builder.Append($escape).Append($code.ToString('x4'))
                }
                else {
                    [void]$builder.Append($character)
                }
            }
        }
    }
    $builder.ToString()
}

function Get-VMetricSecretSpelling {
    # Every spelling of the secrets a message may carry: as they are, and as they read inside JSON text (a
    # secret holding a quote or a backslash is echoed as \" or \\), longest first.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [string[]] $Secret
    )

    $spellings = [System.Collections.Generic.List[string]]::new()
    foreach ($value in $Secret) {
        if (-not $value) {
            continue
        }
        foreach ($spelling in @($value, (ConvertTo-VMetricJsonStringText -Text $value), (ConvertTo-VMetricJsonStringText -Text $value -Html))) {
            if (-not $spellings.Contains($spelling)) {
                $spellings.Add($spelling)
            }
        }
    }
    return , @($spellings | Sort-Object -Property Length -Descending)
}

function Hide-VMetricSecretText {
    # Masks the secrets in a message: a text that is a secret, of any length, becomes ***; a secret of three
    # characters or more is replaced wherever the text holds it, as it is or as JSON escapes it. (A shorter one
    # would mask every word it happens to spell; a value that holds one is masked where it sits, by
    # Hide-VMetricSecretValue.)
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Text,

        [AllowNull()]
        [string[]] $Secret
    )

    if ([string]::IsNullOrEmpty($Text) -or -not $Secret) {
        return $Text
    }
    $spellings = Get-VMetricSecretSpelling -Secret $Secret
    foreach ($spelling in $spellings) {
        if ([string]::Equals($Text, $spelling, [System.StringComparison]::Ordinal)) {
            return '***'
        }
    }
    foreach ($spelling in $spellings) {
        if ($spelling.Length -ge 3) {
            $Text = $Text.Replace($spelling, '***')
        }
    }
    return $Text
}

function Get-VMetricSecretPath {
    # Where a value holds a SecureString, as the engine writes a what-if delta's path: properties.value,
    # properties.headers[0].value. A resource's secret paths are masked in its preview whatever the server
    # answers there.
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $paths = [System.Collections.Generic.List[string]]::new()
    $walk = {
        param($Value, [string] $Path)

        if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType]) {
            return
        }
        if ($Value -is [System.Security.SecureString]) {
            $paths.Add($Path)
            return
        }
        if ($Value -is [System.Collections.IDictionary] -or (Test-VMetricObjectLike -InputObject $Value)) {
            foreach ($entry in (Get-VMetricEntryList -InputObject $Value)) {
                & $walk $entry.Value $(if ($Path) { "$Path.$($entry.Name)" } else { $entry.Name })
            }
            return
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $index = 0
            foreach ($item in $Value) {
                & $walk $item "$Path[$index]"
                $index++
            }
        }
    }
    & $walk $InputObject ''
    return , $paths.ToArray()
}

function Hide-VMetricSecretValue {
    # A copy of a what-if value for display, masked before it is encoded: a value at one of SecretPath (the
    # request's secrets' own paths) is *** whatever it holds, and every other string goes through
    # Hide-VMetricSecretText. Masking the encoded text instead would miss a secret JSON escapes, or a short one.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject,

        [AllowNull()]
        [string[]] $Secret,

        # The value's own path (properties.value).
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Path,

        [AllowNull()]
        [string[]] $SecretPath
    )

    $secrets = @($Secret)
    $secretPaths = @($SecretPath)
    $walk = {
        param($Value, [string] $Here)

        if ($null -eq $Value) {
            return $null
        }
        $atSecret = $false
        foreach ($candidate in $secretPaths) {
            if ($candidate -and [string]::Equals($candidate, $Here, [System.StringComparison]::OrdinalIgnoreCase)) {
                $atSecret = $true
            }
        }
        if ($Value -is [System.Security.SecureString]) {
            return '***'
        }
        if ($Value -is [string]) {
            if ($atSecret -and $Value -ne '') {
                return '***'
            }
            return (Hide-VMetricSecretText -Text $Value -Secret $secrets)
        }
        if ($Value -is [System.ValueType]) {
            if ($atSecret) {
                return '***'
            }
            return $Value
        }
        if ($Value -is [System.Collections.IDictionary] -or (Test-VMetricObjectLike -InputObject $Value)) {
            $map = [ordered]@{}
            foreach ($entry in (Get-VMetricEntryList -InputObject $Value)) {
                $map[$entry.Name] = & $walk $entry.Value $(if ($Here) { "$Here.$($entry.Name)" } else { $entry.Name })
            }
            return $map
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $list = [System.Collections.Generic.List[object]]::new()
            $index = 0
            foreach ($item in $Value) {
                $list.Add((& $walk $item "$Here[$index]"))
                $index++
            }
            return , $list.ToArray()
        }
        return $Value
    }
    $masked = & $walk $InputObject ([string]$Path)
    if ($masked -is [array]) {
        return , $masked
    }
    return $masked
}