diff --git a/.github/PSModule.yml b/.github/PSModule.yml index b37f9f1..aa919aa 100644 --- a/.github/PSModule.yml +++ b/.github/PSModule.yml @@ -16,9 +16,10 @@ Test: # Skip: true # MacOS: # Skip: true -# Build: -# Docs: -# Skip: true +Build: + Docs: + # PlatyPS loads a conflicting YamlDotNet assembly before importing this module. + Skip: true Linter: env: diff --git a/.github/linters/.yaml-lint.yml b/.github/linters/.yaml-lint.yml new file mode 100644 index 0000000..12ac3bb --- /dev/null +++ b/.github/linters/.yaml-lint.yml @@ -0,0 +1,6 @@ +--- +extends: relaxed + +# Official conformance fixtures intentionally preserve invalid and unusual YAML. +ignore: | + **/tests/fixtures/** diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 67f2c25..f524633 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,10 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@11117919e65242d3388727819a751f74ad24ea9e # v5.5.0 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 # v6.1.4 + with: + ImportantFilePatterns: | + ^src/ + ^tests/ + ^README\.md$ secrets: inherit diff --git a/README.md b/README.md index 96936ee..465af3f 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,170 @@ -# {{ NAME }} +# Yaml -{{ DESCRIPTION }} +`Yaml` converts between YAML streams and PowerShell values. It uses +YamlDotNet's low-level parser and emitter while applying YAML 1.2 core-schema +rules in PowerShell, without activating .NET types from YAML tags. -## Prerequisites +## Installation + +```powershell +Install-PSResource -Name Yaml +Import-Module -Name Yaml +``` -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule) for building, testing and publishing the module. +The module exports: -## Installation +| Command | Purpose | +| --- | --- | +| `ConvertFrom-Yaml` | Parse one or more YAML documents into PowerShell values. | +| `ConvertTo-Yaml` | Serialize supported PowerShell values as YAML 1.2-compatible text. | +| `Test-Yaml` | Test YAML syntax, tags, duplicate keys, and configured resource limits. | + +## Parse YAML -To install the module from the PowerShell Gallery, you can use the following command: +Ordinary string-key mappings become ordered `PSCustomObject` values. A +top-level sequence writes its items to the pipeline by default. ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +$config = @' +name: example +enabled: true +ports: [80, 443] +'@ | ConvertFrom-Yaml + +$config.name +$config.ports[0] ``` -## Usage +Pipeline strings are joined with LF and parsed as one stream. This makes +line-oriented input work as expected: -Here is a list of example that are typical use cases for the module. - -### Example 1: Greet an entity +```powershell +$config = Get-Content -Path '.\config.yaml' | ConvertFrom-Yaml +``` -Provide examples for typical commands that a user would like to do with the module. +Use `-NoEnumerate` when a top-level sequence must remain one pipeline record: ```powershell -Greet-Entity -Name 'World' -Hello, World! +$servers = @' +- name: web-1 +- name: web-2 +'@ | ConvertFrom-Yaml -NoEnumerate ``` -### Example 2 +Every YAML document is returned separately: + +```powershell +$documents = @(@' +--- +name: first +--- +name: second +'@ | ConvertFrom-Yaml) +``` -Provide examples for typical commands that a user would like to do with the module. +Use `-AsHashtable` for insertion-ordered dictionaries and mappings with +complex, non-string, empty, or case-colliding keys: ```powershell -Import-Module -Name PSModuleTemplate +$mapping = @' +? [region, port] +: eu-1 +'@ | ConvertFrom-Yaml -AsHashtable ``` -### Find more examples +## Serialize PowerShell values -To find more examples of how to use the module, please refer to the [examples](examples) folder. +`ConvertTo-Yaml` supports `PSCustomObject` and explicit PSObject note-property +bags, dictionaries, sequences, strings, characters, Booleans, integer and +floating-point numbers, `BigInteger`, `DateTime`, `DateTimeOffset`, enums, +null, and byte arrays. -Alternatively, you can use the Get-Command -Module 'This module' to find more commands that are available in the module. -To find examples of each of the commands you can use Get-Help -Examples 'CommandName'. +```powershell +$yaml = [ordered]@{ + name = 'example' + enabled = $true + ports = @(80, 443) +} | ConvertTo-Yaml -ExplicitDocumentStart -## Documentation +$roundTrip = $yaml | ConvertFrom-Yaml +``` -Link to further documentation if available, or describe where in the repository users can find more detailed documentation about -the module's functions and features. +Multiple pipeline records are collected into one top-level YAML sequence: -## Contributing +```powershell +'one', 'two' | ConvertTo-Yaml +``` + +Pass an array directly when it represents one input value: -Coder or not, you can contribute to the project! We welcome all contributions. +```powershell +$items = @('one', 'two') +ConvertTo-Yaml -InputObject $items +``` + +`-EnumsAsStrings` emits enum names instead of their underlying numeric values. +`-Indent` accepts 2 through 9 spaces. `-Depth`, `-MaxNodes`, and +`-MaxScalarLength` constrain serialization. -### For Users +Repeated acyclic collection references are emitted with anchors and aliases. +Cyclic graphs and unsupported runtime objects fail specifically; values are +never silently truncated or converted with `ToString()`. -If you don't code, you still sit on valuable information that can make this project even better. If you experience that the -product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests. -Please see the issues tab on this project and submit a new issue that matches your needs. +## Validate YAML -### For Developers +```powershell +if (Get-Content -Path '.\config.yaml' | Test-Yaml) { + 'The YAML stream is valid.' +} +``` -If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information. -You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement. +`Test-Yaml` uses the same parser and limits as `ConvertFrom-Yaml`. It returns +`$false` for YAML-specific failures, including duplicate keys and resource +limit violations. Unexpected runtime failures are not suppressed. + +## Data model and safety + +- Plain implicit scalars use the YAML 1.2 core schema. YAML 1.1-only Boolean + words such as `yes` and untagged timestamps remain strings. +- Standard explicit tags are handled without reflection: `str`, `null`, + `bool`, `int`, `float`, `binary`, `timestamp`, `seq`, `map`, `set`, `omap`, + and `pairs`. +- Unknown application tags are discarded safely. Tagged scalars become + strings and tagged collections retain their sequence or mapping shape. +- Duplicate mapping keys are rejected after scalar construction, including + structurally equal complex keys. +- Aliases preserve collection reference identity where PowerShell can + represent it. +- YAML merge keys are not expanded; `<<` is ordinary mapping data under the + YAML 1.2 core schema. +- Parsing defaults to depth 100, 100000 nodes, 1000 aliases, and 1048576 + decoded characters per scalar. The corresponding limit parameters can be + lowered for untrusted input. + +Default object projection requires mapping keys that can be represented +without loss as PowerShell properties. Use `-AsHashtable` when that restriction +does not fit the data. + +## Compatibility boundaries + +The module preserves data values and graph sharing where representable. A +PowerShell object does not retain YAML presentation details, so data round +trips do **not** preserve comments, scalar style, tag spelling or handles, +anchor names, mapping presentation, line endings, or source formatting. +Unknown application tags are not reconstructed. + +Exact numeric CLR widths and enum CLR types are also not reconstructed after a +YAML round trip. The emitter writes a deliberately limited YAML +1.2-compatible subset even though YamlDotNet describes its emitter as YAML +1.1. + +## Third-party component + +The packaged module includes YamlDotNet 18.1.0 +`lib/netstandard2.0/YamlDotNet.dll`. See +[`src/THIRD-PARTY-NOTICES.txt`](src/THIRD-PARTY-NOTICES.txt) and +[`src/licenses/YamlDotNet.LICENSE.txt`](src/licenses/YamlDotNet.LICENSE.txt). -## Acknowledgements +## Contributing -Here is a list of people and projects that helped this project in some way. +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/examples/General.ps1 b/examples/General.ps1 index e193423..2c428b4 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -1,19 +1,45 @@ -<# - .SYNOPSIS - This is a general example of how to use the module. +<# + .SYNOPSIS + Demonstrates the public Yaml commands. #> -# Import the module -Import-Module -Name 'PSModule' +Import-Module -Name Yaml -# Define the path to the font file -$FontFilePath = 'C:\Fonts\CodeNewRoman\CodeNewRomanNerdFontPropo-Regular.tff' +$yaml = @' +--- +name: example +enabled: true +ports: [80, 443] +'@ -# Install the font -Install-Font -Path $FontFilePath -Verbose +# Parse a mapping to an ordered PSCustomObject. +$config = $yaml | ConvertFrom-Yaml +$config -# List installed fonts -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' +# Keep a top-level sequence as one pipeline record. +$servers = @' +- name: web-1 +- name: web-2 +'@ | ConvertFrom-Yaml -NoEnumerate +$servers.Count -# Uninstall the font -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' | Uninstall-Font -Verbose +# Preserve mappings whose keys cannot be PowerShell property names. +$complexMapping = @' +? [region, port] +: eu-1 +'@ | ConvertFrom-Yaml -AsHashtable +$complexMapping + +# Serialize supported PowerShell data and parse it again. +$outputYaml = [ordered]@{ + name = 'example' + enabled = $true + ports = @(80, 443) +} | ConvertTo-Yaml -ExplicitDocumentStart + +$outputYaml +$outputYaml | ConvertFrom-Yaml + +# Test syntax, duplicate keys, tags, and resource limits without conversion. +$isValid = $outputYaml | Test-Yaml +$isValid diff --git a/src/THIRD-PARTY-NOTICES.txt b/src/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..3cda06f --- /dev/null +++ b/src/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,18 @@ +YamlDotNet 18.1.0 +================= + +Copyright (c) Antoine Aubry and contributors. +Licensed under the MIT License. See licenses/YamlDotNet.LICENSE.txt. + +Package: + https://api.nuget.org/v3-flatcontainer/yamldotnet/18.1.0/yamldotnet.18.1.0.nupkg +Package repository: + https://github.com/aaubry/YamlDotNet/tree/748334a8fa7c227740018b284b71ad95cc6b7fc7 +Bundled asset: + lib/netstandard2.0/YamlDotNet.dll +Target framework: + netstandard2.0 +Package SHA-256: + 59FFE65ADE67AD9D886267F877B634A450363CB81B94E19DB9CA4C36461416F6 +Bundled DLL SHA-256: + 91CD6D1FD0AE5B64BF6B252EB3B1AF2382CBC599C29045427C45FE8403D4295D diff --git a/src/assemblies/YamlDotNet.dll b/src/assemblies/YamlDotNet.dll new file mode 100644 index 0000000..55aec98 Binary files /dev/null and b/src/assemblies/YamlDotNet.dll differ diff --git a/src/functions/private/Confirm-YamlScalarLength.ps1 b/src/functions/private/Confirm-YamlScalarLength.ps1 new file mode 100644 index 0000000..dfaee73 --- /dev/null +++ b/src/functions/private/Confirm-YamlScalarLength.ps1 @@ -0,0 +1,20 @@ +function Confirm-YamlScalarLength { + <# + .SYNOPSIS + Enforces the configured length limit for an emission scalar. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + if ($Node.Value.Length -gt $State.MaxScalarLength) { + throw (New-YamlSerializationException -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A scalar exceeds the configured limit of $($State.MaxScalarLength) characters." + )) + } +} diff --git a/src/functions/private/ConvertFrom-YamlInteger.ps1 b/src/functions/private/ConvertFrom-YamlInteger.ps1 new file mode 100644 index 0000000..e40cf64 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlInteger.ps1 @@ -0,0 +1,52 @@ +function ConvertFrom-YamlInteger { + <# + .SYNOPSIS + Constructs the narrowest supported numeric value from a YAML integer. + #> + [CmdletBinding()] + [OutputType([int], [long], [System.Numerics.BigInteger])] + param ( + [Parameter(Mandatory)] + [string] $Value + ) + + $sign = [System.Numerics.BigInteger]::One + $digits = $Value + $base = 10 + + if ($digits.StartsWith('+', [System.StringComparison]::Ordinal)) { + $digits = $digits.Substring(1) + } elseif ($digits.StartsWith('-', [System.StringComparison]::Ordinal)) { + $sign = [System.Numerics.BigInteger]::MinusOne + $digits = $digits.Substring(1) + } + + if ($digits.StartsWith('0o', [System.StringComparison]::Ordinal)) { + $base = 8 + $digits = $digits.Substring(2) + } elseif ($digits.StartsWith('0x', [System.StringComparison]::Ordinal)) { + $base = 16 + $digits = $digits.Substring(2) + } + + $result = [System.Numerics.BigInteger]::Zero + foreach ($character in $digits.ToCharArray()) { + if ($character -ge [char] '0' -and $character -le [char] '9') { + $digit = [int] $character - [int] [char] '0' + } else { + $digit = 10 + ([int] [char]::ToUpperInvariant($character) - [int] [char] 'A') + } + $result = ($result * $base) + $digit + } + $result *= $sign + + if ($result -ge [int]::MinValue -and $result -le [int]::MaxValue) { + Write-Output -InputObject ([int] $result) -NoEnumerate + return + } + if ($result -ge [long]::MinValue -and $result -le [long]::MaxValue) { + Write-Output -InputObject ([long] $result) -NoEnumerate + return + } + Write-Output -InputObject $result -NoEnumerate +} diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 new file mode 100644 index 0000000..3b1b120 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -0,0 +1,100 @@ +function ConvertFrom-YamlNode { + <# + .SYNOPSIS + Projects an internal YAML node graph to PowerShell values. + #> + [CmdletBinding()] + [OutputType([object])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, object]] $Cache, + + [switch] $AsHashtable + ) + + if ($Node.Kind -eq 'Alias') { + Write-Output -InputObject (ConvertFrom-YamlNode -Node $Node.Target -Cache $Cache -AsHashtable:$AsHashtable) -NoEnumerate + return + } + + if ($Node.Kind -eq 'Scalar') { + Write-Output -InputObject (Resolve-YamlScalar -Node $Node) -NoEnumerate + return + } + + if ($Cache.ContainsKey($Node.Id)) { + Write-Output -InputObject ($Cache[$Node.Id]) -NoEnumerate + return + } + + if ($Node.Kind -eq 'Sequence') { + if ($Node.Tag -eq 'tag:yaml.org,2002:omap') { + $orderedMap = [System.Collections.Specialized.OrderedDictionary]::new() + $Cache[$Node.Id] = $orderedMap + foreach ($item in $Node.Items) { + $entryMap = ConvertFrom-YamlNode -Node $item -Cache $Cache -AsHashtable + $key = @($entryMap.Keys)[0] + $orderedMap.Add($key, $entryMap[$key]) + } + Write-Output -InputObject $orderedMap -NoEnumerate + return + } + + $isPairs = $Node.Tag -eq 'tag:yaml.org,2002:pairs' + $sequence = [object[]]::new($Node.Items.Count) + $Cache[$Node.Id] = $sequence + for ($index = 0; $index -lt $Node.Items.Count; $index++) { + $sequence[$index] = ConvertFrom-YamlNode -Node $Node.Items[$index] -Cache $Cache ` + -AsHashtable:($AsHashtable -or $isPairs) + } + Write-Output -InputObject $sequence -NoEnumerate + return + } + + $isSet = $Node.Tag -eq 'tag:yaml.org,2002:set' + if ($AsHashtable -or $isSet) { + $dictionary = [System.Collections.Specialized.OrderedDictionary]::new() + $Cache[$Node.Id] = $dictionary + foreach ($entry in $Node.Entries) { + $key = ConvertFrom-YamlNode -Node $entry.Key -Cache $Cache -AsHashtable:$AsHashtable + if ($null -eq $key) { + $key = [System.DBNull]::Value + } + $entryValue = if ($isSet) { + $null + } else { + ConvertFrom-YamlNode -Node $entry.Value -Cache $Cache -AsHashtable:$AsHashtable + } + $dictionary.Add($key, $entryValue) + } + Write-Output -InputObject $dictionary -NoEnumerate + return + } + + $result = [pscustomobject]@{} + $Cache[$Node.Id] = $result + $propertyNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + foreach ($entry in $Node.Entries) { + $key = ConvertFrom-YamlNode -Node $entry.Key -Cache $Cache + if ($key -isnot [string] -or [string]::IsNullOrEmpty($key)) { + throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End -ErrorId 'YamlMappingKeyNotString' -Message ( + 'This mapping key cannot be represented as a PSCustomObject property. Use -AsHashtable.' + )) + } + if (-not $propertyNames.Add($key)) { + throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End -ErrorId 'YamlPropertyNameCollision' -Message ( + "The mapping keys contain a case-insensitive property collision for '$key'. Use -AsHashtable." + )) + } + $entryValue = ConvertFrom-YamlNode -Node $entry.Value -Cache $Cache + $property = [System.Management.Automation.PSNoteProperty]::new($key, $entryValue) + $result.PSObject.Properties.Add($property) + } + Write-Output -InputObject $result -NoEnumerate +} diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 new file mode 100644 index 0000000..dd87c17 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -0,0 +1,296 @@ +function ConvertTo-YamlNode { + <# + .SYNOPSIS + Normalizes a supported PowerShell value to an emission node graph. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter(Mandatory)] + [ValidateRange(1, 1024)] + [int] $Depth, + + [switch] $EnumsAsStrings + ) + + if ($Depth -gt $State.MaxDepth) { + throw (New-YamlSerializationException -ErrorId 'YamlDepthExceeded' -Message ( + "The object graph exceeds the configured depth limit of $($State.MaxDepth)." + )) + } + + $State.NodeCount++ + if ($State.NodeCount -gt $State.MaxNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $($State.MaxNodes) nodes." + )) + } + + $scalar = New-YamlEmissionNode -Kind Scalar + if ($null -eq $Value -or $Value -is [System.DBNull]) { + $scalar.Value = 'null' + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::Plain + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + $dataProperties = @( + $Value.PSObject.Properties | + Where-Object { + $_.IsInstance -and + $_.MemberType -eq [System.Management.Automation.PSMemberTypes]::NoteProperty + } + ) + $unsupportedProperties = @( + $Value.PSObject.Properties | + Where-Object { + $_.IsInstance -and $_.MemberType -in @( + [System.Management.Automation.PSMemberTypes]::AliasProperty, + [System.Management.Automation.PSMemberTypes]::CodeProperty, + [System.Management.Automation.PSMemberTypes]::ScriptProperty + ) + } + ) + $isPropertyBag = $Value -is [System.Management.Automation.PSCustomObject] + $isPropertyBag = $isPropertyBag -or $dataProperties.Count -gt 0 + $isPropertyBag = $isPropertyBag -or $unsupportedProperties.Count -gt 0 + + if ($unsupportedProperties.Count -gt 0) { + throw (New-YamlSerializationException -Kind NotSupported -ErrorId 'YamlUnsupportedProperty' -Message ( + "Property '$($unsupportedProperties[0].Name)' is not a note property." + )) + } + + if (-not $isPropertyBag -and ($Value -is [string] -or $Value -is [char])) { + $scalar.Value = [string] $Value + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::DoubleQuoted + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and $Value -is [bool]) { + $scalar.Value = $Value.ToString().ToLowerInvariant() + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::Plain + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and $Value.GetType().IsEnum) { + if ($EnumsAsStrings) { + $scalar.Value = $Value.ToString() + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::DoubleQuoted + } else { + $underlyingType = [System.Enum]::GetUnderlyingType($Value.GetType()) + $numericValue = [System.Convert]::ChangeType( + $Value, + $underlyingType, + [System.Globalization.CultureInfo]::InvariantCulture + ) + $scalar.Value = ([System.IConvertible] $numericValue).ToString( + [System.Globalization.CultureInfo]::InvariantCulture + ) + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::Plain + } + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + $integerTypes = @( + [System.Byte], + [System.SByte], + [System.Int16], + [System.UInt16], + [System.Int32], + [System.UInt32], + [System.Int64], + [System.UInt64], + [System.Numerics.BigInteger] + ) + if (-not $isPropertyBag -and $Value.GetType() -in $integerTypes) { + if ($Value -is [System.Numerics.BigInteger]) { + $scalar.Value = $Value.ToString([System.Globalization.CultureInfo]::InvariantCulture) + } else { + $scalar.Value = ([System.IConvertible] $Value).ToString( + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::Plain + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and $Value -is [decimal]) { + $scalar.Value = $Value.ToString([System.Globalization.CultureInfo]::InvariantCulture) + if ($scalar.Value -notmatch '[\.eE]') { + $scalar.Value += '.0' + } + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::Plain + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and ($Value -is [double] -or $Value -is [single])) { + $number = [double] $Value + if ([double]::IsNaN($number)) { + $scalar.Value = '.nan' + } elseif ([double]::IsPositiveInfinity($number)) { + $scalar.Value = '.inf' + } elseif ([double]::IsNegativeInfinity($number)) { + $scalar.Value = '-.inf' + } else { + $scalar.Value = $number.ToString('R', [System.Globalization.CultureInfo]::InvariantCulture) + if ($scalar.Value -notmatch '[\.eE]') { + $scalar.Value += '.0' + } + } + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::Plain + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and $Value -is [datetimeoffset]) { + $scalar.Tag = 'tag:yaml.org,2002:timestamp' + $scalar.Value = $Value.ToString('o', [System.Globalization.CultureInfo]::InvariantCulture) + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::DoubleQuoted + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and $Value -is [datetime]) { + $scalar.Tag = 'tag:yaml.org,2002:timestamp' + if ($Value.Kind -eq [System.DateTimeKind]::Utc) { + $scalar.Value = $Value.ToUniversalTime().ToString( + "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", + [System.Globalization.CultureInfo]::InvariantCulture + ) + } elseif ($Value.Kind -eq [System.DateTimeKind]::Local) { + $scalar.Value = ([datetimeoffset] $Value).ToString( + 'o', + [System.Globalization.CultureInfo]::InvariantCulture + ) + } else { + $scalar.Value = $Value.ToString( + "yyyy-MM-dd'T'HH:mm:ss.fffffff", + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::DoubleQuoted + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + if (-not $isPropertyBag -and $Value -is [byte[]]) { + $scalar.Tag = 'tag:yaml.org,2002:binary' + $scalar.Value = [System.Convert]::ToBase64String($Value) + $scalar.Style = [YamlDotNet.Core.ScalarStyle]::DoubleQuoted + Confirm-YamlScalarLength -Node $scalar -State $State + Write-Output -InputObject $scalar -NoEnumerate + return + } + + $isDictionary = $Value -is [System.Collections.IDictionary] + $isCustomObject = $isPropertyBag + $isSequence = $Value -is [System.Collections.IEnumerable] + if (-not $isDictionary -and -not $isCustomObject -and -not $isSequence) { + throw (New-YamlSerializationException -Kind NotSupported -ErrorId 'YamlUnsupportedType' -Message ( + "Values of type '$($Value.GetType().FullName)' are not supported for YAML serialization." + )) + } + + $firstTime = $false + $referenceId = $State.IdGenerator.GetId($Value, [ref] $firstTime) + if (-not $firstTime) { + $State.ReferenceCounts[$referenceId]++ + if ($State.Active.Contains($referenceId)) { + throw (New-YamlSerializationException -ErrorId 'YamlCycleDetected' -Message ( + "A cycle was detected while serializing type '$($Value.GetType().FullName)'." + )) + } + Write-Output -InputObject $State.NodesById[$referenceId] -NoEnumerate + return + } + + $State.ReferenceCounts[$referenceId] = 1 + $State.ReferenceOrder.Add($referenceId) + [void] $State.Active.Add($referenceId) + + try { + if ($isDictionary -or $isCustomObject) { + $node = New-YamlEmissionNode -Kind Mapping + $node.ReferenceId = $referenceId + $State.NodesById[$referenceId] = $node + $keyFingerprints = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + + if ($isDictionary) { + foreach ($entry in $Value.GetEnumerator()) { + $keyNode = ConvertTo-YamlNode -Value ([object] $entry.Key) -State $State -Depth ($Depth + 1) ` + -EnumsAsStrings:$EnumsAsStrings + $keyFingerprint = Get-YamlEmissionNodeFingerprint -Node $keyNode -Cache $State.Fingerprints -Active ( + [System.Collections.Generic.HashSet[long]]::new() + ) -Hasher $State.FingerprintHasher + if (-not $keyFingerprints.Add($keyFingerprint)) { + throw (New-YamlSerializationException -ErrorId 'YamlDuplicateKey' -Message ( + 'Two mapping keys normalize to the same YAML value.' + )) + } + $valueNode = ConvertTo-YamlNode -Value ([object] $entry.Value) -State $State -Depth ($Depth + 1) ` + -EnumsAsStrings:$EnumsAsStrings + $node.Entries.Add([pscustomobject]@{ + Key = $keyNode + Value = $valueNode + }) + } + } else { + foreach ($property in $dataProperties) { + $keyNode = ConvertTo-YamlNode -Value ([object] $property.Name) -State $State -Depth ($Depth + 1) ` + -EnumsAsStrings:$EnumsAsStrings + $keyFingerprint = Get-YamlEmissionNodeFingerprint -Node $keyNode -Cache $State.Fingerprints -Active ( + [System.Collections.Generic.HashSet[long]]::new() + ) -Hasher $State.FingerprintHasher + if (-not $keyFingerprints.Add($keyFingerprint)) { + throw (New-YamlSerializationException -ErrorId 'YamlDuplicateKey' -Message ( + 'Two mapping keys normalize to the same YAML value.' + )) + } + $valueNode = ConvertTo-YamlNode -Value ([object] $property.Value) -State $State -Depth ($Depth + 1) ` + -EnumsAsStrings:$EnumsAsStrings + $node.Entries.Add([pscustomobject]@{ + Key = $keyNode + Value = $valueNode + }) + } + } + } else { + $node = New-YamlEmissionNode -Kind Sequence + $node.ReferenceId = $referenceId + $State.NodesById[$referenceId] = $node + foreach ($item in $Value) { + $itemNode = ConvertTo-YamlNode -Value ([object] $item) -State $State -Depth ($Depth + 1) ` + -EnumsAsStrings:$EnumsAsStrings + $node.Items.Add($itemNode) + } + } + } finally { + [void] $State.Active.Remove($referenceId) + } + + Write-Output -InputObject $node -NoEnumerate +} diff --git a/src/functions/private/ConvertTo-YamlParserCompatibleText.ps1 b/src/functions/private/ConvertTo-YamlParserCompatibleText.ps1 new file mode 100644 index 0000000..5124877 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlParserCompatibleText.ps1 @@ -0,0 +1,112 @@ +function ConvertTo-YamlParserCompatibleText { + <# + .SYNOPSIS + Folds valid multiline flow syntax rejected by the YamlDotNet parser. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml + ) + + $lines = [System.Text.RegularExpressions.Regex]::Split($Yaml, '\r?\n') + if ($lines.Count -le 1) { + return $Yaml + } + + $builder = [System.Text.StringBuilder]::new() + $flowIndents = [System.Collections.Generic.Stack[int]]::new() + $trimLeading = $false + $inSingleQuote = $false + $inDoubleQuote = $false + + for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { + $originalLine = $lines[$lineIndex] + $leadingLength = $originalLine.Length - $originalLine.TrimStart(' ').Length + $line = if ($trimLeading) { + $originalLine.TrimStart(' ') + } else { + $originalLine + } + $trimLeading = $false + $hasComment = $false + $previousSignificant = [char] 0 + + for ($characterIndex = 0; $characterIndex -lt $line.Length; $characterIndex++) { + $character = $line[$characterIndex] + + if ($inDoubleQuote) { + if ($character -eq '\') { + $characterIndex++ + } elseif ($character -eq '"') { + $inDoubleQuote = $false + } + continue + } + + if ($inSingleQuote) { + if ($character -eq "'") { + if ($characterIndex + 1 -lt $line.Length -and $line[$characterIndex + 1] -eq "'") { + $characterIndex++ + } else { + $inSingleQuote = $false + } + } + continue + } + + $commentStart = $characterIndex -eq 0 -or [char]::IsWhiteSpace($line[$characterIndex - 1]) + if ($character -eq '#' -and $commentStart) { + $hasComment = $true + break + } + if ($character -eq '"') { + $inDoubleQuote = $true + continue + } + if ($character -eq "'") { + $inSingleQuote = $true + continue + } + + if ($character -eq '{' -or $character -eq '[') { + $isCollectionStart = $previousSignificant -eq [char] 0 + $isCollectionStart = $isCollectionStart -or $previousSignificant -in @(':', '-', '?', ',', '[', '{') + if ($isCollectionStart) { + $flowIndents.Push($leadingLength) + } + } elseif (($character -eq '}' -or $character -eq ']') -and $flowIndents.Count -gt 0) { + [void] $flowIndents.Pop() + } + + if (-not [char]::IsWhiteSpace($character)) { + $previousSignificant = $character + } + } + + [void] $builder.Append($line) + if ($lineIndex -eq $lines.Count - 1) { + continue + } + + $nextLine = $lines[$lineIndex + 1] + $nextIndent = $nextLine.Length - $nextLine.TrimStart(' ').Length + $canFoldStructuralLine = $flowIndents.Count -gt 0 + $canFoldStructuralLine = $canFoldStructuralLine -and -not $hasComment + $canFoldStructuralLine = $canFoldStructuralLine -and -not $inSingleQuote + $canFoldStructuralLine = $canFoldStructuralLine -and -not $inDoubleQuote + $canFoldStructuralLine = $canFoldStructuralLine -and $nextLine.Trim().Length -gt 0 + $canFoldStructuralLine = $canFoldStructuralLine -and $nextIndent -gt $flowIndents.Peek() + + if ($canFoldStructuralLine) { + [void] $builder.Append(' ') + $trimLeading = $true + } else { + [void] $builder.Append("`n") + } + } + + return $builder.ToString() +} diff --git a/src/functions/private/ConvertTo-YamlText.ps1 b/src/functions/private/ConvertTo-YamlText.ps1 new file mode 100644 index 0000000..cf37efb --- /dev/null +++ b/src/functions/private/ConvertTo-YamlText.ps1 @@ -0,0 +1,51 @@ +function ConvertTo-YamlText { + <# + .SYNOPSIS + Emits an internal node graph as LF-normalized YAML text. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [ValidateRange(2, 9)] + [int] $Indent, + + [switch] $ExplicitDocumentStart + ) + + $writer = [System.IO.StringWriter]::new( + [System.Globalization.CultureInfo]::InvariantCulture + ) + try { + $settings = [YamlDotNet.Core.EmitterSettings]::new( + $Indent, + [int]::MaxValue, + $false, + 1024, + $false, + $false, + "`n", + $false + ) + $emitter = [YamlDotNet.Core.Emitter]::new($writer, $settings) + $emitter.Emit([YamlDotNet.Core.Events.StreamStart]::new()) + $emitter.Emit( + [YamlDotNet.Core.Events.DocumentStart]::new( + $null, + $null, + -not $ExplicitDocumentStart + ) + ) + Write-YamlNodeEvent -Emitter $emitter -Node $Node -EmittedReferences ( + [System.Collections.Generic.HashSet[long]]::new() + ) + $emitter.Emit([YamlDotNet.Core.Events.DocumentEnd]::new($true)) + $emitter.Emit([YamlDotNet.Core.Events.StreamEnd]::new()) + return $writer.ToString() + } finally { + $writer.Dispose() + } +} diff --git a/src/functions/private/Get-YamlEmissionNodeFingerprint.ps1 b/src/functions/private/Get-YamlEmissionNodeFingerprint.ps1 new file mode 100644 index 0000000..50a3655 --- /dev/null +++ b/src/functions/private/Get-YamlEmissionNodeFingerprint.ps1 @@ -0,0 +1,79 @@ +function Get-YamlEmissionNodeFingerprint { + <# + .SYNOPSIS + Creates a structural fingerprint for a normalized emission node. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[long, string]] $Cache, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[long]] $Active, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $Hasher + ) + + $hasReferenceId = $Node.ReferenceId -ne 0 + $cachedFingerprint = '' + if ($hasReferenceId -and $Cache.TryGetValue($Node.ReferenceId, [ref] $cachedFingerprint)) { + return $cachedFingerprint + } + if ($hasReferenceId -and -not $Active.Add($Node.ReferenceId)) { + throw (New-YamlSerializationException -ErrorId 'YamlCycleDetected' -Message ( + 'A cycle was detected while fingerprinting a YAML mapping key.' + )) + } + + try { + if ($Node.Kind -eq 'Scalar') { + $isPlainImplicit = [string]::IsNullOrEmpty($Node.Tag) + $isPlainImplicit = $isPlainImplicit -and $Node.Style -eq [YamlDotNet.Core.ScalarStyle]::Plain + $resolved = Resolve-YamlScalar -Node ([pscustomobject]@{ + Tag = $Node.Tag + Value = $Node.Value + IsPlainImplicit = $isPlainImplicit + Start = [YamlDotNet.Core.Mark]::Empty + End = [YamlDotNet.Core.Mark]::Empty + }) + $fingerprint = Get-YamlScalarFingerprint -Value $resolved -Hasher $Hasher + } elseif ($Node.Kind -eq 'Sequence') { + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($item in $Node.Items) { + $itemFingerprint = Get-YamlEmissionNodeFingerprint -Node $item -Cache $Cache -Active $Active ` + -Hasher $Hasher + $parts.Add($itemFingerprint) + } + $fingerprint = Get-YamlFingerprintHash -Value ('sequence:{0}' -f ($parts -join '|')) -Hasher $Hasher + } else { + $entries = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $Node.Entries) { + $keyFingerprint = Get-YamlEmissionNodeFingerprint -Node $entry.Key -Cache $Cache -Active $Active ` + -Hasher $Hasher + $valueFingerprint = Get-YamlEmissionNodeFingerprint -Node $entry.Value -Cache $Cache -Active $Active ` + -Hasher $Hasher + $entryFingerprint = Get-YamlFingerprintHash -Value "entry:$keyFingerprint=$valueFingerprint" ` + -Hasher $Hasher + $entries.Add($entryFingerprint) + } + $entries.Sort([System.StringComparer]::Ordinal) + $fingerprint = Get-YamlFingerprintHash -Value ('mapping:{0}' -f ($entries -join '|')) -Hasher $Hasher + } + + if ($hasReferenceId) { + $Cache[$Node.ReferenceId] = $fingerprint + } + return $fingerprint + } finally { + if ($hasReferenceId) { + [void] $Active.Remove($Node.ReferenceId) + } + } +} diff --git a/src/functions/private/Get-YamlFingerprintHash.ps1 b/src/functions/private/Get-YamlFingerprintHash.ps1 new file mode 100644 index 0000000..fbdbf30 --- /dev/null +++ b/src/functions/private/Get-YamlFingerprintHash.ps1 @@ -0,0 +1,19 @@ +function Get-YamlFingerprintHash { + <# + .SYNOPSIS + Hashes canonical YAML fingerprint input to a fixed-size digest. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $Hasher + ) + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) + return [System.Convert]::ToBase64String($Hasher.ComputeHash($bytes)) +} diff --git a/src/functions/private/Get-YamlNodeFingerprint.ps1 b/src/functions/private/Get-YamlNodeFingerprint.ps1 new file mode 100644 index 0000000..178eadb --- /dev/null +++ b/src/functions/private/Get-YamlNodeFingerprint.ps1 @@ -0,0 +1,78 @@ +function Get-YamlNodeFingerprint { + <# + .SYNOPSIS + Creates a structural fingerprint for YAML duplicate-key detection. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[int]] $Active, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, string]] $Cache, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $Hasher + ) + + if ($Node.Kind -eq 'Alias') { + return Get-YamlNodeFingerprint -Node $Node.Target -Active $Active -Cache $Cache -Hasher $Hasher + } + + $cachedFingerprint = '' + if ($Cache.TryGetValue($Node.Id, [ref] $cachedFingerprint)) { + return $cachedFingerprint + } + + if (-not $Active.Add($Node.Id)) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlCyclicMappingKey' -Message ( + 'A cyclic YAML node cannot be used as a mapping key.' + )) + } + + try { + if ($Node.Kind -eq 'Scalar') { + $resolved = Resolve-YamlScalar -Node $Node + $fingerprint = Get-YamlScalarFingerprint -Value $resolved -Hasher $Hasher + } elseif ($Node.Kind -eq 'Sequence') { + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($item in $Node.Items) { + $itemFingerprint = Get-YamlNodeFingerprint -Node $item -Active $Active -Cache $Cache -Hasher $Hasher + $parts.Add($itemFingerprint) + } + $semanticTag = if ($Node.Tag -in @( + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs' + )) { $Node.Tag } else { 'tag:yaml.org,2002:seq' } + $canonicalValue = 'sequence:{0}:{1}' -f $semanticTag, ($parts -join '|') + $fingerprint = Get-YamlFingerprintHash -Value $canonicalValue -Hasher $Hasher + } else { + $entries = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $Node.Entries) { + $key = Get-YamlNodeFingerprint -Node $entry.Key -Active $Active -Cache $Cache -Hasher $Hasher + $entryValue = Get-YamlNodeFingerprint -Node $entry.Value -Active $Active -Cache $Cache -Hasher $Hasher + $entryFingerprint = Get-YamlFingerprintHash -Value "entry:$key=$entryValue" -Hasher $Hasher + $entries.Add($entryFingerprint) + } + $entries.Sort([System.StringComparer]::Ordinal) + $mappingTag = if ($Node.Tag -eq 'tag:yaml.org,2002:set') { + $Node.Tag + } else { + 'tag:yaml.org,2002:map' + } + $canonicalValue = 'mapping:{0}:{1}' -f $mappingTag, ($entries -join '|') + $fingerprint = Get-YamlFingerprintHash -Value $canonicalValue -Hasher $Hasher + } + + $Cache[$Node.Id] = $fingerprint + return $fingerprint + } finally { + [void] $Active.Remove($Node.Id) + } +} diff --git a/src/functions/private/Get-YamlScalarFingerprint.ps1 b/src/functions/private/Get-YamlScalarFingerprint.ps1 new file mode 100644 index 0000000..0eb73ab --- /dev/null +++ b/src/functions/private/Get-YamlScalarFingerprint.ps1 @@ -0,0 +1,46 @@ +function Get-YamlScalarFingerprint { + <# + .SYNOPSIS + Creates a canonical fingerprint for a resolved YAML scalar. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $Hasher + ) + + if ($null -eq $Value) { + $canonicalValue = 'null' + } elseif ($Value -is [string]) { + $canonicalValue = 'str:{0}:{1}' -f $Value.Length, $Value + } elseif ($Value -is [bool]) { + $canonicalValue = 'bool:{0}' -f $Value.ToString().ToLowerInvariant() + } elseif ($Value -is [byte[]]) { + $canonicalValue = 'binary:{0}' -f [System.Convert]::ToBase64String($Value) + } elseif ($Value -is [datetimeoffset]) { + $canonicalValue = 'timestamp-offset:{0}' -f $Value.UtcDateTime.Ticks + } elseif ($Value -is [datetime]) { + $canonicalValue = 'timestamp:{0}' -f $Value.Ticks + } elseif ($Value -is [double]) { + if ([double]::IsNaN($Value)) { + $canonicalValue = 'float:nan' + } elseif ([double]::IsPositiveInfinity($Value)) { + $canonicalValue = 'float:+inf' + } elseif ([double]::IsNegativeInfinity($Value)) { + $canonicalValue = 'float:-inf' + } elseif ($Value -eq 0) { + $canonicalValue = 'float:0' + } else { + $canonicalValue = 'float:{0}' -f $Value.ToString('R', [cultureinfo]::InvariantCulture) + } + } else { + $canonicalValue = 'int:{0}' -f $Value.ToString([cultureinfo]::InvariantCulture) + } + + return Get-YamlFingerprintHash -Value $canonicalValue -Hasher $Hasher +} diff --git a/src/functions/private/New-YamlEmissionNode.ps1 b/src/functions/private/New-YamlEmissionNode.ps1 new file mode 100644 index 0000000..bf155be --- /dev/null +++ b/src/functions/private/New-YamlEmissionNode.ps1 @@ -0,0 +1,30 @@ +function New-YamlEmissionNode { + <# + .SYNOPSIS + Creates an internal YAML emission node. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory representation node without changing system state.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [ValidateSet('Mapping', 'Scalar', 'Sequence')] + [string] $Kind + ) + + $node = [pscustomobject]@{ + PSTypeName = 'PSModule.Yaml.EmissionNode' + Kind = $Kind + Tag = '' + Value = '' + Style = [YamlDotNet.Core.ScalarStyle]::Any + Items = [System.Collections.Generic.List[object]]::new() + Entries = [System.Collections.Generic.List[object]]::new() + ReferenceId = [long] 0 + Anchor = '' + } + Write-Output -InputObject $node -NoEnumerate +} diff --git a/src/functions/private/New-YamlErrorRecord.ps1 b/src/functions/private/New-YamlErrorRecord.ps1 new file mode 100644 index 0000000..4f3f57c --- /dev/null +++ b/src/functions/private/New-YamlErrorRecord.ps1 @@ -0,0 +1,38 @@ +function New-YamlErrorRecord { + <# + .SYNOPSIS + Creates a public error record from an internal YAML exception. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory error record without changing system state.' + )] + [CmdletBinding()] + [OutputType([System.Management.Automation.ErrorRecord])] + param ( + [Parameter(Mandatory)] + [System.Exception] $Exception, + + [Parameter(Mandatory)] + [string] $DefaultErrorId, + + [Parameter(Mandatory)] + [System.Management.Automation.ErrorCategory] $Category, + + [AllowNull()] + [object] $TargetObject + ) + + $errorId = $DefaultErrorId + if ($Exception.Data.Contains('YamlErrorId')) { + $errorId = [string] $Exception.Data['YamlErrorId'] + } + + $record = [System.Management.Automation.ErrorRecord]::new( + $Exception, + $errorId, + $Category, + $TargetObject + ) + Write-Output -InputObject $record -NoEnumerate +} diff --git a/src/functions/private/New-YamlException.ps1 b/src/functions/private/New-YamlException.ps1 new file mode 100644 index 0000000..8c17a5c --- /dev/null +++ b/src/functions/private/New-YamlException.ps1 @@ -0,0 +1,34 @@ +function New-YamlException { + <# + .SYNOPSIS + Creates a location-aware YAML validation exception. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory exception without changing system state.' + )] + [CmdletBinding()] + [OutputType([YamlDotNet.Core.YamlException])] + param ( + [Parameter(Mandatory)] + [YamlDotNet.Core.Mark] $Start, + + [Parameter(Mandatory)] + [YamlDotNet.Core.Mark] $End, + + [Parameter(Mandatory)] + [string] $Message, + + [Parameter(Mandatory)] + [string] $ErrorId + ) + + $formattedMessage = '{0} Start: {1}. End: {2}.' -f @( + $Message.TrimEnd('.'), + $Start, + $End + ) + $exception = [YamlDotNet.Core.YamlException]::new($formattedMessage) + $exception.Data['YamlErrorId'] = $ErrorId + Write-Output -InputObject $exception -NoEnumerate +} diff --git a/src/functions/private/New-YamlNode.ps1 b/src/functions/private/New-YamlNode.ps1 new file mode 100644 index 0000000..37b837e --- /dev/null +++ b/src/functions/private/New-YamlNode.ps1 @@ -0,0 +1,44 @@ +function New-YamlNode { + <# + .SYNOPSIS + Creates an internal YAML representation node. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory representation node without changing system state.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [int] $Id, + + [Parameter(Mandatory)] + [ValidateSet('Alias', 'Mapping', 'Scalar', 'Sequence')] + [string] $Kind, + + [Parameter(Mandatory)] + [YamlDotNet.Core.Mark] $Start, + + [Parameter(Mandatory)] + [YamlDotNet.Core.Mark] $End + ) + + $node = [pscustomobject]@{ + PSTypeName = 'PSModule.Yaml.InternalNode' + Id = $Id + Kind = $Kind + Tag = '' + Anchor = '' + Value = $null + Style = $null + IsPlainImplicit = $false + IsQuotedImplicit = $false + Items = [System.Collections.Generic.List[object]]::new() + Entries = [System.Collections.Generic.List[object]]::new() + Target = $null + Start = $Start + End = $End + } + Write-Output -InputObject $node -NoEnumerate +} diff --git a/src/functions/private/New-YamlSerializationException.ps1 b/src/functions/private/New-YamlSerializationException.ps1 new file mode 100644 index 0000000..69c39c8 --- /dev/null +++ b/src/functions/private/New-YamlSerializationException.ps1 @@ -0,0 +1,30 @@ +function New-YamlSerializationException { + <# + .SYNOPSIS + Creates a typed YAML serialization exception. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory exception without changing system state.' + )] + [CmdletBinding()] + [OutputType([System.Exception])] + param ( + [Parameter(Mandatory)] + [string] $Message, + + [Parameter(Mandatory)] + [string] $ErrorId, + + [ValidateSet('InvalidOperation', 'NotSupported')] + [string] $Kind = 'InvalidOperation' + ) + + $exception = if ($Kind -eq 'NotSupported') { + [System.NotSupportedException]::new($Message) + } else { + [System.InvalidOperationException]::new($Message) + } + $exception.Data['YamlErrorId'] = $ErrorId + Write-Output -InputObject $exception -NoEnumerate +} diff --git a/src/functions/private/Read-YamlNode.ps1 b/src/functions/private/Read-YamlNode.ps1 new file mode 100644 index 0000000..902d2e1 --- /dev/null +++ b/src/functions/private/Read-YamlNode.ps1 @@ -0,0 +1,144 @@ +function Read-YamlNode { + <# + .SYNOPSIS + Reads one representation node from the low-level YAML event stream. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [YamlDotNet.Core.Parser] $Parser, + + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [ValidateRange(1, 1024)] + [int] $Depth + ) + + $parserEvent = $Parser.Current + if ($null -eq $parserEvent) { + throw (New-YamlException -Start ([YamlDotNet.Core.Mark]::Empty) -End ([YamlDotNet.Core.Mark]::Empty) -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended while a node was expected.' + )) + } + if ($Depth -gt $Context.MaxDepth) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlDepthExceeded' -Message ( + "The YAML nesting depth exceeds the configured limit of $($Context.MaxDepth)." + )) + } + + $Context.NodeCount++ + if ($Context.NodeCount -gt $Context.MaxNodes) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The YAML stream exceeds the configured limit of $($Context.MaxNodes) nodes." + )) + } + + $id = $Context.NextId + $Context.NextId++ + + if ($parserEvent -is [YamlDotNet.Core.Events.AnchorAlias]) { + $Context.AliasCount++ + if ($Context.AliasCount -gt $Context.MaxAliases) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlAliasLimitExceeded' -Message ( + "The YAML stream exceeds the configured limit of $($Context.MaxAliases) aliases." + )) + } + $anchorName = $parserEvent.Value.Value + if (-not $Context.Anchors.ContainsKey($anchorName)) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlUndefinedAlias' -Message ( + "The YAML alias '*$anchorName' does not refer to a preceding anchor." + )) + } + $node = New-YamlNode -Id $id -Kind Alias -Start $parserEvent.Start -End $parserEvent.End + $node.Target = $Context.Anchors[$anchorName] + [void] $Parser.MoveNext() + Write-Output -InputObject $node -NoEnumerate + return + } + + if ($parserEvent -is [YamlDotNet.Core.Events.Scalar]) { + if ($parserEvent.Value.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + $node = New-YamlNode -Id $id -Kind Scalar -Start $parserEvent.Start -End $parserEvent.End + $node.Tag = $parserEvent.Tag.Value + $node.Anchor = $parserEvent.Anchor.Value + $node.Value = $parserEvent.Value + $node.Style = $parserEvent.Style + $node.IsPlainImplicit = $parserEvent.IsPlainImplicit + $node.IsQuotedImplicit = $parserEvent.IsQuotedImplicit + if (-not [string]::IsNullOrEmpty($node.Anchor)) { + $Context.Anchors[$node.Anchor] = $node + } + [void] $Parser.MoveNext() + Write-Output -InputObject $node -NoEnumerate + return + } + + if ($parserEvent -is [YamlDotNet.Core.Events.SequenceStart]) { + $node = New-YamlNode -Id $id -Kind Sequence -Start $parserEvent.Start -End $parserEvent.End + $node.Tag = $parserEvent.Tag.Value + $node.Anchor = $parserEvent.Anchor.Value + if (-not [string]::IsNullOrEmpty($node.Anchor)) { + $Context.Anchors[$node.Anchor] = $node + } + if (-not $Parser.MoveNext()) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended inside a sequence.' + )) + } + while ($Parser.Current -isnot [YamlDotNet.Core.Events.SequenceEnd]) { + $item = Read-YamlNode -Parser $Parser -Context $Context -Depth ($Depth + 1) + $node.Items.Add($item) + if ($null -eq $Parser.Current) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended inside a sequence.' + )) + } + } + $node.End = $Parser.Current.End + [void] $Parser.MoveNext() + Write-Output -InputObject $node -NoEnumerate + return + } + + if ($parserEvent -is [YamlDotNet.Core.Events.MappingStart]) { + $node = New-YamlNode -Id $id -Kind Mapping -Start $parserEvent.Start -End $parserEvent.End + $node.Tag = $parserEvent.Tag.Value + $node.Anchor = $parserEvent.Anchor.Value + if (-not [string]::IsNullOrEmpty($node.Anchor)) { + $Context.Anchors[$node.Anchor] = $node + } + if (-not $Parser.MoveNext()) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended inside a mapping.' + )) + } + while ($Parser.Current -isnot [YamlDotNet.Core.Events.MappingEnd]) { + $key = Read-YamlNode -Parser $Parser -Context $Context -Depth ($Depth + 1) + $entryValue = Read-YamlNode -Parser $Parser -Context $Context -Depth ($Depth + 1) + $node.Entries.Add([pscustomobject]@{ + Key = $key + Value = $entryValue + }) + if ($null -eq $Parser.Current) { + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended inside a mapping.' + )) + } + } + $node.End = $Parser.Current.End + [void] $Parser.MoveNext() + Write-Output -InputObject $node -NoEnumerate + return + } + + throw (New-YamlException -Start $parserEvent.Start -End $parserEvent.End -ErrorId 'YamlUnexpectedEvent' -Message ( + "Unexpected YAML parser event '$($parserEvent.GetType().Name)'." + )) +} diff --git a/src/functions/private/Read-YamlStream.ps1 b/src/functions/private/Read-YamlStream.ps1 new file mode 100644 index 0000000..71f709d --- /dev/null +++ b/src/functions/private/Read-YamlStream.ps1 @@ -0,0 +1,64 @@ +function Read-YamlStream { + <# + .SYNOPSIS + Parses YAML text with a narrow parser-compatibility retry. + #> + [CmdletBinding()] + [OutputType([object[]])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml, + + [Parameter(Mandatory)] + [ValidateRange(1, 1024)] + [int] $Depth, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength + ) + + try { + Write-Output -InputObject ( + Read-YamlStreamCore -Yaml $Yaml -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength + ) -NoEnumerate + } catch [YamlDotNet.Core.SemanticErrorException] { + $compatibleYaml = ConvertTo-YamlParserCompatibleText -Yaml $Yaml + if ($compatibleYaml -ceq $Yaml) { + throw + } + Write-Output -InputObject ( + Read-YamlStreamCore -Yaml $compatibleYaml -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength + ) -NoEnumerate + } catch [System.Management.Automation.MethodInvocationException] { + if ($_.Exception.InnerException -is [YamlDotNet.Core.SemanticErrorException]) { + $compatibleYaml = ConvertTo-YamlParserCompatibleText -Yaml $Yaml + if ($compatibleYaml -ceq $Yaml) { + throw $_.Exception.InnerException + } + Write-Output -InputObject ( + Read-YamlStreamCore -Yaml $compatibleYaml -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength + ) -NoEnumerate + return + } + if ($_.Exception.InnerException -isnot [System.InvalidOperationException]) { + throw + } + throw (New-YamlException -Start ([YamlDotNet.Core.Mark]::Empty) ` + -End ([YamlDotNet.Core.Mark]::Empty) -ErrorId 'YamlInvalidSyntax' -Message ( + 'YamlDotNet entered an invalid parser state while reading malformed YAML.' + )) + } +} diff --git a/src/functions/private/Read-YamlStreamCore.ps1 b/src/functions/private/Read-YamlStreamCore.ps1 new file mode 100644 index 0000000..3c5e1a4 --- /dev/null +++ b/src/functions/private/Read-YamlStreamCore.ps1 @@ -0,0 +1,90 @@ +function Read-YamlStreamCore { + <# + .SYNOPSIS + Reads and validates all documents in a YAML stream. + #> + [CmdletBinding()] + [OutputType([object[]])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml, + + [Parameter(Mandatory)] + [ValidateRange(1, 1024)] + [int] $Depth, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength + ) + + $parser = [YamlDotNet.Core.Parser]::new([System.IO.StringReader]::new($Yaml)) + $documents = [System.Collections.Generic.List[object]]::new() + $context = [pscustomobject]@{ + NextId = 1 + NodeCount = 0 + AliasCount = 0 + MaxDepth = $Depth + MaxNodes = $MaxNodes + MaxAliases = $MaxAliases + MaxScalarLength = $MaxScalarLength + Anchors = $null + } + + if (-not $parser.MoveNext() -or $parser.Current -isnot [YamlDotNet.Core.Events.StreamStart]) { + throw (New-YamlException -Start ([YamlDotNet.Core.Mark]::Empty) -End ([YamlDotNet.Core.Mark]::Empty) -ErrorId 'YamlInvalidStream' -Message ( + 'The input is not a valid YAML stream.' + )) + } + [void] $parser.MoveNext() + + while ($parser.Current -is [YamlDotNet.Core.Events.DocumentStart]) { + $context.Anchors = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + if (-not $parser.MoveNext()) { + $emptyMark = [YamlDotNet.Core.Mark]::Empty + $exception = New-YamlException -Start $emptyMark -End $emptyMark -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended after a document start.' + ) + throw $exception + } + + $document = Read-YamlNode -Parser $parser -Context $context -Depth 1 + if ($parser.Current -isnot [YamlDotNet.Core.Events.DocumentEnd]) { + $mark = if ($null -eq $parser.Current) { [YamlDotNet.Core.Mark]::Empty } else { $parser.Current.Start } + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidDocument' -Message ( + 'The YAML document did not end where expected.' + )) + } + + $fingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + try { + Test-YamlNodeGraph -Node $document -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache ([System.Collections.Generic.Dictionary[int, string]]::new()) ` + -FingerprintHasher $fingerprintHasher + } finally { + $fingerprintHasher.Dispose() + } + $documents.Add($document) + [void] $parser.MoveNext() + } + + if ($parser.Current -isnot [YamlDotNet.Core.Events.StreamEnd]) { + $mark = if ($null -eq $parser.Current) { [YamlDotNet.Core.Mark]::Empty } else { $parser.Current.Start } + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidStream' -Message ( + 'The YAML stream contains unexpected content.' + )) + } + + Write-Output -InputObject ([object[]] $documents.ToArray()) -NoEnumerate +} diff --git a/src/functions/private/Resolve-YamlScalar.ps1 b/src/functions/private/Resolve-YamlScalar.ps1 new file mode 100644 index 0000000..68ef51c --- /dev/null +++ b/src/functions/private/Resolve-YamlScalar.ps1 @@ -0,0 +1,159 @@ +function Resolve-YamlScalar { + <# + .SYNOPSIS + Constructs a safe PowerShell scalar using YAML 1.2 schema rules. + #> + [CmdletBinding()] + [OutputType([object])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + $tagPrefix = 'tag:yaml.org,2002:' + $tag = [string] $Node.Tag + $value = [string] $Node.Value + $isImplicit = [string]::IsNullOrEmpty($tag) -and $Node.IsPlainImplicit + + if (-not $isImplicit -and -not $tag.StartsWith($tagPrefix, [System.StringComparison]::Ordinal)) { + Write-Output -InputObject $value -NoEnumerate + return + } + + $standardTag = if ($isImplicit) { '' } else { $tag.Substring($tagPrefix.Length) } + $nullPattern = '^(?:|~|null|Null|NULL)$' + $booleanPattern = '^(?:true|True|TRUE|false|False|FALSE)$' + $integerPattern = '^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$' + $numberPattern = '^[-+]?(?:(?:\.[0-9]+)|(?:[0-9]+(?:\.[0-9]*)?))(?:[eE][-+]?[0-9]+)?$' + $infinityPattern = '^[-+]?\.(?:inf|Inf|INF)$' + $nanPattern = '^\.(?:nan|NaN|NAN)$' + + if ($standardTag -eq 'str') { + Write-Output -InputObject $value -NoEnumerate + return + } + + if (($standardTag -eq 'null' -or $isImplicit) -and $value -cmatch $nullPattern) { + return + } + + if (($standardTag -eq 'bool' -or $isImplicit) -and $value -cmatch $booleanPattern) { + Write-Output -InputObject ($value[0] -ceq 't' -or $value[0] -ceq 'T') -NoEnumerate + return + } + + if (($standardTag -eq 'int' -or $isImplicit) -and $value -cmatch $integerPattern) { + Write-Output -InputObject (ConvertFrom-YamlInteger -Value $value) -NoEnumerate + return + } + + if (($standardTag -eq 'float' -or $isImplicit) -and $value -cmatch $infinityPattern) { + $infinity = if ($value.StartsWith('-', [System.StringComparison]::Ordinal)) { + [double]::NegativeInfinity + } else { + [double]::PositiveInfinity + } + Write-Output -InputObject $infinity -NoEnumerate + return + } + + if (($standardTag -eq 'float' -or $isImplicit) -and $value -cmatch $nanPattern) { + Write-Output -InputObject ([double]::NaN) -NoEnumerate + return + } + + if (($standardTag -eq 'float' -or $isImplicit) -and $value -cmatch $numberPattern) { + $number = [double] 0 + $parsed = [double]::TryParse( + $value, + [System.Globalization.NumberStyles]::Float, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $number + ) + if (-not $parsed -or [double]::IsInfinity($number)) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlFloatOverflow' -Message ( + "The finite YAML floating-point value '$value' is outside the supported range." + )) + } + Write-Output -InputObject $number -NoEnumerate + return + } + + if ($standardTag -eq 'binary') { + try { + $bytes = [System.Convert]::FromBase64String(($value -replace '\s', '')) + } catch [System.FormatException] { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlInvalidBinary' -Message ( + "The value for tag 'tag:yaml.org,2002:binary' is not valid Base64." + )) + } + Write-Output -InputObject $bytes -NoEnumerate + return + } + + if ($standardTag -eq 'timestamp') { + $timestampPattern = '^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[ \t]*(?:[Zz]|[-+]\d{1,2}(?::?\d{2})?))?)?$' + if ($value -cnotmatch $timestampPattern) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlInvalidTimestamp' -Message ( + "The value '$value' is not a supported YAML timestamp." + )) + } + + $date = [datetime]::MinValue + if ([datetime]::TryParseExact( + $value, + 'yyyy-MM-dd', + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::None, + [ref] $date + )) { + Write-Output -InputObject ([datetime]::SpecifyKind($date, [System.DateTimeKind]::Unspecified)) -NoEnumerate + return + } + + $normalized = $value -replace '\s+([+-]\d{1,2}(?::?\d{2})?)$', '$1' + if ($normalized -match '([+-])(\d{1,2})$') { + $offsetSuffix = '{0}{1}:00' -f $Matches[1], $Matches[2].PadLeft(2, '0') + $normalized = $normalized.Substring(0, $normalized.Length - $Matches[0].Length) + $offsetSuffix + } elseif ($normalized -match '([+-])(\d{2})(\d{2})$') { + $offsetSuffix = '{0}{1}:{2}' -f $Matches[1], $Matches[2], $Matches[3] + $normalized = $normalized.Substring(0, $normalized.Length - $Matches[0].Length) + $offsetSuffix + } + + if ($normalized -cmatch '(?:[Zz]|[-+]\d{2}:\d{2})$') { + $offset = [datetimeoffset]::MinValue + if ([datetimeoffset]::TryParse( + $normalized, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AllowWhiteSpaces, + [ref] $offset + )) { + Write-Output -InputObject $offset -NoEnumerate + return + } + } else { + $dateTime = [datetime]::MinValue + if ([datetime]::TryParse( + $normalized, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AllowWhiteSpaces, + [ref] $dateTime + )) { + Write-Output -InputObject ([datetime]::SpecifyKind($dateTime, [System.DateTimeKind]::Unspecified)) -NoEnumerate + return + } + } + + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlInvalidTimestamp' -Message ( + "The value '$value' is not a supported YAML timestamp." + )) + } + + if (-not $isImplicit -and $standardTag -in @('null', 'bool', 'int', 'float')) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlInvalidTaggedScalar' -Message ( + "The value '$value' is invalid for YAML tag '$tag'." + )) + } + + Write-Output -InputObject $value -NoEnumerate +} diff --git a/src/functions/private/Set-YamlNodeAnchor.ps1 b/src/functions/private/Set-YamlNodeAnchor.ps1 new file mode 100644 index 0000000..1e562e5 --- /dev/null +++ b/src/functions/private/Set-YamlNodeAnchor.ps1 @@ -0,0 +1,23 @@ +function Set-YamlNodeAnchor { + <# + .SYNOPSIS + Assigns deterministic anchors to repeated emission nodes. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Assigns anchors within a private in-memory representation graph.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $anchorNumber = 1 + foreach ($referenceId in $State.ReferenceOrder) { + if ($State.ReferenceCounts[$referenceId] -gt 1) { + $State.NodesById[$referenceId].Anchor = 'id{0:d3}' -f $anchorNumber + $anchorNumber++ + } + } +} diff --git a/src/functions/private/Test-YamlNodeGraph.ps1 b/src/functions/private/Test-YamlNodeGraph.ps1 new file mode 100644 index 0000000..2672e5e --- /dev/null +++ b/src/functions/private/Test-YamlNodeGraph.ps1 @@ -0,0 +1,142 @@ +function Test-YamlNodeGraph { + <# + .SYNOPSIS + Validates tags and mapping-key uniqueness in a YAML node graph. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[int]] $Visited, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, string]] $FingerprintCache, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher + ) + + if ($Node.Kind -eq 'Alias') { + Test-YamlNodeGraph -Node $Node.Target -Visited $Visited -FingerprintCache $FingerprintCache ` + -FingerprintHasher $FingerprintHasher + return + } + if (-not $Visited.Add($Node.Id)) { + return + } + + $scalarTags = @( + 'tag:yaml.org,2002:binary', + 'tag:yaml.org,2002:bool', + 'tag:yaml.org,2002:float', + 'tag:yaml.org,2002:int', + 'tag:yaml.org,2002:null', + 'tag:yaml.org,2002:str', + 'tag:yaml.org,2002:timestamp' + ) + $tag = [string] $Node.Tag + + if ($Node.Kind -eq 'Scalar') { + if ($tag -in @( + 'tag:yaml.org,2002:map', + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs', + 'tag:yaml.org,2002:seq', + 'tag:yaml.org,2002:set' + )) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' cannot be applied to a scalar node." + )) + } + $null = Resolve-YamlScalar -Node $Node + return + } + + if ($tag -in $scalarTags) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' cannot be applied to a $($Node.Kind.ToLowerInvariant()) node." + )) + } + + if ($Node.Kind -eq 'Sequence') { + if ($tag -in @('tag:yaml.org,2002:map', 'tag:yaml.org,2002:set')) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' requires a mapping node." + )) + } + + $isPairs = $tag -eq 'tag:yaml.org,2002:pairs' + $isOrderedMap = $tag -eq 'tag:yaml.org,2002:omap' + $orderedKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($item in $Node.Items) { + Test-YamlNodeGraph -Node $item -Visited $Visited -FingerprintCache $FingerprintCache ` + -FingerprintHasher $FingerprintHasher + if ($isPairs -or $isOrderedMap) { + $entryNode = $item + while ($entryNode.Kind -eq 'Alias') { + $entryNode = $entryNode.Target + } + if ($entryNode.Kind -ne 'Mapping' -or $entryNode.Entries.Count -ne 1) { + throw (New-YamlException -Start $item.Start -End $item.End -ErrorId 'YamlInvalidTaggedCollection' -Message ( + "YAML tag '$tag' requires a sequence of one-entry mappings." + )) + } + if ($isOrderedMap) { + $keyFingerprint = Get-YamlNodeFingerprint -Node $entryNode.Entries[0].Key -Active ( + [System.Collections.Generic.HashSet[int]]::new() + ) -Cache $FingerprintCache -Hasher $FingerprintHasher + if (-not $orderedKeys.Add($keyFingerprint)) { + $keyNode = $entryNode.Entries[0].Key + $exception = New-YamlException -Start $keyNode.Start -End $keyNode.End ` + -ErrorId 'YamlDuplicateKey' -Message 'A duplicate key was found in a YAML ordered mapping.' + throw $exception + } + } + } + } + return + } + + if ($tag -in @( + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs', + 'tag:yaml.org,2002:seq' + )) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' requires a sequence node." + )) + } + + $keys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($entry in $Node.Entries) { + $fingerprint = Get-YamlNodeFingerprint -Node $entry.Key -Active ( + [System.Collections.Generic.HashSet[int]]::new() + ) -Cache $FingerprintCache -Hasher $FingerprintHasher + if (-not $keys.Add($fingerprint)) { + throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End -ErrorId 'YamlDuplicateKey' -Message ( + 'A duplicate mapping key is not allowed.' + )) + } + + Test-YamlNodeGraph -Node $entry.Key -Visited $Visited -FingerprintCache $FingerprintCache ` + -FingerprintHasher $FingerprintHasher + Test-YamlNodeGraph -Node $entry.Value -Visited $Visited -FingerprintCache $FingerprintCache ` + -FingerprintHasher $FingerprintHasher + + if ($tag -eq 'tag:yaml.org,2002:set') { + $setValue = $entry.Value + while ($setValue.Kind -eq 'Alias') { + $setValue = $setValue.Target + } + if ($setValue.Kind -ne 'Scalar' -or $null -ne (Resolve-YamlScalar -Node $setValue)) { + throw (New-YamlException -Start $entry.Value.Start -End $entry.Value.End -ErrorId 'YamlInvalidTaggedCollection' -Message ( + 'Every value in a YAML set must be null.' + )) + } + } + } +} diff --git a/src/functions/private/Write-YamlNodeEvent.ps1 b/src/functions/private/Write-YamlNodeEvent.ps1 new file mode 100644 index 0000000..ae5a150 --- /dev/null +++ b/src/functions/private/Write-YamlNodeEvent.ps1 @@ -0,0 +1,89 @@ +function Write-YamlNodeEvent { + <# + .SYNOPSIS + Writes one normalized node graph to the low-level YAML emitter. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [YamlDotNet.Core.Emitter] $Emitter, + + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[long]] $EmittedReferences + ) + + if ($Node.ReferenceId -ne 0 -and $EmittedReferences.Contains($Node.ReferenceId)) { + $Emitter.Emit( + [YamlDotNet.Core.Events.AnchorAlias]::new( + [YamlDotNet.Core.AnchorName]::new($Node.Anchor) + ) + ) + return + } + + if ($Node.ReferenceId -ne 0) { + [void] $EmittedReferences.Add($Node.ReferenceId) + } + + $anchor = if ([string]::IsNullOrEmpty($Node.Anchor)) { + [YamlDotNet.Core.AnchorName]::Empty + } else { + [YamlDotNet.Core.AnchorName]::new($Node.Anchor) + } + $tag = if ([string]::IsNullOrEmpty($Node.Tag)) { + [YamlDotNet.Core.TagName]::Empty + } else { + [YamlDotNet.Core.TagName]::new($Node.Tag) + } + + if ($Node.Kind -eq 'Scalar') { + $isPlainImplicit = [string]::IsNullOrEmpty($Node.Tag) -and $Node.Style -eq [YamlDotNet.Core.ScalarStyle]::Plain + $isQuotedImplicit = [string]::IsNullOrEmpty($Node.Tag) -and -not $isPlainImplicit + $Emitter.Emit( + [YamlDotNet.Core.Events.Scalar]::new( + $anchor, + $tag, + $Node.Value, + $Node.Style, + $isPlainImplicit, + $isQuotedImplicit + ) + ) + return + } + + $isImplicit = [string]::IsNullOrEmpty($Node.Tag) + if ($Node.Kind -eq 'Sequence') { + $Emitter.Emit( + [YamlDotNet.Core.Events.SequenceStart]::new( + $anchor, + $tag, + $isImplicit, + [YamlDotNet.Core.Events.SequenceStyle]::Block + ) + ) + foreach ($item in $Node.Items) { + Write-YamlNodeEvent -Emitter $Emitter -Node $item -EmittedReferences $EmittedReferences + } + $Emitter.Emit([YamlDotNet.Core.Events.SequenceEnd]::new()) + return + } + + $Emitter.Emit( + [YamlDotNet.Core.Events.MappingStart]::new( + $anchor, + $tag, + $isImplicit, + [YamlDotNet.Core.Events.MappingStyle]::Block + ) + ) + foreach ($entry in $Node.Entries) { + Write-YamlNodeEvent -Emitter $Emitter -Node $entry.Key -EmittedReferences $EmittedReferences + Write-YamlNodeEvent -Emitter $Emitter -Node $entry.Value -EmittedReferences $EmittedReferences + } + $Emitter.Emit([YamlDotNet.Core.Events.MappingEnd]::new()) +} diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 new file mode 100644 index 0000000..632a145 --- /dev/null +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -0,0 +1,117 @@ +function ConvertFrom-Yaml { + <# + .SYNOPSIS + Converts a YAML stream into PowerShell values. + + .DESCRIPTION + Parses YAML with YamlDotNet's low-level parser and constructs values + with the YAML 1.2 core schema. Mapping keys must be unique. Unknown + application tags never activate .NET types and are treated as neutral + metadata. + + Pipeline strings are joined with a line feed and parsed as one stream, + which supports Get-Content. Each YAML document is written separately. + + .PARAMETER Yaml + YAML text. Multiple pipeline records are joined with a line feed and + parsed as one YAML stream. + + .PARAMETER AsHashtable + Returns mappings as insertion-ordered dictionaries. Use this for + complex, non-string, empty, or case-colliding mapping keys. + + .PARAMETER NoEnumerate + Writes each top-level YAML sequence as one array pipeline record. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum number of YAML nodes in the stream. The default is 100000. + + .PARAMETER MaxAliases + Maximum number of alias nodes in the stream. The default is 1000. + + .PARAMETER MaxScalarLength + Maximum decoded character count for one scalar. The default is + 1048576. + + .EXAMPLE + 'name: Ada' | ConvertFrom-Yaml + + Converts one mapping to a PSCustomObject. + + .EXAMPLE + Get-Content -Path '.\config.yaml' | ConvertFrom-Yaml -AsHashtable + + Joins the input lines and returns ordered dictionaries. + + .INPUTS + System.String[] + + .OUTPUTS + System.Object + #> + [CmdletBinding()] + [OutputType([object])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $Yaml, + + [switch] $AsHashtable, + + [switch] $NoEnumerate, + + [ValidateRange(1, 1024)] + [int] $Depth = 100, + + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576 + ) + + begin { + $lines = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($line in $Yaml) { + $lines.Add($line) + } + } + end { + $yamlText = $lines -join "`n" + try { + $documents = Read-YamlStream -Yaml $yamlText -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength + foreach ($document in $documents) { + $value = ConvertFrom-YamlNode -Node $document -AsHashtable:$AsHashtable -Cache ( + [System.Collections.Generic.Dictionary[int, object]]::new() + ) + + $effectiveNode = $document + while ($effectiveNode.Kind -eq 'Alias') { + $effectiveNode = $effectiveNode.Target + } + $isTopLevelSequence = $effectiveNode.Kind -eq 'Sequence' -and $effectiveNode.Tag -ne 'tag:yaml.org,2002:omap' + + if ($isTopLevelSequence -and -not $NoEnumerate) { + foreach ($item in $value) { + $PSCmdlet.WriteObject($item, $false) + } + } else { + $PSCmdlet.WriteObject($value, $false) + } + } + } catch [YamlDotNet.Core.YamlException] { + $record = New-YamlErrorRecord -Exception $_.Exception -DefaultErrorId 'YamlInvalidInput' ` + -Category InvalidData -TargetObject $yamlText + $PSCmdlet.ThrowTerminatingError($record) + } + } +} diff --git a/src/functions/public/ConvertTo-Yaml.ps1 b/src/functions/public/ConvertTo-Yaml.ps1 new file mode 100644 index 0000000..9baafbc --- /dev/null +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -0,0 +1,127 @@ +function ConvertTo-Yaml { + <# + .SYNOPSIS + Converts PowerShell values to YAML 1.2-compatible text. + + .DESCRIPTION + Normalizes supported PowerShell values into mappings, sequences, and + scalars before emitting YAML through YamlDotNet's low-level emitter. + PowerShell metadata is not serialized. Repeated acyclic collection + references use YAML anchors and aliases; cyclic and unsupported values + terminate with a specific error. + + Multiple pipeline records are collected into one top-level sequence. + + .PARAMETER InputObject + A value to serialize. Multiple pipeline records become one sequence. + + .PARAMETER Depth + Maximum object-graph nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum number of traversed nodes. The default is 100000. + + .PARAMETER MaxScalarLength + Maximum character count for one emitted scalar. The default is + 1048576. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .PARAMETER ExplicitDocumentStart + Emits an explicit `---` document start marker. + + .PARAMETER EnumsAsStrings + Emits enum names as strings instead of underlying numeric values. + + .EXAMPLE + [ordered]@{ name = 'Ada'; active = $true } | ConvertTo-Yaml + + Converts one mapping to YAML. + + .EXAMPLE + 'one', 'two' | ConvertTo-Yaml + + Converts two pipeline records to one YAML sequence. + + .INPUTS + System.Object + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowNull()] + [object] $InputObject, + + [ValidateRange(1, 1024)] + [int] $Depth = 100, + + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [ValidateRange(2, 9)] + [int] $Indent = 2, + + [switch] $ExplicitDocumentStart, + + [switch] $EnumsAsStrings + ) + + begin { + $values = [System.Collections.Generic.List[object]]::new() + } + process { + $values.Add($InputObject) + } + end { + if ($values.Count -eq 0) { + if (-not $PSBoundParameters.ContainsKey('InputObject')) { + return + } + $value = [object[]]::new(0) + } elseif ($values.Count -eq 1) { + $value = $values[0] + } else { + $value = [object[]] $values.ToArray() + } + $state = [pscustomobject]@{ + IdGenerator = [System.Runtime.Serialization.ObjectIDGenerator]::new() + NodesById = [System.Collections.Generic.Dictionary[long, object]]::new() + ReferenceCounts = [System.Collections.Generic.Dictionary[long, int]]::new() + ReferenceOrder = [System.Collections.Generic.List[long]]::new() + Fingerprints = [System.Collections.Generic.Dictionary[long, string]]::new() + FingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + Active = [System.Collections.Generic.HashSet[long]]::new() + NodeCount = 0 + MaxDepth = $Depth + MaxNodes = $MaxNodes + MaxScalarLength = $MaxScalarLength + } + + try { + $node = ConvertTo-YamlNode -Value ([object] $value) -State $state -Depth 1 ` + -EnumsAsStrings:$EnumsAsStrings + Set-YamlNodeAnchor -State $state + $yaml = ConvertTo-YamlText -Node $node -Indent $Indent ` + -ExplicitDocumentStart:$ExplicitDocumentStart + $PSCmdlet.WriteObject($yaml, $false) + } catch [System.NotSupportedException] { + $record = New-YamlErrorRecord -Exception $_.Exception -DefaultErrorId 'YamlUnsupportedType' ` + -Category InvalidType -TargetObject $value + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.InvalidOperationException] { + $record = New-YamlErrorRecord -Exception $_.Exception -DefaultErrorId 'YamlSerializationFailed' ` + -Category InvalidOperation -TargetObject $value + $PSCmdlet.ThrowTerminatingError($record) + } finally { + $state.FingerprintHasher.Dispose() + } + } +} diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 deleted file mode 100644 index ffe3483..0000000 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -#Requires -Modules Utilities - -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/Test-Yaml.ps1 b/src/functions/public/Test-Yaml.ps1 new file mode 100644 index 0000000..36a913e --- /dev/null +++ b/src/functions/public/Test-Yaml.ps1 @@ -0,0 +1,78 @@ +function Test-Yaml { + <# + .SYNOPSIS + Tests whether text is a valid, safely processable YAML stream. + + .DESCRIPTION + Parses a YAML stream, checks unique mapping keys, validates standard + tags, and applies the same resource limits as ConvertFrom-Yaml. It + returns false only for YamlDotNet YAML exceptions. Unexpected runtime + failures are not swallowed. + + Pipeline strings are joined with a line feed and tested as one stream. + + .PARAMETER Yaml + YAML text. Multiple pipeline records are joined with a line feed. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum number of YAML nodes in the stream. The default is 100000. + + .PARAMETER MaxAliases + Maximum number of alias nodes in the stream. The default is 1000. + + .PARAMETER MaxScalarLength + Maximum decoded character count for one scalar. The default is + 1048576. + + .EXAMPLE + 'name: Ada' | Test-Yaml + + Returns true. + + .INPUTS + System.String[] + + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $Yaml, + + [ValidateRange(1, 1024)] + [int] $Depth = 100, + + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576 + ) + + begin { + $lines = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($line in $Yaml) { + $lines.Add($line) + } + } + end { + try { + $null = Read-YamlStream -Yaml ($lines -join "`n") -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength + $PSCmdlet.WriteObject($true) + } catch [YamlDotNet.Core.YamlException] { + $PSCmdlet.WriteObject($false) + } + } +} diff --git a/src/licenses/YamlDotNet.LICENSE.txt b/src/licenses/YamlDotNet.LICENSE.txt new file mode 100644 index 0000000..d4f2924 --- /dev/null +++ b/src/licenses/YamlDotNet.LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/manifest.psd1 b/src/manifest.psd1 new file mode 100644 index 0000000..0f92e69 --- /dev/null +++ b/src/manifest.psd1 @@ -0,0 +1,3 @@ +@{ + DotNetFrameworkVersion = '4.7.2' +} diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 new file mode 100644 index 0000000..f260aac --- /dev/null +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -0,0 +1,259 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') +} + +Describe 'ConvertFrom-Yaml' { + Context 'YAML 1.2 core schema' { + It 'resolves as ' -ForEach @( + @{ Text = ''; Type = 'null'; Expected = $null } + @{ Text = '~'; Type = 'null'; Expected = $null } + @{ Text = 'null'; Type = 'null'; Expected = $null } + @{ Text = 'Null'; Type = 'null'; Expected = $null } + @{ Text = 'NULL'; Type = 'null'; Expected = $null } + @{ Text = 'true'; Type = 'Boolean'; Expected = $true } + @{ Text = 'True'; Type = 'Boolean'; Expected = $true } + @{ Text = 'TRUE'; Type = 'Boolean'; Expected = $true } + @{ Text = 'false'; Type = 'Boolean'; Expected = $false } + @{ Text = 'False'; Type = 'Boolean'; Expected = $false } + @{ Text = 'FALSE'; Type = 'Boolean'; Expected = $false } + @{ Text = '01'; Type = 'Int32'; Expected = 1 } + @{ Text = '0o14'; Type = 'Int32'; Expected = 12 } + @{ Text = '0xC'; Type = 'Int32'; Expected = 12 } + @{ Text = '1.23015e+3'; Type = 'Double'; Expected = 1230.15 } + ) { + $result = "value: $Text" | ConvertFrom-Yaml + + $result.value | Should -Be $Expected + if ($Type -ne 'null') { + $result.value.GetType().Name | Should -Be $Type + } + } + + It 'keeps YAML 1.1-only implicit values and timestamps as strings' { + $result = @' +yes: yes +no: NO +on: on +off: Off +timestamp: 2001-12-15T02:59:43.1Z +'@ | ConvertFrom-Yaml + + $result.yes | Should -BeOfType [string] + $result.no | Should -BeOfType [string] + $result.on | Should -BeOfType [string] + $result.off | Should -BeOfType [string] + $result.timestamp | Should -BeOfType [string] + } + + It 'keeps quoted and block scalars as strings' { + $result = @' +quoted: "true" +literal: | + 42 +folded: > + null + text +'@ | ConvertFrom-Yaml + + $result.quoted | Should -Be 'true' + $result.quoted | Should -BeOfType [string] + $result.literal | Should -Be "42`n" + $result.folded | Should -Be 'null text' + } + + It 'uses BigInteger beyond Int64' { + $result = 'value: 9223372036854775808' | ConvertFrom-Yaml + + $result.value | Should -BeOfType [System.Numerics.BigInteger] + $result.value.ToString() | Should -Be '9223372036854775808' + } + } + + Context 'Mappings and sequences' { + It 'returns ordinary mappings as ordered PSCustomObject properties' { + $result = "zebra: 1`napple: 2" | ConvertFrom-Yaml + + $result | Should -BeOfType [pscustomobject] + @($result.PSObject.Properties.Name) | Should -Be @('zebra', 'apple') + } + + It 'returns recursive ordered dictionaries with AsHashtable' { + $result = "outer:`n inner: value" | ConvertFrom-Yaml -AsHashtable + + $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['outer'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['outer']['inner'] | Should -Be 'value' + } + + It 'preserves a complex key with AsHashtable' { + $result = "? [Detroit Tigers, Chicago Cubs]`n: 2001-07-23" | + ConvertFrom-Yaml -AsHashtable + $enumerator = $result.GetEnumerator() + $null = $enumerator.MoveNext() + $key = $enumerator.Key + + , $key | Should -BeOfType [object[]] + $key | Should -Be @('Detroit Tigers', 'Chicago Cubs') + $enumerator.Value | Should -Be '2001-07-23' + } + + It 'fails rather than losing a complex key in PSCustomObject mode' { + { "? [a, b]`n: value" | ConvertFrom-Yaml } | + Should -Throw -ExpectedMessage '*Use -AsHashtable*' + } + + It 'fails on case-insensitive property collisions but preserves them with AsHashtable' { + { "Name: one`nname: two" | ConvertFrom-Yaml } | + Should -Throw -ExpectedMessage '*case-insensitive property collision*' + + $result = "Name: one`nname: two" | ConvertFrom-Yaml -AsHashtable + $result.Count | Should -Be 2 + $result['Name'] | Should -Be 'one' + $result['name'] | Should -Be 'two' + } + + It 'enumerates only top-level sequences by default' { + $result = "- one`n- two" | ConvertFrom-Yaml + + @($result) | Should -Be @('one', 'two') + } + + It 'preserves a top-level sequence with NoEnumerate' { + $result = "- one`n- two" | ConvertFrom-Yaml -NoEnumerate + + , $result | Should -BeOfType [object[]] + $result | Should -Be @('one', 'two') + } + } + + Context 'Streams and pipeline input' { + It 'joins pipeline lines as one YAML stream' { + $result = 'name: Ada', 'active: true' | ConvertFrom-Yaml + + $result.name | Should -Be 'Ada' + $result.active | Should -BeTrue + } + + It 'returns every document separately' { + $result = @( + "---`nname: first`n...`n---`nname: second" | ConvertFrom-Yaml + ) + + $result.Count | Should -Be 2 + $result[0].name | Should -Be 'first' + $result[1].name | Should -Be 'second' + } + } + + Context 'Tags, anchors, and aliases' { + It 'constructs explicit standard scalar tags safely' { + $result = @' +text: !!str 42 +number: !!int "42" +binary: !!binary SGVsbG8= +offset: !!timestamp 2026-07-19T15:49:21+02:00 +date: !!timestamp 2026-07-19 +'@ | ConvertFrom-Yaml + + $result.text | Should -BeOfType [string] + $result.number | Should -BeOfType [int] + [Text.Encoding]::UTF8.GetString($result.binary) | Should -Be 'Hello' + $result.offset | Should -BeOfType [datetimeoffset] + $result.date | Should -BeOfType [datetime] + } + + It 'treats unknown application tags as neutral non-activating metadata' { + $result = @' +scalar: !System.Management.Automation.PSObject 42 +mapping: ! + name: safe +'@ | ConvertFrom-Yaml + + $result.scalar | Should -Be '42' + $result.scalar | Should -BeOfType [string] + $result.mapping.name | Should -Be 'safe' + } + + It 'preserves repeated collection references' { + $result = @' +source: &source + value: 1 +copy: *source +'@ | ConvertFrom-Yaml + + [object]::ReferenceEquals($result.source, $result.copy) | Should -BeTrue + } + + It 'constructs recursive aliases without recursing forever' { + $result = '&root [*root]' | ConvertFrom-Yaml -NoEnumerate + + [object]::ReferenceEquals($result, $result[0]) | Should -BeTrue + } + + It 'constructs set, ordered-map, and pairs tags safely' { + $set = "!!set`n? one`n? two" | ConvertFrom-Yaml + $orderedMap = "!!omap`n- one: 1`n- two: 2" | ConvertFrom-Yaml + $pairs = "!!pairs`n- one: 1`n- one: 2" | ConvertFrom-Yaml -NoEnumerate + + $set | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $set.Count | Should -Be 2 + $orderedMap | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + @($orderedMap.Keys) | Should -Be @('one', 'two') + $pairs.Count | Should -Be 2 + $pairs[0] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + } + } + + Context 'Validation and limits' { + It 'rejects duplicate scalar, canonical numeric, and complex keys' { + { "key: one`nkey: two" | ConvertFrom-Yaml } | Should -Throw + { "1: one`n01: two" | ConvertFrom-Yaml -AsHashtable } | Should -Throw + { "? [a, b]`n: one`n? [a, b]`n: two" | ConvertFrom-Yaml -AsHashtable } | + Should -Throw + { "? {a: 1, A: 1}`n: one`n? {A: 1, a: 1}`n: two" | ConvertFrom-Yaml -AsHashtable } | + Should -Throw + } + + It 'rejects equivalent offset timestamps as duplicate keys' { + $yaml = @' +? !!timestamp 2001-12-15T02:59:43.1Z +: one +? !!timestamp 2001-12-14T21:59:43.1-05:00 +: two +'@ + + { $yaml | ConvertFrom-Yaml -AsHashtable } | Should -Throw + ($yaml | Test-Yaml) | Should -BeFalse + } + + It 'rejects finite floating-point values outside the supported range' { + { 'value: 1e9999' | ConvertFrom-Yaml } | + Should -Throw -ExpectedMessage '*outside the supported range*' + ('value: 1e9999' | Test-Yaml) | Should -BeFalse + } + + It 'rejects undefined aliases' { + { 'value: *missing' | ConvertFrom-Yaml } | Should -Throw + } + + It 'enforces depth, node, alias, and scalar limits' { + { "a:`n b:`n c: value" | ConvertFrom-Yaml -Depth 2 } | Should -Throw + { "[one, two]" | ConvertFrom-Yaml -MaxNodes 2 } | Should -Throw + { "a: &a value`nb: *a" | ConvertFrom-Yaml -MaxAliases 0 } | Should -Throw + { 'value: long' | ConvertFrom-Yaml -MaxScalarLength 4 } | Should -Throw + } + } +} diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 new file mode 100644 index 0000000..caf573d --- /dev/null +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -0,0 +1,268 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') +} + +Describe 'ConvertTo-Yaml' { + Context 'Supported values' { + It 'serializes mappings, sequences, and core scalars to valid YAML' { + $inputObject = [ordered]@{ + name = 'Ada' + active = $true + count = 42 + ratio = 1.5 + nothing = $null + items = @('one', 'two') + } + + $yaml = ConvertTo-Yaml -InputObject $inputObject + $result = $yaml | ConvertFrom-Yaml -AsHashtable + + $yaml | Should -BeOfType [string] + ($yaml | Test-Yaml) | Should -BeTrue + $result['name'] | Should -Be 'Ada' + $result['active'] | Should -BeTrue + $result['count'] | Should -Be 42 + $result['ratio'] | Should -Be 1.5 + $result['nothing'] | Should -BeNullOrEmpty + $result['items'] | Should -Be @('one', 'two') + } + + It 'quotes strings that resemble core-schema values' { + $inputObject = [ordered]@{ + boolean = 'true' + integer = '42' + null = 'null' + empty = '' + } + + $result = ($inputObject | ConvertTo-Yaml) | ConvertFrom-Yaml -AsHashtable + + $result['boolean'] | Should -BeOfType [string] + $result['integer'] | Should -BeOfType [string] + $result['null'] | Should -BeOfType [string] + $result['empty'] | Should -BeOfType [string] + } + + It 'serializes signed, unsigned, large, decimal, and special numbers' { + $inputObject = [ordered]@{ + signed = [long] -9223372036854775808 + unsigned = [ulong]::MaxValue + big = [System.Numerics.BigInteger]::Parse('18446744073709551616') + decimal = [decimal] 12.50 + positive = [double]::PositiveInfinity + negative = [double]::NegativeInfinity + nan = [double]::NaN + } + + $result = ($inputObject | ConvertTo-Yaml) | ConvertFrom-Yaml -AsHashtable + + $result['signed'] | Should -Be ([long] -9223372036854775808) + $result['unsigned'] | Should -BeOfType [System.Numerics.BigInteger] + $result['big'] | Should -BeOfType [System.Numerics.BigInteger] + $result['decimal'] | Should -Be 12.5 + [double]::IsPositiveInfinity($result['positive']) | Should -BeTrue + [double]::IsNegativeInfinity($result['negative']) | Should -BeTrue + [double]::IsNaN($result['nan']) | Should -BeTrue + } + + It 'serializes DateTime, DateTimeOffset, and binary values with standard tags' { + $inputObject = [ordered]@{ + utc = [datetime]::new(2026, 7, 19, 13, 49, 21, [DateTimeKind]::Utc) + local = [datetimeoffset]::Parse('2026-07-19T15:49:21+02:00') + binary = [Text.Encoding]::UTF8.GetBytes('hello') + } + + $yaml = $inputObject | ConvertTo-Yaml + $result = $yaml | ConvertFrom-Yaml -AsHashtable + + $yaml | Should -Match '!!timestamp' + $yaml | Should -Match '!!binary' + $result['utc'] | Should -BeOfType [datetimeoffset] + $result['local'] | Should -BeOfType [datetimeoffset] + [Text.Encoding]::UTF8.GetString($result['binary']) | Should -Be 'hello' + } + + It 'serializes enum values numerically or by name' { + $numeric = ([ordered]@{ day = [DayOfWeek]::Monday }) | ConvertTo-Yaml + $named = ([ordered]@{ day = [DayOfWeek]::Monday }) | + ConvertTo-Yaml -EnumsAsStrings + + ($numeric | ConvertFrom-Yaml).day | Should -Be 1 + ($named | ConvertFrom-Yaml).day | Should -Be 'Monday' + ($named | ConvertFrom-Yaml).day | Should -BeOfType [string] + } + + It 'serializes empty mappings and sequences distinctly' { + (ConvertTo-Yaml -InputObject @()).Trim() | Should -Be '[]' + (([ordered]@{} | ConvertTo-Yaml).Trim()) | Should -Be '{}' + + $nested = ConvertTo-Yaml -InputObject (, @()) + $nested.Trim() | Should -Be '- []' + } + + It 'serializes a null input explicitly' { + (ConvertTo-Yaml -InputObject $null).Trim() | Should -Be 'null' + } + + It 'preserves complex dictionary keys through a hashtable round trip' { + $dictionary = [System.Collections.Specialized.OrderedDictionary]::new() + $key = [object[]]@('a', 'b') + $dictionary.Add($key, 'value') + + $result = ($dictionary | ConvertTo-Yaml) | ConvertFrom-Yaml -AsHashtable + $enumerator = $result.GetEnumerator() + $null = $enumerator.MoveNext() + $roundTripKey = $enumerator.Key + + , $roundTripKey | Should -BeOfType [object[]] + $roundTripKey | Should -Be @('a', 'b') + $enumerator.Value | Should -Be 'value' + } + + It 'rejects dictionary keys that normalize to the same YAML value' { + $dictionary = [System.Collections.Specialized.OrderedDictionary]::new() + $dictionary.Add([int] 1, 'int') + $dictionary.Add([long] 1, 'long') + + { $dictionary | ConvertTo-Yaml } | + Should -Throw -ExpectedMessage '*normalize to the same YAML value*' + } + + It 'compares unordered complex keys with ordinal case-sensitive sorting' { + $firstKey = [System.Collections.Specialized.OrderedDictionary]::new() + $firstKey.Add('a', 1) + $firstKey.Add('A', 1) + $secondKey = [System.Collections.Specialized.OrderedDictionary]::new() + $secondKey.Add('A', 1) + $secondKey.Add('a', 1) + $dictionary = [System.Collections.Specialized.OrderedDictionary]::new() + $dictionary.Add($firstKey, 'first') + $dictionary.Add($secondKey, 'second') + + { $dictionary | ConvertTo-Yaml } | + Should -Throw -ExpectedMessage '*normalize to the same YAML value*' + } + } + + Context 'Pipeline and formatting' { + It 'collects multiple pipeline records into one sequence' { + $yaml = 'one', 'two', 'three' | ConvertTo-Yaml + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + + $result | Should -Be @('one', 'two', 'three') + } + + It 'uses the requested indentation and document start marker' { + $yaml = [ordered]@{ + outer = [ordered]@{ + inner = 'value' + } + } | ConvertTo-Yaml -Indent 4 -ExplicitDocumentStart + + $yaml | Should -Match '^---' + $yaml | Should -Match '(?m)^ "inner":' + } + + It 'normalizes output line endings to LF' { + $yaml = [ordered]@{ one = 1; two = 2 } | ConvertTo-Yaml + + $yaml | Should -Not -Match "`r" + } + } + + Context 'References, metadata, and failures' { + It 'emits aliases for repeated acyclic references' { + $shared = [ordered]@{ value = 1 } + $inputObject = [ordered]@{ + first = $shared + second = $shared + } + + $yaml = $inputObject | ConvertTo-Yaml + $result = $yaml | ConvertFrom-Yaml + + $yaml | Should -Match '&id001' + $yaml | Should -Match '\*id001' + [object]::ReferenceEquals($result.first, $result.second) | Should -BeTrue + } + + It 'does not leak PowerShell type metadata' { + $inputObject = [pscustomobject]@{ name = 'Ada' } + $inputObject.PSObject.TypeNames.Insert(0, 'Secret.Application.Type') + + $yaml = $inputObject | ConvertTo-Yaml + + $yaml | Should -Not -Match 'Secret\.Application\.Type' + $yaml | Should -Not -Match 'PSTypeNames' + ($yaml | ConvertFrom-Yaml).name | Should -Be 'Ada' + } + + It 'serializes explicit PSObject note properties without adapted metadata' { + $inputObject = [object]::new() + $inputObject | Add-Member -MemberType NoteProperty -Name name -Value 'Ada' + + $yaml = $inputObject | ConvertTo-Yaml + $result = $yaml | ConvertFrom-Yaml + + $result.name | Should -Be 'Ada' + $yaml | Should -Not -Match 'GetType' + $yaml | Should -Not -Match 'ToString' + } + + It 'rejects cyclic structures specifically' { + $cycle = [System.Collections.ArrayList]::new() + $cycle.Add($cycle) + + { ConvertTo-Yaml -InputObject $cycle } | + Should -Throw -ExpectedMessage '*cycle was detected*' + } + + It 'rejects unsupported runtime objects instead of stringifying them' { + { ConvertTo-Yaml -InputObject ([uri] 'https://example.com') } | + Should -Throw -ExpectedMessage "*System.Uri*not supported*" + } + + It 'rejects non-data PSCustomObject properties' { + $inputObject = [pscustomobject]@{ name = 'Ada' } + $inputObject | Add-Member -MemberType ScriptProperty -Name Computed -Value { 'value' } + + { $inputObject | ConvertTo-Yaml } | + Should -Throw -ExpectedMessage "*Computed*not a note property*" + } + + It 'enforces depth, node, and scalar limits without truncating' { + $nested = [ordered]@{ a = [ordered]@{ b = [ordered]@{ c = 1 } } } + + { $nested | ConvertTo-Yaml -Depth 2 } | Should -Throw + { @(1, 2) | ConvertTo-Yaml -MaxNodes 2 } | Should -Throw + { 'long' | ConvertTo-Yaml -MaxScalarLength 3 } | Should -Throw + } + + It 'enforces the scalar limit for every emitted scalar kind' { + $bigInteger = [System.Numerics.BigInteger]::Parse('12345') + + { ConvertTo-Yaml -InputObject $null -MaxScalarLength 3 } | Should -Throw + { ConvertTo-Yaml -InputObject $true -MaxScalarLength 3 } | Should -Throw + { ConvertTo-Yaml -InputObject $bigInteger -MaxScalarLength 4 } | Should -Throw + { ConvertTo-Yaml -InputObject ([decimal] 12.5) -MaxScalarLength 3 } | Should -Throw + { ConvertTo-Yaml -InputObject ([double]::PositiveInfinity) -MaxScalarLength 3 } | Should -Throw + { ConvertTo-Yaml -InputObject ([datetime]::UtcNow) -MaxScalarLength 10 } | Should -Throw + { ConvertTo-Yaml -InputObject ([byte[]] @(1, 2, 3)) -MaxScalarLength 3 } | Should -Throw + { ConvertTo-Yaml -InputObject ([DayOfWeek]::Monday) -EnumsAsStrings -MaxScalarLength 5 } | + Should -Throw + } + } +} diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index 8258bb7..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -} diff --git a/tests/Packaging.Tests.ps1 b/tests/Packaging.Tests.ps1 new file mode 100644 index 0000000..4ada169 --- /dev/null +++ b/tests/Packaging.Tests.ps1 @@ -0,0 +1,70 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'Packaged dependency' { + BeforeAll { + $repositoryRoot = Split-Path -Parent $PSScriptRoot + } + + It 'bundles the exact YamlDotNet 18.1.0 netstandard2.0 assembly' { + $hash = Get-FileHash -Path ( + Join-Path $repositoryRoot 'src\assemblies\YamlDotNet.dll' + ) -Algorithm SHA256 + + $hash.Hash | Should -Be '91CD6D1FD0AE5B64BF6B252EB3B1AF2382CBC599C29045427C45FE8403D4295D' + } + + It 'keeps RequiredAssemblies out of the source manifest' { + $manifest = Import-PowerShellDataFile -Path ( + Join-Path $repositoryRoot 'src\manifest.psd1' + ) + + $manifest.DotNetFrameworkVersion | Should -Be '4.7.2' + $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse + } + + It 'packages the upstream license and dependency provenance' { + $license = Get-Content -Path ( + Join-Path $repositoryRoot 'src\licenses\YamlDotNet.LICENSE.txt' + ) -Raw + $notice = Get-Content -Path ( + Join-Path $repositoryRoot 'src\THIRD-PARTY-NOTICES.txt' + ) -Raw + + $license | Should -Match 'Copyright \(c\) 2008, 2009, 2010' + $license | Should -Match 'Permission is hereby granted, free of charge' + $notice | Should -Match 'YamlDotNet 18\.1\.0' + $notice | Should -Match 'lib/netstandard2\.0/YamlDotNet\.dll' + $notice | Should -Match '59FFE65ADE67AD9D886267F877B634A450363CB81B94E19DB9CA4C36461416F6' + $notice | Should -Match '91CD6D1FD0AE5B64BF6B252EB3B1AF2382CBC599C29045427C45FE8403D4295D' + } + + It 'uses Process-PSModule 6.1.4 and treats tests as important changes' { + $workflow = Get-Content -Path ( + Join-Path $repositoryRoot '.github\workflows\Process-PSModule.yml' + ) -Raw + + $workflow | Should -Match 'workflow\.yml@da180bac16b13bfbcdf08b2e4e221b5b49e5ff28 # v6\.1\.4' + $workflow | Should -Match '\^src/' + $workflow | Should -Match '\^tests/' + } + + It 'does not add a custom assembly loader to module source' { + $source = Get-ChildItem -Path (Join-Path $repositoryRoot 'src') -Recurse -File | + Where-Object Extension -EQ '.ps1' | + Get-Content -Raw + + $source | Should -Not -Match '\bAdd-Type\b' + $source | Should -Not -Match 'Assembly\]::Load' + } +} diff --git a/tests/Specification.Tests.ps1 b/tests/Specification.Tests.ps1 new file mode 100644 index 0000000..f316a65 --- /dev/null +++ b/tests/Specification.Tests.ps1 @@ -0,0 +1,206 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') +} + +Describe 'YAML 1.2.2 Chapter 2 examples' { + BeforeAll { + $chapterPath = Join-Path $PSScriptRoot 'fixtures\yaml-spec-1.2.2\chapter-02' + } + + It 'accepts and constructs Example : ' -ForEach @( + @{ Number = '2.01'; Name = 'Sequence of Scalars' } + @{ Number = '2.02'; Name = 'Mapping Scalars to Scalars' } + @{ Number = '2.03'; Name = 'Mapping Scalars to Sequences' } + @{ Number = '2.04'; Name = 'Sequence of Mappings' } + @{ Number = '2.05'; Name = 'Sequence of Sequences' } + @{ Number = '2.06'; Name = 'Mapping of Mappings' } + @{ Number = '2.07'; Name = 'Two Documents in a Stream' } + @{ Number = '2.08'; Name = 'Play by Play Feed' } + @{ Number = '2.09'; Name = 'Document with Comments' } + @{ Number = '2.10'; Name = 'Anchor and Alias' } + @{ Number = '2.11'; Name = 'Mapping between Sequences' } + @{ Number = '2.12'; Name = 'Compact Nested Mapping' } + @{ Number = '2.13'; Name = 'Literal Scalar' } + @{ Number = '2.14'; Name = 'Folded Scalar' } + @{ Number = '2.15'; Name = 'Folded More-indented Lines' } + @{ Number = '2.16'; Name = 'Indentation Scope' } + @{ Number = '2.17'; Name = 'Quoted Scalars' } + @{ Number = '2.18'; Name = 'Multi-line Flow Scalars' } + @{ Number = '2.19'; Name = 'Integers' } + @{ Number = '2.20'; Name = 'Floating Point' } + @{ Number = '2.21'; Name = 'Miscellaneous Scalars' } + @{ Number = '2.22'; Name = 'Timestamps' } + @{ Number = '2.23'; Name = 'Explicit Tags' } + @{ Number = '2.24'; Name = 'Global Tags' } + @{ Number = '2.25'; Name = 'Unordered Set' } + @{ Number = '2.26'; Name = 'Ordered Mapping' } + @{ Number = '2.27'; Name = 'Invoice' } + @{ Number = '2.28'; Name = 'Log File' } + ) { + $yaml = Get-Content -Path (Join-Path $chapterPath "$Number.yaml") -Raw + + ($yaml | Test-Yaml) | Should -BeTrue + { $yaml | ConvertFrom-Yaml -AsHashtable -NoEnumerate } | Should -Not -Throw + } + + It 'constructs the block and flow collection examples' { + $sequence = Get-Content -Path (Join-Path $chapterPath '2.01.yaml') -Raw | + ConvertFrom-Yaml -NoEnumerate + $mapping = Get-Content -Path (Join-Path $chapterPath '2.06.yaml') -Raw | + ConvertFrom-Yaml + + $sequence | Should -Be @('Mark McGwire', 'Sammy Sosa', 'Ken Griffey') + $mapping.'Mark McGwire'.hr | Should -Be 65 + $mapping.'Sammy Sosa'.avg | Should -Be 0.288 + } + + It 'constructs multi-document streams from Examples 2.7, 2.8, and 2.28' { + $ranking = @( + Get-Content -Path (Join-Path $chapterPath '2.07.yaml') -Raw | + ConvertFrom-Yaml -NoEnumerate + ) + $feed = @( + Get-Content -Path (Join-Path $chapterPath '2.08.yaml') -Raw | + ConvertFrom-Yaml + ) + $log = @( + Get-Content -Path (Join-Path $chapterPath '2.28.yaml') -Raw | + ConvertFrom-Yaml + ) + + $ranking.Count | Should -Be 2 + $feed.Count | Should -Be 2 + $feed[1].action | Should -Be 'grand slam' + $log.Count | Should -Be 3 + $log[2].Stack[1].code | Should -Be 'foo = bar' + } + + It 'constructs literal, folded, quoted, and multi-line scalars' { + $literal = Get-Content -Path (Join-Path $chapterPath '2.13.yaml') -Raw | + ConvertFrom-Yaml + $folded = Get-Content -Path (Join-Path $chapterPath '2.14.yaml') -Raw | + ConvertFrom-Yaml + $quoted = Get-Content -Path (Join-Path $chapterPath '2.17.yaml') -Raw | + ConvertFrom-Yaml + $multiLine = Get-Content -Path (Join-Path $chapterPath '2.18.yaml') -Raw | + ConvertFrom-Yaml + + $literal | Should -Match '\\//\|\|' + $folded | Should -Be "Mark McGwire's year was crippled by a knee injury.`n" + $quoted.unicode | Should -Be "Sosa did fine.$([char]0x263A)" + $quoted.quoted | Should -Be " # Not a 'comment'." + $multiLine.plain | Should -Be 'This unquoted scalar spans many lines.' + } + + It 'constructs the Chapter 2 numeric examples with the core schema' { + $integers = Get-Content -Path (Join-Path $chapterPath '2.19.yaml') -Raw | + ConvertFrom-Yaml + $floats = Get-Content -Path (Join-Path $chapterPath '2.20.yaml') -Raw | + ConvertFrom-Yaml + + $integers.canonical | Should -Be 12345 + $integers.octal | Should -Be 12 + $integers.hexadecimal | Should -Be 12 + $floats.fixed | Should -Be 1230.15 + [double]::IsNegativeInfinity($floats.'negative infinity') | Should -BeTrue + [double]::IsNaN($floats.'not a number') | Should -BeTrue + } + + It 'keeps untagged timestamps as strings in Example 2.22' { + $timestamps = Get-Content -Path (Join-Path $chapterPath '2.22.yaml') -Raw | + ConvertFrom-Yaml + + $timestamps.canonical | Should -BeOfType [string] + $timestamps.iso8601 | Should -BeOfType [string] + $timestamps.spaced | Should -BeOfType [string] + $timestamps.date | Should -BeOfType [string] + } + + It 'handles explicit and application tags safely in Examples 2.23 and 2.24' { + $tagged = Get-Content -Path (Join-Path $chapterPath '2.23.yaml') -Raw | + ConvertFrom-Yaml + $shapes = Get-Content -Path (Join-Path $chapterPath '2.24.yaml') -Raw | + ConvertFrom-Yaml -NoEnumerate + + $tagged.'not-date' | Should -Be '2002-04-28' + , $tagged.picture | Should -BeOfType [byte[]] + $tagged.'application specific tag' | Should -BeOfType [string] + [object]::ReferenceEquals($shapes[0].center, $shapes[1].start) | Should -BeTrue + [object]::ReferenceEquals($shapes[0].center, $shapes[2].start) | Should -BeTrue + } + + It 'constructs set and ordered-map tags from Examples 2.25 and 2.26' { + $set = Get-Content -Path (Join-Path $chapterPath '2.25.yaml') -Raw | + ConvertFrom-Yaml + $orderedMap = Get-Content -Path (Join-Path $chapterPath '2.26.yaml') -Raw | + ConvertFrom-Yaml + + $set | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + @($set.Keys) | Should -Be @('Mark McGwire', 'Sammy Sosa', 'Ken Griffey') + $orderedMap | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + @($orderedMap.Keys) | Should -Be @('Mark McGwire', 'Sammy Sosa', 'Ken Griffey') + } + + It 'preserves the invoice address alias identity in Example 2.27' { + $invoice = Get-Content -Path (Join-Path $chapterPath '2.27.yaml') -Raw | + ConvertFrom-Yaml + + [object]::ReferenceEquals($invoice.'bill-to', $invoice.'ship-to') | Should -BeTrue + $invoice.product.Count | Should -Be 2 + $invoice.total | Should -Be 4443.52 + } +} + +Describe 'Pinned yaml-test-suite reference cases' { + BeforeAll { + $suitePath = Join-Path $PSScriptRoot 'fixtures\yaml-test-suite' + } + + It 'accepts valid reference case ' -ForEach @( + @{ Case = 'M5DY' } + @{ Case = 'SBG9' } + @{ Case = '6BFJ' } + @{ Case = '565N' } + @{ Case = 'EHF6' } + @{ Case = 'VJP3-valid' } + ) { + $yaml = Get-Content -Path (Join-Path $suitePath "$Case.yaml") -Raw + + ($yaml | Test-Yaml) | Should -BeTrue + { $yaml | ConvertFrom-Yaml -AsHashtable -NoEnumerate } | Should -Not -Throw + } + + It 'rejects invalid reference case ' -ForEach @( + @{ Case = '2JQS' } + @{ Case = 'SF5V' } + @{ Case = 'H7TQ' } + @{ Case = 'VJP3-invalid' } + ) { + $yaml = Get-Content -Path (Join-Path $suitePath "$Case.yaml") -Raw + + ($yaml | Test-Yaml) | Should -BeFalse + } + + It 'constructs both binary spellings in case 565N identically' { + $yaml = Get-Content -Path (Join-Path $suitePath '565N.yaml') -Raw + $result = $yaml | ConvertFrom-Yaml + + [System.Linq.Enumerable]::SequenceEqual[byte]( + $result.canonical, + $result.generic + ) | Should -BeTrue + } +} diff --git a/tests/Test-Yaml.Tests.ps1 b/tests/Test-Yaml.Tests.ps1 new file mode 100644 index 0000000..159b937 --- /dev/null +++ b/tests/Test-Yaml.Tests.ps1 @@ -0,0 +1,84 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') +} + +Describe 'Test-Yaml' { + It 'returns true for valid YAML and an empty stream' { + ('name: Ada' | Test-Yaml) | Should -BeTrue + ('' | Test-Yaml) | Should -BeTrue + } + + It 'joins pipeline lines consistently with ConvertFrom-Yaml' { + ('name: Ada', 'items: [one, two]' | Test-Yaml) | Should -BeTrue + } + + It 'returns false for malformed syntax' { + ('items: [one, two' | Test-Yaml) | Should -BeFalse + } + + It 'returns false for duplicate mapping keys' { + ("key: one`nkey: two" | Test-Yaml) | Should -BeFalse + ("1: one`n01: two" | Test-Yaml) | Should -BeFalse + } + + It 'accepts complex keys even though default object projection cannot' { + ("? [a, b]`n: value" | Test-Yaml) | Should -BeTrue + } + + It 'returns false when a configured safety limit is exceeded' { + ("a:`n b:`n c: value" | Test-Yaml -Depth 2) | Should -BeFalse + ("[one, two]" | Test-Yaml -MaxNodes 2) | Should -BeFalse + ("a: &a value`nb: *a" | Test-Yaml -MaxAliases 0) | Should -BeFalse + ('value: long' | Test-Yaml -MaxScalarLength 4) | Should -BeFalse + } + + It 'uses fixed-size fingerprints for an alias DAG' { + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('base: &a0 [x, x]') + for ($level = 1; $level -le 12; $level++) { + $lines.Add(('level{0}: &a{0} [*a{1}, *a{1}]' -f $level, ($level - 1))) + } + $lines.Add('? *a12') + $lines.Add(': value') + $yaml = $lines -join "`n" + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $result = Test-Yaml -Yaml $yaml -MaxNodes 100 -MaxAliases 100 + $stopwatch.Stop() + + $fingerprintLengths = @(Get-TestYamlFingerprintLength -Yaml $yaml) + + $result | Should -BeTrue + $stopwatch.Elapsed.TotalSeconds | Should -BeLessThan 5 + @($fingerprintLengths | Where-Object { $_ -ne 44 }).Count | Should -Be 0 + } + + It 'does not swallow an unexpected runtime failure' { + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + Mock Read-YamlStream { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } else { + Mock Read-YamlStream -ModuleName $loadedModule.Name { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } + + { 'name: Ada' | Test-Yaml } | + Should -Throw -ExpectedMessage '*unexpected runtime failure*' + } +} diff --git a/tests/TestBootstrap.ps1 b/tests/TestBootstrap.ps1 new file mode 100644 index 0000000..a39e83e --- /dev/null +++ b/tests/TestBootstrap.ps1 @@ -0,0 +1,46 @@ +$yamlModule = Get-Module -Name Yaml | Select-Object -First 1 +if ($null -eq $yamlModule) { + $assemblyPath = Join-Path $PSScriptRoot '..\src\assemblies\YamlDotNet.dll' + [void][System.Reflection.Assembly]::LoadFrom((Resolve-Path $assemblyPath)) + + Get-ChildItem -Path (Join-Path $PSScriptRoot '..\src\functions\private') -Filter '*.ps1' | + Sort-Object Name | + ForEach-Object { . $_.FullName } + + Get-ChildItem -Path (Join-Path $PSScriptRoot '..\src\functions\public') -Filter '*.ps1' | + Sort-Object Name | + ForEach-Object { . $_.FullName } +} + +function Get-TestYamlFingerprintLength { + <# + .SYNOPSIS + Returns structural fingerprint lengths for a YAML alias graph. + #> + param ( + [Parameter(Mandatory)] + [string] $Yaml + ) + + $implementation = { + param ([string] $YamlText) + + $document = @(Read-YamlStreamCore -Yaml $YamlText -Depth 100 -MaxNodes 100 -MaxAliases 100 ` + -MaxScalarLength 1048576)[0] + $cache = [System.Collections.Generic.Dictionary[int, string]]::new() + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + Test-YamlNodeGraph -Node $document -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $cache -FingerprintHasher $hasher + } finally { + $hasher.Dispose() + } + @($cache.Values | ForEach-Object Length) + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return @(& $implementation $Yaml) + } + return @(& $loadedModule $implementation $Yaml) +} diff --git a/tests/fixtures/yaml-spec-1.2.2/SOURCES.txt b/tests/fixtures/yaml-spec-1.2.2/SOURCES.txt new file mode 100644 index 0000000..04c5707 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/SOURCES.txt @@ -0,0 +1,18 @@ +YAML 1.2.2 Chapter 2 examples +================================ + +Source repository: + https://github.com/yaml/yaml-spec +Pinned commit: + 1b1a1be43bd6e0cfec45caf0e40af3b5d2bb7f8a +Source file: + spec/1.2.2/spec.md +Published specification: + https://yaml.org/spec/1.2.2/ + +The files in chapter-02 are exact YAML snippets from Examples 2.1 through +2.28. They are named by example number. + +The specification states: + + This document may be freely copied, provided it is not modified. diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.01.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.01.yaml new file mode 100644 index 0000000..d12e671 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.01.yaml @@ -0,0 +1,3 @@ +- Mark McGwire +- Sammy Sosa +- Ken Griffey diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.02.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.02.yaml new file mode 100644 index 0000000..7b7ec94 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.02.yaml @@ -0,0 +1,3 @@ +hr: 65 # Home runs +avg: 0.278 # Batting average +rbi: 147 # Runs Batted In diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.03.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.03.yaml new file mode 100644 index 0000000..01883b9 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.03.yaml @@ -0,0 +1,8 @@ +american: +- Boston Red Sox +- Detroit Tigers +- New York Yankees +national: +- New York Mets +- Chicago Cubs +- Atlanta Braves diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.04.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.04.yaml new file mode 100644 index 0000000..430f6b3 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.04.yaml @@ -0,0 +1,8 @@ +- + name: Mark McGwire + hr: 65 + avg: 0.278 +- + name: Sammy Sosa + hr: 63 + avg: 0.288 diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.05.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.05.yaml new file mode 100644 index 0000000..cdd7770 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.05.yaml @@ -0,0 +1,3 @@ +- [name , hr, avg ] +- [Mark McGwire, 65, 0.278] +- [Sammy Sosa , 63, 0.288] diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.06.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.06.yaml new file mode 100644 index 0000000..3d7bf39 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.06.yaml @@ -0,0 +1,5 @@ +Mark McGwire: {hr: 65, avg: 0.278} +Sammy Sosa: { + hr: 63, + avg: 0.288, + } diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.07.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.07.yaml new file mode 100644 index 0000000..bc711d5 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.07.yaml @@ -0,0 +1,10 @@ +# Ranking of 1998 home runs +--- +- Mark McGwire +- Sammy Sosa +- Ken Griffey + +# Team ranking +--- +- Chicago Cubs +- St Louis Cardinals diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.08.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.08.yaml new file mode 100644 index 0000000..05e102d --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.08.yaml @@ -0,0 +1,10 @@ +--- +time: 20:03:20 +player: Sammy Sosa +action: strike (miss) +... +--- +time: 20:03:47 +player: Sammy Sosa +action: grand slam +... diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.09.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.09.yaml new file mode 100644 index 0000000..ab74579 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.09.yaml @@ -0,0 +1,8 @@ +--- +hr: # 1998 hr ranking +- Mark McGwire +- Sammy Sosa +# 1998 rbi ranking +rbi: +- Sammy Sosa +- Ken Griffey diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.10.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.10.yaml new file mode 100644 index 0000000..d2484be --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.10.yaml @@ -0,0 +1,8 @@ +--- +hr: +- Mark McGwire +# Following node labeled SS +- &SS Sammy Sosa +rbi: +- *SS # Subsequent occurrence +- Ken Griffey diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.11.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.11.yaml new file mode 100644 index 0000000..e12ac8b --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.11.yaml @@ -0,0 +1,8 @@ +? - Detroit Tigers + - Chicago cubs +: - 2001-07-23 + +? [ New York Yankees, + Atlanta Braves ] +: [ 2001-07-02, 2001-08-12, + 2001-08-14 ] diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.12.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.12.yaml new file mode 100644 index 0000000..63a8ed7 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.12.yaml @@ -0,0 +1,8 @@ +--- +# Products purchased +- item : Super Hoop + quantity: 1 +- item : Basketball + quantity: 4 +- item : Big Shoes + quantity: 1 diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.13.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.13.yaml new file mode 100644 index 0000000..13fb656 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.13.yaml @@ -0,0 +1,4 @@ +# ASCII Art +--- | + \//||\/|| + // || ||__ diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.14.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.14.yaml new file mode 100644 index 0000000..fb4ed4a --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.14.yaml @@ -0,0 +1,4 @@ +--- > + Mark McGwire's + year was crippled + by a knee injury. diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.15.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.15.yaml new file mode 100644 index 0000000..09cbf06 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.15.yaml @@ -0,0 +1,8 @@ +--- > + Sammy Sosa completed another + fine season with great stats. + + 63 Home Runs + 0.288 Batting Average + + What a year! diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.16.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.16.yaml new file mode 100644 index 0000000..9f66d88 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.16.yaml @@ -0,0 +1,7 @@ +name: Mark McGwire +accomplishment: > + Mark set a major league + home run record in 1998. +stats: | + 65 Home Runs + 0.278 Batting Average diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.17.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.17.yaml new file mode 100644 index 0000000..c5c2a18 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.17.yaml @@ -0,0 +1,7 @@ +unicode: "Sosa did fine.\u263A" +control: "\b1998\t1999\t2000\n" +hex esc: "\x0d\x0a is \r\n" + +single: '"Howdy!" he cried.' +quoted: ' # Not a ''comment''.' +tie-fighter: '|\-*-/|' diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.18.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.18.yaml new file mode 100644 index 0000000..e0a8bfa --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.18.yaml @@ -0,0 +1,6 @@ +plain: + This unquoted scalar + spans many lines. + +quoted: "So does this + quoted scalar.\n" diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.19.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.19.yaml new file mode 100644 index 0000000..830ab3f --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.19.yaml @@ -0,0 +1,4 @@ +canonical: 12345 +decimal: +12345 +octal: 0o14 +hexadecimal: 0xC diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.20.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.20.yaml new file mode 100644 index 0000000..074c729 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.20.yaml @@ -0,0 +1,5 @@ +canonical: 1.23015e+3 +exponential: 12.3015e+02 +fixed: 1230.15 +negative infinity: -.inf +not a number: .nan diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.21.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.21.yaml new file mode 100644 index 0000000..510165d --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.21.yaml @@ -0,0 +1,3 @@ +null: +booleans: [ true, false ] +string: '012345' diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.22.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.22.yaml new file mode 100644 index 0000000..aaac185 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.22.yaml @@ -0,0 +1,4 @@ +canonical: 2001-12-15T02:59:43.1Z +iso8601: 2001-12-14t21:59:43.10-05:00 +spaced: 2001-12-14 21:59:43.10 -5 +date: 2002-12-14 diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.23.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.23.yaml new file mode 100644 index 0000000..5dbd992 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.23.yaml @@ -0,0 +1,13 @@ +--- +not-date: !!str 2002-04-28 + +picture: !!binary | + R0lGODlhDAAMAIQAAP//9/X + 17unp5WZmZgAAAOfn515eXv + Pz7Y6OjuDg4J+fn5OTk6enp + 56enmleECcgggoBADs= + +application specific tag: !something | + The semantics of the tag + above may be different for + different documents. diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.24.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.24.yaml new file mode 100644 index 0000000..1180757 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.24.yaml @@ -0,0 +1,14 @@ +%TAG ! tag:clarkevans.com,2002: +--- !shape + # Use the ! handle for presenting + # tag:clarkevans.com,2002:circle +- !circle + center: &ORIGIN {x: 73, y: 129} + radius: 7 +- !line + start: *ORIGIN + finish: { x: 89, y: 102 } +- !label + start: *ORIGIN + color: 0xFFEEBB + text: Pretty vector drawing. diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.25.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.25.yaml new file mode 100644 index 0000000..a723d5d --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.25.yaml @@ -0,0 +1,7 @@ +# Sets are represented as a +# Mapping where each key is +# associated with a null value +--- !!set +? Mark McGwire +? Sammy Sosa +? Ken Griffey diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.26.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.26.yaml new file mode 100644 index 0000000..5d871bf --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.26.yaml @@ -0,0 +1,7 @@ +# Ordered maps are represented as +# A sequence of mappings, with +# each mapping having one key +--- !!omap +- Mark McGwire: 65 +- Sammy Sosa: 63 +- Ken Griffey: 58 diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.27.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.27.yaml new file mode 100644 index 0000000..6a82497 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.27.yaml @@ -0,0 +1,29 @@ +--- ! +invoice: 34843 +date : 2001-01-23 +bill-to: &id001 + given : Chris + family : Dumars + address: + lines: | + 458 Walkman Dr. + Suite #292 + city : Royal Oak + state : MI + postal : 48046 +ship-to: *id001 +product: +- sku : BL394D + quantity : 4 + description : Basketball + price : 450.00 +- sku : BL4438H + quantity : 1 + description : Super Hoop + price : 2392.00 +tax : 251.42 +total: 4443.52 +comments: + Late afternoon is best. + Backup contact is Nancy + Billsmer @ 338-4338. diff --git a/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.28.yaml b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.28.yaml new file mode 100644 index 0000000..44c6325 --- /dev/null +++ b/tests/fixtures/yaml-spec-1.2.2/chapter-02/2.28.yaml @@ -0,0 +1,26 @@ +--- +Time: 2001-11-23 15:01:42 -5 +User: ed +Warning: + This is an error message + for the log file +--- +Time: 2001-11-23 15:02:31 -5 +User: ed +Warning: + A slightly different error + message. +--- +Date: 2001-11-23 15:03:17 -5 +User: ed +Fatal: + Unknown variable "bar" +Stack: +- file: TopClass.py + line: 23 + code: | + x = MoreObject("345\n") +- file: MoreClass.py + line: 58 + code: |- + foo = bar diff --git a/tests/fixtures/yaml-test-suite/2JQS.yaml b/tests/fixtures/yaml-test-suite/2JQS.yaml new file mode 100644 index 0000000..d0a086d --- /dev/null +++ b/tests/fixtures/yaml-test-suite/2JQS.yaml @@ -0,0 +1,2 @@ +: a +: b diff --git a/tests/fixtures/yaml-test-suite/565N.yaml b/tests/fixtures/yaml-test-suite/565N.yaml new file mode 100644 index 0000000..dcdb16f --- /dev/null +++ b/tests/fixtures/yaml-test-suite/565N.yaml @@ -0,0 +1,12 @@ +canonical: !!binary "\ + R0lGODlhDAAMAIQAAP//9/X17unp5WZmZgAAAOfn515eXvPz7Y6OjuDg4J+fn5\ + OTk6enp56enmlpaWNjY6Ojo4SEhP/++f/++f/++f/++f/++f/++f/++f/++f/+\ + +f/++f/++f/++f/++f/++SH+Dk1hZGUgd2l0aCBHSU1QACwAAAAADAAMAAAFLC\ + AgjoEwnuNAFOhpEMTRiggcz4BNJHrv/zCFcLiwMWYNG84BwwEeECcgggoBADs=" +generic: !!binary | + R0lGODlhDAAMAIQAAP//9/X17unp5WZmZgAAAOfn515eXvPz7Y6OjuDg4J+fn5 + OTk6enp56enmlpaWNjY6Ojo4SEhP/++f/++f/++f/++f/++f/++f/++f/++f/+ + +f/++f/++f/++f/++f/++SH+Dk1hZGUgd2l0aCBHSU1QACwAAAAADAAMAAAFLC + AgjoEwnuNAFOhpEMTRiggcz4BNJHrv/zCFcLiwMWYNG84BwwEeECcgggoBADs= +description: + The binary value above is a tiny arrow encoded as a gif image. diff --git a/tests/fixtures/yaml-test-suite/6BFJ.yaml b/tests/fixtures/yaml-test-suite/6BFJ.yaml new file mode 100644 index 0000000..19d1e3e --- /dev/null +++ b/tests/fixtures/yaml-test-suite/6BFJ.yaml @@ -0,0 +1,3 @@ +--- +&mapping +&key [ &item a, b, c ]: value diff --git a/tests/fixtures/yaml-test-suite/EHF6.yaml b/tests/fixtures/yaml-test-suite/EHF6.yaml new file mode 100644 index 0000000..f69ccde --- /dev/null +++ b/tests/fixtures/yaml-test-suite/EHF6.yaml @@ -0,0 +1,4 @@ +!!map { + k: !!seq + [ a, !!str b] +} diff --git a/tests/fixtures/yaml-test-suite/H7TQ.yaml b/tests/fixtures/yaml-test-suite/H7TQ.yaml new file mode 100644 index 0000000..79aadcd --- /dev/null +++ b/tests/fixtures/yaml-test-suite/H7TQ.yaml @@ -0,0 +1,2 @@ +%YAML 1.2 foo +--- diff --git a/tests/fixtures/yaml-test-suite/LICENSE.txt b/tests/fixtures/yaml-test-suite/LICENSE.txt new file mode 100644 index 0000000..5059e95 --- /dev/null +++ b/tests/fixtures/yaml-test-suite/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016-2020 Ingy döt Net + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/yaml-test-suite/M5DY.yaml b/tests/fixtures/yaml-test-suite/M5DY.yaml new file mode 100644 index 0000000..9123ce2 --- /dev/null +++ b/tests/fixtures/yaml-test-suite/M5DY.yaml @@ -0,0 +1,9 @@ +? - Detroit Tigers + - Chicago cubs +: + - 2001-07-23 + +? [ New York Yankees, + Atlanta Braves ] +: [ 2001-07-02, 2001-08-12, + 2001-08-14 ] diff --git a/tests/fixtures/yaml-test-suite/SBG9.yaml b/tests/fixtures/yaml-test-suite/SBG9.yaml new file mode 100644 index 0000000..e6e30e2 --- /dev/null +++ b/tests/fixtures/yaml-test-suite/SBG9.yaml @@ -0,0 +1 @@ +{a: [b, c], [d, e]: f} diff --git a/tests/fixtures/yaml-test-suite/SF5V.yaml b/tests/fixtures/yaml-test-suite/SF5V.yaml new file mode 100644 index 0000000..cb5488f --- /dev/null +++ b/tests/fixtures/yaml-test-suite/SF5V.yaml @@ -0,0 +1,3 @@ +%YAML 1.2 +%YAML 1.2 +--- diff --git a/tests/fixtures/yaml-test-suite/SOURCES.txt b/tests/fixtures/yaml-test-suite/SOURCES.txt new file mode 100644 index 0000000..e94dab1 --- /dev/null +++ b/tests/fixtures/yaml-test-suite/SOURCES.txt @@ -0,0 +1,22 @@ +yaml-test-suite reference fixtures +================================== + +Source repository: + https://github.com/yaml/yaml-test-suite +Pinned commit: + da267a5c4782e7361e82889e76c0dc7df0e1e870 +Source directory: + src/ + +The local YAML payloads are copied from these pinned test cases: + 2JQS Duplicate empty mapping keys + M5DY Complex sequence keys + SBG9 Flow sequence as a mapping key + 6BFJ Anchors on a mapping and complex key + 565N Explicit binary construction + EHF6 Explicit map and sequence tags + SF5V Duplicate YAML directive (invalid) + H7TQ Extra directive words (invalid) + VJP3 Multi-line flow indentation, invalid and valid variants + +See LICENSE.txt for the upstream MIT license. diff --git a/tests/fixtures/yaml-test-suite/VJP3-invalid.yaml b/tests/fixtures/yaml-test-suite/VJP3-invalid.yaml new file mode 100644 index 0000000..79c6eda --- /dev/null +++ b/tests/fixtures/yaml-test-suite/VJP3-invalid.yaml @@ -0,0 +1,5 @@ +k: { +k +: +v +} diff --git a/tests/fixtures/yaml-test-suite/VJP3-valid.yaml b/tests/fixtures/yaml-test-suite/VJP3-valid.yaml new file mode 100644 index 0000000..1d71c2a --- /dev/null +++ b/tests/fixtures/yaml-test-suite/VJP3-valid.yaml @@ -0,0 +1,5 @@ +k: { + k + : + v + }