Skip to content

Commit 29797c7

Browse files
Enforce YAML tag grammar and identity
Include effective tags in structural key fingerprints, validate ns-uri-char and percent escapes by whitelist, and accept reserved directives through their YAML productions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1af06a9 commit 29797c7

8 files changed

Lines changed: 179 additions & 38 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
function Get-YamlEffectiveTag {
2+
<#
3+
.SYNOPSIS
4+
Gets the effective representation tag for a YAML node.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[pscustomobject] $Node,
11+
12+
[Parameter(Mandatory)]
13+
[AllowNull()]
14+
[object] $Value
15+
)
16+
17+
if (-not [string]::IsNullOrEmpty($Node.Tag)) {
18+
return $Node.Tag
19+
}
20+
if ($Node.Kind.Equals('Sequence', [System.StringComparison]::Ordinal)) {
21+
return 'tag:yaml.org,2002:seq'
22+
}
23+
if ($Node.Kind.Equals('Mapping', [System.StringComparison]::Ordinal)) {
24+
return 'tag:yaml.org,2002:map'
25+
}
26+
27+
if ($null -eq $Value) {
28+
return 'tag:yaml.org,2002:null'
29+
}
30+
if ($Value -is [string]) {
31+
return 'tag:yaml.org,2002:str'
32+
}
33+
if ($Value -is [bool]) {
34+
return 'tag:yaml.org,2002:bool'
35+
}
36+
if ($Value -is [byte[]]) {
37+
return 'tag:yaml.org,2002:binary'
38+
}
39+
if ($Value -is [datetime] -or $Value -is [datetimeoffset]) {
40+
return 'tag:yaml.org,2002:timestamp'
41+
}
42+
if ($Value -is [decimal] -or $Value -is [double] -or $Value -is [single]) {
43+
return 'tag:yaml.org,2002:float'
44+
}
45+
return 'tag:yaml.org,2002:int'
46+
}

src/functions/private/Get-YamlEmissionNodeFingerprint.ps1

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ function Get-YamlEmissionNodeFingerprint {
6565
ResolvedValue = $null
6666
MaxNumericLength = 1048576
6767
})
68-
$fingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher
68+
$valueFingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher
69+
$effectiveTag = Get-YamlEffectiveTag -Node $frame.Node -Value $resolved.Value
70+
$fingerprint = Get-YamlFingerprintHash -Value (
71+
'scalar:{0}:{1}:{2}' -f $effectiveTag.Length, $effectiveTag, $valueFingerprint
72+
) -Hasher $Hasher
6973
if ($hasReferenceId) {
7074
$Cache[$frame.Node.ReferenceId] = $fingerprint
7175
[void] $Active.Remove($frame.Node.ReferenceId)
@@ -86,8 +90,13 @@ function Get-YamlEmissionNodeFingerprint {
8690

8791
if ($frame.State -eq 'Sequence') {
8892
if ($frame.Index -ge $frame.Node.Items.Count) {
93+
$effectiveTag = Get-YamlEffectiveTag -Node $frame.Node -Value $null
8994
$fingerprint = Get-YamlFingerprintHash -Value (
90-
'sequence:{0}' -f ($frame.Parts -join '|')
95+
'sequence:{0}:{1}:{2}' -f @(
96+
$effectiveTag.Length,
97+
$effectiveTag,
98+
($frame.Parts -join '|')
99+
)
91100
) -Hasher $Hasher
92101
if ($frame.Node.ReferenceId -ne 0) {
93102
$Cache[$frame.Node.ReferenceId] = $fingerprint
@@ -120,8 +129,13 @@ function Get-YamlEmissionNodeFingerprint {
120129
if ($frame.State -eq 'MappingKey') {
121130
if ($frame.Index -ge $frame.Node.Entries.Count) {
122131
$frame.Parts.Sort([System.StringComparer]::Ordinal)
132+
$effectiveTag = Get-YamlEffectiveTag -Node $frame.Node -Value $null
123133
$fingerprint = Get-YamlFingerprintHash -Value (
124-
'mapping:{0}' -f ($frame.Parts -join '|')
134+
'mapping:{0}:{1}:{2}' -f @(
135+
$effectiveTag.Length,
136+
$effectiveTag,
137+
($frame.Parts -join '|')
138+
)
125139
) -Hasher $Hasher
126140
if ($frame.Node.ReferenceId -ne 0) {
127141
$Cache[$frame.Node.ReferenceId] = $fingerprint

src/functions/private/Get-YamlNodeFingerprint.ps1

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ function Get-YamlNodeFingerprint {
5757

5858
if ($effective.Kind -eq 'Scalar') {
5959
$resolved = Resolve-YamlScalar -Node $effective
60-
$fingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher
60+
$valueFingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher
61+
$effectiveTag = Get-YamlEffectiveTag -Node $effective -Value $resolved.Value
62+
$fingerprint = Get-YamlFingerprintHash -Value (
63+
'scalar:{0}:{1}:{2}' -f $effectiveTag.Length, $effectiveTag, $valueFingerprint
64+
) -Hasher $Hasher
6165
$Cache[$effective.Id] = $fingerprint
6266
[void] $Active.Remove($effective.Id)
6367
$frame.Holder.Value = $fingerprint
@@ -76,15 +80,12 @@ function Get-YamlNodeFingerprint {
7680

7781
if ($frame.State -eq 'Sequence') {
7882
if ($frame.Index -ge $frame.Node.Items.Count) {
79-
$semanticTag = if (
80-
$frame.Node.Tag -ceq 'tag:yaml.org,2002:omap' -or
81-
$frame.Node.Tag -ceq 'tag:yaml.org,2002:pairs'
82-
) {
83-
$frame.Node.Tag
84-
} else {
85-
'tag:yaml.org,2002:seq'
86-
}
87-
$canonical = 'sequence:{0}:{1}' -f $semanticTag, ($frame.Parts -join '|')
83+
$semanticTag = Get-YamlEffectiveTag -Node $frame.Node -Value $null
84+
$canonical = 'sequence:{0}:{1}:{2}' -f @(
85+
$semanticTag.Length,
86+
$semanticTag,
87+
($frame.Parts -join '|')
88+
)
8889
$fingerprint = Get-YamlFingerprintHash -Value $canonical -Hasher $Hasher
8990
$Cache[$frame.Node.Id] = $fingerprint
9091
[void] $Active.Remove($frame.Node.Id)
@@ -115,12 +116,12 @@ function Get-YamlNodeFingerprint {
115116
if ($frame.State -eq 'MappingKey') {
116117
if ($frame.Index -ge $frame.Node.Entries.Count) {
117118
$frame.Parts.Sort([System.StringComparer]::Ordinal)
118-
$mappingTag = if ($frame.Node.Tag -ceq 'tag:yaml.org,2002:set') {
119-
$frame.Node.Tag
120-
} else {
121-
'tag:yaml.org,2002:map'
122-
}
123-
$canonical = 'mapping:{0}:{1}' -f $mappingTag, ($frame.Parts -join '|')
119+
$mappingTag = Get-YamlEffectiveTag -Node $frame.Node -Value $null
120+
$canonical = 'mapping:{0}:{1}:{2}' -f @(
121+
$mappingTag.Length,
122+
$mappingTag,
123+
($frame.Parts -join '|')
124+
)
124125
$fingerprint = Get-YamlFingerprintHash -Value $canonical -Hasher $Hasher
125126
$Cache[$frame.Node.Id] = $fingerprint
126127
[void] $Active.Remove($frame.Node.Id)

src/functions/private/Read-YamlDirectiveBlock.ps1

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function Read-YamlDirectiveBlock {
3333
if ($directive -cmatch '^%YAML(?:[ \t]|$)') {
3434
if ($yamlDirectiveSeen -or $directive -cnotmatch (
3535
'^%YAML[ \t]+([0-9]+)\.([0-9]+)(?:[ \t]+#.*)?$'
36-
) -or $Matches[1] -ne '1') {
36+
) -or -not $Matches[1].Equals('1', [System.StringComparison]::Ordinal)) {
3737
throw (New-YamlException -Start $mark -End $mark `
3838
-ErrorId 'YamlInvalidDirective' -Message (
3939
"The YAML directive '$directive' is malformed, duplicated, or requests an unsupported version."
@@ -63,16 +63,15 @@ function Read-YamlDirectiveBlock {
6363
"A YAML tag prefix exceeds the configured limit of $($Context.MaxTagLength) characters."
6464
))
6565
}
66-
if ($prefix -ne '!' -and -not (Test-YamlTagUriText -Text $prefix)) {
66+
if (-not $prefix.Equals('!', [System.StringComparison]::Ordinal) -and
67+
-not (Test-YamlTagUriText -Text $prefix)) {
6768
throw (New-YamlException -Start $mark -End $mark `
6869
-ErrorId 'YamlInvalidDirective' -Message (
6970
"The TAG directive prefix '$prefix' contains invalid URI text."
7071
))
7172
}
7273
$tagHandles[$handle] = $prefix
73-
} elseif ($directive -cnotmatch (
74-
'^%[A-Za-z0-9]+(?:[ \t]+[^# \t][^#]*?)?(?:[ \t]+#.*)?$'
75-
)) {
74+
} elseif (-not (Test-YamlReservedDirective -Directive $directive)) {
7675
throw (New-YamlException -Start $mark -End $mark `
7776
-ErrorId 'YamlInvalidDirective' -Message (
7877
"The directive '$directive' is malformed."

src/functions/private/Resolve-YamlTag.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function Resolve-YamlTag {
4848
$suffix = $Token.Substring($secondBang + 1)
4949
}
5050
}
51-
if ($Token -eq '!') {
51+
if ($Token.Equals('!', [System.StringComparison]::Ordinal)) {
5252
$prefix = ''
5353
$suffix = '!'
5454
} elseif ([string]::IsNullOrEmpty($suffix) -or
@@ -62,7 +62,7 @@ function Resolve-YamlTag {
6262
"The tag handle '$handle' was not declared."
6363
))
6464
}
65-
if ($Token -ne '!') {
65+
if (-not $Token.Equals('!', [System.StringComparison]::Ordinal)) {
6666
$prefix = $Context.TagHandles[$handle]
6767
}
6868
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
function Test-YamlReservedDirective {
2+
<#
3+
.SYNOPSIS
4+
Tests the YAML reserved-directive name and parameter productions.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([bool])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[string] $Directive
11+
)
12+
13+
if ($Directive.Length -lt 2 -or -not $Directive[0].Equals([char] '%')) {
14+
return $false
15+
}
16+
17+
$comment = Find-YamlCommentStart -Text $Directive
18+
$contentEnd = if ($comment -ge 0) { $comment } else { $Directive.Length }
19+
$body = $Directive.Substring(1, $contentEnd - 1).TrimEnd(' ', "`t")
20+
if ($body.Length -eq 0 -or (Test-YamlWhiteSpace -Character $body[0])) {
21+
return $false
22+
}
23+
24+
$index = 0
25+
while ($index -lt $body.Length) {
26+
$tokenStart = $index
27+
while ($index -lt $body.Length -and
28+
-not (Test-YamlWhiteSpace -Character $body[$index])) {
29+
$index++
30+
}
31+
if ($index -eq $tokenStart) {
32+
return $false
33+
}
34+
while ($index -lt $body.Length -and
35+
(Test-YamlWhiteSpace -Character $body[$index])) {
36+
$index++
37+
}
38+
}
39+
return $true
40+
}

src/functions/private/Test-YamlTagUriText.ps1

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,40 @@ function Test-YamlTagUriText {
1717
return $false
1818
}
1919

20+
$uriPunctuation = '#;/?:@&=+$,_.!~*''()[]'
21+
$shorthandExcluded = '!,[]'
2022
for ($index = 0; $index -lt $Text.Length; $index++) {
2123
$character = $Text[$index]
22-
if ((Test-YamlWhiteSpace -Character $character) -or $character -ceq "`n" -or
23-
[char]::IsControl($character) -or
24-
$character -in @('<', '>', '{', '}')) {
25-
return $false
26-
}
27-
if ($Shorthand -and $character -in @('!', '[', ']', ',')) {
28-
return $false
29-
}
30-
if ($character -ne '%') {
24+
if (-not $character.Equals([char] '%')) {
25+
$code = [int] $character
26+
$isWordCharacter = (
27+
($code -ge 0x30 -and $code -le 0x39) -or
28+
($code -ge 0x41 -and $code -le 0x5A) -or
29+
($code -ge 0x61 -and $code -le 0x7A) -or
30+
$character.Equals([char] '-')
31+
)
32+
if (-not $isWordCharacter -and $uriPunctuation.IndexOf($character) -lt 0) {
33+
return $false
34+
}
35+
if ($Shorthand -and $shorthandExcluded.IndexOf($character) -ge 0) {
36+
return $false
37+
}
3138
continue
3239
}
33-
if ($index + 2 -ge $Text.Length -or
34-
$Text[$index + 1] -notmatch '^[0-9A-Fa-f]$' -or
35-
$Text[$index + 2] -notmatch '^[0-9A-Fa-f]$') {
40+
41+
if ($index + 2 -ge $Text.Length) {
3642
return $false
3743
}
44+
foreach ($offset in 1, 2) {
45+
$hexCode = [int] $Text[$index + $offset]
46+
if (-not (
47+
($hexCode -ge 0x30 -and $hexCode -le 0x39) -or
48+
($hexCode -ge 0x41 -and $hexCode -le 0x46) -or
49+
($hexCode -ge 0x61 -and $hexCode -le 0x66)
50+
)) {
51+
return $false
52+
}
53+
}
3854
$index += 2
3955
}
4056
return $true

tests/Test-Yaml.Tests.ps1

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,19 @@ Describe 'Test-Yaml' {
7171
It 'validates tag directives, tag tokens, and alias properties' {
7272
("%TAG !! tag:example.com,2000:app/`n---`n!!int value" | Test-Yaml) |
7373
Should -BeTrue
74+
("%FOO-BAR baz`n---`nvalue" | Test-Yaml) | Should -BeTrue
75+
("%FOO:B alpha-beta p#q`n---`nvalue" | Test-Yaml) | Should -BeTrue
7476
("%TAG ! tag:first/`n%TAG ! tag:second/`n---`n!value data" | Test-Yaml) |
7577
Should -BeFalse
78+
("% FOO baz`n---`nvalue" | Test-Yaml) | Should -BeFalse
79+
("%`n---`nvalue" | Test-Yaml) | Should -BeFalse
7680
('!foo%GG value' | Test-Yaml) | Should -BeFalse
81+
('!foo\bar value' | Test-Yaml) | Should -BeFalse
82+
('!<tag:example.test,2026:foo\bar> value' | Test-Yaml) | Should -BeFalse
7783
('!! value' | Test-Yaml) | Should -BeFalse
7884
('!<tag:example.test,2026:bad tag> value' | Test-Yaml) | Should -BeFalse
85+
('!<tag:example.test,2026:a;b/c?d@e&f=g+h$i,j_k.l~m*n''o(p)[q]%21> value' |
86+
Test-Yaml) | Should -BeTrue
7987
("a: &a value`nb: &b *a" | Test-Yaml) | Should -BeFalse
8088
}
8189

@@ -89,6 +97,23 @@ Describe 'Test-Yaml' {
8997
("1: one`n01: two" | Test-Yaml) | Should -BeFalse
9098
}
9199

100+
It 'includes effective tags in mapping-key equality' {
101+
("!foo x: one`n!bar x: two" | Test-Yaml) | Should -BeTrue
102+
("!foo x: one`n!foo x: two" | Test-Yaml) | Should -BeFalse
103+
(@'
104+
? !foo [x]
105+
: one
106+
? !bar [x]
107+
: two
108+
'@ | Test-Yaml) | Should -BeTrue
109+
(@'
110+
? !foo {x: y}
111+
: one
112+
? !bar {x: y}
113+
: two
114+
'@ | Test-Yaml) | Should -BeTrue
115+
}
116+
92117
It 'accepts complex keys even though default object projection cannot' {
93118
("? [a, b]`n: value" | Test-Yaml) | Should -BeTrue
94119
}

0 commit comments

Comments
 (0)