Skip to content

Commit 2a1bb48

Browse files
Stabilize YAML import path identity
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c01eeaa commit 2a1bb48

2 files changed

Lines changed: 147 additions & 56 deletions

File tree

src/functions/public/Import-Yaml.ps1

Lines changed: 76 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -155,21 +155,15 @@ function Import-Yaml {
155155
)
156156

157157
begin {
158-
$requestedPaths = [System.Collections.Generic.List[string]]::new()
158+
$resolvedPaths = [System.Collections.Generic.List[string]]::new()
159159
}
160160
process {
161161
$currentPaths = if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') {
162162
$LiteralPath
163163
} else {
164164
$Path
165165
}
166-
foreach ($currentPath in $currentPaths) {
167-
$requestedPaths.Add($currentPath)
168-
}
169-
}
170-
end {
171-
$resolvedPaths = [System.Collections.Generic.List[string]]::new()
172-
foreach ($requestedPath in $requestedPaths) {
166+
foreach ($requestedPath in $currentPaths) {
173167
if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') {
174168
try {
175169
$provider = $null
@@ -426,74 +420,100 @@ function Import-Yaml {
426420
$resolvedPaths.Add([System.IO.Path]::GetFullPath($pathInfo.ProviderPath))
427421
}
428422
}
423+
}
424+
end {
429425

430-
$caseSensitivePaths = [System.Collections.Generic.HashSet[string]]::new(
426+
$pathsByIdentity = [System.Collections.Generic.Dictionary[string, string]]::new(
431427
[System.StringComparer]::Ordinal
432428
)
433-
$caseInsensitivePaths = [System.Collections.Generic.HashSet[string]]::new(
434-
[System.StringComparer]::OrdinalIgnoreCase
429+
$directoryNameGroups = [System.Collections.Generic.Dictionary[string, object]]::new(
430+
[System.StringComparer]::Ordinal
435431
)
436432
foreach ($resolvedPath in $resolvedPaths) {
437-
$useCaseSensitiveIdentity = -not $IsWindows
438-
if ($IsWindows) {
439-
$identityPath = $resolvedPath
440-
while (-not [string]::IsNullOrEmpty($identityPath)) {
441-
$directoryPath = [System.IO.Path]::GetDirectoryName($identityPath)
442-
if ([string]::IsNullOrEmpty($directoryPath)) {
443-
break
444-
}
445-
$leafName = [System.IO.Path]::GetFileName($identityPath)
446-
try {
447-
$caseVariantCount = 0
433+
$identityComponents = [System.Collections.Generic.List[string]]::new()
434+
$identityPath = $resolvedPath
435+
$identityFailed = $false
436+
437+
while ($true) {
438+
$directoryPath = [System.IO.Path]::GetDirectoryName($identityPath)
439+
$leafName = [System.IO.Path]::GetFileName($identityPath)
440+
if ([string]::IsNullOrEmpty($directoryPath) -or
441+
[string]::IsNullOrEmpty($leafName)) {
442+
break
443+
}
444+
445+
$nameGroups = $null
446+
try {
447+
if (-not $directoryNameGroups.TryGetValue(
448+
$directoryPath,
449+
[ref]$nameGroups
450+
)) {
451+
$nameGroups = (
452+
[System.Collections.Generic.Dictionary[string, object]]::new(
453+
[System.StringComparer]::OrdinalIgnoreCase
454+
)
455+
)
448456
foreach ($directoryEntry in (
449457
[System.IO.Directory]::EnumerateFileSystemEntries($directoryPath)
450458
)) {
451-
if ([string]::Equals(
452-
[System.IO.Path]::GetFileName($directoryEntry),
453-
$leafName,
454-
[System.StringComparison]::OrdinalIgnoreCase
455-
)) {
456-
$caseVariantCount++
457-
if ($caseVariantCount -gt 1) {
458-
$useCaseSensitiveIdentity = $true
459-
break
460-
}
459+
$entryName = [System.IO.Path]::GetFileName($directoryEntry)
460+
$nameGroup = $null
461+
if ($nameGroups.TryGetValue($entryName, [ref]$nameGroup)) {
462+
$nameGroup.Count++
463+
} else {
464+
$nameGroups.Add(
465+
$entryName,
466+
[pscustomobject]@{
467+
Count = 1
468+
CanonicalName = $entryName
469+
}
470+
)
461471
}
462472
}
463-
} catch [System.UnauthorizedAccessException] {
464-
$useCaseSensitiveIdentity = $true
465-
} catch [System.IO.IOException] {
466-
$useCaseSensitiveIdentity = $true
473+
$directoryNameGroups.Add($directoryPath, $nameGroups)
467474
}
468-
if ($useCaseSensitiveIdentity -or
469-
[string]::Equals(
470-
$identityPath,
471-
$directoryPath,
472-
[System.StringComparison]::Ordinal
473-
)) {
475+
476+
$nameGroup = $null
477+
if (-not $nameGroups.TryGetValue($leafName, [ref]$nameGroup)) {
478+
$identityFailed = $true
474479
break
475480
}
476-
$identityPath = $directoryPath
481+
if ($nameGroup.Count -gt 1) {
482+
$identityComponents.Add($leafName)
483+
} else {
484+
$identityComponents.Add($nameGroup.CanonicalName)
485+
}
486+
} catch [System.UnauthorizedAccessException] {
487+
$identityFailed = $true
488+
break
489+
} catch [System.IO.IOException] {
490+
$identityFailed = $true
491+
break
477492
}
493+
494+
$identityPath = $directoryPath
478495
}
479496

480-
if ($useCaseSensitiveIdentity) {
481-
$null = $caseSensitivePaths.Add($resolvedPath)
497+
if ($identityFailed) {
498+
$identityKey = $resolvedPath
482499
} else {
483-
$null = $caseInsensitivePaths.Add($resolvedPath)
500+
$rootPath = [System.IO.Path]::GetPathRoot($resolvedPath)
501+
if ($IsWindows) {
502+
$rootPath = $rootPath.ToUpperInvariant()
503+
}
504+
$identityComponents.Add($rootPath)
505+
$identityComponents.Reverse()
506+
$identityKey = $identityComponents -join [char]0x1F
484507
}
508+
509+
$null = $pathsByIdentity.TryAdd($identityKey, $resolvedPath)
485510
}
486-
$uniquePaths = [System.Collections.Generic.List[string]]::new()
487-
foreach ($uniquePath in $caseSensitivePaths) {
488-
$uniquePaths.Add($uniquePath)
489-
}
490-
foreach ($uniquePath in $caseInsensitivePaths) {
491-
$uniquePaths.Add($uniquePath)
492-
}
493-
[string[]] $orderedPaths = $uniquePaths
494-
[System.Array]::Sort($orderedPaths, [System.StringComparer]::Ordinal)
495511

496-
foreach ($resolvedPath in $orderedPaths) {
512+
[string[]] $orderedIdentityKeys = $pathsByIdentity.Keys
513+
[System.Array]::Sort($orderedIdentityKeys, [System.StringComparer]::Ordinal)
514+
515+
foreach ($identityKey in $orderedIdentityKeys) {
516+
$resolvedPath = $pathsByIdentity[$identityKey]
497517
try {
498518
$bytes = [System.IO.File]::ReadAllBytes($resolvedPath)
499519
} catch [System.UnauthorizedAccessException] {

tests/Import-Yaml.Tests.ps1

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,77 @@ Describe 'Import-Yaml' {
106106
$result[0].value | Should -Be 'once'
107107
}
108108

109+
It 'sorts by canonical identity regardless of duplicate path spelling' {
110+
$firstPath = Join-Path $TestDrive 'canonical-a.yaml'
111+
$secondPath = Join-Path $TestDrive 'canonical-b.yaml'
112+
$secondAlias = $secondPath.ToUpperInvariant()
113+
[System.IO.File]::WriteAllText($firstPath, 'value: first')
114+
[System.IO.File]::WriteAllText($secondPath, 'value: second')
115+
try {
116+
$null = [System.IO.File]::GetAttributes($secondAlias)
117+
} catch [System.IO.FileNotFoundException] {
118+
Set-ItResult -Skipped -Because 'the test filesystem is case-sensitive'
119+
return
120+
}
121+
122+
$result = @(Import-Yaml -Path @($secondAlias, $firstPath, $secondPath))
123+
124+
@($result.value) | Should -Be @('first', 'second')
125+
}
126+
127+
It 'normalizes insensitive components around a case-sensitive directory' `
128+
-Skip:(-not $IsWindows) {
129+
$caseDirectory = Join-Path $TestDrive 'Sensitive'
130+
$null = [System.IO.Directory]::CreateDirectory($caseDirectory)
131+
$null = & fsutil.exe file SetCaseSensitiveInfo $caseDirectory enable 2>&1
132+
if ($LASTEXITCODE -ne 0) {
133+
Set-ItResult -Skipped -Because 'per-directory case sensitivity is unavailable'
134+
return
135+
}
136+
137+
$upperPath = Join-Path $caseDirectory 'Case.yaml'
138+
$lowerPath = Join-Path $caseDirectory 'case.yaml'
139+
$variantUpperPath = Join-Path (
140+
Split-Path -Parent $caseDirectory
141+
) 'sensitive\Case.yaml'
142+
[System.IO.File]::WriteAllText($upperPath, 'value: upper')
143+
[System.IO.File]::WriteAllText($lowerPath, 'value: lower')
144+
145+
$result = @(Import-Yaml -Path @($upperPath, $variantUpperPath, $lowerPath))
146+
147+
@($result.value) | Should -Be @('upper', 'lower')
148+
}
149+
150+
It 'resolves each relative pipeline path at the location where it arrives' {
151+
$firstDirectory = Join-Path $TestDrive 'pipeline-a'
152+
$secondDirectory = Join-Path $TestDrive 'pipeline-b'
153+
$null = [System.IO.Directory]::CreateDirectory($firstDirectory)
154+
$null = [System.IO.Directory]::CreateDirectory($secondDirectory)
155+
[System.IO.File]::WriteAllText(
156+
(Join-Path $firstDirectory 'config.yaml'),
157+
'value: first'
158+
)
159+
[System.IO.File]::WriteAllText(
160+
(Join-Path $secondDirectory 'config.yaml'),
161+
'value: second'
162+
)
163+
164+
$result = @(
165+
& {
166+
Push-Location $firstDirectory
167+
try {
168+
Write-Output 'config.yaml'
169+
Set-Location $secondDirectory
170+
Write-Output 'config.yaml'
171+
} finally {
172+
Pop-Location
173+
}
174+
} | Import-Yaml
175+
)
176+
177+
@($result.value) | Should -Be @('first', 'second')
178+
}
179+
109180
It 'preserves deterministic file and document order together' {
110181
$firstPath = Join-Path $TestDrive '01.yaml'
111182
$secondPath = Join-Path $TestDrive '02.yaml'

0 commit comments

Comments
 (0)