Private/PathHelpers.ps1

function Test-IsAdmin {
    <#
    .SYNOPSIS
        Returns $true if running as Administrator, $false otherwise.
    #>

    [CmdletBinding()]
    param()
    
    return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Get-PathEntries {
    <#
    .SYNOPSIS
        Retrieves all PATH entries for both User and Machine scopes.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('User', 'Machine', 'Both')]
        [string]$Target = 'Both'
    )

    $result = @{
        User    = @()
        Machine = @()
    }

    if ($Target -eq 'User' -or $Target -eq 'Both') {
        $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
        if ($userPath) {
            $result.User = $userPath -split ';' | Where-Object { $_ -ne '' }
        }
    }

    if ($Target -eq 'Machine' -or $Target -eq 'Both') {
        $machinePath = [Environment]::GetEnvironmentVariable('PATH', 'Machine')
        if ($machinePath) {
            $result.Machine = $machinePath -split ';' | Where-Object { $_ -ne '' }
        }
    }

    return $result
}

function Test-PathEntry {
    <#
    .SYNOPSIS
        Tests if a PATH entry exists and is valid.
    #>

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

    # Expand environment variables
    $expandedPath = [Environment]::ExpandEnvironmentVariables($Path)
    
    $exists = $false
    try {
        $exists = Test-Path -LiteralPath $expandedPath -PathType Container -ErrorAction Stop
    }
    catch {
        # Access denied or other errors - treat as inaccessible
        $exists = $false
    }
    
    return @{
        Original = $Path
        Expanded = $expandedPath
        Exists   = $exists
    }
}

function Find-DuplicatePaths {
    <#
    .SYNOPSIS
        Finds duplicate PATH entries (case-insensitive).
    #>

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

    $seen = @{}
    $duplicates = @()

    foreach ($path in $Paths) {
        $normalized = $path.TrimEnd('\').ToLowerInvariant()
        $expanded = [Environment]::ExpandEnvironmentVariables($normalized)
        
        if ($seen.ContainsKey($expanded)) {
            $duplicates += @{
                Path        = $path
                DuplicateOf = $seen[$expanded]
            }
        }
        else {
            $seen[$expanded] = $path
        }
    }

    return $duplicates
}

function Get-PathCharacterCount {
    <#
    .SYNOPSIS
        Gets the total character count of a PATH string.
    #>

    [CmdletBinding()]
    param(
        [string[]]$Paths
    )

    if ($null -eq $Paths -or $Paths.Count -eq 0) {
        return 0
    }

    return ($Paths -join ';').Length
}

function Test-PathSecurity {
    <#
    .SYNOPSIS
        Validates a path for security issues.
    .DESCRIPTION
        Checks for:
        - Forbidden characters in Windows paths (< > " | ? *)
        - Reserved Windows device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
        - Path traversal attacks (..\ or ../)
        - Control characters (ASCII 0-31)
        - Null bytes (injection attacks)
        - Excessively long paths
        - UNC paths to potentially dangerous locations
    .PARAMETER Path
        The path to validate.
    .OUTPUTS
        Returns a hashtable with:
        - IsValid: $true if path is safe, $false otherwise
        - Issues: Array of security issues found
        - Severity: 'Safe', 'Warning', or 'Critical'
    .EXAMPLE
        Test-PathSecurity -Path "C:\Windows\System32"
        # Returns: @{ IsValid = $true; Issues = @(); Severity = 'Safe' }
    .EXAMPLE
        Test-PathSecurity -Path "C:\Users\..\Windows"
        # Returns: @{ IsValid = $false; Issues = @('Path traversal detected'); Severity = 'Critical' }
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path
    )

    $issues = @()
    $severity = 'Safe'

    # Check for empty or null path
    if ([string]::IsNullOrWhiteSpace($Path)) {
        return @{
            IsValid  = $false
            Issues   = @('Path is empty or whitespace only')
            Severity = 'Critical'
        }
    }

    # Check for null bytes (injection attack vector)
    if ($Path.Contains([char]0)) {
        $issues += 'Null byte detected (potential injection attack)'
        $severity = 'Critical'
    }

    # Check for control characters (ASCII 0-31, except tab which is sometimes used)
    $controlChars = [regex]::Matches($Path, '[\x00-\x08\x0B\x0C\x0E-\x1F]')
    if ($controlChars.Count -gt 0) {
        $issues += "Control characters detected (ASCII codes: $($controlChars | ForEach-Object { [int][char]$_.Value } | Sort-Object -Unique))"
        $severity = 'Critical'
    }

    # Check for forbidden characters in Windows paths
    # Note: We allow : for drive letters (C:) and \ / for path separators
    # These characters are CRITICAL because they cannot exist in valid Windows paths
    $forbiddenChars = '<', '>', '"', '|', '?', '*'
    foreach ($char in $forbiddenChars) {
        if ($Path.Contains($char)) {
            $issues += "Forbidden character '$char' in path"
            $severity = 'Critical'
        }
    }

    # Check for reserved Windows device names
    # These cannot be used as file or folder names
    $reservedNames = @(
        'CON', 'PRN', 'AUX', 'NUL',
        'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
        'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
    )
    
    # Split path and check each component
    $pathComponents = $Path -split '[\\\/]' | Where-Object { $_ -ne '' }
    foreach ($component in $pathComponents) {
        # Remove extension for comparison (CON.txt is also reserved)
        $nameWithoutExt = $component -replace '\.[^.]*$', ''
        
        if ($reservedNames -contains $nameWithoutExt.ToUpperInvariant()) {
            $issues += "Reserved Windows name '$component' in path"
            $severity = 'Critical'
        }
    }

    # Check for path traversal attacks
    # Look for patterns like ..\ or ../ that could escape intended directory
    if ($Path -match '\.\.[\\\/]' -or $Path -match '[\\\/]\.\.([\\\/]|$)') {
        $issues += 'Path traversal detected (..\ or ../ pattern)'
        $severity = 'Critical'
    }

    # Check for paths starting with .. (relative path traversal)
    if ($Path -match '^\.\.') {
        $issues += 'Path starts with parent directory reference (..)'
        if ($severity -ne 'Critical') { $severity = 'Warning' }
    }

    # Check for excessively long paths (Windows MAX_PATH is 260, but long paths can be 32767)
    if ($Path.Length -gt 32767) {
        $issues += "Path exceeds maximum length (32767 characters)"
        $severity = 'Critical'
    }
    elseif ($Path.Length -gt 260) {
        $issues += "Path exceeds legacy MAX_PATH (260 characters) - may not work on older systems"
        if ($severity -eq 'Safe') { $severity = 'Warning' }
    }

    # Check for suspicious UNC paths
    if ($Path -match '^\\\\') {
        # UNC path - add warning but don't block
        $issues += 'UNC network path detected - verify this is a trusted location'
        if ($severity -eq 'Safe') { $severity = 'Warning' }
        
        # Check for UNC paths to localhost with traversal
        if ($Path -match '^\\\\(localhost|127\.0\.0\.1|::1)\\.*\.\.') {
            $issues += 'Suspicious UNC path with traversal to localhost'
            $severity = 'Critical'
        }
    }

    # Check for paths with trailing dots or spaces (Windows normalizes these, can be confusing)
    if ($Path -match '\s+$' -or $Path -match '\.+$') {
        $issues += 'Path ends with spaces or dots (Windows will normalize these)'
        if ($severity -eq 'Safe') { $severity = 'Warning' }
    }

    # Check for multiple consecutive separators (could indicate path manipulation)
    if ($Path -match '[\\\/]{3,}') {
        $issues += 'Multiple consecutive path separators detected'
        if ($severity -eq 'Safe') { $severity = 'Warning' }
    }

    # Check for mixed path separators (potential confusion attack)
    if ($Path -match '\\' -and $Path -match '/') {
        $issues += 'Mixed path separators (\ and /) detected'
        if ($severity -eq 'Safe') { $severity = 'Warning' }
    }

    return @{
        IsValid  = ($severity -ne 'Critical')
        Issues   = $issues
        Severity = $severity
    }
}

function Find-CrossScopeDuplicates {
    <#
    .SYNOPSIS
        Finds User PATH entries that already exist in Machine PATH.
    .DESCRIPTION
        Since Windows loads Machine PATH before User PATH, any paths that exist
        in both scopes are redundant in the User PATH. This function identifies
        such entries for potential removal.
    .PARAMETER UserPaths
        Array of User PATH entries.
    .PARAMETER MachinePaths
        Array of Machine PATH entries.
    .OUTPUTS
        Array of hashtables containing the redundant User PATH entries and
        their Machine PATH counterparts.
    .EXAMPLE
        $entries = Get-PathEntries -Target Both
        Find-CrossScopeDuplicates -UserPaths $entries.User -MachinePaths $entries.Machine
    #>

    [CmdletBinding()]
    [OutputType([array])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]]$UserPaths,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]]$MachinePaths
    )

    $crossScopeDuplicates = @()

    # Build a lookup of normalized Machine paths
    $machinePathLookup = @{}
    foreach ($path in $MachinePaths) {
        $normalized = $path.TrimEnd('\').ToLowerInvariant()
        $expanded = [Environment]::ExpandEnvironmentVariables($normalized)
        if (-not $machinePathLookup.ContainsKey($expanded)) {
            $machinePathLookup[$expanded] = $path
        }
    }

    # Check each User path against Machine paths
    foreach ($userPath in $UserPaths) {
        $normalized = $userPath.TrimEnd('\').ToLowerInvariant()
        $expanded = [Environment]::ExpandEnvironmentVariables($normalized)
        
        if ($machinePathLookup.ContainsKey($expanded)) {
            $crossScopeDuplicates += @{
                UserPath     = $userPath
                MachinePath  = $machinePathLookup[$expanded]
                ExpandedPath = $expanded
            }
        }
    }

    return $crossScopeDuplicates
}