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 } |