Skip to content

Commit bf41279

Browse files
Normalize YAML floating key equality
Canonicalize Decimal, Double, and Single values by decimal significand and exponent, making large equivalent values and signed zero obey YAML representation equality. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 29797c7 commit bf41279

4 files changed

Lines changed: 146 additions & 26 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
function Get-YamlNormalizedFloat {
2+
<#
3+
.SYNOPSIS
4+
Normalizes a finite CLR floating-point value to a decimal significand and exponent.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[object] $Value
11+
)
12+
13+
$culture = [System.Globalization.CultureInfo]::InvariantCulture
14+
if ($Value -is [decimal]) {
15+
if ($Value -eq 0) {
16+
return '0e0'
17+
}
18+
$text = $Value.ToString('G29', $culture)
19+
} elseif ($Value -is [single]) {
20+
$number = [single] $Value
21+
if ([single]::IsNaN($number)) {
22+
return 'nan'
23+
}
24+
if ([single]::IsPositiveInfinity($number)) {
25+
return '+inf'
26+
}
27+
if ([single]::IsNegativeInfinity($number)) {
28+
return '-inf'
29+
}
30+
if ($number -eq 0) {
31+
return '0e0'
32+
}
33+
$text = $number.ToString('R', $culture)
34+
} elseif ($Value -is [double]) {
35+
$number = [double] $Value
36+
if ([double]::IsNaN($number)) {
37+
return 'nan'
38+
}
39+
if ([double]::IsPositiveInfinity($number)) {
40+
return '+inf'
41+
}
42+
if ([double]::IsNegativeInfinity($number)) {
43+
return '-inf'
44+
}
45+
if ($number -eq 0) {
46+
return '0e0'
47+
}
48+
$text = $number.ToString('R', $culture)
49+
} else {
50+
throw [System.ArgumentException]::new(
51+
"Type '$($Value.GetType().FullName)' is not a supported floating-point type.",
52+
'Value'
53+
)
54+
}
55+
56+
$match = [regex]::Match(
57+
$text,
58+
'^(?<sign>[-+]?)(?<integer>[0-9]+)(?:\.(?<fraction>[0-9]*))?(?:[eE](?<exponent>[-+]?[0-9]+))?$',
59+
[System.Text.RegularExpressions.RegexOptions]::CultureInvariant
60+
)
61+
if (-not $match.Success) {
62+
throw [System.InvalidOperationException]::new(
63+
"The invariant floating-point text '$text' could not be normalized."
64+
)
65+
}
66+
67+
$fraction = $match.Groups['fraction'].Value
68+
$digits = ($match.Groups['integer'].Value + $fraction).TrimStart('0')
69+
if ($digits.Length -eq 0) {
70+
return '0e0'
71+
}
72+
73+
$exponent = 0 - $fraction.Length
74+
if ($match.Groups['exponent'].Success) {
75+
$exponent += [int]::Parse(
76+
$match.Groups['exponent'].Value,
77+
[System.Globalization.NumberStyles]::AllowLeadingSign,
78+
$culture
79+
)
80+
}
81+
while ($digits.Length -gt 1 -and $digits[$digits.Length - 1].Equals([char] '0')) {
82+
$digits = $digits.Substring(0, $digits.Length - 1)
83+
$exponent++
84+
}
85+
86+
$sign = if ($match.Groups['sign'].Value.Equals(
87+
'-',
88+
[System.StringComparison]::Ordinal
89+
)) {
90+
'-'
91+
} else {
92+
''
93+
}
94+
return '{0}{1}e{2}' -f $sign, $digits, $exponent
95+
}

src/functions/private/Get-YamlScalarFingerprint.ps1

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,32 +33,8 @@ function Get-YamlScalarFingerprint {
3333
[datetime]::SpecifyKind($Value, [System.DateTimeKind]::Utc)
3434
}
3535
$canonicalValue = 'timestamp:{0}' -f $utcValue.Ticks
36-
} elseif ($Value -is [decimal]) {
37-
if ($Value -eq 0 -and
38-
([decimal]::GetBits($Value)[3] -band [int]::MinValue) -ne 0) {
39-
$canonicalValue = 'float:-0'
40-
} else {
41-
$canonicalValue = 'float:{0}' -f $Value.ToString(
42-
'G29',
43-
[cultureinfo]::InvariantCulture
44-
)
45-
}
46-
} elseif ($Value -is [double]) {
47-
if ([double]::IsNaN($Value)) {
48-
$canonicalValue = 'float:nan'
49-
} elseif ([double]::IsPositiveInfinity($Value)) {
50-
$canonicalValue = 'float:+inf'
51-
} elseif ([double]::IsNegativeInfinity($Value)) {
52-
$canonicalValue = 'float:-inf'
53-
} elseif ($Value -eq 0) {
54-
$negative = [System.BitConverter]::DoubleToInt64Bits([double] $Value) -lt 0
55-
$canonicalValue = if ($negative) { 'float:-0' } else { 'float:0' }
56-
} else {
57-
$canonicalValue = 'float:{0}' -f $Value.ToString(
58-
'R',
59-
[cultureinfo]::InvariantCulture
60-
)
61-
}
36+
} elseif ($Value -is [decimal] -or $Value -is [double] -or $Value -is [single]) {
37+
$canonicalValue = 'float:{0}' -f (Get-YamlNormalizedFloat -Value $Value)
6238
} else {
6339
$canonicalValue = 'int:{0}' -f $Value.ToString([cultureinfo]::InvariantCulture)
6440
}

tests/ConvertFrom-Yaml.Tests.ps1

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,28 @@ date: !!timestamp 2001-12-14
376376
Should -Throw
377377
}
378378

379+
It 'normalizes cross-type finite floats for key equality' {
380+
$yaml = @'
381+
100000000000000000000.0: decimal
382+
1e20: double
383+
'@
384+
385+
($yaml | Test-Yaml) | Should -BeFalse
386+
{ $yaml | ConvertFrom-Yaml -AsHashtable } |
387+
Should -Throw -ExpectedMessage '*duplicate mapping key*'
388+
}
389+
390+
It 'treats signed zero keys as the same YAML representation value' {
391+
$yaml = @'
392+
0.0: positive
393+
-0.0: negative
394+
'@
395+
396+
($yaml | Test-Yaml) | Should -BeFalse
397+
{ $yaml | ConvertFrom-Yaml -AsHashtable } |
398+
Should -Throw -ExpectedMessage '*duplicate mapping key*'
399+
}
400+
379401
It 'rejects equivalent offset timestamps as duplicate keys' {
380402
$yaml = @'
381403
? !!timestamp 2001-12-15T02:59:43.1Z

tests/ConvertTo-Yaml.Tests.ps1

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,33 @@ Describe 'ConvertTo-Yaml' {
181181
Should -Throw -ExpectedMessage '*normalize to the same YAML value*'
182182
}
183183

184+
It 'normalizes finite float key fingerprints across CLR types' {
185+
$equal = [System.Collections.Specialized.OrderedDictionary]::new()
186+
$equal.Add(
187+
[decimal]::Parse(
188+
'100000000000000000000.0',
189+
[cultureinfo]::InvariantCulture
190+
),
191+
'decimal'
192+
)
193+
$equal.Add([double] 1e20, 'double')
194+
195+
{ $equal | ConvertTo-Yaml } |
196+
Should -Throw -ExpectedMessage '*normalize to the same YAML value*'
197+
198+
$different = [System.Collections.Specialized.OrderedDictionary]::new()
199+
$different.Add(
200+
[decimal]::Parse(
201+
'100000000000000000001',
202+
[cultureinfo]::InvariantCulture
203+
),
204+
'decimal'
205+
)
206+
$different.Add([double] 1e20, 'double')
207+
208+
{ $different | ConvertTo-Yaml } | Should -Not -Throw
209+
}
210+
184211
It 'compares unordered complex keys with ordinal case-sensitive sorting' {
185212
$firstKey = [System.Collections.Specialized.OrderedDictionary]::new()
186213
$firstKey.Add('a', 1)

0 commit comments

Comments
 (0)