Private/Resolve-ProjectDir.ps1

# Resolve-ProjectDir
# -----------------
# Reads active-project.txt (single-line absolute path).
# Validation pipeline (all 6 must pass):
# 1. File missing -> fallback (NO log; first-run is normal).
# 2. File empty/whitespace -> fallback + dedup WARN.
# 3. Path must match ^${BaseDir}\<segment> prefix on the RAW text.
# 4. Leaf directory NAME must match $Allowlist regex.
# 5. Path must exist on disk as a directory.
# 6. After CANONICALIZATION via [Path]::GetFullPath (collapses `..` segments)
# AND Get-Item (resolves symlinks/junctions to their real targets),
# the canonical path MUST still start with ${BaseDir}\.
# Returns: absolute path string (always). Never returns $null.
#
# BaseDir replaces the original hardcoded '^[Cc]:\Dev\' regex prefix from
# scripts/start-channels.ps1 -- callers on non-C:\Dev\ layouts (D:\Code\,
# /Users/foo/dev on hypothetical *nix port, etc.) supply their own.
#
# $script:LastResolveWarn is module-scope state for log dedup. Declared here
# so it persists across calls within the same Import-Module lifetime.

$script:LastResolveWarn = ''

# Module-scope helper hoisted out of Resolve-ProjectDir per [G4] code-reviewer
# T3-10: redefining a nested function on every call is wasteful. -LogFile is
# read from the calling function's scope via $PSCmdlet / parent variable lookup
# since this is dot-sourced, but to keep the contract explicit we accept it
# as a parameter from the caller.
function Write-ResolveOnceWarn {
    param(
        [Parameter(Mandatory)][string]$Reason,
        [Parameter(Mandatory)][string]$LogFile
    )
    if ($script:LastResolveWarn -eq $Reason) { return }
    $script:LastResolveWarn = $Reason
    Write-LogLine -Message $Reason -Level 'WARN' -LogFile $LogFile
}

function Resolve-ProjectDir {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$File,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Fallback,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$BaseDir,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Allowlist,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$LogFile
    )

    # Normalize BaseDir: drop trailing slash(es) so the regex composes cleanly.
    $baseNorm = $BaseDir.TrimEnd('\').TrimEnd('/')

    # [G4] security F3: reject bare UNC server roots like '\\server' which would
    # match the entire server's shares as if they were repo names. Operator MUST
    # pass a full UNC share path: '\\server\sharename'.
    if ($baseNorm -match '^\\\\[^\\]+$') {
        throw "BaseDir '$BaseDir' is a bare UNC server root. Pass a full UNC share path like \\server\sharename"
    }

    $basePrefixPattern = '^' + [regex]::Escape($baseNorm) + '\\[^\\]+'

    if (-not (Test-Path $File)) {
        $script:LastResolveWarn = ''
        return $Fallback
    }

    $raw = (Get-Content -Path $File -Raw -ErrorAction SilentlyContinue)
    if ([string]::IsNullOrWhiteSpace($raw)) {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt is empty, falling back to $Fallback"
        return $Fallback
    }

    $candidate = $raw.Trim().Trim('"').Trim("'")
    $normalized = $candidate -replace '/', '\'

    if ($normalized -notmatch $basePrefixPattern) {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$candidate' is not a valid $baseNorm\<name>\ path -- rejecting, falling back to $Fallback"
        return $Fallback
    }

    $leaf = Split-Path -Leaf $normalized
    if ($leaf -notmatch $Allowlist) {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt leaf '$leaf' fails allowlist regex -- rejecting, falling back to $Fallback"
        return $Fallback
    }

    if (-not (Test-Path -Path $normalized -PathType Container)) {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$normalized' does not exist on disk -- falling back to $Fallback"
        return $Fallback
    }

    try {
        $fullPath = [System.IO.Path]::GetFullPath($normalized)
    } catch {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$normalized' could not be canonicalized: $($_.Exception.Message) -- falling back to $Fallback"
        return $Fallback
    }
    if ($fullPath -notmatch $basePrefixPattern) {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$normalized' canonicalizes to '$fullPath' which escapes $baseNorm\ (.. traversal) -- rejecting, falling back to $Fallback"
        return $Fallback
    }

    try {
        $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop
    } catch {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$fullPath' Get-Item failed: $($_.Exception.Message) -- falling back to $Fallback"
        return $Fallback
    }
    if ($item.LinkType) {
        $linkTarget = @($item.Target)[0]
        if (-not $linkTarget) {
            Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$fullPath' is a $($item.LinkType) with no readable target -- falling back to $Fallback"
            return $Fallback
        }
        try {
            $resolvedTarget = [System.IO.Path]::GetFullPath($linkTarget)
        } catch {
            Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt link target '$linkTarget' could not be canonicalized -- falling back to $Fallback"
            return $Fallback
        }
        if ($resolvedTarget -notmatch $basePrefixPattern) {
            Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt path '$fullPath' is a $($item.LinkType) -> '$resolvedTarget' which escapes $baseNorm\ -- rejecting, falling back to $Fallback"
            return $Fallback
        }
        $fullPath = $resolvedTarget
    }

    $canonicalLeaf = Split-Path -Leaf $fullPath
    if ($canonicalLeaf -notmatch $Allowlist) {
        Write-ResolveOnceWarn -LogFile $LogFile -Reason "active-project.txt canonical leaf '$canonicalLeaf' fails allowlist regex -- rejecting, falling back to $Fallback"
        return $Fallback
    }
    $canonical = $fullPath

    $script:LastResolveWarn = ''
    return $canonical
}