Private/00.Utils.ps1

<#
SPDX-License-Identifier: Apache-2.0
Copyright (c) 2025 Stefan Ploch
#>



# region Constants
# Kerberos principal name types used by the keytab writer and principal helpers
$script:NameTypes = @{
    KRB_NT_PRINCIPAL = 1  # Named user or krbtgt principal
    KRB_NT_SRV_HST   = 3  # Service with host name as instance (e.g., host/fqdn)
}

# Coarse classification for special/high-impact principals
$script:HighImpactPrincipals = @{ 'KRBTGT' = $true }


#endregion

# region Utility & Dependency
# ---------------------------------------------------------------------- #
#
# Utility & Dependency
#
# ---------------------------------------------------------------------- #

function Get-RequiredModule {
    <#
        .SYNOPSIS
        Ensure a PowerShell module is installed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [switch]$AutoInstall
    )
    if (Get-Module -ListAvailable -Name $Name) { return }

    if (-not $AutoInstall) { throw "Required module '$Name' not installed." }

    Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}

function Get-CredentialFromEnv {
    <#
    .SYNOPSIS
    Get a PSCredential object from environment variables.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$EnvFile
    )

    if (-not (Test-Path -LiteralPath $EnvFile)) {
         throw "Env file '$EnvFile' not found."
    }

    $pairs = @{}
    Get-Content -LiteralPath $EnvFile | Foreach-Object {
        if ($_ -match '^\s*(#|$)') { return }

        $kv = $_ -split '=',2
        if ($kv.Count -eq 2) { $pairs[$kv[0].Trim()] = $kv[1].Trim() }
    }

    $u = $pairs['STCRYPT_DCSYNC_USERNAME']
    $p = $pairs['STCRYPT_DCSYNC_PASSWORD']
    if (-not $u) { $u = $pairs['STCRYPT_DSYNC_USERNAME'] }     # legacy typo support
    if (-not $p) { $p = $pairs['STCRYPT_DSYNC_PASSWORD'] }     # legacy typo support

    if (-not $u -or -not $p) {
        throw "Env file missing STCRYPT_DCSYNC_USERNAME/STCRYPT_DCSYNC_PASSWORD (or legacy STCRYPT_DSYNC_USERNAME/STCRYPT_DSYNC_PASSWORD)."
    }

    $sec = ConvertTo-SecureString $p -AsPlainText -Force
    [pscredential]::new($u,$sec)
}

function New-StrongPassword {
    <#
    .SYNOPSIS
    Generate a cryptographically strong random password.
    #>

    [CmdletBinding()]
    param (
        [int]$Length = 64
    )

    # Character sets for pw generation
    $uppercase =  'ABCDEFGHJKLMNPQRSTUVWXYZ' #excluding I, O for readability
    $lowercase =  'abcdefghijkmnopqrstuvwxyz' # excluding l for readability
    $numbers   =  '0123456789'
    $special   =  '!@#$%^&*()-_=+[]{};:,.<>?'

    $allChars = $uppercase + $lowercase + $numbers + $special

    $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::New()
    try {
        $bytes = New-Object byte[] $Length
        $rng.GetBytes($bytes)

        $password = ""
        for ($i = 0; $i -lt $Length; $i++) {
            $password += $allChars[$bytes[$i] % $allChars.Length]
        }

        # Ensure complexity requirements are met
        $hasUpper = $password -cmatch '[A-Z]'
        $hasLower = $password -cmatch '[a-z]'
        $hasNumber = $password -cmatch '[0-9]'
        $hasSymbol = $password -cmatch '[^A-Za-z0-9]'

        if (-not ($hasUpper -and $hasLower -and $hasNumber -and $hasSymbol)) {
            # Regenerate if complexity requirements not met
            return New-StrongPassword -Length $Length
        }

        return ConvertTo-SecureString $password -AsPlainText -Force

    } finally {
        $rng.Dispose()
    }
}

function Resolve-PathUniversal {
    <#
    .SYNOPSIS
    REsolve a FileSystem path to an aboslute path, handling relative paths and UNC.
 
    .DESCRIPTION
    - For existing paths, return the provider-resolved absolute path.
    - For non-existing paths, resolves against a base directory (current location by default).
    - Errors on non-FileSystem providers (e.g. HKLM:\)
 
        .PARAMETER PATH
        The path to resolve.
 
        .PARAMETER Purpose
        'Input' (default) implies MustExist unless overridden; 'Output' implies MustNotExist.
 
        .PARAMETER MustExist
        Explicitly require existence
 
        .PARAMETER BaseDirectory
        Base to resolve relative paths when the leaf does not yet exist. Defaults to Get-Location.
 
        .OUTPUTS
        System.String absolute FileSystem path.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [ValidateSet('Input','Output')]
        [string]$Purpose = 'Input',
        [switch]$MustExist,
        [string]$BaseDirectory = ((Get-Location).Path)
    )

    # Default MustExist by purpose
    if (-not $PSBoundParameters.ContainsKey('MustExist')) {
        $MustExist = ($Purpose -eq 'Input')
    }

    # Normalize base directory
    $baseDir = if ($BaseDirectory) { [System.IO.Path]::GetFullPath($BaseDirectory) } else { [System.IO.Directory]::GetCurrentDirectory() }

    # If the path exists, return its absolute filesystem path without using Resolve-Path (avoid mocked cmdlets)
    if (Test-Path -LiteralPath $Path) {
        if ([System.IO.Path]::IsPathRooted($Path)) { return [System.IO.Path]::GetFullPath($Path) }
        return [System.IO.Path]::GetFullPath( (Join-Path -Path $baseDir -ChildPath $Path) )
    }

    # Non-existing leaf (e.g., Output). Build from parent/leaf
    $parent = Split-Path -Path $Path -Parent
    $leaf = Split-Path -Path $Path -Leaf

    if ([string]::IsNullOrWhiteSpace($parent)) {
        # relative leaf only
        return [System.IO.Path]::GetFullPath( (Join-Path -Path $baseDir -ChildPath $leaf) )
    }

    # Parent provided
    $parentAbs = if ([System.IO.Path]::IsPathRooted($parent)) {
        [System.IO.Path]::GetFullPath($parent)
    } else {
        [System.IO.Path]::GetFullPath( (Join-Path -Path $baseDir -ChildPath $parent) )
    }

    if ((-not (Test-Path -LiteralPath $parentAbs)) -and $MustExist) {
        throw "Path not found: '$Path' (parent '$parent' does not exist)"
    }

    return [System.IO.Path]::GetFullPath( (Join-Path -Path $parentAbs -ChildPath $leaf) )

}

function Resolve-OutputPath {
    <#
    .SYNOPSIS
    Derive an absolute output file path from inputs and options.
 
        .PARAMETER OutputPath
        Optional explicit output. If provided, returns its absolute path (creating parent when -CreateDirectory).
 
        .PARAMETER InputPath
        Optional input file (absolute or relative). When OutputPath is omitted, its directory and basename guide defaults.
 
        .PARAMETER BaseName
        Optional base file name (without extension). If omitted and InputPath present, uses its basename.
 
        .PARAMETER Extension
        Desired output extension, like '.keytab' or '.json'. If omitted, keeps BaseName as-is.
 
        .PARAMETER Directory
        Optional directory to place the output in. Defaults to InputPath’s directory or current location.
 
        .PARAMETER AppendExtension
        Append Extension instead of replacing (e.g., file.keytab + '.dpapi' -> file.keytab.dpapi).
 
        .PARAMETER CreateDirectory
        Create the parent directory when it does not exist.
 
        .OUTPUTS
        System.String absolute FileSystem path.
    #>


    [CmdletBinding()]
    param(
        [string]$OutputPath,
        [string]$InputPath,
        [string]$BaseName,
        [string]$Extension,
        [string]$Directory,
        [switch]$AppendExtension,
        [switch]$CreateDirectory
    )

    if ($OutputPath) {
        $abs = Resolve-PathUniversal -Path $OutputPath -Purpose Output
        $parent = Split-Path -Path $abs -Parent
        if ($CreateDirectory -and -not (Test-Path -Path $parent)) {
            New-Item -Path $parent -ItemType Directory -Force | Out-Null
        }
        return $abs
    }

    $inAbs = $null
    if ($InputPath) { $inAbs = Resolve-PathUniversal -Path $InputPath -Purpose Input }

    $dir = if ($Directory) {
        (Resolve-PathUniversal -Path $Directory -Purpose Output)
    } elseif ($inAbs) {
        Split-Path -Path $inAbs -Parent
    } else {
        (Get-Location).Path
    }

    # determine basename
    $name = if ($BaseName) {
        $BaseName
    } elseif ($inAbs) {
        [System.IO.Path]::GetFileNameWithoutExtension($inAbs)
    } else {
        'output'
    }

    $name = (Sanitize-FileName -Name $name)

    $fileName =
        if ($Extension) {
            if ($AppendExtension) {
                $name + $Extension
            } else {
                [System.IO.Path]::GetFileName( [System.IO.Path]::ChangeExtension($name, $Extension))
            }
        } else {
            $name
        }

    $candidate = Join-Path $dir $fileName
    $absOut = [System.IO.Path]::GetFullPath($candidate)

    if ($CreateDirectory) {
        $parent = Split-Path -Path $absOut -Parent
        if (-not (Test-Path -Path $parent)) {
            New-Item -Path $parent -ItemType Directory -Force | Out-Null
        }
    }
    return $absOut
}

function New-MergeOutputFileName {
    <#
    .SYNOPSIS
    Build a deterministic merged file name from multiple input paths.
 
        .PARAMETER InputPaths
        Array of input file paths (absolute or relative).
 
        .PARAMETER Suffix
        Suffix to append (default 'merged').
 
        .PARAMETER Extension
        Output extension including dot (e.g., '.keytab').
 
        .PARAMETER MaxParts
        Limit number of basenames included to keep file name readable (default 5).
 
        .PARAMETER MaxLength
        Maximum final file name length before a hash tail is added (default 120 chars).
 
        .OUTPUTS
        System.String file name only (no directory).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$InputPaths,
        [string]$Suffix = 'merged',
        [Parameter(Mandatory)][string]$Extension,
        [int]$MaxParts = 5,
        [int]$MaxLength = 120
    )

    $bases = @()
    foreach ($p in $InputPaths) {
        $abs = Resolve-PathUniversal -Path $p -Purpose Input
        $bases += [System.IO.Path]::GetFileNameWithoutExtension($abs)
    }
    if ($bases.Count -gt $MaxParts) {
        $bases = $bases[0..($MaxParts - 1)]
    }

    $safeParts = $bases | ForEach-Object { Sanitize-FileName -Name $_ }
    $joined = ($safeParts -join '_')

    if ($Suffix) { $joined = "$($joined)_$(Sanitize-FileName -Name $Suffix)" }

    # Enforce max length
    $finalStem = $joined
    if ($finalStem.Length -gt $MaxLength) {
        $hash = Get-ShortHash -Text $joined
        $keep = [Math]::Max(1, $MaxLength - 9) # leave room for ~ + 8 hex
        $finalStem = $finalStem.Substring(0, $keep) + '~' + $hash
    }

    return "$($finalStem)$($Extension)"
}

function Sanitize-FileName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name
    )
    $invalid = [System.IO.Path]::GetInvalidFileNameChars()
    $sb = New-Object System.Text.StringBuilder
    foreach ($ch in $Name.ToCharArray()) {
        if ($invalid -contains $ch) { [void]$sb.Append('_') }
        else { [void]$sb.Append($ch) }
    }

    # avoid trailing/leading spaces and dots on Windows
    $out = $sb.ToString().Trim()
    while ($out.EndsWith('.')) { $out = $out.TrimEnd('.') }
    if ([string]::IsNullOrWhiteSpace($out)) { $out = 'file' }
    return $out
}

function Get-ShortHash {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Text)
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $hash = $sha.ComputeHash($bytes)
        # 8 hex chars tail
        return ($hash | Select-Object -First 4 | ForEach-Object { $_.ToString('x2') }) -join ''
    } finally {
        $sha.Dispose()
    }
}
#endregion