Eigenverft.Manifested.Drydock.PathLimits.ps1
|
# Private helpers for Windows path-limit diagnostics (not exported). function Get-WindowsLongPathsPolicy { $keyPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' $valueName = 'LongPathsEnabled' $result = [ordered]@{ RegistryPath = $keyPath ValueName = $valueName ValuePresent = $false LongPathsEnabled = $false LongPathsEnabledRaw = $null ReadError = $null } try { $item = Get-ItemProperty -LiteralPath $keyPath -Name $valueName -ErrorAction Stop $raw = [int]$item.$valueName $result.ValuePresent = $true $result.LongPathsEnabledRaw = $raw $result.LongPathsEnabled = ($raw -ne 0) } catch { $result.ReadError = $_.Exception.Message } [pscustomobject]$result } function Resolve-PathIoFailureHint { param( [Parameter(Mandatory)] [string]$TargetPath, [Parameter()] [System.Exception]$Exception ) $normalized = $TargetPath.Trim() if ($normalized.StartsWith('\\?\', [StringComparison]::Ordinal)) { $normalized = $normalized.Substring(4) if ($normalized.StartsWith('UNC\', [StringComparison]::OrdinalIgnoreCase)) { $normalized = '\\' + $normalized.Substring(4) } } $parent = [System.IO.Path]::GetDirectoryName($normalized) $fileName = [System.IO.Path]::GetFileName($normalized) $parentExists = if ([string]::IsNullOrWhiteSpace($parent)) { $false } else { Test-Path -LiteralPath $parent -PathType Container } $pathLength = $normalized.Length $fileNameLength = if ($null -eq $fileName) { 0 } else { $fileName.Length } $parentLength = if ($null -eq $parent) { 0 } else { $parent.Length } $exceptionText = if ($Exception) { if ($Exception.InnerException) { $Exception.InnerException.Message } else { $Exception.Message } } else { $null } $hints = New-Object System.Collections.Generic.List[string] $likelyCause = 'Unknown' $secondaryRisks = New-Object System.Collections.Generic.List[string] if ($fileNameLength -gt 255) { $likelyCause = 'ComponentTooLong' [void]$hints.Add(("File name length {0} exceeds Windows per-component limit 255." -f $fileNameLength)) } elseif (-not $parentExists) { $likelyCause = 'MissingParentDirectory' [void]$hints.Add(("Parent directory does not exist: '{0}'." -f $parent)) [void]$hints.Add('Same Win32/HRESULT text often appears for missing parents and for overlong paths.') if ($pathLength -gt 259) { [void]$secondaryRisks.Add('ClassicMaxPathOverflow') [void]$hints.Add(("Secondary risk: full path length {0} also exceeds classic MAX_PATH (~259 usable). After creating the parent, create may still fail on non-long-path-aware hosts." -f $pathLength)) } } elseif ($pathLength -gt 259) { $likelyCause = 'ClassicMaxPathOverflow' [void]$hints.Add(("Full path length {0} exceeds classic MAX_PATH usable limit (~259 chars, API often reports 260 including NUL)." -f $pathLength)) [void]$hints.Add('Parent exists, so missing-folder is unlikely; host may still be limited to legacy MAX_PATH.') } else { $likelyCause = 'OtherIoFailure' [void]$hints.Add('Path length and parent look OK; failure is likely ACL, sharing, invalid chars, or another IO condition.') } [pscustomobject]@{ Path = $normalized PathLength = $pathLength ParentDirectory = $parent ParentDirectoryLength = $parentLength ParentExists = $parentExists FileName = $fileName FileNameLength = $fileNameLength ExceedsClassicMaxPath = ($pathLength -gt 259) ExceedsComponentLimit = ($fileNameLength -gt 255) LikelyCause = $likelyCause SecondaryRisks = @($secondaryRisks.ToArray()) Hints = @($hints.ToArray()) ExceptionMessage = $exceptionText } } function ConvertTo-WindowsExtendedPath { param( [Parameter(Mandatory)] [string]$Path ) if ([string]::IsNullOrWhiteSpace($Path)) { return $Path } if ($Path.StartsWith('\\?\', [StringComparison]::Ordinal)) { return $Path } if ($Path.StartsWith('\\', [StringComparison]::Ordinal)) { return '\\?\UNC\' + $Path.Substring(2) } return '\\?\' + $Path } function New-ProbeFilePath { param( [Parameter(Mandatory)] [string]$Root, [Parameter(Mandatory)] [int]$TargetFullLength ) $prefix = Join-Path $Root 'probe' $prefixWithSep = $prefix + [System.IO.Path]::DirectorySeparatorChar $remaining = $TargetFullLength - $prefixWithSep.Length if ($remaining -lt 8) { throw ("ProbeRoot too long for target length {0}: '{1}'" -f $TargetFullLength, $Root) } $stemLen = $remaining - 4 if ($stemLen -lt 1) { throw ("Cannot build probe name for target length {0}." -f $TargetFullLength) } $stem = ('a' * $stemLen) $candidate = Join-Path $prefix ($stem + '.bin') if ($candidate.Length -ne $TargetFullLength) { $delta = $TargetFullLength - $candidate.Length if ($delta -gt 0) { $stem = $stem + ('b' * $delta) } elseif ($delta -lt 0 -and $stem.Length -gt -$delta) { $stem = $stem.Substring(0, $stem.Length + $delta) } $candidate = Join-Path $prefix ($stem + '.bin') } return $candidate } function Test-FileStreamCreate { param( [Parameter(Mandatory)] [string]$TargetPath, [Parameter()] [switch]$UseExtendedSyntax ) $openPath = if ($UseExtendedSyntax) { ConvertTo-WindowsExtendedPath -Path $TargetPath } else { $TargetPath } $created = $false $errorMessage = $null try { $parent = [System.IO.Path]::GetDirectoryName($TargetPath) if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } $fs = [System.IO.FileStream]::new( $openPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None, 4096, [System.IO.FileOptions]::None ) try { $fs.WriteByte(1) } finally { $fs.Dispose() } $created = $true } catch { $errorMessage = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message } } finally { if (Test-Path -LiteralPath $TargetPath -PathType Leaf) { Remove-Item -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue } } $exceptionForHint = $null if ($errorMessage) { $exceptionForHint = [System.IO.IOException]::new($errorMessage) } $hint = Resolve-PathIoFailureHint -TargetPath $TargetPath -Exception $exceptionForHint [pscustomobject]@{ TargetPath = $TargetPath OpenPath = $openPath TargetPathLength = $TargetPath.Length UseExtendedSyntax = [bool]$UseExtendedSyntax CreateSucceeded = $created ErrorMessage = $errorMessage LikelyCause = if ($created) { 'None' } else { $hint.LikelyCause } Hints = if ($created) { @() } else { $hint.Hints } } } function Invoke-WindowsPathLimitsCore { param( [Parameter(Mandatory)] [ValidateSet('Classic', 'Extended')] [string]$Mode, [Parameter()] [string[]]$Path, [Parameter()] [switch]$TryOpenPath, [Parameter()] [string]$ProbeRoot = (Join-Path $env:TEMP 'Evf.PathLimitProbe'), [Parameter()] [switch]$SkipProbe ) $useExtended = ($Mode -eq 'Extended') $policy = Get-WindowsLongPathsPolicy $runtime = [ordered]@{ PSVersion = $PSVersionTable.PSVersion.ToString() PSEdition = [string]$PSVersionTable.PSEdition OSVersion = [System.Environment]::OSVersion.VersionString FrameworkDescription = $null IsWindows = $true ClassicMaxPathLimit = 260 ClassicMaxPathUsable = 259 ComponentLengthLimit = 255 PathOpenMode = $Mode } try { $runtime.FrameworkDescription = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription } catch { $runtime.FrameworkDescription = [System.Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory() } try { $runtime.IsWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform]::Windows ) } catch { $runtime.IsWindows = ($env:OS -eq 'Windows_NT') } $pathAnalyses = @() foreach ($candidate in @($Path)) { if ([string]::IsNullOrWhiteSpace($candidate)) { continue } $analysis = Resolve-PathIoFailureHint -TargetPath $candidate $openResult = $null $status = 'NotProbed' $workaroundHint = $null if ($TryOpenPath) { $parent = $analysis.ParentDirectory if (-not $analysis.ParentExists) { $openResult = [pscustomobject]@{ CreateSucceeded = $false UseExtendedSyntax = $useExtended OpenPath = if ($useExtended) { ConvertTo-WindowsExtendedPath -Path $analysis.Path } else { $analysis.Path } ErrorMessage = ("Skipped create: parent directory missing ('{0}')." -f $parent) LikelyCause = 'MissingParentDirectory' } $status = 'BlockedMissingParent' $workaroundHint = 'Create the parent directory first, then re-run both Test-WindowsPathLimits and Test-WindowsPathLimitsWithExtendedPath.' } else { $openResult = Test-FileStreamCreate -TargetPath $analysis.Path -UseExtendedSyntax:$useExtended if ($openResult.CreateSucceeded) { $status = if ($useExtended) { 'ExtendedCreateSucceeded' } else { 'ClassicCreateSucceeded' } $workaroundHint = if ($useExtended) { 'Extended path open works for this path on this host.' } else { 'Classic path open works; extended-path workaround is not required for this path.' } } else { $status = if ($useExtended) { 'ExtendedCreateFailed' } else { 'ClassicCreateFailed' } if (-not $useExtended) { $workaroundHint = 'Classic open failed. Run Test-WindowsPathLimitsWithExtendedPath -Path <same> -TryOpenPath to check whether \\?\ is a viable workaround.' } else { $workaroundHint = 'Extended path open also failed. Likely missing parent, ACL, share lock, or component-too-long — not a simple MAX_PATH issue.' } } } } $pathAnalyses += [pscustomobject]@{ Analysis = $analysis OpenProbe = $openResult Status = $status WorkaroundHint = $workaroundHint Summary = @( ("Mode={0}" -f $Mode) ("PathLength={0}" -f $analysis.PathLength) ("ParentExists={0}" -f $analysis.ParentExists) ("LikelyCause={0}" -f $analysis.LikelyCause) ("Status={0}" -f $status) ($analysis.Hints -join ' ') ) -join '; ' } } $probes = @() if (-not $SkipProbe) { $modeProbeRoot = Join-Path $ProbeRoot $Mode New-Item -ItemType Directory -Force -Path $modeProbeRoot | Out-Null try { foreach ($len in 200, 240, 259, 260, 261, 273, 300) { try { $target = New-ProbeFilePath -Root $modeProbeRoot -TargetFullLength $len } catch { $probes += [pscustomobject]@{ TargetPathLength = $len CreateSucceeded = $false UseExtendedSyntax = $useExtended ErrorMessage = $_.Exception.Message LikelyCause = 'ProbeSetupFailed' Hints = @($_.Exception.Message) OpenPath = $null TargetPath = $null } continue } $probes += (Test-FileStreamCreate -TargetPath $target -UseExtendedSyntax:$useExtended) } } finally { if (Test-Path -LiteralPath $modeProbeRoot -PathType Container) { Remove-Item -LiteralPath $modeProbeRoot -Recurse -Force -ErrorAction SilentlyContinue } } } $probeVerdict = 'Skipped' if (-not $SkipProbe) { $over = @($probes | Where-Object { $_.TargetPathLength -ge 261 }) $overOk = @($over | Where-Object { $_.CreateSucceeded }) $overFail = @($over | Where-Object { -not $_.CreateSucceeded }) if ($overOk.Count -gt 0 -and $overFail.Count -eq 0) { $probeVerdict = if ($useExtended) { 'ExtendedRuntimeAllowsPathsBeyondClassicMaxPath' } else { 'ClassicRuntimeAllowsPathsBeyondClassicMaxPath' } } elseif ($overOk.Count -gt 0) { $probeVerdict = 'RuntimePartiallyAllowsLongPaths' } elseif ($overFail.Count -gt 0) { $probeVerdict = if ($useExtended) { 'ExtendedRuntimeBlockedBeyondClassicMaxPath' } else { 'ClassicRuntimeBlockedByClassicMaxPath' } } else { $probeVerdict = 'Inconclusive' } } $policyVerdict = if ($policy.LongPathsEnabled) { 'OsPolicyAllowsLongPaths' } elseif ($policy.ValuePresent) { 'OsPolicyDisallowsLongPaths' } else { 'OsPolicyUnset(defaultsToClassicMaxPath)' } $combinedParts = New-Object System.Collections.Generic.List[string] [void]$combinedParts.Add(("Mode={0}" -f $Mode)) [void]$combinedParts.Add($policyVerdict) [void]$combinedParts.Add($probeVerdict) if ($Mode -eq 'Classic' -and $probeVerdict -eq 'ClassicRuntimeBlockedByClassicMaxPath') { [void]$combinedParts.Add('NEXT: run Test-WindowsPathLimitsWithExtendedPath to test \\?\ workaround.') } elseif ($Mode -eq 'Extended' -and $probeVerdict -eq 'ExtendedRuntimeAllowsPathsBeyondClassicMaxPath') { [void]$combinedParts.Add('Workaround potential: extended paths bypass classic MAX_PATH on this host.') } elseif ($Mode -eq 'Extended' -and $probeVerdict -eq 'ExtendedRuntimeBlockedBeyondClassicMaxPath') { [void]$combinedParts.Add('Extended paths do not help on this host for probed lengths.') } [pscustomobject]@{ Mode = $Mode Policy = $policy Runtime = [pscustomobject]$runtime PolicyVerdict = $policyVerdict ProbeVerdict = $probeVerdict CombinedVerdict = ($combinedParts.ToArray() -join ' | ') PathAnalyses = $pathAnalyses Probes = $probes } } function Test-WindowsPathLimits { <# .SYNOPSIS Diagnose classic (non-extended) Windows path create behavior. .DESCRIPTION Public Drydock diagnostic for legacy/plain path opens (no \\?\ prefix). Reports OS LongPathsEnabled policy, host identity, empirical FileStream probes, and optional analysis of concrete paths from error logs. Pair with Test-WindowsPathLimitsWithExtendedPath on the same -Path to compare status vs potential \\?\ workaround when logs show "Could not find a part of the path". .PARAMETER Path Optional concrete path(s) to classify (from an error log). .PARAMETER TryOpenPath When set with -Path, attempts a real classic FileStream create (parent must exist). .PARAMETER ProbeRoot Root directory for empirical length probes. Default: %TEMP%\Evf.PathLimitProbe .PARAMETER SkipProbe Skip empirical create probes; only report policy and optional -Path analysis. .EXAMPLE Test-WindowsPathLimits .EXAMPLE Test-WindowsPathLimits -Path $failedPartial -TryOpenPath Test-WindowsPathLimitsWithExtendedPath -Path $failedPartial -TryOpenPath #> [CmdletBinding(PositionalBinding = $false)] param( [Parameter()] [string[]]$Path, [Parameter()] [switch]$TryOpenPath, [Parameter()] [string]$ProbeRoot = (Join-Path $env:TEMP 'Evf.PathLimitProbe'), [Parameter()] [switch]$SkipProbe ) Invoke-WindowsPathLimitsCore -Mode Classic -Path $Path -TryOpenPath:$TryOpenPath -ProbeRoot $ProbeRoot -SkipProbe:$SkipProbe } function Test-WindowsPathLimitsWithExtendedPath { <# .SYNOPSIS Diagnose Windows path create behavior using extended \\?\ path syntax. .DESCRIPTION Public Drydock diagnostic identical in shape to Test-WindowsPathLimits, but every FileStream open uses the extended path prefix (\\?\ or \\?\UNC\...). Use after a classic failure to check whether extended paths are a viable workaround on this host (Status + WorkaroundHint on PathAnalyses). .PARAMETER Path Optional concrete path(s) to classify (from an error log). .PARAMETER TryOpenPath When set with -Path, attempts a real extended FileStream create (parent must exist). .PARAMETER ProbeRoot Root directory for empirical length probes. Default: %TEMP%\Evf.PathLimitProbe .PARAMETER SkipProbe Skip empirical create probes; only report policy and optional -Path analysis. .EXAMPLE Test-WindowsPathLimitsWithExtendedPath .EXAMPLE $failed = '...zip.partial....' Test-WindowsPathLimits -Path $failed -TryOpenPath Test-WindowsPathLimitsWithExtendedPath -Path $failed -TryOpenPath #> [CmdletBinding(PositionalBinding = $false)] param( [Parameter()] [string[]]$Path, [Parameter()] [switch]$TryOpenPath, [Parameter()] [string]$ProbeRoot = (Join-Path $env:TEMP 'Evf.PathLimitProbe'), [Parameter()] [switch]$SkipProbe ) Invoke-WindowsPathLimitsCore -Mode Extended -Path $Path -TryOpenPath:$TryOpenPath -ProbeRoot $ProbeRoot -SkipProbe:$SkipProbe } |