diff --git a/.gitattributes b/.gitattributes index 96c2e0d..7527711 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,4 @@ *.png binary *.dll binary *.so binary +*.zip binary diff --git a/.github/PSModule.yml b/.github/PSModule.yml index b37f9f1..c807b64 100644 --- a/.github/PSModule.yml +++ b/.github/PSModule.yml @@ -4,7 +4,7 @@ Test: CodeCoverage: - PercentTarget: 50 + PercentTarget: 25 # TestResults: # Skip: true # SourceCode: @@ -16,10 +16,6 @@ Test: # Skip: true # MacOS: # Skip: true -# Build: -# Docs: -# Skip: true - Linter: env: VALIDATE_BIOME_FORMAT: false 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/mkdocs.yml b/.github/mkdocs.yml deleted file mode 100644 index df5e17a..0000000 --- a/.github/mkdocs.yml +++ /dev/null @@ -1,75 +0,0 @@ -site_name: -{{ REPO_NAME }}- -theme: - name: material - language: en - font: - text: Roboto - code: Sono - logo: Assets/icon.png - favicon: Assets/icon.png - palette: - # Palette toggle for automatic mode - - media: "(prefers-color-scheme)" - toggle: - icon: material/link - name: Switch to dark mode - # Palette toggle for dark mode - - media: '(prefers-color-scheme: dark)' - scheme: slate - toggle: - primary: black - accent: green - icon: material/toggle-switch-off-outline - name: Switch to light mode - # Palette toggle for light mode - - media: '(prefers-color-scheme: light)' - scheme: default - toggle: - primary: indigo - accent: green - icon: material/toggle-switch - name: Switch to system preference - icon: - repo: material/github - features: - - navigation.instant - - navigation.instant.progress - - navigation.indexes - - navigation.top - - navigation.tracking - - navigation.expand - - search.suggest - - search.highlight - -repo_name: -{{ REPO_OWNER }}-/-{{ REPO_NAME }}- -repo_url: https://github.com/-{{ REPO_OWNER }}-/-{{ REPO_NAME }}- - -plugins: - - search - -markdown_extensions: - - toc: - permalink: true # Adds a link icon to headings - - attr_list - - admonition - - md_in_html - - pymdownx.details # Enables collapsible admonitions - -extra: - social: - - icon: fontawesome/brands/discord - link: https://discord.gg/jedJWCPAhD - name: -{{ REPO_OWNER }}- on Discord - - icon: fontawesome/brands/github - link: https://github.com/-{{ REPO_OWNER }}-/ - name: -{{ REPO_OWNER }}- on GitHub - consent: - title: Cookie consent - description: >- - We use cookies to recognize your repeated visits and preferences, as well - as to measure the effectiveness of our documentation and whether users - find what they're searching for. With your consent, you're helping us to - make our documentation better. - actions: - - accept - - reject diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 67f2c25..932862f 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@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 + with: + ImportantFilePatterns: | + ^src/ + ^tests/ + ^README\.md$ secrets: inherit diff --git a/.github/zensical.toml b/.github/zensical.toml new file mode 100644 index 0000000..bc59d58 --- /dev/null +++ b/.github/zensical.toml @@ -0,0 +1,81 @@ +[project] +site_name = "-{{ REPO_NAME }}-" +repo_name = "-{{ REPO_OWNER }}-/-{{ REPO_NAME }}-" +repo_url = "https://github.com/-{{ REPO_OWNER }}-/-{{ REPO_NAME }}-" + +[project.theme] +language = "en" +font.text = "Roboto" +font.code = "Sono" +logo = "Assets/icon.png" +favicon = "Assets/icon.png" +features = [ + "navigation.instant", + "navigation.instant.progress", + "navigation.indexes", + "navigation.top", + "navigation.tracking", + "navigation.expand", + "search.suggest", + "search.highlight", +] + +[project.theme.icon] +repo = "material/github" + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "material/link" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "black" +accent = "green" +toggle.icon = "material/toggle-switch-off-outline" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "indigo" +accent = "green" +toggle.icon = "material/toggle-switch" +toggle.name = "Switch to system preference" + +[project.plugins.search] + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions.attr_list] + +[project.markdown_extensions.admonition] + +[project.markdown_extensions.md_in_html] + +[project.markdown_extensions.pymdownx.details] + +[[project.extra.social]] +icon = "fontawesome/brands/discord" +link = "https://discord.gg/jedJWCPAhD" +name = "-{{ REPO_OWNER }}- on Discord" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/-{{ REPO_OWNER }}-/" +name = "-{{ REPO_OWNER }}- on GitHub" + +[project.extra.consent] +title = "Cookie consent" +description = """ +We use cookies to recognize your repeated visits and preferences, as well as to +measure the effectiveness of our documentation and whether users find what +they're searching for. With your consent, you're helping us to make our +documentation better. +""" +actions = [ + "accept", + "reject", +] diff --git a/README.md b/README.md index 96936ee..ab32b0d 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,203 @@ -# {{ NAME }} +# Yaml -{{ DESCRIPTION }} +`Yaml` converts between YAML streams and PowerShell values. Its parser, +schema construction, graph projection, and emitter are implemented in the +module's PowerShell source, with no external parser library or runtime +assembly dependency. -## Prerequisites +Compatibility target: PowerShell Core LTS and newer, with the source and +artifact contract pinned to PowerShell 7.6 (`CompatiblePSEditions = 'Core'`). -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule) for building, testing and publishing the module. +The implementation is layered: Unicode and c-printable validation feeds a +document/directive scanner; context-aware block and flow readers produce syntax +tokens; an iterative composer builds the representation graph; the core-schema +constructor and PowerShell projector create public values. Serialization uses a +separate safe value classifier, iterative reference-graph normalizer, and +presentation emitter. Internal arrays, nulls, and byte arrays move through +boxed values rather than the PowerShell pipeline. ## Installation -To install the module from the PowerShell Gallery, you can use the following command: - ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +Install-PSResource -Name Yaml +Import-Module -Name Yaml ``` -## Usage +The module exports: + +| 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 -Here is a list of example that are typical use cases for the module. +Ordinary string-key mappings become ordered `PSCustomObject` values. A +top-level sequence writes its items to the pipeline by default. -### Example 1: Greet an entity +```powershell +$config = @' +name: example +enabled: true +ports: [80, 443] +'@ | ConvertFrom-Yaml + +$config.name +$config.ports[0] +``` -Provide examples for typical commands that a user would like to do with the module. +Pipeline strings are joined with LF and parsed as one stream. This makes +line-oriented input work as expected: ```powershell -Greet-Entity -Name 'World' -Hello, World! +$config = Get-Content -Path '.\config.yaml' | ConvertFrom-Yaml ``` -### Example 2 +Use `-NoEnumerate` when a top-level sequence must remain one pipeline record: + +```powershell +$servers = @' +- name: web-1 +- name: web-2 +'@ | ConvertFrom-Yaml -NoEnumerate +``` -Provide examples for typical commands that a user would like to do with the module. +Every YAML document is returned separately: ```powershell -Import-Module -Name PSModuleTemplate +$documents = @(@' +--- +name: first +--- +name: second +'@ | ConvertFrom-Yaml) ``` -### Find more examples +Use `-AsHashtable` for insertion-ordered dictionaries and mappings with +complex, non-string, empty, or case-colliding keys: -To find more examples of how to use the module, please refer to the [examples](examples) folder. +```powershell +$mapping = @' +? [region, port] +: eu-1 +'@ | ConvertFrom-Yaml -AsHashtable +``` -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'. +## Serialize PowerShell values -## Documentation +`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. -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. +```powershell +$yaml = [ordered]@{ + name = 'example' + enabled = $true + ports = @(80, 443) +} | ConvertTo-Yaml -ExplicitDocumentStart -## Contributing +$roundTrip = $yaml | ConvertFrom-Yaml +``` + +Multiple pipeline records are collected into one top-level YAML sequence: + +```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. The maximum supported depth is +128, and the default is 100. -### 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, 1048576 decoded + characters per scalar, 1024 characters per expanded tag, 65536 cumulative + expanded tag characters, and 4096 digits per numeric scalar. The + corresponding limit parameters can be lowered for untrusted input. +- The public maximum depth of 128 is exercised for parsing and serialization + in fresh PowerShell 7.6+ artifact tests. + +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. + +## Conformance corpus + +The offline test gate runs the complete released `yaml-test-suite` data corpus +at commit `6ad3d2c62885d82fc349026c136ef560838fdf3d` (generated from source +commit `45db50ae`). The pinned archive contains 402 inputs: + +- all 94 fixtures marked invalid are rejected; +- 306 of 308 fixtures marked valid are accepted; +- the other two valid-syntax fixtures, `2JQS` and `X38W`, are deliberately + rejected as a load/composition policy after syntactic recognition because this + module rejects duplicate mapping keys in the representation graph; +- 282 fixtures include `in.json`; three belong to invalid inputs, 277 of the + 279 applicable constructions match exactly, and two use a different + documented projection policy. + +The two construction-policy differences are `565N`, where this module +constructs `!!binary` as `byte[]` instead of a base64 string, and `J7PZ`, where +the explicitly supported `!!omap` tag becomes an ordered dictionary instead of +remaining a sequence of one-entry mappings. The deterministic runner reports +398 passing cases, four policy exclusions, and no unexplained failures. + +These results are a pinned compatibility measurement, not a claim that a finite +corpus proves complete YAML 1.2.2 compliance. + +## 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 integer CLR widths and enum CLR types are not reconstructed after a YAML +round trip. Finite non-exponent decimal values are constructed as `Decimal` +when representable; other finite floats use `Double`. The emitter writes a +deliberately limited YAML 1.2-compatible subset. -## 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/functions/private/Assert-YamlText.ps1 b/src/functions/private/Assert-YamlText.ps1 new file mode 100644 index 0000000..85126af --- /dev/null +++ b/src/functions/private/Assert-YamlText.ps1 @@ -0,0 +1,34 @@ +function Assert-YamlText { + <# + .SYNOPSIS + Validates YAML input characters before parsing. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml + ) + + $invalid = [regex]::Match( + $Yaml, + '[^\x09\x0A\x0D\x20-\x7E\x85\xA0-\uD7FF\uD800-\uDFFF\uE000-\uFFFD]|' + + '[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? + [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..31a4046 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlInteger.ps1 @@ -0,0 +1,63 @@ +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) + } + + if ($base -eq 10) { + $signedDigits = if ($sign -lt 0) { "-$digits" } else { $digits } + $result = [System.Numerics.BigInteger]::Zero + if (-not [System.Numerics.BigInteger]::TryParse( + $signedDigits, + [System.Globalization.NumberStyles]::Integer, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $result + )) { + throw [System.FormatException]::new("The YAML integer '$Value' is invalid.") + } + } else { + $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) { + return [int] $result + } + if ($result -ge [long]::MinValue -and $result -le [long]::MaxValue) { + return [long] $result + } + return $result +} diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 new file mode 100644 index 0000000..88dc837 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -0,0 +1,282 @@ +function ConvertFrom-YamlNode { + <# + .SYNOPSIS + Iteratively projects an internal YAML graph through a value box. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, object]] $Cache, + + [switch] $AsHashtable + ) + + $root = [pscustomobject]@{ Value = $null } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Node = $Node + Holder = $root + AsHashtable = [bool] $AsHashtable + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + $effective = $frame.Node + while ($effective.Kind -eq 'Alias') { + $effective = $effective.Target + } + $frame.Node = $effective + + if ($effective.Kind -eq 'Scalar') { + $frame.Holder.PSObject.Properties['Value'].Value = (Resolve-YamlScalar -Node $effective).Value + [void] $stack.Pop() + continue + } + if ($Cache.ContainsKey($effective.Id)) { + $frame.Holder.PSObject.Properties['Value'].Value = $Cache[$effective.Id] + [void] $stack.Pop() + continue + } + + if ($effective.Kind -eq 'Sequence') { + if ($effective.Tag -ceq 'tag:yaml.org,2002:omap') { + $frame.Result = [System.Collections.Specialized.OrderedDictionary]::new() + $frame.State = 'OmapKey' + } else { + $frame.Result = [object[]]::new($effective.Items.Count) + $frame.State = 'Sequence' + } + } elseif ($frame.AsHashtable -or + $effective.Tag -ceq 'tag:yaml.org,2002:set') { + $frame.Result = [System.Collections.Specialized.OrderedDictionary]::new() + $frame.State = 'DictionaryKey' + } else { + $frame.Result = [pscustomobject]@{} + $frame.Names = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $frame.State = 'PropertyKey' + } + $Cache[$effective.Id] = $frame.Result + $frame.Holder.PSObject.Properties['Value'].Value = $frame.Result + continue + } + + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Node.Items.Count) { + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Items[$frame.Index] + Holder = $frame.Child + AsHashtable = $frame.AsHashtable -or + $frame.Node.Tag -ceq 'tag:yaml.org,2002:pairs' + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Result[$frame.Index] = $frame.Child.PSObject.Properties['Value'].Value + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'OmapKey') { + if ($frame.Index -ge $frame.Node.Items.Count) { + [void] $stack.Pop() + continue + } + $entryNode = $frame.Node.Items[$frame.Index] + while ($entryNode.Kind -eq 'Alias') { + $entryNode = $entryNode.Target + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.Key = $entryNode + $frame.State = 'OmapKeyValue' + $stack.Push([pscustomobject]@{ + Node = $entryNode.Entries[0].Key + Holder = $frame.Child + AsHashtable = $true + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'OmapKeyValue') { + $keyValue = $frame.Child.PSObject.Properties['Value'].Value + if ([object]::ReferenceEquals($keyValue, $null)) { + $frame.Key = [System.DBNull]::Value + } else { + $frame.Key = $keyValue + } + $entryNode = $frame.Node.Items[$frame.Index] + while ($entryNode.Kind -eq 'Alias') { + $entryNode = $entryNode.Target + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'OmapValue' + $stack.Push([pscustomobject]@{ + Node = $entryNode.Entries[0].Value + Holder = $frame.Child + AsHashtable = $true + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'OmapValue') { + $frame.Result.Add($frame.Key, $frame.Child.PSObject.Properties['Value'].Value) + $frame.Index++ + $frame.State = 'OmapKey' + continue + } + + if ($frame.State -eq 'DictionaryKey') { + if ($frame.Index -ge $frame.Node.Entries.Count) { + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'DictionaryKeyValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Key + Holder = $frame.Child + AsHashtable = $frame.AsHashtable + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'DictionaryKeyValue') { + $keyValue = $frame.Child.PSObject.Properties['Value'].Value + if ([object]::ReferenceEquals($keyValue, $null)) { + $frame.Key = [System.DBNull]::Value + } else { + $frame.Key = $keyValue + } + if ($frame.Node.Tag -ceq 'tag:yaml.org,2002:set') { + $frame.Result.Add($frame.Key, $null) + $frame.Index++ + $frame.State = 'DictionaryKey' + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'DictionaryValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Value + Holder = $frame.Child + AsHashtable = $frame.AsHashtable + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'DictionaryValue') { + $frame.Result.Add($frame.Key, $frame.Child.PSObject.Properties['Value'].Value) + $frame.Index++ + $frame.State = 'DictionaryKey' + continue + } + + if ($frame.State -eq 'PropertyKey') { + if ($frame.Index -ge $frame.Node.Entries.Count) { + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'PropertyKeyValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Key + Holder = $frame.Child + AsHashtable = $false + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'PropertyKeyValue') { + $frame.Key = $frame.Child.PSObject.Properties['Value'].Value + if ($frame.Key -isnot [string] -or [string]::IsNullOrEmpty($frame.Key)) { + $keyNode = $frame.Node.Entries[$frame.Index].Key + throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` + -ErrorId 'YamlMappingKeyNotString' -Message ( + 'This mapping key cannot be represented as a PSCustomObject property. Use -AsHashtable.' + )) + } + if (-not $frame.Names.Add($frame.Key)) { + $keyNode = $frame.Node.Entries[$frame.Index].Key + throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` + -ErrorId 'YamlPropertyNameCollision' -Message ( + "The mapping keys contain a case-insensitive property collision for '$($frame.Key)'. Use -AsHashtable." + )) + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'PropertyValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Value + Holder = $frame.Child + AsHashtable = $false + State = 'Start' + Index = 0 + Result = $null + Child = $null + Key = $null + Names = $null + }) + continue + } + if ($frame.State -eq 'PropertyValue') { + $frame.Result.PSObject.Properties.Add( + [System.Management.Automation.PSNoteProperty]::new( + [string] $frame.Key, + $frame.Child.PSObject.Properties['Value'].Value + ) + ) + $frame.Index++ + $frame.State = 'PropertyKey' + } + } + + New-YamlValueBox -Value $root.PSObject.Properties['Value'].Value +} diff --git a/src/functions/private/ConvertFrom-YamlSyntaxTree.ps1 b/src/functions/private/ConvertFrom-YamlSyntaxTree.ps1 new file mode 100644 index 0000000..d5ba751 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlSyntaxTree.ps1 @@ -0,0 +1,145 @@ +function ConvertFrom-YamlSyntaxTree { + <# + .SYNOPSIS + Iteratively composes syntax tokens into a representation graph. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Root + ) + + $result = [pscustomobject]@{ Value = $null } + $cache = [System.Collections.Generic.Dictionary[int, object]]::new() + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Source = $Root + Holder = $result + Target = $null + Child = $null + State = 'Start' + Index = 0 + Key = $null + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + if ($cache.ContainsKey($frame.Source.Id)) { + $frame.Holder.Value = $cache[$frame.Source.Id] + [void] $stack.Pop() + continue + } + + $target = New-YamlNode -Id $frame.Source.Id -Kind $frame.Source.Kind ` + -Start $frame.Source.Start -End $frame.Source.End + $target.Tag = $frame.Source.Tag + $target.HasUnknownTag = $frame.Source.HasUnknownTag + $target.Anchor = $frame.Source.Anchor + $target.Value = $frame.Source.Value + $target.Style = $frame.Source.Style + $target.IsPlainImplicit = $frame.Source.IsPlainImplicit + $target.IsQuotedImplicit = $frame.Source.IsQuotedImplicit + $target.MaxNumericLength = $frame.Source.MaxNumericLength + $cache[$frame.Source.Id] = $target + $frame.Target = $target + $frame.Holder.Value = $target + + if ($frame.Source.Kind -eq 'Scalar') { + [void] $stack.Pop() + } elseif ($frame.Source.Kind -eq 'Alias') { + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'Alias' + $stack.Push([pscustomobject]@{ + Source = $frame.Source.Target + Holder = $frame.Child + Target = $null + Child = $null + State = 'Start' + Index = 0 + Key = $null + }) + } elseif ($frame.Source.Kind -eq 'Sequence') { + $frame.State = 'Sequence' + } else { + $frame.State = 'MappingKey' + } + continue + } + + if ($frame.State -eq 'Alias') { + $frame.Target.Target = $frame.Child.Value + [void] $stack.Pop() + continue + } + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Source.Items.Count) { + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Source = $frame.Source.Items[$frame.Index] + Holder = $frame.Child + Target = $null + Child = $null + State = 'Start' + Index = 0 + Key = $null + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Target.Items.Add($frame.Child.Value) + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'MappingKey') { + if ($frame.Index -ge $frame.Source.Entries.Count) { + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'MappingKeyValue' + $stack.Push([pscustomobject]@{ + Source = $frame.Source.Entries[$frame.Index].Key + Holder = $frame.Child + Target = $null + Child = $null + State = 'Start' + Index = 0 + Key = $null + }) + continue + } + if ($frame.State -eq 'MappingKeyValue') { + $frame.Key = $frame.Child.Value + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'MappingValue' + $stack.Push([pscustomobject]@{ + Source = $frame.Source.Entries[$frame.Index].Value + Holder = $frame.Child + Target = $null + Child = $null + State = 'Start' + Index = 0 + Key = $null + }) + continue + } + if ($frame.State -eq 'MappingValue') { + $frame.Target.Entries.Add([pscustomobject]@{ + Key = $frame.Key + Value = $frame.Child.Value + }) + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + $result.Value +} diff --git a/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 b/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 new file mode 100644 index 0000000..75b3cdd --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 @@ -0,0 +1,66 @@ +function ConvertFrom-YamlTagUriEscape { + <# + .SYNOPSIS + Decodes YAML tag URI %xx escapes as UTF-8. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] $Text, + + [Parameter(Mandatory)] + [pscustomobject] $Mark, + + [Parameter(Mandatory)] + [string] $Token + ) + + $builder = [System.Text.StringBuilder]::new() + $escapedBytes = [System.Collections.Generic.List[byte]]::new() + $utf8 = [System.Text.UTF8Encoding]::new($false, $true) + + for ($index = 0; $index -lt $Text.Length; $index++) { + $character = $Text[$index] + if ($character -ne '%') { + if ($escapedBytes.Count -gt 0) { + try { + [void] $builder.Append($utf8.GetString($escapedBytes.ToArray())) + } catch [System.Text.DecoderFallbackException] { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' contains an invalid UTF-8 escape sequence." + )) + } + $escapedBytes.Clear() + } + [void] $builder.Append($character) + continue + } + + if ($index + 2 -ge $Text.Length) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' contains a malformed percent escape." + )) + } + $pair = '{0}{1}' -f $Text[$index + 1], $Text[$index + 2] + if ($pair -cnotmatch '^[0-9A-Fa-f]{2}$') { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' contains a malformed percent escape." + )) + } + $escapedBytes.Add([System.Convert]::ToByte($pair, 16)) + $index += 2 + } + + if ($escapedBytes.Count -gt 0) { + try { + [void] $builder.Append($utf8.GetString($escapedBytes.ToArray())) + } catch [System.Text.DecoderFallbackException] { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' contains an invalid UTF-8 escape sequence." + )) + } + } + + $builder.ToString() +} diff --git a/src/functions/private/ConvertTo-YamlFlowText.ps1 b/src/functions/private/ConvertTo-YamlFlowText.ps1 new file mode 100644 index 0000000..d5c12e8 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlFlowText.ps1 @@ -0,0 +1,142 @@ +function ConvertTo-YamlFlowText { + <# + .SYNOPSIS + Iteratively renders one emission graph in flow form. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[long]] $EmittedReferences + ) + + $root = [pscustomobject]@{ Value = '' } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Node = $Node + Holder = $root + State = 'Start' + Index = 0 + Prefix = '' + Parts = $null + Child = $null + KeyText = '' + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + if ($frame.Node.ReferenceId -ne 0 -and + $EmittedReferences.Contains($frame.Node.ReferenceId)) { + $frame.Holder.Value = "*$($frame.Node.Anchor)" + [void] $stack.Pop() + continue + } + if ($frame.Node.ReferenceId -ne 0) { + [void] $EmittedReferences.Add($frame.Node.ReferenceId) + } + $frame.Prefix = Get-YamlEmissionPrefix -Node $frame.Node + if (-not [string]::IsNullOrEmpty($frame.Prefix)) { + $frame.Prefix += ' ' + } + + if ($frame.Node.Kind -eq 'Scalar') { + $text = if ($frame.Node.Style -eq 'Plain') { + $frame.Node.Value + } else { + ConvertTo-YamlQuotedText -Value $frame.Node.Value + } + $frame.Holder.Value = $frame.Prefix + $text + [void] $stack.Pop() + continue + } + + $frame.Parts = [System.Collections.Generic.List[string]]::new() + $frame.State = if ($frame.Node.Kind -eq 'Sequence') { + 'Sequence' + } else { + 'MappingKey' + } + continue + } + + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Node.Items.Count) { + $frame.Holder.Value = $frame.Prefix + '[{0}]' -f ( + $frame.Parts -join ', ' + ) + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Items[$frame.Index] + Holder = $frame.Child + State = 'Start' + Index = 0 + Prefix = '' + Parts = $null + Child = $null + KeyText = '' + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Parts.Add($frame.Child.Value) + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'MappingKey') { + if ($frame.Index -ge $frame.Node.Entries.Count) { + $frame.Holder.Value = $frame.Prefix + '{{{0}}}' -f ( + $frame.Parts -join ', ' + ) + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'MappingKeyValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Key + Holder = $frame.Child + State = 'Start' + Index = 0 + Prefix = '' + Parts = $null + Child = $null + KeyText = '' + }) + continue + } + if ($frame.State -eq 'MappingKeyValue') { + $frame.KeyText = $frame.Child.Value + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'MappingValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Value + Holder = $frame.Child + State = 'Start' + Index = 0 + Prefix = '' + Parts = $null + Child = $null + KeyText = '' + }) + continue + } + if ($frame.State -eq 'MappingValue') { + $frame.Parts.Add("$($frame.KeyText)`: $($frame.Child.Value)") + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + $root.Value +} diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 new file mode 100644 index 0000000..fc333f0 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -0,0 +1,194 @@ +function ConvertTo-YamlNode { + <# + .SYNOPSIS + Iteratively normalizes a supported PowerShell value to an emission graph. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [switch] $EnumsAsStrings + ) + + $root = [pscustomobject]@{ Value = $null } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Value = $Value + Depth = $Depth + Holder = $root + State = 'Start' + Shape = $null + Node = $null + ReferenceId = [long] 0 + Index = 0 + Child = $null + KeyNode = $null + Fingerprints = $null + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + if ($frame.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." + )) + } + + $frame.Shape = Get-YamlSerializationShape -Value $frame.Value -State $State ` + -EnumsAsStrings:$EnumsAsStrings + if ($frame.Shape.Kind -eq 'Scalar') { + $frame.Holder.Value = $frame.Shape.Node + [void] $stack.Pop() + continue + } + + $firstTime = $false + $frame.ReferenceId = $State.IdGenerator.GetId($frame.Value, [ref] $firstTime) + if (-not $firstTime) { + $State.ReferenceCounts[$frame.ReferenceId]++ + if ($State.Active.Contains($frame.ReferenceId)) { + throw (New-YamlSerializationException -ErrorId 'YamlCycleDetected' -Message ( + "A cycle was detected while serializing type '$($frame.Value.GetType().FullName)'." + )) + } + $frame.Holder.Value = $State.NodesById[$frame.ReferenceId] + [void] $stack.Pop() + continue + } + + $State.ReferenceCounts[$frame.ReferenceId] = 1 + $State.ReferenceOrder.Add($frame.ReferenceId) + if ($frame.Shape.Kind -eq 'Binary') { + $frame.Shape.Node.ReferenceId = $frame.ReferenceId + $State.NodesById[$frame.ReferenceId] = $frame.Shape.Node + $frame.Holder.Value = $frame.Shape.Node + [void] $stack.Pop() + continue + } + + $frame.Node = New-YamlEmissionNode -Kind $frame.Shape.Kind + $frame.Node.ReferenceId = $frame.ReferenceId + $State.NodesById[$frame.ReferenceId] = $frame.Node + $frame.Holder.Value = $frame.Node + [void] $State.Active.Add($frame.ReferenceId) + if ($frame.Shape.Kind -eq 'Mapping') { + $frame.Fingerprints = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + $frame.State = 'MappingKey' + } else { + $frame.State = 'Sequence' + } + continue + } + + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Shape.Values.Count) { + [void] $State.Active.Remove($frame.ReferenceId) + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Value = [object] $frame.Shape.Values[$frame.Index] + Depth = $frame.Depth + 1 + Holder = $frame.Child + State = 'Start' + Shape = $null + Node = $null + ReferenceId = [long] 0 + Index = 0 + Child = $null + KeyNode = $null + Fingerprints = $null + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Node.Items.Add($frame.Child.Value) + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'MappingKey') { + if ($frame.Index -ge $frame.Shape.Values.Count) { + [void] $State.Active.Remove($frame.ReferenceId) + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'MappingKeyValue' + $stack.Push([pscustomobject]@{ + Value = [object] $frame.Shape.Values[$frame.Index].Key + Depth = $frame.Depth + 1 + Holder = $frame.Child + State = 'Start' + Shape = $null + Node = $null + ReferenceId = [long] 0 + Index = 0 + Child = $null + KeyNode = $null + Fingerprints = $null + }) + continue + } + if ($frame.State -eq 'MappingKeyValue') { + $frame.KeyNode = $frame.Child.Value + $fingerprint = Get-YamlEmissionNodeFingerprint -Node $frame.KeyNode ` + -Cache $State.Fingerprints ` + -Active ([System.Collections.Generic.HashSet[long]]::new()) ` + -Hasher $State.FingerprintHasher + if (-not $frame.Fingerprints.Add($fingerprint)) { + throw (New-YamlSerializationException -ErrorId 'YamlDuplicateKey' -Message ( + 'Two mapping keys normalize to the same YAML value.' + )) + } + $frame.Child = [pscustomobject]@{ Value = $null } + $frame.State = 'MappingValue' + $stack.Push([pscustomobject]@{ + Value = [object] $frame.Shape.Values[$frame.Index].Value + Depth = $frame.Depth + 1 + Holder = $frame.Child + State = 'Start' + Shape = $null + Node = $null + ReferenceId = [long] 0 + Index = 0 + Child = $null + KeyNode = $null + Fingerprints = $null + }) + continue + } + if ($frame.State -eq 'MappingValue') { + $frame.Node.Entries.Add([pscustomobject]@{ + Key = $frame.KeyNode + Value = $frame.Child.Value + }) + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + $root.Value +} diff --git a/src/functions/private/ConvertTo-YamlQuotedText.ps1 b/src/functions/private/ConvertTo-YamlQuotedText.ps1 new file mode 100644 index 0000000..e6deb67 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlQuotedText.ps1 @@ -0,0 +1,63 @@ +function ConvertTo-YamlQuotedText { + <# + .SYNOPSIS + Encodes a string as a YAML double-quoted scalar. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Value + ) + + $builder = [System.Text.StringBuilder]::new() + [void] $builder.Append('"') + for ($index = 0; $index -lt $Value.Length; $index++) { + $character = $Value[$index] + $code = [int] $character + if ([char]::IsHighSurrogate($character)) { + if ($index + 1 -ge $Value.Length -or -not [char]::IsLowSurrogate($Value[$index + 1])) { + throw [System.ArgumentException]::new( + 'A YAML string cannot contain an unpaired UTF-16 high surrogate.' + ) + } + [void] $builder.Append($character) + [void] $builder.Append($Value[++$index]) + } elseif ([char]::IsLowSurrogate($character)) { + throw [System.ArgumentException]::new( + 'A YAML string cannot contain an unpaired UTF-16 low surrogate.' + ) + } elseif ($code -eq 0) { + [void] $builder.Append('\0') + } elseif ($code -eq 7) { + [void] $builder.Append('\a') + } elseif ($code -eq 8) { + [void] $builder.Append('\b') + } elseif ($code -eq 9) { + [void] $builder.Append('\t') + } elseif ($code -eq 10) { + [void] $builder.Append('\n') + } elseif ($code -eq 11) { + [void] $builder.Append('\v') + } elseif ($code -eq 12) { + [void] $builder.Append('\f') + } elseif ($code -eq 13) { + [void] $builder.Append('\r') + } elseif ($code -eq 27) { + [void] $builder.Append('\e') + } elseif ($code -eq 34) { + [void] $builder.Append('\"') + } elseif ($code -eq 92) { + [void] $builder.Append('\\') + } elseif ($code -lt 0x20 -or $code -eq 0x7F -or + $code -eq 0xFFFE -or $code -eq 0xFFFF -or + ($code -ge 0x80 -and $code -le 0x9F)) { + [void] $builder.Append(('\u{0:X4}' -f $code)) + } else { + [void] $builder.Append($character) + } + } + [void] $builder.Append('"') + 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..ee4cd7c --- /dev/null +++ b/src/functions/private/ConvertTo-YamlText.ps1 @@ -0,0 +1,26 @@ +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 + ) + + $builder = [System.Text.StringBuilder]::new() + if ($ExplicitDocumentStart) { + [void] $builder.Append("---`n") + } + Write-YamlNodeText -Builder $builder -Node $Node -Level 0 -Indent $Indent ` + -EmittedReferences ([System.Collections.Generic.HashSet[long]]::new()) + return $builder.ToString() +} diff --git a/src/functions/private/Find-YamlMappingColon.ps1 b/src/functions/private/Find-YamlMappingColon.ps1 new file mode 100644 index 0000000..bb6ba59 --- /dev/null +++ b/src/functions/private/Find-YamlMappingColon.ps1 @@ -0,0 +1,111 @@ +function Find-YamlMappingColon { + <# + .SYNOPSIS + Finds a block mapping value indicator outside quoted and flow content. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + if ($Text.IndexOf(':') -lt 0) { + return -1 + } + + $flowDepth = 0 + $singleQuoted = $false + $doubleQuoted = $false + $atNodeStart = $true + for ($index = 0; $index -lt $Text.Length; $index++) { + $character = $Text[$index] + if ($doubleQuoted) { + if ($character -eq '\') { + $index++ + } elseif ($character -eq '"') { + $doubleQuoted = $false + } + continue + } + if ($singleQuoted) { + if ($character -eq "'") { + if ($index + 1 -lt $Text.Length -and $Text[$index + 1] -eq "'") { + $index++ + } else { + $singleQuoted = $false + } + } + continue + } + if ($atNodeStart -and [char]::IsWhiteSpace($character)) { + continue + } + if ($atNodeStart -and $character -eq '&') { + $index++ + while ($index -lt $Text.Length -and -not [char]::IsWhiteSpace($Text[$index])) { + $index++ + } + $index-- + continue + } + if ($atNodeStart -and $character -eq '*') { + $index++ + while ($index -lt $Text.Length -and -not [char]::IsWhiteSpace($Text[$index])) { + $index++ + } + $index-- + continue + } + if ($atNodeStart -and $character -eq '!') { + if ($index + 1 -lt $Text.Length -and $Text[$index + 1] -eq '<') { + $index = $Text.IndexOf('>', $index + 2) + if ($index -lt 0) { + return -1 + } + } else { + $index++ + while ($index -lt $Text.Length -and + -not [char]::IsWhiteSpace($Text[$index]) -and + $Text[$index] -notin @('[', ']', '{', '}', ',')) { + $index++ + } + $index-- + } + continue + } + if ($atNodeStart -and $character -eq '"') { + $doubleQuoted = $true + $atNodeStart = $false + continue + } + if ($atNodeStart -and $character -eq "'") { + $singleQuoted = $true + $atNodeStart = $false + continue + } + $atNodeStart = $false + switch ($character) { + '[' { $flowDepth++; continue } + '{' { $flowDepth++; continue } + ']' { if ($flowDepth -gt 0) { $flowDepth-- }; continue } + '}' { if ($flowDepth -gt 0) { $flowDepth-- }; continue } + ':' { + if ($flowDepth -eq 0) { + $nextIsSeparator = $index + 1 -ge $Text.Length + $nextIsSeparator = $nextIsSeparator -or [char]::IsWhiteSpace($Text[$index + 1]) + if ($nextIsSeparator) { + return $index + } + } + } + '#' { + if ($flowDepth -eq 0 -and ($index -eq 0 -or [char]::IsWhiteSpace($Text[$index - 1]))) { + return -1 + } + } + } + } + return -1 +} diff --git a/src/functions/private/Get-YamlContentWithoutComment.ps1 b/src/functions/private/Get-YamlContentWithoutComment.ps1 new file mode 100644 index 0000000..61c44eb --- /dev/null +++ b/src/functions/private/Get-YamlContentWithoutComment.ps1 @@ -0,0 +1,19 @@ +function Get-YamlContentWithoutComment { + <# + .SYNOPSIS + Removes a separated comment from non-flow scalar text. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $comment = [regex]::Match($Text, '(? + [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 + ) + + $root = [pscustomobject]@{ Value = '' } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Node = $Node + Holder = $root + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + $hasReferenceId = $frame.Node.ReferenceId -ne 0 + $cached = '' + if ($hasReferenceId -and + $Cache.TryGetValue($frame.Node.ReferenceId, [ref] $cached)) { + $frame.Holder.Value = $cached + [void] $stack.Pop() + continue + } + if ($hasReferenceId -and -not $Active.Add($frame.Node.ReferenceId)) { + throw (New-YamlSerializationException -ErrorId 'YamlCycleDetected' -Message ( + 'A cycle was detected while fingerprinting a YAML mapping key.' + )) + } + + if ($frame.Node.Kind -eq 'Scalar') { + $isPlainImplicit = [string]::IsNullOrEmpty($frame.Node.Tag) -and + $frame.Node.Style -eq 'Plain' + $mark = New-YamlMark -Index 0 -Line 0 -Column 0 + $resolved = Resolve-YamlScalar -Node ([pscustomobject]@{ + Tag = $frame.Node.Tag + HasUnknownTag = $false + Value = $frame.Node.Value + IsPlainImplicit = $isPlainImplicit + Start = $mark + End = $mark + ResolutionState = 0 + ResolvedValue = $null + MaxNumericLength = 1048576 + }) + $fingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher + if ($hasReferenceId) { + $Cache[$frame.Node.ReferenceId] = $fingerprint + [void] $Active.Remove($frame.Node.ReferenceId) + } + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + + $frame.Parts = [System.Collections.Generic.List[string]]::new() + $frame.State = if ($frame.Node.Kind -eq 'Sequence') { + 'Sequence' + } else { + 'MappingKey' + } + continue + } + + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Node.Items.Count) { + $fingerprint = Get-YamlFingerprintHash -Value ( + 'sequence:{0}' -f ($frame.Parts -join '|') + ) -Hasher $Hasher + if ($frame.Node.ReferenceId -ne 0) { + $Cache[$frame.Node.ReferenceId] = $fingerprint + [void] $Active.Remove($frame.Node.ReferenceId) + } + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Items[$frame.Index] + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Parts.Add($frame.Child.Value) + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'MappingKey') { + if ($frame.Index -ge $frame.Node.Entries.Count) { + $frame.Parts.Sort([System.StringComparer]::Ordinal) + $fingerprint = Get-YamlFingerprintHash -Value ( + 'mapping:{0}' -f ($frame.Parts -join '|') + ) -Hasher $Hasher + if ($frame.Node.ReferenceId -ne 0) { + $Cache[$frame.Node.ReferenceId] = $fingerprint + [void] $Active.Remove($frame.Node.ReferenceId) + } + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'MappingKeyValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Key + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + continue + } + if ($frame.State -eq 'MappingKeyValue') { + $frame.KeyHash = $frame.Child.Value + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'MappingValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Value + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + continue + } + if ($frame.State -eq 'MappingValue') { + $frame.Parts.Add( + (Get-YamlFingerprintHash -Value ( + "entry:$($frame.KeyHash)=$($frame.Child.Value)" + ) -Hasher $Hasher) + ) + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + $root.Value +} diff --git a/src/functions/private/Get-YamlEmissionPrefix.ps1 b/src/functions/private/Get-YamlEmissionPrefix.ps1 new file mode 100644 index 0000000..d674e29 --- /dev/null +++ b/src/functions/private/Get-YamlEmissionPrefix.ps1 @@ -0,0 +1,26 @@ +function Get-YamlEmissionPrefix { + <# + .SYNOPSIS + Gets anchor and standard-tag presentation for one emission node. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + $parts = [System.Collections.Generic.List[string]]::new() + if (-not [string]::IsNullOrEmpty($Node.Anchor)) { + $parts.Add("&$($Node.Anchor)") + } + if (-not [string]::IsNullOrEmpty($Node.Tag)) { + $prefix = 'tag:yaml.org,2002:' + if ($Node.Tag.StartsWith($prefix, [System.StringComparison]::Ordinal)) { + $parts.Add("!!$($Node.Tag.Substring($prefix.Length))") + } else { + $parts.Add("!<$($Node.Tag)>") + } + } + return $parts -join ' ' +} 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-YamlIndent.ps1 b/src/functions/private/Get-YamlIndent.ps1 new file mode 100644 index 0000000..474c64c --- /dev/null +++ b/src/functions/private/Get-YamlIndent.ps1 @@ -0,0 +1,29 @@ +function Get-YamlIndent { + <# + .SYNOPSIS + Gets a line's space indentation. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Line metadata is part of the shared indentation-reader contract.' + )] + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Line, + + [Parameter(Mandatory)] + [int] $LineNumber, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $indent = 0 + while ($indent -lt $Line.Length -and $Line[$indent] -eq ' ') { + $indent++ + } + return $indent +} diff --git a/src/functions/private/Get-YamlNodeFingerprint.ps1 b/src/functions/private/Get-YamlNodeFingerprint.ps1 new file mode 100644 index 0000000..9206142 --- /dev/null +++ b/src/functions/private/Get-YamlNodeFingerprint.ps1 @@ -0,0 +1,171 @@ +function Get-YamlNodeFingerprint { + <# + .SYNOPSIS + Iteratively creates a structural fingerprint for 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 + ) + + $root = [pscustomobject]@{ Value = '' } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Node = $Node + Holder = $root + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + $effective = $frame.Node + while ($effective.Kind -eq 'Alias') { + $effective = $effective.Target + } + $frame.Node = $effective + + $cached = '' + if ($Cache.TryGetValue($effective.Id, [ref] $cached)) { + $frame.Holder.Value = $cached + [void] $stack.Pop() + continue + } + if (-not $Active.Add($effective.Id)) { + throw (New-YamlException -Start $effective.Start -End $effective.End ` + -ErrorId 'YamlCyclicMappingKey' -Message ( + 'A cyclic YAML node cannot be used as a mapping key.' + )) + } + + if ($effective.Kind -eq 'Scalar') { + $resolved = Resolve-YamlScalar -Node $effective + $fingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher + $Cache[$effective.Id] = $fingerprint + [void] $Active.Remove($effective.Id) + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + + $frame.Parts = [System.Collections.Generic.List[string]]::new() + $frame.State = if ($effective.Kind -eq 'Sequence') { + 'Sequence' + } else { + 'MappingKey' + } + continue + } + + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Node.Items.Count) { + $semanticTag = if ( + $frame.Node.Tag -ceq 'tag:yaml.org,2002:omap' -or + $frame.Node.Tag -ceq 'tag:yaml.org,2002:pairs' + ) { + $frame.Node.Tag + } else { + 'tag:yaml.org,2002:seq' + } + $canonical = 'sequence:{0}:{1}' -f $semanticTag, ($frame.Parts -join '|') + $fingerprint = Get-YamlFingerprintHash -Value $canonical -Hasher $Hasher + $Cache[$frame.Node.Id] = $fingerprint + [void] $Active.Remove($frame.Node.Id) + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Items[$frame.Index] + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Parts.Add($frame.Child.Value) + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'MappingKey') { + if ($frame.Index -ge $frame.Node.Entries.Count) { + $frame.Parts.Sort([System.StringComparer]::Ordinal) + $mappingTag = if ($frame.Node.Tag -ceq 'tag:yaml.org,2002:set') { + $frame.Node.Tag + } else { + 'tag:yaml.org,2002:map' + } + $canonical = 'mapping:{0}:{1}' -f $mappingTag, ($frame.Parts -join '|') + $fingerprint = Get-YamlFingerprintHash -Value $canonical -Hasher $Hasher + $Cache[$frame.Node.Id] = $fingerprint + [void] $Active.Remove($frame.Node.Id) + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'MappingKeyValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Key + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + continue + } + if ($frame.State -eq 'MappingKeyValue') { + $frame.KeyHash = $frame.Child.Value + $frame.Child = [pscustomobject]@{ Value = '' } + $frame.State = 'MappingValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Entries[$frame.Index].Value + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + }) + continue + } + if ($frame.State -eq 'MappingValue') { + $frame.Parts.Add( + (Get-YamlFingerprintHash -Value ( + "entry:$($frame.KeyHash)=$($frame.Child.Value)" + ) -Hasher $Hasher) + ) + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + $root.Value +} diff --git a/src/functions/private/Get-YamlScalarFingerprint.ps1 b/src/functions/private/Get-YamlScalarFingerprint.ps1 new file mode 100644 index 0000000..a438342 --- /dev/null +++ b/src/functions/private/Get-YamlScalarFingerprint.ps1 @@ -0,0 +1,67 @@ +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:{0}' -f $Value.UtcDateTime.Ticks + } elseif ($Value -is [datetime]) { + $utcValue = if ($Value.Kind -eq [System.DateTimeKind]::Utc) { + $Value + } elseif ($Value.Kind -eq [System.DateTimeKind]::Local) { + $Value.ToUniversalTime() + } else { + [datetime]::SpecifyKind($Value, [System.DateTimeKind]::Utc) + } + $canonicalValue = 'timestamp:{0}' -f $utcValue.Ticks + } elseif ($Value -is [decimal]) { + if ($Value -eq 0 -and + ([decimal]::GetBits($Value)[3] -band [int]::MinValue) -ne 0) { + $canonicalValue = 'float:-0' + } else { + $canonicalValue = 'float:{0}' -f $Value.ToString( + 'G29', + [cultureinfo]::InvariantCulture + ) + } + } 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) { + $negative = [System.BitConverter]::DoubleToInt64Bits([double] $Value) -lt 0 + $canonicalValue = if ($negative) { 'float:-0' } else { '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/Get-YamlSerializationShape.ps1 b/src/functions/private/Get-YamlSerializationShape.ps1 new file mode 100644 index 0000000..796e62e --- /dev/null +++ b/src/functions/private/Get-YamlSerializationShape.ps1 @@ -0,0 +1,229 @@ +function Get-YamlSerializationShape { + <# + .SYNOPSIS + Classifies one PowerShell value for safe YAML graph normalization. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [pscustomobject] $State, + + [switch] $EnumsAsStrings + ) + + $scalar = New-YamlEmissionNode -Kind Scalar + if ($null -eq $Value -or $Value -is [System.DBNull]) { + $scalar.Value = 'null' + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + + $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 + ) + } + ) + if ($unsupportedProperties.Count -gt 0) { + throw (New-YamlSerializationException -Kind NotSupported ` + -ErrorId 'YamlUnsupportedProperty' -Message ( + "Property '$($unsupportedProperties[0].Name)' is not a note property." + )) + } + + $isDictionary = $Value -is [System.Collections.IDictionary] + $isByteArray = $Value -is [byte[]] + $isSequence = ( + $Value -is [System.Collections.IEnumerable] -and + $Value -isnot [string] -and + $Value -isnot [char] -and + -not $isDictionary -and + -not $isByteArray + ) + $isCustomObject = $Value -is [System.Management.Automation.PSCustomObject] + if ($dataProperties.Count -gt 0 -and ($isDictionary -or $isSequence -or $isByteArray)) { + throw (New-YamlSerializationException -Kind NotSupported ` + -ErrorId 'YamlMixedObjectSemantics' -Message ( + "Type '$($Value.GetType().FullName)' combines collection data with attached note properties and cannot be represented without loss." + )) + } + $isPropertyBag = $isCustomObject -or $dataProperties.Count -gt 0 + + if (-not $isPropertyBag -and ($Value -is [string] -or $Value -is [char])) { + $scalar.Value = [string] $Value + $scalar.Style = 'DoubleQuoted' + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + if (-not $isPropertyBag -and $Value -is [bool]) { + $scalar.Value = $Value.ToString().ToLowerInvariant() + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + if (-not $isPropertyBag -and $Value.GetType().IsEnum) { + if ($EnumsAsStrings) { + $scalar.Value = $Value.ToString() + $scalar.Style = '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 + ) + } + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + + $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) { + $scalar.Value = if ($Value -is [System.Numerics.BigInteger]) { + $Value.ToString([System.Globalization.CultureInfo]::InvariantCulture) + } else { + ([System.IConvertible] $Value).ToString( + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + if (-not $isPropertyBag -and $Value -is [decimal]) { + $bits = [decimal]::GetBits($Value) + $negativeZero = $Value -eq 0 -and ($bits[3] -band [int]::MinValue) -ne 0 + $scalar.Value = if ($negativeZero) { + '-0.0' + } else { + $Value.ToString([System.Globalization.CultureInfo]::InvariantCulture) + } + if ($scalar.Value -notmatch '[\.eE]') { + $scalar.Value += '.0' + } + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + if (-not $isPropertyBag -and ($Value -is [double] -or $Value -is [single])) { + $number = [double] $Value + $scalar.Value = if ([double]::IsNaN($number)) { + '.nan' + } elseif ([double]::IsPositiveInfinity($number)) { + '.inf' + } elseif ([double]::IsNegativeInfinity($number)) { + '-.inf' + } elseif ($number -eq 0 -and + [System.BitConverter]::DoubleToInt64Bits($number) -lt 0) { + '-0.0' + } else { + $formatted = $number.ToString( + 'R', + [System.Globalization.CultureInfo]::InvariantCulture + ) + if ($formatted -notmatch '[\.eE]') { + $formatted += '.0' + } + $formatted + } + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + 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 = 'DoubleQuoted' + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + if (-not $isPropertyBag -and $Value -is [datetime]) { + $scalar.Tag = 'tag:yaml.org,2002:timestamp' + if ($Value.Kind -eq [System.DateTimeKind]::Local) { + $scalar.Value = ([datetimeoffset] $Value).ToString( + 'o', + [System.Globalization.CultureInfo]::InvariantCulture + ) + } else { + $utc = if ($Value.Kind -eq [System.DateTimeKind]::Utc) { + $Value + } else { + [datetime]::SpecifyKind($Value, [System.DateTimeKind]::Utc) + } + $scalar.Value = $utc.ToString( + "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + $scalar.Style = 'DoubleQuoted' + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + + if ($isByteArray) { + $scalar.Tag = 'tag:yaml.org,2002:binary' + $scalar.Value = [System.Convert]::ToBase64String($Value) + $scalar.Style = 'DoubleQuoted' + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Binary'; Node = $scalar; Values = $null } + } + + if ($isDictionary -or $isPropertyBag) { + $entries = [System.Collections.Generic.List[object]]::new() + if ($isDictionary) { + foreach ($entry in $Value.GetEnumerator()) { + $entries.Add([pscustomobject]@{ + Key = [object] $entry.Key + Value = [object] $entry.Value + }) + } + } else { + foreach ($property in $dataProperties) { + $entries.Add([pscustomobject]@{ + Key = [object] $property.Name + Value = [object] $property.Value + }) + } + } + return [pscustomobject]@{ + Kind = 'Mapping' + Node = $null + Values = [object[]] $entries.ToArray() + } + } + if ($isSequence) { + $items = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Value) { + $items.Add([object] $item) + } + return [pscustomobject]@{ + Kind = 'Sequence' + Node = $null + Values = [object[]] $items.ToArray() + } + } + + throw (New-YamlSerializationException -Kind NotSupported ` + -ErrorId 'YamlUnsupportedType' -Message ( + "Values of type '$($Value.GetType().FullName)' are not supported for YAML serialization." + )) +} diff --git a/src/functions/private/Move-YamlCursor.ps1 b/src/functions/private/Move-YamlCursor.ps1 new file mode 100644 index 0000000..c5ff829 --- /dev/null +++ b/src/functions/private/Move-YamlCursor.ps1 @@ -0,0 +1,32 @@ +function Move-YamlCursor { + <# + .SYNOPSIS + Advances a mutable parser cursor. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Advances an in-memory parser cursor.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Cursor, + + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [ValidateRange(1, 2147483647)] + [int] $Count = 1 + ) + + for ($step = 0; $step -lt $Count -and $Cursor.Index -lt $Context.Text.Length; $step++) { + $character = $Context.Text[$Cursor.Index] + $Cursor.Index++ + if ($character -eq "`n") { + $Cursor.Line++ + $Cursor.Column = 0 + } else { + $Cursor.Column++ + } + } +} diff --git a/src/functions/private/New-YamlEmissionNode.ps1 b/src/functions/private/New-YamlEmissionNode.ps1 new file mode 100644 index 0000000..6f05dc3 --- /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 = 'Plain' + 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-YamlEmptyScalar.ps1 b/src/functions/private/New-YamlEmptyScalar.ps1 new file mode 100644 index 0000000..4e369be --- /dev/null +++ b/src/functions/private/New-YamlEmptyScalar.ps1 @@ -0,0 +1,38 @@ +function New-YamlEmptyScalar { + <# + .SYNOPSIS + Creates an empty implicit scalar node. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory representation node.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [int] $Depth, + + [Parameter(Mandatory)] + [pscustomobject] $Mark, + + [AllowEmptyString()] + [string] $Tag = '', + + [bool] $HasUnknownTag = $false, + + [AllowEmptyString()] + [string] $Anchor = '' + ) + + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $Mark -End $Mark + Set-YamlParsedNodeProperty -Node $node -Tag $Tag -HasUnknownTag $HasUnknownTag -Anchor $Anchor ` + -Context $Context + $node.Value = '' + $node.Style = 'Plain' + $node.IsPlainImplicit = [string]::IsNullOrEmpty($Tag) -and -not $HasUnknownTag + return $node +} 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..ca53e3c --- /dev/null +++ b/src/functions/private/New-YamlException.ps1 @@ -0,0 +1,37 @@ +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([System.FormatException])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Start, + + [Parameter(Mandatory)] + [pscustomobject] $End, + + [Parameter(Mandatory)] + [string] $Message, + + [Parameter(Mandatory)] + [string] $ErrorId + ) + + $formattedMessage = '{0} Start: line {1}, column {2}. End: line {3}, column {4}.' -f @( + $Message.TrimEnd('.'), + ($Start.Line + 1), + ($Start.Column + 1), + ($End.Line + 1), + ($End.Column + 1) + ) + $exception = [System.FormatException]::new($formattedMessage) + $exception.Data['YamlErrorId'] = $ErrorId + $exception.Data['IsYamlException'] = $true + Write-Output -InputObject $exception -NoEnumerate +} diff --git a/src/functions/private/New-YamlMark.ps1 b/src/functions/private/New-YamlMark.ps1 new file mode 100644 index 0000000..ca4ea35 --- /dev/null +++ b/src/functions/private/New-YamlMark.ps1 @@ -0,0 +1,28 @@ +function New-YamlMark { + <# + .SYNOPSIS + Creates an internal source location. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory source location.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [int] $Index, + + [Parameter(Mandatory)] + [int] $Line, + + [Parameter(Mandatory)] + [int] $Column + ) + + [pscustomobject]@{ + Index = $Index + Line = $Line + Column = $Column + } +} diff --git a/src/functions/private/New-YamlNode.ps1 b/src/functions/private/New-YamlNode.ps1 new file mode 100644 index 0000000..3070978 --- /dev/null +++ b/src/functions/private/New-YamlNode.ps1 @@ -0,0 +1,48 @@ +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)] + [pscustomobject] $Start, + + [Parameter(Mandatory)] + [pscustomobject] $End + ) + + $node = [pscustomobject]@{ + PSTypeName = 'PSModule.Yaml.InternalNode' + Id = $Id + Kind = $Kind + Tag = '' + HasUnknownTag = $false + Anchor = '' + Value = $null + Style = 'Plain' + IsPlainImplicit = $false + IsQuotedImplicit = $false + ResolutionState = 0 + ResolvedValue = $null + MaxNumericLength = 4096 + 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-YamlReaderContext.ps1 b/src/functions/private/New-YamlReaderContext.ps1 new file mode 100644 index 0000000..1449f91 --- /dev/null +++ b/src/functions/private/New-YamlReaderContext.ps1 @@ -0,0 +1,69 @@ +function New-YamlReaderContext { + <# + .SYNOPSIS + Validates Unicode input and creates the parser reader state. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory reader context.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml, + + [Parameter(Mandatory)] + [int] $Depth, + + [Parameter(Mandatory)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [int] $MaxScalarLength, + + [Parameter(Mandatory)] + [int] $MaxTagLength, + + [Parameter(Mandatory)] + [int] $MaxTotalTagLength, + + [Parameter(Mandatory)] + [int] $MaxNumericLength + ) + + Assert-YamlText -Yaml $Yaml + $text = $Yaml.Replace("`r`n", "`n").Replace("`r", "`n") + $lines = [System.Text.RegularExpressions.Regex]::Split($text, "`n") + $lineStarts = [int[]]::new($lines.Count) + $offset = 0 + for ($line = 0; $line -lt $lines.Count; $line++) { + $lineStarts[$line] = $offset + $offset += $lines[$line].Length + 1 + } + + [pscustomobject]@{ + PSTypeName = 'PSModule.Yaml.ReaderContext' + Text = $text + Lines = $lines + LineStarts = $lineStarts + LineIndex = 0 + NextId = 1 + NodeCount = 0 + AliasCount = 0 + TotalTagLength = 0 + MaxDepth = $Depth + MaxNodes = $MaxNodes + MaxAliases = $MaxAliases + MaxScalarLength = $MaxScalarLength + MaxTagLength = $MaxTagLength + MaxTotalTagLength = $MaxTotalTagLength + MaxNumericLength = $MaxNumericLength + Anchors = $null + TagHandles = $null + } +} 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/New-YamlSyntaxNode.ps1 b/src/functions/private/New-YamlSyntaxNode.ps1 new file mode 100644 index 0000000..3aac6fa --- /dev/null +++ b/src/functions/private/New-YamlSyntaxNode.ps1 @@ -0,0 +1,47 @@ +function New-YamlSyntaxNode { + <# + .SYNOPSIS + Creates a context-aware syntax token with enforced parser budgets. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory syntax token.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [ValidateSet('Alias', 'Mapping', 'Scalar', 'Sequence')] + [string] $Kind, + + [Parameter(Mandatory)] + [int] $Depth, + + [Parameter(Mandatory)] + [pscustomobject] $Start, + + [Parameter(Mandatory)] + [pscustomobject] $End + ) + + if ($Depth -gt $Context.MaxDepth) { + throw (New-YamlException -Start $Start -End $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 $Start -End $End -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The YAML stream exceeds the configured limit of $($Context.MaxNodes) nodes." + )) + } + + $node = New-YamlNode -Id $Context.NextId -Kind $Kind -Start $Start -End $End + $node.PSObject.TypeNames.Insert(0, 'PSModule.Yaml.SyntaxToken') + $Context.NextId++ + $node.MaxNumericLength = $Context.MaxNumericLength + Write-Output -InputObject $node -NoEnumerate +} diff --git a/src/functions/private/New-YamlValueBox.ps1 b/src/functions/private/New-YamlValueBox.ps1 new file mode 100644 index 0000000..68bd2a1 --- /dev/null +++ b/src/functions/private/New-YamlValueBox.ps1 @@ -0,0 +1,22 @@ +function New-YamlValueBox { + <# + .SYNOPSIS + Boxes an internal value so PowerShell never enumerates it in transit. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory value box.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + [pscustomobject]@{ + PSTypeName = 'PSModule.Yaml.ValueBox' + Value = $Value + } +} diff --git a/src/functions/private/Read-YamlBlockKey.ps1 b/src/functions/private/Read-YamlBlockKey.ps1 new file mode 100644 index 0000000..6987f61 --- /dev/null +++ b/src/functions/private/Read-YamlBlockKey.ps1 @@ -0,0 +1,90 @@ +function Read-YamlBlockKey { + <# + .SYNOPSIS + Reads one single-line implicit block mapping key. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter(Mandatory)] + [int] $Line, + + [Parameter(Mandatory)] + [int] $Column, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth + ) + + $properties = Read-YamlNodeProperty -Text $Text -Line $Line -Column $Column -Context $Context + $rest = $properties.Rest + $contentColumn = $Column + $properties.Consumed + $start = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column) -Line $Line -Column $Column + + if ([string]::IsNullOrEmpty($rest)) { + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $start -End $start + Set-YamlParsedNodeProperty -Node $node -Tag $properties.Tag ` + -HasUnknownTag $properties.HasUnknownTag -Anchor $properties.Anchor -Context $Context + $node.Value = '' + $node.IsPlainImplicit = [string]::IsNullOrEmpty($properties.Tag) -and + -not $properties.HasUnknownTag + return $node + } + + if ($rest[0] -in @('[', '{', "'", '"', '*')) { + $cursor = [pscustomobject]@{ + Index = $Context.LineStarts[$Line] + $contentColumn + Line = $Line + Column = $contentColumn + ParentIndent = $Column + } + $node = Read-YamlFlowNode -Cursor $cursor -Context $Context -Depth $Depth ` + -PendingTag $properties.Tag -PendingUnknownTag $properties.HasUnknownTag ` + -PendingAnchor $properties.Anchor + $expectedEnd = $Context.LineStarts[$Line] + $Column + $Text.Length + while ($cursor.Index -lt $expectedEnd -and $Context.Text[$cursor.Index] -in @(' ', "`t")) { + Move-YamlCursor -Cursor $cursor -Context $Context + } + if ($cursor.Index -ne $expectedEnd) { + $mark = New-YamlMark -Index $cursor.Index -Line $cursor.Line -Column $cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidImplicitKey' -Message ( + 'An implicit block mapping key contains unexpected trailing content.' + )) + } + return $node + } + + $first = $rest[0] + if ($first -in @(',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') -or + ($first -in @('-', '?', ':') -and ( + $rest.Length -gt 1 -and [char]::IsWhiteSpace($rest[1]) + ))) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidPlainScalar' -Message ( + 'The first character is not allowed in a YAML plain scalar.' + )) + } + if ($rest.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + + $end = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $Text.Length) ` + -Line $Line -Column ($Column + $Text.Length) + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $start -End $end + Set-YamlParsedNodeProperty -Node $node -Tag $properties.Tag ` + -HasUnknownTag $properties.HasUnknownTag -Anchor $properties.Anchor -Context $Context + $node.Value = $rest.TrimEnd() + $node.Style = 'Plain' + $node.IsPlainImplicit = [string]::IsNullOrEmpty($properties.Tag) -and + -not $properties.HasUnknownTag + return $node +} diff --git a/src/functions/private/Read-YamlBlockMapping.ps1 b/src/functions/private/Read-YamlBlockMapping.ps1 new file mode 100644 index 0000000..bc64bc0 --- /dev/null +++ b/src/functions/private/Read-YamlBlockMapping.ps1 @@ -0,0 +1,254 @@ +function Read-YamlBlockMapping { + <# + .SYNOPSIS + Reads a block mapping, including explicit and complex keys. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [int] $Indent, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowEmptyString()] + [string] $Tag = '', + + [bool] $HasUnknownTag = $false, + + [AllowEmptyString()] + [string] $Anchor = '', + + [AllowNull()] + [string] $FirstText, + + [int] $FirstColumn = -1 + ) + + $startLine = $Context.LineIndex + $startColumn = if ($FirstColumn -ge 0) { $FirstColumn } else { $Indent } + $start = New-YamlMark -Index ($Context.LineStarts[$startLine] + $startColumn) -Line $startLine ` + -Column $startColumn + $node = New-YamlSyntaxNode -Context $Context -Kind Mapping -Depth $Depth -Start $start -End $start + Set-YamlParsedNodeProperty -Node $node -Tag $Tag -HasUnknownTag $HasUnknownTag -Anchor $Anchor ` + -Context $Context + + $hasFirst = $PSBoundParameters.ContainsKey('FirstText') + while ($hasFirst -or $Context.LineIndex -lt $Context.Lines.Count) { + if ($hasFirst) { + $content = $FirstText + $contentColumn = $FirstColumn + $lineNumber = $Context.LineIndex + $hasFirst = $false + } else { + $lineNumber = $Context.LineIndex + $line = $Context.Lines[$lineNumber] + $trimmed = $line.TrimStart(' ', "`t") + if ($line -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + break + } + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + $Context.LineIndex++ + continue + } + $lineIndent = Get-YamlIndent -Line $line -LineNumber $lineNumber -Context $Context + if ($lineIndent -ne $Indent) { + break + } + $content = $line.Substring($Indent) + if ($content.StartsWith("`t", [System.StringComparison]::Ordinal)) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $Indent) ` + -Line $lineNumber -Column $Indent + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidIndentation' -Message ( + 'A tab cannot separate indentation from a block mapping entry.' + )) + } + $contentColumn = $Indent + if (Test-YamlIndicator -Text $content -Indicator '-') { + break + } + } + + $isExplicit = Test-YamlIndicator -Text $content -Indicator '?' + if ($isExplicit) { + $afterQuestion = $content.Substring(1) + $leading = $afterQuestion.Length - $afterQuestion.TrimStart().Length + $keyText = $afterQuestion.TrimStart() + $keyColumn = $contentColumn + 1 + $leading + if ([string]::IsNullOrWhiteSpace((Get-YamlContentWithoutComment -Text $keyText))) { + $Context.LineIndex++ + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -ge $Context.Lines.Count) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $keyColumn) ` + -Line $lineNumber -Column $keyColumn + $key = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } else { + $nextIndent = Get-YamlIndent -Line $Context.Lines[$Context.LineIndex] ` + -LineNumber $Context.LineIndex -Context $Context + $nextContent = $Context.Lines[$Context.LineIndex].Substring($nextIndent) + $indentlessSequence = $nextIndent -eq $Indent -and + (Test-YamlIndicator -Text $nextContent -Indicator '-') + if ($indentlessSequence) { + $key = Read-YamlBlockSequence -Context $Context -Indent $Indent -Depth ($Depth + 1) + } elseif ($nextIndent -gt $Indent) { + $key = Read-YamlBlockNode -Context $Context -ParentIndent $Indent -Depth ($Depth + 1) + } else { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $keyColumn) ` + -Line $lineNumber -Column $keyColumn + $key = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } + } + } elseif (Test-YamlIndicator -Text $keyText -Indicator '-') { + $firstItem = $keyText.Substring(1).TrimStart() + $dashColumn = $keyColumn + $itemColumn = $dashColumn + 1 + ($keyText.Substring(1).Length - $keyText.Substring(1).TrimStart().Length) + $key = Read-YamlBlockSequence -Context $Context -Indent $dashColumn -Depth ($Depth + 1) ` + -FirstItemText $firstItem -FirstItemColumn $itemColumn + } else { + $key = Read-YamlBlockNode -Context $Context -ParentIndent $Indent -Depth ($Depth + 1) ` + -Segment $keyText -SegmentColumn $keyColumn -AllowIndentlessSequence + } + + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -lt $Context.Lines.Count) { + $valueLine = $Context.Lines[$Context.LineIndex] + $valueIndent = Get-YamlIndent -Line $valueLine -LineNumber $Context.LineIndex -Context $Context + $valueContent = if ($valueIndent -le $valueLine.Length) { + $valueLine.Substring($valueIndent) + } else { + '' + } + } else { + $valueIndent = -1 + $valueContent = '' + } + if ($valueIndent -eq $Indent -and + (Test-YamlIndicator -Text $valueContent -Indicator ':')) { + $valueText = $valueContent.Substring(1) + $leading = $valueText.Length - $valueText.TrimStart().Length + $valueText = $valueText.TrimStart() + $valueColumn = $Indent + 1 + $leading + if ([string]::IsNullOrWhiteSpace((Get-YamlContentWithoutComment -Text $valueText))) { + $valueLineNumber = $Context.LineIndex + $Context.LineIndex++ + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -lt $Context.Lines.Count) { + $nextLine = $Context.Lines[$Context.LineIndex] + $nextIndent = Get-YamlIndent -Line $nextLine -LineNumber $Context.LineIndex -Context $Context + } else { + $nextIndent = -1 + } + $indentlessSequence = $nextIndent -eq $Indent -and + $Context.LineIndex -lt $Context.Lines.Count -and + (Test-YamlIndicator -Text ( + $Context.Lines[$Context.LineIndex].Substring($nextIndent) + ) -Indicator '-') + if ($indentlessSequence) { + $value = Read-YamlBlockSequence -Context $Context -Indent $Indent -Depth ($Depth + 1) + } elseif ($nextIndent -gt $Indent) { + $value = Read-YamlBlockNode -Context $Context -ParentIndent $Indent ` + -Depth ($Depth + 1) -AllowIndentlessSequence + } else { + $mark = New-YamlMark -Index ($Context.LineStarts[$valueLineNumber] + $valueColumn) ` + -Line $valueLineNumber -Column $valueColumn + $value = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } + } else { + $value = Read-YamlBlockNode -Context $Context -ParentIndent $Indent -Depth ($Depth + 1) ` + -Segment $valueText -SegmentColumn $valueColumn + } + } else { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + $value = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } + $node.Entries.Add([pscustomobject]@{ Key = $key; Value = $value }) + continue + } + + $colon = Find-YamlMappingColon -Text $content + if ($colon -lt 0) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidMapping' -Message ( + 'A block mapping entry is missing its value indicator.' + )) + } + if ($colon -gt 1024) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn + $colon) ` + -Line $lineNumber -Column ($contentColumn + $colon) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidImplicitKey' -Message ( + 'An implicit mapping key cannot exceed 1024 characters.' + )) + } + + if ($colon -eq 0) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + $key = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } else { + $keyText = $content.Substring(0, $colon).TrimEnd() + $key = Read-YamlBlockKey -Context $Context -Text $keyText -Line $lineNumber ` + -Column $contentColumn -Depth ($Depth + 1) + } + + $valueStart = $colon + 1 + $valueSource = $content.Substring($valueStart) + $leading = $valueSource.Length - $valueSource.TrimStart().Length + $valueText = $valueSource.TrimStart() + $valueColumn = $contentColumn + $valueStart + $leading + if ([string]::IsNullOrWhiteSpace((Get-YamlContentWithoutComment -Text $valueText))) { + $Context.LineIndex = $lineNumber + 1 + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -lt $Context.Lines.Count) { + $nextLine = $Context.Lines[$Context.LineIndex] + $nextIndent = Get-YamlIndent -Line $nextLine -LineNumber $Context.LineIndex -Context $Context + $nextContent = $nextLine.Substring($nextIndent) + } else { + $nextIndent = -1 + $nextContent = '' + } + $indentlessSequence = ( + $nextIndent -eq $Indent -and + ( + (Test-YamlIndicator -Text $nextContent -Indicator '-') + ) + ) + if ($nextIndent -gt $Indent -or $indentlessSequence) { + if ($indentlessSequence) { + $value = Read-YamlBlockSequence -Context $Context -Indent $Indent -Depth ($Depth + 1) + } else { + $value = Read-YamlBlockNode -Context $Context -ParentIndent $Indent ` + -Depth ($Depth + 1) -AllowIndentlessSequence + } + } else { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $valueColumn) ` + -Line $lineNumber -Column $valueColumn + $value = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } + } else { + if ((Test-YamlIndicator -Text $valueText -Indicator '-') -or + (Find-YamlMappingColon -Text $valueText) -ge 0) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $valueColumn) ` + -Line $lineNumber -Column $valueColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidMapping' -Message ( + 'A compact block collection cannot begin on the same line as a mapping value indicator.' + )) + } + $Context.LineIndex = $lineNumber + $value = Read-YamlBlockNode -Context $Context -ParentIndent $Indent -Depth ($Depth + 1) ` + -Segment $valueText -SegmentColumn $valueColumn -AllowIndentlessSequence + } + $node.Entries.Add([pscustomobject]@{ Key = $key; Value = $value }) + } + + $endLine = [Math]::Max($startLine, $Context.LineIndex - 1) + $node.End = New-YamlMark -Index ($Context.LineStarts[$endLine] + $Context.Lines[$endLine].Length) ` + -Line $endLine -Column $Context.Lines[$endLine].Length + return $node +} diff --git a/src/functions/private/Read-YamlBlockNode.ps1 b/src/functions/private/Read-YamlBlockNode.ps1 new file mode 100644 index 0000000..972c605 --- /dev/null +++ b/src/functions/private/Read-YamlBlockNode.ps1 @@ -0,0 +1,185 @@ +function Read-YamlBlockNode { + <# + .SYNOPSIS + Reads one node in block context. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [int] $ParentIndent, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowNull()] + [string] $Segment, + + [int] $SegmentColumn = -1, + + [AllowEmptyString()] + [string] $PendingTag = '', + + [bool] $PendingUnknownTag = $false, + + [AllowEmptyString()] + [string] $PendingAnchor = '', + + [switch] $AllowIndentlessSequence + ) + + $hasSegment = $PSBoundParameters.ContainsKey('Segment') + if (-not $hasSegment) { + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -ge $Context.Lines.Count) { + $mark = New-YamlMark -Index $Context.Text.Length -Line ([Math]::Max(0, $Context.Lines.Count - 1)) ` + -Column 0 + return New-YamlEmptyScalar -Context $Context -Depth $Depth -Mark $mark -Tag $PendingTag ` + -HasUnknownTag $PendingUnknownTag -Anchor $PendingAnchor + } + $line = $Context.Lines[$Context.LineIndex] + if ($line -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + $mark = New-YamlMark -Index $Context.LineStarts[$Context.LineIndex] -Line $Context.LineIndex -Column 0 + return New-YamlEmptyScalar -Context $Context -Depth $Depth -Mark $mark -Tag $PendingTag ` + -HasUnknownTag $PendingUnknownTag -Anchor $PendingAnchor + } + $indent = Get-YamlIndent -Line $line -LineNumber $Context.LineIndex -Context $Context + if ($indent -le $ParentIndent -and -not ( + $indent -eq $ParentIndent -and + (Test-YamlIndicator -Text $line.Substring($indent) -Indicator '-') + )) { + $mark = New-YamlMark -Index ($Context.LineStarts[$Context.LineIndex] + $indent) ` + -Line $Context.LineIndex -Column $indent + return New-YamlEmptyScalar -Context $Context -Depth $Depth -Mark $mark -Tag $PendingTag ` + -HasUnknownTag $PendingUnknownTag -Anchor $PendingAnchor + } + $Segment = $line.Substring($indent) + $SegmentColumn = $indent + } else { + $indent = $ParentIndent + 1 + } + + $lineNumber = $Context.LineIndex + if ( + (-not [string]::IsNullOrEmpty($PendingTag) -or $PendingUnknownTag -or + -not [string]::IsNullOrEmpty($PendingAnchor)) -and + -not (Test-YamlIndicator -Text $Segment -Indicator '-') -and + ((Find-YamlMappingColon -Text $Segment) -ge 0 -or + (Test-YamlIndicator -Text $Segment -Indicator '?')) + ) { + return Read-YamlBlockMapping -Context $Context -Indent $SegmentColumn -Depth $Depth ` + -Tag $PendingTag -HasUnknownTag $PendingUnknownTag -Anchor $PendingAnchor ` + -FirstText $Segment -FirstColumn $SegmentColumn + } + if ( + [string]::IsNullOrEmpty($PendingTag) -and -not $PendingUnknownTag -and + [string]::IsNullOrEmpty($PendingAnchor) -and + -not (Test-YamlIndicator -Text $Segment -Indicator '-') -and + ((Find-YamlMappingColon -Text $Segment) -ge 0 -or + (Test-YamlIndicator -Text $Segment -Indicator '?')) + ) { + return Read-YamlBlockMapping -Context $Context -Indent $SegmentColumn -Depth $Depth ` + -FirstText $Segment -FirstColumn $SegmentColumn + } + + $properties = Read-YamlNodeProperty -Text $Segment -Line $lineNumber -Column $SegmentColumn ` + -Context $Context + if ((-not [string]::IsNullOrEmpty($PendingTag) -or $PendingUnknownTag) -and + (-not [string]::IsNullOrEmpty($properties.Tag) -or $properties.HasUnknownTag)) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $SegmentColumn) -Line $lineNumber ` + -Column $SegmentColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlDuplicateNodeProperty' -Message ( + 'A YAML node cannot have more than one tag property.' + )) + } + if (-not [string]::IsNullOrEmpty($PendingAnchor) -and + -not [string]::IsNullOrEmpty($properties.Anchor)) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $SegmentColumn) -Line $lineNumber ` + -Column $SegmentColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlDuplicateNodeProperty' -Message ( + 'A YAML node cannot have more than one anchor property.' + )) + } + $tag = if (-not [string]::IsNullOrEmpty($properties.Tag)) { $properties.Tag } else { $PendingTag } + $unknownTag = $properties.HasUnknownTag -or $PendingUnknownTag + $anchor = if (-not [string]::IsNullOrEmpty($properties.Anchor)) { $properties.Anchor } else { $PendingAnchor } + $restSource = $properties.Rest + $rest = Get-YamlContentWithoutComment -Text $restSource + $contentColumn = $SegmentColumn + $properties.Consumed + + if ([string]::IsNullOrWhiteSpace($rest)) { + $Context.LineIndex++ + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -ge $Context.Lines.Count -or + $Context.Lines[$Context.LineIndex] -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + return New-YamlEmptyScalar -Context $Context -Depth $Depth -Mark $mark -Tag $tag ` + -HasUnknownTag $unknownTag -Anchor $anchor + } + $nextLine = $Context.Lines[$Context.LineIndex] + $nextIndent = Get-YamlIndent -Line $nextLine -LineNumber $Context.LineIndex -Context $Context + if ($AllowIndentlessSequence -and $nextIndent -eq $ParentIndent -and + (Test-YamlIndicator -Text $nextLine.Substring($nextIndent) -Indicator '-')) { + return Read-YamlBlockSequence -Context $Context -Indent $ParentIndent -Depth $Depth ` + -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor + } + if ($nextIndent -le $ParentIndent) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + return New-YamlEmptyScalar -Context $Context -Depth $Depth -Mark $mark -Tag $tag ` + -HasUnknownTag $unknownTag -Anchor $anchor + } + return Read-YamlBlockNode -Context $Context -ParentIndent $ParentIndent -Depth $Depth ` + -PendingTag $tag -PendingUnknownTag $unknownTag -PendingAnchor $anchor ` + -AllowIndentlessSequence:$AllowIndentlessSequence + } + + if ($rest[0] -in @('|', '>')) { + return Read-YamlBlockScalar -Context $Context -Header $rest -HeaderColumn $contentColumn ` + -ParentIndent $ParentIndent -Depth $Depth -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor + } + + if (Test-YamlIndicator -Text $rest -Indicator '-') { + if ($properties.Consumed -gt 0) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidBlockSequence' -Message ( + 'Node properties cannot precede a block sequence entry on the same line.' + )) + } + $firstSource = $rest.Substring(1) + if ($firstSource.IndexOf("`t", [System.StringComparison]::Ordinal) -ge 0 -and + $firstSource.Trim() -eq '-') { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn + 1) ` + -Line $lineNumber -Column ($contentColumn + 1) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidIndentation' -Message ( + 'Tabs cannot separate adjacent block sequence indicators.' + )) + } + $first = $firstSource + $leading = $first.Length - $first.TrimStart().Length + $first = $first.TrimStart() + return Read-YamlBlockSequence -Context $Context -Indent $contentColumn -Depth $Depth -Tag $tag ` + -HasUnknownTag $unknownTag -Anchor $anchor -FirstItemText $first ` + -FirstItemColumn ($contentColumn + 1 + $leading) + } + + if ((Find-YamlMappingColon -Text $rest) -ge 0 -or + (Test-YamlIndicator -Text $rest -Indicator '?')) { + return Read-YamlBlockMapping -Context $Context -Indent $contentColumn -Depth $Depth -Tag $tag ` + -HasUnknownTag $unknownTag -Anchor $anchor -FirstText $rest -FirstColumn $contentColumn + } + + if ($rest[0] -in @('[', '{', "'", '"', '*')) { + return Read-YamlInlineNode -Context $Context -Column $contentColumn -ParentIndent $ParentIndent ` + -Depth $Depth -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor + } + + return Read-YamlPlainScalar -Context $Context -FirstText $restSource -FirstColumn $contentColumn ` + -ParentIndent $ParentIndent -Depth $Depth -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor +} diff --git a/src/functions/private/Read-YamlBlockScalar.ps1 b/src/functions/private/Read-YamlBlockScalar.ps1 new file mode 100644 index 0000000..1c9320f --- /dev/null +++ b/src/functions/private/Read-YamlBlockScalar.ps1 @@ -0,0 +1,223 @@ +function Read-YamlBlockScalar { + <# + .SYNOPSIS + Reads a literal or folded block scalar. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [string] $Header, + + [Parameter(Mandatory)] + [int] $HeaderColumn, + + [Parameter(Mandatory)] + [int] $ParentIndent, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowEmptyString()] + [string] $Tag = '', + + [bool] $HasUnknownTag = $false, + + [AllowEmptyString()] + [string] $Anchor = '' + ) + + if ($Header -notmatch ( + '^([|>])(?:(?:([1-9])([+-])?)|(?:([+-])([1-9])?))?' + + '(?:[ \t]+#.*|[ \t]*)$' + )) { + $line = $Context.LineIndex + $mark = New-YamlMark -Index ($Context.LineStarts[$line] + $HeaderColumn) -Line $line ` + -Column $HeaderColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidBlockScalar' -Message ( + "The block scalar header '$Header' is malformed." + )) + } + $style = $Matches[1] + $indentDigit = if ($Matches[2]) { $Matches[2] } else { $Matches[5] } + $chomp = if ($Matches[3]) { $Matches[3] } else { $Matches[4] } + $startLine = $Context.LineIndex + $start = New-YamlMark -Index ($Context.LineStarts[$startLine] + $HeaderColumn) -Line $startLine ` + -Column $HeaderColumn + $Context.LineIndex++ + + $contentIndent = if ($indentDigit) { + [Math]::Max(0, $ParentIndent) + [int] $indentDigit + } else { + -1 + } + $contentIndentDetected = -not [string]::IsNullOrEmpty($indentDigit) + if ($contentIndent -lt 0) { + $leadingBlankIndent = 0 + for ($probe = $Context.LineIndex; $probe -lt $Context.Lines.Count; $probe++) { + $probeLine = $Context.Lines[$probe] + if ($probe -eq $Context.Lines.Count - 1 -and + $probeLine.Length -eq 0 -and + $Context.Text.EndsWith("`n", [System.StringComparison]::Ordinal)) { + break + } + $probeIndent = 0 + while ($probeIndent -lt $probeLine.Length -and $probeLine[$probeIndent] -eq ' ') { + $probeIndent++ + } + if ($probeLine.Trim(' ', "`t").Length -eq 0) { + $leadingBlankIndent = [Math]::Max($leadingBlankIndent, $probeIndent) + continue + } + if ($probeIndent -le $ParentIndent) { + break + } + $contentIndent = $probeIndent + $contentIndentDetected = $true + if ($leadingBlankIndent -gt $contentIndent) { + $mark = New-YamlMark -Index $Context.LineStarts[$probe] -Line $probe -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidBlockScalar' -Message ( + 'Leading empty block-scalar lines cannot be more indented than the first content line.' + )) + } + break + } + if ($contentIndent -lt 0) { + $contentIndent = $ParentIndent + 1 + } + } + + $lines = [System.Collections.Generic.List[string]]::new() + $moreIndented = [System.Collections.Generic.List[bool]]::new() + $hasLineBreak = [System.Collections.Generic.List[bool]]::new() + $decodedLength = 0 + while ($Context.LineIndex -lt $Context.Lines.Count) { + $line = $Context.Lines[$Context.LineIndex] + if ($line -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + break + } + if ($Context.LineIndex -eq $Context.Lines.Count - 1 -and + $line.Length -eq 0 -and + $Context.Text.EndsWith("`n", [System.StringComparison]::Ordinal)) { + break + } + $lineBreak = $Context.LineIndex -lt $Context.Lines.Count - 1 + $indent = 0 + while ($indent -lt $line.Length -and $line[$indent] -eq ' ') { + $indent++ + } + if ($line.Trim(' ', "`t").Length -eq 0) { + if ($indent -lt $line.Length -and $line[$indent] -eq "`t" -and + $indent -lt $contentIndent) { + $mark = New-YamlMark -Index ($Context.LineStarts[$Context.LineIndex] + $indent) ` + -Line $Context.LineIndex -Column $indent + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidIndentation' -Message ( + 'A tab cannot provide block-scalar indentation.' + )) + } + $blankContent = if ($contentIndentDetected) { + if ($line.Length -ge $contentIndent) { + $line.Substring($contentIndent) + } else { + '' + } + } else { + $line.Substring($indent) + } + if ($decodedLength + $blankContent.Length + [int] $lineBreak -gt + $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + $lines.Add($blankContent) + $moreIndented.Add($blankContent.Length -gt 0) + $hasLineBreak.Add($lineBreak) + $decodedLength += $blankContent.Length + [int] $lineBreak + $Context.LineIndex++ + continue + } + if ($indent -lt $contentIndent) { + break + } + $contentLength = $line.Length - $contentIndent + if ($decodedLength + $contentLength + [int] $lineBreak -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + $lines.Add($line.Substring($contentIndent, $contentLength)) + $moreIndented.Add( + $contentLength -gt 0 -and $line[$contentIndent] -in @(' ', "`t") + ) + $hasLineBreak.Add($lineBreak) + $decodedLength += $contentLength + [int] $lineBreak + $Context.LineIndex++ + } + + $builder = [System.Text.StringBuilder]::new() + for ($index = 0; $index -lt $lines.Count; $index++) { + [void] $builder.Append($lines[$index]) + if (-not $hasLineBreak[$index]) { + continue + } + if ($style -eq '|') { + [void] $builder.Append("`n") + continue + } + if ($index -eq $lines.Count - 1) { + [void] $builder.Append("`n") + continue + } + $currentBlank = $lines[$index].Length -eq 0 + $nextBlank = $lines[$index + 1].Length -eq 0 + if ($currentBlank -or $moreIndented[$index] -or $moreIndented[$index + 1]) { + [void] $builder.Append("`n") + } elseif (-not $nextBlank) { + [void] $builder.Append(' ') + } else { + $lookAhead = $index + 1 + while ($lookAhead -lt $lines.Count -and $lines[$lookAhead].Length -eq 0) { + $lookAhead++ + } + if ($lookAhead -lt $lines.Count -and $moreIndented[$lookAhead]) { + [void] $builder.Append("`n") + } + } + } + $value = $builder.ToString() + if ($chomp -eq '+' -and $lines.Count -gt 0 -and + -not $hasLineBreak[$lines.Count - 1] -and + $lines[$lines.Count - 1].Length -eq 0) { + $value += "`n" + } + if ($chomp -eq '-') { + $value = $value.TrimEnd("`n") + } elseif ($chomp -ne '+') { + $value = $value.TrimEnd("`n") + $hasContent = $false + foreach ($contentLine in $lines) { + if ($contentLine.Length -gt 0) { + $hasContent = $true + break + } + } + if ($hasContent) { + $value += "`n" + } + } + $endLine = [Math]::Max($startLine, $Context.LineIndex - 1) + $end = New-YamlMark -Index ($Context.LineStarts[$endLine] + $Context.Lines[$endLine].Length) ` + -Line $endLine -Column $Context.Lines[$endLine].Length + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $start -End $end + Set-YamlParsedNodeProperty -Node $node -Tag $Tag -HasUnknownTag $HasUnknownTag -Anchor $Anchor ` + -Context $Context + $node.Value = $value + $node.Style = if ($style -eq '|') { 'Literal' } else { 'Folded' } + $node.IsQuotedImplicit = [string]::IsNullOrEmpty($Tag) -and -not $HasUnknownTag + return $node +} diff --git a/src/functions/private/Read-YamlBlockSequence.ps1 b/src/functions/private/Read-YamlBlockSequence.ps1 new file mode 100644 index 0000000..5ca2bba --- /dev/null +++ b/src/functions/private/Read-YamlBlockSequence.ps1 @@ -0,0 +1,99 @@ +function Read-YamlBlockSequence { + <# + .SYNOPSIS + Reads a block sequence, including compact first items. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [int] $Indent, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowEmptyString()] + [string] $Tag = '', + + [bool] $HasUnknownTag = $false, + + [AllowEmptyString()] + [string] $Anchor = '', + + [AllowNull()] + [string] $FirstItemText, + + [int] $FirstItemColumn = -1 + ) + + $startLine = $Context.LineIndex + $startColumn = if ($FirstItemColumn -ge 0) { $FirstItemColumn - 2 } else { $Indent } + $start = New-YamlMark -Index ($Context.LineStarts[$startLine] + [Math]::Max(0, $startColumn)) ` + -Line $startLine -Column ([Math]::Max(0, $startColumn)) + $node = New-YamlSyntaxNode -Context $Context -Kind Sequence -Depth $Depth -Start $start -End $start + Set-YamlParsedNodeProperty -Node $node -Tag $Tag -HasUnknownTag $HasUnknownTag -Anchor $Anchor ` + -Context $Context + + $hasFirstItem = $PSBoundParameters.ContainsKey('FirstItemText') + while ($hasFirstItem -or $Context.LineIndex -lt $Context.Lines.Count) { + if ($hasFirstItem) { + $rest = $FirstItemText + $itemColumn = $FirstItemColumn + $hasFirstItem = $false + } else { + $line = $Context.Lines[$Context.LineIndex] + $trimmed = $line.TrimStart(' ', "`t") + if ($line -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + break + } + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + $Context.LineIndex++ + continue + } + $lineIndent = Get-YamlIndent -Line $line -LineNumber $Context.LineIndex -Context $Context + if ($lineIndent -ne $Indent) { + break + } + $content = $line.Substring($Indent) + if (-not (Test-YamlIndicator -Text $content -Indicator '-')) { + break + } + $rest = $content.Substring(1).TrimStart() + $itemColumn = $Indent + 1 + ($content.Substring(1).Length - $content.Substring(1).TrimStart().Length) + } + + if ([string]::IsNullOrWhiteSpace((Get-YamlContentWithoutComment -Text $rest))) { + $line = $Context.LineIndex + $Context.LineIndex++ + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -ge $Context.Lines.Count) { + $mark = New-YamlMark -Index ($Context.LineStarts[$line] + $itemColumn) -Line $line ` + -Column $itemColumn + $item = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } else { + $nextLine = $Context.Lines[$Context.LineIndex] + $nextIndent = Get-YamlIndent -Line $nextLine -LineNumber $Context.LineIndex -Context $Context + if ($nextIndent -le $Indent -or $nextLine -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + $mark = New-YamlMark -Index ($Context.LineStarts[$line] + $itemColumn) -Line $line ` + -Column $itemColumn + $item = New-YamlEmptyScalar -Context $Context -Depth ($Depth + 1) -Mark $mark + } else { + $item = Read-YamlBlockNode -Context $Context -ParentIndent $Indent -Depth ($Depth + 1) + } + } + } else { + $item = Read-YamlBlockNode -Context $Context -ParentIndent $Indent -Depth ($Depth + 1) ` + -Segment $rest -SegmentColumn $itemColumn + } + $node.Items.Add($item) + } + + $endLine = [Math]::Max($startLine, $Context.LineIndex - 1) + $node.End = New-YamlMark -Index ($Context.LineStarts[$endLine] + $Context.Lines[$endLine].Length) ` + -Line $endLine -Column $Context.Lines[$endLine].Length + return $node +} diff --git a/src/functions/private/Read-YamlDirectiveBlock.ps1 b/src/functions/private/Read-YamlDirectiveBlock.ps1 new file mode 100644 index 0000000..7b7b97d --- /dev/null +++ b/src/functions/private/Read-YamlDirectiveBlock.ps1 @@ -0,0 +1,89 @@ +function Read-YamlDirectiveBlock { + <# + .SYNOPSIS + Scans one document's directive block and resolves tag handles. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $tagHandles = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::Ordinal + ) + $tagHandles['!'] = '!' + $tagHandles['!!'] = 'tag:yaml.org,2002:' + $declaredTagHandles = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + $yamlDirectiveSeen = $false + $directiveSeen = $false + + while ($Context.LineIndex -lt $Context.Lines.Count -and + $Context.Lines[$Context.LineIndex].StartsWith( + '%', + [System.StringComparison]::Ordinal + )) { + $directiveSeen = $true + $directive = $Context.Lines[$Context.LineIndex] + $mark = New-YamlMark -Index $Context.LineStarts[$Context.LineIndex] ` + -Line $Context.LineIndex -Column 0 + if ($directive -cmatch '^%YAML(?:[ \t]|$)') { + if ($yamlDirectiveSeen -or $directive -cnotmatch ( + '^%YAML[ \t]+([0-9]+)\.([0-9]+)(?:[ \t]+#.*)?$' + ) -or $Matches[1] -ne '1') { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlInvalidDirective' -Message ( + "The YAML directive '$directive' is malformed, duplicated, or requests an unsupported version." + )) + } + $yamlDirectiveSeen = $true + } elseif ($directive -cmatch '^%TAG(?:[ \t]|$)') { + if ($directive -cnotmatch ( + '^%TAG[ \t]+(!|!!|![0-9A-Za-z-]+!)[ \t]+([^ \t#]+)(?:[ \t]+#.*)?$' + )) { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlInvalidDirective' -Message ( + "The TAG directive '$directive' is malformed." + )) + } + $handle = $Matches[1] + $prefix = $Matches[2] + if (-not $declaredTagHandles.Add($handle)) { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlDuplicateTagHandle' -Message ( + "The tag handle '$handle' is declared more than once." + )) + } + if ($prefix.Length -gt $Context.MaxTagLength) { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag prefix exceeds the configured limit of $($Context.MaxTagLength) characters." + )) + } + if ($prefix -ne '!' -and -not (Test-YamlTagUriText -Text $prefix)) { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlInvalidDirective' -Message ( + "The TAG directive prefix '$prefix' contains invalid URI text." + )) + } + $tagHandles[$handle] = $prefix + } elseif ($directive -cnotmatch ( + '^%[A-Za-z0-9]+(?:[ \t]+[^#\s][^#]*?)?(?:[ \t]+#.*)?$' + )) { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlInvalidDirective' -Message ( + "The directive '$directive' is malformed." + )) + } + $Context.LineIndex++ + Skip-YamlBlockTrivia -Context $Context + } + + [pscustomobject]@{ + TagHandles = $tagHandles + DirectiveSeen = $directiveSeen + } +} diff --git a/src/functions/private/Read-YamlFlowNode.ps1 b/src/functions/private/Read-YamlFlowNode.ps1 new file mode 100644 index 0000000..0015d34 --- /dev/null +++ b/src/functions/private/Read-YamlFlowNode.ps1 @@ -0,0 +1,602 @@ +function Read-YamlFlowNode { + <# + .SYNOPSIS + Reads one node from a character cursor, including flow collections. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Cursor, + + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowEmptyString()] + [string] $PendingTag = '', + + [bool] $PendingUnknownTag = $false, + + [AllowEmptyString()] + [string] $PendingAnchor = '' + ) + + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + $start = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $tag = $PendingTag + $unknownTag = $PendingUnknownTag + $anchor = $PendingAnchor + + while ($Cursor.Index -lt $Context.Text.Length) { + $character = $Context.Text[$Cursor.Index] + if ($character -eq '!') { + if (-not [string]::IsNullOrEmpty($tag) -or $unknownTag) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlDuplicateNodeProperty' -Message ( + 'A YAML node cannot have more than one tag property.' + )) + } + $tokenStart = $Cursor.Index + if ($Cursor.Index + 1 -lt $Context.Text.Length -and $Context.Text[$Cursor.Index + 1] -eq '<') { + while ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -ne '>') { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + if ($Cursor.Index -ge $Context.Text.Length) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidTag' -Message ( + 'A verbatim YAML tag is missing its closing angle bracket.' + )) + } + Move-YamlCursor -Cursor $Cursor -Context $Context + } else { + while ($Cursor.Index -lt $Context.Text.Length -and + -not [char]::IsWhiteSpace($Context.Text[$Cursor.Index]) -and + $Context.Text[$Cursor.Index] -notin @(',', '[', ']', '{', '}')) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + } + if ($Cursor.Index - $tokenStart -gt $Context.MaxTagLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag token exceeds the configured limit of $($Context.MaxTagLength) characters." + )) + } + $resolved = Resolve-YamlTag -Token $Context.Text.Substring( + $tokenStart, $Cursor.Index - $tokenStart + ) -Context $Context -Mark $start + $tag = $resolved.Tag + $unknownTag = $resolved.IsUnknown + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + continue + } + if ($character -eq '&') { + if (-not [string]::IsNullOrEmpty($anchor)) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlDuplicateNodeProperty' -Message ( + 'A YAML node cannot have more than one anchor property.' + )) + } + Move-YamlCursor -Cursor $Cursor -Context $Context + $anchorStart = $Cursor.Index + while ($Cursor.Index -lt $Context.Text.Length -and + -not [char]::IsWhiteSpace($Context.Text[$Cursor.Index]) -and + $Context.Text[$Cursor.Index] -notin @(',', '[', ']', '{', '}')) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + $anchor = $Context.Text.Substring($anchorStart, $Cursor.Index - $anchorStart) + if ([string]::IsNullOrEmpty($anchor)) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidAnchor' -Message ( + 'A YAML anchor name is missing.' + )) + } + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + continue + } + break + } + + if ($Cursor.Index -ge $Context.Text.Length) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlUnexpectedEnd' -Message ( + 'The YAML stream ended while a node was expected.' + )) + } + + $character = $Context.Text[$Cursor.Index] + if ($character -in @(':', ',', ']', '}') -and ( + -not [string]::IsNullOrEmpty($tag) -or $unknownTag -or + -not [string]::IsNullOrEmpty($anchor) + )) { + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $start -End $start + Set-YamlParsedNodeProperty -Node $node -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor ` + -Context $Context + $node.Value = '' + $node.IsPlainImplicit = [string]::IsNullOrEmpty($tag) -and -not $unknownTag + return $node + } + if ($character -eq '*') { + if (-not [string]::IsNullOrEmpty($tag) -or $unknownTag -or + -not [string]::IsNullOrEmpty($anchor)) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidAlias' -Message ( + 'A YAML alias node cannot have tag or anchor properties.' + )) + } + Move-YamlCursor -Cursor $Cursor -Context $Context + $aliasStart = $Cursor.Index + while ($Cursor.Index -lt $Context.Text.Length -and + -not [char]::IsWhiteSpace($Context.Text[$Cursor.Index]) -and + $Context.Text[$Cursor.Index] -notin @(',', '[', ']', '{', '}')) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + $alias = $Context.Text.Substring($aliasStart, $Cursor.Index - $aliasStart) + $Context.AliasCount++ + if ($Context.AliasCount -gt $Context.MaxAliases) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlAliasLimitExceeded' -Message ( + "The YAML stream exceeds the configured limit of $($Context.MaxAliases) aliases." + )) + } + if (-not $Context.Anchors.ContainsKey($alias)) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlUndefinedAlias' -Message ( + "The YAML alias '*$alias' does not refer to a preceding anchor." + )) + } + $end = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $node = New-YamlSyntaxNode -Context $Context -Kind Alias -Depth $Depth -Start $start -End $end + $node.Target = $Context.Anchors[$alias] + return $node + } + + if ($character -eq '[' -or $character -eq '{') { + $kind = if ($character -eq '[') { 'Sequence' } else { 'Mapping' } + $closing = if ($character -eq '[') { ']' } else { '}' } + $node = New-YamlSyntaxNode -Context $Context -Kind $kind -Depth $Depth -Start $start -End $start + Set-YamlParsedNodeProperty -Node $node -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor ` + -Context $Context + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + + if ($kind -eq 'Sequence') { + while ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -ne $closing) { + $explicitPair = $false + if ($Context.Text[$Cursor.Index] -eq '?' -and + ($Cursor.Index + 1 -ge $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 1]))) { + $explicitPair = $true + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + } + if ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -eq ':' -and ( + $Cursor.Index + 1 -ge $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 1]) -or + $Context.Text[$Cursor.Index + 1] -in @(',', ']', '}') + )) { + $itemMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $item = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 1) ` + -Start $itemMark -End $itemMark + $item.Value = '' + $item.IsPlainImplicit = $true + } else { + $item = Read-YamlFlowNode -Cursor $Cursor -Context $Context -Depth ($Depth + 1) + } + $itemStartLine = $item.Start.Line + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + if ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -eq ':') { + if (-not $explicitPair -and ( + $Cursor.Line -ne $itemStartLine -or + $Cursor.Index - $item.Start.Index -gt 1024 + )) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidImplicitKey' -Message ( + 'An implicit mapping key must fit on one line and cannot exceed 1024 characters.' + )) + } + $pair = New-YamlSyntaxNode -Context $Context -Kind Mapping -Depth ($Depth + 1) ` + -Start $item.Start -End $item.End + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + if ($Cursor.Index -ge $Context.Text.Length -or + $Context.Text[$Cursor.Index] -in @(',', ']')) { + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $value = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 2) ` + -Start $nullMark -End $nullMark + $value.Value = '' + $value.IsPlainImplicit = $true + } else { + $value = Read-YamlFlowNode -Cursor $Cursor -Context $Context -Depth ($Depth + 2) + } + $pair.Entries.Add([pscustomobject]@{ Key = $item; Value = $value }) + $item = $pair + } elseif ($explicitPair) { + $pair = New-YamlSyntaxNode -Context $Context -Kind Mapping -Depth ($Depth + 1) ` + -Start $item.Start -End $item.End + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $value = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 2) ` + -Start $nullMark -End $nullMark + $value.Value = '' + $value.IsPlainImplicit = $true + $pair.Entries.Add([pscustomobject]@{ Key = $item; Value = $value }) + $item = $pair + } + $node.Items.Add($item) + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + if ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -eq ',') { + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + continue + } + if ($Cursor.Index -ge $Context.Text.Length -or $Context.Text[$Cursor.Index] -ne $closing) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidFlowCollection' -Message ( + "A flow sequence requires ',' or '$closing'." + )) + } + } + } else { + while ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -ne $closing) { + $explicit = $false + if ($Context.Text[$Cursor.Index] -eq '?' -and + ($Cursor.Index + 1 -ge $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 1]))) { + $explicit = $true + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + } + if ($Context.Text[$Cursor.Index] -eq ':') { + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $key = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 1) ` + -Start $nullMark -End $nullMark + $key.Value = '' + $key.IsPlainImplicit = $true + } else { + if ($explicit -and $Context.Text[$Cursor.Index] -in @(',', '}')) { + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $key = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 1) ` + -Start $nullMark -End $nullMark + $key.Value = '' + $key.IsPlainImplicit = $true + } else { + $key = Read-YamlFlowNode -Cursor $Cursor -Context $Context -Depth ($Depth + 1) + } + } + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + if (-not $explicit -and $Cursor.Index - $key.Start.Index -gt 1024) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidImplicitKey' -Message ( + 'An implicit mapping key must fit on one line and cannot exceed 1024 characters.' + )) + } + if ($Cursor.Index -ge $Context.Text.Length -or $Context.Text[$Cursor.Index] -ne ':') { + if (-not $explicit) { + if ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -in @(',', '}')) { + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $value = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 1) ` + -Start $nullMark -End $nullMark + $value.Value = '' + $value.IsPlainImplicit = $true + } else { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidFlowMapping' -Message ( + 'A flow mapping entry is missing its value indicator.' + )) + } + } else { + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $value = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 1) ` + -Start $nullMark -End $nullMark + $value.Value = '' + $value.IsPlainImplicit = $true + } + } else { + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + if ($Cursor.Index -ge $Context.Text.Length -or + $Context.Text[$Cursor.Index] -in @(',', '}')) { + $nullMark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $value = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth ($Depth + 1) ` + -Start $nullMark -End $nullMark + $value.Value = '' + $value.IsPlainImplicit = $true + } else { + $value = Read-YamlFlowNode -Cursor $Cursor -Context $Context -Depth ($Depth + 1) + } + } + $node.Entries.Add([pscustomobject]@{ Key = $key; Value = $value }) + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + if ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -eq ',') { + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + continue + } + if ($Cursor.Index -ge $Context.Text.Length -or $Context.Text[$Cursor.Index] -ne $closing) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidFlowCollection' -Message ( + "A flow mapping requires ',' or '$closing'." + )) + } + } + } + + if ($Cursor.Index -ge $Context.Text.Length) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlUnexpectedEnd' -Message ( + "A flow collection is missing '$closing'." + )) + } + Move-YamlCursor -Cursor $Cursor -Context $Context + $node.End = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + return $node + } + + if ($character -eq "'" -or $character -eq '"') { + $quote = $character + Move-YamlCursor -Cursor $Cursor -Context $Context + $builder = [System.Text.StringBuilder]::new() + $closed = $false + $pendingBreaks = 0 + $rawTrailingWhitespace = 0 + while ($Cursor.Index -lt $Context.Text.Length) { + $character = $Context.Text[$Cursor.Index] + if ($character -eq $quote) { + if ($quote -eq "'" -and $Cursor.Index + 1 -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index + 1] -eq "'") { + if ($pendingBreaks -gt 0) { + [void] $builder.Append( + $(if ($pendingBreaks -eq 1) { ' ' } else { "`n" * ($pendingBreaks - 1) }) + ) + } + $pendingBreaks = 0 + [void] $builder.Append("'") + $rawTrailingWhitespace = 0 + Move-YamlCursor -Cursor $Cursor -Context $Context -Count 2 + continue + } + if ($pendingBreaks -gt 0) { + [void] $builder.Append( + $(if ($pendingBreaks -eq 1) { ' ' } else { "`n" * ($pendingBreaks - 1) }) + ) + $pendingBreaks = 0 + } + Move-YamlCursor -Cursor $Cursor -Context $Context + $closed = $true + break + } + if ($quote -eq '"' -and $character -eq '\') { + Move-YamlCursor -Cursor $Cursor -Context $Context + if ($Cursor.Index -ge $Context.Text.Length) { + break + } + $escape = $Context.Text[$Cursor.Index] + if ($escape -eq "`n") { + Move-YamlCursor -Cursor $Cursor -Context $Context + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -in @(' ', "`t")) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + continue + } + $simpleEscape = $true + $escapedCharacter = switch -CaseSensitive ([string] $escape) { + '0' { [char] 0 } 'a' { [char] 7 } 'b' { [char] 8 } 't' { [char] 9 } + 'n' { [char] 10 } 'v' { [char] 11 } 'f' { [char] 12 } 'r' { [char] 13 } + 'e' { [char] 27 } ' ' { [char] 32 } '"' { [char] 34 } '/' { [char] 47 } + '\' { [char] 92 } 'N' { [char] 0x85 } '_' { [char] 0xA0 } + 'L' { [char] 0x2028 } 'P' { [char] 0x2029 } + "`t" { [char] 9 } + default { $simpleEscape = $false; $null } + } + if ($simpleEscape) { + if ($pendingBreaks -gt 0) { + [void] $builder.Append( + $(if ($pendingBreaks -eq 1) { ' ' } else { "`n" * ($pendingBreaks - 1) }) + ) + } + $pendingBreaks = 0 + [void] $builder.Append($escapedCharacter) + $rawTrailingWhitespace = 0 + Move-YamlCursor -Cursor $Cursor -Context $Context + continue + } + $hexLength = switch -CaseSensitive ([string] $escape) { + 'x' { 2 } + 'u' { 4 } + 'U' { 8 } + default { 0 } + } + if ($hexLength -eq 0 -or $Cursor.Index + $hexLength -ge $Context.Text.Length) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidEscape' -Message ( + "The YAML escape sequence '\\$escape' is invalid." + )) + } + $hex = $Context.Text.Substring($Cursor.Index + 1, $hexLength) + [uint32] $codePoint = 0 + if (-not [uint32]::TryParse( + $hex, + [System.Globalization.NumberStyles]::HexNumber, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $codePoint + ) -or $codePoint -gt 0x10FFFF -or + ($codePoint -ge 0xD800 -and $codePoint -le 0xDFFF)) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidEscape' -Message ( + "The YAML escape sequence '\\$escape$hex' is invalid." + )) + } + if ($pendingBreaks -gt 0) { + [void] $builder.Append( + $(if ($pendingBreaks -eq 1) { ' ' } else { "`n" * ($pendingBreaks - 1) }) + ) + } + $pendingBreaks = 0 + [void] $builder.Append([char]::ConvertFromUtf32([int] $codePoint)) + $rawTrailingWhitespace = 0 + Move-YamlCursor -Cursor $Cursor -Context $Context -Count ($hexLength + 1) + continue + } + if ($character -eq "`n") { + if ($rawTrailingWhitespace -gt 0) { + $builder.Length -= $rawTrailingWhitespace + } + $rawTrailingWhitespace = 0 + $pendingBreaks = 0 + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -eq "`n") { + $pendingBreaks++ + Move-YamlCursor -Cursor $Cursor -Context $Context + $indentSpaces = 0 + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -in @(' ', "`t")) { + if ($Context.Text[$Cursor.Index] -eq ' ') { + $indentSpaces++ + } + Move-YamlCursor -Cursor $Cursor -Context $Context + } + } + if ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -ne "`n" -and + $indentSpaces -le $Cursor.ParentIndent) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidIndentation' -Message ( + 'A multiline quoted scalar must be indented beyond its surrounding block context.' + )) + } + if ($Cursor.Column -eq 0 -and $Cursor.Index + 3 -le $Context.Text.Length) { + $marker = $Context.Text.Substring($Cursor.Index, 3) + if ($marker -in @('---', '...') -and + ($Cursor.Index + 3 -eq $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 3]))) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidQuotedScalar' -Message ( + 'A document marker cannot occur inside a multiline quoted scalar.' + )) + } + } + continue + } + if ($pendingBreaks -gt 0) { + [void] $builder.Append( + $(if ($pendingBreaks -eq 1) { ' ' } else { "`n" * ($pendingBreaks - 1) }) + ) + } + $pendingBreaks = 0 + [void] $builder.Append($character) + if ($character -in @(' ', "`t")) { + $rawTrailingWhitespace++ + } else { + $rawTrailingWhitespace = 0 + } + if ($builder.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + Move-YamlCursor -Cursor $Cursor -Context $Context + } + if (-not $closed) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlUnexpectedEnd' -Message ( + 'A quoted YAML scalar is missing its closing quote.' + )) + } + $end = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $start -End $end + Set-YamlParsedNodeProperty -Node $node -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor ` + -Context $Context + $node.Value = $builder.ToString() + $node.Style = if ($quote -eq "'") { 'SingleQuoted' } else { 'DoubleQuoted' } + $node.IsQuotedImplicit = [string]::IsNullOrEmpty($tag) -and -not $unknownTag + if ($node.Value.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $end -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + return $node + } + + $builder = [System.Text.StringBuilder]::new() + $pendingSpace = $false + $lastContentLine = $Cursor.Line + $firstPlainCharacter = $Context.Text[$Cursor.Index] + if ($firstPlainCharacter -in @(',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') -or + ($firstPlainCharacter -in @('-', '?', ':') -and ( + $Cursor.Index + 1 -ge $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 1]) -or + $Context.Text[$Cursor.Index + 1] -in @(',', '[', ']', '{', '}') + ))) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidPlainScalar' -Message ( + 'The first character is not allowed in a YAML plain scalar.' + )) + } + while ($Cursor.Index -lt $Context.Text.Length) { + $character = $Context.Text[$Cursor.Index] + if ($character -in @(',', '[', ']', '{', '}')) { + break + } + if ($character -eq ':' -and ( + $Cursor.Index + 1 -ge $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 1]) -or + $Context.Text[$Cursor.Index + 1] -in @(',', ']', '}') + )) { + break + } + if ($character -eq '#' -and + ($Cursor.Index -eq 0 -or [char]::IsWhiteSpace($Context.Text[$Cursor.Index - 1]))) { + break + } + if ([char]::IsWhiteSpace($character)) { + $pendingSpace = $true + Move-YamlCursor -Cursor $Cursor -Context $Context + if ($character -eq "`n") { + $indentSpaces = 0 + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -eq ' ') { + $indentSpaces++ + Move-YamlCursor -Cursor $Cursor -Context $Context + } + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -eq "`t") { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + if ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -notin @("`n", '#', ']', '}') -and + $indentSpaces -le $Cursor.ParentIndent) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidFlowIndentation' -Message ( + 'A multiline flow scalar must be indented beyond its surrounding block context.' + )) + } + } + continue + } + if ($pendingSpace -and $builder.Length -gt 0) { + [void] $builder.Append(' ') + } + $pendingSpace = $false + [void] $builder.Append($character) + if ($builder.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + $lastContentLine = $Cursor.Line + Move-YamlCursor -Cursor $Cursor -Context $Context + } + $value = $builder.ToString().TrimEnd() + if ([string]::IsNullOrEmpty($value)) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlExpectedNode' -Message ( + 'A YAML node was expected.' + )) + } + $end = New-YamlMark -Index $Cursor.Index -Line $lastContentLine -Column $Cursor.Column + $node = New-YamlSyntaxNode -Context $Context -Kind Scalar -Depth $Depth -Start $start -End $end + Set-YamlParsedNodeProperty -Node $node -Tag $tag -HasUnknownTag $unknownTag -Anchor $anchor ` + -Context $Context + $node.Value = $value + $node.Style = 'Plain' + $node.IsPlainImplicit = [string]::IsNullOrEmpty($tag) -and -not $unknownTag + if ($value.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $end -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + return $node +} diff --git a/src/functions/private/Read-YamlInlineNode.ps1 b/src/functions/private/Read-YamlInlineNode.ps1 new file mode 100644 index 0000000..7be99cf --- /dev/null +++ b/src/functions/private/Read-YamlInlineNode.ps1 @@ -0,0 +1,65 @@ +function Read-YamlInlineNode { + <# + .SYNOPSIS + Reads a quoted, alias, or flow node beginning on the current block line. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [int] $Column, + + [Parameter(Mandatory)] + [int] $ParentIndent, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowEmptyString()] + [string] $Tag = '', + + [bool] $HasUnknownTag = $false, + + [AllowEmptyString()] + [string] $Anchor = '' + ) + + $line = $Context.LineIndex + $cursor = [pscustomobject]@{ + Index = $Context.LineStarts[$line] + $Column + Line = $line + Column = $Column + ParentIndent = $ParentIndent + } + $node = Read-YamlFlowNode -Cursor $cursor -Context $Context -Depth $Depth -PendingTag $Tag ` + -PendingUnknownTag $HasUnknownTag -PendingAnchor $Anchor + + $separated = $false + while ($cursor.Index -lt $Context.Text.Length -and $Context.Text[$cursor.Index] -in @(' ', "`t")) { + $separated = $true + Move-YamlCursor -Cursor $cursor -Context $Context + } + if ($cursor.Index -lt $Context.Text.Length -and $Context.Text[$cursor.Index] -eq '#') { + if (-not $separated) { + $mark = New-YamlMark -Index $cursor.Index -Line $cursor.Line -Column $cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidComment' -Message ( + 'A YAML comment must be separated from preceding content.' + )) + } + while ($cursor.Index -lt $Context.Text.Length -and $Context.Text[$cursor.Index] -ne "`n") { + Move-YamlCursor -Cursor $cursor -Context $Context + } + } + if ($cursor.Index -lt $Context.Text.Length -and $Context.Text[$cursor.Index] -ne "`n") { + $mark = New-YamlMark -Index $cursor.Index -Line $cursor.Line -Column $cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlUnexpectedContent' -Message ( + 'Unexpected content follows a YAML node.' + )) + } + $Context.LineIndex = $cursor.Line + 1 + return $node +} diff --git a/src/functions/private/Read-YamlNodeProperty.ps1 b/src/functions/private/Read-YamlNodeProperty.ps1 new file mode 100644 index 0000000..98228d1 --- /dev/null +++ b/src/functions/private/Read-YamlNodeProperty.ps1 @@ -0,0 +1,103 @@ +function Read-YamlNodeProperty { + <# + .SYNOPSIS + Reads tag and anchor properties from the start of a node segment. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter(Mandatory)] + [int] $Line, + + [Parameter(Mandatory)] + [int] $Column, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $position = 0 + $tag = '' + $unknown = $false + $anchor = '' + while ($position -lt $Text.Length) { + while ($position -lt $Text.Length -and [char]::IsWhiteSpace($Text[$position])) { + $position++ + } + if ($position -ge $Text.Length) { + break + } + if ($Text[$position] -eq '!') { + if (-not [string]::IsNullOrEmpty($tag) -or $unknown) { + $mark = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $position) -Line $Line ` + -Column ($Column + $position) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlDuplicateNodeProperty' -Message ( + 'A YAML node cannot have more than one tag property.' + )) + } + $start = $position + if ($position + 1 -lt $Text.Length -and $Text[$position + 1] -eq '<') { + $end = $Text.IndexOf('>', $position + 2) + if ($end -lt 0) { + $end = $Text.Length - 1 + } + $position = $end + 1 + } else { + while ($position -lt $Text.Length -and -not [char]::IsWhiteSpace($Text[$position]) -and + $Text[$position] -notin @(',', '[', ']', '{', '}')) { + $position++ + } + } + if ($position - $start -gt $Context.MaxTagLength) { + $mark = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $start) -Line $Line ` + -Column ($Column + $start) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag token exceeds the configured limit of $($Context.MaxTagLength) characters." + )) + } + $token = $Text.Substring($start, $position - $start) + $mark = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $start) -Line $Line ` + -Column ($Column + $start) + $resolved = Resolve-YamlTag -Token $token -Context $Context -Mark $mark + $tag = $resolved.Tag + $unknown = $resolved.IsUnknown + continue + } + if ($Text[$position] -eq '&') { + if (-not [string]::IsNullOrEmpty($anchor)) { + $mark = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $position) -Line $Line ` + -Column ($Column + $position) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlDuplicateNodeProperty' -Message ( + 'A YAML node cannot have more than one anchor property.' + )) + } + $start = ++$position + while ($position -lt $Text.Length -and -not [char]::IsWhiteSpace($Text[$position]) -and + $Text[$position] -notin @(',', '[', ']', '{', '}')) { + $position++ + } + $anchor = $Text.Substring($start, $position - $start) + if ([string]::IsNullOrEmpty($anchor) -or $anchor.IndexOfAny(@('[', ']', '{', '}', ',')) -ge 0) { + $mark = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $start - 1) -Line $Line ` + -Column ($Column + $start - 1) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidAnchor' -Message ( + 'A YAML anchor name is missing or malformed.' + )) + } + continue + } + break + } + + [pscustomobject]@{ + Tag = $tag + HasUnknownTag = $unknown + Anchor = $anchor + Rest = $Text.Substring($position).TrimStart() + Consumed = $position + ($Text.Substring($position).Length - $Text.Substring($position).TrimStart().Length) + } +} diff --git a/src/functions/private/Read-YamlPlainScalar.ps1 b/src/functions/private/Read-YamlPlainScalar.ps1 new file mode 100644 index 0000000..7602340 --- /dev/null +++ b/src/functions/private/Read-YamlPlainScalar.ps1 @@ -0,0 +1,130 @@ +function Read-YamlPlainScalar { + <# + .SYNOPSIS + Reads a block-context plain scalar and its folded continuation lines. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [string] $FirstText, + + [Parameter(Mandatory)] + [int] $FirstColumn, + + [Parameter(Mandatory)] + [int] $ParentIndent, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $Depth, + + [AllowEmptyString()] + [string] $Tag = '', + + [bool] $HasUnknownTag = $false, + + [AllowEmptyString()] + [string] $Anchor = '' + ) + + $startLine = $Context.LineIndex + $start = New-YamlMark -Index ($Context.LineStarts[$startLine] + $FirstColumn) -Line $startLine ` + -Column $FirstColumn + $parts = [System.Collections.Generic.List[object]]::new() + $firstCommentMatch = [regex]::Match($FirstText, '(?', "'", '"', '%', '@', '`' + ) + $conditionalIndicator = $firstCharacter -in @('-', '?', ':') + if ($forbiddenFirst -or ( + $conditionalIndicator -and ( + $firstValue.Length -eq 1 -or [char]::IsWhiteSpace($firstValue[1]) + ) + )) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidPlainScalar' -Message ( + 'The first character is not allowed in a YAML plain scalar.' + )) + } + if ((Find-YamlMappingColon -Text $firstValue) -ge 0) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlInvalidPlainScalar' -Message ( + 'A mapping value indicator cannot occur in a block plain scalar.' + )) + } + if ($firstValue.Length -gt $Context.MaxScalarLength) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A YAML scalar exceeds the configured limit of $($Context.MaxScalarLength) characters." + )) + } + $parts.Add($firstValue) + $decodedLength = $firstValue.Length + $Context.LineIndex++ + $pendingBreaks = 0 + + while ($firstComment -lt 0 -and $Context.LineIndex -lt $Context.Lines.Count) { + $line = $Context.Lines[$Context.LineIndex] + $trimmed = $line.TrimStart(' ', "`t") + if ($line -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + break + } + if ($trimmed.Length -eq 0) { + $pendingBreaks++ + $Context.LineIndex++ + continue + } + if ($trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + break + } + $indent = Get-YamlIndent -Line $line -LineNumber $Context.LineIndex -Context $Context + if ($indent -le $ParentIndent) { + break + } + $sourceContent = $line.Substring($indent) + $commentMatch = [regex]::Match($sourceContent, '(? + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml, + + [Parameter(Mandatory)] + [ValidateRange(1, 128)] + [int] $Depth, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength, + + [Parameter(Mandatory)] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength, + + [Parameter(Mandatory)] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength + ) + + Read-YamlStreamCore -Yaml $Yaml -Depth $Depth -MaxNodes $MaxNodes -MaxAliases $MaxAliases ` + -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength +} diff --git a/src/functions/private/Read-YamlStreamCore.ps1 b/src/functions/private/Read-YamlStreamCore.ps1 new file mode 100644 index 0000000..2942a51 --- /dev/null +++ b/src/functions/private/Read-YamlStreamCore.ps1 @@ -0,0 +1,193 @@ +function Read-YamlStreamCore { + <# + .SYNOPSIS + Reads and validates all documents with the repository-owned YAML parser. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Yaml, + + [Parameter(Mandatory)] + [ValidateRange(1, 128)] + [int] $Depth, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength, + + [Parameter(Mandatory)] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength, + + [Parameter(Mandatory)] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength, + + [Parameter(Mandatory)] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength, + + [switch] $SkipGraphValidation + ) + + $context = New-YamlReaderContext -Yaml $Yaml -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength ` + -MaxTagLength $MaxTagLength -MaxTotalTagLength $MaxTotalTagLength ` + -MaxNumericLength $MaxNumericLength + $text = $context.Text + $lines = $context.Lines + $lineStarts = $context.LineStarts + $documents = [System.Collections.Generic.List[object]]::new() + $implicitDocumentSeen = $false + + while ($context.LineIndex -lt $lines.Count) { + Skip-YamlBlockTrivia -Context $context + if ($context.LineIndex -ge $lines.Count) { + break + } + if ($lines[$context.LineIndex] -match '^\.\.\.(?:[ \t]|$)') { + $suffix = Get-YamlContentWithoutComment -Text $lines[$context.LineIndex].Substring(3) + if ($suffix.Trim().Length -gt 0) { + $mark = New-YamlMark -Index ($lineStarts[$context.LineIndex] + 3) ` + -Line $context.LineIndex -Column 3 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidDocumentEnd' -Message ( + 'Unexpected content follows the document end marker.' + )) + } + $context.LineIndex++ + $implicitDocumentSeen = $false + continue + } + + $directives = Read-YamlDirectiveBlock -Context $context + $tagHandles = $directives.TagHandles + $directiveSeen = $directives.DirectiveSeen + + if ($context.LineIndex -ge $lines.Count) { + if ($directiveSeen) { + $mark = New-YamlMark -Index $text.Length -Line ([Math]::Max(0, $lines.Count - 1)) -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlDirectiveWithoutDocument' -Message ( + 'YAML directives must be followed by an explicit document start marker.' + )) + } + break + } + + $lineText = $lines[$context.LineIndex] + $explicitStart = $lineText -match '^---(?:[ \t]|$)' + if ($directiveSeen -and -not $explicitStart) { + $mark = New-YamlMark -Index $lineStarts[$context.LineIndex] -Line $context.LineIndex -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlDirectiveWithoutDocument' -Message ( + 'YAML directives must be followed by an explicit document start marker.' + )) + } + if (-not $explicitStart -and $implicitDocumentSeen) { + $mark = New-YamlMark -Index $lineStarts[$context.LineIndex] -Line $context.LineIndex -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidStream' -Message ( + 'A second document requires an explicit document start marker.' + )) + } + + $context.TagHandles = $tagHandles + $context.Anchors = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + if ($explicitStart) { + $afterMarker = $lineText.Substring(3) + $leading = $afterMarker.Length - $afterMarker.TrimStart().Length + $segment = $afterMarker.TrimStart() + $segmentColumn = 3 + $leading + if ([string]::IsNullOrWhiteSpace((Get-YamlContentWithoutComment -Text $segment))) { + $markerLine = $context.LineIndex + $context.LineIndex++ + Skip-YamlBlockTrivia -Context $context + if ($context.LineIndex -ge $lines.Count -or + $lines[$context.LineIndex] -match '^(?:---|\.\.\.)(?:[ \t]|$)') { + $mark = New-YamlMark -Index ($lineStarts[$markerLine] + 3) -Line $markerLine -Column 3 + $document = New-YamlEmptyScalar -Context $context -Depth 1 -Mark $mark + } else { + $document = Read-YamlBlockNode -Context $context -ParentIndent -1 -Depth 1 + } + } else { + if ($segment[0] -in @('!', '&') -and + (Find-YamlMappingColon -Text $segment) -ge 0) { + $mark = New-YamlMark -Index ($lineStarts[$context.LineIndex] + $segmentColumn) ` + -Line $context.LineIndex -Column $segmentColumn + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidDocumentStart' -Message ( + 'Node properties cannot precede a compact block mapping on a document-start line.' + )) + } + if (-not (Test-YamlIndicator -Text $segment -Indicator '-') -and + ((Find-YamlMappingColon -Text $segment) -ge 0 -or + (Test-YamlIndicator -Text $segment -Indicator '?'))) { + $document = Read-YamlBlockMapping -Context $context -Indent 0 -Depth 1 ` + -FirstText $segment -FirstColumn $segmentColumn + } elseif (Test-YamlIndicator -Text $segment -Indicator '-') { + $firstItem = $segment.Substring(1) + $leading = $firstItem.Length - $firstItem.TrimStart().Length + $document = Read-YamlBlockSequence -Context $context -Indent 0 -Depth 1 ` + -FirstItemText $firstItem.TrimStart() ` + -FirstItemColumn ($segmentColumn + 1 + $leading) + } else { + $document = Read-YamlBlockNode -Context $context -ParentIndent -1 -Depth 1 ` + -Segment $segment -SegmentColumn $segmentColumn + } + } + } else { + $implicitDocumentSeen = $true + $document = Read-YamlBlockNode -Context $context -ParentIndent -1 -Depth 1 + } + + $document = ConvertFrom-YamlSyntaxTree -Root $document + if (-not $SkipGraphValidation) { + $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) + + Skip-YamlBlockTrivia -Context $context + $explicitEnd = $false + if ($context.LineIndex -lt $lines.Count -and + $lines[$context.LineIndex] -match '^\.\.\.(?:[ \t]|$)') { + $explicitEnd = $true + $endLine = $lines[$context.LineIndex] + if ((Get-YamlContentWithoutComment -Text $endLine.Substring(3)).Trim().Length -gt 0) { + $mark = New-YamlMark -Index ($lineStarts[$context.LineIndex] + 3) ` + -Line $context.LineIndex -Column 3 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidDocumentEnd' -Message ( + 'Unexpected content follows the document end marker.' + )) + } + $context.LineIndex++ + Skip-YamlBlockTrivia -Context $context + $implicitDocumentSeen = $false + } + if (-not $explicitEnd -and $context.LineIndex -lt $lines.Count -and + $lines[$context.LineIndex] -notmatch '^---(?:[ \t]|$)' -and + $lines[$context.LineIndex].Trim().Length -gt 0) { + $mark = New-YamlMark -Index $lineStarts[$context.LineIndex] -Line $context.LineIndex -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidStream' -Message ( + 'Unexpected content remains after a YAML document.' + )) + } + } + + return New-YamlValueBox -Value ([object[]] $documents.ToArray()) +} diff --git a/src/functions/private/Resolve-YamlScalar.ps1 b/src/functions/private/Resolve-YamlScalar.ps1 new file mode 100644 index 0000000..17820ec --- /dev/null +++ b/src/functions/private/Resolve-YamlScalar.ps1 @@ -0,0 +1,183 @@ +function Resolve-YamlScalar { + <# + .SYNOPSIS + Constructs and caches a safe PowerShell scalar using YAML 1.2 rules. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + if ($Node.ResolutionState -eq 1) { + return New-YamlValueBox -Value $Node.ResolvedValue + } + + $tagPrefix = 'tag:yaml.org,2002:' + $tag = [string] $Node.Tag + $value = [string] $Node.Value + $isImplicit = [string]::IsNullOrEmpty($tag) -and $Node.IsPlainImplicit + $standardTag = if ($isImplicit) { '' } elseif ( + $tag.StartsWith($tagPrefix, [System.StringComparison]::Ordinal) + ) { + $tag.Substring($tagPrefix.Length) + } else { + '' + } + $resolved = $value + + if ($Node.HasUnknownTag -or (-not $isImplicit -and [string]::IsNullOrEmpty($standardTag))) { + $resolved = $value + } elseif ($standardTag -ceq 'str') { + $resolved = $value + } elseif (($standardTag -ceq 'null' -or $isImplicit) -and + $value -cmatch '^(?:|~|null|Null|NULL)$') { + $resolved = $null + } elseif (($standardTag -ceq 'bool' -or $isImplicit) -and + $value -cmatch '^(?:true|True|TRUE|false|False|FALSE)$') { + $resolved = $value[0] -ceq 't' -or $value[0] -ceq 'T' + } elseif (($standardTag -ceq 'int' -or $isImplicit) -and + $value -cmatch '^(?:[-+]?[0-9]+|[-+]?0o[0-7]+|[-+]?0x[0-9a-fA-F]+)$') { + $digits = $value.TrimStart('+', '-') + if ($digits.StartsWith('0o', [System.StringComparison]::Ordinal) -or + $digits.StartsWith('0x', [System.StringComparison]::Ordinal)) { + $digits = $digits.Substring(2) + } + if ($digits.Length -gt $Node.MaxNumericLength) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlNumericLimitExceeded' -Message ( + "A YAML numeric scalar exceeds the configured limit of $($Node.MaxNumericLength) digits." + )) + } + $resolved = ConvertFrom-YamlInteger -Value $value + } elseif (($standardTag -ceq 'float' -or $isImplicit) -and + $value -cmatch '^[-+]?\.(?:inf|Inf|INF)$') { + $resolved = if ($value.StartsWith('-', [System.StringComparison]::Ordinal)) { + [double]::NegativeInfinity + } else { + [double]::PositiveInfinity + } + } elseif (($standardTag -ceq 'float' -or $isImplicit) -and + $value -cmatch '^\.(?:nan|NaN|NAN)$') { + $resolved = [double]::NaN + } elseif (($standardTag -ceq 'float' -or $isImplicit) -and + $value -cmatch '^[-+]?(?:(?:\.[0-9]+)|(?:[0-9]+(?:\.[0-9]*)?))(?:[eE][-+]?[0-9]+)?$') { + $numericCharacters = ($value -replace '[^0-9]', '').Length + if ($numericCharacters -gt $Node.MaxNumericLength) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlNumericLimitExceeded' -Message ( + "A YAML numeric scalar exceeds the configured limit of $($Node.MaxNumericLength) digits." + )) + } + if ($value.IndexOfAny(@('e', 'E')) -lt 0) { + $decimalValue = [decimal] 0 + if ([decimal]::TryParse( + $value, + [System.Globalization.NumberStyles]::Number, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $decimalValue + )) { + $resolved = $decimalValue + } else { + $number = [double] 0 + if (-not [double]::TryParse( + $value, + [System.Globalization.NumberStyles]::Float, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $number + ) -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." + )) + } + $resolved = $number + } + } else { + $number = [double] 0 + if (-not [double]::TryParse( + $value, + [System.Globalization.NumberStyles]::Float, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $number + ) -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." + )) + } + $resolved = $number + } + } elseif ($standardTag -ceq 'binary') { + try { + $resolved = [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." + )) + } + } elseif ($standardTag -ceq '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 + $utcStyles = ( + [System.Globalization.DateTimeStyles]::AssumeUniversal -bor + [System.Globalization.DateTimeStyles]::AdjustToUniversal + ) + if ([datetime]::TryParseExact( + $value, + 'yyyy-MM-dd', + [System.Globalization.CultureInfo]::InvariantCulture, + $utcStyles, + [ref] $date + )) { + $resolved = [datetime]::SpecifyKind($date, [System.DateTimeKind]::Utc) + } else { + $normalized = $value -replace '\s+([+-]\d{1,2}(?::?\d{2})?)$', '$1' + if ($normalized -match '([+-])(\d{1,2})(?::?(\d{2}))?$') { + $minutes = if ($Matches[3]) { $Matches[3] } else { '00' } + $suffix = '{0}{1}:{2}' -f $Matches[1], $Matches[2].PadLeft(2, '0'), $minutes + $normalized = $normalized.Substring(0, $normalized.Length - $Matches[0].Length) + $suffix + } + if ($normalized -cmatch '(?:[Zz]|[-+]\d{2}:\d{2})$') { + $offset = [datetimeoffset]::MinValue + if (-not [datetimeoffset]::TryParse( + $normalized, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AllowWhiteSpaces, + [ref] $offset + )) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlInvalidTimestamp' -Message ( + "The value '$value' is not a supported YAML timestamp." + )) + } + $resolved = $offset + } else { + $dateTime = [datetime]::MinValue + if (-not [datetime]::TryParse( + $normalized, + [System.Globalization.CultureInfo]::InvariantCulture, + $utcStyles, + [ref] $dateTime + )) { + throw (New-YamlException -Start $Node.Start -End $Node.End -ErrorId 'YamlInvalidTimestamp' -Message ( + "The value '$value' is not a supported YAML timestamp." + )) + } + $resolved = [datetime]::SpecifyKind($dateTime, [System.DateTimeKind]::Utc) + } + } + } elseif (-not $isImplicit -and $standardTag -cin @('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'." + )) + } else { + $resolved = $value + } + + $Node.ResolvedValue = $resolved + $Node.ResolutionState = 1 + return New-YamlValueBox -Value $resolved +} diff --git a/src/functions/private/Resolve-YamlTag.ps1 b/src/functions/private/Resolve-YamlTag.ps1 new file mode 100644 index 0000000..6c5893d --- /dev/null +++ b/src/functions/private/Resolve-YamlTag.ps1 @@ -0,0 +1,103 @@ +function Resolve-YamlTag { + <# + .SYNOPSIS + Expands, budgets, and classifies one YAML tag token. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [string] $Token, + + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [pscustomobject] $Mark + ) + + if (-not $Token.StartsWith('!', [System.StringComparison]::Ordinal)) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' is malformed." + )) + } + + if ($Token.StartsWith('!<', [System.StringComparison]::Ordinal)) { + if (-not $Token.EndsWith('>', [System.StringComparison]::Ordinal) -or $Token.Length -lt 4) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' is malformed." + )) + } + $suffix = $Token.Substring(2, $Token.Length - 3) + if (-not (Test-YamlTagUriText -Text $suffix)) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' contains invalid URI text." + )) + } + $prefix = '' + } else { + $handle = '!' + $suffix = $Token.Substring(1) + if ($Token.StartsWith('!!', [System.StringComparison]::Ordinal)) { + $handle = '!!' + $suffix = $Token.Substring(2) + } else { + $secondBang = $Token.IndexOf('!', 1) + if ($secondBang -ge 1) { + $handle = $Token.Substring(0, $secondBang + 1) + $suffix = $Token.Substring($secondBang + 1) + } + } + if ($Token -eq '!') { + $prefix = '' + $suffix = '!' + } elseif ([string]::IsNullOrEmpty($suffix) -or + -not (Test-YamlTagUriText -Text $suffix -Shorthand)) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( + "The tag token '$Token' contains an invalid shorthand suffix." + )) + } + if (-not $Context.TagHandles.ContainsKey($handle)) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlUndefinedTagHandle' -Message ( + "The tag handle '$handle' was not declared." + )) + } + if ($Token -ne '!') { + $prefix = $Context.TagHandles[$handle] + } + } + + $expanded = ConvertFrom-YamlTagUriEscape -Text ($prefix + $suffix) -Mark $Mark -Token $Token + $expandedLength = $expanded.Length + if ($expandedLength -gt $Context.MaxTagLength) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $($Context.MaxTagLength) characters." + )) + } + $Context.TotalTagLength += $expandedLength + if ($Context.TotalTagLength -gt $Context.MaxTotalTagLength) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlTagLimitExceeded' -Message ( + "The YAML stream exceeds the configured cumulative tag limit of $($Context.MaxTotalTagLength) characters." + )) + } + + $known = $expanded -cin @( + 'tag:yaml.org,2002:binary', + 'tag:yaml.org,2002:bool', + 'tag:yaml.org,2002:float', + 'tag:yaml.org,2002:int', + 'tag:yaml.org,2002:map', + 'tag:yaml.org,2002:null', + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs', + 'tag:yaml.org,2002:seq', + 'tag:yaml.org,2002:set', + 'tag:yaml.org,2002:str', + 'tag:yaml.org,2002:timestamp' + ) + + [pscustomobject]@{ + Tag = $expanded + IsUnknown = -not $known + } +} 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/Set-YamlParsedNodeProperty.ps1 b/src/functions/private/Set-YamlParsedNodeProperty.ps1 new file mode 100644 index 0000000..fc83329 --- /dev/null +++ b/src/functions/private/Set-YamlParsedNodeProperty.ps1 @@ -0,0 +1,36 @@ +function Set-YamlParsedNodeProperty { + <# + .SYNOPSIS + Applies parsed node properties and registers an anchor. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Mutates an internal representation node during parsing.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Tag, + + [Parameter(Mandatory)] + [bool] $HasUnknownTag, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Anchor, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $Node.Tag = $Tag + $Node.HasUnknownTag = $HasUnknownTag + $Node.Anchor = $Anchor + if (-not [string]::IsNullOrEmpty($Anchor)) { + $Context.Anchors[$Anchor] = $Node + } +} diff --git a/src/functions/private/Skip-YamlBlockTrivia.ps1 b/src/functions/private/Skip-YamlBlockTrivia.ps1 new file mode 100644 index 0000000..d1603e9 --- /dev/null +++ b/src/functions/private/Skip-YamlBlockTrivia.ps1 @@ -0,0 +1,25 @@ +function Skip-YamlBlockTrivia { + <# + .SYNOPSIS + Advances past blank and comment-only block lines. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Advances an in-memory parser context.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + while ($Context.LineIndex -lt $Context.Lines.Count) { + $line = $Context.Lines[$Context.LineIndex] + $trimmed = $line.TrimStart(' ', "`t") + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + $Context.LineIndex++ + continue + } + break + } +} diff --git a/src/functions/private/Skip-YamlFlowTrivia.ps1 b/src/functions/private/Skip-YamlFlowTrivia.ps1 new file mode 100644 index 0000000..f184481 --- /dev/null +++ b/src/functions/private/Skip-YamlFlowTrivia.ps1 @@ -0,0 +1,96 @@ +function Skip-YamlFlowTrivia { + <# + .SYNOPSIS + Skips separation whitespace and comments inside flow content. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Advances an in-memory parser cursor.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Cursor, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + while ($Cursor.Index -lt $Context.Text.Length) { + $character = $Context.Text[$Cursor.Index] + if ($character -eq ' ' -or $character -eq "`t") { + Move-YamlCursor -Cursor $Cursor -Context $Context + continue + } + if ($character -eq '#') { + if ($Cursor.Index -gt 0 -and + $Context.Text[$Cursor.Index - 1] -notin @(' ', "`t", "`n")) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidComment' -Message ( + 'A YAML comment must be separated from preceding content.' + )) + } + while ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -ne "`n") { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + continue + } + if ($character -ne "`n") { + break + } + + Move-YamlCursor -Cursor $Cursor -Context $Context + while ($Cursor.Index -lt $Context.Text.Length) { + $lineStart = $Cursor.Index + $spaceIndent = 0 + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -eq ' ') { + $spaceIndent++ + Move-YamlCursor -Cursor $Cursor -Context $Context + } + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -eq "`t") { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + if ($Cursor.Index -ge $Context.Text.Length) { + return + } + if ($Context.Text[$Cursor.Index] -eq '#') { + while ($Cursor.Index -lt $Context.Text.Length -and $Context.Text[$Cursor.Index] -ne "`n") { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + if ($Cursor.Index -lt $Context.Text.Length) { + Move-YamlCursor -Cursor $Cursor -Context $Context + continue + } + return + } + if ($Context.Text[$Cursor.Index] -eq "`n") { + Move-YamlCursor -Cursor $Cursor -Context $Context + continue + } + + $indent = $Cursor.Index - $lineStart + if ($indent -eq 0 -and $Cursor.Index + 3 -le $Context.Text.Length) { + $marker = $Context.Text.Substring($Cursor.Index, 3) + if ($marker -in @('---', '...') -and + ($Cursor.Index + 3 -eq $Context.Text.Length -or + [char]::IsWhiteSpace($Context.Text[$Cursor.Index + 3]))) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column 0 + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidFlowCollection' -Message ( + 'A document marker cannot occur inside a flow collection.' + )) + } + } + $isClosing = $Context.Text[$Cursor.Index] -eq ']' -or $Context.Text[$Cursor.Index] -eq '}' + if ((-not $isClosing -and $spaceIndent -le $Cursor.ParentIndent) -or + ($isClosing -and $spaceIndent -lt $Cursor.ParentIndent)) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidFlowIndentation' -Message ( + 'Continuation lines in a flow collection must be indented beyond the surrounding block context.' + )) + } + break + } + } +} diff --git a/src/functions/private/Test-YamlIndicator.ps1 b/src/functions/private/Test-YamlIndicator.ps1 new file mode 100644 index 0000000..5083424 --- /dev/null +++ b/src/functions/private/Test-YamlIndicator.ps1 @@ -0,0 +1,22 @@ +function Test-YamlIndicator { + <# + .SYNOPSIS + Tests whether text starts with a separated block indicator. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter(Mandatory)] + [ValidateSet('-', '?', ':')] + [string] $Indicator + ) + + $separators = if ($Indicator -eq '-') { @(' ', "`t") } else { @(' ') } + return $Text.Length -gt 0 -and $Text[0] -eq $Indicator[0] -and ( + $Text.Length -eq 1 -or $Text[1] -in $separators + ) +} diff --git a/src/functions/private/Test-YamlNodeGraph.ps1 b/src/functions/private/Test-YamlNodeGraph.ps1 new file mode 100644 index 0000000..5aa88b8 --- /dev/null +++ b/src/functions/private/Test-YamlNodeGraph.ps1 @@ -0,0 +1,159 @@ +function Test-YamlNodeGraph { + <# + .SYNOPSIS + Iteratively validates tags and mapping-key uniqueness. + #> + [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 + ) + + $scalarTags = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($scalarTag in @('binary', 'bool', 'float', 'int', 'null', 'str', 'timestamp')) { + [void] $scalarTags.Add("tag:yaml.org,2002:$scalarTag") + } + + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push($Node) + while ($stack.Count -gt 0) { + $current = $stack.Pop() + while ($current.Kind -eq 'Alias') { + $current = $current.Target + } + if (-not $Visited.Add($current.Id)) { + continue + } + + $tag = [string] $current.Tag + if ($current.Kind -eq 'Scalar') { + if ($tag -cin @( + '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 $current.Start -End $current.End ` + -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' cannot be applied to a scalar node." + )) + } + $null = Resolve-YamlScalar -Node $current + continue + } + + if ($scalarTags.Contains($tag)) { + throw (New-YamlException -Start $current.Start -End $current.End ` + -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' cannot be applied to a $($current.Kind.ToLowerInvariant()) node." + )) + } + + if ($current.Kind -eq 'Sequence') { + if ($tag -ceq 'tag:yaml.org,2002:map' -or + $tag -ceq 'tag:yaml.org,2002:set') { + throw (New-YamlException -Start $current.Start -End $current.End ` + -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' requires a mapping node." + )) + } + + $isPairs = $tag -ceq 'tag:yaml.org,2002:pairs' + $isOrderedMap = $tag -ceq 'tag:yaml.org,2002:omap' + $orderedKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + for ($index = $current.Items.Count - 1; $index -ge 0; $index--) { + $item = $current.Items[$index] + 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 + throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` + -ErrorId 'YamlDuplicateKey' -Message ( + 'A duplicate key was found in a YAML ordered mapping.' + )) + } + } + } + $stack.Push($item) + } + continue + } + + if ($tag -cin @( + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs', + 'tag:yaml.org,2002:seq' + )) { + throw (New-YamlException -Start $current.Start -End $current.End ` + -ErrorId 'YamlTagKindMismatch' -Message ( + "YAML tag '$tag' requires a sequence node." + )) + } + + $keys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) { + $entry = $current.Entries[$index] + $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.' + )) + } + + if ($tag -ceq 'tag:yaml.org,2002:set') { + $setValue = $entry.Value + while ($setValue.Kind -eq 'Alias') { + $setValue = $setValue.Target + } + $setScalar = if ($setValue.Kind -eq 'Scalar') { + Resolve-YamlScalar -Node $setValue + } else { + $null + } + if ($setValue.Kind -ne 'Scalar' -or $null -ne $setScalar.Value) { + throw (New-YamlException -Start $entry.Value.Start -End $entry.Value.End ` + -ErrorId 'YamlInvalidTaggedCollection' -Message ( + 'Every value in a YAML set must be null.' + )) + } + } + $stack.Push($entry.Value) + $stack.Push($entry.Key) + } + } +} diff --git a/src/functions/private/Test-YamlTagUriText.ps1 b/src/functions/private/Test-YamlTagUriText.ps1 new file mode 100644 index 0000000..6459d39 --- /dev/null +++ b/src/functions/private/Test-YamlTagUriText.ps1 @@ -0,0 +1,40 @@ +function Test-YamlTagUriText { + <# + .SYNOPSIS + Tests tag URI text without allocating a decoded copy. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [switch] $Shorthand + ) + + if ($Text.Length -eq 0) { + return $false + } + + for ($index = 0; $index -lt $Text.Length; $index++) { + $character = $Text[$index] + if ([char]::IsWhiteSpace($character) -or [char]::IsControl($character) -or + $character -in @('<', '>', '{', '}')) { + return $false + } + if ($Shorthand -and $character -in @('!', '[', ']', ',')) { + return $false + } + if ($character -ne '%') { + continue + } + if ($index + 2 -ge $Text.Length -or + $Text[$index + 1] -notmatch '^[0-9A-Fa-f]$' -or + $Text[$index + 2] -notmatch '^[0-9A-Fa-f]$') { + return $false + } + $index += 2 + } + return $true +} diff --git a/src/functions/private/Write-YamlNodeText.ps1 b/src/functions/private/Write-YamlNodeText.ps1 new file mode 100644 index 0000000..5260afa --- /dev/null +++ b/src/functions/private/Write-YamlNodeText.ps1 @@ -0,0 +1,118 @@ +function Write-YamlNodeText { + <# + .SYNOPSIS + Iteratively writes an emission graph in deterministic YAML block form. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Appends deterministic text to an in-memory builder.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Text.StringBuilder] $Builder, + + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [ValidateRange(0, 128)] + [int] $Level, + + [Parameter(Mandatory)] + [ValidateRange(2, 9)] + [int] $Indent, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[long]] $EmittedReferences, + + [AllowEmptyString()] + [string] $LeadingText = '' + ) + + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Type = 'Node' + Node = $Node + Entry = $null + Level = $Level + LeadingText = $LeadingText + }) + + while ($stack.Count -gt 0) { + $task = $stack.Pop() + if ($task.Type -eq 'Entry') { + $keyText = ConvertTo-YamlFlowText -Node $task.Entry.Key ` + -EmittedReferences $EmittedReferences + $stack.Push([pscustomobject]@{ + Type = 'Node' + Node = $task.Entry.Value + Entry = $null + Level = $task.Level + LeadingText = "$keyText`: " + }) + continue + } + + $current = $task.Node + $spaces = ' ' * ($task.Level * $Indent) + if ($current.ReferenceId -ne 0 -and + $EmittedReferences.Contains($current.ReferenceId)) { + [void] $Builder.Append($spaces).Append($task.LeadingText). + Append('*').Append($current.Anchor).Append("`n") + continue + } + + $isEmptyCollection = ( + ($current.Kind -eq 'Sequence' -and $current.Items.Count -eq 0) -or + ($current.Kind -eq 'Mapping' -and $current.Entries.Count -eq 0) + ) + if ($current.Kind -eq 'Scalar' -or $isEmptyCollection) { + $flow = ConvertTo-YamlFlowText -Node $current ` + -EmittedReferences $EmittedReferences + [void] $Builder.Append($spaces).Append($task.LeadingText). + Append($flow).Append("`n") + continue + } + + if ($current.ReferenceId -ne 0) { + [void] $EmittedReferences.Add($current.ReferenceId) + } + $prefix = Get-YamlEmissionPrefix -Node $current + $childLevel = $task.Level + if (-not [string]::IsNullOrEmpty($task.LeadingText)) { + [void] $Builder.Append($spaces).Append($task.LeadingText) + if (-not [string]::IsNullOrEmpty($prefix)) { + [void] $Builder.Append($prefix) + } + [void] $Builder.Append("`n") + $childLevel++ + } elseif (-not [string]::IsNullOrEmpty($prefix)) { + [void] $Builder.Append($spaces).Append($prefix).Append("`n") + } + + if ($current.Kind -eq 'Sequence') { + for ($index = $current.Items.Count - 1; $index -ge 0; $index--) { + $stack.Push([pscustomobject]@{ + Type = 'Node' + Node = $current.Items[$index] + Entry = $null + Level = $childLevel + LeadingText = '- ' + }) + } + continue + } + + for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) { + $stack.Push([pscustomobject]@{ + Type = 'Entry' + Node = $null + Entry = $current.Entries[$index] + Level = $childLevel + LeadingText = '' + }) + } + } +} diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 new file mode 100644 index 0000000..73af471 --- /dev/null +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -0,0 +1,140 @@ +function ConvertFrom-Yaml { + <# + .SYNOPSIS + Converts a YAML stream into PowerShell values. + + .DESCRIPTION + Parses YAML with the module's repository-owned YAML 1.2 parser and + constructs values with the 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. + + .PARAMETER MaxTagLength + Maximum expanded character count for one tag. The default is 1024. + + .PARAMETER MaxTotalTagLength + Maximum cumulative expanded tag characters. The default is 65536. + + .PARAMETER MaxNumericLength + Maximum digits in an implicitly or explicitly typed number. The + default is 4096. + + .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, 128)] + [int] $Depth = 100, + + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $lines = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($line in $Yaml) { + $lines.Add($line) + } + } + end { + $yamlText = $lines -join "`n" + try { + $documentBox = Read-YamlStream -Yaml $yamlText -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength + foreach ($document in $documentBox.Value) { + $cache = [System.Collections.Generic.Dictionary[int, object]]::new() + $valueBox = ConvertFrom-YamlNode -Node $document -AsHashtable:$AsHashtable -Cache $cache + $value = $valueBox.Value + + $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 { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $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..cbfee38 --- /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 with the module's repository-owned YAML + emitter. PowerShell metadata is not serialized. Repeated acyclic + 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, 128)] + [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 = [object] $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..64ffc8d --- /dev/null +++ b/src/functions/public/Test-Yaml.ps1 @@ -0,0 +1,100 @@ +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 module-classified YAML failures. 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. + + .PARAMETER MaxTagLength + Maximum expanded character count for one tag. The default is 1024. + + .PARAMETER MaxTotalTagLength + Maximum cumulative expanded tag characters. The default is 65536. + + .PARAMETER MaxNumericLength + Maximum digits in a constructed number. The default is 4096. + + .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, 128)] + [int] $Depth = 100, + + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + 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 -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength + $PSCmdlet.WriteObject($true) + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $PSCmdlet.WriteObject($false) + } + } +} diff --git a/src/header.ps1 b/src/header.ps1 new file mode 100644 index 0000000..f1fccf0 --- /dev/null +++ b/src/header.ps1 @@ -0,0 +1,4 @@ +#Requires -Version 7.6 +#Requires -PSEdition Core +[CmdletBinding()] +param() diff --git a/src/manifest.psd1 b/src/manifest.psd1 new file mode 100644 index 0000000..985ced7 --- /dev/null +++ b/src/manifest.psd1 @@ -0,0 +1,5 @@ +@{ + ModuleVersion = '0.1.0' + PowerShellVersion = '7.6' + CompatiblePSEditions = @('Core') +} diff --git a/tests/Conformance.Tests.ps1 b/tests/Conformance.Tests.ps1 new file mode 100644 index 0000000..6442030 --- /dev/null +++ b/tests/Conformance.Tests.ps1 @@ -0,0 +1,106 @@ +#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 { + $archivePath = Join-Path $PSScriptRoot 'fixtures\yaml-test-suite\yaml-test-suite-data-2022-01-17.zip' + $suitePath = Join-Path $TestDrive 'yaml-test-suite' + Expand-Archive -LiteralPath $archivePath -DestinationPath $suitePath + $suiteResults = @( + & (Join-Path $PSScriptRoot 'tools\Invoke-YamlTestSuite.ps1') ` + -Path $suitePath ` + -CompareJson ` + -CompareEvents ` + -CompareOutYaml ` + -CompareEmitRoundTrip + ) +} + +Describe 'Released yaml-test-suite corpus accounting' { + It 'uses the pinned unmodified release archive' { + (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash | + Should -Be 'DCC1F037B13F6C3032D5190C447B6A6EF5560738A4C104F29B4243B0AB8F8029' + $suiteResults.Count | Should -Be 402 + } + + It 'accounts for syntax acceptance, rejection, and policy differences' { + @($suiteResults | Where-Object SyntaxResult -EQ 'Pass').Count | Should -Be 400 + @($suiteResults | Where-Object SyntaxResult -EQ 'PolicyDifference').Count | + Should -Be 2 + @($suiteResults | Where-Object SyntaxResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object SyntaxResult -EQ 'NotApplicable').Count | + Should -Be 0 + $syntaxPolicyCases = @( + $suiteResults | + Where-Object SyntaxResult -EQ 'PolicyDifference' | + Select-Object -ExpandProperty Case | + Sort-Object + ) + $syntaxPolicyCases | Should -Be @('2JQS', 'X38W') + } + + It 'accounts for parser representation/event comparisons' { + @($suiteResults | Where-Object EventResult -EQ 'Pass').Count | Should -Be 308 + @($suiteResults | Where-Object EventResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($suiteResults | Where-Object EventResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object EventResult -EQ 'NotApplicable').Count | + Should -Be 94 + } + + It 'accounts for JSON construction comparisons' { + @($suiteResults | Where-Object JsonResult -EQ 'Pass').Count | Should -Be 277 + @($suiteResults | Where-Object JsonResult -EQ 'PolicyDifference').Count | + Should -Be 2 + @($suiteResults | Where-Object JsonResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object JsonResult -EQ 'NotApplicable').Count | + Should -Be 123 + $jsonPolicyCases = @( + $suiteResults | + Where-Object JsonResult -EQ 'PolicyDifference' | + Select-Object -ExpandProperty Case | + Sort-Object + ) + $jsonPolicyCases | Should -Be @('565N', 'J7PZ') + } + + It 'accounts for out.yaml representation comparisons' { + @($suiteResults | Where-Object OutYamlResult -EQ 'Pass').Count | Should -Be 241 + @($suiteResults | Where-Object OutYamlResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($suiteResults | Where-Object OutYamlResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object OutYamlResult -EQ 'NotApplicable').Count | + Should -Be 161 + } + + It 'accounts for emitter and round-trip comparisons' { + @($suiteResults | Where-Object EmitResult -EQ 'Pass').Count | Should -Be 306 + @($suiteResults | Where-Object EmitResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($suiteResults | Where-Object EmitResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object EmitResult -EQ 'NotApplicable').Count | + Should -Be 96 + } + + It 'keeps the previously failing multi-document JSON cases green' { + $jsonRegressions = @( + $suiteResults | + Where-Object Case -In @( + '35KP', '6XDY', '6ZKB', '7Z25', '9DXL', + '9KAX', 'JHB9', 'KSS4', 'L383', 'M7A3', + 'PUW8', 'RZT7', 'U9NS', 'UT92', 'W4TN' + ) | + Where-Object JsonResult -NE 'Pass' + ) + $jsonRegressions.Count | Should -Be 0 + } +} diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 new file mode 100644 index 0000000..82d6e00 --- /dev/null +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -0,0 +1,477 @@ +#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`n" + } + + 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] + } + + It 'caches binary scalar construction and preserves binary alias identity' { + $result = "first: &bytes !!binary SGVsbG8=`nsecond: *bytes" | + ConvertFrom-Yaml -AsHashtable + + , $result['first'] | Should -BeOfType [byte[]] + [object]::ReferenceEquals($result['first'], $result['second']) | Should -BeTrue + } + + It 'constructs ordered mappings with complex keys without re-enumerating them' { + $result = "!!omap`n- ? [a, b]`n : value" | ConvertFrom-Yaml -AsHashtable + $enumerator = $result.GetEnumerator() + $null = $enumerator.MoveNext() + + , $enumerator.Key | Should -BeOfType [object[]] + $enumerator.Key | Should -Be @('a', 'b') + $enumerator.Value | Should -Be 'value' + } + + It 'parses explicit complex keys whose sequence content starts on the next line' { + $yaml = @' +--- &mapping +? &key +- &item a +- b +- c +: value +'@ + $result = @($yaml | ConvertFrom-Yaml -AsHashtable -NoEnumerate) + + $result.Count | Should -Be 1 + $mapping = $result[0] + $mapping.Count | Should -Be 1 + $entry = $mapping.GetEnumerator() | Select-Object -First 1 + , $entry.Key | Should -BeOfType [object[]] + $entry.Key | Should -Be @('a', 'b', 'c') + $entry.Value | Should -Be 'value' + } + + It 'preserves empty sequence keys inside nested complex mapping keys' { + $result = '? []: x' | ConvertFrom-Yaml -AsHashtable + $result.Count | Should -Be 1 + $outerKey = @($result.Keys)[0] + $outerValue = $result[$outerKey] + + $outerKey | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $outerKey.Count | Should -Be 1 + $innerEntry = $outerKey.GetEnumerator() | Select-Object -First 1 + $innerKey = $innerEntry.Key + , $innerKey | Should -BeOfType [object[]] + $innerKey.Count | Should -Be 0 + $innerEntry.Value | Should -Be 'x' + $outerValue | Should -BeNullOrEmpty + } + + It 'matches standard tags ordinally and treats case variants as unknown' { + $result = "integer: !!INT 12`nset: !!SET {one: null}" | + ConvertFrom-Yaml -AsHashtable + + $result['integer'] | Should -BeOfType [string] + $result['integer'] | Should -Be '12' + $result['set'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['set']['one'] | Should -BeNullOrEmpty + } + + It 'uses deterministic UTC semantics for zone-less explicit timestamps' { + $result = @' +offset: !!timestamp 2001-12-14T21:59:43.1+5:30 +wholeHour: !!timestamp 2001-12-14T21:59:43+5 +zoneLess: !!timestamp 2001-12-14T21:59:43.1 +date: !!timestamp 2001-12-14 +'@ | ConvertFrom-Yaml + + $result.offset | Should -BeOfType [datetimeoffset] + $result.offset.Offset | Should -Be ([timespan]::FromHours(5.5)) + $result.wholeHour | Should -BeOfType [datetimeoffset] + $result.wholeHour.Offset | Should -Be ([timespan]::FromHours(5)) + $result.zoneLess | Should -BeOfType [datetime] + $result.zoneLess.Kind | Should -Be ([DateTimeKind]::Utc) + $result.date.Kind | Should -Be ([DateTimeKind]::Utc) + } + } + + 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 + { "1.0: one`n1.00: two" | ConvertFrom-Yaml -AsHashtable } | Should -Throw + { "1.0: one`n1e0: 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 equivalent zone-less and UTC timestamp keys' { + $yaml = @' +? !!timestamp 2001-12-15T02:59:43.1 +: one +? !!timestamp 2001-12-15T02:59:43.1Z +: 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 + } + + It 'enforces tag and numeric limits before expensive construction' { + $prefix = 'x' * 2000 + $tagged = "%TAG ! tag:example.test,$prefix`n---`n- !value one" + $manyTags = "%TAG ! tag:e,`n---`n- !a one`n- !b two" + $largeInteger = '9' * 5000 + + ($tagged | Test-Yaml -MaxTagLength 1024) | Should -BeFalse + ($manyTags | Test-Yaml -MaxTagLength 1024 -MaxTotalTagLength 13) | Should -BeFalse + ($largeInteger | Test-Yaml -MaxNumericLength 4096) | Should -BeFalse + { $largeInteger | ConvertFrom-Yaml -MaxNumericLength 4096 } | Should -Throw + } + + It 'decodes percent escapes in expanded tags using UTF-8' { + $yaml = @' +%TAG !e! tag:example.com,2000:app/ +--- +first: !e!tag%21 value +second: !e!currency%E2%82%AC amount +'@ + $representation = Read-YamlStreamCore -Yaml $yaml -Depth 32 -MaxNodes 64 -MaxAliases 16 ` + -MaxScalarLength 4096 -MaxTagLength 1024 -MaxTotalTagLength 4096 ` + -MaxNumericLength 64 + + $representation.Value.Count | Should -Be 1 + $entries = $representation.Value[0].Entries + $entries.Count | Should -Be 2 + $entries[0].Value.Tag | Should -Be 'tag:example.com,2000:app/tag!' + $entries[1].Value.Tag | Should -Be ('tag:example.com,2000:app/currency' + [char] 0x20AC) + } + + It 'rejects malformed or non-UTF8 tag percent escapes' { + $truncated = @' +%TAG !e! tag:example.com,2000:app/ +--- +!e!tag%2 value +'@ + $invalidHex = @' +%TAG !e! tag:example.com,2000:app/ +--- +!e!tag%ZZ value +'@ + $invalidUtf8 = @' +%TAG !e! tag:example.com,2000:app/ +--- +!e!tag%E2%28%A1 value +'@ + + ($truncated | Test-Yaml) | Should -BeFalse + ($invalidHex | Test-Yaml) | Should -BeFalse + ($invalidUtf8 | Test-Yaml) | Should -BeFalse + { $truncated | ConvertFrom-Yaml } | Should -Throw + { $invalidHex | ConvertFrom-Yaml } | Should -Throw + { $invalidUtf8 | ConvertFrom-Yaml } | Should -Throw + } + + It 'bounds expanded-tag storage and rejects huge numerics promptly' { + $prefix = 'x' * 20000 + $taggedItems = 1..500 | ForEach-Object { '- !e!value item' } + $tagAmplification = ( + @("%TAG !e! tag:example.test,$prefix", '---') + $taggedItems + ) -join "`n" + $hugeInteger = '9' * 320000 + + ($tagAmplification | Test-Yaml -MaxTagLength 25000) | + Should -BeFalse + $testResult = $null + $testDuration = Measure-Command { + $testResult = $hugeInteger | Test-Yaml -MaxNumericLength 4096 + } + $convertFailed = $false + $convertDuration = Measure-Command { + try { + $null = $hugeInteger | + ConvertFrom-Yaml -MaxNumericLength 4096 + } catch { + $convertFailed = $true + } + } + + $testResult | Should -BeFalse + $convertFailed | Should -BeTrue + $testDuration.TotalSeconds | Should -BeLessThan 2 + $convertDuration.TotalSeconds | Should -BeLessThan 2 + } + + It 'preserves finite decimal precision and IEEE negative zero' { + $result = @' +precise: 0.1234567890123456789012345678 +negativeZero: -0.0 +'@ | ConvertFrom-Yaml + + $result.precise | Should -BeOfType [decimal] + $result.precise.ToString([cultureinfo]::InvariantCulture) | + Should -Be '0.1234567890123456789012345678' + $result.negativeZero | Should -BeOfType [decimal] + ([decimal]::GetBits($result.negativeZero)[3] -band [int]::MinValue) | + Should -Be ([int]::MinValue) + } + + It 'preserves flow-looking text inside block scalars without repairing it' { + $result = "value: |`n [`n keep`n" | ConvertFrom-Yaml + + $result.value | Should -Be "[`n keep`n" + } + + It 'applies block scalar indentation, folding, and chomping exactly' { + ("|2`n text`n" | ConvertFrom-Yaml) | Should -Be "text`n" + (">`n one`n`n two`n" | ConvertFrom-Yaml) | Should -Be "one`ntwo`n" + ("|+`n text`n`n" | ConvertFrom-Yaml) | Should -Be "text`n`n" + } + + It 'applies YAML flow folding to multiline quoted scalars' { + ('"one' + "`n`n two`n " + '"') | ConvertFrom-Yaml | + Should -Be "one`ntwo " + } + + It 'rejects raw non-printable characters and unpaired surrogates' { + { ConvertFrom-Yaml -Yaml ("value: x{0}" -f [char] 0) } | Should -Throw + { ConvertFrom-Yaml -Yaml ("value: x{0}" -f [char] 1) } | Should -Throw + { ConvertFrom-Yaml -Yaml ("value: x{0}" -f [char] 11) } | Should -Throw + { ConvertFrom-Yaml -Yaml ("value: x{0}" -f [char] 0xD800) } | Should -Throw + } + } +} diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 new file mode 100644 index 0000000..5cc3da6 --- /dev/null +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -0,0 +1,347 @@ +#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 'escapes quoted scalar content without duplicating characters' { + $inputObject = [ordered]@{ + quote = 'a"b' + slash = 'a\b' + multiline = "one`ntwo" + } + + $yaml = $inputObject | ConvertTo-Yaml + $result = $yaml | ConvertFrom-Yaml -AsHashtable + + ($yaml | Test-Yaml) | Should -BeTrue + $result['quote'] | Should -Be $inputObject.quote + $result['slash'] | Should -Be $inputObject.slash + $result['multiline'] | Should -Be $inputObject.multiline + } + + It 'emits only valid YAML characters and rejects malformed UTF-16 input' { + $noncharacters = ([string] [char] 0xFFFE) + [char] 0xFFFF + $yaml = ConvertTo-Yaml -InputObject $noncharacters + + $yaml | Should -Match '\\uFFFE\\uFFFF' + ($yaml | Test-Yaml) | Should -BeTrue + ($yaml | ConvertFrom-Yaml) | Should -Be $noncharacters + { ConvertTo-Yaml -InputObject ([string] [char] 0xD800) } | + Should -Throw -ExpectedMessage '*surrogate*' + } + + 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 'emits aliases for repeated byte arrays and preserves their identity' { + $bytes = [Text.Encoding]::UTF8.GetBytes('hello') + $inputObject = [ordered]@{ first = $bytes; second = $bytes } + + $yaml = $inputObject | ConvertTo-Yaml + $result = $yaml | ConvertFrom-Yaml -AsHashtable + + $yaml | Should -Match '&id001 !!binary' + $yaml | Should -Match '\*id001' + , $result['first'] | Should -BeOfType [byte[]] + [object]::ReferenceEquals($result['first'], $result['second']) | Should -BeTrue + } + + 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 'rejects attached note properties on arrays and dictionaries' { + $array = [object[]] @(1, 2) + Add-Member -InputObject $array -MemberType NoteProperty -Name metadata -Value 'lossy' + $dictionary = [ordered]@{ value = 1 } + Add-Member -InputObject $dictionary -MemberType NoteProperty -Name metadata -Value 'lossy' + + { ConvertTo-Yaml -InputObject $array } | + Should -Throw -ExpectedMessage '*combines collection data with attached note properties*' + { ConvertTo-Yaml -InputObject $dictionary } | + Should -Throw -ExpectedMessage '*combines collection data with attached note properties*' + } + + It 'preserves the sign of IEEE negative zero across serialization' { + $negativeZero = [BitConverter]::Int64BitsToDouble([long]::MinValue) + $yaml = ConvertTo-Yaml -InputObject $negativeZero + $result = $yaml | ConvertFrom-Yaml + + $yaml.Trim() | Should -Be '-0.0' + ([decimal]::GetBits($result)[3] -band [int]::MinValue) | + Should -Be ([int]::MinValue) + } + + 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 'supports the public maximum depth and rejects the next level specifically' { + $atLimit = [ordered]@{} + $current = $atLimit + for ($level = 1; $level -lt 127; $level++) { + $next = [ordered]@{} + $current['nested'] = $next + $current = $next + } + $current['value'] = 1 + + { ConvertTo-Yaml -InputObject $atLimit -Depth 128 } | Should -Not -Throw + + $overLimit = [ordered]@{ nested = $atLimit } + { ConvertTo-Yaml -InputObject $overLimit -Depth 128 } | + Should -Throw -ExpectedMessage '*configured depth limit of 128*' + } + + 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..e6daef6 --- /dev/null +++ b/tests/Packaging.Tests.ps1 @@ -0,0 +1,204 @@ +#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' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Justification = 'Internal test helper function for packaging assertions.' +)] +[CmdletBinding()] +param() + +$script:repositoryRoot = $null +$script:resolvedArtifactManifestPath = $null +$script:artifactManifestPath = $null + +function Test-YamlArtifactManifestAvailable { + param() + + if (-not [string]::IsNullOrWhiteSpace($env:PSMODULE_YAML_TEST_ARTIFACT)) { + return $true + } + $repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path + Test-Path -LiteralPath (Join-Path $repositoryRoot 'outputs\module\Yaml\Yaml.psd1') -PathType Leaf +} + +$artifactManifestAvailable = Test-YamlArtifactManifestAvailable + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') + $script:repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path + $defaultArtifactManifestPath = Join-Path $script:repositoryRoot 'outputs\module\Yaml\Yaml.psd1' + $script:resolvedArtifactManifestPath = if ( + Test-Path -LiteralPath $defaultArtifactManifestPath -PathType Leaf + ) { + (Resolve-Path -LiteralPath $defaultArtifactManifestPath).Path + } elseif (-not [string]::IsNullOrWhiteSpace($env:PSMODULE_YAML_TEST_ARTIFACT)) { + if (Test-Path -LiteralPath $env:PSMODULE_YAML_TEST_ARTIFACT -PathType Leaf) { + (Resolve-Path -LiteralPath $env:PSMODULE_YAML_TEST_ARTIFACT).Path + } else { + $null + } + } else { + $null + } + $script:artifactManifestPath = $script:resolvedArtifactManifestPath +} + +Describe 'Dependency-free package source' { + It 'contains no external parser assembly, license, or notice payload' { + @( + Get-ChildItem -Path (Join-Path $script:repositoryRoot 'src\assemblies') ` + -File -ErrorAction SilentlyContinue + ).Count | Should -Be 0 + Test-Path (Join-Path $script:repositoryRoot 'src\licenses\YamlDotNet.LICENSE.txt') | Should -BeFalse + Test-Path (Join-Path $script:repositoryRoot 'src\THIRD-PARTY-NOTICES.txt') | Should -BeFalse + } + + It 'keeps RequiredAssemblies out of the source manifest' { + $manifest = Import-PowerShellDataFile -Path ( + Join-Path $script:repositoryRoot 'src\manifest.psd1' + ) + + $manifest.PowerShellVersion | Should -Be '7.6' + @($manifest.CompatiblePSEditions) | Should -Be @('Core') + $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse + $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse + } + + It 'contains no external parser references or custom assembly loader' { + $sourceFiles = Get-ChildItem -Path (Join-Path $script:repositoryRoot 'src') -Recurse -File + $source = $sourceFiles | + Where-Object Extension -In @('.ps1', '.psd1', '.psm1') | + Get-Content -Raw + + $source | Should -Not -Match 'YamlDotNet' + $source | Should -Not -Match '\bAdd-Type\b' + $source | Should -Not -Match 'Assembly\]::Load' + } + + It 'keeps the owned processor layers explicit and source-level' { + $privatePath = Join-Path $script:repositoryRoot 'src\functions\private' + @( + 'New-YamlReaderContext.ps1', + 'Read-YamlDirectiveBlock.ps1', + 'New-YamlSyntaxNode.ps1', + 'ConvertFrom-YamlSyntaxTree.ps1', + 'Resolve-YamlScalar.ps1', + 'ConvertFrom-YamlNode.ps1', + 'Get-YamlSerializationShape.ps1', + 'ConvertTo-YamlNode.ps1', + 'Write-YamlNodeText.ps1' + ) | ForEach-Object { + $isPresent = Test-Path -LiteralPath (Join-Path $privatePath $_) + $isPresent | Should -BeTrue -Because "$_ defines a required processor layer" + } + } + + It 'uses Process-PSModule 6.1.13 and treats tests as important changes' { + $workflow = Get-Content -Path ( + Join-Path $script:repositoryRoot '.github\workflows\Process-PSModule.yml' + ) -Raw + + $workflow | Should -Match 'workflow\.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6\.1\.13' + $workflow | Should -Match '\^src/' + $workflow | Should -Match '\^tests/' + } + + It 'does not skip generated documentation' { + $configuration = Get-Content -Path ( + Join-Path $script:repositoryRoot '.github\PSModule.yml' + ) -Raw + + $configuration | Should -Not -Match '(?ms)Build:\s+Docs:\s+.*Skip:\s*true' + } + + It 'uses zensical configuration and does not skip site build' { + $configuration = Get-Content -Path ( + Join-Path $script:repositoryRoot '.github\PSModule.yml' + ) -Raw + + $configuration | Should -Not -Match '(?ms)Build:\s+Site:\s+.*Skip:\s*true' + Test-Path -Path (Join-Path $script:repositoryRoot '.github\zensical.toml') | Should -BeTrue + Test-Path -Path (Join-Path $script:repositoryRoot '.github\mkdocs.yml') | Should -BeFalse + } +} + +Describe 'Generated artifact package' { + It 'has no RequiredAssemblies or packaged DLL and has a complete FileList' ` + -Skip:(-not $artifactManifestAvailable) { + $manifest = Import-PowerShellDataFile -Path $script:artifactManifestPath + $moduleBase = Split-Path -Parent $script:artifactManifestPath + + $manifest.PowerShellVersion | Should -Be '7.6' + @($manifest.CompatiblePSEditions) | Should -Be @('Core') + $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse + $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse + @($manifest.FunctionsToExport | Sort-Object) | + Should -Be @('ConvertFrom-Yaml', 'ConvertTo-Yaml', 'Test-Yaml') + @($manifest.FileList) | Should -Contain 'Yaml.psm1' + @($manifest.FileList | Where-Object { $_ -match '\.(?:dll|exe)$' }).Count | Should -Be 0 + @($manifest.FileList | Where-Object { $_ -match 'THIRD-PARTY|YamlDotNet' }).Count | Should -Be 0 + @(Get-ChildItem -Path $moduleBase -Recurse -File -Filter '*.dll').Count | Should -Be 0 + { Test-ModuleManifest -Path $artifactManifestPath } | Should -Not -Throw + } + + It 'imports in a fresh PowerShell 7 process and preserves arrays, aliases, and depth' ` + -Skip:(( + -not $artifactManifestAvailable + ) -or ($null -eq (Get-Command pwsh -ErrorAction SilentlyContinue))) { + $script = @' +$ErrorActionPreference = 'Stop' +$ps = $PSVersionTable.PSVersion +if ($ps.Major -lt 7 -or ($ps.Major -eq 7 -and $ps.Minor -lt 6)) { + throw "Expected PowerShell 7.6+ but got $ps." +} +Import-Module -Name '__MANIFEST__' -Force +$value = 'v: []' | ConvertFrom-Yaml -AsHashtable +if ($value['v'] -isnot [object[]] -or $value['v'].Count -ne 0) { + throw 'The empty sequence did not survive import.' +} +if (-not ('name: Ada' | Test-Yaml)) { + throw 'The imported parser did not validate YAML.' +} +$shared = [ordered]@{ value = 1 } +$roundTrip = [ordered]@{ first = $shared; second = $shared } | + ConvertTo-Yaml | + ConvertFrom-Yaml -AsHashtable +if (-not [object]::ReferenceEquals($roundTrip['first'], $roundTrip['second'])) { + throw 'The fresh-process graph round trip lost alias identity.' +} +$negativeZero = [BitConverter]::Int64BitsToDouble([long]::MinValue) +if ((ConvertTo-Yaml -InputObject $negativeZero).Trim() -ne '-0.0') { + throw 'The negative-zero sign was lost.' +} +$flow = ('[' * 127) + 'null' + (']' * 127) +if (-not (Test-Yaml -Yaml $flow -Depth 128 -MaxNodes 200)) { + throw 'The public maximum parse depth failed.' +} +$atLimit = [ordered]@{} +$current = $atLimit +for ($level = 1; $level -lt 127; $level++) { + $next = [ordered]@{} + $current['nested'] = $next + $current = $next +} +$current['value'] = 1 +$deepYaml = ConvertTo-Yaml -InputObject $atLimit -Depth 128 -MaxNodes 300 +if (-not (Test-Yaml -Yaml $deepYaml -Depth 128 -MaxNodes 300)) { + throw 'The public maximum serialization depth failed.' +} +'powershell-7-ok' +'@.Replace('__MANIFEST__', $script:artifactManifestPath.Replace("'", "''")) + + (& pwsh -NoLogo -NoProfile -Command $script) | + Should -Contain 'powershell-7-ok' + $LASTEXITCODE | Should -Be 0 + } +} 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..b3fcdd4 --- /dev/null +++ b/tests/Test-Yaml.Tests.ps1 @@ -0,0 +1,116 @@ +#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 + ("[ key`n : value ]" | Test-Yaml) | Should -BeFalse + ("{ key`n : value }" | Test-Yaml) | Should -BeTrue + ((('k' * 1025) + ': value') | Test-Yaml) | Should -BeFalse + ("%YAML 1.1#invalid`n---`nvalue" | Test-Yaml) | Should -BeFalse + ('"\UFFFFFFFF"' | Test-Yaml) | Should -BeFalse + ('@reserved' | Test-Yaml) | Should -BeFalse + ("key: first`n nested: value" | Test-Yaml) | Should -BeFalse + ("a: &a value`nb: !foo *a" | Test-Yaml) | Should -BeFalse + } + + It 'validates tag directives, tag tokens, and alias properties' { + ("%TAG !! tag:example.com,2000:app/`n---`n!!int value" | Test-Yaml) | + Should -BeTrue + ("%TAG ! tag:first/`n%TAG ! tag:second/`n---`n!value data" | Test-Yaml) | + Should -BeFalse + ('!foo%GG value' | Test-Yaml) | Should -BeFalse + ('!! value' | Test-Yaml) | Should -BeFalse + ('! value' | Test-Yaml) | Should -BeFalse + ("a: &a value`nb: &b *a" | Test-Yaml) | Should -BeFalse + } + + It 'recognizes document markers only at column zero' { + ("key:`n ---" | Test-Yaml) | Should -BeTrue + ("key:`n ..." | Test-Yaml) | Should -BeTrue + } + + 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 'accepts the public maximum depth and rejects the next level as YAML data' { + $atLimit = ('[' * 127) + 'null' + (']' * 127) + $overLimit = ('[' * 128) + 'null' + (']' * 128) + + ($atLimit | Test-Yaml -Depth 128 -MaxNodes 200) | Should -BeTrue + ($overLimit | Test-Yaml -Depth 128 -MaxNodes 200) | 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' { + $yamlCommand = Get-Command -Name Test-Yaml -ErrorAction Stop + if ([string]::IsNullOrEmpty($yamlCommand.ModuleName)) { + Mock Read-YamlStream { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } else { + Mock Read-YamlStream -ModuleName $yamlCommand.ModuleName { + 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..660a962 --- /dev/null +++ b/tests/TestBootstrap.ps1 @@ -0,0 +1,51 @@ +$artifactManifestOverride = $env:PSMODULE_YAML_TEST_ARTIFACT +$yamlModule = $null +if (-not [string]::IsNullOrWhiteSpace($artifactManifestOverride)) { + $yamlModule = Import-Module -Name $artifactManifestOverride -Force -Global -PassThru | + Where-Object Name -EQ 'Yaml' | + Select-Object -First 1 +} + +if ($null -eq $yamlModule) { + 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 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` + -MaxNumericLength 4096).Value[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..48eaa8f --- /dev/null +++ b/tests/fixtures/yaml-test-suite/SOURCES.txt @@ -0,0 +1,35 @@ +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. + +The complete released data corpus is vendored as: + yaml-test-suite-data-2022-01-17.zip +Data commit: + 6ad3d2c62885d82fc349026c136ef560838fdf3d +Generated from source commit: + 45db50ae +Archive SHA-256: + DCC1F037B13F6C3032D5190C447B6A6EF5560738A4C104F29B4243B0AB8F8029 + +The archive contains 402 released inputs in 352 case directories. It is +consumed locally by tests/tools/Invoke-YamlTestSuite.ps1 without network +access. 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 + } diff --git a/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip b/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip new file mode 100644 index 0000000..e940eeb Binary files /dev/null and b/tests/fixtures/yaml-test-suite/yaml-test-suite-data-2022-01-17.zip differ diff --git a/tests/tools/Invoke-YamlTestSuite.ps1 b/tests/tools/Invoke-YamlTestSuite.ps1 new file mode 100644 index 0000000..b9d28f4 --- /dev/null +++ b/tests/tools/Invoke-YamlTestSuite.ps1 @@ -0,0 +1,904 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSProvideCommentHelp', '', + Justification = 'Internal test helper functions in this tooling script.' +)] +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string] $Path, + + [switch] $CompareJson, + [switch] $CompareEvents, + [switch] $CompareOutYaml, + [switch] $CompareEmitRoundTrip +) + +. (Join-Path $PSScriptRoot '..\TestBootstrap.ps1') + +if (-not $PSBoundParameters.ContainsKey('CompareJson') -and + -not $PSBoundParameters.ContainsKey('CompareEvents') -and + -not $PSBoundParameters.ContainsKey('CompareOutYaml') -and + -not $PSBoundParameters.ContainsKey('CompareEmitRoundTrip')) { + $CompareJson = $true + $CompareEvents = $true + $CompareOutYaml = $true + $CompareEmitRoundTrip = $true +} + +function Invoke-InYamlModule { + param ( + [Parameter(Mandatory)] + [scriptblock] $ScriptBlock, + + [AllowNull()] + [object[]] $Arguments = @() + ) + + if ($null -eq $yamlModule) { + return & $ScriptBlock @Arguments + } + + return & $yamlModule $ScriptBlock @Arguments +} + +function Split-YamlSuiteJsonDocument { + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $documents = [System.Collections.Generic.List[string]]::new() + $index = 0 + while ($index -lt $Text.Length) { + while ($index -lt $Text.Length -and [char]::IsWhiteSpace($Text[$index])) { + $index++ + } + if ($index -ge $Text.Length) { + break + } + + $start = $index + $first = $Text[$index] + if ($first -eq '{' -or $first -eq '[') { + $depth = 0 + $quoted = $false + $escaped = $false + while ($index -lt $Text.Length) { + $character = $Text[$index++] + if ($quoted) { + if ($escaped) { + $escaped = $false + } elseif ($character -eq '\') { + $escaped = $true + } elseif ($character -eq '"') { + $quoted = $false + } + continue + } + if ($character -eq '"') { + $quoted = $true + } elseif ($character -eq '{' -or $character -eq '[') { + $depth++ + } elseif ($character -eq '}' -or $character -eq ']') { + $depth-- + if ($depth -eq 0) { + break + } + } + } + } elseif ($first -eq '"') { + $index++ + $escaped = $false + while ($index -lt $Text.Length) { + $character = $Text[$index++] + if ($escaped) { + $escaped = $false + } elseif ($character -eq '\') { + $escaped = $true + } elseif ($character -eq '"') { + break + } + } + } else { + while ($index -lt $Text.Length -and -not [char]::IsWhiteSpace($Text[$index])) { + $index++ + } + } + $documents.Add($Text.Substring($start, $index - $start)) + } + + [string[]] $documents.ToArray() +} + +function ConvertTo-YamlSuiteCanonicalValue { + param ( + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value -or $Value -is [System.DBNull]) { + return 'null' + } + if ($Value -is [string]) { + return 'string:{0}:{1}' -f $Value.Length, $Value + } + if ($Value -is [bool]) { + return 'bool:{0}' -f $Value.ToString().ToLowerInvariant() + } + + $typeCode = [System.Type]::GetTypeCode($Value.GetType()) + if ($Value -is [System.Numerics.BigInteger] -or $typeCode -in @( + [System.TypeCode]::SByte, + [System.TypeCode]::Byte, + [System.TypeCode]::Int16, + [System.TypeCode]::UInt16, + [System.TypeCode]::Int32, + [System.TypeCode]::UInt32, + [System.TypeCode]::Int64, + [System.TypeCode]::UInt64 + )) { + return 'number:{0}' -f $Value.ToString([cultureinfo]::InvariantCulture) + } + if ($Value -is [decimal]) { + return 'number:{0}' -f $Value.ToString('G29', [cultureinfo]::InvariantCulture) + } + if ($Value -is [single] -or $Value -is [double]) { + return 'number:{0}' -f ([double] $Value).ToString('R', [cultureinfo]::InvariantCulture) + } + if ($Value -is [System.Collections.IDictionary]) { + $entries = [System.Collections.Generic.List[string]]::new() + foreach ($key in $Value.Keys) { + if ($key -isnot [string]) { + return 'unsupported:non-string-mapping-key' + } + $canonicalValue = ConvertTo-YamlSuiteCanonicalValue -Value $Value[$key] + $entries.Add(('{0}:{1}={2}' -f $key.Length, $key, $canonicalValue)) + } + $entries.Sort([System.StringComparer]::Ordinal) + return 'map:{0}:{{{1}}}' -f $entries.Count, ($entries -join '|') + } + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + if ($Value -is [byte[]]) { + return 'unsupported:System.Byte[]' + } + $items = [System.Collections.Generic.List[string]]::new() + foreach ($item in $Value) { + $items.Add((ConvertTo-YamlSuiteCanonicalValue -Value $item)) + } + return 'sequence:{0}:[{1}]' -f $items.Count, ($items -join '|') + } + return 'unsupported:{0}' -f $Value.GetType().FullName +} + +function ConvertTo-YamlSuiteReferenceSignature { + [OutputType([string])] + param ( + [AllowNull()] + [object] $Value + ) + + $idGenerator = [System.Runtime.Serialization.ObjectIDGenerator]::new() + $pathsById = [System.Collections.Generic.Dictionary[long, object]]::new() + $typesById = [System.Collections.Generic.Dictionary[long, string]]::new() + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ Value = $Value; Path = '$' }) + + while ($stack.Count -gt 0) { + $frame = $stack.Pop() + $current = $frame.Value + if ($null -eq $current -or $current -is [string] -or $current -is [byte[]]) { + continue + } + if ($current -isnot [System.Collections.IDictionary] -and + ($current -isnot [System.Collections.IEnumerable])) { + continue + } + + $first = $false + $id = $idGenerator.GetId($current, [ref] $first) + if (-not $pathsById.ContainsKey($id)) { + $pathsById[$id] = [System.Collections.Generic.List[string]]::new() + $typesById[$id] = $current.GetType().FullName + } + $pathsById[$id].Add($frame.Path) + if (-not $first) { + continue + } + + if ($current -is [System.Collections.IDictionary]) { + foreach ($key in $current.Keys) { + $childPath = '{0}{{{1}}}' -f $frame.Path, (ConvertTo-YamlSuiteCanonicalValue -Value $key) + $stack.Push([pscustomobject]@{ + Value = $current[$key] + Path = $childPath + }) + } + continue + } + + $index = 0 + foreach ($item in $current) { + $stack.Push([pscustomobject]@{ + Value = $item + Path = ('{0}[{1}]' -f $frame.Path, $index) + }) + $index++ + } + } + + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($id in $pathsById.Keys) { + $paths = $pathsById[$id] + if ($paths.Count -gt 1) { + $sorted = [string[]] $paths.ToArray() + [array]::Sort($sorted, [System.StringComparer]::Ordinal) + $parts.Add(('{0}|{1}|{2}' -f $typesById[$id], $paths.Count, ($sorted -join ','))) + } + } + $output = [string[]] $parts.ToArray() + [array]::Sort($output, [System.StringComparer]::Ordinal) + return ($output -join ';') +} + +function Get-YamlSuitePolicyReason { + [OutputType([string])] + param ( + [string] $YamlText, + [string] $Expected, + [string] $Actual, + [string] $DefaultReason + ) + + if ($DefaultReason) { + return $DefaultReason + } + if ($YamlText -cmatch '!!(?:binary|omap|pairs|set|timestamp)(?:[ \t\r\n,\[\]\{\}]|$)') { + return 'StandardTagProjectionPolicy' + } + if (($Expected -match 'unsupported:' -or $Actual -match 'unsupported:')) { + return 'PowerShellTypePolicy' + } + return '' +} + +function ConvertFrom-YamlSuiteEventText { + [OutputType([string[]])] + param ( + [Parameter(Mandatory)] + [string] $Text + ) + + function ConvertTo-YamlSuiteEventEscapedText { + param ([AllowNull()][string] $Value) + if ($null -eq $Value) { + return '' + } + $builder = [System.Text.StringBuilder]::new() + foreach ($character in $Value.ToCharArray()) { + switch ($character) { + '\' { [void] $builder.Append('\\') } + "`n" { [void] $builder.Append('\n') } + "`r" { [void] $builder.Append('\r') } + "`t" { [void] $builder.Append('\t') } + default { [void] $builder.Append($character) } + } + } + $builder.ToString() + } + + function ConvertFrom-YamlSuiteEventEscape { + param ([AllowNull()][string] $Value) + if ($null -eq $Value) { + return '' + } + $builder = [System.Text.StringBuilder]::new() + $index = 0 + while ($index -lt $Value.Length) { + $current = $Value[$index] + if ($current -eq '\' -and $index + 1 -lt $Value.Length) { + $index++ + switch ($Value[$index]) { + 'n' { [void] $builder.Append("`n") } + 'r' { [void] $builder.Append("`r") } + 't' { [void] $builder.Append("`t") } + 'b' { [void] $builder.Append("`b") } + '\' { [void] $builder.Append('\') } + default { + [void] $builder.Append($Value[$index]) + } + } + $index++ + continue + } + [void] $builder.Append($current) + $index++ + } + $builder.ToString() + } + + $anchorMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $anchorCounter = 0 + $canonical = [System.Collections.Generic.List[string]]::new() + $lines = $Text -split '\r?\n' + + foreach ($rawLine in $lines) { + $line = $rawLine + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + if ($line -in @('+STR', '-STR', '+DOC', '-DOC', '+DOC ---', '-DOC ...')) { + $canonical.Add($line.Substring(0, 4)) + continue + } + if ($line -eq '-SEQ' -or $line -eq '-MAP') { + $canonical.Add($line) + continue + } + + if ($line.StartsWith('+SEQ', [System.StringComparison]::Ordinal) -or + $line.StartsWith('+MAP', [System.StringComparison]::Ordinal) -or + $line.StartsWith('=VAL', [System.StringComparison]::Ordinal)) { + $prefix = $line.Substring(0, 4) + $rest = if ($line.Length -gt 4) { $line.Substring(4).TrimStart() } else { '' } + $anchor = '' + $tag = '' + $value = '' + $style = '' + + while ($rest.Length -gt 0) { + if ($rest.StartsWith('[]', [System.StringComparison]::Ordinal) -or + $rest.StartsWith('{}', [System.StringComparison]::Ordinal)) { + $rest = $rest.Substring(2).TrimStart() + continue + } + if ($rest[0] -eq '&') { + $space = $rest.IndexOf(' ') + if ($space -lt 0) { + $anchor = $rest.Substring(1) + $rest = '' + } else { + $anchor = $rest.Substring(1, $space - 1) + $rest = $rest.Substring($space + 1).TrimStart() + } + continue + } + if ($rest[0] -eq '<') { + $end = $rest.IndexOf('>') + if ($end -ge 0) { + $tag = $rest.Substring(1, $end - 1) + $rest = $rest.Substring($end + 1).TrimStart() + continue + } + } + break + } + + if ($prefix -eq '=VAL') { + if ($rest.Length -gt 0 -and + ($rest[0] -eq ':' -or $rest[0] -eq '"' -or $rest[0] -eq "'" -or + $rest[0] -eq '|' -or $rest[0] -eq '>')) { + $style = [string] $rest[0] + $value = $rest.Substring(1) + } else { + $value = $rest + } + if ($style -in @('|', '>') -and [string]::IsNullOrEmpty($value)) { + $value = '' + } + $value = ConvertTo-YamlSuiteEventEscapedText -Value ( + ConvertFrom-YamlSuiteEventEscape -Value $value + ) + } + + $anchorToken = '' + if ($anchor) { + if (-not $anchorMap.ContainsKey($anchor)) { + $anchorCounter++ + $anchorMap[$anchor] = 'a{0:d3}' -f $anchorCounter + } + $anchorToken = $anchorMap[$anchor] + } + $parts = [System.Collections.Generic.List[string]]::new() + $parts.Add($prefix) + if ($tag -and $tag -ne '!') { $parts.Add("tag=$tag") } + if ($anchorToken) { $parts.Add("anchor=$anchorToken") } + if ($prefix -eq '=VAL') { $parts.Add("value=$value") } + $canonical.Add(($parts -join '|')) + continue + } + + if ($line.StartsWith('=ALI', [System.StringComparison]::Ordinal)) { + $alias = $line.Substring(4).Trim() + if ($alias.StartsWith('*', [System.StringComparison]::Ordinal)) { + $alias = $alias.Substring(1) + } + if (-not $anchorMap.ContainsKey($alias)) { + $anchorCounter++ + $anchorMap[$alias] = 'a{0:d3}' -f $anchorCounter + } + $canonical.Add(('=ALI|target={0}' -f $anchorMap[$alias])) + continue + } + + $canonical.Add("UNKNOWN|$line") + } + + [string[]] $canonical.ToArray() +} + +function ConvertTo-YamlSuiteActualEvent { + [OutputType([string[]])] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents + ) + + function ConvertTo-YamlSuiteEventEscapedText { + param ([AllowNull()][string] $Value) + if ($null -eq $Value) { + return '' + } + $builder = [System.Text.StringBuilder]::new() + foreach ($character in $Value.ToCharArray()) { + switch ($character) { + '\' { [void] $builder.Append('\\') } + "`n" { [void] $builder.Append('\n') } + "`r" { [void] $builder.Append('\r') } + "`t" { [void] $builder.Append('\t') } + default { [void] $builder.Append($character) } + } + } + $builder.ToString() + } + + $anchorMap = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::Ordinal + ) + $anchorCounter = 0 + $events = [System.Collections.Generic.List[string]]::new() + $events.Add('+STR') + + foreach ($document in $Documents) { + $events.Add('+DOC') + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ Type = 'Node'; Node = $document }) + + while ($stack.Count -gt 0) { + $frame = $stack.Pop() + if ($frame.Type -eq 'End') { + $events.Add($frame.Value) + continue + } + + $node = $frame.Node + if ($node.Kind -eq 'Alias') { + $targetAnchorKey = if ([string]::IsNullOrEmpty($node.Target.Anchor)) { + 'id:{0}' -f $node.Target.Id + } else { + $node.Target.Anchor + } + $targetAnchor = Get-YamlSuiteAnchorToken -Key $targetAnchorKey ` + -AnchorMap $anchorMap -AnchorCounter ([ref] $anchorCounter) + $events.Add("=ALI|target=$targetAnchor") + continue + } + if ($node.Kind -eq 'Scalar') { + $parts = [System.Collections.Generic.List[string]]::new() + $parts.Add('=VAL') + if ($node.Tag -and $node.Tag -ne '!') { $parts.Add("tag=$($node.Tag)") } + if ($node.Anchor) { + $parts.Add("anchor=$( + Get-YamlSuiteAnchorToken -Key $node.Anchor -AnchorMap $anchorMap ` + -AnchorCounter ([ref] $anchorCounter) + )") + } + $parts.Add(("value={0}" -f ( + ConvertTo-YamlSuiteEventEscapedText -Value ([string] $node.Value) + ))) + $events.Add(($parts -join '|')) + continue + } + + $startParts = [System.Collections.Generic.List[string]]::new() + $startToken = if ($node.Kind -eq 'Sequence') { '+SEQ' } else { '+MAP' } + $startParts.Add($startToken) + if ($node.Tag -and $node.Tag -ne '!') { $startParts.Add("tag=$($node.Tag)") } + if ($node.Anchor) { + $startParts.Add("anchor=$( + Get-YamlSuiteAnchorToken -Key $node.Anchor -AnchorMap $anchorMap ` + -AnchorCounter ([ref] $anchorCounter) + )") + } + $events.Add(($startParts -join '|')) + + if ($node.Kind -eq 'Sequence') { + $stack.Push([pscustomobject]@{ Type = 'End'; Value = '-SEQ' }) + for ($index = $node.Items.Count - 1; $index -ge 0; $index--) { + $stack.Push([pscustomobject]@{ Type = 'Node'; Node = $node.Items[$index] }) + } + } else { + $stack.Push([pscustomobject]@{ Type = 'End'; Value = '-MAP' }) + for ($index = $node.Entries.Count - 1; $index -ge 0; $index--) { + $stack.Push([pscustomobject]@{ Type = 'Node'; Node = $node.Entries[$index].Value }) + $stack.Push([pscustomobject]@{ Type = 'Node'; Node = $node.Entries[$index].Key }) + } + } + } + $events.Add('-DOC') + } + + $events.Add('-STR') + [string[]] $events.ToArray() +} + +function Compare-YamlSuiteCanonicalList { + [OutputType([bool])] + param ( + [string[]] $Left, + [string[]] $Right + ) + + if ($Left.Length -ne $Right.Length) { + return $false + } + for ($index = 0; $index -lt $Left.Length; $index++) { + if ($Left[$index] -cne $Right[$index]) { + return $false + } + } + return $true +} + +function Get-YamlSuiteAnchorToken { + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] $Key, + + [Parameter(Mandatory)] + [System.Collections.Generic.Dictionary[string, string]] $AnchorMap, + + [Parameter(Mandatory)] + [ref] $AnchorCounter + ) + + if (-not $AnchorMap.ContainsKey($Key)) { + $AnchorCounter.Value++ + $AnchorMap[$Key] = 'a{0:d3}' -f $AnchorCounter.Value + } + $AnchorMap[$Key] +} + +$readYamlSuiteRepresentation = { + param ([string] $YamlText) + Read-YamlStreamCore -Yaml $YamlText -Depth 128 -MaxNodes 100000 ` + -MaxAliases 1000 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096 -SkipGraphValidation +} +$readYamlSuiteStream = { + param ([string] $YamlText) + Read-YamlStream -Yaml $YamlText -Depth 128 -MaxNodes 100000 ` + -MaxAliases 1000 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096 +} +$projectYamlSuiteStream = { + param ([object[]] $Nodes) + $values = [System.Collections.Generic.List[object]]::new() + foreach ($node in $Nodes) { + $cache = [System.Collections.Generic.Dictionary[int, object]]::new() + $values.Add((ConvertFrom-YamlNode -Node $node -Cache $cache -AsHashtable).Value) + } + New-YamlValueBox -Value ([object[]] $values.ToArray()) +} +$projectYamlSuiteText = { + param ([string] $YamlText) + + $stream = Read-YamlStream -Yaml $YamlText -Depth 128 -MaxNodes 100000 ` + -MaxAliases 1000 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096 + $values = [System.Collections.Generic.List[object]]::new() + foreach ($node in $stream.Value) { + $cache = [System.Collections.Generic.Dictionary[int, object]]::new() + $values.Add((ConvertFrom-YamlNode -Node $node -Cache $cache -AsHashtable).Value) + } + New-YamlValueBox -Value ([object[]] $values.ToArray()) +} + +$suiteRoot = (Resolve-Path -LiteralPath $Path).Path +$inputFiles = @( + Get-ChildItem -LiteralPath $suiteRoot -Recurse -File -Filter 'in.yaml' | + Sort-Object FullName +) + +foreach ($inputFile in $inputFiles) { + $casePath = $inputFile.DirectoryName.Substring($suiteRoot.Length).TrimStart( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ).Replace([System.IO.Path]::DirectorySeparatorChar, '/') + $yaml = [System.IO.File]::ReadAllText( + $inputFile.FullName, + [System.Text.UTF8Encoding]::new($false, $true) + ) + + $errorPath = Join-Path $inputFile.DirectoryName 'error' + $jsonPath = Join-Path $inputFile.DirectoryName 'in.json' + $eventPath = Join-Path $inputFile.DirectoryName 'test.event' + $outYamlPath = Join-Path $inputFile.DirectoryName 'out.yaml' + $emitYamlPath = Join-Path $inputFile.DirectoryName 'emit.yaml' + + $expectsError = Test-Path -LiteralPath $errorPath -PathType Leaf + $hasJson = Test-Path -LiteralPath $jsonPath -PathType Leaf + $hasEvent = Test-Path -LiteralPath $eventPath -PathType Leaf + $hasOutYaml = Test-Path -LiteralPath $outYamlPath -PathType Leaf + $hasEmitYaml = Test-Path -LiteralPath $emitYamlPath -PathType Leaf + + $syntaxResult = 'Pass' + $syntaxReason = '' + $eventResult = 'NotApplicable' + $eventReason = '' + $jsonResult = 'NotApplicable' + $jsonReason = '' + $outYamlResult = 'NotApplicable' + $outYamlReason = '' + $emitResult = 'NotApplicable' + $emitReason = '' + + $representation = $null + $stream = $null + $projectedValues = $null + $projectedCanonical = $null + $projectedReference = '' + $projectionError = '' + $eventExpected = $null + $eventActual = $null + $jsonExpected = $null + $jsonActual = $null + $outYamlCanonical = $null + $emitCanonical = $null + $emitReference = $null + + try { + $representation = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation -Arguments @($yaml) + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + if ($expectsError) { + $syntaxResult = 'Pass' + } else { + $syntaxResult = 'Fail' + $syntaxReason = [string] $_.Exception.Data['YamlErrorId'] + } + } + + if ($syntaxResult -ne 'Fail') { + try { + $stream = Invoke-InYamlModule -ScriptBlock $readYamlSuiteStream -Arguments @($yaml) + if ($expectsError) { + $syntaxResult = 'Fail' + $syntaxReason = 'InvalidInputAccepted' + } + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + if ($expectsError) { + $syntaxResult = 'Pass' + } elseif ($_.Exception.Data['YamlErrorId'] -eq 'YamlDuplicateKey') { + $syntaxResult = 'PolicyDifference' + $syntaxReason = 'DuplicateKeyRejected' + } else { + $syntaxResult = 'Fail' + $syntaxReason = [string] $_.Exception.Data['YamlErrorId'] + } + } + } + + if ($null -ne $stream) { + try { + $projectedValues = (Invoke-InYamlModule -ScriptBlock $projectYamlSuiteStream -Arguments (, $stream.Value)).Value + $projectedCanonical = ConvertTo-YamlSuiteCanonicalValue -Value ([object[]] $projectedValues) + $projectedReference = ConvertTo-YamlSuiteReferenceSignature -Value ([object[]] $projectedValues) + } catch { + if ($_.Exception.Data.Contains('YamlErrorId')) { + $projectionError = [string] $_.Exception.Data['YamlErrorId'] + } else { + $projectionError = $_.Exception.GetType().Name + } + } + } + + if ($CompareEvents -and $hasEvent) { + if ($null -eq $representation -or $expectsError) { + $eventResult = 'NotApplicable' + if ($expectsError) { $eventReason = 'InvalidSyntax' } + } else { + $expectedEvents = ConvertFrom-YamlSuiteEventText -Text ( + [System.IO.File]::ReadAllText($eventPath, [System.Text.UTF8Encoding]::new($false, $true)) + ) + $actualEvents = ConvertTo-YamlSuiteActualEvent -Documents $representation.Value + $eventExpected = ($expectedEvents -join "`n") + $eventActual = ($actualEvents -join "`n") + if (Compare-YamlSuiteCanonicalList -Left $actualEvents -Right $expectedEvents) { + $eventResult = 'Pass' + } else { + $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected ($expectedEvents -join "`n") ` + -Actual ($actualEvents -join "`n") -DefaultReason '' + if ($reason) { + $eventResult = 'PolicyDifference' + $eventReason = $reason + } else { + $eventResult = 'Fail' + $eventReason = 'EventMismatch' + } + } + } + } + + if ($CompareJson -and $hasJson) { + if ($null -eq $stream -or $expectsError -or $syntaxResult -eq 'PolicyDifference' -or + $null -eq $projectedValues) { + $jsonResult = 'NotApplicable' + if ($syntaxResult -eq 'PolicyDifference') { $jsonReason = $syntaxReason } + if ($projectionError) { $jsonReason = $projectionError } + } else { + $expectedDocuments = Split-YamlSuiteJsonDocument -Text ( + [System.IO.File]::ReadAllText($jsonPath, [System.Text.UTF8Encoding]::new($false, $true)) + ) + $expectedValues = [System.Collections.Generic.List[object]]::new() + foreach ($document in $expectedDocuments) { + $expectedValues.Add((ConvertFrom-Json -InputObject $document -AsHashtable -NoEnumerate)) + } + $expectedCanonical = ConvertTo-YamlSuiteCanonicalValue -Value ([object[]] $expectedValues.ToArray()) + $jsonExpected = $expectedCanonical + $jsonActual = $projectedCanonical + if ($projectedCanonical -ceq $expectedCanonical) { + $jsonResult = 'Pass' + } else { + $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected $expectedCanonical ` + -Actual $projectedCanonical -DefaultReason '' + if ($reason) { + $jsonResult = 'PolicyDifference' + $jsonReason = $reason + } else { + $jsonResult = 'Fail' + $jsonReason = 'ConstructedValueMismatch' + } + } + } + } + + if ($CompareOutYaml -and $hasOutYaml) { + if ($null -eq $stream -or $expectsError -or $syntaxResult -eq 'PolicyDifference' -or + $null -eq $projectedValues) { + $outYamlResult = 'NotApplicable' + if ($syntaxResult -eq 'PolicyDifference') { $outYamlReason = $syntaxReason } + if ($projectionError) { $outYamlReason = $projectionError } + } else { + $outYaml = [System.IO.File]::ReadAllText($outYamlPath, [System.Text.UTF8Encoding]::new($false, $true)) + try { + $outStream = Invoke-InYamlModule -ScriptBlock $readYamlSuiteStream -Arguments @($outYaml) + $outValues = (Invoke-InYamlModule -ScriptBlock $projectYamlSuiteStream -Arguments (, $outStream.Value)).Value + $outCanonical = ConvertTo-YamlSuiteCanonicalValue -Value ([object[]] $outValues) + $outYamlCanonical = $outCanonical + if ($outCanonical -ceq $projectedCanonical) { + $outYamlResult = 'Pass' + } else { + $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected $projectedCanonical ` + -Actual $outCanonical -DefaultReason '' + if ($reason) { + $outYamlResult = 'PolicyDifference' + $outYamlReason = $reason + } else { + $outYamlResult = 'Fail' + $outYamlReason = 'OutYamlConstructionMismatch' + } + } + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $outYamlResult = 'Fail' + $outYamlReason = [string] $_.Exception.Data['YamlErrorId'] + } else { + throw + } + } + } + } + + if ($CompareEmitRoundTrip) { + if ($null -eq $stream -or $expectsError) { + $emitResult = 'NotApplicable' + if ($expectsError) { $emitReason = 'InvalidSyntax' } + } elseif ($projectionError) { + $emitResult = 'PolicyDifference' + $emitReason = $projectionError + } elseif ($syntaxResult -eq 'PolicyDifference') { + $emitResult = 'PolicyDifference' + $emitReason = $syntaxReason + } else { + try { + $emittedDocuments = [System.Collections.Generic.List[string]]::new() + foreach ($value in $projectedValues) { + $emitted = Invoke-InYamlModule -ScriptBlock { + param ($InputValue) + ConvertTo-Yaml -InputObject $InputValue -ExplicitDocumentStart + } -Arguments (, $value) + $emittedDocuments.Add([string] $emitted) + } + $emittedText = ($emittedDocuments.ToArray() -join "`n") + $isValidEmit = Invoke-InYamlModule -ScriptBlock { + param ($YamlText) + Test-Yaml -Yaml $YamlText -Depth 128 -MaxNodes 100000 -MaxAliases 1000 ` + -MaxScalarLength 1048576 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` + -MaxNumericLength 4096 + } -Arguments @($emittedText) + if (-not $isValidEmit) { + $emitResult = 'Fail' + $emitReason = 'EmittedYamlInvalid' + } else { + $roundTripValues = (Invoke-InYamlModule -ScriptBlock $projectYamlSuiteText ` + -Arguments @($emittedText)).Value + $roundCanonical = ConvertTo-YamlSuiteCanonicalValue -Value ([object[]] $roundTripValues) + $roundReference = ConvertTo-YamlSuiteReferenceSignature -Value ([object[]] $roundTripValues) + $emitCanonical = $roundCanonical + $emitReference = $roundReference + if ($roundCanonical -ceq $projectedCanonical -and $roundReference -ceq $projectedReference) { + $emitResult = 'Pass' + } else { + $reason = Get-YamlSuitePolicyReason -YamlText $yaml -Expected $projectedCanonical ` + -Actual $roundCanonical -DefaultReason '' + if ($reason) { + $emitResult = 'PolicyDifference' + $emitReason = $reason + } else { + $emitResult = 'Fail' + $emitReason = 'EmitRoundTripMismatch' + } + } + } + } catch [System.NotSupportedException] { + $emitResult = 'PolicyDifference' + $emitReason = 'UnsupportedEmissionType' + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $emitResult = 'Fail' + $emitReason = [string] $_.Exception.Data['YamlErrorId'] + } else { + throw + } + } + } + } + + [pscustomobject]@{ + Case = $casePath + ExpectsError = $expectsError + HasJson = $hasJson + HasEvent = $hasEvent + HasOutYaml = $hasOutYaml + HasEmitYaml = $hasEmitYaml + SyntaxResult = $syntaxResult + SyntaxReason = $syntaxReason + EventResult = $eventResult + EventReason = $eventReason + JsonResult = $jsonResult + JsonReason = $jsonReason + OutYamlResult = $outYamlResult + OutYamlReason = $outYamlReason + EmitResult = $emitResult + EmitReason = $emitReason + EventExpected = $eventExpected + EventActual = $eventActual + JsonExpected = $jsonExpected + JsonActual = $jsonActual + OutYamlActual = $outYamlCanonical + EmitActual = $emitCanonical + EmitReferences = $emitReference + ProjectedActual = $projectedCanonical + ProjectedRefs = $projectedReference + } +}