Skip to content

Commit f973ab4

Browse files
Reject lossy ETS projections
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent bf41279 commit f973ab4

6 files changed

Lines changed: 120 additions & 4 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
function Add-YamlDictionaryEntry {
2+
<#
3+
.SYNOPSIS
4+
Adds a projected mapping entry with a YAML-classified collision error.
5+
#>
6+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
7+
'PSUseShouldProcessForStateChangingFunctions', '',
8+
Justification = 'Mutates an internal projection dictionary.'
9+
)]
10+
[CmdletBinding()]
11+
param (
12+
[Parameter(Mandatory)]
13+
[System.Collections.IDictionary] $Dictionary,
14+
15+
[Parameter(Mandatory)]
16+
[object] $Key,
17+
18+
[Parameter(Mandatory)]
19+
[AllowNull()]
20+
[object] $Value,
21+
22+
[Parameter(Mandatory)]
23+
[pscustomobject] $KeyNode
24+
)
25+
26+
try {
27+
$Dictionary.Add($Key, $Value)
28+
} catch [System.Management.Automation.MethodInvocationException] {
29+
if ($_.Exception.InnerException -isnot [System.ArgumentException]) {
30+
throw
31+
}
32+
throw (New-YamlException -Start $KeyNode.Start -End $KeyNode.End `
33+
-ErrorId 'YamlProjectionKeyCollision' -Message (
34+
'Distinct YAML mapping keys cannot be projected distinctly into a PowerShell dictionary.'
35+
))
36+
}
37+
}

src/functions/private/ConvertFrom-YamlNode.ps1

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ function ConvertFrom-YamlNode {
154154
continue
155155
}
156156
if ($frame.State -eq 'OmapValue') {
157-
$frame.Result.Add($frame.Key, $frame.Child.PSObject.Properties['Value'].Value)
157+
Add-YamlDictionaryEntry -Dictionary $frame.Result -Key $frame.Key `
158+
-Value $frame.Child.PSObject.Properties['Value'].Value `
159+
-KeyNode $frame.Node.Items[$frame.Index].Entries[0].Key
158160
$frame.Index++
159161
$frame.State = 'OmapKey'
160162
continue
@@ -188,7 +190,8 @@ function ConvertFrom-YamlNode {
188190
$frame.Key = $keyValue
189191
}
190192
if ($frame.Node.Tag -ceq 'tag:yaml.org,2002:set') {
191-
$frame.Result.Add($frame.Key, $null)
193+
Add-YamlDictionaryEntry -Dictionary $frame.Result -Key $frame.Key -Value $null `
194+
-KeyNode $frame.Node.Entries[$frame.Index].Key
192195
$frame.Index++
193196
$frame.State = 'DictionaryKey'
194197
continue
@@ -209,7 +212,9 @@ function ConvertFrom-YamlNode {
209212
continue
210213
}
211214
if ($frame.State -eq 'DictionaryValue') {
212-
$frame.Result.Add($frame.Key, $frame.Child.PSObject.Properties['Value'].Value)
215+
Add-YamlDictionaryEntry -Dictionary $frame.Result -Key $frame.Key `
216+
-Value $frame.Child.PSObject.Properties['Value'].Value `
217+
-KeyNode $frame.Node.Entries[$frame.Index].Key
213218
$frame.Index++
214219
$frame.State = 'DictionaryKey'
215220
continue
@@ -244,6 +249,13 @@ function ConvertFrom-YamlNode {
244249
'This mapping key cannot be represented as a PSCustomObject property. Use -AsHashtable.'
245250
))
246251
}
252+
if (Test-YamlReservedPropertyName -Name $frame.Key) {
253+
$keyNode = $frame.Node.Entries[$frame.Index].Key
254+
throw (New-YamlException -Start $keyNode.Start -End $keyNode.End `
255+
-ErrorId 'YamlPropertyNameReserved' -Message (
256+
"The mapping key '$($frame.Key)' is reserved by PowerShell ETS. Use -AsHashtable."
257+
))
258+
}
247259
if (-not $frame.Names.Add($frame.Key)) {
248260
$keyNode = $frame.Node.Entries[$frame.Index].Key
249261
throw (New-YamlException -Start $keyNode.Start -End $keyNode.End `

src/functions/private/Get-YamlSerializationShape.ps1

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,22 @@ function Get-YamlSerializationShape {
5757
-not $isByteArray
5858
)
5959
$isCustomObject = $Value -is [System.Management.Automation.PSCustomObject]
60+
$isPurePropertyBag = $isCustomObject -or (
61+
$dataProperties.Count -gt 0 -and $Value.GetType() -eq [object]
62+
)
6063
if ($dataProperties.Count -gt 0 -and ($isDictionary -or $isSequence -or $isByteArray)) {
6164
throw (New-YamlSerializationException -Kind NotSupported `
6265
-ErrorId 'YamlMixedObjectSemantics' -Message (
6366
"Type '$($Value.GetType().FullName)' combines collection data with attached note properties and cannot be represented without loss."
6467
))
6568
}
66-
$isPropertyBag = $isCustomObject -or $dataProperties.Count -gt 0
69+
if ($dataProperties.Count -gt 0 -and -not $isPurePropertyBag) {
70+
throw (New-YamlSerializationException -Kind NotSupported `
71+
-ErrorId 'YamlMixedObjectSemantics' -Message (
72+
"Type '$($Value.GetType().FullName)' combines scalar data with attached note properties and cannot be represented without loss."
73+
))
74+
}
75+
$isPropertyBag = $isPurePropertyBag
6776

6877
if (-not $isPropertyBag -and ($Value -is [string] -or $Value -is [char])) {
6978
$scalar.Value = [string] $Value
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
function Test-YamlReservedPropertyName {
2+
<#
3+
.SYNOPSIS
4+
Tests whether a property name is reserved by PowerShell ETS.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([bool])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[string] $Name
11+
)
12+
13+
foreach ($reservedName in @(
14+
'PSObject',
15+
'PSTypeNames',
16+
'PSBase',
17+
'PSAdapted',
18+
'PSExtended'
19+
)) {
20+
if ($Name.Equals($reservedName, [System.StringComparison]::OrdinalIgnoreCase)) {
21+
return $true
22+
}
23+
}
24+
return $false
25+
}

tests/ConvertFrom-Yaml.Tests.ps1

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ folded: >
125125
$result['name'] | Should -Be 'two'
126126
}
127127

128+
It 'classifies ETS-reserved property names and preserves them with AsHashtable' {
129+
foreach ($name in @(
130+
'PSObject',
131+
'psobject',
132+
'PSTypeNames',
133+
'PSBase',
134+
'PSAdapted',
135+
'PSExtended'
136+
)) {
137+
{ "$name`: value" | ConvertFrom-Yaml } |
138+
Should -Throw -ExpectedMessage '*Use -AsHashtable*'
139+
140+
$result = "$name`: value" | ConvertFrom-Yaml -AsHashtable
141+
$result[$name] | Should -Be 'value'
142+
}
143+
}
144+
128145
It 'enumerates only top-level sequences by default' {
129146
$result = "- one`n- two" | ConvertFrom-Yaml
130147

@@ -346,6 +363,14 @@ copy: *source
346363
$result['set']['one'] | Should -BeNullOrEmpty
347364
}
348365

366+
It 'classifies projection collisions from representation-distinct tagged keys' {
367+
$yaml = "!foo x: one`n!bar x: two"
368+
369+
($yaml | Test-Yaml) | Should -BeTrue
370+
{ $yaml | ConvertFrom-Yaml -AsHashtable } |
371+
Should -Throw -ExpectedMessage '*cannot be projected distinctly*'
372+
}
373+
349374
It 'uses deterministic UTC semantics for zone-less explicit timestamps' {
350375
$result = @'
351376
offset: !!timestamp 2001-12-14T21:59:43.1+5:30

tests/ConvertTo-Yaml.Tests.ps1

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,14 @@ Describe 'ConvertTo-Yaml' {
322322
Should -Throw -ExpectedMessage '*combines collection data with attached note properties*'
323323
}
324324

325+
It 'rejects attached note properties on scalar values' {
326+
$value = [psobject] 42
327+
Add-Member -InputObject $value -MemberType NoteProperty -Name metadata -Value 'lossy'
328+
329+
{ ConvertTo-Yaml -InputObject $value } |
330+
Should -Throw -ExpectedMessage '*combines scalar data with attached note properties*'
331+
}
332+
325333
It 'preserves the sign of IEEE negative zero across serialization' {
326334
$negativeZero = [BitConverter]::Int64BitsToDouble([long]::MinValue)
327335
$yaml = ConvertTo-Yaml -InputObject $negativeZero

0 commit comments

Comments
 (0)