Private/Resolve-TheCleanersFileSystemPath.ps1
|
function Test-TheCleanersFullyQualifiedPath { <# .SYNOPSIS Test whether a value uses a fully qualified Windows filesystem path syntax. .DESCRIPTION Accept standard and extended-length drive-qualified paths and UNC paths with both server and share components. Reject drive-relative, root-relative, provider-qualified, and device paths before filesystem resolution. .PARAMETER Path Path syntax to test without resolving it. .OUTPUTS System.Boolean #> [OutputType([bool])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Path ) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } $Comparison = [System.StringComparison]::OrdinalIgnoreCase $NormalizedPath = $Path.Replace('/', '\') if ($NormalizedPath.StartsWith('\\.\', $Comparison)) { return $false } if ($NormalizedPath.StartsWith('\\?\', $Comparison)) { if ($NormalizedPath -match '^\\\\\?\\[A-Za-z]:[\\/]') { return $true } if ($NormalizedPath -match '^\\\\\?\\UNC\\[^\\/]+\\[^\\/]') { return $true } return $false } if ($NormalizedPath.StartsWith('\\')) { $Segments = $NormalizedPath -split '\\' if ($Segments.Count -lt 4 -or [string]::IsNullOrWhiteSpace($Segments[2]) -or [string]::IsNullOrWhiteSpace($Segments[3])) { return $false } return $true } if ($NormalizedPath -match '^[A-Za-z]:[\\/]') { return $true } $false } function Convert-TheCleanersPathForComparison { <# .SYNOPSIS Normalize supported filesystem syntax for safe path comparisons. .DESCRIPTION Remove only the extended-length namespace prefix. The filesystem identity checks remain responsible for object identity; this helper is only for separator-boundary and broad-root comparisons. .PARAMETER Path Fully qualified filesystem path. .OUTPUTS System.String #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) $ComparablePath = $Path.Replace('/', '\') $IsExtendedLength = $ComparablePath.StartsWith('\\?\', [System.StringComparison]::OrdinalIgnoreCase) if ($IsExtendedLength) { if ($ComparablePath.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase)) { $ComparablePath = '\\' + $ComparablePath.Substring(8) } else { $ComparablePath = $ComparablePath.Substring(4) } # Windows PowerShell 5.1 can reject a long path after the namespace # prefix has been removed. Normalize the already-qualified string without # asking the legacy Path implementation to enforce MAX_PATH. $PathRoot = [System.IO.Path]::GetPathRoot($ComparablePath) if ([string]::IsNullOrWhiteSpace($PathRoot)) { throw "The extended-length path has no usable root: '$Path'." } $Remainder = $ComparablePath.Substring($PathRoot.Length) $Segments = [System.Collections.Generic.List[string]]::new() foreach ($Segment in @($Remainder -split '\\')) { if ([string]::IsNullOrEmpty($Segment) -or $Segment -eq '.') { continue } if ($Segment -eq '..') { if ($Segments.Count -gt 0) { $Segments.RemoveAt($Segments.Count - 1) } continue } $Segments.Add($Segment) } return ($PathRoot.TrimEnd([char[]]@('\', '/')) + '\' + ($Segments -join '\')).TrimEnd([char[]]@('\', '/')) } [System.IO.Path]::GetFullPath($ComparablePath).TrimEnd([char[]]@('\', '/')) } function Convert-TheCleanersPathForTraversal { <# .SYNOPSIS Normalize a filesystem traversal path without dropping its namespace. .DESCRIPTION Canonicalize separators and dot segments using the comparison helper, then restore a supported extended-length prefix when the input carried one. This remains a string-only operation so Windows PowerShell does not route a long path through a legacy MAX_PATH API. .PARAMETER Path Fully qualified filesystem path to normalize for traversal. .OUTPUTS System.String #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) $PathWithWindowsSeparators = $Path.Replace('/', '\') $IsExtendedLength = $PathWithWindowsSeparators.StartsWith('\\?\', [System.StringComparison]::OrdinalIgnoreCase) $IsExtendedUnc = $PathWithWindowsSeparators.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase) $ComparablePath = Convert-TheCleanersPathForComparison -Path $PathWithWindowsSeparators if (-not $IsExtendedLength) { return $ComparablePath } if ($IsExtendedUnc) { return '\\?\UNC\' + $ComparablePath.Substring(2) } '\\?\' + $ComparablePath } function Resolve-TheCleanersFileSystemPath { <# .SYNOPSIS Validate an existing filesystem root or a literal descendant without changing it. .DESCRIPTION Require fully qualified Windows filesystem paths, reject broad cleanup roots, and reject reparse points anywhere in the current ancestry. With RootPath, require a strict descendant using a separator boundary, not a naive string-prefix check. Call again immediately before mutation. Extended-length drive and UNC forms are accepted when the Windows filesystem provider and long-path policy support them; device namespace paths remain rejected. These path-based checks do not provide an atomic security boundary against hostile concurrent filesystem changes. .PARAMETER LiteralPath Existing literal filesystem path to validate. .PARAMETER RootPath Previously validated cleanup root; LiteralPath must be strictly beneath it. .EXAMPLE Resolve-TheCleanersFileSystemPath -LiteralPath $env:TEMP .OUTPUTS System.IO.FileSystemInfo #> [CmdletBinding()] [OutputType([System.IO.FileSystemInfo])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $LiteralPath, [Parameter()] [ValidateNotNullOrEmpty()] [string] $RootPath ) if (-not (Test-TheCleanersFullyQualifiedPath -Path $LiteralPath)) { throw 'A fully qualified Windows filesystem path is required.' } if ($PSBoundParameters.ContainsKey('RootPath') -and -not (Test-TheCleanersFullyQualifiedPath -Path $RootPath)) { throw 'A fully qualified Windows filesystem RootPath is required.' } $Item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop if ($Item -isnot [System.IO.FileSystemInfo]) { throw "Not a filesystem path: $LiteralPath" } if ($PSBoundParameters.ContainsKey('RootPath')) { $Prefix = (Convert-TheCleanersPathForComparison -Path $RootPath) + [System.IO.Path]::DirectorySeparatorChar $ComparableItemPath = Convert-TheCleanersPathForComparison -Path $Item.FullName if (-not $ComparableItemPath.StartsWith($Prefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Path is not a strict descendant of the approved root: $LiteralPath" } } else { if ($Item -isnot [System.IO.DirectoryInfo]) { throw "Cleanup root is not a directory: $LiteralPath" } $WindowsRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows) if ([string]::IsNullOrWhiteSpace($WindowsRoot)) { $WindowsRoot = [Environment]::GetEnvironmentVariable('SystemRoot', 'Machine') } if ([string]::IsNullOrWhiteSpace($WindowsRoot)) { throw [System.InvalidOperationException]::new('The Windows directory could not be resolved from the operating system.') } $ForbiddenRoots = @( [System.IO.Path]::GetPathRoot($Item.FullName) $WindowsRoot $env:USERPROFILE $env:ProgramFiles $env:ProgramData ) $ComparableItemPath = Convert-TheCleanersPathForComparison -Path $Item.FullName foreach ($ForbiddenRoot in $ForbiddenRoots) { if (-not [string]::IsNullOrWhiteSpace($ForbiddenRoot) -and $ComparableItemPath -eq (Convert-TheCleanersPathForComparison -Path $ForbiddenRoot)) { throw "Refusing a broad cleanup root: $LiteralPath" } } } $Current = $Item while ($null -ne $Current) { $Current.Refresh() if (-not $Current.Exists -or ($Current.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "Missing path or reparse point in cleanup ancestry: $($Current.FullName)" } if ($Current -is [System.IO.DirectoryInfo]) { $Current = $Current.Parent } else { $Current = $Current.Directory } } $Item } |