Modules/IdLE.Core/Private/Resolve-IdleTemplateString.ps1

function Resolve-IdleTemplateString {
    <#
    .SYNOPSIS
    Resolves template placeholders in a string using request context.

    .DESCRIPTION
    Scans a string for {{...}} placeholders and resolves them against the request object.
    Only allowlisted request roots are permitted for security.

    Template syntax:
    - Placeholder format: {{<Path>}}
    - Path is a dot-separated property path
    - Multiple placeholders are supported in one string

    Allowed roots (security boundary):
    - Request.Input.* (aliased to Request.DesiredState.* if Input does not exist)
    - Request.DesiredState.*
    - Request.IdentityKeys.*
    - Request.Changes.*
    - Request.LifecycleEvent
    - Request.CorrelationId
    - Request.Actor

    Escaping:
    - \{{ → literal {{ (escape removed after resolution)

    .PARAMETER Value
    The string value to resolve. If not a string, returns the value unchanged.

    .PARAMETER Request
    The request object providing context for template resolution.

    .PARAMETER StepName
    The name of the step being processed (for error messages).

    .OUTPUTS
    Resolved string with placeholders replaced by request values.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object] $Value,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Request,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $StepName
    )

    if ($null -eq $Value) {
        return $null
    }

    if ($Value -isnot [string]) {
        return $Value
    }

    $stringValue = [string]$Value

    # Quick exit: no template markers present
    if ($stringValue -notlike '*{{*' -and $stringValue -notlike '*}}*') {
        # Handle escaped braces with no actual templates
        if ($stringValue -like '*\{{*') {
            return $stringValue -replace '\\{{', '{{'
        }
        return $stringValue
    }

    # Check for unbalanced braces (typo safety)
    # Count non-escaped opening braces
    $openCount = ([regex]::Matches($stringValue, '(?<!\\)\{\{')).Count
    # For closing braces, only count those that belong to templates (have a corresponding non-escaped opening)
    # We can do this by counting matches of the full template pattern
    $templatePattern = '(?<!\\)\{\{([^}]+)\}\}'
    $templateCount = ([regex]::Matches($stringValue, $templatePattern)).Count
    # Any }} that's part of a template is matched. Any other }} is unbalanced.
    $allCloseCount = ([regex]::Matches($stringValue, '\}\}')).Count
    
    # The expected close count should equal template count (each template has one closing)
    if ($openCount -ne $templateCount -or $allCloseCount -ne $templateCount) {
        throw [System.ArgumentException]::new(
            ("Template syntax error in step '{0}': Unbalanced braces in value '{1}'. Found {2} opening '{{{{' and {3} closing '}}}}'. Check for typos or missing braces." -f $StepName, $stringValue, $openCount, $allCloseCount),
            'Workflow'
        )
    }

    # Parse and resolve placeholders
    $result = $stringValue
    $pattern = '(?<!\\)\{\{([^}]+)\}\}'
    $matches = [regex]::Matches($stringValue, $pattern)

    foreach ($match in $matches) {
        $placeholder = $match.Groups[0].Value
        $path = $match.Groups[1].Value.Trim()

        # Validate path pattern (strict: alphanumeric + dots only)
        if ($path -notmatch '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$') {
            throw [System.ArgumentException]::new(
                ("Template path error in step '{0}': Invalid path pattern '{1}'. Paths must use dot-separated identifiers (letters, numbers, underscores) with no spaces or special characters." -f $StepName, $path),
                'Workflow'
            )
        }

        # Security: validate allowed roots
        $allowedRoots = @('Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor')
        $isAllowed = $false
        foreach ($root in $allowedRoots) {
            if ($path -eq $root -or $path.StartsWith("$root.")) {
                $isAllowed = $true
                break
            }
        }

        if (-not $isAllowed) {
            throw [System.ArgumentException]::new(
                ("Template security error in step '{0}': Path '{1}' is not allowed. Only these roots are permitted: {2}" -f $StepName, $path, ([string]::Join(', ', $allowedRoots))),
                'Workflow'
            )
        }

        # Handle Request.Input.* alias to Request.DesiredState.*
        $resolvePath = $path
        $hasInputProperty = $false
        if ($Request.PSObject.Properties['Input']) {
            $hasInputProperty = $true
        }
        
        if ($path.StartsWith('Request.Input.')) {
            if (-not $hasInputProperty) {
                # Alias to DesiredState
                $resolvePath = $path -replace '^Request\.Input\.', 'Request.DesiredState.'
            }
        }
        elseif ($path -eq 'Request.Input') {
            if (-not $hasInputProperty) {
                $resolvePath = 'Request.DesiredState'
            }
        }

        # Resolve the value (using custom logic that handles hashtables)
        $contextWrapper = [pscustomobject]@{ Request = $Request }
        $current = $contextWrapper
        foreach ($segment in ($resolvePath -split '\.')) {
            if ($null -eq $current) {
                $resolvedValue = $null
                break
            }

            # Handle hashtables/dictionaries
            if ($current -is [System.Collections.IDictionary]) {
                if ($current.ContainsKey($segment)) {
                    $current = $current[$segment]
                }
                else {
                    $current = $null
                }
            }
            # Handle PSCustomObjects and class instances
            else {
                $prop = $current.PSObject.Properties[$segment]
                if ($null -eq $prop) {
                    $current = $null
                }
                else {
                    $current = $prop.Value
                }
            }
        }
        $resolvedValue = $current

        # Fail fast on null/missing values
        if ($null -eq $resolvedValue) {
            throw [System.ArgumentException]::new(
                ("Template resolution error in step '{0}': Path '{1}' resolved to null or does not exist. Ensure the request contains all required values." -f $StepName, $path),
                'Workflow'
            )
        }

        # Type validation: only scalar-ish types allowed
        if ($resolvedValue -is [hashtable] -or
            $resolvedValue -is [System.Collections.IDictionary] -or
            $resolvedValue -is [array] -or
            ($resolvedValue -is [System.Collections.IEnumerable] -and $resolvedValue -isnot [string])) {
            throw [System.ArgumentException]::new(
                ("Template type error in step '{0}': Path '{1}' resolved to a non-scalar value (hashtable/array/object). Templates only support scalar values (string, number, bool, datetime, guid). Use an explicit mapping step or host-side pre-flattening." -f $StepName, $path),
                'Workflow'
            )
        }

        # Convert to string
        $stringReplacement = [string]$resolvedValue

        # Replace placeholder
        $result = $result.Replace($placeholder, $stringReplacement)
    }

    # Process escape sequences (unescape \{{ to {{)
    $result = $result -replace '\\{{', '{{'

    return $result
}