Skip to content

Commit 17422e3

Browse files
Confirm YAML key fingerprints structurally
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 80d4c8c commit 17422e3

5 files changed

Lines changed: 224 additions & 29 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
function Assert-YamlNodeKeyUnique {
2+
<#
3+
.SYNOPSIS
4+
Indexes one representation key and confirms equality for fingerprint candidates.
5+
#>
6+
[CmdletBinding()]
7+
param (
8+
[Parameter(Mandatory)]
9+
[pscustomobject] $Node,
10+
11+
[Parameter(Mandatory)]
12+
[AllowEmptyCollection()]
13+
[System.Collections.Generic.Dictionary[string, object]] $Buckets,
14+
15+
[Parameter(Mandatory)]
16+
[AllowEmptyCollection()]
17+
[System.Collections.Generic.Dictionary[int, string]] $FingerprintCache,
18+
19+
[Parameter(Mandatory)]
20+
[System.Security.Cryptography.HashAlgorithm] $FingerprintHasher,
21+
22+
[Parameter(Mandatory)]
23+
[string] $DuplicateMessage,
24+
25+
[Parameter()]
26+
[AllowNull()]
27+
[pscustomobject] $RemovalWorkState,
28+
29+
[Parameter()]
30+
[AllowNull()]
31+
[pscustomobject] $EqualityState,
32+
33+
[Parameter()]
34+
[AllowNull()]
35+
[System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache
36+
)
37+
38+
$fingerprint = Get-YamlNodeFingerprint -Node $Node `
39+
-Active ([System.Collections.Generic.HashSet[int]]::new()) `
40+
-Cache $FingerprintCache -Hasher $FingerprintHasher `
41+
-RemovalWorkState $RemovalWorkState
42+
$bucket = $null
43+
if (-not $Buckets.TryGetValue($fingerprint, [ref] $bucket)) {
44+
$bucket = [System.Collections.Generic.List[object]]::new()
45+
$Buckets[$fingerprint] = $bucket
46+
} elseif ($null -eq $EqualityState) {
47+
throw (New-YamlException -Start $Node.Start -End $Node.End `
48+
-ErrorId 'YamlDuplicateKey' -Message $DuplicateMessage)
49+
} else {
50+
foreach ($candidate in $bucket) {
51+
if (Test-YamlMergeNodeEqual -Node $candidate -OtherNode $Node `
52+
-State $EqualityState `
53+
-LeftFingerprintCache $EqualityFingerprintCache `
54+
-RightFingerprintCache $EqualityFingerprintCache) {
55+
throw (New-YamlException -Start $Node.Start -End $Node.End `
56+
-ErrorId 'YamlDuplicateKey' -Message $DuplicateMessage)
57+
}
58+
}
59+
}
60+
$bucket.Add($Node)
61+
}

src/functions/private/Assert-YamlRemovalGraph.ps1

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,47 @@ function Assert-YamlRemovalGraph {
163163
Count = 0L
164164
MaxNodes = $MaxNodes
165165
}
166+
$equalityWorkState = [pscustomobject]@{
167+
Count = 0L
168+
MaxNodes = $MaxNodes
169+
}
170+
$equalityState = [pscustomobject]@{
171+
MaxNodes = $MaxNodes
172+
FingerprintHasher = $fingerprintHasher
173+
WorkState = $equalityWorkState
174+
MutationState = [pscustomobject]@{ Version = 0L }
175+
IndexDependents = [System.Collections.Generic.Dictionary[int, object]]::new()
176+
Cache = [System.Collections.Generic.Dictionary[string, bool]]::new(
177+
[System.StringComparer]::Ordinal
178+
)
179+
InputIndex = 0
180+
}
181+
$equalityFingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new()
166182
try {
167183
foreach ($document in $Documents) {
168-
Test-YamlNodeGraph -Node $document `
169-
-Visited ([System.Collections.Generic.HashSet[int]]::new()) `
170-
-FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher `
171-
-RemovalWorkState $fingerprintWorkState
184+
try {
185+
Test-YamlNodeGraph -Node $document `
186+
-Visited ([System.Collections.Generic.HashSet[int]]::new()) `
187+
-FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher `
188+
-RemovalWorkState $fingerprintWorkState -EqualityState $equalityState `
189+
-EqualityFingerprintCache $equalityFingerprintCache
190+
} catch {
191+
if ($_.Exception.Data.Contains('YamlErrorId') -and
192+
$_.Exception.Data['YamlErrorId'] -ceq 'YamlMergeWorkLimitExceeded') {
193+
$exception = New-YamlRemovalException -Node $document `
194+
-ErrorId 'YamlRemovalWorkLimitExceeded' -Message (
195+
'Post-removal duplicate-key graph comparison exceeded the configured ' +
196+
"invocation work limit of $MaxNodes operations."
197+
)
198+
$exception.Data['YamlRemovalWorkCount'] = $equalityWorkState.Count
199+
$exception.Data['YamlRemovalWorkLimit'] = $MaxNodes
200+
$exception.Data['YamlRemovalWorkOperation'] = (
201+
'duplicate-key graph comparison'
202+
)
203+
throw $exception
204+
}
205+
throw
206+
}
172207
}
173208
} finally {
174209
$fingerprintHasher.Dispose()

src/functions/private/Test-YamlMergeNodeEqual.ps1

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ function Test-YamlMergeNodeEqual {
8080
}
8181
$leftTag = Get-YamlMergeNodeTag -Node $left
8282
$rightTag = Get-YamlMergeNodeTag -Node $right
83-
if ($leftTag -cne $rightTag) {
83+
if (-not [string]::Equals(
84+
$leftTag,
85+
$rightTag,
86+
[System.StringComparison]::Ordinal
87+
)) {
8488
$State.Cache[$cacheKey] = $false
8589
return $false
8690
}

src/functions/private/Test-YamlNodeGraph.ps1

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ function Test-YamlNodeGraph {
2121

2222
[Parameter()]
2323
[AllowNull()]
24-
[pscustomobject] $RemovalWorkState
24+
[pscustomobject] $RemovalWorkState,
25+
26+
[Parameter()]
27+
[AllowNull()]
28+
[pscustomobject] $EqualityState,
29+
30+
[Parameter()]
31+
[AllowNull()]
32+
[System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache
2533
)
2634

2735
$scalarTags = [System.Collections.Generic.HashSet[string]]::new(
@@ -78,7 +86,7 @@ function Test-YamlNodeGraph {
7886

7987
$isPairs = $tag -ceq 'tag:yaml.org,2002:pairs'
8088
$isOrderedMap = $tag -ceq 'tag:yaml.org,2002:omap'
81-
$orderedKeys = [System.Collections.Generic.HashSet[string]]::new(
89+
$orderedKeys = [System.Collections.Generic.Dictionary[string, object]]::new(
8290
[System.StringComparer]::Ordinal
8391
)
8492
for ($index = $current.Items.Count - 1; $index -ge 0; $index--) {
@@ -95,18 +103,13 @@ function Test-YamlNodeGraph {
95103
))
96104
}
97105
if ($isOrderedMap) {
98-
$keyFingerprint = Get-YamlNodeFingerprint `
106+
Assert-YamlNodeKeyUnique `
99107
-Node $entryNode.Entries[0].Key `
100-
-Active ([System.Collections.Generic.HashSet[int]]::new()) `
101-
-Cache $FingerprintCache -Hasher $FingerprintHasher `
102-
-RemovalWorkState $RemovalWorkState
103-
if (-not $orderedKeys.Add($keyFingerprint)) {
104-
$keyNode = $entryNode.Entries[0].Key
105-
throw (New-YamlException -Start $keyNode.Start -End $keyNode.End `
106-
-ErrorId 'YamlDuplicateKey' -Message (
107-
'A duplicate key was found in a YAML ordered mapping.'
108-
))
109-
}
108+
-Buckets $orderedKeys -FingerprintCache $FingerprintCache `
109+
-FingerprintHasher $FingerprintHasher `
110+
-DuplicateMessage 'A duplicate key was found in a YAML ordered mapping.' `
111+
-RemovalWorkState $RemovalWorkState -EqualityState $EqualityState `
112+
-EqualityFingerprintCache $EqualityFingerprintCache
110113
}
111114
}
112115
$stack.Push($item)
@@ -125,21 +128,16 @@ function Test-YamlNodeGraph {
125128
))
126129
}
127130

128-
$keys = [System.Collections.Generic.HashSet[string]]::new(
131+
$keys = [System.Collections.Generic.Dictionary[string, object]]::new(
129132
[System.StringComparer]::Ordinal
130133
)
131134
for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) {
132135
$entry = $current.Entries[$index]
133-
$fingerprint = Get-YamlNodeFingerprint -Node $entry.Key `
134-
-Active ([System.Collections.Generic.HashSet[int]]::new()) `
135-
-Cache $FingerprintCache -Hasher $FingerprintHasher `
136-
-RemovalWorkState $RemovalWorkState
137-
if (-not $keys.Add($fingerprint)) {
138-
throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End `
139-
-ErrorId 'YamlDuplicateKey' -Message (
140-
'A duplicate mapping key is not allowed.'
141-
))
142-
}
136+
Assert-YamlNodeKeyUnique -Node $entry.Key -Buckets $keys `
137+
-FingerprintCache $FingerprintCache -FingerprintHasher $FingerprintHasher `
138+
-DuplicateMessage 'A duplicate mapping key is not allowed.' `
139+
-RemovalWorkState $RemovalWorkState -EqualityState $EqualityState `
140+
-EqualityFingerprintCache $EqualityFingerprintCache
143141

144142
if ($tag -ceq 'tag:yaml.org,2002:set') {
145143
$setValue = $entry.Value

tests/Remove-YamlEntry.Tests.ps1

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,63 @@ BeforeAll {
9595
Count = [long] $Matches.WorkCount
9696
}
9797
}
98+
99+
function Test-RemoveYamlFingerprintCollision {
100+
<#
101+
.SYNOPSIS
102+
Forces representation-key fingerprint candidates through exact graph equality.
103+
#>
104+
param (
105+
[Parameter(Mandatory)]
106+
[string] $Yaml
107+
)
108+
109+
$implementation = {
110+
param ([string] $YamlText)
111+
112+
$document = (Read-YamlStreamCore -Yaml $YamlText -Depth 100 -MaxNodes 100 `
113+
-MaxAliases 100 -MaxScalarLength 1048576 -MaxTagLength 1024 `
114+
-MaxTotalTagLength 65536 -MaxNumericLength 4096).Value[0]
115+
$fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new()
116+
foreach ($entry in $document.Entries) {
117+
$fingerprintCache[$entry.Key.Id] = 'forced-collision'
118+
}
119+
$hasher = [System.Security.Cryptography.SHA256]::Create()
120+
try {
121+
$equalityState = [pscustomobject]@{
122+
MaxNodes = 100
123+
FingerprintHasher = $hasher
124+
WorkState = [pscustomobject]@{ Count = 0L; MaxNodes = 100 }
125+
MutationState = [pscustomobject]@{ Version = 0L }
126+
IndexDependents = (
127+
[System.Collections.Generic.Dictionary[int, object]]::new()
128+
)
129+
Cache = (
130+
[System.Collections.Generic.Dictionary[string, bool]]::new(
131+
[System.StringComparer]::Ordinal
132+
)
133+
)
134+
InputIndex = 0
135+
}
136+
Test-YamlNodeGraph -Node $document `
137+
-Visited ([System.Collections.Generic.HashSet[int]]::new()) `
138+
-FingerprintCache $fingerprintCache -FingerprintHasher $hasher `
139+
-EqualityState $equalityState `
140+
-EqualityFingerprintCache (
141+
[System.Collections.Generic.Dictionary[int, string]]::new()
142+
)
143+
} finally {
144+
$hasher.Dispose()
145+
}
146+
return $true
147+
}
148+
149+
$loadedModule = Get-Module -Name Yaml | Select-Object -First 1
150+
if ($null -eq $loadedModule) {
151+
return & $implementation $Yaml
152+
}
153+
return & $loadedModule $implementation $Yaml
154+
}
98155
}
99156

100157
Describe 'Remove-YamlEntry' {
@@ -781,6 +838,25 @@ ordered: !!omap
781838
$failure.FullyQualifiedErrorId |
782839
Should -Be 'YamlDuplicateKey,Remove-YamlEntry'
783840
}
841+
842+
It 'confirms fingerprint candidates with exact graph equality' -ForEach @(
843+
@{
844+
Yaml = @'
845+
? { x: 1 }
846+
: first
847+
? { x: 2 }
848+
: second
849+
'@
850+
}
851+
@{
852+
Yaml = @'
853+
!!str key: string
854+
!<tag:yaml.org,2002:st%C2%ADr> key: tagged
855+
'@
856+
}
857+
) {
858+
Test-RemoveYamlFingerprintCollision -Yaml $Yaml | Should -BeTrue
859+
}
784860
}
785861

786862
Context 'Validation and work limits' {
@@ -819,6 +895,27 @@ ordered: !!omap
819895
$failure.Exception.Data['YamlRemovalWorkLimit'] | Should -Be 5
820896
}
821897

898+
It 'bounds duplicate-key graph equality with removal error classification' {
899+
$pairs = 0..9 | ForEach-Object { " key$($_): $($_)" }
900+
$shared = @('shared: &shared') + $pairs + ' drop: true'
901+
$literal = ($pairs | ForEach-Object Trim) -join ', '
902+
$yaml = (@($shared) + '? *shared' + ': first' +
903+
"? { $literal }" + ': second') -join "`n"
904+
$failure = Get-RemoveYamlFailure {
905+
Remove-YamlEntry $yaml '/shared/drop' -MaxNodes 80
906+
}
907+
908+
$failure.Exception.Data['YamlErrorId'] |
909+
Should -BeExactly 'YamlRemovalWorkLimitExceeded'
910+
$failure.FullyQualifiedErrorId |
911+
Should -Be 'YamlRemovalWorkLimitExceeded,Remove-YamlEntry'
912+
$failure.Exception.Data['YamlRemovalWorkLimit'] | Should -Be 80
913+
$failure.Exception.Data['YamlRemovalWorkOperation'] |
914+
Should -BeExactly 'duplicate-key graph comparison'
915+
$failure.Exception.Message |
916+
Should -Match 'duplicate-key graph comparison'
917+
}
918+
822919
It 'reports deterministic work and produces a result within budget' {
823920
$measurement = Measure-RemoveYamlWork `
824921
-Yaml "root:`n first: 1`n second: 2`n third: 3" `

0 commit comments

Comments
 (0)