diff --git a/.gitattributes b/.gitattributes index 96c2e0d..7fd3b64 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,4 @@ *.png binary *.dll binary *.so binary +*.zip binary -eol 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..44cb9c9 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -18,14 +18,39 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: write - pull-requests: write - statuses: write - pages: write - id-token: write +permissions: {} jobs: - Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@11117919e65242d3388727819a751f74ad24ea9e # v5.5.0 - secrets: inherit + Process-PSModule-PR: + if: github.event_name == 'pull_request' && github.event.action != 'closed' + permissions: + contents: read + pull-requests: write + statuses: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 + with: + ImportantFilePatterns: | + ^src/ + ^tests/ + ^README\.md$ + secrets: + APIKey: ${{ secrets.APIKey }} + TestData: ${{ secrets.TestData }} + + Process-PSModule-Release: + if: github.event_name != 'pull_request' || github.event.action == 'closed' + permissions: + contents: write + pull-requests: write + statuses: write + pages: write + id-token: write + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 + with: + ImportantFilePatterns: | + ^src/ + ^tests/ + ^README\.md$ + secrets: + APIKey: ${{ secrets.APIKey }} + TestData: ${{ secrets.TestData }} 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..8a9a588 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,369 @@ -# {{ 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 Yaml +Import-Module -Name Yaml +``` + +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. | +| `Export-Yaml` | Serialize values and atomically write one YAML file. | +| `Format-Yaml` | Normalize YAML streams without projecting representation nodes to PowerShell values. | +| `Import-Yaml` | Strictly decode and parse YAML files. | +| `Merge-Yaml` | Merge complete YAML streams without losing representation graph details. | +| `Remove-YamlEntry` | Remove selected entries or documents without losing representation graph details. | +| `Test-Yaml` | Test YAML syntax, tags, duplicate keys, and configured resource limits. | + +## Parse YAML + +Ordinary string-key mappings become ordered `PSCustomObject` values. A +top-level sequence writes its items to the pipeline by default. ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +$config = @' +name: example +enabled: true +ports: [80, 443] +'@ | ConvertFrom-Yaml + +$config.name +$config.ports[0] ``` -## Usage +Pipeline strings are joined with LF and parsed as one stream. This makes +line-oriented input work as expected: -Here is a list of example that are typical use cases for the module. +```powershell +$config = Get-Content -Path '.\config.yaml' | ConvertFrom-Yaml +``` -### Example 1: Greet an entity +Use `-NoEnumerate` when a top-level sequence must remain one pipeline record: -Provide examples for typical commands that a user would like to do with the module. +```powershell +$servers = @' +- name: web-1 +- name: web-2 +'@ | ConvertFrom-Yaml -NoEnumerate +``` + +Every YAML document is returned separately: + +```powershell +$documents = @(@' +--- +name: first +--- +name: second +'@ | ConvertFrom-Yaml) +``` + +Use `-AsHashtable` for insertion-ordered dictionaries and mappings with +complex, non-string, empty, or case-colliding keys: ```powershell -Greet-Entity -Name 'World' -Hello, World! +$mapping = @' +? [region, port] +: eu-1 +'@ | ConvertFrom-Yaml -AsHashtable ``` -### Example 2 +## Import YAML files -Provide examples for typical commands that a user would like to do with the module. +`Import-Yaml` reads complete files with strict Unicode decoding and delegates +parsing to `ConvertFrom-Yaml`. `-Path` expands wildcards and accepts `FileInfo` +pipeline input; `-LiteralPath` preserves wildcard characters in filenames. +Resolved files are deduplicated and read in deterministic path order. ```powershell -Import-Module -Name PSModuleTemplate +$configs = Import-Yaml -Path '.\config\*.yaml' -AsHashtable +Get-ChildItem -Path '.\services' -Filter '*.yaml' | Import-Yaml +Import-Yaml -LiteralPath '.\config[production].yaml' ``` -### Find more examples +UTF-8 without a byte order mark is the default. UTF-8, UTF-16, and UTF-32 byte +order marks are detected automatically and override `-Encoding`. Malformed +bytes terminate with a path-specific error. `-NoEnumerate` and all parser +resource limits have the same behavior as `ConvertFrom-Yaml`. -To find more examples of how to use the module, please refer to the [examples](examples) folder. +## Serialize PowerShell values -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'. +`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. -## Documentation +```powershell +$yaml = [ordered]@{ + name = 'example' + enabled = $true + ports = @(80, 443) +} | ConvertTo-Yaml -ExplicitDocumentStart -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. +$roundTrip = $yaml | ConvertFrom-Yaml +``` -## Contributing +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 +``` -### For Users +`-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. -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. +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()`. -### For Developers +## Format YAML streams -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. +`Format-Yaml` normalizes existing YAML without converting it through +`PSCustomObject` or dictionary values. It retains document order and empty +documents, node kinds, scalar content, effective tags, anchors and aliases, +recursive graphs, complex keys, collection structure, and mapping order. -## Acknowledgements +```powershell +$normalized = Get-Content -Path '.\config.yaml' | Format-Yaml -Indent 4 +``` + +Pipeline records are joined with LF and parsed as one stream. The output is one +string with LF line endings and no final newline. Every document starts with +`---`; document-end markers, comments, directives, flow presentation, scalar +styles, and original anchor names are normalized. Effective standard tags use +`!!` shorthand where possible, while local and global tags use a deterministic +verbatim form. + +Formatting is byte-idempotent at the same options: + +```powershell +$normalized -ceq ($normalized | Format-Yaml -Indent 4) +``` + +`-Indent` accepts 2 through 9 spaces. The `-Depth`, `-MaxNodes`, `-MaxAliases`, +`-MaxScalarLength`, `-MaxTagLength`, `-MaxTotalTagLength`, and +`-MaxNumericLength` defaults and ranges match `ConvertFrom-Yaml`. Invalid YAML, +duplicate representation keys, undefined aliases, malformed tags, and resource +limit violations terminate with the same classified YAML errors as parsing. + +## Merge YAML streams + +`Merge-Yaml` combines two or more complete YAML streams directly through their +representation graphs. Every array element or pipeline record is one complete +stream, and every stream must contain the same positive document count. Later +streams have higher precedence, and documents merge pairwise by zero-based index. + +```powershell +$baseYaml = Get-Content -LiteralPath '.\base.yaml' -Raw +$overlayYaml = Get-Content -LiteralPath '.\overlay.yaml' -Raw +$mergedYaml = Merge-Yaml -InputObject @($baseYaml, $overlayYaml) +``` + +Compatible mappings merge recursively by structural YAML key equality. Base key +order remains stable, replacing a value retains its position, and new overlay +keys append in overlay order. Complex and tagged keys are supported. Structural +fingerprints select comparison candidates only; mutation-aware indexes are +retained across overlays, and graph-aware equality makes the final key decision. + +Compatible sequences use `-SequenceAction Replace`, `Append`, or `Unique`. +Unequal scalars, collection kinds, and incompatible effective tags use +`-ConflictAction Replace` or `Error`. A later YAML null uses `-NullAction +Replace` or `Ignore`; ignoring retains an existing prior node, including at a +document root. + +```powershell +$baseYaml, $environmentYaml, $secretYaml | + Merge-Yaml -SequenceAction Unique -ConflictAction Error -Indent 4 +``` + +Tags, anchors, aliases, repeated nodes, cycles, mapping order, and selected +representation nodes remain graph data. Inputs are immutable, and YAML 1.1 `<<` +merge keys remain ordinary mapping entries rather than being expanded. Output is +one deterministic string with LF line endings, explicit document starts, and no +final newline. + +The parser safety parameters and defaults match `Format-Yaml`. `-MaxNodes` +limits each parsed stream and applies independently to invocation-wide clone +creation, charged merge operations, and the resulting stream graph. Index, +fingerprint, candidate, alias-traversal, and equality work all consume the merge +operation budget. Alias and expanded-tag budgets are also enforced on the result. + +## Remove YAML entries + +`Remove-YamlEntry` removes mapping entries, sequence items, or whole documents directly from a deep-cloned representation graph. Input array elements and pipeline records are joined with LF as one YAML stream, matching `Format-Yaml`. + +```powershell +$cleanYaml = Get-Content -Path '.\config.yaml' | + Remove-YamlEntry -Path @('/metadata/internalIdentifier', '/services/1/deprecated') +``` + +Paths use [RFC 6901 JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901). An empty pointer selects a document root, `~0` addresses a tilde, and `~1` addresses a slash. Mapping tokens match only scalar YAML string keys by ordinal content, so numeric, complex, and unknown-tagged keys are never coerced or guessed. Sequence tokens must be `0` or a non-zero decimal index without signs or leading zeros. + +Document zero is selected by default. Use `-DocumentIndex` for another zero-based document, or `-AllDocuments` to apply every path independently to every original document. `-IgnoreMissing` skips only unresolved document/path combinations. + +```powershell +$withoutTemporaryData = Remove-YamlEntry $stream '/temporary' ` + -AllDocuments -IgnoreMissing -Indent 4 +$withoutSecondDocument = Remove-YamlEntry $stream '' -DocumentIndex 1 +``` + +Every required target is resolved before mutation, duplicate logical targets are coalesced, and ancestors subsume descendants. Removing inside a shared mapping or sequence changes every alias to that node; removing an alias edge removes only that edge. Original sequence indexes and document roots are removed in descending order. + +Output preserves unaffected tags, anchors, aliases, shared and cyclic identity, complex keys, mapping order, and document order. It is one deterministic string with LF line endings and no final newline; removing every document returns an empty string. Parser limits are mirrored, while cloning, pointer and mutation work, and output validation use independent resource ceilings. + +## Export YAML files + +`Export-Yaml` aggregates pipeline records like `ConvertTo-Yaml`, serializes the +complete value before changing the filesystem, and atomically publishes a +same-directory temporary file. It writes UTF-8 without a byte order mark, LF +line endings, and exactly one final newline by default. + +```powershell +$config | Export-Yaml -Path '.\config.yaml' +'one', 'two' | Export-Yaml -Path '.\items.yaml' -Encoding utf16LE +$config | Export-Yaml -Path '.\generated\config.yaml' -CreateDirectory -PassThru +``` + +Use `-NewLine CRLF` or `-NoFinalNewline` to change presentation. `-NoClobber` +prevents replacement, while `-Force` permits replacing a read-only destination +and preserves its read-only state. The switches are mutually exclusive. +`-WhatIf` creates no directory or temporary file. `-PassThru` is the only mode +that emits the final `FileInfo`. + +## Validate YAML + +```powershell +if (Get-Content -Path '.\config.yaml' | Test-Yaml) { + 'The YAML stream is valid.' +} +``` + +`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. +- YAML stream merging compares effective tags and structural representation + values without projecting through PowerShell objects. +- YAML entry removal resolves JSON Pointers against an immutable representation + clone and mutates shared nodes by identity without object projection. +- 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 latest official `yaml-test-suite` source release, +`v2022-01-17`, at commit `45db50aecf9b1520f8258938c88f396e96f30831`. +Its `data-2022-01-17` export is pinned at commit +`6e6c296ae9c9d2d5c4134b4b64d01b29ac19ff6f` with archive SHA-256 +`47C173AFFEB480517B30FB77DC8C76FD48609B9B65DD1C1D3D0D0BAEE48D6AA9`. +The archive contains 402 inputs: + +| Surface | Pass | PolicyDifference | Fail | NotApplicable | +| --- | ---: | ---: | ---: | ---: | +| Syntax and composition | 400 | 2 | 0 | 0 | +| Representation events | 308 | 0 | 0 | 94 | +| JSON projection | 277 | 2 | 0 | 123 | +| `out.yaml` projection | 241 | 1 | 0 | 160 | +| Official `emit.yaml` fixtures | 55 | 0 | 0 | 347 | +| Module self-round-trip | 305 | 3 | 0 | 94 | +| `Format-Yaml` representation and idempotence | 400 | 0 | 0 | 2 | + +All 94 fixtures marked invalid are rejected. The valid `2JQS` and `X38W` +inputs are syntactically recognized and produce matching representation +events, then are rejected during load validation because representation +mapping keys must be unique. They are not unsupported grammar. Both are +reported as policy differences for module self-round-trip; `X38W`, the one +case with an `out.yaml` fixture, is also reported that way on that surface. + +The formatter surface directly compares representation events and graph +identity before and after formatting, validates the emitted stream, and +requires byte-identical second formatting. It passes all 306 loadable valid +inputs and confirms that all 94 invalid inputs remain rejected. The two valid +duplicate-key policy cases are not applicable because `Format-Yaml` applies +the same representation-key uniqueness policy as `ConvertFrom-Yaml`. + +The two JSON projection differences are `565N`, where `!!binary` intentionally +becomes `byte[]` instead of a Base64 string, and `J7PZ`, where legacy `!!omap` +intentionally becomes `System.Collections.Specialized.OrderedDictionary` +instead of remaining a sequence of one-entry mappings. `J7PZ` is also a +self-round-trip policy difference: once projected, its ordered dictionary +cannot be distinguished from an ordinary insertion-ordered PowerShell +dictionary, so emission cannot reconstruct the explicit `!!omap` tag. The +runner still preserves and compares `!!omap` order from representation +metadata. No event mismatch is classified as policy. All 55 official +`emit.yaml` fixtures are read and validated independently of the 402-input +module self-round-trip. The deterministic runner reports 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. + +## 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..72a0af7 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -1,19 +1,78 @@ -<# - .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 + +# Normalize YAML presentation without projecting its representation graph. +$normalizedYaml = @' +# Presentation differences are removed. +{ name: example, ports: [80, 443] } +'@ | Format-Yaml -Indent 4 +$normalizedYaml + +# Merge complete YAML streams without projecting their representation graphs. +$baseYaml = @' +service: + image: example:v1 + ports: [80] +'@ +$overlayYaml = @' +service: + image: example:v2 + ports: [443] +'@ +$mergedYaml = Merge-Yaml -InputObject @($baseYaml, $overlayYaml) +$mergedYaml + +# Remove selected YAML entries without projecting the representation graph. +$cleanedYaml = $mergedYaml | + Remove-YamlEntry -Path @('/service/image', '/service/ports/0') +$cleanedYaml + +# Atomically export one file, then import it with strict decoding. +$configPath = Join-Path $env:TEMP 'yaml-example.yaml' +$config | Export-Yaml -Path $configPath -PassThru +$importedConfig = Import-Yaml -LiteralPath $configPath +$importedConfig +Remove-Item -LiteralPath $configPath + +# Test syntax, duplicate keys, tags, and resource limits without conversion. +$isValid = $outputYaml | Test-Yaml +$isValid diff --git a/src/functions/private/Add-YamlDictionaryEntry.ps1 b/src/functions/private/Add-YamlDictionaryEntry.ps1 new file mode 100644 index 0000000..5d53b06 --- /dev/null +++ b/src/functions/private/Add-YamlDictionaryEntry.ps1 @@ -0,0 +1,37 @@ +function Add-YamlDictionaryEntry { + <# + .SYNOPSIS + Adds a projected mapping entry with a YAML-classified collision error. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Mutates an internal projection dictionary.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Dictionary, + + [Parameter(Mandatory)] + [object] $Key, + + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [pscustomobject] $KeyNode + ) + + try { + $Dictionary.Add($Key, $Value) + } catch [System.Management.Automation.MethodInvocationException] { + if ($_.Exception.InnerException -isnot [System.ArgumentException]) { + throw + } + throw (New-YamlException -Start $KeyNode.Start -End $KeyNode.End ` + -ErrorId 'YamlProjectionKeyCollision' -Message ( + 'Distinct YAML mapping keys cannot be projected distinctly into a PowerShell dictionary.' + )) + } +} diff --git a/src/functions/private/Add-YamlMergeIndexCandidate.ps1 b/src/functions/private/Add-YamlMergeIndexCandidate.ps1 new file mode 100644 index 0000000..4c4e057 --- /dev/null +++ b/src/functions/private/Add-YamlMergeIndexCandidate.ps1 @@ -0,0 +1,44 @@ +function Add-YamlMergeIndexCandidate { + <# + .SYNOPSIS + Adds one mapping key or sequence item to a structural candidate index. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Index, + + [Parameter(Mandatory)] + [pscustomobject] $Candidate, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $node = if ($Index.Kind -eq 'Mapping') { + $Candidate.Key + } else { + $Candidate + } + Add-YamlMergeWork -State $Context.WorkState -Node $node ` + -Operation 'index candidate identity' + $effective = Get-YamlMergeNode -Node $node + if (-not $Index.IndexedEffectiveIds.Add($effective.Id)) { + return + } + + $fingerprint = Get-YamlMergeFingerprint -Node $node -State $Context.EqualityState ` + -Cache $Index.FingerprintCache -CandidateIndex $Index + + Add-YamlMergeWork -State $Context.WorkState -Node $node -Operation 'index bucket visit' + $bucket = $null + if (-not $Index.Buckets.TryGetValue($fingerprint, [ref] $bucket)) { + $bucket = [pscustomobject]@{ + Candidates = [System.Collections.Generic.List[object]]::new() + } + $Index.Buckets[$fingerprint] = $bucket + } + + Add-YamlMergeWork -State $Context.WorkState -Node $node -Operation 'index candidate visit' + $bucket.Candidates.Add($Candidate) +} diff --git a/src/functions/private/Add-YamlMergeWork.ps1 b/src/functions/private/Add-YamlMergeWork.ps1 new file mode 100644 index 0000000..1088e18 --- /dev/null +++ b/src/functions/private/Add-YamlMergeWork.ps1 @@ -0,0 +1,36 @@ +function Add-YamlMergeWork { + <# + .SYNOPSIS + Charges one or more deterministic operations to the YAML merge work budget. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $Count = 1, + + [Parameter(Mandatory)] + [string] $Operation, + + [Parameter()] + [AllowNull()] + [pscustomobject] $Node + ) + + $State.Count = [long] $State.Count + $Count + if ($State.Count -le $State.MaxNodes) { + return + } + + $exception = New-YamlMergeException -Node $Node -ErrorId 'YamlMergeWorkLimitExceeded' ` + -Message ( + "YAML merge operation '$Operation' exceeded the configured invocation work limit " + + "of $($State.MaxNodes) operations." + ) + $exception.Data['YamlMergeWorkCount'] = $State.Count + $exception.Data['YamlMergeWorkLimit'] = $State.MaxNodes + throw $exception +} diff --git a/src/functions/private/Add-YamlRemovalWork.ps1 b/src/functions/private/Add-YamlRemovalWork.ps1 new file mode 100644 index 0000000..3c3f514 --- /dev/null +++ b/src/functions/private/Add-YamlRemovalWork.ps1 @@ -0,0 +1,37 @@ +function Add-YamlRemovalWork { + <# + .SYNOPSIS + Charges deterministic operations to the YAML removal work budget. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $Count = 1, + + [Parameter(Mandatory)] + [string] $Operation, + + [Parameter()] + [AllowNull()] + [pscustomobject] $Node + ) + + $State.Count = [long] $State.Count + $Count + if ($State.Count -le $State.MaxNodes) { + return + } + + $exception = New-YamlRemovalException -Node $Node ` + -ErrorId 'YamlRemovalWorkLimitExceeded' -Message ( + "YAML removal operation '$Operation' exceeded the configured invocation work " + + "limit of $($State.MaxNodes) operations." + ) + $exception.Data['YamlRemovalWorkCount'] = $State.Count + $exception.Data['YamlRemovalWorkLimit'] = $State.MaxNodes + $exception.Data['YamlRemovalWorkOperation'] = $Operation + throw $exception +} diff --git a/src/functions/private/Assert-YamlMergeGraph.ps1 b/src/functions/private/Assert-YamlMergeGraph.ps1 new file mode 100644 index 0000000..9979ff2 --- /dev/null +++ b/src/functions/private/Assert-YamlMergeGraph.ps1 @@ -0,0 +1,125 @@ +function Assert-YamlMergeGraph { + <# + .SYNOPSIS + Validates the merged representation graph and its resource budgets. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents, + + [Parameter(Mandatory)] + [int] $Depth, + + [Parameter(Mandatory)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [int] $MaxScalarLength, + + [Parameter(Mandatory)] + [int] $MaxTagLength, + + [Parameter(Mandatory)] + [int] $MaxTotalTagLength + ) + + $visited = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + foreach ($document in $Documents) { + $pending.Push([pscustomobject]@{ Node = $document; Depth = 1 }) + } + $nodeCount = 0 + $aliasCount = 0 + $totalTagLength = 0L + + while ($pending.Count -gt 0) { + $item = $pending.Pop() + $node = $item.Node + if (-not $visited.Add($node.Id)) { + continue + } + $nodeCount++ + if ($nodeCount -gt $MaxNodes) { + throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeNodeLimitExceeded' -Message ( + "The merged YAML graph exceeds the configured limit of $MaxNodes nodes." + )) + } + if ($item.Depth -gt $Depth) { + throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeDepthLimitExceeded' -Message ( + "The merged YAML graph exceeds the configured depth of $Depth." + )) + } + + if ($node.Kind -eq 'Alias') { + $aliasCount++ + if ($aliasCount -gt $MaxAliases) { + throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeAliasLimitExceeded' -Message ( + "The merged YAML graph exceeds the configured limit of $MaxAliases aliases." + )) + } + $pending.Push([pscustomobject]@{ Node = $node.Target; Depth = $item.Depth }) + continue + } + + $tagLength = ([string] $node.Tag).Length + if ($tagLength -gt $MaxTagLength) { + throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeTagLimitExceeded' -Message ( + "A tag in the merged YAML graph exceeds the configured limit of $MaxTagLength characters." + )) + } + $totalTagLength += $tagLength + if ($totalTagLength -gt $MaxTotalTagLength) { + throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeTagLimitExceeded' -Message ( + "The merged YAML graph exceeds the configured cumulative tag limit of " + + "$MaxTotalTagLength characters." + )) + } + + if ($node.Kind -eq 'Scalar') { + if ((Get-YamlRuneCount -Text ([string] $node.Value)) -gt $MaxScalarLength) { + throw (New-YamlMergeException -Node $node ` + -ErrorId 'YamlMergeScalarLimitExceeded' -Message ( + "A scalar in the merged YAML graph exceeds the configured limit of " + + "$MaxScalarLength characters." + )) + } + continue + } + if ($node.Kind -eq 'Sequence') { + for ($index = $node.Items.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Node = $node.Items[$index] + Depth = $item.Depth + 1 + }) + } + continue + } + for ($index = $node.Entries.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Node = $node.Entries[$index].Value + Depth = $item.Depth + 1 + }) + $pending.Push([pscustomobject]@{ + Node = $node.Entries[$index].Key + Depth = $item.Depth + 1 + }) + } + } + + $fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + $fingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + try { + foreach ($document in $Documents) { + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher + } + } finally { + $fingerprintHasher.Dispose() + } +} diff --git a/src/functions/private/Assert-YamlNoByteOrderMark.ps1 b/src/functions/private/Assert-YamlNoByteOrderMark.ps1 new file mode 100644 index 0000000..1171d22 --- /dev/null +++ b/src/functions/private/Assert-YamlNoByteOrderMark.ps1 @@ -0,0 +1,21 @@ +function Assert-YamlNoByteOrderMark { + <# + .SYNOPSIS + Rejects a raw byte order mark outside a quoted scalar or document prefix. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter(Mandatory)] + [pscustomobject] $Mark + ) + + if ($Text.IndexOf([char] 0xFEFF) -ge 0) { + throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidByteOrderMark' -Message ( + 'A raw YAML byte order mark is only allowed in a quoted scalar or document prefix.' + )) + } +} diff --git a/src/functions/private/Assert-YamlNodeKeyUnique.ps1 b/src/functions/private/Assert-YamlNodeKeyUnique.ps1 new file mode 100644 index 0000000..3472a34 --- /dev/null +++ b/src/functions/private/Assert-YamlNodeKeyUnique.ps1 @@ -0,0 +1,61 @@ +function Assert-YamlNodeKeyUnique { + <# + .SYNOPSIS + Indexes one representation key and confirms equality for fingerprint candidates. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[string, object]] $Buckets, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, string]] $FingerprintCache, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher, + + [Parameter(Mandatory)] + [string] $DuplicateMessage, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState, + + [Parameter()] + [AllowNull()] + [pscustomobject] $EqualityState, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache + ) + + $fingerprint = Get-YamlNodeFingerprint -Node $Node ` + -Active ([System.Collections.Generic.HashSet[int]]::new()) ` + -Cache $FingerprintCache -Hasher $FingerprintHasher ` + -RemovalWorkState $RemovalWorkState + $bucket = $null + if (-not $Buckets.TryGetValue($fingerprint, [ref] $bucket)) { + $bucket = [System.Collections.Generic.List[object]]::new() + $Buckets[$fingerprint] = $bucket + } elseif ($null -eq $EqualityState) { + throw (New-YamlException -Start $Node.Start -End $Node.End ` + -ErrorId 'YamlDuplicateKey' -Message $DuplicateMessage) + } else { + foreach ($candidate in $bucket) { + if (Test-YamlMergeNodeEqual -Node $candidate -OtherNode $Node ` + -State $EqualityState ` + -LeftFingerprintCache $EqualityFingerprintCache ` + -RightFingerprintCache $EqualityFingerprintCache) { + throw (New-YamlException -Start $Node.Start -End $Node.End ` + -ErrorId 'YamlDuplicateKey' -Message $DuplicateMessage) + } + } + } + $bucket.Add($Node) +} diff --git a/src/functions/private/Assert-YamlRemovalGraph.ps1 b/src/functions/private/Assert-YamlRemovalGraph.ps1 new file mode 100644 index 0000000..db88ce4 --- /dev/null +++ b/src/functions/private/Assert-YamlRemovalGraph.ps1 @@ -0,0 +1,211 @@ +function Assert-YamlRemovalGraph { + <# + .SYNOPSIS + Validates a removed representation graph and its resource budgets. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents, + + [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)] + [pscustomobject] $State + ) + + $visited = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + foreach ($document in $Documents) { + $pending.Push([pscustomobject]@{ Node = $document; Depth = 1 }) + } + $nodeCount = 0 + $aliasCount = 0 + $totalTagLength = 0L + + while ($pending.Count -gt 0) { + $item = $pending.Pop() + $node = $item.Node + Add-YamlRemovalWork -State $State -Operation 'output validation' -Node $node + if (-not $visited.Add($node.Id)) { + continue + } + + $nodeCount++ + if ($nodeCount -gt $MaxNodes) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalNodeLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured limit of $MaxNodes nodes." + )) + } + if ($item.Depth -gt $Depth) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalDepthLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured depth of $Depth." + )) + } + + if ($node.Kind -eq 'Alias') { + $aliasCount++ + if ($aliasCount -gt $MaxAliases) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalAliasLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured limit of $MaxAliases aliases." + )) + } + $pending.Push([pscustomobject]@{ Node = $node.Target; Depth = $item.Depth }) + continue + } + + $tagLength = ([string] $node.Tag).Length + if ($tagLength -gt $MaxTagLength) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalTagLimitExceeded' -Message ( + "A tag in the resulting YAML graph exceeds the configured limit of " + + "$MaxTagLength characters." + )) + } + $totalTagLength += $tagLength + if ($totalTagLength -gt $MaxTotalTagLength) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalTagLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured cumulative tag limit of " + + "$MaxTotalTagLength characters." + )) + } + + if ($node.Kind -eq 'Scalar') { + if ((Get-YamlRuneCount -Text ([string] $node.Value)) -gt $MaxScalarLength) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalScalarLimitExceeded' -Message ( + "A scalar in the resulting YAML graph exceeds the configured limit of " + + "$MaxScalarLength characters." + )) + } + continue + } + + if ($node.Kind -eq 'Sequence') { + if ($node.Tag -cin @( + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs' + )) { + foreach ($sequenceItem in $node.Items) { + $effectiveItem = Get-YamlRemovalNode -Node $sequenceItem -State $State + if ($effectiveItem.Kind -ne 'Mapping' -or + $effectiveItem.Entries.Count -ne 1) { + throw (New-YamlException -Start $sequenceItem.Start ` + -End $sequenceItem.End -ErrorId 'YamlInvalidTaggedCollection' ` + -Message ( + "YAML tag '$($node.Tag)' requires a sequence of one-entry mappings." + )) + } + } + } + for ($index = $node.Items.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Node = $node.Items[$index] + Depth = $item.Depth + 1 + }) + } + continue + } + + if ($node.Tag -ceq 'tag:yaml.org,2002:set') { + foreach ($entry in $node.Entries) { + $effectiveValue = Get-YamlRemovalNode -Node $entry.Value -State $State + $resolvedValue = if ($effectiveValue.Kind -eq 'Scalar') { + (Resolve-YamlScalar -Node $effectiveValue).Value + } else { + [System.Management.Automation.Internal.AutomationNull]::Value + } + if ($effectiveValue.Kind -ne 'Scalar' -or $null -ne $resolvedValue) { + throw (New-YamlException -Start $entry.Value.Start -End $entry.Value.End ` + -ErrorId 'YamlInvalidTaggedCollection' -Message ( + 'Every value in a YAML set must be null.' + )) + } + } + + } + for ($index = $node.Entries.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Node = $node.Entries[$index].Value + Depth = $item.Depth + 1 + }) + $pending.Push([pscustomobject]@{ + Node = $node.Entries[$index].Key + Depth = $item.Depth + 1 + }) + } + } + + $fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + $fingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + $fingerprintWorkState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $equalityWorkState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $equalityState = [pscustomobject]@{ + MaxNodes = $MaxNodes + FingerprintHasher = $fingerprintHasher + WorkState = $equalityWorkState + MutationState = [pscustomobject]@{ Version = 0L } + IndexDependents = [System.Collections.Generic.Dictionary[int, object]]::new() + Cache = [System.Collections.Generic.Dictionary[string, bool]]::new( + [System.StringComparer]::Ordinal + ) + InputIndex = 0 + } + $equalityFingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + try { + foreach ($document in $Documents) { + try { + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher ` + -RemovalWorkState $fingerprintWorkState -EqualityState $equalityState ` + -EqualityFingerprintCache $equalityFingerprintCache + } catch { + if ($_.Exception.Data.Contains('YamlErrorId') -and + $_.Exception.Data['YamlErrorId'] -ceq 'YamlMergeWorkLimitExceeded') { + $exception = New-YamlRemovalException -Node $document ` + -ErrorId 'YamlRemovalWorkLimitExceeded' -Message ( + 'Post-removal duplicate-key graph comparison exceeded the configured ' + + "invocation work limit of $MaxNodes operations." + ) + $exception.Data['YamlRemovalWorkCount'] = $equalityWorkState.Count + $exception.Data['YamlRemovalWorkLimit'] = $MaxNodes + $exception.Data['YamlRemovalWorkOperation'] = ( + 'duplicate-key graph comparison' + ) + throw $exception + } + throw + } + } + } finally { + $fingerprintHasher.Dispose() + } +} 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-YamlJsonPointer.ps1 b/src/functions/private/ConvertFrom-YamlJsonPointer.ps1 new file mode 100644 index 0000000..da99042 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlJsonPointer.ps1 @@ -0,0 +1,66 @@ +function ConvertFrom-YamlJsonPointer { + <# + .SYNOPSIS + Parses and strictly decodes one RFC 6901 JSON Pointer. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Pointer, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $workCount = [Math]::Max(1, $Pointer.Length) + Add-YamlRemovalWork -State $State -Count $workCount -Operation 'pointer parsing' + if ($Pointer.Length -eq 0) { + return New-YamlValueBox -Value ([string[]]::new(0)) + } + if ($Pointer[0] -cne '/') { + throw (New-YamlRemovalException -ErrorId 'YamlRemovalInvalidPointer' -Message ( + 'A YAML removal path must be empty or start with a slash as required by RFC 6901.' + )) + } + + $rawTokens = $Pointer.Substring(1).Split( + [char[]] @('/'), + [System.StringSplitOptions]::None + ) + $tokens = [System.Collections.Generic.List[string]]::new() + foreach ($rawToken in $rawTokens) { + $decoded = [System.Text.StringBuilder]::new() + for ($index = 0; $index -lt $rawToken.Length; $index++) { + $character = $rawToken[$index] + if ($character -cne '~') { + [void] $decoded.Append($character) + continue + } + + if ($index + 1 -ge $rawToken.Length) { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalInvalidPointerEscape' -Message ( + 'A YAML removal path contains an incomplete JSON Pointer tilde escape.' + )) + } + $escaped = $rawToken[$index + 1] + if ($escaped -ceq '0') { + [void] $decoded.Append('~') + } elseif ($escaped -ceq '1') { + [void] $decoded.Append('/') + } else { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalInvalidPointerEscape' -Message ( + "A YAML removal path contains invalid JSON Pointer escape '~$escaped'; " + + "only '~0' and '~1' are valid." + )) + } + $index++ + } + $tokens.Add($decoded.ToString()) + } + + return New-YamlValueBox -Value ([string[]] $tokens.ToArray()) +} diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 new file mode 100644 index 0000000..64b297a --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -0,0 +1,294 @@ +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') { + Add-YamlDictionaryEntry -Dictionary $frame.Result -Key $frame.Key ` + -Value $frame.Child.PSObject.Properties['Value'].Value ` + -KeyNode $frame.Node.Items[$frame.Index].Entries[0].Key + $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') { + Add-YamlDictionaryEntry -Dictionary $frame.Result -Key $frame.Key -Value $null ` + -KeyNode $frame.Node.Entries[$frame.Index].Key + $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') { + Add-YamlDictionaryEntry -Dictionary $frame.Result -Key $frame.Key ` + -Value $frame.Child.PSObject.Properties['Value'].Value ` + -KeyNode $frame.Node.Entries[$frame.Index].Key + $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 (Test-YamlReservedPropertyName -Name $frame.Key) { + $keyNode = $frame.Node.Entries[$frame.Index].Key + throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` + -ErrorId 'YamlPropertyNameReserved' -Message ( + "The mapping key '$($frame.Key)' is reserved by PowerShell ETS. 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..02008c8 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 @@ -0,0 +1,90 @@ +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, + + [Parameter(Mandatory)] + [ValidateRange(1, 1048576)] + [int] $MaxLength + ) + + $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 { + $decoded = $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." + )) + } + if ($builder.Length + $decoded.Length -gt $MaxLength) { + throw (New-YamlException -Start $Mark -End $Mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $MaxLength characters." + )) + } + [void] $builder.Append($decoded) + $escapedBytes.Clear() + } + if ($builder.Length -ge $MaxLength) { + throw (New-YamlException -Start $Mark -End $Mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $MaxLength characters." + )) + } + [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 { + $decoded = $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." + )) + } + if ($builder.Length + $decoded.Length -gt $MaxLength) { + throw (New-YamlException -Start $Mark -End $Mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $MaxLength characters." + )) + } + [void] $builder.Append($decoded) + } + + $builder.ToString() +} diff --git a/src/functions/private/ConvertTo-YamlFlowText.ps1 b/src/functions/private/ConvertTo-YamlFlowText.ps1 new file mode 100644 index 0000000..2754cb2 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlFlowText.ps1 @@ -0,0 +1,146 @@ +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') { + $explicitKey = ( + Get-YamlEmissionImplicitKeyLength -RenderedText $frame.KeyText + ) -gt 1024 + $keyPrefix = if ($explicitKey) { '? ' } else { '' } + $frame.Parts.Add("$keyPrefix$($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..058bfeb --- /dev/null +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -0,0 +1,204 @@ +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 + Reserved = $false + }) + + 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)." + )) + } + if ($frame.Reserved) { + $State.ReservedNodeCount-- + } + $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." + )) + } + + $isReference = Test-YamlSerializationReference -Value $frame.Value + if ($isReference) { + $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) + } + + $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 + } + + 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 + Reserved = $true + }) + 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 + Reserved = $true + }) + 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 + Reserved = $true + }) + 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..4090cfb --- /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 0xFEFF -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-YamlRepresentationNode.ps1 b/src/functions/private/ConvertTo-YamlRepresentationNode.ps1 new file mode 100644 index 0000000..745e537 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlRepresentationNode.ps1 @@ -0,0 +1,114 @@ +function ConvertTo-YamlRepresentationNode { + <# + .SYNOPSIS + Converts one representation graph to a lossless emission graph. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $orderedNodes = [System.Collections.Generic.List[object]]::new() + $visited = [System.Collections.Generic.HashSet[int]]::new() + $aliasTargets = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push($Node) + + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if ($current.Kind -eq 'Alias') { + [void] $aliasTargets.Add($current.Target.Id) + if (-not $visited.Contains($current.Target.Id)) { + $pending.Push($current.Target) + } + continue + } + if (-not $visited.Add($current.Id)) { + continue + } + + $orderedNodes.Add($current) + if ($current.Kind -eq 'Sequence') { + for ($index = $current.Items.Count - 1; $index -ge 0; $index--) { + $pending.Push($current.Items[$index]) + } + } elseif ($current.Kind -eq 'Mapping') { + for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) { + $pending.Push($current.Entries[$index].Value) + $pending.Push($current.Entries[$index].Key) + } + } + } + + $anchorNames = [System.Collections.Generic.Dictionary[int, string]]::new() + foreach ($source in $orderedNodes) { + if (-not [string]::IsNullOrEmpty($source.Anchor) -or + $aliasTargets.Contains($source.Id)) { + $anchorNames[$source.Id] = 'id{0:d3}' -f $State.NextAnchor + $State.NextAnchor++ + } + } + + $nodes = [System.Collections.Generic.Dictionary[int, object]]::new() + foreach ($source in $orderedNodes) { + $target = New-YamlEmissionNode -Kind $source.Kind + $target.Tag = [string] $source.Tag + $target.HasUnknownTag = [bool] $source.HasUnknownTag + if ($anchorNames.ContainsKey($source.Id)) { + $target.Anchor = $anchorNames[$source.Id] + $target.ReferenceId = [long] $source.Id + } + + if ($source.Kind -eq 'Scalar') { + $target.Value = [string] $source.Value + $target.Style = 'DoubleQuoted' + if ([string]::IsNullOrEmpty($source.Tag) -and + -not $source.HasUnknownTag -and $source.IsPlainImplicit) { + $resolved = (Resolve-YamlScalar -Node $source).Value + $effectiveTag = Get-YamlEffectiveTag -Node $source -Value $resolved + if ($effectiveTag -cne 'tag:yaml.org,2002:str') { + $target.Style = 'Plain' + } + } + } + $nodes[$source.Id] = $target + } + + foreach ($source in $orderedNodes) { + $target = $nodes[$source.Id] + if ($source.Kind -eq 'Sequence') { + foreach ($item in $source.Items) { + $itemId = if ($item.Kind -eq 'Alias') { + $item.Target.Id + } else { + $item.Id + } + $target.Items.Add($nodes[$itemId]) + } + } elseif ($source.Kind -eq 'Mapping') { + foreach ($entry in $source.Entries) { + $keyId = if ($entry.Key.Kind -eq 'Alias') { + $entry.Key.Target.Id + } else { + $entry.Key.Id + } + $valueId = if ($entry.Value.Kind -eq 'Alias') { + $entry.Value.Target.Id + } else { + $entry.Value.Id + } + $target.Entries.Add([pscustomobject]@{ + Key = $nodes[$keyId] + Value = $nodes[$valueId] + }) + } + } + } + + Write-Output -InputObject $nodes[$Node.Id] -NoEnumerate +} diff --git a/src/functions/private/ConvertTo-YamlRepresentationText.ps1 b/src/functions/private/ConvertTo-YamlRepresentationText.ps1 new file mode 100644 index 0000000..9627812 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlRepresentationText.ps1 @@ -0,0 +1,31 @@ +function ConvertTo-YamlRepresentationText { + <# + .SYNOPSIS + Emits representation documents as deterministic LF-normalized YAML. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents, + + [Parameter(Mandatory)] + [ValidateRange(2, 9)] + [int] $Indent + ) + + if ($Documents.Count -eq 0) { + return '' + } + + $state = [pscustomobject]@{ NextAnchor = 1 } + $renderedDocuments = [System.Collections.Generic.List[string]]::new() + foreach ($document in $Documents) { + $emissionNode = ConvertTo-YamlRepresentationNode -Node $document -State $state + $text = ConvertTo-YamlText -Node $emissionNode -Indent $Indent -ExplicitDocumentStart + $text = $text -replace '[ \t]+(?=\n|$)', '' + $renderedDocuments.Add($text.TrimEnd("`n")) + } + return $renderedDocuments.ToArray() -join "`n" +} diff --git a/src/functions/private/ConvertTo-YamlTagText.ps1 b/src/functions/private/ConvertTo-YamlTagText.ps1 new file mode 100644 index 0000000..690aa5c --- /dev/null +++ b/src/functions/private/ConvertTo-YamlTagText.ps1 @@ -0,0 +1,60 @@ +function ConvertTo-YamlTagText { + <# + .SYNOPSIS + Encodes an effective tag as canonical YAML tag text. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] $Tag + ) + + $uriPunctuation = '#;/?:@&=+$,_.!~*''()[]' + $utf8 = [System.Text.UTF8Encoding]::new($false, $true) + $builder = [System.Text.StringBuilder]::new() + for ($index = 0; $index -lt $Tag.Length; $index++) { + $character = $Tag[$index] + $code = [int] $character + $isAsciiWord = ( + ($code -ge 0x30 -and $code -le 0x39) -or + ($code -ge 0x41 -and $code -le 0x5A) -or + ($code -ge 0x61 -and $code -le 0x7A) -or + $character -eq '-' + ) + if ($isAsciiWord -or $uriPunctuation.IndexOf($character) -ge 0) { + [void] $builder.Append($character) + continue + } + + if ([char]::IsHighSurrogate($character)) { + if ($index + 1 -ge $Tag.Length -or -not [char]::IsLowSurrogate($Tag[$index + 1])) { + throw [System.ArgumentException]::new( + 'A YAML tag cannot contain an unpaired UTF-16 high surrogate.' + ) + } + $scalarText = $Tag.Substring($index, 2) + $index++ + } elseif ([char]::IsLowSurrogate($character)) { + throw [System.ArgumentException]::new( + 'A YAML tag cannot contain an unpaired UTF-16 low surrogate.' + ) + } else { + $scalarText = [string] $character + } + + foreach ($byte in $utf8.GetBytes($scalarText)) { + [void] $builder.Append(('%{0:X2}' -f $byte)) + } + } + + $escapedTag = $builder.ToString() + $standardPrefix = 'tag:yaml.org,2002:' + if ($escapedTag.StartsWith($standardPrefix, [System.StringComparison]::Ordinal)) { + $suffix = $escapedTag.Substring($standardPrefix.Length) + if (Test-YamlTagUriText -Text $suffix -Shorthand) { + return "!!$suffix" + } + } + return "!<$escapedTag>" +} 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/ConvertTo-YamlTimestampText.ps1 b/src/functions/private/ConvertTo-YamlTimestampText.ps1 new file mode 100644 index 0000000..b6c76d4 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlTimestampText.ps1 @@ -0,0 +1,51 @@ +function ConvertTo-YamlTimestampText { + <# + .SYNOPSIS + Formats a representable CLR timestamp for YAML emission. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [object] $Value + ) + + try { + if ($Value -is [datetimeoffset]) { + $offsetMinutes = $Value.Offset.TotalMinutes + if ($offsetMinutes -lt -840 -or $offsetMinutes -gt 840 -or + $offsetMinutes -ne [System.Math]::Truncate($offsetMinutes) -or + $Value.UtcTicks -lt [datetime]::MinValue.Ticks -or + $Value.UtcTicks -gt [datetime]::MaxValue.Ticks) { + throw [System.ArgumentOutOfRangeException]::new('Value') + } + return $Value.ToString( + 'o', + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + + if ($Value.Kind -eq [System.DateTimeKind]::Local) { + return [datetimeoffset]::new($Value).ToString( + 'o', + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + + $utc = if ($Value.Kind -eq [System.DateTimeKind]::Utc) { + $Value + } else { + [datetime]::SpecifyKind($Value, [System.DateTimeKind]::Utc) + } + return $utc.ToString( + "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", + [System.Globalization.CultureInfo]::InvariantCulture + ) + } catch [System.ArgumentException] { + throw (New-YamlSerializationException -ErrorId 'YamlTimestampSerializationFailed' ` + -Message 'The timestamp cannot be represented with its local or explicit UTC offset.') + } catch [System.FormatException] { + throw (New-YamlSerializationException -ErrorId 'YamlTimestampSerializationFailed' ` + -Message 'The timestamp cannot be represented with its local or explicit UTC offset.') + } +} diff --git a/src/functions/private/Copy-YamlMergeNode.ps1 b/src/functions/private/Copy-YamlMergeNode.ps1 new file mode 100644 index 0000000..6d6f904 --- /dev/null +++ b/src/functions/private/Copy-YamlMergeNode.ps1 @@ -0,0 +1,167 @@ +function Copy-YamlMergeNode { + <# + .SYNOPSIS + Deep-clones a YAML representation graph with identity memoization. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an isolated in-memory representation graph.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, object]] $Cache, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + if ($Cache.ContainsKey($Node.Id)) { + Write-Output -InputObject $Cache[$Node.Id] -NoEnumerate + return + } + + $result = [pscustomobject]@{ Value = $null } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Source = $Node + Holder = $result + Target = $null + Child = $null + Key = $null + Index = 0 + State = 'Start' + }) + + 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 + } + + $State.CreatedNodes++ + if ($State.CreatedNodes -gt $State.MaxNodes) { + throw (New-YamlMergeException -Node $frame.Source ` + -ErrorId 'YamlMergeNodeLimitExceeded' -Message ( + "Cloning the merged YAML graph exceeded the configured limit of $($State.MaxNodes) nodes." + )) + } + $target = New-YamlNode -Id $State.NextId -Kind $frame.Source.Kind ` + -Start $frame.Source.Start -End $frame.Source.End + $State.NextId++ + $target.Tag = [string] $frame.Source.Tag + $target.HasUnknownTag = [bool] $frame.Source.HasUnknownTag + $target.Anchor = [string] $frame.Source.Anchor + $target.Value = $frame.Source.Value + $target.Style = [string] $frame.Source.Style + $target.IsPlainImplicit = [bool] $frame.Source.IsPlainImplicit + $target.IsQuotedImplicit = [bool] $frame.Source.IsQuotedImplicit + $target.MaxNumericLength = [int] $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 + Key = $null + Index = 0 + State = 'Start' + }) + } 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 + Key = $null + Index = 0 + State = 'Start' + }) + 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 + Key = $null + Index = 0 + State = 'Start' + }) + 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 + Key = $null + Index = 0 + State = 'Start' + }) + continue + } + if ($frame.State -eq 'MappingValue') { + $frame.Target.Entries.Add([pscustomobject]@{ + Key = $frame.Key + Value = $frame.Child.Value + }) + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + Write-Output -InputObject $result.Value -NoEnumerate +} diff --git a/src/functions/private/Find-YamlCommentStart.ps1 b/src/functions/private/Find-YamlCommentStart.ps1 new file mode 100644 index 0000000..a0d08f7 --- /dev/null +++ b/src/functions/private/Find-YamlCommentStart.ps1 @@ -0,0 +1,22 @@ +function Find-YamlCommentStart { + <# + .SYNOPSIS + Finds a comment indicator separated by YAML s-white. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + for ($index = 0; $index -lt $Text.Length; $index++) { + if ($Text[$index] -ceq '#' -and ( + $index -eq 0 -or (Test-YamlWhiteSpace -Character $Text[$index - 1]) + )) { + return $index + } + } + return -1 +} diff --git a/src/functions/private/Find-YamlMappingColon.ps1 b/src/functions/private/Find-YamlMappingColon.ps1 new file mode 100644 index 0000000..ada6943 --- /dev/null +++ b/src/functions/private/Find-YamlMappingColon.ps1 @@ -0,0 +1,132 @@ +function Find-YamlMappingColon { + <# + .SYNOPSIS + Finds a block mapping value indicator outside quoted and flow content. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [switch] $AllowAnchorFallback + ) + + if ($Text.IndexOf(':') -lt 0) { + return -1 + } + + $flowDepth = 0 + $singleQuoted = $false + $doubleQuoted = $false + $atNodeStart = $true + $anchorColonCandidate = -1 + 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 (Test-YamlWhiteSpace -Character $character)) { + continue + } + if ($atNodeStart -and $character -eq '&') { + $index++ + while ($index -lt $Text.Length -and + -not (Test-YamlWhiteSpace -Character $Text[$index]) -and + $Text[$index] -notin @(',', '[', ']', '{', '}')) { + if ($index + 1 -lt $Text.Length -and + (Test-YamlMappingValueIndicator -Text $Text -Index $index)) { + $anchorColonCandidate = $index + } + $index++ + } + $index-- + continue + } + if ($atNodeStart -and $character -eq '*') { + $index++ + while ($index -lt $Text.Length -and + -not (Test-YamlWhiteSpace -Character $Text[$index]) -and + $Text[$index] -notin @(',', '[', ']', '{', '}')) { + if ($index + 1 -lt $Text.Length -and + (Test-YamlMappingValueIndicator -Text $Text -Index $index)) { + $anchorColonCandidate = $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 (Test-YamlWhiteSpace -Character $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 + (Test-YamlWhiteSpace -Character $Text[$index + 1]) + if ($nextIsSeparator) { + return $index + } + } + } + '#' { + if ($flowDepth -eq 0 -and ( + $index -eq 0 -or (Test-YamlWhiteSpace -Character $Text[$index - 1]) + )) { + return -1 + } + } + } + } + if ($AllowAnchorFallback) { + return $anchorColonCandidate + } + return -1 +} diff --git a/src/functions/private/Find-YamlMergeIndexMatch.ps1 b/src/functions/private/Find-YamlMergeIndexMatch.ps1 new file mode 100644 index 0000000..a8df6a3 --- /dev/null +++ b/src/functions/private/Find-YamlMergeIndexMatch.ps1 @@ -0,0 +1,42 @@ +function Find-YamlMergeIndexMatch { + <# + .SYNOPSIS + Finds a collision-safe structural match in a YAML merge candidate index. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Index, + + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $fingerprint = Get-YamlMergeFingerprint -Node $Node -State $Context.EqualityState ` + -Cache $Context.OverlayFingerprintCache + Add-YamlMergeWork -State $Context.WorkState -Node $Node -Operation 'index bucket lookup' + + $bucket = $null + if (-not $Index.Buckets.TryGetValue($fingerprint, [ref] $bucket)) { + return + } + + foreach ($candidate in $bucket.Candidates) { + $candidateNode = if ($Index.Kind -eq 'Mapping') { + $candidate.Key + } else { + $candidate + } + Add-YamlMergeWork -State $Context.WorkState -Node $Node ` + -Operation 'index candidate comparison' + if (Test-YamlMergeNodeEqual -Node $candidateNode -OtherNode $Node ` + -State $Context.EqualityState) { + Write-Output -InputObject $candidate -NoEnumerate + return + } + } +} diff --git a/src/functions/private/Get-YamlContentWithoutComment.ps1 b/src/functions/private/Get-YamlContentWithoutComment.ps1 new file mode 100644 index 0000000..64d1e25 --- /dev/null +++ b/src/functions/private/Get-YamlContentWithoutComment.ps1 @@ -0,0 +1,25 @@ +function Get-YamlContentWithoutComment { + <# + .SYNOPSIS + Removes a separated comment from non-flow scalar text. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter(Mandatory)] + [pscustomobject] $Mark + ) + + $comment = Find-YamlCommentStart -Text $Text + if ($comment -ge 0) { + $commentMark = New-YamlMark -Index ($Mark.Index + $comment) -Line $Mark.Line ` + -Column ($Mark.Column + $comment) + Assert-YamlNoByteOrderMark -Text $Text.Substring($comment) -Mark $commentMark + return $Text.Substring(0, $comment).TrimEnd(' ', "`t") + } + return $Text.TrimEnd(' ', "`t") +} diff --git a/src/functions/private/Get-YamlEffectiveTag.ps1 b/src/functions/private/Get-YamlEffectiveTag.ps1 new file mode 100644 index 0000000..4bb7a65 --- /dev/null +++ b/src/functions/private/Get-YamlEffectiveTag.ps1 @@ -0,0 +1,49 @@ +function Get-YamlEffectiveTag { + <# + .SYNOPSIS + Gets the effective representation tag for a YAML node. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if (-not [string]::IsNullOrEmpty($Node.Tag)) { + return $Node.Tag + } + if ($Node.Kind.Equals('Sequence', [System.StringComparison]::Ordinal)) { + return 'tag:yaml.org,2002:seq' + } + if ($Node.Kind.Equals('Mapping', [System.StringComparison]::Ordinal)) { + return 'tag:yaml.org,2002:map' + } + if ($Node.HasUnknownTag) { + return 'tag:yaml.org,2002:str' + } + + if ($null -eq $Value) { + return 'tag:yaml.org,2002:null' + } + if ($Value -is [string]) { + return 'tag:yaml.org,2002:str' + } + if ($Value -is [bool]) { + return 'tag:yaml.org,2002:bool' + } + if ($Value -is [byte[]]) { + return 'tag:yaml.org,2002:binary' + } + if ($Value -is [datetime] -or $Value -is [datetimeoffset]) { + return 'tag:yaml.org,2002:timestamp' + } + if ($Value -is [decimal] -or $Value -is [double] -or $Value -is [single]) { + return 'tag:yaml.org,2002:float' + } + return 'tag:yaml.org,2002:int' +} diff --git a/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 b/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 new file mode 100644 index 0000000..d39e71c --- /dev/null +++ b/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 @@ -0,0 +1,15 @@ +function Get-YamlEmissionImplicitKeyLength { + <# + .SYNOPSIS + Gets an emitted implicit key length in Unicode scalar values. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $RenderedText + ) + + return Get-YamlRuneCount -Text $RenderedText +} diff --git a/src/functions/private/Get-YamlEmissionNodeFingerprint.ps1 b/src/functions/private/Get-YamlEmissionNodeFingerprint.ps1 new file mode 100644 index 0000000..a3b176b --- /dev/null +++ b/src/functions/private/Get-YamlEmissionNodeFingerprint.ps1 @@ -0,0 +1,188 @@ +function Get-YamlEmissionNodeFingerprint { + <# + .SYNOPSIS + Iteratively fingerprints a normalized emission node. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[long, string]] $Cache, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[long]] $Active, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $Hasher + ) + + $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 + }) + $valueFingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher + $effectiveTag = Get-YamlEffectiveTag -Node $frame.Node -Value $resolved.Value + $fingerprint = Get-YamlFingerprintHash -Value ( + 'scalar:{0}:{1}:{2}' -f $effectiveTag.Length, $effectiveTag, $valueFingerprint + ) -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) { + $effectiveTag = Get-YamlEffectiveTag -Node $frame.Node -Value $null + $fingerprint = Get-YamlFingerprintHash -Value ( + 'sequence:{0}:{1}:{2}' -f @( + $effectiveTag.Length, + $effectiveTag, + ($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) + $effectiveTag = Get-YamlEffectiveTag -Node $frame.Node -Value $null + $fingerprint = Get-YamlFingerprintHash -Value ( + 'mapping:{0}:{1}:{2}' -f @( + $effectiveTag.Length, + $effectiveTag, + ($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..92d21b9 --- /dev/null +++ b/src/functions/private/Get-YamlEmissionPrefix.ps1 @@ -0,0 +1,23 @@ +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)) { + $parts.Add((ConvertTo-YamlTagText -Tag $Node.Tag)) + } elseif ($Node.HasUnknownTag) { + $parts.Add('!') + } + 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-YamlImplicitKeyLength.ps1 b/src/functions/private/Get-YamlImplicitKeyLength.ps1 new file mode 100644 index 0000000..37c6c75 --- /dev/null +++ b/src/functions/private/Get-YamlImplicitKeyLength.ps1 @@ -0,0 +1,27 @@ +function Get-YamlImplicitKeyLength { + <# + .SYNOPSIS + Gets an implicit key length in Unicode scalar values. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter()] + [ValidateRange(0, 2147483647)] + [int] $EndIndex + ) + + $sourceEnd = if ($PSBoundParameters.ContainsKey('EndIndex')) { + $EndIndex + } else { + $Node.End.Index + } + $sourceLength = [Math]::Max(0, $sourceEnd - $Node.Start.Index) + return Get-YamlRuneCount -Text $Context.Text.Substring($Node.Start.Index, $sourceLength) +} 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-YamlMergeFingerprint.ps1 b/src/functions/private/Get-YamlMergeFingerprint.ps1 new file mode 100644 index 0000000..36f75a9 --- /dev/null +++ b/src/functions/private/Get-YamlMergeFingerprint.ps1 @@ -0,0 +1,256 @@ +function Get-YamlMergeFingerprint { + <# + .SYNOPSIS + Creates a deterministic structural candidate index for merge comparisons. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $Cache, + + [Parameter()] + [AllowNull()] + [pscustomobject] $CandidateIndex + ) + + if ($null -eq $Cache) { + $Cache = [System.Collections.Generic.Dictionary[int, string]]::new() + } + $active = [System.Collections.Generic.HashSet[int]]::new() + $root = [pscustomobject]@{ Value = ''; IsContextual = $false } + $stack = [System.Collections.Generic.Stack[object]]::new() + $stack.Push([pscustomobject]@{ + Node = $Node + Holder = $root + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + Accumulator = $null + IsContextual = $false + }) + + while ($stack.Count -gt 0) { + $frame = $stack.Peek() + if ($frame.State -eq 'Start') { + $effective = $frame.Node + while ($effective.Kind -eq 'Alias') { + Add-YamlMergeWork -State $State.WorkState -Node $effective ` + -Operation 'fingerprint alias traversal' + $effective = $effective.Target + } + Add-YamlMergeWork -State $State.WorkState -Node $effective ` + -Operation 'fingerprint node traversal' + $frame.Node = $effective + + if ($null -ne $CandidateIndex -and + $CandidateIndex.Dependencies.Add($effective.Id)) { + $dependents = $null + if (-not $State.IndexDependents.TryGetValue( + $effective.Id, + [ref] $dependents + )) { + $dependents = [System.Collections.Generic.List[object]]::new() + $State.IndexDependents[$effective.Id] = $dependents + } + $dependents.Add($CandidateIndex) + } + + $cached = '' + if ($Cache.TryGetValue($effective.Id, [ref] $cached)) { + $frame.Holder.Value = $cached + [void] $stack.Pop() + continue + } + + if (-not $active.Add($effective.Id)) { + $tag = Get-YamlMergeNodeTag -Node $effective + $count = if ($effective.Kind -eq 'Sequence') { + $effective.Items.Count + } elseif ($effective.Kind -eq 'Mapping') { + $effective.Entries.Count + } else { + 0 + } + $frame.Holder.Value = Get-YamlFingerprintHash -Value ( + 'cycle:{0}:{1}:{2}:{3}' -f @( + $effective.Kind.ToLowerInvariant(), + $tag.Length, + $tag, + $count + ) + ) -Hasher $State.FingerprintHasher + $frame.Holder.IsContextual = $true + [void] $stack.Pop() + continue + } + + if ($effective.Kind -eq 'Scalar') { + $resolved = (Resolve-YamlScalar -Node $effective).Value + $tag = Get-YamlEffectiveTag -Node $effective -Value $resolved + $value = Get-YamlScalarFingerprint -Value $resolved ` + -Hasher $State.FingerprintHasher + $fingerprint = Get-YamlFingerprintHash -Value ( + 'scalar:{0}:{1}:{2}' -f $tag.Length, $tag, $value + ) -Hasher $State.FingerprintHasher + $Cache[$effective.Id] = $fingerprint + [void] $active.Remove($effective.Id) + $frame.Holder.Value = $fingerprint + [void] $stack.Pop() + continue + } + + if ($effective.Kind -eq 'Sequence') { + $frame.Parts = [System.Collections.Generic.List[string]]::new() + $frame.State = 'Sequence' + } else { + $frame.Accumulator = [int[]]::new(32) + $frame.State = 'MappingKey' + } + continue + } + + if ($frame.State -eq 'Sequence') { + if ($frame.Index -ge $frame.Node.Items.Count) { + $tag = Get-YamlEffectiveTag -Node $frame.Node -Value $null + $canonical = if ($frame.IsContextual) { + 'cyclic-sequence:{0}:{1}:{2}' -f @( + $tag.Length, + $tag, + $frame.Node.Items.Count + ) + } else { + 'sequence:{0}:{1}:{2}' -f $tag.Length, $tag, ($frame.Parts -join '|') + } + $fingerprint = Get-YamlFingerprintHash -Value $canonical ` + -Hasher $State.FingerprintHasher + if (-not $frame.IsContextual) { + $Cache[$frame.Node.Id] = $fingerprint + } + [void] $active.Remove($frame.Node.Id) + $frame.Holder.Value = $fingerprint + $frame.Holder.IsContextual = $frame.IsContextual + [void] $stack.Pop() + continue + } + Add-YamlMergeWork -State $State.WorkState -Node $frame.Node ` + -Operation 'fingerprint sequence entry' + $frame.Child = [pscustomobject]@{ Value = ''; IsContextual = $false } + $frame.State = 'SequenceValue' + $stack.Push([pscustomobject]@{ + Node = $frame.Node.Items[$frame.Index] + Holder = $frame.Child + State = 'Start' + Index = 0 + Parts = $null + Child = $null + KeyHash = '' + Accumulator = $null + IsContextual = $false + }) + continue + } + if ($frame.State -eq 'SequenceValue') { + $frame.Parts.Add($frame.Child.Value) + $frame.IsContextual = $frame.IsContextual -or $frame.Child.IsContextual + $frame.Index++ + $frame.State = 'Sequence' + continue + } + + if ($frame.State -eq 'MappingKey') { + if ($frame.Index -ge $frame.Node.Entries.Count) { + $tag = Get-YamlEffectiveTag -Node $frame.Node -Value $null + $canonical = if ($frame.IsContextual) { + 'cyclic-mapping:{0}:{1}:{2}' -f @( + $tag.Length, + $tag, + $frame.Node.Entries.Count + ) + } else { + $aggregate = [byte[]]::new($frame.Accumulator.Count) + for ($index = 0; $index -lt $aggregate.Count; $index++) { + $aggregate[$index] = [byte] $frame.Accumulator[$index] + } + 'mapping:{0}:{1}:{2}:{3}' -f @( + $tag.Length, + $tag, + $frame.Node.Entries.Count, + [System.Convert]::ToBase64String($aggregate) + ) + } + $fingerprint = Get-YamlFingerprintHash -Value $canonical ` + -Hasher $State.FingerprintHasher + if (-not $frame.IsContextual) { + $Cache[$frame.Node.Id] = $fingerprint + } + [void] $active.Remove($frame.Node.Id) + $frame.Holder.Value = $fingerprint + $frame.Holder.IsContextual = $frame.IsContextual + [void] $stack.Pop() + continue + } + Add-YamlMergeWork -State $State.WorkState -Node $frame.Node ` + -Operation 'fingerprint mapping entry' + $frame.Child = [pscustomobject]@{ Value = ''; IsContextual = $false } + $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 = '' + Accumulator = $null + IsContextual = $false + }) + continue + } + if ($frame.State -eq 'MappingKeyValue') { + $frame.KeyHash = $frame.Child.Value + $frame.IsContextual = $frame.IsContextual -or $frame.Child.IsContextual + $frame.Child = [pscustomobject]@{ Value = ''; IsContextual = $false } + $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 = '' + Accumulator = $null + IsContextual = $false + }) + continue + } + if ($frame.State -eq 'MappingValue') { + Add-YamlMergeWork -State $State.WorkState -Node $frame.Node ` + -Operation 'fingerprint mapping combination' + $entryHash = Get-YamlFingerprintHash -Value ( + "entry:$($frame.KeyHash)=$($frame.Child.Value)" + ) -Hasher $State.FingerprintHasher + $entryBytes = [System.Convert]::FromBase64String($entryHash) + for ($index = 0; $index -lt $entryBytes.Count; $index++) { + $frame.Accumulator[$index] = ( + $frame.Accumulator[$index] + $entryBytes[$index] + ) -band 0xff + } + $frame.IsContextual = $frame.IsContextual -or $frame.Child.IsContextual + $frame.Index++ + $frame.State = 'MappingKey' + } + } + + $root.Value +} diff --git a/src/functions/private/Get-YamlMergeIndex.ps1 b/src/functions/private/Get-YamlMergeIndex.ps1 new file mode 100644 index 0000000..33912df --- /dev/null +++ b/src/functions/private/Get-YamlMergeIndex.ps1 @@ -0,0 +1,62 @@ +function Get-YamlMergeIndex { + <# + .SYNOPSIS + Gets or rebuilds a mutation-aware structural candidate index. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [ValidateSet('Mapping', 'Sequence')] + [string] $Kind, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $effective = Get-YamlMergeNode -Node $Node + $cache = if ($Kind -eq 'Mapping') { + $Context.MappingIndexes + } else { + $Context.SequenceIndexes + } + + $index = $null + if (-not $cache.TryGetValue($effective.Id, [ref] $index)) { + $index = [pscustomobject]@{ + Kind = $Kind + IsValid = $false + Buckets = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + Dependencies = [System.Collections.Generic.HashSet[int]]::new() + FingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + IndexedEffectiveIds = [System.Collections.Generic.HashSet[int]]::new() + } + $cache[$effective.Id] = $index + } + + if ($index.IsValid) { + Write-Output -InputObject $index -NoEnumerate + return + } + + Add-YamlMergeWork -State $Context.WorkState -Node $effective -Operation 'index rebuild' + $index.Buckets.Clear() + $index.FingerprintCache.Clear() + $index.IndexedEffectiveIds.Clear() + $candidates = if ($Kind -eq 'Mapping') { + $effective.Entries + } else { + $effective.Items + } + foreach ($candidate in $candidates) { + Add-YamlMergeIndexCandidate -Index $index -Candidate $candidate -Context $Context + } + $index.IsValid = $true + + Write-Output -InputObject $index -NoEnumerate +} diff --git a/src/functions/private/Get-YamlMergeNode.ps1 b/src/functions/private/Get-YamlMergeNode.ps1 new file mode 100644 index 0000000..ff7212a --- /dev/null +++ b/src/functions/private/Get-YamlMergeNode.ps1 @@ -0,0 +1,18 @@ +function Get-YamlMergeNode { + <# + .SYNOPSIS + Resolves aliases to their effective YAML representation node. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + $effective = $Node + while ($effective.Kind -eq 'Alias') { + $effective = $effective.Target + } + Write-Output -InputObject $effective -NoEnumerate +} diff --git a/src/functions/private/Get-YamlMergeNodeTag.ps1 b/src/functions/private/Get-YamlMergeNodeTag.ps1 new file mode 100644 index 0000000..b31c184 --- /dev/null +++ b/src/functions/private/Get-YamlMergeNodeTag.ps1 @@ -0,0 +1,20 @@ +function Get-YamlMergeNodeTag { + <# + .SYNOPSIS + Gets the effective tag used for YAML merge compatibility. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + $effective = Get-YamlMergeNode -Node $Node + $value = if ($effective.Kind -eq 'Scalar') { + (Resolve-YamlScalar -Node $effective).Value + } else { + $null + } + Get-YamlEffectiveTag -Node $effective -Value $value +} diff --git a/src/functions/private/Get-YamlMergePath.ps1 b/src/functions/private/Get-YamlMergePath.ps1 new file mode 100644 index 0000000..20f7be6 --- /dev/null +++ b/src/functions/private/Get-YamlMergePath.ps1 @@ -0,0 +1,29 @@ +function Get-YamlMergePath { + <# + .SYNOPSIS + Creates a stable diagnostic path for one YAML mapping entry. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] $Parent, + + [Parameter(Mandatory)] + [pscustomobject] $Key, + + [Parameter(Mandatory)] + [int] $Index + ) + + $effective = Get-YamlMergeNode -Node $Key + if ($effective.Kind -eq 'Scalar' -and + (Get-YamlMergeNodeTag -Node $effective) -ceq 'tag:yaml.org,2002:str') { + $value = [string] (Resolve-YamlScalar -Node $effective).Value + if ($value -cmatch '^[A-Za-z_][A-Za-z0-9_-]*$') { + return "$Parent.$value" + } + return "{0}['{1}']" -f $Parent, $value.Replace("'", "''") + } + return "$Parent{key:$Index}" +} diff --git a/src/functions/private/Get-YamlNodeFingerprint.ps1 b/src/functions/private/Get-YamlNodeFingerprint.ps1 new file mode 100644 index 0000000..6147873 --- /dev/null +++ b/src/functions/private/Get-YamlNodeFingerprint.ps1 @@ -0,0 +1,180 @@ +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, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState + ) + + $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 ($null -ne $RemovalWorkState) { + Add-YamlRemovalWork -State $RemovalWorkState ` + -Operation 'duplicate-key fingerprint' -Node $effective + } + 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 + $valueFingerprint = Get-YamlScalarFingerprint -Value $resolved.Value -Hasher $Hasher + $effectiveTag = Get-YamlEffectiveTag -Node $effective -Value $resolved.Value + $fingerprint = Get-YamlFingerprintHash -Value ( + 'scalar:{0}:{1}:{2}' -f $effectiveTag.Length, $effectiveTag, $valueFingerprint + ) -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 = Get-YamlEffectiveTag -Node $frame.Node -Value $null + $canonical = 'sequence:{0}:{1}:{2}' -f @( + $semanticTag.Length, + $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 = Get-YamlEffectiveTag -Node $frame.Node -Value $null + $canonical = 'mapping:{0}:{1}:{2}' -f @( + $mappingTag.Length, + $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-YamlNormalizedFloat.ps1 b/src/functions/private/Get-YamlNormalizedFloat.ps1 new file mode 100644 index 0000000..f52e142 --- /dev/null +++ b/src/functions/private/Get-YamlNormalizedFloat.ps1 @@ -0,0 +1,95 @@ +function Get-YamlNormalizedFloat { + <# + .SYNOPSIS + Normalizes a finite CLR floating-point value to a decimal significand and exponent. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [object] $Value + ) + + $culture = [System.Globalization.CultureInfo]::InvariantCulture + if ($Value -is [decimal]) { + if ($Value -eq 0) { + return '0e0' + } + $text = $Value.ToString('G29', $culture) + } elseif ($Value -is [single]) { + $number = [single] $Value + if ([single]::IsNaN($number)) { + return 'nan' + } + if ([single]::IsPositiveInfinity($number)) { + return '+inf' + } + if ([single]::IsNegativeInfinity($number)) { + return '-inf' + } + if ($number -eq 0) { + return '0e0' + } + $text = $number.ToString('R', $culture) + } elseif ($Value -is [double]) { + $number = [double] $Value + if ([double]::IsNaN($number)) { + return 'nan' + } + if ([double]::IsPositiveInfinity($number)) { + return '+inf' + } + if ([double]::IsNegativeInfinity($number)) { + return '-inf' + } + if ($number -eq 0) { + return '0e0' + } + $text = $number.ToString('R', $culture) + } else { + throw [System.ArgumentException]::new( + "Type '$($Value.GetType().FullName)' is not a supported floating-point type.", + 'Value' + ) + } + + $match = [regex]::Match( + $text, + '^(?[-+]?)(?[0-9]+)(?:\.(?[0-9]*))?(?:[eE](?[-+]?[0-9]+))?$', + [System.Text.RegularExpressions.RegexOptions]::CultureInvariant + ) + if (-not $match.Success) { + throw [System.InvalidOperationException]::new( + "The invariant floating-point text '$text' could not be normalized." + ) + } + + $fraction = $match.Groups['fraction'].Value + $digits = ($match.Groups['integer'].Value + $fraction).TrimStart('0') + if ($digits.Length -eq 0) { + return '0e0' + } + + $exponent = 0 - $fraction.Length + if ($match.Groups['exponent'].Success) { + $exponent += [int]::Parse( + $match.Groups['exponent'].Value, + [System.Globalization.NumberStyles]::AllowLeadingSign, + $culture + ) + } + while ($digits.Length -gt 1 -and $digits[$digits.Length - 1].Equals([char] '0')) { + $digits = $digits.Substring(0, $digits.Length - 1) + $exponent++ + } + + $sign = if ($match.Groups['sign'].Value.Equals( + '-', + [System.StringComparison]::Ordinal + )) { + '-' + } else { + '' + } + return '{0}{1}e{2}' -f $sign, $digits, $exponent +} diff --git a/src/functions/private/Get-YamlRemovalNode.ps1 b/src/functions/private/Get-YamlRemovalNode.ps1 new file mode 100644 index 0000000..6f5ee2e --- /dev/null +++ b/src/functions/private/Get-YamlRemovalNode.ps1 @@ -0,0 +1,35 @@ +function Get-YamlRemovalNode { + <# + .SYNOPSIS + Resolves aliases to an effective YAML node with cycle-safe accounting. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $effective = $Node + $visitedAliases = [System.Collections.Generic.HashSet[int]]::new() + while ($effective.Kind -eq 'Alias') { + Add-YamlRemovalWork -State $State -Operation 'alias traversal' -Node $effective + if (-not $visitedAliases.Add($effective.Id)) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalAliasCycle' -Message ( + 'A YAML alias-only cycle cannot be traversed by a removal path.' + )) + } + if ($null -eq $effective.Target) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalInvalidGraph' -Message ( + 'A YAML alias in the removal graph has no target.' + )) + } + $effective = $effective.Target + } + Write-Output -InputObject $effective -NoEnumerate +} diff --git a/src/functions/private/Get-YamlRuneCount.ps1 b/src/functions/private/Get-YamlRuneCount.ps1 new file mode 100644 index 0000000..dd838c0 --- /dev/null +++ b/src/functions/private/Get-YamlRuneCount.ps1 @@ -0,0 +1,22 @@ +function Get-YamlRuneCount { + <# + .SYNOPSIS + Counts Unicode scalar values in validated YAML text. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $count = 0 + $index = 0 + while ($index -lt $Text.Length) { + $rune = [System.Text.Rune]::GetRuneAt($Text, $index) + $index += $rune.Utf16SequenceLength + $count++ + } + return $count +} diff --git a/src/functions/private/Get-YamlScalarFingerprint.ps1 b/src/functions/private/Get-YamlScalarFingerprint.ps1 new file mode 100644 index 0000000..ba4c198 --- /dev/null +++ b/src/functions/private/Get-YamlScalarFingerprint.ps1 @@ -0,0 +1,43 @@ +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] -or $Value -is [double] -or $Value -is [single]) { + $canonicalValue = 'float:{0}' -f (Get-YamlNormalizedFloat -Value $Value) + } 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..68221a2 --- /dev/null +++ b/src/functions/private/Get-YamlSerializationShape.ps1 @@ -0,0 +1,252 @@ +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, + + [switch] $InspectOnly + ) + + $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] + $isPurePropertyBag = $isCustomObject -or ( + $dataProperties.Count -gt 0 -and $Value.GetType() -eq [object] + ) + 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." + )) + } + if ($dataProperties.Count -gt 0 -and -not $isPurePropertyBag) { + throw (New-YamlSerializationException -Kind NotSupported ` + -ErrorId 'YamlMixedObjectSemantics' -Message ( + "Type '$($Value.GetType().FullName)' combines scalar data with attached note properties and cannot be represented without loss." + )) + } + $isPropertyBag = $isPurePropertyBag + + 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 = ConvertTo-YamlTimestampText -Value $Value + $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' + $scalar.Value = ConvertTo-YamlTimestampText -Value $Value + $scalar.Style = 'DoubleQuoted' + Confirm-YamlScalarLength -Node $scalar -State $State + return [pscustomobject]@{ Kind = 'Scalar'; Node = $scalar; Values = $null } + } + + if ($isByteArray) { + $base64Length = [long] 4 * [long] [System.Math]::Ceiling($Value.LongLength / 3.0) + if ($base64Length -gt $State.MaxScalarLength) { + throw (New-YamlSerializationException -ErrorId 'YamlScalarLimitExceeded' -Message ( + "A scalar exceeds the configured limit of $($State.MaxScalarLength) characters." + )) + } + if ($InspectOnly) { + return [pscustomobject]@{ Kind = 'Binary'; Node = $null; Values = $null } + } + $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) { + if ($InspectOnly) { + return [pscustomobject]@{ Kind = 'Mapping'; Node = $null; Values = $null } + } + $entries = [System.Collections.Generic.List[object]]::new() + if ($isDictionary) { + $rawEntries = Read-YamlBoundedEnumerable -Value $Value ` + -MaxNodes $State.MaxNodes -State $State -DictionaryEntries ` + -EnumsAsStrings:$EnumsAsStrings -NodesPerItem 2 + foreach ($entry in $rawEntries) { + $entries.Add([pscustomobject]@{ + Key = [object] $entry.Key + Value = [object] $entry.Value + }) + } + } else { + $availableNodes = $State.MaxNodes - $State.NodeCount - $State.ReservedNodeCount + if (($dataProperties.Count * 2) -gt $availableNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $($State.MaxNodes) nodes." + )) + } + foreach ($property in $dataProperties) { + $null = Get-YamlSerializationShape -Value $property.Name -State $State ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + $null = Get-YamlSerializationShape -Value $property.Value -State $State ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + $State.ReservedNodeCount += 2 + $entries.Add([pscustomobject]@{ + Key = [object] $property.Name + Value = [object] $property.Value + }) + } + } + return [pscustomobject]@{ + Kind = 'Mapping' + Node = $null + Values = [object[]] $entries.ToArray() + } + } + if ($isSequence) { + if ($InspectOnly) { + return [pscustomobject]@{ Kind = 'Sequence'; Node = $null; Values = $null } + } + $items = Read-YamlBoundedEnumerable -Value $Value ` + -MaxNodes $State.MaxNodes -State $State -EnumsAsStrings:$EnumsAsStrings + 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/Get-YamlTagPresentationLimit.ps1 b/src/functions/private/Get-YamlTagPresentationLimit.ps1 new file mode 100644 index 0000000..62c3847 --- /dev/null +++ b/src/functions/private/Get-YamlTagPresentationLimit.ps1 @@ -0,0 +1,26 @@ +function Get-YamlTagPresentationLimit { + <# + .SYNOPSIS + Gets an allocation-safe presentation bound for a decoded tag limit. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [switch] $Token + ) + + # One UTF-16 code unit can require three UTF-8 bytes, each written as %XX. + $limit = 9L * $Context.MaxTagLength + if ($Token) { + $maximumHandleLength = 0 + foreach ($handle in $Context.TagHandles.Keys) { + $maximumHandleLength = [Math]::Max($maximumHandleLength, $handle.Length) + } + $limit += $maximumHandleLength + 3L + } + + [int] [Math]::Min($limit, [int]::MaxValue) +} diff --git a/src/functions/private/Get-YamlTextEncoding.ps1 b/src/functions/private/Get-YamlTextEncoding.ps1 new file mode 100644 index 0000000..2792706 --- /dev/null +++ b/src/functions/private/Get-YamlTextEncoding.ps1 @@ -0,0 +1,57 @@ +function Get-YamlTextEncoding { + <# + .SYNOPSIS + Creates a strict text encoding for YAML file input or output. + + .DESCRIPTION + Returns an encoding that throws when bytes or characters cannot be + represented. The encoding preamble matches the public encoding name. + + .PARAMETER Name + The public YAML file encoding name. + + .EXAMPLE + Get-YamlTextEncoding -Name utf8 + + Returns strict UTF-8 without a byte order mark. + + .INPUTS + None. + + .OUTPUTS + System.Text.Encoding + #> + [OutputType( + [System.Text.UTF8Encoding], + [System.Text.UnicodeEncoding], + [System.Text.UTF32Encoding] + )] + [CmdletBinding()] + param ( + # Selects the strict decoder and its output preamble policy. + [Parameter(Mandatory)] + [ValidateSet('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE')] + [string] $Name + ) + + switch ($Name) { + 'utf8' { + [System.Text.UTF8Encoding]::new($false, $true) + } + 'utf8BOM' { + [System.Text.UTF8Encoding]::new($true, $true) + } + 'utf16LE' { + [System.Text.UnicodeEncoding]::new($false, $true, $true) + } + 'utf16BE' { + [System.Text.UnicodeEncoding]::new($true, $true, $true) + } + 'utf32LE' { + [System.Text.UTF32Encoding]::new($false, $true, $true) + } + 'utf32BE' { + [System.Text.UTF32Encoding]::new($true, $true, $true) + } + } +} diff --git a/src/functions/private/Merge-YamlRepresentationNode.ps1 b/src/functions/private/Merge-YamlRepresentationNode.ps1 new file mode 100644 index 0000000..3ce15cc --- /dev/null +++ b/src/functions/private/Merge-YamlRepresentationNode.ps1 @@ -0,0 +1,180 @@ +function Merge-YamlRepresentationNode { + <# + .SYNOPSIS + Merges one later YAML representation node into an existing cloned node. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Mutates only an isolated in-memory merge graph.' + )] + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $BaseNode, + + [Parameter(Mandatory)] + [pscustomobject] $OverlayNode, + + [Parameter(Mandatory)] + [ValidateSet('Replace', 'Append', 'Unique')] + [string] $SequenceAction, + + [Parameter(Mandatory)] + [ValidateSet('Replace', 'Error')] + [string] $ConflictAction, + + [Parameter(Mandatory)] + [ValidateSet('Replace', 'Ignore')] + [string] $NullAction, + + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + Add-YamlMergeWork -State $Context.WorkState -Node $OverlayNode ` + -Operation 'representation node merge' + + $base = Get-YamlMergeNode -Node $BaseNode + $overlay = Get-YamlMergeNode -Node $OverlayNode + $overlayTag = Get-YamlMergeNodeTag -Node $overlay + if ($NullAction -eq 'Ignore' -and + $overlayTag -ceq 'tag:yaml.org,2002:null') { + Write-Output -InputObject $BaseNode -NoEnumerate + return + } + + $baseTag = Get-YamlMergeNodeTag -Node $base + $isCompatible = $base.Kind -ceq $overlay.Kind -and $baseTag -ceq $overlayTag + if (-not $isCompatible) { + if ($ConflictAction -eq 'Error') { + throw (New-YamlMergeException -Node $overlay -ErrorId 'YamlMergeConflict' -Message ( + "YAML merge conflict at $Path in input index $($Context.InputIndex), " + + "document index $($Context.DocumentIndex): $($base.Kind.ToLowerInvariant()) " + + "tag '$baseTag' conflicts with $($overlay.Kind.ToLowerInvariant()) tag '$overlayTag'." + )) + } + return Copy-YamlMergeNode -Node $OverlayNode -Cache $Context.CloneCache ` + -State $Context.CloneState + } + + if ($base.Kind -eq 'Scalar') { + if (Test-YamlMergeNodeEqual -Node $base -OtherNode $overlay ` + -State $Context.EqualityState) { + Write-Output -InputObject $BaseNode -NoEnumerate + return + } + if ($ConflictAction -eq 'Error') { + throw (New-YamlMergeException -Node $overlay -ErrorId 'YamlMergeConflict' -Message ( + "YAML merge conflict at $Path in input index $($Context.InputIndex), " + + "document index $($Context.DocumentIndex): unequal scalar values use tag '$baseTag'." + )) + } + return Copy-YamlMergeNode -Node $OverlayNode -Cache $Context.CloneCache ` + -State $Context.CloneState + } + + if ($base.Kind -eq 'Sequence' -and $SequenceAction -eq 'Replace') { + if (Test-YamlMergeNodeEqual -Node $base -OtherNode $overlay ` + -State $Context.EqualityState) { + if (-not $Context.CloneCache.ContainsKey($overlay.Id)) { + $Context.CloneCache[$overlay.Id] = $base + } + Write-Output -InputObject $BaseNode -NoEnumerate + return + } + return Copy-YamlMergeNode -Node $OverlayNode -Cache $Context.CloneCache ` + -State $Context.CloneState + } + + if ($Context.CloneCache.ContainsKey($overlay.Id)) { + if ($OverlayNode.Kind -eq 'Alias') { + return Copy-YamlMergeNode -Node $OverlayNode -Cache $Context.CloneCache ` + -State $Context.CloneState + } + Write-Output -InputObject $Context.CloneCache[$overlay.Id] -NoEnumerate + return + } + + if (Test-YamlMergeNodeEqual -Node $base -OtherNode $overlay ` + -State $Context.EqualityState) { + $Context.CloneCache[$overlay.Id] = $base + Write-Output -InputObject $BaseNode -NoEnumerate + return + } + $Context.CloneCache[$overlay.Id] = $base + + if ($base.Kind -eq 'Sequence') { + if ($SequenceAction -eq 'Append') { + $changed = $false + foreach ($item in $overlay.Items) { + $copy = Copy-YamlMergeNode -Node $item -Cache $Context.CloneCache ` + -State $Context.CloneState + $base.Items.Add($copy) + $changed = $true + } + if ($changed) { + Set-YamlMergeNodeChanged -Node $base -Context $Context + } + Write-Output -InputObject $BaseNode -NoEnumerate + return + } + + $overlayItems = [System.Collections.Generic.HashSet[int]]::new() + foreach ($item in $overlay.Items) { + Add-YamlMergeWork -State $Context.WorkState -Node $item ` + -Operation 'unique overlay candidate identity' + $effectiveItem = Get-YamlMergeNode -Node $item + if (-not $overlayItems.Add($effectiveItem.Id)) { + continue + } + $retained = Get-YamlMergeIndex -Node $base -Kind Sequence -Context $Context + $match = Find-YamlMergeIndexMatch -Index $retained -Node $item -Context $Context + if ($null -ne $match) { + continue + } + $copy = Copy-YamlMergeNode -Node $item -Cache $Context.CloneCache ` + -State $Context.CloneState + $base.Items.Add($copy) + Add-YamlMergeIndexCandidate -Index $retained -Candidate $copy -Context $Context + Set-YamlMergeNodeChanged -Node $base -Context $Context + } + Write-Output -InputObject $BaseNode -NoEnumerate + return + } + + for ($index = 0; $index -lt $overlay.Entries.Count; $index++) { + $overlayEntry = $overlay.Entries[$index] + $entries = Get-YamlMergeIndex -Node $base -Kind Mapping -Context $Context + $match = Find-YamlMergeIndexMatch -Index $entries -Node $overlayEntry.Key ` + -Context $Context + + if ($null -ne $match) { + $childPath = Get-YamlMergePath -Parent $Path -Key $overlayEntry.Key -Index $index + $previous = $match.Value + $merged = Merge-YamlRepresentationNode -BaseNode $previous ` + -OverlayNode $overlayEntry.Value -SequenceAction $SequenceAction ` + -ConflictAction $ConflictAction -NullAction $NullAction -Path $childPath ` + -Context $Context + $match.Value = $merged + if (-not [object]::ReferenceEquals($previous, $merged)) { + Set-YamlMergeNodeChanged -Node $base -Context $Context + } + continue + } + + $keyCopy = Copy-YamlMergeNode -Node $overlayEntry.Key -Cache $Context.CloneCache ` + -State $Context.CloneState + $valueCopy = Copy-YamlMergeNode -Node $overlayEntry.Value -Cache $Context.CloneCache ` + -State $Context.CloneState + $newEntry = [pscustomobject]@{ Key = $keyCopy; Value = $valueCopy } + $base.Entries.Add($newEntry) + Add-YamlMergeIndexCandidate -Index $entries -Candidate $newEntry -Context $Context + Set-YamlMergeNodeChanged -Node $base -Context $Context + } + + Write-Output -InputObject $BaseNode -NoEnumerate +} 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..277b770 --- /dev/null +++ b/src/functions/private/New-YamlEmissionNode.ps1 @@ -0,0 +1,31 @@ +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 = '' + HasUnknownTag = $false + 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-YamlMergeException.ps1 b/src/functions/private/New-YamlMergeException.ps1 new file mode 100644 index 0000000..80e43fe --- /dev/null +++ b/src/functions/private/New-YamlMergeException.ps1 @@ -0,0 +1,30 @@ +function New-YamlMergeException { + <# + .SYNOPSIS + Creates a classified exception for a YAML merge failure. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory exception without changing system state.' + )] + [CmdletBinding()] + [OutputType([System.FormatException])] + param ( + [Parameter(Mandatory)] + [string] $ErrorId, + + [Parameter(Mandatory)] + [string] $Message, + + [Parameter()] + [AllowNull()] + [pscustomobject] $Node + ) + + if ($null -eq $Node) { + $mark = New-YamlMark -Index 0 -Line 0 -Column 0 + return New-YamlException -Start $mark -End $mark -ErrorId $ErrorId -Message $Message + } + + return New-YamlException -Start $Node.Start -End $Node.End -ErrorId $ErrorId -Message $Message +} 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..de48ca6 --- /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 + ) + + $text = $Yaml.Replace("`r`n", "`n").Replace("`r", "`n") + Assert-YamlText -Yaml $text + $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-YamlRemovalException.ps1 b/src/functions/private/New-YamlRemovalException.ps1 new file mode 100644 index 0000000..82853ad --- /dev/null +++ b/src/functions/private/New-YamlRemovalException.ps1 @@ -0,0 +1,31 @@ +function New-YamlRemovalException { + <# + .SYNOPSIS + Creates a classified exception for a YAML removal failure. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory exception without changing system state.' + )] + [CmdletBinding()] + [OutputType([System.FormatException])] + param ( + [Parameter(Mandatory)] + [string] $ErrorId, + + [Parameter(Mandatory)] + [string] $Message, + + [Parameter()] + [AllowNull()] + [pscustomobject] $Node + ) + + if ($null -eq $Node) { + $mark = New-YamlMark -Index 0 -Line 0 -Column 0 + return New-YamlException -Start $mark -End $mark -ErrorId $ErrorId -Message $Message + } + + return New-YamlException -Start $Node.Start -End $Node.End ` + -ErrorId $ErrorId -Message $Message +} 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..2c0a208 --- /dev/null +++ b/src/functions/private/Read-YamlBlockKey.ps1 @@ -0,0 +1,91 @@ +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 -InImplicitKey + $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 + } + + Assert-YamlNoByteOrderMark -Text $rest -Mark $start + $first = $rest[0] + if ($first -in @(',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') -or + ($first -in @('-', '?', ':') -and ( + $rest.Length -gt 1 -and (Test-YamlWhiteSpace -Character $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(' ', "`t") + $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..3ef255e --- /dev/null +++ b/src/functions/private/Read-YamlBlockMapping.ps1 @@ -0,0 +1,308 @@ +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]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + break + } + if ($trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + $column = $line.Length - $trimmed.Length + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $column) ` + -Line $lineNumber -Column $column + Assert-YamlNoByteOrderMark -Text $trimmed -Mark $mark + $Context.LineIndex++ + continue + } + if ($trimmed.Length -eq 0) { + $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(' ', "`t").Length + $separator = $afterQuestion.Substring(0, $leading) + $keyText = $afterQuestion.TrimStart(' ', "`t") + $keyColumn = $contentColumn + 1 + $leading + if ($separator.IndexOf("`t", [System.StringComparison]::Ordinal) -ge 0 -and + ( + (Test-YamlIndicator -Text $keyText -Indicator '-') -or + (Test-YamlIndicator -Text $keyText -Indicator '?') -or + (Find-YamlMappingColon -Text $keyText -AllowAnchorFallback) -ge 0 + )) { + $mark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn + 1) ` + -Line $lineNumber -Column ($contentColumn + 1) + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidIndentation' -Message ( + 'A tab cannot separate a mapping indicator from a compact block collection.' + )) + } + $keyMark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $keyColumn) ` + -Line $lineNumber -Column $keyColumn + if ([string]::IsNullOrEmpty(( + Get-YamlContentWithoutComment -Text $keyText -Mark $keyMark + ))) { + $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(' ', "`t") + $dashColumn = $keyColumn + $itemColumn = $dashColumn + 1 + ( + $keyText.Substring(1).Length - + $keyText.Substring(1).TrimStart(' ', "`t").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 ':')) { + $valueSource = $valueContent.Substring(1) + $leading = $valueSource.Length - $valueSource.TrimStart(' ', "`t").Length + $separator = $valueSource.Substring(0, $leading) + $valueText = $valueSource.TrimStart(' ', "`t") + $valueColumn = $Indent + 1 + $leading + if ($separator.IndexOf("`t", [System.StringComparison]::Ordinal) -ge 0 -and + ( + (Test-YamlIndicator -Text $valueText -Indicator '-') -or + (Test-YamlIndicator -Text $valueText -Indicator '?') -or + (Find-YamlMappingColon -Text $valueText -AllowAnchorFallback) -ge 0 + )) { + $mark = New-YamlMark -Index ( + $Context.LineStarts[$Context.LineIndex] + $Indent + 1 + ) -Line $Context.LineIndex -Column ($Indent + 1) + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlInvalidIndentation' -Message ( + 'A tab cannot separate a mapping indicator from a compact block collection.' + )) + } + $valueMark = New-YamlMark -Index ( + $Context.LineStarts[$Context.LineIndex] + $valueColumn + ) -Line $Context.LineIndex -Column $valueColumn + if ([string]::IsNullOrEmpty(( + Get-YamlContentWithoutComment -Text $valueText -Mark $valueMark + ))) { + $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 -AllowAnchorFallback + 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 -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(' ', "`t") + $key = Read-YamlBlockKey -Context $Context -Text $keyText -Line $lineNumber ` + -Column $contentColumn -Depth ($Depth + 1) + } + if ($colon -gt 0 -and + (Get-YamlRuneCount -Text $content.Substring(0, $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 Unicode scalar values.' + )) + } + + $valueStart = $colon + 1 + $valueSource = $content.Substring($valueStart) + $leading = $valueSource.Length - $valueSource.TrimStart(' ', "`t").Length + $valueText = $valueSource.TrimStart(' ', "`t") + $valueColumn = $contentColumn + $valueStart + $leading + $valueMark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $valueColumn) ` + -Line $lineNumber -Column $valueColumn + if ([string]::IsNullOrEmpty(( + Get-YamlContentWithoutComment -Text $valueText -Mark $valueMark + ))) { + $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 ` + -DisallowCompactMapping + } + $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..e160baa --- /dev/null +++ b/src/functions/private/Read-YamlBlockNode.ps1 @@ -0,0 +1,194 @@ +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, + + [switch] $DisallowCompactMapping + ) + + $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]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + $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 $DisallowCompactMapping -and + -not (Test-YamlIndicator -Text $Segment -Indicator '-') -and + ((Find-YamlMappingColon -Text $Segment -AllowAnchorFallback) -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 $DisallowCompactMapping -and + -not (Test-YamlIndicator -Text $Segment -Indicator '-') -and + ((Find-YamlMappingColon -Text $Segment -AllowAnchorFallback) -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 + $contentColumn = $SegmentColumn + $properties.Consumed + $restMark = New-YamlMark -Index ($Context.LineStarts[$lineNumber] + $contentColumn) ` + -Line $lineNumber -Column $contentColumn + $rest = Get-YamlContentWithoutComment -Text $restSource -Mark $restMark + + if ([string]::IsNullOrEmpty($rest)) { + $Context.LineIndex++ + Skip-YamlBlockTrivia -Context $Context + if ($Context.LineIndex -ge $Context.Lines.Count -or + $Context.Lines[$Context.LineIndex] -match '^(?:---|\.\.\.)(?:[ \t]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + $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(' ', "`t") -ceq '-') { + $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(' ', "`t").Length + $first = $first.TrimStart(' ', "`t") + return Read-YamlBlockSequence -Context $Context -Indent $contentColumn -Depth $Depth -Tag $tag ` + -HasUnknownTag $unknownTag -Anchor $anchor -FirstItemText $first ` + -FirstItemColumn ($contentColumn + 1 + $leading) + } + + if (-not $DisallowCompactMapping -and ( + (Find-YamlMappingColon -Text $rest -AllowAnchorFallback) -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..970c0d3 --- /dev/null +++ b/src/functions/private/Read-YamlBlockScalar.ps1 @@ -0,0 +1,229 @@ +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 = '' + ) + + $headerMark = New-YamlMark -Index ( + $Context.LineStarts[$Context.LineIndex] + $HeaderColumn + ) -Line $Context.LineIndex -Column $HeaderColumn + Assert-YamlNoByteOrderMark -Text $Header -Mark $headerMark + 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]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + break + } + Assert-YamlNoByteOrderMark -Text $line -Mark $start + 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..29ae86d --- /dev/null +++ b/src/functions/private/Read-YamlBlockSequence.ps1 @@ -0,0 +1,118 @@ +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]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + break + } + if ($trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + $column = $line.Length - $trimmed.Length + $mark = New-YamlMark -Index ($Context.LineStarts[$Context.LineIndex] + $column) ` + -Line $Context.LineIndex -Column $column + Assert-YamlNoByteOrderMark -Text $trimmed -Mark $mark + $Context.LineIndex++ + continue + } + if ($trimmed.Length -eq 0) { + $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(' ', "`t") + $itemColumn = $Indent + 1 + ( + $content.Substring(1).Length - + $content.Substring(1).TrimStart(' ', "`t").Length + ) + } + + $restMark = New-YamlMark -Index ( + $Context.LineStarts[$Context.LineIndex] + $itemColumn + ) -Line $Context.LineIndex -Column $itemColumn + if ([string]::IsNullOrEmpty(( + Get-YamlContentWithoutComment -Text $rest -Mark $restMark + ))) { + $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]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + $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-YamlBoundedEnumerable.ps1 b/src/functions/private/Read-YamlBoundedEnumerable.ps1 new file mode 100644 index 0000000..6bf9493 --- /dev/null +++ b/src/functions/private/Read-YamlBoundedEnumerable.ps1 @@ -0,0 +1,62 @@ +function Read-YamlBoundedEnumerable { + <# + .SYNOPSIS + Materializes only as many enumerable items as the node budget permits. + #> + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[object]])] + param ( + [Parameter(Mandatory)] + [System.Collections.IEnumerable] $Value, + + [Parameter(Mandatory)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [pscustomobject] $State, + + [switch] $DictionaryEntries, + + [switch] $EnumsAsStrings, + + [ValidateRange(1, 2)] + [int] $NodesPerItem = 1 + ) + + $availableNodes = $State.MaxNodes - $State.NodeCount - $State.ReservedNodeCount + if ($Value -is [System.Collections.ICollection] -and + ([long] $Value.Count * $NodesPerItem) -gt $availableNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $MaxNodes nodes." + )) + } + + $items = [System.Collections.Generic.List[object]]::new() + $enumerator = $Value.GetEnumerator() + try { + while ($enumerator.MoveNext()) { + if (($State.NodeCount + $State.ReservedNodeCount + $NodesPerItem) -gt $MaxNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $MaxNodes nodes." + )) + } + $item = [object] $enumerator.Current + if ($DictionaryEntries) { + $null = Get-YamlSerializationShape -Value $item.Key -State $State ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + $null = Get-YamlSerializationShape -Value $item.Value -State $State ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + } else { + $null = Get-YamlSerializationShape -Value $item -State $State ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + } + $State.ReservedNodeCount += $NodesPerItem + $items.Add($item) + } + } finally { + if ($enumerator -is [System.IDisposable]) { + $enumerator.Dispose() + } + } + Write-Output -InputObject $items -NoEnumerate +} diff --git a/src/functions/private/Read-YamlDirectiveBlock.ps1 b/src/functions/private/Read-YamlDirectiveBlock.ps1 new file mode 100644 index 0000000..aad4309 --- /dev/null +++ b/src/functions/private/Read-YamlDirectiveBlock.ps1 @@ -0,0 +1,91 @@ +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 + Assert-YamlNoByteOrderMark -Text $directive -Mark $mark + if ($directive -cmatch '^%YAML(?:[ \t]|$)') { + if ($yamlDirectiveSeen -or $directive -cnotmatch ( + '^%YAML[ \t]+([0-9]+)\.([0-9]+)(?:[ \t]+#.*)?$' + ) -or -not $Matches[1].Equals('1', [System.StringComparison]::Ordinal)) { + 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 (Get-YamlTagPresentationLimit -Context $Context)) { + throw (New-YamlException -Start $mark -End $mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag prefix cannot fit the configured limit of $($Context.MaxTagLength) decoded characters." + )) + } + if (-not $prefix.Equals('!', [System.StringComparison]::Ordinal) -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." + )) + } + $null = ConvertFrom-YamlTagUriEscape -Text $prefix -Mark $mark -Token $prefix ` + -MaxLength $Context.MaxTagLength + $tagHandles[$handle] = $prefix + } elseif (-not (Test-YamlReservedDirective -Directive $directive)) { + 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-YamlDocumentByteOrderMark.ps1 b/src/functions/private/Read-YamlDocumentByteOrderMark.ps1 new file mode 100644 index 0000000..e44bc2b --- /dev/null +++ b/src/functions/private/Read-YamlDocumentByteOrderMark.ps1 @@ -0,0 +1,46 @@ +function Read-YamlDocumentByteOrderMark { + <# + .SYNOPSIS + Consumes a byte order mark while scanning an actual document prefix. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Advances an in-memory parser context.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [switch] $RequireDocumentStart, + + [switch] $SkipValidation + ) + + if ($Context.LineIndex -ge $Context.Lines.Count) { + return + } + $index = $Context.LineStarts[$Context.LineIndex] + $atStreamStart = $index -eq 0 + if (-not $atStreamStart -and -not $SkipValidation -and + -not (Test-YamlDocumentByteOrderMark -Context $Context ` + -RequireDocumentStart:$RequireDocumentStart)) { + return + } + if (-not $Context.Lines[$Context.LineIndex].StartsWith( + [string] [char] 0xFEFF, + [System.StringComparison]::Ordinal + )) { + return + } + + $line = $Context.Lines[$Context.LineIndex] + $count = 0 + while ($count -lt $line.Length -and $line[$count] -ceq [char] 0xFEFF) { + $count++ + } + + # Keep the source immutable and advance only this line's physical source offset. + $Context.Lines[$Context.LineIndex] = $line.Substring($count) + $Context.LineStarts[$Context.LineIndex] += $count +} diff --git a/src/functions/private/Read-YamlFlowNode.ps1 b/src/functions/private/Read-YamlFlowNode.ps1 new file mode 100644 index 0000000..99aa69d --- /dev/null +++ b/src/functions/private/Read-YamlFlowNode.ps1 @@ -0,0 +1,637 @@ +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 = '', + + [switch] $InFlowCollection, + + [switch] $InImplicitKey + ) + + 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 (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index]) -and + $Context.Text[$Cursor.Index] -cne "`n" -and + $Context.Text[$Cursor.Index] -notin @(',', '[', ']', '{', '}')) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + } + if ($Cursor.Index - $tokenStart -gt ( + Get-YamlTagPresentationLimit -Context $Context -Token + )) { + throw (New-YamlException -Start $start -End $start -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag token cannot fit the configured limit of $($Context.MaxTagLength) decoded 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 (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index]) -and + $Context.Text[$Cursor.Index] -cne "`n" -and + $Context.Text[$Cursor.Index] -notin @(',', '[', ']', '{', '}') -and + -not (($InFlowCollection -or $InImplicitKey) -and + (Test-YamlMappingValueIndicator -Text $Context.Text ` + -Index $Cursor.Index -Flow:$InFlowCollection))) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + $anchor = $Context.Text.Substring($anchorStart, $Cursor.Index - $anchorStart) + Assert-YamlNoByteOrderMark -Text $anchor -Mark $start + 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 (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index]) -and + $Context.Text[$Cursor.Index] -cne "`n" -and + $Context.Text[$Cursor.Index] -notin @(',', '[', ']', '{', '}') -and + -not (($InFlowCollection -or $InImplicitKey) -and + (Test-YamlMappingValueIndicator -Text $Context.Text ` + -Index $Cursor.Index -Flow:$InFlowCollection))) { + Move-YamlCursor -Cursor $Cursor -Context $Context + } + $alias = $Context.Text.Substring($aliasStart, $Cursor.Index - $aliasStart) + Assert-YamlNoByteOrderMark -Text $alias -Mark $start + $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 + (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index + 1]) -or + $Context.Text[$Cursor.Index + 1] -ceq "`n")) { + $explicitPair = $true + Move-YamlCursor -Cursor $Cursor -Context $Context + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + } + if ($Cursor.Index -lt $Context.Text.Length -and + (Test-YamlMappingValueIndicator -Text $Context.Text -Index $Cursor.Index -Flow)) { + $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) ` + -InFlowCollection + } + $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 + (Get-YamlImplicitKeyLength -Node $item -Context $Context ` + -EndIndex $Cursor.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 Unicode scalar values.' + )) + } + $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) ` + -InFlowCollection + } + $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 + (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index + 1]) -or + $Context.Text[$Cursor.Index + 1] -ceq "`n")) { + $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) ` + -InFlowCollection + } + } + Skip-YamlFlowTrivia -Cursor $Cursor -Context $Context + 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) ` + -InFlowCollection + } + } + $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 -cin @('---', '...') -and + ($Cursor.Index + 3 -eq $Context.Text.Length -or + (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index + 3]) -or + $Context.Text[$Cursor.Index + 3] -ceq "`n")) { + $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() + $pendingWhiteSpace = [System.Text.StringBuilder]::new() + $pendingBreaks = 0 + $lastContentLine = $Cursor.Line + $firstPlainCharacter = $Context.Text[$Cursor.Index] + if ($firstPlainCharacter -in @(',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') -or + ($firstPlainCharacter -in @('-', '?', ':') -and ( + $Cursor.Index + 1 -ge $Context.Text.Length -or + (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index + 1]) -or + $Context.Text[$Cursor.Index + 1] -ceq "`n" -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 -ceq [char] 0xFEFF) { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidByteOrderMark' -Message ( + 'A raw YAML byte order mark is only allowed in a quoted scalar or document prefix.' + )) + } + if ($character -in @(',', '[', ']', '{', '}')) { + break + } + if ($InFlowCollection -and + (Test-YamlMappingValueIndicator -Text $Context.Text -Index $Cursor.Index -Flow)) { + break + } + if ($character -eq '#' -and + ($Cursor.Index -eq 0 -or + (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index - 1]) -or + $Context.Text[$Cursor.Index - 1] -ceq "`n")) { + break + } + if (Test-YamlWhiteSpace -Character $character) { + [void] $pendingWhiteSpace.Append($character) + Move-YamlCursor -Cursor $Cursor -Context $Context + continue + } + if ($character -eq "`n") { + $null = $pendingWhiteSpace.Clear() + $pendingBreaks = 0 + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -ceq "`n") { + $pendingBreaks++ + Move-YamlCursor -Cursor $Cursor -Context $Context + $indentSpaces = 0 + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -ceq ' ') { + $indentSpaces++ + Move-YamlCursor -Cursor $Cursor -Context $Context + } + while ($Cursor.Index -lt $Context.Text.Length -and + $Context.Text[$Cursor.Index] -ceq "`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 ($pendingBreaks -gt 0 -and $builder.Length -gt 0) { + [void] $builder.Append( + $(if ($pendingBreaks -eq 1) { ' ' } else { "`n" * ($pendingBreaks - 1) }) + ) + } elseif ($pendingWhiteSpace.Length -gt 0 -and $builder.Length -gt 0) { + [void] $builder.Append($pendingWhiteSpace) + } + $pendingBreaks = 0 + $null = $pendingWhiteSpace.Clear() + [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(' ', "`t") + 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..210c1da --- /dev/null +++ b/src/functions/private/Read-YamlNodeProperty.ps1 @@ -0,0 +1,115 @@ +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 + (Test-YamlWhiteSpace -Character $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 (Test-YamlWhiteSpace -Character $Text[$position]) -and + $Text[$position] -notin @(',', '[', ']', '{', '}')) { + $position++ + } + } + if ($position - $start -gt ( + Get-YamlTagPresentationLimit -Context $Context -Token + )) { + $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 cannot fit the configured limit of $($Context.MaxTagLength) decoded 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 (Test-YamlWhiteSpace -Character $Text[$position]) -and + $Text[$position] -notin @(',', '[', ']', '{', '}')) { + $position++ + } + $anchor = $Text.Substring($start, $position - $start) + Assert-YamlNoByteOrderMark -Text $anchor -Mark ( + New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $start) -Line $Line ` + -Column ($Column + $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(' ', "`t") + Consumed = $position + ( + $Text.Substring($position).Length - + $Text.Substring($position).TrimStart(' ', "`t").Length + ) + } +} diff --git a/src/functions/private/Read-YamlPlainScalar.ps1 b/src/functions/private/Read-YamlPlainScalar.ps1 new file mode 100644 index 0000000..ff4507c --- /dev/null +++ b/src/functions/private/Read-YamlPlainScalar.ps1 @@ -0,0 +1,134 @@ +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() + $firstComment = Find-YamlCommentStart -Text $FirstText + $firstValue = Get-YamlContentWithoutComment -Text $FirstText -Mark $start + $firstValue = $firstValue.Trim(' ', "`t") + Assert-YamlNoByteOrderMark -Text $firstValue -Mark $start + $firstCharacter = if ($firstValue.Length -gt 0) { $firstValue[0] } else { [char] 0 } + $forbiddenFirst = $firstCharacter -in @( + ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`' + ) + $conditionalIndicator = $firstCharacter -in @('-', '?', ':') + if ($forbiddenFirst -or ( + $conditionalIndicator -and ( + $firstValue.Length -eq 1 -or (Test-YamlWhiteSpace -Character $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]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $Context -RequireDocumentStart)) { + 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) + $comment = Find-YamlCommentStart -Text $sourceContent + $contentMark = New-YamlMark -Index ($Context.LineStarts[$Context.LineIndex] + $indent) ` + -Line $Context.LineIndex -Column $indent + $content = Get-YamlContentWithoutComment -Text $sourceContent -Mark $contentMark + if ((Find-YamlMappingColon -Text $content) -ge 0) { + break + } + $trimmedContent = $content.Trim(' ', "`t") + Assert-YamlNoByteOrderMark -Text $trimmedContent -Mark $start + $separatorLength = if ($pendingBreaks -gt 0) { $pendingBreaks } else { 1 } + if ($decodedLength + $separatorLength + $trimmedContent.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." + )) + } + if ($pendingBreaks -gt 0) { + $parts.Add("`n" * $pendingBreaks) + $decodedLength += $pendingBreaks + $pendingBreaks = 0 + } else { + $parts.Add(' ') + $decodedLength++ + } + $parts.Add($trimmedContent) + $decodedLength += $trimmedContent.Length + $Context.LineIndex++ + if ($comment -ge 0) { + break + } + } + + $value = -join $parts + $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 = 'Plain' + $node.IsPlainImplicit = [string]::IsNullOrEmpty($Tag) -and -not $HasUnknownTag + return $node +} diff --git a/src/functions/private/Read-YamlStream.ps1 b/src/functions/private/Read-YamlStream.ps1 new file mode 100644 index 0000000..9aefc0a --- /dev/null +++ b/src/functions/private/Read-YamlStream.ps1 @@ -0,0 +1,45 @@ +function Read-YamlStream { + <# + .SYNOPSIS + Parses YAML text without text repair or external parser dependencies. + #> + [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..3e8087b --- /dev/null +++ b/src/functions/private/Read-YamlStreamCore.ps1 @@ -0,0 +1,202 @@ +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 + $lines = $context.Lines + $lineStarts = $context.LineStarts + $documents = [System.Collections.Generic.List[object]]::new() + $implicitDocumentSeen = $false + + while ($context.LineIndex -lt $lines.Count) { + Skip-YamlDocumentPrefix -Context $context + if ($context.LineIndex -ge $lines.Count) { + break + } + if ($lines[$context.LineIndex] -match '^\.\.\.(?:[ \t]|$)') { + $mark = New-YamlMark -Index ($lineStarts[$context.LineIndex] + 3) ` + -Line $context.LineIndex -Column 3 + $suffix = Get-YamlContentWithoutComment ` + -Text $lines[$context.LineIndex].Substring(3) -Mark $mark + if ($suffix.Trim(' ', "`t").Length -gt 0) { + 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 $context.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(' ', "`t").Length + $segment = $afterMarker.TrimStart(' ', "`t") + $segmentColumn = 3 + $leading + $segmentMark = New-YamlMark -Index ( + $lineStarts[$context.LineIndex] + $segmentColumn + ) -Line $context.LineIndex -Column $segmentColumn + if ([string]::IsNullOrEmpty(( + Get-YamlContentWithoutComment -Text $segment -Mark $segmentMark + ))) { + $markerLine = $context.LineIndex + $context.LineIndex++ + Skip-YamlBlockTrivia -Context $context + if ($context.LineIndex -ge $lines.Count -or + $lines[$context.LineIndex] -match '^(?:---|\.\.\.)(?:[ \t]|$)' -or + (Test-YamlDocumentByteOrderMark -Context $context -RequireDocumentStart)) { + $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 -AllowAnchorFallback) -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 -AllowAnchorFallback) -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(' ', "`t").Length + $document = Read-YamlBlockSequence -Context $context -Indent 0 -Depth 1 ` + -FirstItemText $firstItem.TrimStart(' ', "`t") ` + -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-YamlDocumentPrefix -Context $context -RequireDocumentStart + $explicitEnd = $false + if ($context.LineIndex -lt $lines.Count -and + $lines[$context.LineIndex] -match '^\.\.\.(?:[ \t]|$)') { + $explicitEnd = $true + $endLine = $lines[$context.LineIndex] + $endMark = New-YamlMark -Index ($lineStarts[$context.LineIndex] + 3) ` + -Line $context.LineIndex -Column 3 + if ((Get-YamlContentWithoutComment -Text $endLine.Substring(3) -Mark $endMark). + Trim(' ', "`t").Length -gt 0) { + throw (New-YamlException -Start $endMark -End $endMark ` + -ErrorId 'YamlInvalidDocumentEnd' -Message ( + 'Unexpected content follows the document end marker.' + )) + } + $context.LineIndex++ + Skip-YamlDocumentPrefix -Context $context + $implicitDocumentSeen = $false + } + if (-not $explicitEnd -and $context.LineIndex -lt $lines.Count -and + $lines[$context.LineIndex] -notmatch '^---(?:[ \t]|$)' -and + $lines[$context.LineIndex].Trim(' ', "`t").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/Remove-YamlRepresentationTarget.ps1 b/src/functions/private/Remove-YamlRepresentationTarget.ps1 new file mode 100644 index 0000000..7c7f555 --- /dev/null +++ b/src/functions/private/Remove-YamlRepresentationTarget.ps1 @@ -0,0 +1,105 @@ +function Remove-YamlRepresentationTarget { + <# + .SYNOPSIS + Applies resolved YAML removal targets in stable mutation order. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Mutates only an isolated in-memory representation graph.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Collections.Generic.List[object]] $Documents, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Targets, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + if ($Targets.Count -eq 0) { + return + } + Add-YamlRemovalWork -State $State -Count $Targets.Count -Operation 'target ordering' + $groups = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($target in $Targets) { + $groupKey = if ($target.Kind -ceq 'Document') { + 'Documents' + } else { + "Node:$($target.Parent.Id)" + } + if (-not $groups.ContainsKey($groupKey)) { + $groups[$groupKey] = [pscustomobject]@{ + Key = $groupKey + Depth = [int] $target.Depth + Targets = [System.Collections.Generic.List[object]]::new() + } + } elseif ($target.Depth -gt $groups[$groupKey].Depth) { + $groups[$groupKey].Depth = [int] $target.Depth + } + $groups[$groupKey].Targets.Add($target) + } + $orderedGroups = @( + $groups.Values | Sort-Object -Property @( + @{ Expression = { [int] $_.Depth }; Descending = $true } + @{ Expression = { [string] $_.Key }; Ascending = $true } + ) + ) + + foreach ($group in $orderedGroups) { + $orderedTargets = @( + $group.Targets | Sort-Object -Property @( + @{ Expression = { [int] $_.Index }; Descending = $true } + @{ Expression = { [string] $_.Kind }; Ascending = $true } + ) + ) + foreach ($target in $orderedTargets) { + Add-YamlRemovalWork -State $State -Operation 'target mutation' -Node $target.Node + if ($target.Kind -ceq 'Document') { + if ($target.Index -ge $Documents.Count -or -not [object]::ReferenceEquals( + $Documents[$target.Index], + $target.Node + )) { + throw (New-YamlRemovalException -Node $target.Node ` + -ErrorId 'YamlRemovalMutationConflict' -Message ( + 'A resolved YAML document target changed before mutation.' + )) + } + $Documents.RemoveAt($target.Index) + continue + } + + if ($target.Kind -ceq 'Sequence') { + if ($target.Index -ge $target.Parent.Items.Count -or + -not [object]::ReferenceEquals( + $target.Parent.Items[$target.Index], + $target.Edge + )) { + throw (New-YamlRemovalException -Node $target.Node ` + -ErrorId 'YamlRemovalMutationConflict' -Message ( + 'A resolved YAML sequence target changed before mutation.' + )) + } + $target.Parent.Items.RemoveAt($target.Index) + continue + } + + if ($target.Index -ge $target.Parent.Entries.Count -or + -not [object]::ReferenceEquals( + $target.Parent.Entries[$target.Index], + $target.Edge + )) { + throw (New-YamlRemovalException -Node $target.Node ` + -ErrorId 'YamlRemovalMutationConflict' -Message ( + 'A resolved YAML mapping target changed before mutation.' + )) + } + $target.Parent.Entries.RemoveAt($target.Index) + } + } +} diff --git a/src/functions/private/Resolve-YamlRemovalTarget.ps1 b/src/functions/private/Resolve-YamlRemovalTarget.ps1 new file mode 100644 index 0000000..08b2721 --- /dev/null +++ b/src/functions/private/Resolve-YamlRemovalTarget.ps1 @@ -0,0 +1,199 @@ +function Resolve-YamlRemovalTarget { + <# + .SYNOPSIS + Resolves one decoded JSON Pointer against one YAML document. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Document, + + [Parameter(Mandatory)] + [int] $DocumentIndex, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Pointer, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $Tokens, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $documentKey = "D:$DocumentIndex" + if ($Tokens.Count -eq 0) { + return [pscustomobject]@{ + Found = $true + Key = $documentKey + Kind = 'Document' + Parent = $null + Index = $DocumentIndex + Edge = $Document + Node = $Document + Depth = 0 + Path = [string[]] @($documentKey) + Pointer = $Pointer + DocumentIndex = $DocumentIndex + } + } + + $ancestors = [System.Collections.Generic.List[string]]::new() + $ancestors.Add($documentKey) + $current = $Document + for ($tokenIndex = 0; $tokenIndex -lt $Tokens.Count; $tokenIndex++) { + $token = $Tokens[$tokenIndex] + $isFinal = $tokenIndex -eq $Tokens.Count - 1 + $effective = Get-YamlRemovalNode -Node $current -State $State + Add-YamlRemovalWork -State $State -Operation 'pointer token resolution' ` + -Node $effective + + if ($effective.Kind -eq 'Mapping') { + $hasUnaddressableKey = $false + $matchingIndexes = [System.Collections.Generic.List[int]]::new() + for ($entryIndex = 0; $entryIndex -lt $effective.Entries.Count; $entryIndex++) { + $entry = $effective.Entries[$entryIndex] + Add-YamlRemovalWork -State $State -Operation 'mapping key scan' ` + -Node $entry.Key + $keyNode = Get-YamlRemovalNode -Node $entry.Key -State $State + if ($keyNode.Kind -ne 'Scalar') { + $hasUnaddressableKey = $true + continue + } + + $resolvedKey = (Resolve-YamlScalar -Node $keyNode).Value + $effectiveTag = Get-YamlEffectiveTag -Node $keyNode -Value $resolvedKey + if (-not [string]::Equals( + $effectiveTag, + 'tag:yaml.org,2002:str', + [System.StringComparison]::Ordinal + )) { + $hasUnaddressableKey = $true + continue + } + if ([string]::Equals( + [string] $keyNode.Value, + $token, + [System.StringComparison]::Ordinal + )) { + $matchingIndexes.Add($entryIndex) + } + } + + if ($matchingIndexes.Count -gt 1) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalAmbiguousTarget' -Message ( + "JSON Pointer '$Pointer' ambiguously matches more than one string key " + + "in YAML document index $DocumentIndex." + )) + } + if ($matchingIndexes.Count -eq 0) { + $guidance = if ($hasUnaddressableKey) { + ' The mapping contains complex, non-string, or tagged keys that cannot be ' + + 'addressed by JSON Pointer; no key was coerced or guessed.' + } else { + '' + } + return [pscustomobject]@{ + Found = $false + ErrorId = 'YamlRemovalPathNotFound' + Message = ( + "JSON Pointer '$Pointer' did not resolve in YAML document index " + + "$DocumentIndex at token '$token'.$guidance" + ) + Node = $effective + } + } + + $matchedIndex = $matchingIndexes[0] + $matchedEntry = $effective.Entries[$matchedIndex] + $edgeKey = "M:$($effective.Id):$matchedIndex" + if ($isFinal) { + return [pscustomobject]@{ + Found = $true + Key = $edgeKey + Kind = 'Mapping' + Parent = $effective + Index = $matchedIndex + Edge = $matchedEntry + Node = $matchedEntry.Value + Depth = $Tokens.Count + Path = [string[]] (@($ancestors.ToArray()) + $edgeKey) + Pointer = $Pointer + DocumentIndex = $DocumentIndex + } + } + $ancestors.Add($edgeKey) + $current = $matchedEntry.Value + continue + } + + if ($effective.Kind -eq 'Sequence') { + if ($token -cnotmatch '^(?:0|[1-9][0-9]*)$') { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalInvalidSequenceIndex' -Message ( + "JSON Pointer token '$token' is not a canonical non-negative decimal " + + 'YAML sequence index.' + )) + } + $sequenceIndex = 0 + if (-not [int]::TryParse( + $token, + [System.Globalization.NumberStyles]::None, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $sequenceIndex + )) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalInvalidSequenceIndex' -Message ( + "JSON Pointer token '$token' exceeds the supported YAML sequence index range." + )) + } + if ($sequenceIndex -ge $effective.Items.Count) { + return [pscustomobject]@{ + Found = $false + ErrorId = 'YamlRemovalPathNotFound' + Message = ( + "JSON Pointer '$Pointer' did not resolve in YAML document index " + + "$DocumentIndex because sequence index $sequenceIndex is out of range." + ) + Node = $effective + } + } + + $sequenceItem = $effective.Items[$sequenceIndex] + $edgeKey = "S:$($effective.Id):$sequenceIndex" + if ($isFinal) { + return [pscustomobject]@{ + Found = $true + Key = $edgeKey + Kind = 'Sequence' + Parent = $effective + Index = $sequenceIndex + Edge = $sequenceItem + Node = $sequenceItem + Depth = $Tokens.Count + Path = [string[]] (@($ancestors.ToArray()) + $edgeKey) + Pointer = $Pointer + DocumentIndex = $DocumentIndex + } + } + $ancestors.Add($edgeKey) + $current = $sequenceItem + continue + } + + return [pscustomobject]@{ + Found = $false + ErrorId = 'YamlRemovalPathNotFound' + Message = ( + "JSON Pointer '$Pointer' did not resolve in YAML document index " + + "$DocumentIndex because token '$token' traverses a scalar." + ) + Node = $effective + } + } +} diff --git a/src/functions/private/Resolve-YamlScalar.ps1 b/src/functions/private/Resolve-YamlScalar.ps1 new file mode 100644 index 0000000..05a2fa6 --- /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 '[ \t\n]', '')) + } 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 '[ \t]+([+-]\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..8bdb330 --- /dev/null +++ b/src/functions/private/Resolve-YamlTag.ps1 @@ -0,0 +1,109 @@ +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." + )) + } + + $isNonSpecific = $Token.Equals('!', [System.StringComparison]::Ordinal) + 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.Equals('!', [System.StringComparison]::Ordinal)) { + $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 (-not $Token.Equals('!', [System.StringComparison]::Ordinal)) { + $prefix = $Context.TagHandles[$handle] + } + } + + $expanded = if ($isNonSpecific) { + '' + } else { + ConvertFrom-YamlTagUriEscape -Text ($prefix + $suffix) -Mark $Mark -Token $Token ` + -MaxLength $Context.MaxTagLength + } + $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 = $isNonSpecific -or -not $known + } +} diff --git a/src/functions/private/Select-YamlRemovalTarget.ps1 b/src/functions/private/Select-YamlRemovalTarget.ps1 new file mode 100644 index 0000000..fb541d0 --- /dev/null +++ b/src/functions/private/Select-YamlRemovalTarget.ps1 @@ -0,0 +1,105 @@ +function Select-YamlRemovalTarget { + <# + .SYNOPSIS + Coalesces duplicate targets and drops fully subsumed descendants. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Targets, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $coalesced = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($target in $Targets) { + Add-YamlRemovalWork -State $State -Operation 'target coalescing' -Node $target.Node + if ($coalesced.ContainsKey($target.Key)) { + $coalesced[$target.Key].PathSets.Add([string[]] $target.Path) + continue + } + + $pathSets = [System.Collections.Generic.List[object]]::new() + $pathSets.Add([string[]] $target.Path) + $coalesced[$target.Key] = [pscustomobject]@{ + Key = $target.Key + Kind = $target.Kind + Parent = $target.Parent + Index = $target.Index + Edge = $target.Edge + Node = $target.Node + Depth = $target.Depth + PathSets = $pathSets + Pointer = $target.Pointer + DocumentIndex = $target.DocumentIndex + } + } + + $pathRoot = [pscustomobject]@{ + Children = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + TargetKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + } + foreach ($target in $coalesced.Values) { + foreach ($path in $target.PathSets) { + $pathNode = $pathRoot + foreach ($edgeKey in $path) { + Add-YamlRemovalWork -State $State -Operation 'target path indexing' ` + -Node $target.Node + if (-not $pathNode.Children.ContainsKey($edgeKey)) { + $pathNode.Children[$edgeKey] = [pscustomobject]@{ + Children = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + TargetKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + } + } + $pathNode = $pathNode.Children[$edgeKey] + } + [void] $pathNode.TargetKeys.Add($target.Key) + } + } + + $survivors = [System.Collections.Generic.List[object]]::new() + foreach ($candidate in $coalesced.Values) { + $allRequestsSubsumed = $true + foreach ($path in $candidate.PathSets) { + $requestSubsumed = $false + $pathNode = $pathRoot + for ($pathIndex = 0; $pathIndex -lt $path.Count - 1; $pathIndex++) { + Add-YamlRemovalWork -State $State -Operation 'target path ancestry' ` + -Node $candidate.Node + $edgeKey = $path[$pathIndex] + if (-not $pathNode.Children.ContainsKey($edgeKey)) { + break + } + $pathNode = $pathNode.Children[$edgeKey] + if ($pathNode.TargetKeys.Count -gt 1 -or + ($pathNode.TargetKeys.Count -eq 1 -and + -not $pathNode.TargetKeys.Contains($candidate.Key))) { + $requestSubsumed = $true + break + } + } + if (-not $requestSubsumed) { + $allRequestsSubsumed = $false + break + } + } + if (-not $allRequestsSubsumed) { + $survivors.Add($candidate) + } + } + + return New-YamlValueBox -Value ([object[]] $survivors.ToArray()) +} diff --git a/src/functions/private/Set-YamlMergeNodeChanged.ps1 b/src/functions/private/Set-YamlMergeNodeChanged.ps1 new file mode 100644 index 0000000..32bb339 --- /dev/null +++ b/src/functions/private/Set-YamlMergeNodeChanged.ps1 @@ -0,0 +1,34 @@ +function Set-YamlMergeNodeChanged { + <# + .SYNOPSIS + Invalidates indexes and equality results affected by a graph mutation. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Updates only isolated in-memory merge bookkeeping.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $Context + ) + + $effective = Get-YamlMergeNode -Node $Node + Add-YamlMergeWork -State $Context.WorkState -Node $effective ` + -Operation 'mutation invalidation' + $Context.MutationState.Version = [long] $Context.MutationState.Version + 1 + $Context.EqualityState.Cache.Clear() + + $indexes = $null + if (-not $Context.IndexDependents.TryGetValue($effective.Id, [ref] $indexes)) { + return + } + foreach ($index in $indexes) { + Add-YamlMergeWork -State $Context.WorkState -Node $effective ` + -Operation 'dependent index invalidation' + $index.IsValid = $false + } +} 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..536843f --- /dev/null +++ b/src/functions/private/Skip-YamlBlockTrivia.ps1 @@ -0,0 +1,33 @@ +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.StartsWith('#', [System.StringComparison]::Ordinal)) { + $column = $line.Length - $trimmed.Length + $mark = New-YamlMark -Index ($Context.LineStarts[$Context.LineIndex] + $column) ` + -Line $Context.LineIndex -Column $column + Assert-YamlNoByteOrderMark -Text $trimmed -Mark $mark + $Context.LineIndex++ + continue + } + if ($trimmed.Length -eq 0) { + $Context.LineIndex++ + continue + } + break + } +} diff --git a/src/functions/private/Skip-YamlDocumentPrefix.ps1 b/src/functions/private/Skip-YamlDocumentPrefix.ps1 new file mode 100644 index 0000000..39ad48d --- /dev/null +++ b/src/functions/private/Skip-YamlDocumentPrefix.ps1 @@ -0,0 +1,44 @@ +function Skip-YamlDocumentPrefix { + <# + .SYNOPSIS + Advances across repeated YAML document prefixes. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Advances an in-memory parser context.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [switch] $RequireDocumentStart + ) + + $validatedPrefix = $false + do { + $lineIndex = $Context.LineIndex + $consumedByteOrderMark = $false + if ($lineIndex -lt $Context.Lines.Count -and + $Context.Lines[$lineIndex].StartsWith( + [string] [char] 0xFEFF, + [System.StringComparison]::Ordinal + )) { + $atStreamStart = $Context.LineStarts[$lineIndex] -eq 0 + if ($atStreamStart -or $validatedPrefix -or + (Test-YamlDocumentByteOrderMark -Context $Context ` + -RequireDocumentStart:$RequireDocumentStart)) { + Read-YamlDocumentByteOrderMark -Context $Context ` + -RequireDocumentStart:$RequireDocumentStart -SkipValidation + $consumedByteOrderMark = $true + if (-not $atStreamStart) { + $validatedPrefix = $true + } + } + } + Skip-YamlBlockTrivia -Context $Context + } while ( + $Context.LineIndex -ne $lineIndex -or + $consumedByteOrderMark + ) +} diff --git a/src/functions/private/Skip-YamlFlowTrivia.ps1 b/src/functions/private/Skip-YamlFlowTrivia.ps1 new file mode 100644 index 0000000..5ddc5f4 --- /dev/null +++ b/src/functions/private/Skip-YamlFlowTrivia.ps1 @@ -0,0 +1,120 @@ +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 '#') { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + if ($Cursor.Index -gt 0 -and + $Context.Text[$Cursor.Index - 1] -notin @(' ', "`t", "`n")) { + throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlInvalidComment' -Message ( + 'A YAML comment must be separated from preceding content.' + )) + } + $commentEnd = $Context.Text.IndexOf( + "`n", + $Cursor.Index, + [System.StringComparison]::Ordinal + ) + if ($commentEnd -lt 0) { + $commentEnd = $Context.Text.Length + } + Assert-YamlNoByteOrderMark ` + -Text $Context.Text.Substring($Cursor.Index, $commentEnd - $Cursor.Index) ` + -Mark $mark + 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 '#') { + $mark = New-YamlMark -Index $Cursor.Index -Line $Cursor.Line -Column $Cursor.Column + $commentEnd = $Context.Text.IndexOf( + "`n", + $Cursor.Index, + [System.StringComparison]::Ordinal + ) + if ($commentEnd -lt 0) { + $commentEnd = $Context.Text.Length + } + Assert-YamlNoByteOrderMark ` + -Text $Context.Text.Substring($Cursor.Index, $commentEnd - $Cursor.Index) ` + -Mark $mark + 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 -cin @('---', '...') -and + ($Cursor.Index + 3 -eq $Context.Text.Length -or + (Test-YamlWhiteSpace -Character $Context.Text[$Cursor.Index + 3]) -or + $Context.Text[$Cursor.Index + 3] -ceq "`n")) { + $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-YamlDocumentByteOrderMark.ps1 b/src/functions/private/Test-YamlDocumentByteOrderMark.ps1 new file mode 100644 index 0000000..ad1de12 --- /dev/null +++ b/src/functions/private/Test-YamlDocumentByteOrderMark.ps1 @@ -0,0 +1,25 @@ +function Test-YamlDocumentByteOrderMark { + <# + .SYNOPSIS + Tests for a byte order mark at the current document boundary. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [switch] $RequireDocumentStart + ) + + if ($Context.LineIndex -ge $Context.Lines.Count -or + -not $Context.Lines[$Context.LineIndex].StartsWith( + [string] [char] 0xFEFF, + [System.StringComparison]::Ordinal + )) { + return $false + } + return Test-YamlDocumentPrefix -Text $Context.Text ` + -Index ($Context.LineStarts[$Context.LineIndex] + 1) ` + -RequireDocumentStart:$RequireDocumentStart +} diff --git a/src/functions/private/Test-YamlDocumentPrefix.ps1 b/src/functions/private/Test-YamlDocumentPrefix.ps1 new file mode 100644 index 0000000..2b81a2f --- /dev/null +++ b/src/functions/private/Test-YamlDocumentPrefix.ps1 @@ -0,0 +1,55 @@ +function Test-YamlDocumentPrefix { + <# + .SYNOPSIS + Tests whether text after a BOM is a legal YAML document prefix. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] $Text, + + [Parameter(Mandatory)] + [ValidateRange(0, 2147483647)] + [int] $Index, + + [switch] $RequireDocumentStart + ) + + $directiveSeen = $false + while ($Index -lt $Text.Length) { + if ($Text[$Index] -ceq [char] 0xFEFF) { + $Index++ + continue + } + $lineEnd = $Text.IndexOf("`n", $Index, [System.StringComparison]::Ordinal) + if ($lineEnd -lt 0) { + $lineEnd = $Text.Length + } + $line = $Text.Substring($Index, $lineEnd - $Index) + $trimmed = $line.TrimStart(' ', "`t") + if ($trimmed.Length -eq 0 -or + $trimmed.StartsWith('#', [System.StringComparison]::Ordinal)) { + if ($lineEnd -ge $Text.Length) { + return -not $directiveSeen + } + $Index = $lineEnd + 1 + continue + } + if ($line.StartsWith('%', [System.StringComparison]::Ordinal) -and + -not $RequireDocumentStart) { + $directiveSeen = $true + if ($lineEnd -ge $Text.Length) { + return $false + } + $Index = $lineEnd + 1 + continue + } + if ($line.StartsWith('---', [System.StringComparison]::Ordinal) -and + ($line.Length -eq 3 -or (Test-YamlWhiteSpace -Character $line[3]))) { + return $true + } + return -not $RequireDocumentStart -and -not $directiveSeen + } + return -not $directiveSeen +} diff --git a/src/functions/private/Test-YamlIndicator.ps1 b/src/functions/private/Test-YamlIndicator.ps1 new file mode 100644 index 0000000..4e2de3c --- /dev/null +++ b/src/functions/private/Test-YamlIndicator.ps1 @@ -0,0 +1,25 @@ +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 + ) + + if ($Text.Length -eq 0 -or -not $Text[0].Equals($Indicator[0])) { + return $false + } + if ($Text.Length -eq 1) { + return $true + } + return Test-YamlWhiteSpace -Character $Text[1] +} diff --git a/src/functions/private/Test-YamlMappingValueIndicator.ps1 b/src/functions/private/Test-YamlMappingValueIndicator.ps1 new file mode 100644 index 0000000..fb5c55f --- /dev/null +++ b/src/functions/private/Test-YamlMappingValueIndicator.ps1 @@ -0,0 +1,31 @@ +function Test-YamlMappingValueIndicator { + <# + .SYNOPSIS + Tests whether a colon is a mapping value indicator in the current context. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] $Text, + + [Parameter(Mandatory)] + [ValidateRange(0, 2147483647)] + [int] $Index, + + [switch] $Flow + ) + + if ($Index -ge $Text.Length -or $Text[$Index] -cne ':') { + return $false + } + if ($Index + 1 -ge $Text.Length) { + return $true + } + + $next = $Text[$Index + 1] + if ((Test-YamlWhiteSpace -Character $next) -or $next -ceq "`n") { + return $true + } + return $Flow -and $next -in @(',', '[', ']', '{', '}') +} diff --git a/src/functions/private/Test-YamlMergeNodeEqual.ps1 b/src/functions/private/Test-YamlMergeNodeEqual.ps1 new file mode 100644 index 0000000..e727c2a --- /dev/null +++ b/src/functions/private/Test-YamlMergeNodeEqual.ps1 @@ -0,0 +1,263 @@ +function Test-YamlMergeNodeEqual { + <# + .SYNOPSIS + Compares YAML representation graphs with collision-safe structural equality. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $OtherNode, + + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $LeftFingerprintCache, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $RightFingerprintCache + ) + + if ($null -eq $LeftFingerprintCache) { + $LeftFingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + } + if ($null -eq $RightFingerprintCache) { + $RightFingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + } + + $rootLeft = Get-YamlMergeNode -Node $Node + $rootRight = Get-YamlMergeNode -Node $OtherNode + $cacheKey = '{0}:{1}:{2}:{3}' -f @( + $State.MutationState.Version, + $State.InputIndex, + $rootLeft.Id, + $rootRight.Id + ) + Add-YamlMergeWork -State $State.WorkState -Node $rootRight ` + -Operation 'equality cache lookup' + $cached = $false + if ($State.Cache.TryGetValue($cacheKey, [ref] $cached)) { + return $cached + } + + $seen = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push([pscustomobject]@{ Left = $Node; Right = $OtherNode }) + + while ($pending.Count -gt 0) { + $pair = $pending.Pop() + $left = $pair.Left + while ($left.Kind -eq 'Alias') { + Add-YamlMergeWork -State $State.WorkState -Node $left ` + -Operation 'equality alias traversal' + $left = $left.Target + } + $right = $pair.Right + while ($right.Kind -eq 'Alias') { + Add-YamlMergeWork -State $State.WorkState -Node $right ` + -Operation 'equality alias traversal' + $right = $right.Target + } + Add-YamlMergeWork -State $State.WorkState -Node $right ` + -Operation 'equality pair comparison' + + $pairId = '{0}:{1}' -f $left.Id, $right.Id + if (-not $seen.Add($pairId)) { + continue + } + + if ($left.Kind -cne $right.Kind) { + $State.Cache[$cacheKey] = $false + return $false + } + $leftTag = Get-YamlMergeNodeTag -Node $left + $rightTag = Get-YamlMergeNodeTag -Node $right + if (-not [string]::Equals( + $leftTag, + $rightTag, + [System.StringComparison]::Ordinal + )) { + $State.Cache[$cacheKey] = $false + return $false + } + + if ($left.Kind -eq 'Scalar') { + $leftValue = (Resolve-YamlScalar -Node $left).Value + $rightValue = (Resolve-YamlScalar -Node $right).Value + if ($null -eq $leftValue -or $null -eq $rightValue) { + if ($null -ne $leftValue -or $null -ne $rightValue) { + $State.Cache[$cacheKey] = $false + return $false + } + continue + } + if ($leftValue -is [byte[]] -or $rightValue -is [byte[]]) { + if ($leftValue -isnot [byte[]] -or $rightValue -isnot [byte[]] -or + $leftValue.Count -ne $rightValue.Count) { + $State.Cache[$cacheKey] = $false + return $false + } + for ($index = 0; $index -lt $leftValue.Count; $index++) { + if ($leftValue[$index] -ne $rightValue[$index]) { + $State.Cache[$cacheKey] = $false + return $false + } + } + continue + } + if ($leftValue -is [datetimeoffset] -or $leftValue -is [datetime] -or + $rightValue -is [datetimeoffset] -or $rightValue -is [datetime]) { + $leftTicks = if ($leftValue -is [datetimeoffset]) { + $leftValue.UtcDateTime.Ticks + } elseif ($leftValue.Kind -eq [System.DateTimeKind]::Local) { + $leftValue.ToUniversalTime().Ticks + } else { + $leftValue.Ticks + } + $rightTicks = if ($rightValue -is [datetimeoffset]) { + $rightValue.UtcDateTime.Ticks + } elseif ($rightValue.Kind -eq [System.DateTimeKind]::Local) { + $rightValue.ToUniversalTime().Ticks + } else { + $rightValue.Ticks + } + if ($leftTicks -ne $rightTicks) { + $State.Cache[$cacheKey] = $false + return $false + } + continue + } + if ($leftValue -is [decimal] -or $leftValue -is [double] -or + $leftValue -is [single] -or $rightValue -is [decimal] -or + $rightValue -is [double] -or $rightValue -is [single]) { + $leftNumber = Get-YamlNormalizedFloat -Value $leftValue + $rightNumber = Get-YamlNormalizedFloat -Value $rightValue + if ($leftNumber -cne $rightNumber) { + $State.Cache[$cacheKey] = $false + return $false + } + continue + } + if ($leftValue -is [string] -and $rightValue -is [string]) { + if (-not $leftValue.Equals($rightValue, [System.StringComparison]::Ordinal)) { + $State.Cache[$cacheKey] = $false + return $false + } + continue + } + if ($leftValue -is [bool] -and $rightValue -is [bool]) { + if ($leftValue -ne $rightValue) { + $State.Cache[$cacheKey] = $false + return $false + } + continue + } + $leftText = $leftValue.ToString([System.Globalization.CultureInfo]::InvariantCulture) + $rightText = $rightValue.ToString([System.Globalization.CultureInfo]::InvariantCulture) + if ($leftText -cne $rightText) { + $State.Cache[$cacheKey] = $false + return $false + } + continue + } + + if ($left.Kind -eq 'Sequence') { + if ($left.Items.Count -ne $right.Items.Count) { + $State.Cache[$cacheKey] = $false + return $false + } + for ($index = $left.Items.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Left = $left.Items[$index] + Right = $right.Items[$index] + }) + } + continue + } + + if ($left.Entries.Count -ne $right.Entries.Count) { + $State.Cache[$cacheKey] = $false + return $false + } + Add-YamlMergeWork -State $State.WorkState -Node $right ` + -Operation 'equality mapping index build' + $rightEntries = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + $rightIndexedIds = [System.Collections.Generic.HashSet[int]]::new() + for ($index = 0; $index -lt $right.Entries.Count; $index++) { + $rightKey = $right.Entries[$index].Key + Add-YamlMergeWork -State $State.WorkState -Node $rightKey ` + -Operation 'equality mapping candidate identity' + $effectiveKey = Get-YamlMergeNode -Node $rightKey + if (-not $rightIndexedIds.Add($effectiveKey.Id)) { + continue + } + $fingerprint = Get-YamlMergeFingerprint -Node $rightKey -State $State ` + -Cache $RightFingerprintCache + Add-YamlMergeWork -State $State.WorkState -Node $rightKey ` + -Operation 'equality mapping bucket visit' + $bucket = $null + if (-not $rightEntries.TryGetValue($fingerprint, [ref] $bucket)) { + $bucket = [pscustomobject]@{ + Candidates = [System.Collections.Generic.List[object]]::new() + } + $rightEntries[$fingerprint] = $bucket + } + Add-YamlMergeWork -State $State.WorkState -Node $rightKey ` + -Operation 'equality mapping candidate visit' + $bucket.Candidates.Add([pscustomobject]@{ + Index = $index + Entry = $right.Entries[$index] + }) + } + + $matched = [System.Collections.Generic.HashSet[int]]::new() + foreach ($leftEntry in $left.Entries) { + $fingerprint = Get-YamlMergeFingerprint -Node $leftEntry.Key -State $State ` + -Cache $LeftFingerprintCache + Add-YamlMergeWork -State $State.WorkState -Node $leftEntry.Key ` + -Operation 'equality mapping bucket lookup' + $bucket = $null + if (-not $rightEntries.TryGetValue($fingerprint, [ref] $bucket)) { + $State.Cache[$cacheKey] = $false + return $false + } + $match = $null + foreach ($candidate in $bucket.Candidates) { + Add-YamlMergeWork -State $State.WorkState -Node $leftEntry.Key ` + -Operation 'equality mapping candidate comparison' + if ($matched.Contains($candidate.Index)) { + continue + } + if (Test-YamlMergeNodeEqual -Node $leftEntry.Key ` + -OtherNode $candidate.Entry.Key -State $State ` + -LeftFingerprintCache $LeftFingerprintCache ` + -RightFingerprintCache $RightFingerprintCache) { + $match = $candidate + break + } + } + if ($null -eq $match) { + $State.Cache[$cacheKey] = $false + return $false + } + [void] $matched.Add($match.Index) + $pending.Push([pscustomobject]@{ + Left = $leftEntry.Value + Right = $match.Entry.Value + }) + } + } + + $State.Cache[$cacheKey] = $true + return $true +} diff --git a/src/functions/private/Test-YamlNodeGraph.ps1 b/src/functions/private/Test-YamlNodeGraph.ps1 new file mode 100644 index 0000000..a130a64 --- /dev/null +++ b/src/functions/private/Test-YamlNodeGraph.ps1 @@ -0,0 +1,163 @@ +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, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState, + + [Parameter()] + [AllowNull()] + [pscustomobject] $EqualityState, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache + ) + + $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.Dictionary[string, object]]::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) { + Assert-YamlNodeKeyUnique ` + -Node $entryNode.Entries[0].Key ` + -Buckets $orderedKeys -FingerprintCache $FingerprintCache ` + -FingerprintHasher $FingerprintHasher ` + -DuplicateMessage 'A duplicate key was found in a YAML ordered mapping.' ` + -RemovalWorkState $RemovalWorkState -EqualityState $EqualityState ` + -EqualityFingerprintCache $EqualityFingerprintCache + } + } + $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.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) { + $entry = $current.Entries[$index] + Assert-YamlNodeKeyUnique -Node $entry.Key -Buckets $keys ` + -FingerprintCache $FingerprintCache -FingerprintHasher $FingerprintHasher ` + -DuplicateMessage 'A duplicate mapping key is not allowed.' ` + -RemovalWorkState $RemovalWorkState -EqualityState $EqualityState ` + -EqualityFingerprintCache $EqualityFingerprintCache + + 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-YamlReservedDirective.ps1 b/src/functions/private/Test-YamlReservedDirective.ps1 new file mode 100644 index 0000000..47880c1 --- /dev/null +++ b/src/functions/private/Test-YamlReservedDirective.ps1 @@ -0,0 +1,40 @@ +function Test-YamlReservedDirective { + <# + .SYNOPSIS + Tests the YAML reserved-directive name and parameter productions. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] $Directive + ) + + if ($Directive.Length -lt 2 -or -not $Directive[0].Equals([char] '%')) { + return $false + } + + $comment = Find-YamlCommentStart -Text $Directive + $contentEnd = if ($comment -ge 0) { $comment } else { $Directive.Length } + $body = $Directive.Substring(1, $contentEnd - 1).TrimEnd(' ', "`t") + if ($body.Length -eq 0 -or (Test-YamlWhiteSpace -Character $body[0])) { + return $false + } + + $index = 0 + while ($index -lt $body.Length) { + $tokenStart = $index + while ($index -lt $body.Length -and + -not (Test-YamlWhiteSpace -Character $body[$index])) { + $index++ + } + if ($index -eq $tokenStart) { + return $false + } + while ($index -lt $body.Length -and + (Test-YamlWhiteSpace -Character $body[$index])) { + $index++ + } + } + return $true +} diff --git a/src/functions/private/Test-YamlReservedPropertyName.ps1 b/src/functions/private/Test-YamlReservedPropertyName.ps1 new file mode 100644 index 0000000..7e94dad --- /dev/null +++ b/src/functions/private/Test-YamlReservedPropertyName.ps1 @@ -0,0 +1,25 @@ +function Test-YamlReservedPropertyName { + <# + .SYNOPSIS + Tests whether a property name is reserved by PowerShell ETS. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] $Name + ) + + foreach ($reservedName in @( + 'PSObject', + 'PSTypeNames', + 'PSBase', + 'PSAdapted', + 'PSExtended' + )) { + if ($Name.Equals($reservedName, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} diff --git a/src/functions/private/Test-YamlSerializationReference.ps1 b/src/functions/private/Test-YamlSerializationReference.ps1 new file mode 100644 index 0000000..59a1fc2 --- /dev/null +++ b/src/functions/private/Test-YamlSerializationReference.ps1 @@ -0,0 +1,28 @@ +function Test-YamlSerializationReference { + <# + .SYNOPSIS + Tests whether a value participates in YAML anchor identity. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value -or $Value -is [System.DBNull]) { + return $false + } + if ($Value -is [byte[]] -or + $Value -is [System.Collections.IDictionary] -or + $Value -is [System.Management.Automation.PSCustomObject]) { + return $true + } + if ($Value -is [System.Collections.IEnumerable] -and + $Value -isnot [string] -and + $Value -isnot [char]) { + return $true + } + return $Value.GetType() -eq [object] +} diff --git a/src/functions/private/Test-YamlTagUriText.ps1 b/src/functions/private/Test-YamlTagUriText.ps1 new file mode 100644 index 0000000..9a8a546 --- /dev/null +++ b/src/functions/private/Test-YamlTagUriText.ps1 @@ -0,0 +1,57 @@ +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 + } + + $uriPunctuation = '#;/?:@&=+$,_.!~*''()[]' + $shorthandExcluded = '!,[]' + for ($index = 0; $index -lt $Text.Length; $index++) { + $character = $Text[$index] + if (-not $character.Equals([char] '%')) { + $code = [int] $character + $isWordCharacter = ( + ($code -ge 0x30 -and $code -le 0x39) -or + ($code -ge 0x41 -and $code -le 0x5A) -or + ($code -ge 0x61 -and $code -le 0x7A) -or + $character.Equals([char] '-') + ) + if (-not $isWordCharacter -and $uriPunctuation.IndexOf($character) -lt 0) { + return $false + } + if ($Shorthand -and $shorthandExcluded.IndexOf($character) -ge 0) { + return $false + } + continue + } + + if ($index + 2 -ge $Text.Length) { + return $false + } + foreach ($offset in 1, 2) { + $hexCode = [int] $Text[$index + $offset] + if (-not ( + ($hexCode -ge 0x30 -and $hexCode -le 0x39) -or + ($hexCode -ge 0x41 -and $hexCode -le 0x46) -or + ($hexCode -ge 0x61 -and $hexCode -le 0x66) + )) { + return $false + } + } + $index += 2 + } + return $true +} diff --git a/src/functions/private/Test-YamlWhiteSpace.ps1 b/src/functions/private/Test-YamlWhiteSpace.ps1 new file mode 100644 index 0000000..39ac42d --- /dev/null +++ b/src/functions/private/Test-YamlWhiteSpace.ps1 @@ -0,0 +1,14 @@ +function Test-YamlWhiteSpace { + <# + .SYNOPSIS + Tests the YAML s-white production. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [char] $Character + ) + + return $Character -ceq ' ' -or $Character -ceq "`t" +} diff --git a/src/functions/private/Write-YamlNodeText.ps1 b/src/functions/private/Write-YamlNodeText.ps1 new file mode 100644 index 0000000..4d6fe37 --- /dev/null +++ b/src/functions/private/Write-YamlNodeText.ps1 @@ -0,0 +1,125 @@ +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 + $explicitKey = ( + Get-YamlEmissionImplicitKeyLength -RenderedText $keyText + ) -gt 1024 + if ($explicitKey) { + $spaces = ' ' * ($task.Level * $Indent) + [void] $Builder.Append($spaces).Append('? ').Append($keyText).Append("`n") + } + $stack.Push([pscustomobject]@{ + Type = 'Node' + Node = $task.Entry.Value + Entry = $null + Level = $task.Level + LeadingText = if ($explicitKey) { ': ' } else { "$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..4f382cb --- /dev/null +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -0,0 +1,151 @@ +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() + $inspectionState = [pscustomobject]@{ + MaxScalarLength = $MaxScalarLength + MaxNodes = $MaxNodes + NodeCount = 0 + } + } + process { + try { + $null = Get-YamlSerializationShape -Value $InputObject -State $inspectionState ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + if ($values.Count -gt 0 -and ($values.Count + 2) -gt $MaxNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $MaxNodes nodes." + )) + } + } catch [System.NotSupportedException] { + $record = New-YamlErrorRecord -Exception $_.Exception ` + -DefaultErrorId 'YamlUnsupportedType' -Category InvalidType -TargetObject $InputObject + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.InvalidOperationException] { + $record = New-YamlErrorRecord -Exception $_.Exception ` + -DefaultErrorId 'YamlSerializationFailed' -Category InvalidOperation ` + -TargetObject $InputObject + $PSCmdlet.ThrowTerminatingError($record) + } + $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 + ReservedNodeCount = 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/Export-Yaml.ps1 b/src/functions/public/Export-Yaml.ps1 new file mode 100644 index 0000000..abb5f6a --- /dev/null +++ b/src/functions/public/Export-Yaml.ps1 @@ -0,0 +1,1169 @@ +function Export-Yaml { + <# + .SYNOPSIS + Exports PowerShell values to a YAML file. + + .DESCRIPTION + Collects pipeline records with the same aggregation semantics as + ConvertTo-Yaml, serializes the complete value before changing the + filesystem, and writes strict encoded bytes through a cryptographically + random same-directory temporary file. + + The completed temporary file is flushed and closed before it atomically + replaces or moves to the destination. Existing files are never + truncated before serialization and writing succeed. + + .PARAMETER InputObject + A value to serialize. Multiple pipeline records become one YAML + sequence. A directly supplied array is one input value. + + .PARAMETER Path + One literal FileSystem destination path. Wildcard characters are not + expanded. + + .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. + + .PARAMETER Encoding + Strict output encoding. utf8 omits a byte order mark; utf8BOM and the + UTF-16 and UTF-32 encodings include their corresponding byte order mark. + + .PARAMETER NewLine + Line ending used for emitted YAML structure. The default is LF. + + .PARAMETER NoFinalNewline + Omits the final line ending. By default, exactly one is written. + + .PARAMETER NoClobber + Fails when the destination already exists. + + .PARAMETER Force + Permits atomic replacement of a read-only destination and preserves its + read-only attribute. Force cannot be combined with NoClobber. + + .PARAMETER CreateDirectory + Creates missing parent directories after ShouldProcess approval. + + .PARAMETER PassThru + Writes the final FileInfo after a successful export. + + .EXAMPLE + $config | Export-Yaml -Path '.\config.yaml' + + Serializes one pipeline value to config.yaml with UTF-8 and LF. + + .EXAMPLE + 'one', 'two' | Export-Yaml -Path '.\items.yaml' -Encoding utf16LE + + Collects two pipeline records into one sequence and writes UTF-16LE. + + .EXAMPLE + Export-Yaml -InputObject $config -Path '.\new\config.yaml' -CreateDirectory -PassThru + + Creates the parent directory and returns the final FileInfo. + + .INPUTS + System.Object + + .OUTPUTS + System.IO.FileInfo + + .NOTES + Only FileSystem provider destinations are supported. + + .LINK + https://psmodule.io/Yaml/Functions/ConvertTo-Yaml/ + #> + [OutputType([System.IO.FileInfo])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + param ( + # Collects one value per pipeline record. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowNull()] + [object] $InputObject, + + # Selects one literal FileSystem destination. + [Parameter(Mandatory, Position = 1)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + # Limits object-graph nesting depth. + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + # Limits traversed serialization nodes. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + # Limits emitted characters in one scalar. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + # Selects block indentation width. + [Parameter()] + [ValidateRange(2, 9)] + [int] $Indent = 2, + + # Emits an explicit YAML document start marker. + [Parameter()] + [switch] $ExplicitDocumentStart, + + # Emits enum names instead of numeric values. + [Parameter()] + [switch] $EnumsAsStrings, + + # Selects strict output encoding and byte order mark policy. + [Parameter()] + [ValidateSet('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE')] + [string] $Encoding = 'utf8', + + # Selects structural line endings. + [Parameter()] + [ValidateSet('LF', 'CRLF')] + [string] $NewLine = 'LF', + + # Omits the one final structural line ending. + [Parameter()] + [switch] $NoFinalNewline, + + # Prevents replacement of an existing destination. + [Parameter()] + [switch] $NoClobber, + + # Permits replacement of a read-only destination. + [Parameter()] + [switch] $Force, + + # Creates missing parent directories after approval. + [Parameter()] + [switch] $CreateDirectory, + + # Emits the final FileInfo after success. + [Parameter()] + [switch] $PassThru + ) + + begin { + $values = [System.Collections.Generic.List[object]]::new() + $inspectionState = [pscustomobject]@{ + MaxScalarLength = $MaxScalarLength + MaxNodes = $MaxNodes + NodeCount = 0 + } + } + process { + try { + $null = Get-YamlSerializationShape -Value $InputObject -State $inspectionState ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + if ($values.Count -gt 0 -and ($values.Count + 2) -gt $MaxNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $MaxNodes nodes." + )) + } + } catch [System.NotSupportedException] { + $serializationError = $_ + $exception = [System.InvalidOperationException]::new( + "Cannot serialize YAML for '$Path': $($serializationError.Exception.Message)", + $serializationError.Exception + ) + $errorId = if ($serializationError.Exception.Data.Contains('YamlErrorId')) { + [string] $serializationError.Exception.Data['YamlErrorId'] + } else { + 'YamlUnsupportedType' + } + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category InvalidType -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.InvalidOperationException] { + $serializationError = $_ + $exception = [System.InvalidOperationException]::new( + "Cannot serialize YAML for '$Path': $($serializationError.Exception.Message)", + $serializationError.Exception + ) + $errorId = if ($serializationError.Exception.Data.Contains('YamlErrorId')) { + [string] $serializationError.Exception.Data['YamlErrorId'] + } else { + 'YamlExportSerializationFailed' + } + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category InvalidOperation -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + $values.Add($InputObject) + } + end { + if ($values.Count -eq 0) { + return + } + if ($NoClobber -and $Force) { + $exception = [System.ArgumentException]::new( + "Cannot export YAML to '$Path': Force and NoClobber cannot be combined." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportConflictingFileOptions' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + + try { + $provider = $null + $drive = $null + $providerPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $Path, + [ref] $provider, + [ref] $drive + ) + $destinationPath = [System.IO.Path]::GetFullPath($providerPath) + } catch [System.Management.Automation.ProviderNotFoundException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot export YAML to '$Path': the provider is not available.", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportProviderNotSupported' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.DriveNotFoundException] { + $pathError = $_ + $exception = [System.IO.DirectoryNotFoundException]::new( + "Cannot export YAML to '$Path': the drive was not found.", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryNotFound' -Category ObjectNotFound ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.ArgumentException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML output path '$Path': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInvalid' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.NotSupportedException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML output path '$Path': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInvalid' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.PathTooLongException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML output path '$Path': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInvalid' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($provider.Name -ne 'FileSystem') { + $exception = [System.NotSupportedException]::new( + "Cannot export YAML to '$Path': only the FileSystem provider is supported." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportProviderNotSupported' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + $destinationExists = $false + $destinationAttributes = [System.IO.FileAttributes]::Normal + try { + $destinationAttributes = [System.IO.File]::GetAttributes($destinationPath) + $destinationExists = $true + } catch [System.IO.FileNotFoundException] { + $destinationExists = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationExists = $false + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInspectionFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if ($destinationExists -and + ($destinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination is a directory." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportNotFile' -Category InvalidType ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $directoryPath = [System.IO.Path]::GetDirectoryName($destinationPath) + $directoryExists = $false + $directoryAttributes = [System.IO.FileAttributes]::Normal + try { + $directoryAttributes = [System.IO.File]::GetAttributes($directoryPath) + $directoryExists = $true + } catch [System.IO.FileNotFoundException] { + $directoryExists = $false + } catch [System.IO.DirectoryNotFoundException] { + $directoryExists = $false + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML output directory '$directoryPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML output directory '$directoryPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInspectionFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if ($directoryExists -and + ($directoryAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the parent path is not a directory." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryInvalid' -Category InvalidType ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if (-not $directoryExists -and -not $CreateDirectory) { + $exception = [System.IO.DirectoryNotFoundException]::new( + "Cannot export YAML to '$destinationPath': the parent directory does not exist." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryNotFound' -Category ObjectNotFound ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($destinationExists -and $NoClobber) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination already exists." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationExists' -Category ResourceExists ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $destinationWasReadOnly = $false + if ($destinationExists) { + $destinationWasReadOnly = ( + $destinationAttributes -band [System.IO.FileAttributes]::ReadOnly + ) -ne 0 + if ($destinationWasReadOnly -and -not $Force) { + $exception = [System.UnauthorizedAccessException]::new( + "Cannot export YAML to '$destinationPath': the destination is read-only. Use Force to replace it." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationReadOnly' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + if ($values.Count -eq 1) { + $value = [object] $values[0] + } else { + $value = [object[]] $values.ToArray() + } + $serializerParameters = @{ + InputObject = $value + Depth = $Depth + MaxNodes = $MaxNodes + MaxScalarLength = $MaxScalarLength + Indent = $Indent + ExplicitDocumentStart = $ExplicitDocumentStart + EnumsAsStrings = $EnumsAsStrings + } + try { + $yamlText = ConvertTo-Yaml @serializerParameters + } catch { + $serializationError = $_ + $errorId = ($serializationError.FullyQualifiedErrorId -split ',')[0] + if ([string]::IsNullOrWhiteSpace($errorId) -or + $errorId -notmatch '^[A-Za-z][A-Za-z0-9_.-]*$') { + $errorId = 'YamlExportSerializationFailed' + } + $exception = [System.InvalidOperationException]::new( + "Cannot serialize YAML for '$destinationPath': $($serializationError.Exception.Message)", + $serializationError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category $serializationError.CategoryInfo.Category -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($yamlText.Contains("`r") -or -not $yamlText.EndsWith("`n")) { + $exception = [System.InvalidOperationException]::new( + "Cannot export YAML to '$destinationPath': the serializer did not return LF-normalized text with one final line feed." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPresentationFailed' -Category InvalidData ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $lineEnding = if ($NewLine -eq 'CRLF') { + "`r`n" + } else { + "`n" + } + $presentationText = $yamlText.Substring(0, $yamlText.Length - 1).Replace( + "`n", + $lineEnding + ) + if (-not $NoFinalNewline) { + $presentationText += $lineEnding + } + + $textEncoding = Get-YamlTextEncoding -Name $Encoding + try { + $contentBytes = $textEncoding.GetBytes($presentationText) + } catch [System.Text.EncoderFallbackException] { + $encodingError = $_ + $exception = [System.Text.EncoderFallbackException]::new( + "Cannot encode YAML for '$destinationPath': the text contains an invalid character sequence.", + $encodingError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportEncodingFailed' -Category InvalidData ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + $preamble = $textEncoding.GetPreamble() + $outputBytes = [byte[]]::new($preamble.Length + $contentBytes.Length) + [System.Buffer]::BlockCopy($preamble, 0, $outputBytes, 0, $preamble.Length) + [System.Buffer]::BlockCopy( + $contentBytes, + 0, + $outputBytes, + $preamble.Length, + $contentBytes.Length + ) + + $approvedDestinationExists = $destinationExists + $action = if ($destinationExists) { + 'Replace YAML file' + } else { + 'Create YAML file' + } + if (-not $PSCmdlet.ShouldProcess($destinationPath, $action)) { + return + } + + if (-not $directoryExists) { + try { + [void] [System.IO.Directory]::CreateDirectory($directoryPath) + } catch [System.UnauthorizedAccessException] { + $directoryError = $_ + $exception = [System.IO.IOException]::new( + "Cannot create YAML output directory '$directoryPath': $($directoryError.Exception.Message)", + $directoryError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryCreateFailed' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $directoryError = $_ + $exception = [System.IO.IOException]::new( + "Cannot create YAML output directory '$directoryPath': $($directoryError.Exception.Message)", + $directoryError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryCreateFailed' -Category WriteError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + $destinationExists = $false + $destinationAttributes = [System.IO.FileAttributes]::Normal + try { + $destinationAttributes = [System.IO.File]::GetAttributes($destinationPath) + $destinationExists = $true + } catch [System.IO.FileNotFoundException] { + $destinationExists = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationExists = $false + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInspectionFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($destinationExists -and + ($destinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination is a directory." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportNotFile' -Category InvalidType ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if (-not $approvedDestinationExists -and $destinationExists -and + -not $PSCmdlet.ShouldProcess($destinationPath, 'Replace YAML file')) { + return + } + if ($destinationExists -and $NoClobber) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination already exists." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationExists' -Category ResourceExists ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $destinationWasReadOnly = $false + $destinationUnixMode = $null + if ($destinationExists) { + try { + if (-not $IsWindows) { + $destinationUnixMode = [System.IO.File]::GetUnixFileMode($destinationPath) + } + } catch [System.Security.SecurityException] { + $metadataError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read security metadata for YAML destination '$destinationPath': $($metadataError.Exception.Message)", + $metadataError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.UnauthorizedAccessException] { + $metadataError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read security metadata for YAML destination '$destinationPath': $($metadataError.Exception.Message)", + $metadataError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $metadataError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read security metadata for YAML destination '$destinationPath': $($metadataError.Exception.Message)", + $metadataError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportMetadataReadFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $destinationWasReadOnly = ( + $destinationAttributes -band [System.IO.FileAttributes]::ReadOnly + ) -ne 0 + if ($destinationWasReadOnly -and -not $Force) { + $exception = [System.UnauthorizedAccessException]::new( + "Cannot export YAML to '$destinationPath': the destination is read-only. Use Force to replace it." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationReadOnly' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + $temporaryPath = $null + $backupPath = $null + $backupFileHandle = $null + $replacementAttempted = $false + $replacementCompleted = $false + $replacementOriginalAtBackup = $false + $nativeReplacementCompleted = $false + $attributesCleared = $false + $backupCleanupError = $null + $temporaryCleanupError = $null + try { + $randomName = [System.Convert]::ToHexString( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16) + ).ToLowerInvariant() + $temporaryPath = [System.IO.Path]::Combine( + $directoryPath, + ".yaml-$randomName.tmp" + ) + + try { + $streamOptions = [System.IO.FileStreamOptions]::new() + $streamOptions.Mode = [System.IO.FileMode]::CreateNew + $streamOptions.Access = [System.IO.FileAccess]::Write + $streamOptions.Share = [System.IO.FileShare]::None + $streamOptions.BufferSize = 4096 + $streamOptions.Options = [System.IO.FileOptions]::WriteThrough + if (-not $IsWindows) { + $streamOptions.UnixCreateMode = ( + [System.IO.UnixFileMode]::UserRead -bor + [System.IO.UnixFileMode]::UserWrite + ) + } + + $stream = [System.IO.FileStream]::new($temporaryPath, $streamOptions) + try { + $stream.Write($outputBytes, 0, $outputBytes.Length) + $stream.Flush($true) + if ($destinationExists -and -not $IsWindows) { + [System.IO.File]::SetUnixFileMode( + $stream.SafeFileHandle, + $destinationUnixMode + ) + $stream.Flush($true) + } + } finally { + if ($null -ne $stream) { + $stream.Dispose() + } + } + } catch [System.Security.SecurityException] { + $writeError = $_ + $exception = [System.IO.IOException]::new( + "Cannot secure YAML temporary file for '$destinationPath': $($writeError.Exception.Message)", + $writeError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.UnauthorizedAccessException] { + $writeError = $_ + $exception = [System.IO.IOException]::new( + "Cannot write YAML temporary file for '$destinationPath': $($writeError.Exception.Message)", + $writeError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportWriteFailed' -Category WriteError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $writeError = $_ + $exception = [System.IO.IOException]::new( + "Cannot write YAML temporary file for '$destinationPath': $($writeError.Exception.Message)", + $writeError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportWriteFailed' -Category WriteError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($destinationWasReadOnly -and $IsWindows) { + $prepareError = $null + try { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) + ) + $attributesCleared = $true + } catch [System.UnauthorizedAccessException] { + $prepareError = $_ + } catch [System.IO.IOException] { + $prepareError = $_ + } + if ($null -ne $prepareError) { + if ($attributesCleared) { + try { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes + ) + } catch [System.UnauthorizedAccessException] { + $restoreError = $_ + $exception = [System.IO.IOException]::new( + "Cannot restore read-only YAML destination '$destinationPath': $($restoreError.Exception.Message)", + $restoreError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportReadOnlyRestoreFailed' ` + -Category PermissionDenied -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $restoreError = $_ + $exception = [System.IO.IOException]::new( + "Cannot restore read-only YAML destination '$destinationPath': $($restoreError.Exception.Message)", + $restoreError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportReadOnlyRestoreFailed' ` + -Category WriteError -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + $exception = [System.IO.IOException]::new( + "Cannot prepare read-only YAML destination '$destinationPath' for replacement: $($prepareError.Exception.Message)", + $prepareError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + $moveError = $null + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::NotSpecified + try { + if ($destinationExists -and $IsWindows) { + $backupRandomName = [System.Convert]::ToHexString( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16) + ).ToLowerInvariant() + $backupPath = [System.IO.Path]::Combine( + $directoryPath, + ".yaml-$backupRandomName.old" + ) + $replacementAttempted = $true + [System.IO.File]::Replace( + $temporaryPath, + $destinationPath, + $backupPath, + $false + ) + $nativeReplacementCompleted = $true + $temporaryPath = $null + if ($destinationWasReadOnly) { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes + ) + } + $replacementCompleted = $true + } elseif ($NoClobber -and -not $IsWindows) { + $escapedDirectoryPath = ( + [System.Management.Automation.WildcardPattern]::Escape($directoryPath) + ) + $null = New-Item -Path $escapedDirectoryPath ` + -Name ([System.IO.Path]::GetFileName($destinationPath)) ` + -ItemType HardLink -Value $temporaryPath -Confirm:$false ` + -ErrorAction Stop + try { + [System.IO.File]::Delete($temporaryPath) + $temporaryPath = $null + } catch [System.UnauthorizedAccessException] { + $temporaryCleanupError = $_ + } catch [System.IO.IOException] { + $temporaryCleanupError = $_ + } + } else { + [System.IO.File]::Move( + $temporaryPath, + $destinationPath, + -not $NoClobber + ) + } + $temporaryPath = $null + } catch [System.UnauthorizedAccessException] { + $moveError = $_ + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::PermissionDenied + $replacementOriginalAtBackup = $nativeReplacementCompleted + } catch [System.IO.IOException] { + $moveError = $_ + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::WriteError + $nativeErrorCode = $moveError.Exception.HResult -band 0xFFFF + if ($null -ne $moveError.Exception.InnerException) { + $innerErrorCode = ( + $moveError.Exception.InnerException.HResult -band 0xFFFF + ) + if ($innerErrorCode -ge 1175 -and $innerErrorCode -le 1177) { + $nativeErrorCode = $innerErrorCode + } + } + $replacementOriginalAtBackup = ( + $nativeReplacementCompleted -or $nativeErrorCode -eq 1177 + ) + if ($nativeErrorCode -in 5, 32, 33) { + $moveErrorCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } + } catch { + $moveError = $_ + $moveErrorCategory = $moveError.CategoryInfo.Category + } + + if ($replacementCompleted -and $null -ne $backupPath) { + try { + if ($destinationWasReadOnly) { + $backupFileHandle = [System.IO.File]::OpenHandle( + $backupPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Write, + ( + [System.IO.FileShare]::ReadWrite -bor + [System.IO.FileShare]::Delete + ), + [System.IO.FileOptions]::None + ) + [System.IO.File]::Delete($backupPath) + [System.IO.File]::SetAttributes( + $backupFileHandle, + $destinationAttributes + ) + $backupFileHandle.Dispose() + $backupFileHandle = $null + } else { + [System.IO.File]::Delete($backupPath) + } + $backupPath = $null + } catch [System.UnauthorizedAccessException] { + $backupCleanupError = $_ + } catch [System.IO.IOException] { + $backupCleanupError = $_ + } finally { + if ($null -ne $backupFileHandle) { + $backupFileHandle.Dispose() + $backupFileHandle = $null + } + } + } + if ($null -ne $moveError) { + $destinationCreatedDuringExport = $false + if ($NoClobber) { + try { + $null = [System.IO.File]::GetAttributes($destinationPath) + $destinationCreatedDuringExport = $true + } catch [System.IO.FileNotFoundException] { + $destinationCreatedDuringExport = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationCreatedDuringExport = $false + } catch [System.UnauthorizedAccessException] { + $destinationCreatedDuringExport = $false + } catch [System.IO.IOException] { + $destinationCreatedDuringExport = $false + } + } + $errorId = if ($NoClobber -and ( + $destinationCreatedDuringExport -or $moveErrorCategory -eq ( + [System.Management.Automation.ErrorCategory]::ResourceExists + ) + )) { + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::ResourceExists + 'YamlExportDestinationExists' + } else { + 'YamlExportReplaceFailed' + } + $exception = [System.IO.IOException]::new( + "Cannot replace YAML destination '$destinationPath': $($moveError.Exception.Message)", + $moveError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category $moveErrorCategory -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } finally { + if ($null -ne $backupFileHandle) { + $backupFileHandle.Dispose() + } + if ($null -ne $backupPath) { + if ($replacementCompleted) { + try { + if ($destinationWasReadOnly) { + $backupFileHandle = [System.IO.File]::OpenHandle( + $backupPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Write, + ( + [System.IO.FileShare]::ReadWrite -bor + [System.IO.FileShare]::Delete + ), + [System.IO.FileOptions]::None + ) + [System.IO.File]::Delete($backupPath) + [System.IO.File]::SetAttributes( + $backupFileHandle, + $destinationAttributes + ) + } else { + [System.IO.File]::Delete($backupPath) + } + $backupPath = $null + } catch [System.IO.FileNotFoundException] { + $backupPath = $null + } catch [System.IO.DirectoryNotFoundException] { + $backupPath = $null + } catch [System.UnauthorizedAccessException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $backupCleanupError) { + " A previous cleanup attempt failed: $($backupCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML replacement backup '$backupPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' ` + -Category PermissionDenied -TargetObject $backupPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $backupCleanupError) { + " A previous cleanup attempt failed: $($backupCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML replacement backup '$backupPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' ` + -Category WriteError -TargetObject $backupPath + $PSCmdlet.ThrowTerminatingError($record) + } finally { + if ($null -ne $backupFileHandle) { + $backupFileHandle.Dispose() + $backupFileHandle = $null + } + } + } elseif ($replacementAttempted) { + $backupAvailable = $false + $destinationAvailable = $false + $recoveryCause = $null + $recoveryCategory = [System.Management.Automation.ErrorCategory]::WriteError + try { + $null = [System.IO.File]::GetAttributes($backupPath) + $backupAvailable = $true + } catch [System.IO.FileNotFoundException] { + $backupAvailable = $false + } catch [System.IO.DirectoryNotFoundException] { + $backupAvailable = $false + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + + if ($null -eq $recoveryCause) { + try { + $currentDestinationAttributes = ( + [System.IO.File]::GetAttributes($destinationPath) + ) + $destinationAvailable = $true + } catch [System.IO.FileNotFoundException] { + $destinationAvailable = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationAvailable = $false + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + } + + if ($null -eq $recoveryCause -and + $replacementOriginalAtBackup -and $backupAvailable) { + try { + [System.IO.File]::SetAttributes( + $backupPath, + $destinationAttributes + ) + if ($destinationAvailable) { + $destinationIsReadOnly = ( + $currentDestinationAttributes -band + [System.IO.FileAttributes]::ReadOnly + ) -ne 0 + if ($destinationIsReadOnly) { + [System.IO.File]::SetAttributes( + $destinationPath, + $currentDestinationAttributes -band ( + -bnot [System.IO.FileAttributes]::ReadOnly + ) + ) + } + } + [System.IO.File]::Move( + $backupPath, + $destinationPath, + $true + ) + $backupPath = $null + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + } elseif ($null -eq $recoveryCause -and + $replacementOriginalAtBackup) { + $recoveryCause = [System.IO.FileNotFoundException]::new( + "The replacement backup containing the original YAML file was not found." + ) + } elseif ($null -eq $recoveryCause -and $destinationAvailable) { + if ($attributesCleared) { + try { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes + ) + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + } + if ($null -eq $recoveryCause) { + $backupPath = $null + } + } elseif ($null -eq $recoveryCause) { + $recoveryCause = [System.IO.FileNotFoundException]::new( + "Neither the YAML destination nor its replacement backup could be found." + ) + } + + if ($null -ne $recoveryCause) { + $recoveryMessage = ( + "Cannot recover YAML destination '$destinationPath' after replacement failed. " + + "The original is retained at '$backupPath' when that path exists." + ) + $exception = [System.IO.IOException]::new( + $recoveryMessage, + $recoveryCause + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportRecoveryFailed' ` + -Category $recoveryCategory -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + } + if ($null -ne $temporaryPath) { + $temporaryMissing = $false + try { + $temporaryAttributes = [System.IO.File]::GetAttributes($temporaryPath) + if (($temporaryAttributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) { + [System.IO.File]::SetAttributes( + $temporaryPath, + $temporaryAttributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) + ) + } + [System.IO.File]::Delete($temporaryPath) + } catch [System.IO.FileNotFoundException] { + $temporaryMissing = $true + } catch [System.IO.DirectoryNotFoundException] { + $temporaryMissing = $true + } catch [System.UnauthorizedAccessException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $temporaryCleanupError) { + " A previous cleanup attempt failed: $($temporaryCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML temporary file '$temporaryPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' -Category PermissionDenied ` + -TargetObject $temporaryPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $temporaryCleanupError) { + " A previous cleanup attempt failed: $($temporaryCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML temporary file '$temporaryPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' -Category WriteError ` + -TargetObject $temporaryPath + $PSCmdlet.ThrowTerminatingError($record) + } + if ($temporaryMissing) { + $temporaryPath = $null + } + } + } + + if ($PassThru) { + $fileInfo = [System.IO.FileInfo]::new($destinationPath) + $fileInfo.Refresh() + $PSCmdlet.WriteObject($fileInfo, $false) + } + } +} diff --git a/src/functions/public/Format-Yaml.ps1 b/src/functions/public/Format-Yaml.ps1 new file mode 100644 index 0000000..21e4783 --- /dev/null +++ b/src/functions/public/Format-Yaml.ps1 @@ -0,0 +1,123 @@ +function Format-Yaml { + <# + .SYNOPSIS + Normalizes a YAML stream without projecting it to PowerShell values. + + .DESCRIPTION + Parses YAML into the module's representation graph and emits one + deterministic YAML string directly from those nodes. Document order, + empty documents, effective tags, anchors, aliases, complex keys, node + kinds, scalar content, and mapping order are preserved. + + Comments, directives, flow styles, scalar presentation styles, document + end markers, and source anchor names are normalized. Every document has + an explicit start marker. Output uses LF and has no final newline. + + Pipeline strings are joined with a line feed and parsed as one stream, + matching ConvertFrom-Yaml. + + .PARAMETER InputObject + YAML text. Multiple pipeline records are joined with a line feed and + parsed as one YAML stream. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .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 + Get-Content -Path '.\config.yaml' | Format-Yaml + + Joins the input lines and emits one normalized YAML stream. + + .EXAMPLE + $normalized = Format-Yaml -InputObject '{name: Ada, active: true}' -Indent 4 + + Converts flow presentation to deterministic block presentation with + four-space indentation. + + .INPUTS + System.String[] + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $InputObject, + + [ValidateRange(2, 9)] + [int] $Indent = 2, + + [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 $InputObject) { + $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 + $formatted = ConvertTo-YamlRepresentationText -Documents $documentBox.Value -Indent $Indent + $PSCmdlet.WriteObject($formatted, $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/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/Import-Yaml.ps1 b/src/functions/public/Import-Yaml.ps1 new file mode 100644 index 0000000..40168d8 --- /dev/null +++ b/src/functions/public/Import-Yaml.ps1 @@ -0,0 +1,647 @@ +function Import-Yaml { + <# + .SYNOPSIS + Imports YAML documents from files. + + .DESCRIPTION + Resolves FileSystem files deterministically, decodes each file with a + strict Unicode encoding, and delegates YAML parsing to ConvertFrom-Yaml. + Wildcards are expanded only for Path. Duplicate resolved files are read + once, and each file's documents retain their source order. + + Byte order marks for UTF-8, UTF-16, and UTF-32 are detected + automatically and override Encoding. Files without a byte order mark + default to strict UTF-8. + + .PARAMETER Path + One or more FileSystem paths. Wildcards are expanded. Resolved files + are sorted deterministically and duplicates are suppressed. + + .PARAMETER LiteralPath + One or more literal FileSystem paths. Wildcard characters are not + expanded. + + .PARAMETER Encoding + Encoding for files without a byte order mark. The default is utf8, + which is strict UTF-8 without a byte order mark. + + .PARAMETER AsHashtable + Returns mappings as insertion-ordered dictionaries. + + .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 each file. The default is 100000. + + .PARAMETER MaxAliases + Maximum number of alias nodes in each file. 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 + Import-Yaml -Path '.\config.yaml' + + Imports the YAML documents from config.yaml. + + .EXAMPLE + Get-ChildItem -Path '.\config' -Filter '*.yaml' | Import-Yaml -AsHashtable + + Imports FileInfo pipeline input and returns mappings as dictionaries. + + .EXAMPLE + Import-Yaml -LiteralPath '.\config[production].data' -NoEnumerate + + Imports a literal wildcard-bearing filename and preserves root arrays. + + .INPUTS + System.String + System.IO.FileInfo + + .OUTPUTS + System.Object + + .NOTES + Only FileSystem provider paths are supported. + + .LINK + https://psmodule.io/Yaml/Functions/ConvertFrom-Yaml/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'Path')] + param ( + # Expands wildcard paths and accepts string pipeline values. + [Parameter( + Mandatory, + Position = 0, + ValueFromPipeline, + ParameterSetName = 'Path' + )] + [string[]] $Path, + + # Resolves exact paths from LiteralPath, PSPath, or FullName properties. + [Parameter( + Mandatory, + ValueFromPipelineByPropertyName, + ParameterSetName = 'LiteralPath' + )] + [Alias('PSPath', 'FullName')] + [string[]] $LiteralPath, + + # Selects the fallback decoder when a file has no byte order mark. + [Parameter()] + [ValidateSet('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE')] + [string] $Encoding = 'utf8', + + # Preserves YAML mappings as insertion-ordered dictionaries. + [Parameter()] + [switch] $AsHashtable, + + # Preserves each root YAML sequence as one output record. + [Parameter()] + [switch] $NoEnumerate, + + # Limits YAML node nesting depth. + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + # Limits the number of YAML nodes in each file. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + # Limits alias nodes in each file. + [Parameter()] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + # Limits decoded characters in one scalar. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + # Limits expanded characters in one tag. + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + # Limits cumulative expanded tag characters. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + # Limits digits in numeric scalars. + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $resolvedPaths = [System.Collections.Generic.List[string]]::new() + } + process { + $currentPaths = if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { + $LiteralPath + } else { + $Path + } + foreach ($requestedPath in $currentPaths) { + if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { + try { + $provider = $null + $drive = $null + $providerPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $requestedPath, + [ref] $provider, + [ref] $drive + ) + } catch [System.Management.Automation.ProviderNotFoundException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot import YAML from '$requestedPath': the provider is not available.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.DriveNotFoundException] { + $pathError = $_ + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML input path '$requestedPath': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathResolutionFailed', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($provider.Name -ne 'FileSystem') { + $exception = [System.NotSupportedException]::new( + "Cannot import YAML from '$requestedPath': only the FileSystem provider is supported." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + try { + $pathAttributes = [System.IO.File]::GetAttributes($providerPath) + } catch [System.IO.FileNotFoundException] { + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.DirectoryNotFoundException] { + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$requestedPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPermissionDenied', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$requestedPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathInspectionFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + if (($pathAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot import YAML from '$requestedPath': the path is not a file." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportNotFile', + [System.Management.Automation.ErrorCategory]::InvalidType, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + $resolvedPaths.Add([System.IO.Path]::GetFullPath($providerPath)) + continue + } + + try { + $pathInfos = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath( + $requestedPath + ) + } catch [System.Management.Automation.ItemNotFoundException] { + $pathError = $_ + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.ProviderNotFoundException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot import YAML from '$requestedPath': the provider is not available.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.DriveNotFoundException] { + $pathError = $_ + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML input path '$requestedPath': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathResolutionFailed', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + if ($pathInfos.Count -eq 0) { + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + foreach ($pathInfo in $pathInfos) { + if ($pathInfo.Provider.Name -ne 'FileSystem') { + $exception = [System.NotSupportedException]::new( + "Cannot import YAML from '$($pathInfo.Path)': only the FileSystem provider is supported." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } + try { + $pathAttributes = [System.IO.File]::GetAttributes($pathInfo.ProviderPath) + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$($pathInfo.Path)': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPermissionDenied', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$($pathInfo.Path)': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathInspectionFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } + if (($pathAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot import YAML from '$($pathInfo.Path)': the path is not a file." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportNotFile', + [System.Management.Automation.ErrorCategory]::InvalidType, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } + $resolvedPaths.Add([System.IO.Path]::GetFullPath($pathInfo.ProviderPath)) + } + } + } + end { + + $pathsByIdentity = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::Ordinal + ) + $directoryNameGroups = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($resolvedPath in $resolvedPaths) { + $identityComponents = [System.Collections.Generic.List[string]]::new() + $identityPath = $resolvedPath + $identityFailed = $false + + while ($true) { + $directoryPath = [System.IO.Path]::GetDirectoryName($identityPath) + $leafName = [System.IO.Path]::GetFileName($identityPath) + if ([string]::IsNullOrEmpty($directoryPath) -or + [string]::IsNullOrEmpty($leafName)) { + break + } + + $nameGroups = $null + try { + if (-not $directoryNameGroups.TryGetValue( + $directoryPath, + [ref]$nameGroups + )) { + $nameGroups = ( + [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + ) + foreach ($directoryEntry in ( + [System.IO.Directory]::EnumerateFileSystemEntries($directoryPath) + )) { + $entryName = [System.IO.Path]::GetFileName($directoryEntry) + $entryIdentityName = $entryName.Normalize( + [System.Text.NormalizationForm]::FormC + ) + $nameGroup = $null + if ($nameGroups.TryGetValue( + $entryIdentityName, + [ref]$nameGroup + )) { + $nameGroup.Count++ + } else { + $nameGroups.Add( + $entryIdentityName, + [pscustomobject]@{ + Count = 1 + CanonicalName = $entryName + } + ) + } + } + $directoryNameGroups.Add($directoryPath, $nameGroups) + } + + $nameGroup = $null + $leafIdentityName = $leafName.Normalize( + [System.Text.NormalizationForm]::FormC + ) + if (-not $nameGroups.TryGetValue( + $leafIdentityName, + [ref]$nameGroup + )) { + $identityFailed = $true + break + } + if ($nameGroup.Count -gt 1) { + $identityComponents.Add($leafName) + } else { + $identityComponents.Add($nameGroup.CanonicalName) + } + } catch [System.UnauthorizedAccessException] { + $identityFailed = $true + break + } catch [System.IO.IOException] { + $identityFailed = $true + break + } + + $identityPath = $directoryPath + } + + if ($identityFailed) { + $identityKey = $resolvedPath + } else { + $rootPath = [System.IO.Path]::GetPathRoot($resolvedPath) + if ($IsWindows) { + $rootPath = $rootPath.ToUpperInvariant() + } + $identityComponents.Add($rootPath) + $identityComponents.Reverse() + $identityKeyBuilder = [System.Text.StringBuilder]::new() + foreach ($identityComponent in $identityComponents) { + $null = $identityKeyBuilder.Append($identityComponent.Length) + $null = $identityKeyBuilder.Append(':') + $null = $identityKeyBuilder.Append($identityComponent) + } + $identityKey = $identityKeyBuilder.ToString() + } + + $null = $pathsByIdentity.TryAdd($identityKey, $resolvedPath) + } + + [string[]] $orderedIdentityKeys = $pathsByIdentity.Keys + [System.Array]::Sort($orderedIdentityKeys, [System.StringComparer]::Ordinal) + + foreach ($identityKey in $orderedIdentityKeys) { + $resolvedPath = $pathsByIdentity[$identityKey] + try { + $bytes = [System.IO.File]::ReadAllBytes($resolvedPath) + } catch [System.UnauthorizedAccessException] { + $readError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read YAML file '$resolvedPath': $($readError.Exception.Message)", + $readError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportReadFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $readError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read YAML file '$resolvedPath': $($readError.Exception.Message)", + $readError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportReadFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + $textEncoding = Get-YamlTextEncoding -Name $Encoding + $preambleLength = 0 + if ($bytes.Length -ge 4 -and + $bytes[0] -eq 0x00 -and $bytes[1] -eq 0x00 -and + $bytes[2] -eq 0xFE -and $bytes[3] -eq 0xFF) { + $textEncoding = Get-YamlTextEncoding -Name 'utf32BE' + $preambleLength = 4 + } elseif ($bytes.Length -ge 4 -and + $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE -and + $bytes[2] -eq 0x00 -and $bytes[3] -eq 0x00) { + $textEncoding = Get-YamlTextEncoding -Name 'utf32LE' + $preambleLength = 4 + } elseif ($bytes.Length -ge 3 -and + $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and + $bytes[2] -eq 0xBF) { + $textEncoding = Get-YamlTextEncoding -Name 'utf8BOM' + $preambleLength = 3 + } elseif ($bytes.Length -ge 2 -and + $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { + $textEncoding = Get-YamlTextEncoding -Name 'utf16BE' + $preambleLength = 2 + } elseif ($bytes.Length -ge 2 -and + $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { + $textEncoding = Get-YamlTextEncoding -Name 'utf16LE' + $preambleLength = 2 + } + + try { + $yamlText = $textEncoding.GetString( + $bytes, + $preambleLength, + $bytes.Length - $preambleLength + ) + } catch [System.Text.DecoderFallbackException] { + $decodeError = $_ + $exception = [System.Text.DecoderFallbackException]::new( + "Cannot decode YAML file '$resolvedPath': the byte sequence is invalid.", + $decodeError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportEncodingFailed', + [System.Management.Automation.ErrorCategory]::InvalidData, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + $parserParameters = @{ + Yaml = $yamlText + AsHashtable = $AsHashtable + NoEnumerate = $NoEnumerate + Depth = $Depth + MaxNodes = $MaxNodes + MaxAliases = $MaxAliases + MaxScalarLength = $MaxScalarLength + MaxTagLength = $MaxTagLength + MaxTotalTagLength = $MaxTotalTagLength + MaxNumericLength = $MaxNumericLength + } + try { + ConvertFrom-Yaml @parserParameters | ForEach-Object { + $PSCmdlet.WriteObject($_, $false) + } + } catch { + $parseError = $_ + $errorId = ($parseError.FullyQualifiedErrorId -split ',')[0] + if ([string]::IsNullOrWhiteSpace($errorId)) { + $errorId = 'YamlImportParseFailed' + } + $exception = [System.FormatException]::new( + "Cannot parse YAML file '$resolvedPath': $($parseError.Exception.Message)", + $parseError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + $errorId, + $parseError.CategoryInfo.Category, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + } + } +} diff --git a/src/functions/public/Merge-Yaml.ps1 b/src/functions/public/Merge-Yaml.ps1 new file mode 100644 index 0000000..a2dd73d --- /dev/null +++ b/src/functions/public/Merge-Yaml.ps1 @@ -0,0 +1,279 @@ +function Merge-Yaml { + <# + .SYNOPSIS + Merges two or more complete YAML streams. + + .DESCRIPTION + Parses every input string as one complete YAML stream, merges documents + pairwise by zero-based document index, and emits one deterministic YAML + string directly from the resulting representation graphs. Every stream + must contain the same positive number of documents. Later streams have + higher precedence. + + Compatible mappings merge recursively by YAML structural key equality. + Base key order is retained, replaced values keep their position, and new + overlay keys append in overlay order. Tags, complex keys, anchors, + aliases, shared nodes, and recursive graphs remain representation data; + values are never projected through PowerShell objects or dictionaries. + + Output uses LF line endings, has no final newline, and explicitly starts + every document. Input array elements and pipeline records are complete + streams, not individual lines. Use Get-Content -Raw or Import-Yaml file + handling as appropriate. + + .PARAMETER InputObject + Two or more complete YAML stream strings. Each array element or pipeline + record is parsed independently as one stream. + + .PARAMETER SequenceAction + Action for unequal sequences with compatible effective tags. Replace uses + the later sequence, Append concatenates entries, and Unique appends only + structurally new entries. The default is Replace. + + .PARAMETER ConflictAction + Action for unequal scalars, collection-kind differences, or incompatible + effective tags. Replace uses the later node and Error terminates with + document, path, and input context. The default is Replace. + + .PARAMETER NullAction + Action when a later existing node has the YAML null effective tag. + Replace applies normal merge and conflict behavior; Ignore retains the + prior node. The default is Replace. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum nodes per parsed stream and the invocation-wide ceiling applied + independently to clone creation, merge operations, and the result graph. + The default is 100000. + + .PARAMETER MaxAliases + Maximum aliases per parsed stream and in the result. 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 per input and in the result. + The default is 65536. + + .PARAMETER MaxNumericLength + Maximum digits in an implicitly or explicitly typed number. The default + is 4096. + + .EXAMPLE + $merged = Merge-Yaml -InputObject @($baseYaml, $overlayYaml) + + Merges two complete stream strings with the overlay taking precedence. + + .EXAMPLE + $baseYaml, $environmentYaml, $secretYaml | Merge-Yaml -SequenceAction Unique + + Merges three complete pipeline stream records and deduplicates compatible + sequence entries structurally. + + .EXAMPLE + $base = Get-Content -LiteralPath '.\base.yaml' -Raw + $overlay = Get-Content -LiteralPath '.\overlay.yaml' -Raw + Merge-Yaml -InputObject @($base, $overlay) -ConflictAction Error -Indent 4 + + Reads complete files and rejects incompatible merge conflicts. + + .INPUTS + System.String[] + + .OUTPUTS + System.String + + .NOTES + YAML 1.1 merge keys are ordinary mapping data and are never expanded. + + .LINK + https://github.com/PSModule/Yaml#format-yaml-streams + #> + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $InputObject, + + [Parameter()] + [ValidateSet('Replace', 'Append', 'Unique')] + [string] $SequenceAction = 'Replace', + + [Parameter()] + [ValidateSet('Replace', 'Error')] + [string] $ConflictAction = 'Replace', + + [Parameter()] + [ValidateSet('Replace', 'Ignore')] + [string] $NullAction = 'Replace', + + [Parameter()] + [ValidateRange(2, 9)] + [int] $Indent = 2, + + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [Parameter()] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $streams = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($stream in $InputObject) { + $streams.Add($stream) + } + } + end { + $fingerprintHasher = $null + try { + if ($streams.Count -lt 2) { + throw (New-YamlMergeException -ErrorId 'YamlMergeInputCount' -Message ( + "Merge-Yaml requires at least two complete YAML streams; received $($streams.Count)." + )) + } + + $parsedStreams = [System.Collections.Generic.List[object]]::new() + $expectedDocumentCount = -1 + for ($inputIndex = 0; $inputIndex -lt $streams.Count; $inputIndex++) { + $documentBox = Read-YamlStream -Yaml $streams[$inputIndex] -Depth $Depth ` + -MaxNodes $MaxNodes -MaxAliases $MaxAliases ` + -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength + $documents = [object[]] $documentBox.Value + if ($documents.Count -eq 0) { + throw (New-YamlMergeException -ErrorId 'YamlMergeEmptyStream' -Message ( + "YAML input index $inputIndex contains 0 documents; every stream must contain " + + 'the same positive document count.' + )) + } + if ($inputIndex -eq 0) { + $expectedDocumentCount = $documents.Count + } elseif ($documents.Count -ne $expectedDocumentCount) { + throw (New-YamlMergeException ` + -ErrorId 'YamlMergeDocumentCountMismatch' -Message ( + "YAML input index $inputIndex contains $($documents.Count) documents; " + + "expected $expectedDocumentCount." + )) + } + $parsedStreams.Add($documents) + } + + $cloneState = [pscustomobject]@{ + NextId = 1 + CreatedNodes = 0 + MaxNodes = $MaxNodes + } + $resultDocuments = [System.Collections.Generic.List[object]]::new() + $baseCache = [System.Collections.Generic.Dictionary[int, object]]::new() + foreach ($document in $parsedStreams[0]) { + $resultDocuments.Add(( + Copy-YamlMergeNode -Node $document -Cache $baseCache -State $cloneState + )) + } + + $fingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + $workState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $mutationState = [pscustomobject]@{ Version = 0L } + $indexDependents = [System.Collections.Generic.Dictionary[int, object]]::new() + $equalityState = [pscustomobject]@{ + MaxNodes = $MaxNodes + FingerprintHasher = $fingerprintHasher + WorkState = $workState + MutationState = $mutationState + IndexDependents = $indexDependents + Cache = [System.Collections.Generic.Dictionary[string, bool]]::new( + [System.StringComparer]::Ordinal + ) + InputIndex = 0 + } + $mappingIndexes = [System.Collections.Generic.Dictionary[int, object]]::new() + $sequenceIndexes = [System.Collections.Generic.Dictionary[int, object]]::new() + for ($inputIndex = 1; $inputIndex -lt $parsedStreams.Count; $inputIndex++) { + $cloneCache = [System.Collections.Generic.Dictionary[int, object]]::new() + $overlayFingerprintCache = ( + [System.Collections.Generic.Dictionary[int, string]]::new() + ) + $equalityState.InputIndex = $inputIndex + foreach ($documentIndex in 0..($expectedDocumentCount - 1)) { + $context = [pscustomobject]@{ + InputIndex = $inputIndex + DocumentIndex = $documentIndex + CloneCache = $cloneCache + CloneState = $cloneState + EqualityState = $equalityState + WorkState = $workState + MutationState = $mutationState + MappingIndexes = $mappingIndexes + SequenceIndexes = $sequenceIndexes + IndexDependents = $indexDependents + OverlayFingerprintCache = $overlayFingerprintCache + } + $resultDocuments[$documentIndex] = Merge-YamlRepresentationNode ` + -BaseNode $resultDocuments[$documentIndex] ` + -OverlayNode $parsedStreams[$inputIndex][$documentIndex] ` + -SequenceAction $SequenceAction -ConflictAction $ConflictAction ` + -NullAction $NullAction -Path '$' -Context $context + } + } + + $resultArray = [object[]] $resultDocuments.ToArray() + Assert-YamlMergeGraph -Documents $resultArray -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength ` + -MaxTagLength $MaxTagLength -MaxTotalTagLength $MaxTotalTagLength + $merged = ConvertTo-YamlRepresentationText -Documents $resultArray -Indent $Indent + Write-Debug "Merge-Yaml work operations: $($workState.Count)." + $PSCmdlet.WriteObject($merged, $false) + } catch { + $failure = $_ + if (-not $failure.Exception.Data.Contains('IsYamlException')) { + throw + } + $record = New-YamlErrorRecord -Exception $failure.Exception ` + -DefaultErrorId 'YamlMergeFailed' -Category InvalidData ` + -TargetObject $streams.ToArray() + $PSCmdlet.ThrowTerminatingError($record) + } finally { + if ($null -ne $fingerprintHasher) { + $fingerprintHasher.Dispose() + } + } + } +} diff --git a/src/functions/public/Remove-YamlEntry.ps1 b/src/functions/public/Remove-YamlEntry.ps1 new file mode 100644 index 0000000..384413e --- /dev/null +++ b/src/functions/public/Remove-YamlEntry.ps1 @@ -0,0 +1,288 @@ +function Remove-YamlEntry { + <# + .SYNOPSIS + Removes entries from a YAML representation graph by JSON Pointer. + + .DESCRIPTION + Aggregates input strings with a line feed, parses them as one YAML stream, + deep-clones the representation graph, resolves every RFC 6901 JSON Pointer + against that unchanged clone, and emits one deterministic YAML string after + applying the complete removal transaction. + + Mapping tokens address only scalar keys whose effective YAML tag is string + and whose scalar content is an ordinal match. Complex, non-string, and + unknown-tagged keys remain intact and are never coerced. Sequence tokens + must be canonical non-negative decimal indexes. Tilde escapes are strict: + ~0 decodes to tilde and ~1 decodes to slash. + + Aliases are traversed by node identity. Removing inside a shared collection + changes every alias to that collection, while targeting an alias edge removes + only that parent edge. Duplicate logical targets are coalesced, ancestors + subsume descendants, and sequence entries are removed by descending original + index. + + Output preserves unaffected tags, anchors, aliases, shared and cyclic graph + identity, complex keys, mapping order, and document order. It uses LF line + endings, has no final newline, and explicitly starts every remaining + document. + + .PARAMETER InputObject + YAML text. Multiple array elements or pipeline records are joined with a + line feed and parsed as one YAML stream. + + .PARAMETER Path + One or more RFC 6901 JSON Pointers. An empty string selects a document root. + Every non-empty pointer must start with slash. + + .PARAMETER DocumentIndex + Zero-based document index to modify. The default is 0. + + .PARAMETER AllDocuments + Applies every path independently to every document in the original stream. + + .PARAMETER IgnoreMissing + Skips unresolved document and path combinations. Invalid pointer syntax, + invalid sequence index tokens, ambiguous matches, and an unavailable + DocumentIndex still terminate. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum YAML nodes in the input and result, and the invocation-wide ceiling + applied independently to clone creation, removal work, and output validation. + The default is 100000. + + .PARAMETER MaxAliases + Maximum aliases in the input and result. 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 + Get-Content -Path '.\config.yaml' | + Remove-YamlEntry -Path '/service/obsolete' + + Aggregates file lines and removes one nested mapping entry from document zero. + + .EXAMPLE + $clean = Remove-YamlEntry -InputObject $yaml -Path @('/metadata/identifier', '/items/2') + + Resolves both paths against the original graph, then applies them atomically. + + .EXAMPLE + Remove-YamlEntry $stream '/temporary' -AllDocuments -IgnoreMissing -Indent 4 + + Removes the key where it exists in every document and emits four-space YAML. + + .EXAMPLE + Remove-YamlEntry $stream '' -DocumentIndex 1 + + Removes the second YAML document from the stream. + + .INPUTS + System.String[] + + .OUTPUTS + System.String + + .LINK + https://github.com/PSModule/Yaml#remove-yaml-entries + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Transforms an isolated in-memory YAML graph and returns text.' + )] + [OutputType([string])] + [CmdletBinding(DefaultParameterSetName = 'Document')] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $InputObject, + + [Parameter(Mandatory, Position = 1)] + [AllowEmptyString()] + [string[]] $Path, + + [Parameter(ParameterSetName = 'Document')] + [ValidateRange(0, 2147483647)] + [int] $DocumentIndex = 0, + + [Parameter(Mandatory, ParameterSetName = 'AllDocuments')] + [switch] $AllDocuments, + + [Parameter()] + [switch] $IgnoreMissing, + + [Parameter()] + [ValidateRange(2, 9)] + [int] $Indent = 2, + + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [Parameter()] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $lines = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($line in $InputObject) { + $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 + $sourceDocuments = [object[]] $documentBox.Value + $selectAllDocuments = $AllDocuments.IsPresent + if (-not $selectAllDocuments -and $DocumentIndex -ge $sourceDocuments.Count) { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalDocumentIndexOutOfRange' -Message ( + "YAML document index $DocumentIndex is unavailable; the stream contains " + + "$($sourceDocuments.Count) documents." + )) + } + + $workState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $parsedPointers = [System.Collections.Generic.List[object]]::new() + foreach ($pointer in $Path) { + $tokens = (ConvertFrom-YamlJsonPointer -Pointer $pointer -State $workState).Value + $parsedPointers.Add([pscustomobject]@{ + Pointer = $pointer + Tokens = [string[]] $tokens + }) + } + + $cloneState = [pscustomobject]@{ + NextId = 1 + CreatedNodes = 0 + MaxNodes = $MaxNodes + } + $resultDocuments = [System.Collections.Generic.List[object]]::new() + $cloneCache = [System.Collections.Generic.Dictionary[int, object]]::new() + try { + foreach ($sourceDocument in $sourceDocuments) { + $resultDocuments.Add(( + Copy-YamlMergeNode -Node $sourceDocument -Cache $cloneCache ` + -State $cloneState + )) + } + } catch { + if ($_.Exception.Data.Contains('YamlErrorId') -and + $_.Exception.Data['YamlErrorId'] -ceq 'YamlMergeNodeLimitExceeded') { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalNodeLimitExceeded' -Message ( + "Cloning the YAML removal graph exceeded the configured limit of " + + "$MaxNodes nodes." + )) + } + throw + } + $resolvedTargets = [System.Collections.Generic.List[object]]::new() + if ($selectAllDocuments) { + for ($currentDocumentIndex = 0; $currentDocumentIndex -lt + $resultDocuments.Count; $currentDocumentIndex++) { + foreach ($parsedPointer in $parsedPointers) { + $resolution = Resolve-YamlRemovalTarget ` + -Document $resultDocuments[$currentDocumentIndex] ` + -DocumentIndex $currentDocumentIndex ` + -Pointer $parsedPointer.Pointer -Tokens $parsedPointer.Tokens ` + -State $workState + if ($resolution.Found) { + $resolvedTargets.Add($resolution) + } elseif (-not $IgnoreMissing) { + throw (New-YamlRemovalException -Node $resolution.Node ` + -ErrorId $resolution.ErrorId -Message $resolution.Message) + } + } + } + } else { + foreach ($parsedPointer in $parsedPointers) { + $resolution = Resolve-YamlRemovalTarget ` + -Document $resultDocuments[$DocumentIndex] ` + -DocumentIndex $DocumentIndex ` + -Pointer $parsedPointer.Pointer -Tokens $parsedPointer.Tokens ` + -State $workState + if ($resolution.Found) { + $resolvedTargets.Add($resolution) + } elseif (-not $IgnoreMissing) { + throw (New-YamlRemovalException -Node $resolution.Node ` + -ErrorId $resolution.ErrorId -Message $resolution.Message) + } + } + } + + $targets = ( + Select-YamlRemovalTarget -Targets ([object[]] $resolvedTargets.ToArray()) ` + -State $workState + ).Value + Remove-YamlRepresentationTarget -Documents $resultDocuments ` + -Targets ([object[]] $targets) -State $workState + $resultArray = [object[]] $resultDocuments.ToArray() + $validationState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + Assert-YamlRemovalGraph -Documents $resultArray -Depth $Depth ` + -MaxNodes $MaxNodes -MaxAliases $MaxAliases ` + -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -State $validationState + $result = ConvertTo-YamlRepresentationText -Documents $resultArray -Indent $Indent + Write-Debug "Remove-YamlEntry work operations: $($workState.Count)." + $PSCmdlet.WriteObject($result, $false) + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $record = New-YamlErrorRecord -Exception $_.Exception ` + -DefaultErrorId 'YamlRemovalFailed' -Category InvalidData ` + -TargetObject $yamlText + $PSCmdlet.ThrowTerminatingError($record) + } + } +} 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/tests/Conformance.Tests.ps1 b/tests/Conformance.Tests.ps1 new file mode 100644 index 0000000..24f0515 --- /dev/null +++ b/tests/Conformance.Tests.ps1 @@ -0,0 +1,544 @@ +#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' + $sourcesPath = Join-Path $PSScriptRoot 'fixtures\yaml-test-suite\SOURCES.txt' + $repositoryRoot = Split-Path -Parent $PSScriptRoot + $suitePath = Join-Path $TestDrive 'yaml-test-suite' + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') + $conformanceYamlModule = $yamlModule + Expand-Archive -LiteralPath $archivePath -DestinationPath $suitePath + $suiteRoots = @( + Get-ChildItem -LiteralPath $suitePath -Directory + ) + if ($suiteRoots.Count -ne 1) { + throw "Expected one release archive root, but found $($suiteRoots.Count)." + } + $suiteDataPath = $suiteRoots[0].FullName + $runnerPath = Join-Path $PSScriptRoot 'tools\Invoke-YamlTestSuite.ps1' + $suiteResults = @( + & $runnerPath ` + -Path $suiteDataPath ` + -CompareJson ` + -CompareEvents ` + -CompareOutYaml ` + -CompareEmitYaml ` + -CompareSelfRoundTrip ` + -CompareFormatter + ) + $emptySuitePath = Join-Path $TestDrive 'empty-suite' + $null = New-Item -Path $emptySuitePath -ItemType Directory + . $runnerPath -Path $emptySuitePath -CompareJson +} + +Describe 'Released yaml-test-suite corpus accounting' { + It 'uses the built module for conformance in GitHub Actions' { + if ($env:GITHUB_ACTIONS -eq 'true') { + $conformanceYamlModule | Should -Not -BeNullOrEmpty + $command = Get-Command -Name ConvertFrom-Yaml + $command.Module | Should -Be $conformanceYamlModule + (Get-Command -Name Format-Yaml).Module | Should -Be $conformanceYamlModule + $conformanceYamlModule.ModuleBase | + Should -Not -Be (Join-Path $repositoryRoot 'src') + $conformanceYamlModule.PowerShellVersion | Should -Be '7.6' + @($conformanceYamlModule.CompatiblePSEditions) | Should -Be @('Core') + } + } + + It 'uses the pinned unmodified release archive' { + (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash | + Should -Be '47C173AFFEB480517B30FB77DC8C76FD48609B9B65DD1C1D3D0D0BAEE48D6AA9' + $suiteResults.Count | Should -Be 402 + } + + It 'pins the latest official source and data release attribution' { + $sources = Get-Content -LiteralPath $sourcesPath -Raw + + $sources | Should -Match 'Latest source release:\s+v2022-01-17' + $sources | Should -Match '45db50aecf9b1520f8258938c88f396e96f30831' + $sources | Should -Match '6e6c296ae9c9d2d5c4134b4b64d01b29ac19ff6f' + } + + It 'accounts for syntax recognition and representation-key load policy' { + @($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 + $policyResults = @( + $suiteResults | + Where-Object SyntaxResult -EQ 'PolicyDifference' | + Sort-Object Case + ) + @($policyResults.Case) | Should -Be @('2JQS', 'X38W') + @($policyResults.SyntaxReason | Select-Object -Unique) | + Should -Be @('RepresentationMappingKeyUniqueness') + @($policyResults | Where-Object EventResult -NE 'Pass').Count | Should -Be 0 + } + + 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 + $policyResults = @( + $suiteResults | + Where-Object JsonResult -EQ 'PolicyDifference' | + Sort-Object Case + ) + @($policyResults.Case) | Should -Be @('565N', 'J7PZ') + @($policyResults.JsonReason) | Should -Be @( + 'BinaryByteArrayProjection', + 'LegacyOrderedMapProjection' + ) + } + + It 'does not mask altered JSON values as projection policy' { + $mutatedSuitePath = Join-Path $TestDrive 'mutated-policy-cases' + $null = New-Item -Path $mutatedSuitePath -ItemType Directory -Force + foreach ($case in @('565N', 'J7PZ')) { + Copy-Item -LiteralPath (Join-Path $suiteDataPath $case) ` + -Destination $mutatedSuitePath -Recurse + } + + $binaryJsonPath = Join-Path $mutatedSuitePath '565N\in.json' + $binaryJson = Get-Content -LiteralPath $binaryJsonPath -Raw | + ConvertFrom-Json -AsHashtable + $binaryJson['description'] = 'Altered expected value' + $binaryJson | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $binaryJsonPath -Encoding utf8NoBOM + + $orderedMapJsonPath = Join-Path $mutatedSuitePath 'J7PZ\in.json' + $orderedMapJson = Get-Content -LiteralPath $orderedMapJsonPath -Raw | + ConvertFrom-Json -AsHashtable + $orderedMapJson[0]['Mark McGwire'] = 66 + $orderedMapJson | ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $orderedMapJsonPath -Encoding utf8NoBOM + + $mutatedResults = @( + & (Join-Path $PSScriptRoot 'tools\Invoke-YamlTestSuite.ps1') ` + -Path $mutatedSuitePath ` + -CompareJson + ) + @($mutatedResults | Sort-Object Case | Select-Object -ExpandProperty Case) | + Should -Be @('565N', 'J7PZ') + @($mutatedResults | Where-Object JsonResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($mutatedResults | Where-Object JsonResult -EQ 'Fail').Count | Should -Be 2 + @($mutatedResults.JsonReason | Select-Object -Unique) | + Should -Be @('ConstructedValueMismatch') + } + + It 'canonicalizes binary, complex-key, ordered, and unsupported values distinctly' { + (ConvertTo-YamlSuiteCanonicalValue -Value ([byte[]] @(1, 2))) | + Should -Not -Be (ConvertTo-YamlSuiteCanonicalValue -Value ([byte[]] @(3, 4))) + + $firstComplex = [System.Collections.Specialized.OrderedDictionary]::new() + $secondComplex = [System.Collections.Specialized.OrderedDictionary]::new() + $firstComplex.Add([object[]] @('a'), 1) + $secondComplex.Add([object[]] @('b'), 1) + (ConvertTo-YamlSuiteCanonicalValue -Value $firstComplex) | + Should -Not -Be (ConvertTo-YamlSuiteCanonicalValue -Value $secondComplex) + + $firstOrder = [System.Collections.Specialized.OrderedDictionary]::new() + $secondOrder = [System.Collections.Specialized.OrderedDictionary]::new() + $firstOrder.Add('a', 1) + $firstOrder.Add('b', 2) + $secondOrder.Add('b', 2) + $secondOrder.Add('a', 1) + $ordinarySameOrder = [System.Collections.Specialized.OrderedDictionary]::new() + $ordinarySameOrder.Add('a', 1) + $ordinarySameOrder.Add('b', 2) + (ConvertTo-YamlSuiteCanonicalValue -Value $firstOrder) | + Should -Be (ConvertTo-YamlSuiteCanonicalValue -Value $secondOrder) + + $orderedMappings = [System.Collections.Generic.HashSet[object]]::new( + [System.Collections.Generic.ReferenceEqualityComparer]::Instance + ) + [void] $orderedMappings.Add($firstOrder) + [void] $orderedMappings.Add($secondOrder) + (ConvertTo-YamlSuiteCanonicalValue -Value $firstOrder ` + -OrderedMappings $orderedMappings) | + Should -Not -Be ( + ConvertTo-YamlSuiteCanonicalValue -Value $secondOrder ` + -OrderedMappings $orderedMappings + ) + (ConvertTo-YamlSuiteCanonicalValue -Value $firstOrder ` + -OrderedMappings $orderedMappings) | + Should -Not -Be ( + ConvertTo-YamlSuiteCanonicalValue -Value $ordinarySameOrder ` + -OrderedMappings $orderedMappings + ) + + $firstNestedKey = [System.Collections.Specialized.OrderedDictionary]::new() + $secondNestedKey = [System.Collections.Specialized.OrderedDictionary]::new() + $firstNestedKey.Add($firstOrder, 'value') + $secondNestedKey.Add($secondOrder, 'value') + (ConvertTo-YamlSuiteCanonicalValue -Value $firstNestedKey) | + Should -Be (ConvertTo-YamlSuiteCanonicalValue -Value $secondNestedKey) + (ConvertTo-YamlSuiteCanonicalValue -Value $firstNestedKey ` + -OrderedMappings $orderedMappings) | + Should -Not -Be ( + ConvertTo-YamlSuiteCanonicalValue -Value $secondNestedKey ` + -OrderedMappings $orderedMappings + ) + + (ConvertTo-YamlSuiteCanonicalValue -Value ([uri] 'https://example.com/one')) | + Should -Not -Be ( + ConvertTo-YamlSuiteCanonicalValue -Value ([uri] 'https://example.com/two') + ) + } + + It 'includes binary and complex mapping-key identity in reference signatures' { + $sharedBytes = [byte[]] @(1, 2) + $sharedBinaryGraph = [object[]] @($sharedBytes, $sharedBytes) + $distinctBinaryGraph = [object[]] @([byte[]] @(1, 2), [byte[]] @(1, 2)) + (ConvertTo-YamlSuiteReferenceSignature -Value $sharedBinaryGraph) | + Should -Not -Be (ConvertTo-YamlSuiteReferenceSignature -Value $distinctBinaryGraph) + + $sharedKey = [object[]] @('key') + $sharedKeyGraph = [System.Collections.Specialized.OrderedDictionary]::new() + $sharedKeyGraph.Add($sharedKey, $sharedKey) + $distinctKeyGraph = [System.Collections.Specialized.OrderedDictionary]::new() + $distinctKeyGraph.Add([object[]] @('key'), [object[]] @('key')) + (ConvertTo-YamlSuiteReferenceSignature -Value $sharedKeyGraph) | + Should -Not -Be (ConvertTo-YamlSuiteReferenceSignature -Value $distinctKeyGraph) + } + + It 'distinguishes non-specific tags in canonical representation events' { + $plain = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('"value"') + $nonSpecific = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('! value') + $plainEvents = ConvertTo-YamlSuiteActualEvent -Documents $plain.Value + $nonSpecificEvents = ConvertTo-YamlSuiteActualEvent -Documents $nonSpecific.Value + $oracleEvents = ConvertFrom-YamlSuiteEventText -Text @' ++STR ++DOC +=VAL :value +-DOC +-STR +'@ + + (Compare-YamlSuiteCanonicalList -Left $plainEvents -Right $nonSpecificEvents) | + Should -BeFalse + $nonSpecificEvents | Should -Be $oracleEvents + $nonSpecificEvents | Should -Contain '=VAL|nonSpecificTag=true|value=value' + } + + It 'distinguishes implicit scalar tags without treating style as semantic' { + foreach ($mutation in @( + @{ Plain = 'true'; Quoted = '"true"'; Tag = 'tag:yaml.org,2002:bool' } + @{ Plain = '42'; Quoted = '"42"'; Tag = 'tag:yaml.org,2002:int' } + @{ Plain = '1.5'; Quoted = '"1.5"'; Tag = 'tag:yaml.org,2002:float' } + @{ Plain = '.inf'; Quoted = '".inf"'; Tag = 'tag:yaml.org,2002:float' } + @{ Plain = 'null'; Quoted = '"null"'; Tag = 'tag:yaml.org,2002:null' } + )) { + $plain = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @($mutation.Plain) + $quoted = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @($mutation.Quoted) + $plainEvents = ConvertTo-YamlSuiteActualEvent -Documents $plain.Value ` + -IncludeEffectiveScalarTags + $quotedEvents = ConvertTo-YamlSuiteActualEvent -Documents $quoted.Value ` + -IncludeEffectiveScalarTags + + (Compare-YamlSuiteCanonicalList -Left $plainEvents -Right $quotedEvents) | + Should -BeFalse + $plainEvents | Should -Contain ( + '=VAL|tag={0}|value={1}' -f $mutation.Tag, $mutation.Plain + ) + $quotedEvents | Should -Contain ( + '=VAL|tag=tag:yaml.org,2002:str|value={0}' -f $mutation.Plain + ) + } + + $singleQuoted = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @("'value'") + $doubleQuoted = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('"value"') + $singleEvents = ConvertTo-YamlSuiteActualEvent -Documents $singleQuoted.Value ` + -IncludeEffectiveScalarTags + $doubleEvents = ConvertTo-YamlSuiteActualEvent -Documents $doubleQuoted.Value ` + -IncludeEffectiveScalarTags + + (Compare-YamlSuiteCanonicalList -Left $singleEvents -Right $doubleEvents) | + Should -BeTrue + + $explicit = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('!!str value') + $unknown = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('!local value') + $nonSpecific = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('! value') + $explicitEvents = ConvertTo-YamlSuiteActualEvent -Documents $explicit.Value ` + -IncludeEffectiveScalarTags + $unknownEvents = ConvertTo-YamlSuiteActualEvent -Documents $unknown.Value ` + -IncludeEffectiveScalarTags + $nonSpecificEvents = ConvertTo-YamlSuiteActualEvent -Documents $nonSpecific.Value ` + -IncludeEffectiveScalarTags + + $explicitEvents | + Should -Contain '=VAL|tag=tag:yaml.org,2002:str|explicitTag=true|value=value' + $unknownEvents | Should -Contain '=VAL|tag=!local|explicitTag=true|value=value' + $nonSpecificEvents | + Should -Contain '=VAL|tag=tag:yaml.org,2002:str|nonSpecificTag=true|value=value' + (Compare-YamlSuiteCanonicalList -Left $explicitEvents -Right $doubleEvents) | + Should -BeFalse + } + + It 'detects altered ordered-map and alias semantics in out.yaml' { + $mutatedSuitePath = Join-Path $TestDrive 'mutated-out-cases' + $null = New-Item -Path $mutatedSuitePath -ItemType Directory -Force + foreach ($case in @('J7PZ', 'UGM3')) { + Copy-Item -LiteralPath (Join-Path $suiteDataPath $case) ` + -Destination $mutatedSuitePath -Recurse + } + + @' +--- !!omap +- Sammy Sosa: 63 +- Mark McGwire: 65 +- Ken Griffy: 58 +'@ | Set-Content -LiteralPath (Join-Path $mutatedSuitePath 'J7PZ\out.yaml') ` + -Encoding utf8NoBOM + + $invoicePath = Join-Path $mutatedSuitePath 'UGM3\out.yaml' + $invoice = Get-Content -LiteralPath $invoicePath -Raw + $duplicateAddress = @' +ship-to: + given: Chris + family: Dumars + address: + lines: | + 458 Walkman Dr. + Suite #292 + city: Royal Oak + state: MI + postal: 48046 +'@ + $invoice.Replace('ship-to: *id001', $duplicateAddress.TrimEnd()) | + Set-Content -LiteralPath $invoicePath -Encoding utf8NoBOM + + $mutatedResults = @( + & $runnerPath -Path $mutatedSuitePath -CompareOutYaml + ) + @($mutatedResults | Where-Object OutYamlResult -EQ 'Fail').Count | Should -Be 2 + @($mutatedResults | Sort-Object Case | Select-Object -ExpandProperty OutYamlReason) | + Should -Be @('OutYamlConstructionMismatch', 'OutYamlReferenceMismatch') + } + + It 'ignores source order for ordinary mapping out.yaml comparisons' { + $ordinarySuitePath = Join-Path $TestDrive 'ordinary-mapping-order' + $ordinaryCasePath = Join-Path $ordinarySuitePath 'ordinary-map' + $null = New-Item -Path $ordinaryCasePath -ItemType Directory -Force + "a: 1`nb: 2" | Set-Content -LiteralPath (Join-Path $ordinaryCasePath 'in.yaml') ` + -Encoding utf8NoBOM + "b: 2`na: 1" | Set-Content -LiteralPath (Join-Path $ordinaryCasePath 'out.yaml') ` + -Encoding utf8NoBOM + + $ordinaryResult = & $runnerPath -Path $ordinarySuitePath -CompareOutYaml + + $ordinaryResult.OutYamlResult | Should -Be 'Pass' + } + + It 'detects explicit ordered-map tag loss across representation surfaces' { + $tagLossSuitePath = Join-Path $TestDrive 'ordered-map-tag-loss' + $tagLossCasePath = Join-Path $tagLossSuitePath 'omap-tag-loss' + $null = New-Item -Path $tagLossCasePath -ItemType Directory -Force + "!!omap`n- a: 1`n- b: 2" | + Set-Content -LiteralPath (Join-Path $tagLossCasePath 'in.yaml') ` + -Encoding utf8NoBOM + foreach ($fixture in @('out.yaml', 'emit.yaml')) { + "a: 1`nb: 2" | + Set-Content -LiteralPath (Join-Path $tagLossCasePath $fixture) ` + -Encoding utf8NoBOM + } + + $tagLossResult = & $runnerPath -Path $tagLossSuitePath ` + -CompareOutYaml -CompareEmitYaml -CompareSelfRoundTrip + + $tagLossResult.OutYamlResult | Should -Be 'Fail' + $tagLossResult.OutYamlReason | Should -Be 'OutYamlConstructionMismatch' + $tagLossResult.EmitYamlResult | Should -Be 'Fail' + $tagLossResult.EmitYamlReason | Should -Be 'EmitYamlRepresentationMismatch' + $tagLossResult.SelfRoundTripResult | Should -Be 'PolicyDifference' + $tagLossResult.SelfRoundTripReason | Should -Be 'LegacyOrderedMapProjection' + } + + 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 1 + @($suiteResults | Where-Object OutYamlResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object OutYamlResult -EQ 'NotApplicable').Count | + Should -Be 160 + $outPolicy = $suiteResults | Where-Object OutYamlResult -EQ 'PolicyDifference' + $outPolicy.Case | Should -Be 'X38W' + $outPolicy.OutYamlReason | Should -Be 'RepresentationMappingKeyUniqueness' + } + + It 'validates the 55 official emit.yaml fixtures separately' { + @($suiteResults | Where-Object HasEmitYaml).Count | Should -Be 55 + @($suiteResults | Where-Object EmitYamlResult -EQ 'Pass').Count | Should -Be 55 + @($suiteResults | Where-Object EmitYamlResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($suiteResults | Where-Object EmitYamlResult -EQ 'Fail').Count | Should -Be 0 + @($suiteResults | Where-Object EmitYamlResult -EQ 'NotApplicable').Count | + Should -Be 347 + } + + It 'reads official emit.yaml content rather than counting its presence' { + $mutatedSuitePath = Join-Path $TestDrive 'mutated-emit-fixture' + $null = New-Item -Path $mutatedSuitePath -ItemType Directory -Force + Copy-Item -LiteralPath (Join-Path $suiteDataPath '2LFX') ` + -Destination $mutatedSuitePath -Recurse + '--- altered' | Set-Content ` + -LiteralPath (Join-Path $mutatedSuitePath '2LFX\emit.yaml') ` + -Encoding utf8NoBOM + + $mutatedResult = & $runnerPath -Path $mutatedSuitePath -CompareEmitYaml + + $mutatedResult.EmitYamlResult | Should -Be 'Fail' + $mutatedResult.EmitYamlReason | Should -Be 'EmitYamlRepresentationMismatch' + } + + It 'uses JSON as the oracle for invalid-source emit fixtures' { + $mutatedSuitePath = Join-Path $TestDrive 'mutated-invalid-emit-fixtures' + $null = New-Item -Path $mutatedSuitePath -ItemType Directory -Force + Copy-Item -LiteralPath (Join-Path $suiteDataPath 'DK95') ` + -Destination $mutatedSuitePath -Recurse + foreach ($case in @('01', '06')) { + '--- altered' | Set-Content ` + -LiteralPath (Join-Path $mutatedSuitePath "DK95\$case\emit.yaml") ` + -Encoding utf8NoBOM + } + + $mutatedResults = @( + & $runnerPath -Path $mutatedSuitePath -CompareEmitYaml + ) + $invalidFixtureResults = @( + $mutatedResults | + Where-Object Case -In @('DK95/01', 'DK95/06') | + Sort-Object Case + ) + + @($invalidFixtureResults.EmitYamlResult) | Should -Be @('Fail', 'Fail') + @($invalidFixtureResults.EmitYamlReason | Select-Object -Unique) | + Should -Be @('EmitYamlRepresentationMismatch') + } + + It 'does not run module emission while validating official fixtures' { + $independentSuitePath = Join-Path $TestDrive 'independent-emit-fixture' + $independentCasePath = Join-Path $independentSuitePath 'deep-fixture' + $null = New-Item -Path $independentCasePath -ItemType Directory -Force + $deepYaml = ('[' * 101) + 'value' + (']' * 101) + $deepYaml | Set-Content -LiteralPath (Join-Path $independentCasePath 'in.yaml') ` + -Encoding utf8NoBOM -NoNewline + $deepYaml | Set-Content -LiteralPath (Join-Path $independentCasePath 'emit.yaml') ` + -Encoding utf8NoBOM -NoNewline + + $independentResult = & $runnerPath -Path $independentSuitePath -CompareEmitYaml + + $independentResult.EmitYamlResult | Should -Be 'Pass' + $independentResult.SelfRoundTripResult | Should -Be 'NotApplicable' + } + + It 'accounts honestly for general module self-round-trips' { + @($suiteResults | Where-Object SelfRoundTripResult -EQ 'Pass').Count | + Should -Be 305 + @($suiteResults | Where-Object SelfRoundTripResult -EQ 'PolicyDifference').Count | + Should -Be 3 + @($suiteResults | Where-Object SelfRoundTripResult -EQ 'Fail').Count | + Should -Be 0 + @($suiteResults | Where-Object SelfRoundTripResult -EQ 'NotApplicable').Count | + Should -Be 94 + $policyResults = @( + $suiteResults | + Where-Object SelfRoundTripResult -EQ 'PolicyDifference' | + Sort-Object Case + ) + @($policyResults.Case) | Should -Be @('2JQS', 'J7PZ', 'X38W') + @($policyResults.SelfRoundTripReason) | Should -Be @( + 'RepresentationMappingKeyUniqueness', + 'LegacyOrderedMapProjection', + 'RepresentationMappingKeyUniqueness' + ) + } + + It 'formats every loadable case with representation and idempotence preserved' { + @($suiteResults | Where-Object FormatterResult -EQ 'Pass').Count | + Should -Be 400 + @($suiteResults | Where-Object FormatterResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($suiteResults | Where-Object FormatterResult -EQ 'Fail').Count | + Should -Be 0 + @($suiteResults | Where-Object FormatterResult -EQ 'NotApplicable').Count | + Should -Be 2 + + $excluded = @( + $suiteResults | + Where-Object FormatterResult -EQ 'NotApplicable' | + Sort-Object Case + ) + @($excluded.Case) | Should -Be @('2JQS', 'X38W') + @($excluded.FormatterReason | Select-Object -Unique) | + Should -Be @('RepresentationMappingKeyUniqueness') + + $formatted = @( + $suiteResults | + Where-Object { -not $_.ExpectsError -and $_.FormatterResult -eq 'Pass' } + ) + $formatted.Count | Should -Be 306 + @($formatted | Where-Object FormatterReason).Count | Should -Be 0 + @($formatted | Where-Object { $_.FormatterText -cne $_.FormatterSecond }).Count | + Should -Be 0 + @($formatted | Where-Object { $_.FormatterExpected -cne $_.FormatterActual }).Count | + Should -Be 0 + } + + It 'keeps every invalid corpus input rejected by the formatter' { + $invalid = @($suiteResults | Where-Object ExpectsError) + + $invalid.Count | Should -Be 94 + @($invalid | Where-Object FormatterResult -NE 'Pass').Count | Should -Be 0 + @($invalid.FormatterReason | Select-Object -Unique) | + Should -Be @('InvalidInputRejected') + } + + 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..659bf4d --- /dev/null +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -0,0 +1,662 @@ +#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 'classifies ETS-reserved property names and preserves them with AsHashtable' { + foreach ($name in @( + 'PSObject', + 'psobject', + 'PSTypeNames', + 'PSBase', + 'PSAdapted', + 'PSExtended' + )) { + { "$name`: value" | ConvertFrom-Yaml } | + Should -Throw -ExpectedMessage '*Use -AsHashtable*' + + $result = "$name`: value" | ConvertFrom-Yaml -AsHashtable + $result[$name] | Should -Be 'value' + } + } + + 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') + } + + It 'treats only YAML s-white as structural whitespace' { + $nbsp = [char] 0x00A0 + + ("key:${nbsp}value" | ConvertFrom-Yaml) | Should -Be "key:${nbsp}value" + ("-${nbsp}item" | ConvertFrom-Yaml) | Should -Be "-${nbsp}item" + + $flow = "[foo${nbsp}bar]" | ConvertFrom-Yaml -NoEnumerate + $flow | Should -Be @("foo${nbsp}bar") + } + + It 'preserves flow scalar spaces and folds flow line breaks' { + $result = "[foo bar, foo`n`n bar]" | ConvertFrom-Yaml -NoEnumerate + + $result | Should -Be @('foo bar', "foo`nbar") + } + + It 'accepts adjacent flow collection values after a colon' { + $sequenceValue = '{foo:[bar]}' | ConvertFrom-Yaml + $mappingValue = '{foo:{bar: baz}}' | ConvertFrom-Yaml + $sequencePair = '[foo:[bar]]' | ConvertFrom-Yaml -NoEnumerate + + $sequenceValue.foo | Should -Be @('bar') + $mappingValue.foo.bar | Should -Be 'baz' + $sequencePair.Count | Should -Be 1 + $sequencePair[0].foo | Should -Be @('bar') + } + + It 'accepts tabs after explicit block mapping indicators' { + $tabAfterQuestion = "?`tkey`n: value" | ConvertFrom-Yaml -AsHashtable + $tabAfterColon = "? key`n:`tvalue" | ConvertFrom-Yaml -AsHashtable + + $tabAfterQuestion['key'] | Should -Be 'value' + $tabAfterColon['key'] | Should -Be 'value' + $tabAfterColon.Count | Should -Be 1 + } + } + + 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' + } + + It 'consumes byte order marks only at legal document boundaries' { + $bom = [char] 0xFEFF + $documents = @( + "${bom}%YAML 1.2`n---`none`n...`n${bom}# prefix comment`n`n---`ntwo" | + ConvertFrom-Yaml + ) + $implicitBoundaryDocuments = @( + "---`none`n${bom}# prefix comment`n---`ntwo" | ConvertFrom-Yaml + ) + $commentThenBom = @( + "# first`n${bom}---`nvalue" | ConvertFrom-Yaml + ) + $alternatingPrefixes = @( + "# first`n${bom}# second`n${bom}---`nvalue" | ConvertFrom-Yaml + ) + $trailingPrefix = @( + "---`nvalue`n...`n${bom}# trailing prefix" | ConvertFrom-Yaml + ) + $barePrefixDocument = @( + "# prefix`n${bom}value" | ConvertFrom-Yaml + ) + $bareBoundaryDocuments = @( + "---`none`n...`n${bom}two" | ConvertFrom-Yaml + ) + $commentedBareBoundaryDocuments = @( + "---`none`n...`n${bom}# comment`ntwo" | ConvertFrom-Yaml + ) + + $documents | Should -Be @('one', 'two') + $implicitBoundaryDocuments | Should -Be @('one', 'two') + $commentThenBom | Should -Be @('value') + $alternatingPrefixes | Should -Be @('value') + $trailingPrefix | Should -Be @('value') + $barePrefixDocument | Should -Be @('value') + $bareBoundaryDocuments | Should -Be @('one', 'two') + $commentedBareBoundaryDocuments | Should -Be @('one', 'two') + ("${bom}---`nvalue" | ConvertFrom-Yaml) | Should -Be 'value' + ("foo${bom}bar" | Test-Yaml) | Should -BeFalse + ("---`n${bom}value" | Test-Yaml) | Should -BeFalse + ("---`none`n${bom}two" | Test-Yaml) | Should -BeFalse + ("${bom}%YAML 1.2`nvalue" | Test-Yaml) | Should -BeFalse + ('"foo' + $bom + 'bar"' | ConvertFrom-Yaml) | Should -Be "foo${bom}bar" + ("'foo${bom}bar'" | ConvertFrom-Yaml) | Should -Be "foo${bom}bar" + ("`"a`n${bom}%foo`nb`"" | ConvertFrom-Yaml) | + Should -Be "a ${bom}%foo b" + ("|-`n${bom}%foo" | Test-Yaml) | Should -BeFalse + ("%FOO before${bom}after`n---`nvalue" | Test-Yaml) | Should -BeFalse + ("value # a${bom}b" | Test-Yaml) | Should -BeFalse + ("# a${bom}b`nvalue" | Test-Yaml) | Should -BeFalse + ("[value # a${bom}b`n ]" | Test-Yaml) | Should -BeFalse + ("| # a${bom}b`n value" | Test-Yaml) | Should -BeFalse + } + + It 'validates repeated document prefixes with one suffix scan' { + $bom = [char] 0xFEFF + $yaml = ((([string] $bom + "# prefix`n") * 64) -join '') + "---`nvalue" + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + Mock Test-YamlDocumentPrefix { $true } + } else { + Mock Test-YamlDocumentPrefix -ModuleName $loadedModule.Name { $true } + } + + ($yaml | ConvertFrom-Yaml) | Should -Be 'value' + if ($null -eq $loadedModule) { + Should -Invoke Test-YamlDocumentPrefix -Times 1 -Exactly + } else { + Should -Invoke Test-YamlDocumentPrefix -ModuleName $loadedModule.Name ` + -Times 1 -Exactly + } + } + } + + Context 'Tags, anchors, and aliases' { + It 'stops anchor and alias names before mapping indicators' { + $emptyKey = '&a: value' | ConvertFrom-Yaml -AsHashtable + $aliasedKey = '{anchor: &a foo, *a: value}' | + ConvertFrom-Yaml -AsHashtable + $blockAliasedKey = "source: &a key`n*a: value" | + ConvertFrom-Yaml -AsHashtable + $colonAliasName = "&a: key: &a value`nfoo:`n *a:" | + ConvertFrom-Yaml -AsHashtable + + $emptyKey.Count | Should -Be 1 + $emptyKey[[System.DBNull]::Value] | Should -Be 'value' + @($aliasedKey.Keys) | Should -Be @('anchor', 'foo') + @($aliasedKey.Values) | Should -Be @('foo', 'value') + @($blockAliasedKey.Keys) | Should -Be @('source', 'key') + @($blockAliasedKey.Values) | Should -Be @('key', 'value') + $colonAliasName['foo'] | Should -Be 'key' + } + + 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 'retains unknown local and global tags before neutral value projection' { + $local = Get-TestYamlRepresentationRoot -Yaml '!local value' + $global = Get-TestYamlRepresentationRoot -Yaml ( + '! value' + ) + + $local.Tag | Should -Be '!local' + $local.HasUnknownTag | Should -BeTrue + $global.Tag | Should -Be 'tag:example.test,2026:object' + $global.HasUnknownTag | Should -BeTrue + ('!local value' | ConvertFrom-Yaml) | Should -Be 'value' + ('! value' | ConvertFrom-Yaml) | + Should -Be 'value' + } + + 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 'classifies projection collisions from representation-distinct tagged keys' { + $yaml = "!foo x: one`n!bar x: two" + + ($yaml | Test-Yaml) | Should -BeTrue + { $yaml | ConvertFrom-Yaml -AsHashtable } | + Should -Throw -ExpectedMessage '*cannot be projected distinctly*' + } + + 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 'normalizes cross-type finite floats for key equality' { + $yaml = @' +100000000000000000000.0: decimal +1e20: double +'@ + + ($yaml | Test-Yaml) | Should -BeFalse + { $yaml | ConvertFrom-Yaml -AsHashtable } | + Should -Throw -ExpectedMessage '*duplicate mapping key*' + } + + It 'treats signed zero keys as the same YAML representation value' { + $yaml = @' +0.0: positive +-0.0: negative +'@ + + ($yaml | Test-Yaml) | Should -BeFalse + { $yaml | ConvertFrom-Yaml -AsHashtable } | + Should -Throw -ExpectedMessage '*duplicate mapping key*' + } + + 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' { + $escaped = Get-TestYamlRepresentationRoot -Yaml ( + "%TAG !e! tag:example.com,2000:app/`n--- !e!tag%21 value" + ) + $multibyte = Get-TestYamlRepresentationRoot -Yaml ( + "%TAG !e! tag:example.com,2000:app/`n--- !e!currency%E2%82%AC amount" + ) + + $escaped.Tag | Should -Be 'tag:example.com,2000:app/tag!' + $escaped.HasUnknownTag | Should -BeTrue + $multibyte.Tag | + Should -Be ('tag:example.com,2000:app/currency' + [char] 0x20AC) + $multibyte.HasUnknownTag | Should -BeTrue + } + + 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' * 32000 + + ($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..2796fca --- /dev/null +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -0,0 +1,663 @@ +#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') + if ($null -eq ('YamlTests.InfiniteEnumerable' -as [type])) { + Add-Type -TypeDefinition @' +namespace YamlTests +{ + using System; + using System.Collections; + + public sealed class InfiniteEnumerable : IEnumerable + { + private readonly object value; + public int MoveNextCount { get; private set; } + public int DisposeCount { get; private set; } + + public InfiniteEnumerable() : this(1) { } + public InfiniteEnumerable(object value) { this.value = value; } + public IEnumerator GetEnumerator() { return new Enumerator(this, value); } + + private sealed class Enumerator : IEnumerator, IDisposable + { + private readonly InfiniteEnumerable owner; + private readonly object value; + + public Enumerator(InfiniteEnumerable owner, object value) + { + this.owner = owner; + this.value = value; + } + + public object Current { get { return value; } } + + public bool MoveNext() + { + owner.MoveNextCount++; + return true; + } + + public void Reset() { throw new NotSupportedException(); } + public void Dispose() { owner.DisposeCount++; } + } + } + + public sealed class OneShotEnumerable : IEnumerable + { + private readonly object[] values; + public int GetEnumeratorCount { get; private set; } + public int MoveNextCount { get; private set; } + public int DisposeCount { get; private set; } + + public OneShotEnumerable(object[] values) { this.values = values; } + + public IEnumerator GetEnumerator() + { + GetEnumeratorCount++; + if (GetEnumeratorCount > 1) + { + throw new InvalidOperationException("The enumerable was consumed more than once."); + } + return new Enumerator(this, values); + } + + private sealed class Enumerator : IEnumerator, IDisposable + { + private readonly OneShotEnumerable owner; + private readonly object[] values; + private int index = -1; + + public Enumerator(OneShotEnumerable owner, object[] values) + { + this.owner = owner; + this.values = values; + } + + public object Current { get { return values[index]; } } + public bool MoveNext() + { + owner.MoveNextCount++; + index++; + return index < values.Length; + } + public void Reset() { throw new NotSupportedException(); } + public void Dispose() { owner.DisposeCount++; } + } + } +} +'@ + } + + function Get-YamlTestInfinitePipeline { + <# + .SYNOPSIS + Produces an unbounded stream for resource-limit tests. + #> + param ([string] $Item = 'value') + + while ($true) { + $script:YamlTestPipelineCount++ + $Item + } + } +} + +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 'escapes byte order marks as quoted scalar content' { + $value = "foo$([char] 0xFEFF)bar" + + $yaml = ConvertTo-Yaml -InputObject $value + + $yaml.TrimEnd("`n") | Should -Be '"foo\uFEFFbar"' + ($yaml | ConvertFrom-Yaml) | Should -Be $value + } + + 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 'serializes every representable DateTimeOffset boundary stably' { + $values = @( + [datetimeoffset]::MinValue + [datetimeoffset]::MaxValue + [datetimeoffset]::new( + [datetime]::new(1, 1, 1, 14, 0, 0), + [timespan]::FromHours(14) + ) + [datetimeoffset]::new( + [datetime]::new(9999, 12, 31, 9, 59, 59, 999).AddTicks(9999), + [timespan]::FromHours(-14) + ) + ) + + foreach ($value in $values) { + $yaml = ConvertTo-Yaml -InputObject $value + $roundTrip = $yaml | ConvertFrom-Yaml + + ($yaml | Test-Yaml) | Should -BeTrue + $roundTrip.UtcTicks | Should -Be $value.UtcTicks + } + } + + It 'classifies unrepresentable local DateTime boundaries' { + $errors = [System.Collections.Generic.List[object]]::new() + $successes = 0 + foreach ($value in @( + [datetime]::SpecifyKind([datetime]::MinValue, [DateTimeKind]::Local) + [datetime]::SpecifyKind([datetime]::MaxValue, [DateTimeKind]::Local) + )) { + try { + $yaml = ConvertTo-Yaml -InputObject $value + ($yaml | Test-Yaml) | Should -BeTrue + $successes++ + } catch { + $errors.Add($_) + } + } + + ($successes + $errors.Count) | Should -Be 2 + if ([TimeZoneInfo]::Local.BaseUtcOffset -ne [timespan]::Zero) { + $errors.Count | Should -BeGreaterThan 0 + } + foreach ($serializationError in $errors) { + $serializationError.FullyQualifiedErrorId | + Should -Be 'YamlTimestampSerializationFailed,ConvertTo-Yaml' + $serializationError.Exception.Message | + Should -Be 'The timestamp cannot be represented with its local or explicit UTC offset.' + } + } + + 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 'uses explicit keys when scalar keys exceed 1024 Unicode values' { + $atLimit = [System.Collections.Specialized.OrderedDictionary]::new() + $atLimit.Add(('x' * 1022), 'value') + $overLimit = [System.Collections.Specialized.OrderedDictionary]::new() + $overLimitKey = [char]::ConvertFromUtf32(0x1F600) * 1023 + $overLimit.Add($overLimitKey, [ordered]@{ nested = 'value' }) + + $atLimitYaml = ConvertTo-Yaml -InputObject $atLimit + $overLimitYaml = ConvertTo-Yaml -InputObject $overLimit + $roundTrip = $overLimitYaml | ConvertFrom-Yaml -AsHashtable + + $atLimitYaml | Should -Not -Match '^\? ' + $overLimitYaml | Should -Match '^\? ' + $overLimitYaml | Should -Match '(?m)^: $' + ($overLimitYaml | Test-Yaml) | Should -BeTrue + $roundTrip.Contains($overLimitKey) | Should -BeTrue + $roundTrip[$overLimitKey]['nested'] | Should -Be 'value' + } + + It 'uses explicit keys when rendered collection keys exceed 1024 values' { + $dictionary = [System.Collections.Specialized.OrderedDictionary]::new() + $key = [object[]] (0..299) + $dictionary.Add($key, 'value') + + $yaml = ConvertTo-Yaml -InputObject $dictionary + $roundTrip = $yaml | ConvertFrom-Yaml -AsHashtable + $enumerator = $roundTrip.GetEnumerator() + $null = $enumerator.MoveNext() + + $yaml | Should -Match '^\? \[' + ($yaml | Test-Yaml) | Should -BeTrue + $enumerator.Key | Should -Be $key + $enumerator.Value | Should -Be 'value' + } + + It 'uses explicit keys inside flow-rendered complex keys' { + $innerKey = [System.Collections.Specialized.OrderedDictionary]::new() + $innerKey.Add(('x' * 1025), 'inner') + $dictionary = [System.Collections.Specialized.OrderedDictionary]::new() + $dictionary.Add($innerKey, 'outer') + + $yaml = ConvertTo-Yaml -InputObject $dictionary + + $yaml | Should -Match '\{\? ' + ($yaml | Test-Yaml) | Should -BeTrue + { $yaml | ConvertFrom-Yaml -AsHashtable } | Should -Not -Throw + } + + 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 'normalizes finite float key fingerprints across CLR types' { + $equal = [System.Collections.Specialized.OrderedDictionary]::new() + $equal.Add( + [decimal]::Parse( + '100000000000000000000.0', + [cultureinfo]::InvariantCulture + ), + 'decimal' + ) + $equal.Add([double] 1e20, 'double') + + { $equal | ConvertTo-Yaml } | + Should -Throw -ExpectedMessage '*normalize to the same YAML value*' + + $different = [System.Collections.Specialized.OrderedDictionary]::new() + $different.Add( + [decimal]::Parse( + '100000000000000000001', + [cultureinfo]::InvariantCulture + ), + 'decimal' + ) + $different.Add([double] 1e20, 'double') + + { $different | ConvertTo-Yaml } | Should -Not -Throw + } + + 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 'rejects attached note properties on scalar values' { + $value = [psobject] 42 + Add-Member -InputObject $value -MemberType NoteProperty -Name metadata -Value 'lossy' + + { ConvertTo-Yaml -InputObject $value } | + Should -Throw -ExpectedMessage '*combines scalar 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 'stops infinite pipelines at the node budget' { + $script:YamlTestPipelineCount = 0 + + { Get-YamlTestInfinitePipeline | ConvertTo-Yaml -MaxNodes 4 } | + Should -Throw -ExpectedMessage '*configured limit of 4 nodes*' + $script:YamlTestPipelineCount | Should -BeLessOrEqual 4 + } + + It 'stops infinite pipelines at the first oversized scalar' { + $script:YamlTestPipelineCount = 0 + + $conversion = { + Get-YamlTestInfinitePipeline -Item 'long' | + ConvertTo-Yaml -MaxScalarLength 3 + } + $conversion | Should -Throw -ExpectedMessage '*configured limit of 3 characters*' + $script:YamlTestPipelineCount | Should -Be 1 + } + + It 'stops and disposes infinite enumerables at the node budget' { + $source = [YamlTests.InfiniteEnumerable]::new() + + { ConvertTo-Yaml -InputObject $source -MaxNodes 4 } | + Should -Throw -ExpectedMessage '*configured limit of 4 nodes*' + $source.MoveNextCount | Should -Be 4 + $source.DisposeCount | Should -Be 1 + } + + It 'shares the node budget while buffering nested enumerables' { + $children = [System.Collections.Generic.List[object]]::new() + $sources = [System.Collections.Generic.List[object]]::new() + foreach ($index in 1..9) { + $child = [YamlTests.OneShotEnumerable]::new([object[]] (1..9)) + $children.Add($child) + $sources.Add($child) + } + $root = [YamlTests.OneShotEnumerable]::new([object[]] $children.ToArray()) + $sources.Add($root) + + { ConvertTo-Yaml -InputObject $root -MaxNodes 20 } | + Should -Throw -ExpectedMessage '*configured limit of 20 nodes*' + ($sources | Measure-Object -Property MoveNextCount -Sum).Sum | + Should -BeLessOrEqual 22 + ($sources | Measure-Object -Property DisposeCount -Sum).Sum | Should -Be 3 + } + + It 'stops and disposes enumerables at the first oversized scalar' { + $source = [YamlTests.InfiniteEnumerable]::new('long') + + { ConvertTo-Yaml -InputObject $source -MaxScalarLength 3 } | + Should -Throw -ExpectedMessage '*configured limit of 3 characters*' + $source.MoveNextCount | Should -Be 1 + $source.DisposeCount | Should -Be 1 + } + + It 'does not re-enumerate repeated references' { + $source = [YamlTests.OneShotEnumerable]::new([object[]] @(1, 2)) + $inputObject = [ordered]@{ first = $source; second = $source } + + $yaml = ConvertTo-Yaml -InputObject $inputObject + + $source.GetEnumeratorCount | Should -Be 1 + $source.DisposeCount | Should -Be 1 + $yaml | Should -Match '&id001' + $yaml | Should -Match '\*id001' + } + + It 'prechecks Base64 output before allocating the encoded scalar' { + $bytes = [byte[]] @(1, 2, 3) + + { ConvertTo-Yaml -InputObject $bytes -MaxScalarLength 3 } | + Should -Throw -ExpectedMessage '*configured limit of 3 characters*' + } + + 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/Export-Yaml.Tests.ps1 b/tests/Export-Yaml.Tests.ps1 new file mode 100644 index 0000000..c7d87e2 --- /dev/null +++ b/tests/Export-Yaml.Tests.ps1 @@ -0,0 +1,701 @@ +#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 'Export-Yaml' { + Context 'Command contract' { + It 'declares pipeline, ShouldProcess, and output metadata' { + $command = Get-Command -Name Export-Yaml + $binding = $command.ScriptBlock.Attributes | + Where-Object { $_ -is [System.Management.Automation.CmdletBindingAttribute] } + $inputParameter = $command.ParameterSets.Parameters | + Where-Object Name -EQ 'InputObject' | + Select-Object -First 1 + $pathParameter = $command.ParameterSets.Parameters | + Where-Object Name -EQ 'Path' | + Select-Object -First 1 + + $binding.SupportsShouldProcess | Should -BeTrue + $binding.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::Medium) + $inputParameter.IsMandatory | Should -BeTrue + $inputParameter.Position | Should -Be 0 + $inputParameter.ValueFromPipeline | Should -BeTrue + $pathParameter.IsMandatory | Should -BeTrue + $pathParameter.Position | Should -Be 1 + $command.OutputType.Type | Should -Contain ([System.IO.FileInfo]) + $command.Parameters['Encoding'].Attributes.ValidValues | + Should -Be @('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE') + $command.Parameters['NewLine'].Attributes.ValidValues | Should -Be @('LF', 'CRLF') + } + + It 'provides complete command help' { + $help = Get-Help -Name Export-Yaml -Full + + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description.Text | Should -Not -BeNullOrEmpty + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 3 + @($help.Parameters.Parameter.Name) | Should -Contain 'InputObject' + @($help.Parameters.Parameter.Name) | Should -Contain 'Path' + @($help.Parameters.Parameter.Name) | Should -Contain 'NoClobber' + @($help.Parameters.Parameter.Name) | Should -Contain 'Force' + $help.returnValues.returnValue.Type.Name | Should -Contain 'System.IO.FileInfo' + } + } + + Context 'Pipeline aggregation and serializer controls' { + It 'serializes one direct array as one input value' { + $path = Join-Path $TestDrive 'direct-array.yaml' + $items = @('one', 'two') + + Export-Yaml -InputObject $items -Path $path + $result = Import-Yaml -Path $path -NoEnumerate + + , $result | Should -BeOfType [object[]] + $result | Should -Be @('one', 'two') + } + + It 'preserves an explicit one-element array as a sequence' { + $path = Join-Path $TestDrive 'one-element-array.yaml' + + Export-Yaml -InputObject @(42) -Path $path + + [System.IO.File]::ReadAllText($path) | Should -Be "- 42`n" + } + + It 'preserves an explicit empty array as an empty sequence' { + $path = Join-Path $TestDrive 'empty-array.yaml' + + Export-Yaml -InputObject @() -Path $path + + [System.IO.File]::ReadAllText($path) | Should -Be "[]`n" + } + + It 'collects multiple pipeline records into one sequence' { + $path = Join-Path $TestDrive 'pipeline.yaml' + + 'one', 'two', 'three' | Export-Yaml -Path $path + $result = Import-Yaml -Path $path -NoEnumerate + + $result | Should -Be @('one', 'two', 'three') + } + + It 'distinguishes one nested pipeline record from multiple records' { + $singlePath = Join-Path $TestDrive 'single-nested-record.yaml' + $multiplePath = Join-Path $TestDrive 'multiple-nested-records.yaml' + $firstRecord = [object[]]::new(1) + $firstRecord[0] = [object[]] @(1, 2) + $secondRecord = [object[]]::new(1) + $secondRecord[0] = [object[]] @(3, 4) + + Write-Output -InputObject $firstRecord -NoEnumerate | + Export-Yaml -Path $singlePath + & { + Write-Output -InputObject $firstRecord -NoEnumerate + Write-Output -InputObject $secondRecord -NoEnumerate + } | Export-Yaml -Path $multiplePath + + $singleExpected = ConvertTo-Yaml -InputObject $firstRecord + $multipleExpected = & { + Write-Output -InputObject $firstRecord -NoEnumerate + Write-Output -InputObject $secondRecord -NoEnumerate + } | ConvertTo-Yaml + [System.IO.File]::ReadAllText($singlePath) | Should -Be $singleExpected + [System.IO.File]::ReadAllText($multiplePath) | Should -Be $multipleExpected + } + + It 'serializes an explicit null input' { + $path = Join-Path $TestDrive 'null.yaml' + + Export-Yaml -InputObject $null -Path $path + + [System.IO.File]::ReadAllText($path) | Should -Be "null`n" + } + + It 'passes serializer formatting controls through' { + $path = Join-Path $TestDrive 'format.yaml' + $inputObject = [ordered]@{ + nested = [ordered]@{ day = [DayOfWeek]::Monday } + } + + Export-Yaml -InputObject $inputObject -Path $path -Depth 3 -MaxNodes 5 ` + -MaxScalarLength 6 -Indent 4 -ExplicitDocumentStart -EnumsAsStrings + $text = [System.IO.File]::ReadAllText($path) + $result = Import-Yaml -Path $path + + $text | Should -Match '^---\n' + $text | Should -Match '(?m)^ {4}"day": "Monday"$' + $result.nested.day | Should -Be 'Monday' + } + + It 'preserves an existing destination when serialization fails' { + $path = Join-Path $TestDrive 'serialization-failure.yaml' + [System.IO.File]::WriteAllText($path, "original`n") + + try { + Export-Yaml -InputObject ([uri] 'https://example.com') -Path $path + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlUnsupportedType,Export-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidType' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + [System.IO.File]::ReadAllText($path) | Should -Be "original`n" + @(Get-ChildItem -LiteralPath $TestDrive -Filter '.yaml-*.tmp').Count | Should -Be 0 + } + + It 'classifies malformed text serialization and preserves the destination' { + $path = Join-Path $TestDrive 'malformed-text.yaml' + [System.IO.File]::WriteAllText($path, "original`n") + + try { + Export-Yaml -InputObject ([string] [char] 0xD800) -Path $path + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | + Should -Be 'YamlExportSerializationFailed,Export-Yaml' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + + [System.IO.File]::ReadAllText($path) | Should -Be "original`n" + @(Get-ChildItem -LiteralPath $TestDrive -Filter '.yaml-*.tmp').Count | Should -Be 0 + } + + It 'preserves serializer resource-limit classifications and the destination' { + $path = Join-Path $TestDrive 'limit-failure.yaml' + [System.IO.File]::WriteAllText($path, "original`n") + + try { + @('one', 'two') | Export-Yaml -Path $path -MaxNodes 2 + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlNodeLimitExceeded,Export-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidOperation' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + [System.IO.File]::ReadAllText($path) | Should -Be "original`n" + } + + It 'enforces serializer limits while pipeline records arrive' { + $path = Join-Path $TestDrive 'bounded-pipeline.yaml' + $script:producedRecordCount = 0 + + try { + 1..25 | ForEach-Object { + $script:producedRecordCount++ + $_ + } | Export-Yaml -Path $path -MaxNodes 1 + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlNodeLimitExceeded,Export-Yaml' + } + + $script:producedRecordCount | Should -Be 2 + Test-Path -LiteralPath $path | Should -BeFalse + } + } + + Context 'Strict output encoding' { + It 'writes exact preamble bytes and strict encoded text' -ForEach @( + @{ + Name = 'utf8' + ExpectedPreamble = [byte[]] @() + Decoder = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf8BOM' + ExpectedPreamble = [byte[]] @(0xEF, 0xBB, 0xBF) + Decoder = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf16LE' + ExpectedPreamble = [byte[]] @(0xFF, 0xFE) + Decoder = [System.Text.UnicodeEncoding]::new($false, $false, $true) + } + @{ + Name = 'utf16BE' + ExpectedPreamble = [byte[]] @(0xFE, 0xFF) + Decoder = [System.Text.UnicodeEncoding]::new($true, $false, $true) + } + @{ + Name = 'utf32LE' + ExpectedPreamble = [byte[]] @(0xFF, 0xFE, 0x00, 0x00) + Decoder = [System.Text.UTF32Encoding]::new($false, $false, $true) + } + @{ + Name = 'utf32BE' + ExpectedPreamble = [byte[]] @(0x00, 0x00, 0xFE, 0xFF) + Decoder = [System.Text.UTF32Encoding]::new($true, $false, $true) + } + ) { + $path = Join-Path $TestDrive "$Name-output.yaml" + $value = "caf$([char] 0xE9)" + $inputObject = [ordered]@{ value = $value } + + Export-Yaml -InputObject $inputObject -Path $path -Encoding $Name + $bytes = [System.IO.File]::ReadAllBytes($path) + if ($ExpectedPreamble.Count -eq 0) { + @($bytes[0..2]) | Should -Not -Be @([byte] 0xEF, [byte] 0xBB, [byte] 0xBF) + } else { + @($bytes[0..($ExpectedPreamble.Count - 1)]) | Should -Be $ExpectedPreamble + } + $text = $Decoder.GetString( + $bytes, + $ExpectedPreamble.Count, + $bytes.Length - $ExpectedPreamble.Count + ) + + $text | Should -Be (ConvertTo-Yaml -InputObject $inputObject) + (Import-Yaml -Path $path).value | Should -Be $value + } + } + + Context 'Line ending policy' { + It 'writes LF with exactly one final newline by default' { + $path = Join-Path $TestDrive 'lf.yaml' + + Export-Yaml -InputObject ([ordered]@{ value = 'text' }) -Path $path + $text = [System.Text.UTF8Encoding]::new($false, $true).GetString( + [System.IO.File]::ReadAllBytes($path) + ) + + $text | Should -Be ( + ConvertTo-Yaml -InputObject ([ordered]@{ value = 'text' }) + ) + $text.EndsWith("`n") | Should -BeTrue + $text.EndsWith("`n`n") | Should -BeFalse + $text.Contains("`r") | Should -BeFalse + } + + It 'writes CRLF without changing escaped multiline scalar content' { + $path = Join-Path $TestDrive 'crlf.yaml' + $inputObject = [ordered]@{ + multiline = "one`ntwo" + value = 'text' + } + + Export-Yaml -InputObject $inputObject -Path $path -NewLine CRLF + $text = [System.Text.UTF8Encoding]::new($false, $true).GetString( + [System.IO.File]::ReadAllBytes($path) + ) + $result = Import-Yaml -Path $path + + $text | Should -Not -Match '(? + param ( + [Parameter(Mandatory)] + [scriptblock] $Action + ) + + try { + $null = & $Action + } catch { + return $_ + } + throw 'The YAML operation unexpectedly succeeded.' + } +} + +Describe 'Format-Yaml' { + Context 'Public contract' { + It 'exposes the advanced string formatter contract' { + $command = Get-Command -Name Format-Yaml + $inputParameter = $command.Parameters['InputObject'] + $parameterAttribute = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $allowEmptyString = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $indentRange = $command.Parameters['Indent'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + + $command.CmdletBinding | Should -BeTrue + $inputParameter.ParameterType | Should -Be ([string[]]) + $parameterAttribute.Mandatory | Should -BeTrue + $parameterAttribute.Position | Should -Be 0 + $parameterAttribute.ValueFromPipeline | Should -BeTrue + $allowEmptyString | Should -Not -BeNullOrEmpty + @($command.OutputType.Type) | Should -Contain ([string]) + $indentRange.MinRange | Should -Be 2 + $indentRange.MaxRange | Should -Be 9 + } + + It 'emits exactly one string without CR or a final newline' { + $result = @('one: 1', 'two: 2' | Format-Yaml) + + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType [string] + $result[0] | Should -Not -Match "`r" + $result[0].EndsWith("`n", [System.StringComparison]::Ordinal) | + Should -BeFalse + } + + It 'returns one empty string for empty and comment-only streams' { + $empty = @('' | Format-Yaml) + $commentOnly = @("# heading`n# trailing" | Format-Yaml) + + $empty.Count | Should -Be 1 + $empty[0] | Should -BeExactly '' + $commentOnly.Count | Should -Be 1 + $commentOnly[0] | Should -BeExactly '' + ('---' | Format-Yaml) | Should -BeExactly '---' + } + } + + Context 'Deterministic normalization' { + It 'removes comments and normalizes flow collections to block form' { + $inputYaml = @' +# heading +root: { z: 1, a: "true", nested: [one, two] } # trailing +'@ + $expected = @' +--- +"root": + "z": 1 + "a": "true" + "nested": + - "one" + - "two" +'@.Replace("`r`n", "`n").TrimEnd() + + ($inputYaml | Format-Yaml) | Should -BeExactly $expected + } + + It 'uses the requested indentation' { + $formatted = "outer:`n inner:`n value: one" | Format-Yaml -Indent 4 + + $formatted | Should -Match '(?m)^ "inner":$' + $formatted | Should -Match '(?m)^ "value": "one"$' + } + + It 'joins pipeline records with LF into one stream' { + $formatted = 'root:', ' child: value' | Format-Yaml + + $formatted | Should -BeExactly "---`n`"root`":`n `"child`": `"value`"" + } + + It 'preserves empty documents and document order' { + $inputYaml = "---`n...`n---`n# empty`n---`nvalue`n..." + $formatted = $inputYaml | Format-Yaml + $documents = @($formatted | ConvertFrom-Yaml) + + $formatted | Should -BeExactly "---`n---`n---`n`"value`"" + $documents.Count | Should -Be 3 + $documents[0] | Should -BeNullOrEmpty + $documents[1] | Should -BeNullOrEmpty + $documents[2] | Should -Be 'value' + } + + It 'normalizes literal, folded, and chomping styles without changing content' { + $inputYaml = @' +literal: |- + first + second +folded: >+ + alpha + beta + +'@ + $expectedValues = $inputYaml | ConvertFrom-Yaml -AsHashtable + $formatted = $inputYaml | Format-Yaml + $actualValues = $formatted | ConvertFrom-Yaml -AsHashtable + + $formatted | Should -Not -Match '(?m)^[|>][+-]?$' + $formatted | Should -Match '\\n' + $actualValues['literal'] | Should -BeExactly $expectedValues['literal'] + $actualValues['folded'] | Should -BeExactly $expectedValues['folded'] + } + + It 'quotes Unicode and control content safely' { + $inputYaml = '"\0\a\b\t\n\r\e\x85\u263A\U0001F600"' + $expected = $inputYaml | ConvertFrom-Yaml + $formatted = $inputYaml | Format-Yaml + + ($formatted | ConvertFrom-Yaml) | Should -BeExactly $expected + $formatted | Should -Match '\\0\\a\\b\\t\\n\\r\\e\\u0085' + $formatted | Should -Not -Match "`r" + } + } + + Context 'Representation preservation' { + It 'resolves tag directives and emits effective tags without directives' { + $inputYaml = @' +%TAG !e! tag:example.com,2026: +--- +!e!widget { value: !!str 42 } +'@ + $formatted = $inputYaml | Format-Yaml + $before = Get-TestYamlRepresentationRoot -Yaml $inputYaml + $after = Get-TestYamlRepresentationRoot -Yaml $formatted + + $formatted | Should -Not -Match '(?m)^%TAG' + $formatted | Should -Match '!' + $formatted | Should -Match '!!str "42"' + $after.Kind | Should -Be $before.Kind + $after.Tag | Should -BeExactly $before.Tag + $after.HasUnknownTag | Should -Be $before.HasUnknownTag + } + + It 'preserves standard, local, global, escaped, and non-specific tags' -ForEach @( + @{ Yaml = '!!binary SGVsbG8=' } + @{ Yaml = '!local value' } + @{ Yaml = '! value' } + @{ Yaml = '! value' } + @{ Yaml = '! value' } + ) { + $formatted = $Yaml | Format-Yaml + $before = Get-TestYamlRepresentationRoot -Yaml $Yaml + $after = Get-TestYamlRepresentationRoot -Yaml $formatted + + $after.Kind | Should -Be $before.Kind + $after.Tag | Should -BeExactly $before.Tag + $after.HasUnknownTag | Should -Be $before.HasUnknownTag + $after.Value | Should -BeExactly $before.Value + } + + It 'preserves aliases and recursive graph identity with deterministic anchors' { + $inputYaml = @' +root: &source + self: *source +copy: *source +'@ + $formatted = $inputYaml | Format-Yaml + $result = $formatted | ConvertFrom-Yaml -AsHashtable + + $formatted | Should -Match '&id001' + @([regex]::Matches($formatted, '\*id001')).Count | Should -Be 2 + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + [object]::ReferenceEquals($result['root'], $result['root']['self']) | + Should -BeTrue + } + + It 'preserves complex keys and mapping order' { + $inputYaml = @' +? [region, { port: 443 }] +: first +simple: second +'@ + $formatted = $inputYaml | Format-Yaml + $result = $formatted | ConvertFrom-Yaml -AsHashtable + $entry = $result.GetEnumerator() | Select-Object -First 1 + + @($result.Values) | Should -Be @('first', 'second') + , $entry.Key | Should -BeOfType [object[]] + $entry.Key[0] | Should -Be 'region' + $entry.Key[1] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $entry.Key[1]['port'] | Should -Be 443 + } + + It 'preserves explicit standard collection and scalar tags' -ForEach @( + @{ Yaml = "!!set`n? one`n? two"; Kind = 'Mapping'; Tag = 'tag:yaml.org,2002:set' } + @{ Yaml = "!!omap`n- one: 1`n- two: 2"; Kind = 'Sequence'; Tag = 'tag:yaml.org,2002:omap' } + @{ Yaml = "!!pairs`n- one: 1`n- one: 2"; Kind = 'Sequence'; Tag = 'tag:yaml.org,2002:pairs' } + @{ Yaml = '!!timestamp 2001-12-15T02:59:43.1Z'; Kind = 'Scalar'; Tag = 'tag:yaml.org,2002:timestamp' } + ) { + $formatted = $Yaml | Format-Yaml + $node = Get-TestYamlRepresentationRoot -Yaml $formatted + + $node.Kind | Should -Be $Kind + $node.Tag | Should -BeExactly $Tag + } + + It 'keeps core-looking strings distinct from implicit core scalars' { + $inputYaml = @' +quoted: "true" +tagged: !!str null +boolean: true +nullValue: +float: .nan +'@ + $formatted = $inputYaml | Format-Yaml + $result = $formatted | ConvertFrom-Yaml -AsHashtable + + $result['quoted'] | Should -BeOfType [string] + $result['quoted'] | Should -Be 'true' + $result['tagged'] | Should -BeOfType [string] + $result['tagged'] | Should -Be 'null' + $result['boolean'] | Should -BeTrue + $result['nullValue'] | Should -BeNullOrEmpty + [double]::IsNaN($result['float']) | Should -BeTrue + } + + It 'is byte-idempotent at the same options' -ForEach @( + @{ Yaml = "# comment`n{ b: [2, 3], a: one }"; Indent = 2 } + @{ Yaml = "&root [*root]"; Indent = 4 } + @{ Yaml = "---`n`n---`n!local value"; Indent = 3 } + @{ Yaml = "text: |+`n one`n two`n"; Indent = 2 } + ) { + $first = $Yaml | Format-Yaml -Indent $Indent + $second = $first | Format-Yaml -Indent $Indent + + $second | Should -BeExactly $first + } + } + + Context 'Validation and parser limits' { + It 'keeps exact effective tag-length boundaries idempotent' -ForEach @( + @{ + Name = 'local' + Yaml = '!abc value' + TooLong = '!abcd value' + Limit = 4 + } + @{ + Name = 'verbatim' + Yaml = '! value' + TooLong = '! value' + Limit = 3 + } + @{ + Name = 'expanded handle' + Yaml = "%TAG !e! tag:x%2C`n---`n!e!a value" + TooLong = "%TAG !e! tag:x%2C`n---`n!e!ab value" + Limit = 7 + } + ) { + $first = $Yaml | Format-Yaml -MaxTagLength $Limit + $second = $first | Format-Yaml -MaxTagLength $Limit + $failure = Get-TestYamlFailure { + $TooLong | Format-Yaml -MaxTagLength $Limit + } + + $second | Should -BeExactly $first + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlTagLimitExceeded' + } + + It 'keeps the default tag-length boundary idempotent' { + $accepted = '!' + ('a' * 1023) + ' value' + $rejected = '!' + ('a' * 1024) + ' value' + + $first = $accepted | Format-Yaml + ($first | Format-Yaml) | Should -BeExactly $first + $failure = Get-TestYamlFailure { + $rejected | Format-Yaml + } + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlTagLimitExceeded' + } + + It 'keeps cumulative effective tag budgets idempotent' { + $yaml = "!abc one`n---`n!def two" + $first = $yaml | Format-Yaml -MaxTagLength 4 -MaxTotalTagLength 8 + + ($first | Format-Yaml -MaxTagLength 4 -MaxTotalTagLength 8) | + Should -BeExactly $first + $failure = Get-TestYamlFailure { + $yaml | Format-Yaml -MaxTagLength 4 -MaxTotalTagLength 7 + } + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlTagLimitExceeded' + } + + It 'classifies every parser resource limit like ConvertFrom-Yaml' -ForEach @( + @{ Name = 'depth'; Yaml = "a:`n b:`n c: value"; Parameters = @{ Depth = 2 } } + @{ Name = 'nodes'; Yaml = '[one, two]'; Parameters = @{ MaxNodes = 2 } } + @{ Name = 'aliases'; Yaml = "a: &a value`nb: *a"; Parameters = @{ MaxAliases = 0 } } + @{ Name = 'scalar length'; Yaml = 'value: long'; Parameters = @{ MaxScalarLength = 4 } } + @{ Name = 'tag length'; Yaml = '!long value'; Parameters = @{ MaxTagLength = 2 } } + @{ + Name = 'total tag length' + Yaml = "!a one`n---`n!b two" + Parameters = @{ MaxTotalTagLength = 3 } + } + @{ Name = 'numeric length'; Yaml = '123'; Parameters = @{ MaxNumericLength = 2 } } + ) { + $parseFailure = Get-TestYamlFailure { + $Yaml | ConvertFrom-Yaml @Parameters + } + $formatFailure = Get-TestYamlFailure { + $Yaml | Format-Yaml @Parameters + } + + $formatFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $formatFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Format-Yaml" + } + + It 'classifies invalid YAML like ConvertFrom-Yaml' -ForEach @( + @{ Name = 'malformed flow'; Yaml = '[one, two' } + @{ Name = 'duplicate key'; Yaml = "key: one`nkey: two" } + @{ Name = 'undefined alias'; Yaml = '*missing' } + @{ Name = 'malformed tag'; Yaml = '!foo%GG value' } + ) { + $parseFailure = Get-TestYamlFailure { $Yaml | ConvertFrom-Yaml } + $formatFailure = Get-TestYamlFailure { $Yaml | Format-Yaml } + + $formatFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $formatFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Format-Yaml" + } + + It 'does not swallow unexpected runtime failures' { + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + Mock Read-YamlStream { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } else { + Mock Read-YamlStream -ModuleName $loadedModule.Name { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } + + { 'name: Ada' | Format-Yaml } | + Should -Throw -ExpectedMessage '*unexpected runtime failure*' + } + } +} diff --git a/tests/Import-Yaml.Tests.ps1 b/tests/Import-Yaml.Tests.ps1 new file mode 100644 index 0000000..a219f58 --- /dev/null +++ b/tests/Import-Yaml.Tests.ps1 @@ -0,0 +1,532 @@ +#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 'Import-Yaml' { + Context 'Command contract' { + It 'declares the Path and LiteralPath parameter sets' { + $command = Get-Command -Name Import-Yaml + $pathSet = $command.ParameterSets | Where-Object Name -EQ 'Path' + $literalPathSet = $command.ParameterSets | Where-Object Name -EQ 'LiteralPath' + $pathParameter = $pathSet.Parameters | Where-Object Name -EQ 'Path' + $literalPathParameter = $literalPathSet.Parameters | + Where-Object Name -EQ 'LiteralPath' + + $command.DefaultParameterSet | Should -Be 'Path' + @($command.ParameterSets.Name | Sort-Object) | Should -Be @('LiteralPath', 'Path') + $pathParameter.IsMandatory | Should -BeTrue + $pathParameter.Position | Should -Be 0 + $pathParameter.ValueFromPipeline | Should -BeTrue + $pathParameter.ValueFromPipelineByPropertyName | Should -BeFalse + $literalPathParameter.IsMandatory | Should -BeTrue + $literalPathParameter.ValueFromPipeline | Should -BeFalse + $literalPathParameter.ValueFromPipelineByPropertyName | Should -BeTrue + $command.Parameters['Path'].Aliases | Should -Not -Contain 'FullName' + $command.Parameters['LiteralPath'].Aliases | Should -Contain 'PSPath' + $command.Parameters['LiteralPath'].Aliases | Should -Contain 'FullName' + $command.OutputType.Type | Should -Contain ([object]) + } + + It 'provides complete command help' { + $help = Get-Help -Name Import-Yaml -Full + + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description.Text | Should -Not -BeNullOrEmpty + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 2 + @($help.Parameters.Parameter.Name) | Should -Contain 'Path' + @($help.Parameters.Parameter.Name) | Should -Contain 'LiteralPath' + @($help.Parameters.Parameter.Name) | Should -Contain 'Encoding' + $help.returnValues.returnValue.Type.Name | Should -Contain 'System.Object' + } + } + + Context 'File selection and document order' { + It 'accepts any extension and imports every document in file order' { + $path = Join-Path $TestDrive 'settings.data' + [System.IO.File]::WriteAllText( + $path, + "---`nname: first`n---`nname: second", + [System.Text.UTF8Encoding]::new($false, $true) + ) + + $result = @(Import-Yaml -Path $path) + + $result.Count | Should -Be 2 + $result[0].name | Should -Be 'first' + $result[1].name | Should -Be 'second' + } + + It 'sorts wildcard matches and suppresses duplicate resolved files' { + $firstPath = Join-Path $TestDrive 'a.yaml' + $secondPath = Join-Path $TestDrive 'b.yaml' + [System.IO.File]::WriteAllText($secondPath, 'value: second') + [System.IO.File]::WriteAllText($firstPath, 'value: first') + + $result = @( + Import-Yaml -Path @( + (Join-Path $TestDrive '*.yaml'), + $secondPath, + $firstPath + ) + ) + + @($result.value) | Should -Be @('first', 'second') + } + + It 'keeps case-distinct files separate on case-sensitive filesystems' { + $upperPath = Join-Path $TestDrive 'Case.yaml' + $lowerPath = Join-Path $TestDrive 'case.yaml' + [System.IO.File]::WriteAllText($upperPath, 'value: upper') + [System.IO.File]::WriteAllText($lowerPath, 'value: lower') + if ([System.IO.File]::ReadAllText($upperPath) -eq + [System.IO.File]::ReadAllText($lowerPath)) { + Set-ItResult -Skipped -Because 'the test filesystem is case-insensitive' + return + } + + $result = @(Import-Yaml -Path @($upperPath, $lowerPath)) + + @($result.value) | Should -Be @('upper', 'lower') + } + + It 'suppresses path case variants on case-insensitive filesystems' { + $path = Join-Path $TestDrive 'CaseVariant.yaml' + [System.IO.File]::WriteAllText($path, 'value: once') + $caseVariantPath = Join-Path ( + Split-Path -Parent $path + ) ([System.IO.Path]::GetFileName($path).ToLowerInvariant()) + try { + $null = [System.IO.File]::GetAttributes($caseVariantPath) + } catch [System.IO.FileNotFoundException] { + Set-ItResult -Skipped -Because 'the test filesystem is case-sensitive' + return + } catch [System.IO.DirectoryNotFoundException] { + Set-ItResult -Skipped -Because 'the test filesystem is case-sensitive' + return + } + + $result = @(Import-Yaml -Path @($path, $caseVariantPath)) + + $result.Count | Should -Be 1 + $result[0].value | Should -Be 'once' + } + + It 'suppresses Unicode normalization aliases on matching filesystems' { + $composedPath = Join-Path $TestDrive "caf$([char] 0x00E9).yaml" + $decomposedPath = Join-Path $TestDrive "cafe$([char] 0x0301).yaml" + [System.IO.File]::WriteAllText($composedPath, 'value: once') + try { + $null = [System.IO.File]::GetAttributes($decomposedPath) + } catch [System.IO.FileNotFoundException] { + Set-ItResult -Skipped -Because ( + 'the test filesystem distinguishes Unicode normalization forms' + ) + return + } + + $result = @(Import-Yaml -LiteralPath @($composedPath, $decomposedPath)) + + $result.Count | Should -Be 1 + $result[0].value | Should -Be 'once' + } + + It 'keeps delimiter-bearing Unix paths distinct' -Skip:$IsWindows { + $separator = [char] 0x001F + $firstDirectory = Join-Path $TestDrive 'identity-a' + $secondDirectory = Join-Path $TestDrive "identity-a${separator}identity-b" + $null = [System.IO.Directory]::CreateDirectory($firstDirectory) + $null = [System.IO.Directory]::CreateDirectory($secondDirectory) + $firstPath = Join-Path $firstDirectory "identity-b${separator}value.yaml" + $secondPath = Join-Path $secondDirectory 'value.yaml' + [System.IO.File]::WriteAllText($firstPath, 'value: first') + [System.IO.File]::WriteAllText($secondPath, 'value: second') + + $result = @(Import-Yaml -LiteralPath @($firstPath, $secondPath)) + + @($result.value | Sort-Object) | Should -Be @('first', 'second') + } + + It 'sorts by canonical identity regardless of duplicate path spelling' { + $firstPath = Join-Path $TestDrive 'canonical-a.yaml' + $secondPath = Join-Path $TestDrive 'canonical-b.yaml' + $secondAlias = Join-Path ( + Split-Path -Parent $secondPath + ) ([System.IO.Path]::GetFileName($secondPath).ToUpperInvariant()) + [System.IO.File]::WriteAllText($firstPath, 'value: first') + [System.IO.File]::WriteAllText($secondPath, 'value: second') + try { + $null = [System.IO.File]::GetAttributes($secondAlias) + } catch [System.IO.FileNotFoundException] { + Set-ItResult -Skipped -Because 'the test filesystem is case-sensitive' + return + } catch [System.IO.DirectoryNotFoundException] { + Set-ItResult -Skipped -Because 'the test filesystem is case-sensitive' + return + } + + $result = @(Import-Yaml -Path @($secondAlias, $firstPath, $secondPath)) + + @($result.value) | Should -Be @('first', 'second') + } + + It 'normalizes insensitive components around a case-sensitive directory' ` + -Skip:(-not $IsWindows) { + $caseDirectory = Join-Path $TestDrive 'Sensitive' + $null = [System.IO.Directory]::CreateDirectory($caseDirectory) + $null = & fsutil.exe file SetCaseSensitiveInfo $caseDirectory enable 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-ItResult -Skipped -Because 'per-directory case sensitivity is unavailable' + return + } + + $upperPath = Join-Path $caseDirectory 'Case.yaml' + $lowerPath = Join-Path $caseDirectory 'case.yaml' + $variantUpperPath = Join-Path ( + Split-Path -Parent $caseDirectory + ) 'sensitive\Case.yaml' + [System.IO.File]::WriteAllText($upperPath, 'value: upper') + [System.IO.File]::WriteAllText($lowerPath, 'value: lower') + + $result = @(Import-Yaml -Path @($upperPath, $variantUpperPath, $lowerPath)) + + @($result.value) | Should -Be @('upper', 'lower') + } + + It 'resolves each relative pipeline path at the location where it arrives' { + $firstDirectory = Join-Path $TestDrive 'pipeline-a' + $secondDirectory = Join-Path $TestDrive 'pipeline-b' + $null = [System.IO.Directory]::CreateDirectory($firstDirectory) + $null = [System.IO.Directory]::CreateDirectory($secondDirectory) + [System.IO.File]::WriteAllText( + (Join-Path $firstDirectory 'config.yaml'), + 'value: first' + ) + [System.IO.File]::WriteAllText( + (Join-Path $secondDirectory 'config.yaml'), + 'value: second' + ) + + $result = @( + & { + Push-Location $firstDirectory + try { + Write-Output 'config.yaml' + Set-Location $secondDirectory + Write-Output 'config.yaml' + } finally { + Pop-Location + } + } | Import-Yaml + ) + + @($result.value) | Should -Be @('first', 'second') + } + + It 'preserves deterministic file and document order together' { + $firstPath = Join-Path $TestDrive '01.yaml' + $secondPath = Join-Path $TestDrive '02.yaml' + [System.IO.File]::WriteAllText( + $secondPath, + "---`nvalue: three`n---`nvalue: four" + ) + [System.IO.File]::WriteAllText( + $firstPath, + "---`nvalue: one`n---`nvalue: two" + ) + + $result = @(Import-Yaml -Path (Join-Path $TestDrive '0?.yaml')) + + @($result.value) | Should -Be @('one', 'two', 'three', 'four') + } + + It 'treats wildcard characters literally with LiteralPath' { + $path = Join-Path $TestDrive 'settings[1].data' + [System.IO.File]::WriteAllText($path, 'value: literal') + [System.IO.File]::WriteAllText( + (Join-Path $TestDrive 'settings1.data'), + 'value: wildcard' + ) + + (Import-Yaml -LiteralPath $path).value | Should -Be 'literal' + } + + It 'resolves FileInfo pipeline input literally' { + $path = Join-Path $TestDrive 'config[production].yaml' + [System.IO.File]::WriteAllText($path, 'value: pipeline') + + $result = Get-Item -LiteralPath $path | Import-Yaml + + $result.value | Should -Be 'pipeline' + } + + It 'resolves FullName pipeline properties literally' { + $path = Join-Path $TestDrive 'full[name].yaml' + [System.IO.File]::WriteAllText($path, 'value: fullname') + + $result = [pscustomobject]@{ FullName = $path } | Import-Yaml + + $result.value | Should -Be 'fullname' + } + + It 'accepts PSPath pipeline properties through LiteralPath' { + $path = Join-Path $TestDrive 'literal[2].yaml' + [System.IO.File]::WriteAllText($path, 'value: property') + $item = Get-Item -LiteralPath $path + + $result = [pscustomobject]@{ PSPath = $item.PSPath } | Import-Yaml + + $result.value | Should -Be 'property' + } + + It 'preserves each root sequence as one record with NoEnumerate' { + $path = Join-Path $TestDrive 'sequence.yaml' + [System.IO.File]::WriteAllText($path, "- one`n- two") + + $result = Import-Yaml -Path $path -NoEnumerate + + , $result | Should -BeOfType [object[]] + $result | Should -Be @('one', 'two') + } + + It 'emits no documents for an empty file' { + $path = Join-Path $TestDrive 'empty.yaml' + [System.IO.File]::WriteAllBytes($path, [byte[]]::new(0)) + + @(Import-Yaml -Path $path).Count | Should -Be 0 + } + } + + Context 'Strict text decoding' { + It 'imports BOM-less text with the selected encoding' -ForEach @( + @{ + Name = 'utf8' + EncodingName = 'utf8' + Encoding = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf8BOM' + EncodingName = 'utf8BOM' + Encoding = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf16LE' + EncodingName = 'utf16LE' + Encoding = [System.Text.UnicodeEncoding]::new($false, $false, $true) + } + @{ + Name = 'utf16BE' + EncodingName = 'utf16BE' + Encoding = [System.Text.UnicodeEncoding]::new($true, $false, $true) + } + @{ + Name = 'utf32LE' + EncodingName = 'utf32LE' + Encoding = [System.Text.UTF32Encoding]::new($false, $false, $true) + } + @{ + Name = 'utf32BE' + EncodingName = 'utf32BE' + Encoding = [System.Text.UTF32Encoding]::new($true, $false, $true) + } + ) { + $path = Join-Path $TestDrive "$Name.data" + $text = "value: caf$([char] 0xE9)" + [System.IO.File]::WriteAllBytes($path, $Encoding.GetBytes($text)) + + (Import-Yaml -Path $path -Encoding $EncodingName).value | Should -Be "caf$([char] 0xE9)" + } + + It 'detects a BOM and lets it override the selected encoding' -ForEach @( + @{ + Name = 'utf8' + EncodingName = 'utf16BE' + Encoding = [System.Text.UTF8Encoding]::new($true, $true) + } + @{ + Name = 'utf16LE' + EncodingName = 'utf8' + Encoding = [System.Text.UnicodeEncoding]::new($false, $true, $true) + } + @{ + Name = 'utf16BE' + EncodingName = 'utf8' + Encoding = [System.Text.UnicodeEncoding]::new($true, $true, $true) + } + @{ + Name = 'utf32LE' + EncodingName = 'utf8' + Encoding = [System.Text.UTF32Encoding]::new($false, $true, $true) + } + @{ + Name = 'utf32BE' + EncodingName = 'utf8' + Encoding = [System.Text.UTF32Encoding]::new($true, $true, $true) + } + ) { + $path = Join-Path $TestDrive "$Name-bom.data" + $payload = [byte[]] ( + $Encoding.GetPreamble() + + $Encoding.GetBytes("value: caf$([char] 0xE9)") + ) + [System.IO.File]::WriteAllBytes($path, $payload) + + (Import-Yaml -Path $path -Encoding $EncodingName).value | Should -Be "caf$([char] 0xE9)" + } + + It 'defaults to strict BOM-less UTF-8' { + $path = Join-Path $TestDrive 'default-utf8.yaml' + $encoding = [System.Text.UTF8Encoding]::new($false, $true) + [System.IO.File]::WriteAllBytes( + $path, + $encoding.GetBytes("value: caf$([char] 0xE9)") + ) + + (Import-Yaml -Path $path).value | Should -Be "caf$([char] 0xE9)" + } + + It 'rejects malformed bytes with a path-specific encoding error' { + $path = Join-Path $TestDrive 'malformed.yaml' + [System.IO.File]::WriteAllBytes($path, [byte[]] @(0xC3, 0x28)) + + try { + Import-Yaml -Path $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportEncodingFailed,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidData' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + } + + Context 'Parser controls' { + It 'passes AsHashtable and every parser limit through' { + $path = Join-Path $TestDrive 'mapping.yaml' + [System.IO.File]::WriteAllText($path, "outer:`n value: 12") + + $result = Import-Yaml -Path $path -AsHashtable -Depth 3 -MaxNodes 5 ` + -MaxAliases 0 -MaxScalarLength 5 -MaxTagLength 3 ` + -MaxTotalTagLength 3 -MaxNumericLength 2 + + $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['outer']['value'] | Should -Be 12 + } + + It 'preserves parser limit classifications and identifies the path' { + $path = Join-Path $TestDrive 'limited.yaml' + [System.IO.File]::WriteAllText($path, "[one, two]") + + try { + Import-Yaml -Path $path -MaxNodes 2 + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlNodeLimitExceeded,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidData' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + } + + Context 'Classified failures' { + It 'classifies a missing literal path' { + $path = Join-Path $TestDrive 'missing.yaml' + + try { + Import-Yaml -LiteralPath $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportPathNotFound,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'ObjectNotFound' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + + It 'classifies an unresolved wildcard path' { + $path = Join-Path $TestDrive '*.missing' + + try { + Import-Yaml -Path $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportPathNotFound,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'ObjectNotFound' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + + It 'rejects non-FileSystem providers' { + try { + Import-Yaml -LiteralPath 'Env:PATH' + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportProviderNotSupported,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidArgument' + $_.Exception.Message | Should -Match 'Env:PATH' + } + } + + It 'rejects directories' { + $path = Join-Path $TestDrive 'directory' + $null = [System.IO.Directory]::CreateDirectory($path) + + try { + Import-Yaml -LiteralPath $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportNotFile,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidType' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + + It 'classifies file read failures' { + $path = Join-Path $TestDrive 'locked.yaml' + [System.IO.File]::WriteAllText($path, 'value: locked') + $stream = [System.IO.File]::Open( + $path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + try { + try { + Import-Yaml -LiteralPath $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportReadFailed,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'ReadError' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } finally { + $stream.Dispose() + } + } + + It 'preserves parser classifications and identifies the path' { + $path = Join-Path $TestDrive 'invalid.yaml' + [System.IO.File]::WriteAllText($path, '[unterminated') + + try { + Import-Yaml -Path $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Match '^Yaml.+,Import-Yaml$' + $_.CategoryInfo.Category | Should -Be 'InvalidData' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + } +} diff --git a/tests/Merge-Yaml.Tests.ps1 b/tests/Merge-Yaml.Tests.ps1 new file mode 100644 index 0000000..4d03ef4 --- /dev/null +++ b/tests/Merge-Yaml.Tests.ps1 @@ -0,0 +1,899 @@ +#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') + + function Get-MergeYamlFailure { + <# + .SYNOPSIS + Captures one expected terminating Merge-Yaml error. + #> + param ( + [Parameter(Mandatory)] + [scriptblock] $Action + ) + + try { + $null = & $Action + } catch { + return $_ + } + throw 'The YAML merge unexpectedly succeeded.' + } + + function Get-MergeYamlGraphFact { + <# + .SYNOPSIS + Returns representation graph facts without projecting YAML values. + #> + param ( + [Parameter(Mandatory)] + [string] $Yaml + ) + + $implementation = { + param ([string] $YamlText) + + $documents = (Read-YamlStreamCore -Yaml $YamlText -Depth 100 -MaxNodes 10000 ` + -MaxAliases 1000 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096).Value + $visited = [System.Collections.Generic.HashSet[int]]::new() + $stack = [System.Collections.Generic.Stack[object]]::new() + foreach ($document in $documents) { + $stack.Push($document) + } + $aliases = 0 + $nodes = 0 + $tags = [System.Collections.Generic.List[string]]::new() + while ($stack.Count -gt 0) { + $node = $stack.Pop() + if (-not $visited.Add($node.Id)) { + continue + } + $nodes++ + if (-not [string]::IsNullOrEmpty($node.Tag)) { + $tags.Add([string] $node.Tag) + } + if ($node.Kind -eq 'Alias') { + $aliases++ + $stack.Push($node.Target) + } elseif ($node.Kind -eq 'Sequence') { + foreach ($item in $node.Items) { + $stack.Push($item) + } + } elseif ($node.Kind -eq 'Mapping') { + foreach ($entry in $node.Entries) { + $stack.Push($entry.Value) + $stack.Push($entry.Key) + } + } + } + + [pscustomobject]@{ + DocumentCount = $documents.Count + NodeCount = $nodes + AliasCount = $aliases + Tags = [string[]] $tags.ToArray() + } + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation $Yaml + } + return & $loadedModule $implementation $Yaml + } + + function Measure-MergeYamlWork { + <# + .SYNOPSIS + Captures the deterministic merge operation count from the debug stream. + #> + param ( + [Parameter(Mandatory)] + [string[]] $InputObject, + + [Parameter()] + [hashtable] $Parameters = @{} + ) + + $records = @(Merge-Yaml -InputObject $InputObject @Parameters -Debug 5>&1) + $debugText = $records | + ForEach-Object ToString | + Where-Object { $_ -like '*Merge-Yaml work operations:*' } | + Select-Object -Last 1 + if ($null -eq $debugText -or + $debugText -notmatch 'Merge-Yaml work operations: (?\d+)') { + throw 'Merge-Yaml did not report its deterministic work count.' + } + $output = $records | + Where-Object { $_ -isnot [System.Management.Automation.DebugRecord] } | + Select-Object -First 1 + + [pscustomobject]@{ + Output = [string] $output + Count = [long] $Matches.WorkCount + } + } +} + +Describe 'Merge-Yaml' { + Context 'Public contract' { + It 'exposes the documented advanced string merge contract' { + $command = Get-Command -Name Merge-Yaml + $inputParameter = $command.Parameters['InputObject'] + $inputAttribute = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $allowEmptyString = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $indentRange = $command.Parameters['Indent'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $sequenceSet = $command.Parameters['SequenceAction'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $conflictSet = $command.Parameters['ConflictAction'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $nullSet = $command.Parameters['NullAction'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + $help = Get-Help -Name Merge-Yaml -Full + + $command.CmdletBinding | Should -BeTrue + $inputParameter.ParameterType | Should -Be ([string[]]) + $inputAttribute.Mandatory | Should -BeTrue + $inputAttribute.Position | Should -Be 0 + $inputAttribute.ValueFromPipeline | Should -BeTrue + $allowEmptyString | Should -Not -BeNullOrEmpty + @($command.OutputType.Type) | Should -Contain ([string]) + @($sequenceSet.ValidValues) | Should -Be @('Replace', 'Append', 'Unique') + @($conflictSet.ValidValues) | Should -Be @('Replace', 'Error') + @($nullSet.ValidValues) | Should -Be @('Replace', 'Ignore') + $indentRange.MinRange | Should -Be 2 + $indentRange.MaxRange | Should -Be 9 + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description.Text | Should -Match 'complete YAML stream' + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 2 + } + + It 'mirrors every parser safety range' -ForEach @( + @{ Name = 'Depth'; Minimum = 1; Maximum = 128 } + @{ Name = 'MaxNodes'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxAliases'; Minimum = 0; Maximum = 2147483647 } + @{ Name = 'MaxScalarLength'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxTagLength'; Minimum = 1; Maximum = 1048576 } + @{ Name = 'MaxTotalTagLength'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxNumericLength'; Minimum = 1; Maximum = 1048576 } + ) { + $range = (Get-Command Merge-Yaml).Parameters[$Name].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + + $range.MinRange | Should -Be $Minimum + $range.MaxRange | Should -Be $Maximum + } + + It 'accepts direct arrays and complete pipeline stream records' { + $base = "root:`n first: 1" + $overlay = "root:`n second: 2" + $expected = "root:`n first: 1`n second: 2" | Format-Yaml + + (Merge-Yaml -InputObject @($base, $overlay)) | Should -BeExactly $expected + (@($base, $overlay) | Merge-Yaml) | Should -BeExactly $expected + } + + It 'emits exactly one LF-only string without a final newline' { + $result = @(Merge-Yaml -InputObject @("one: 1`r`n", "two: 2`r`n")) + + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType [string] + $result[0] | Should -Not -Match "`r" + $result[0].EndsWith("`n", [System.StringComparison]::Ordinal) | + Should -BeFalse + } + + It 'requires at least two complete streams' { + $failure = Get-MergeYamlFailure { Merge-Yaml -InputObject 'one: 1' } + + $failure.Exception.Data['YamlErrorId'] | Should -BeExactly 'YamlMergeInputCount' + $failure.FullyQualifiedErrorId | Should -Be 'YamlMergeInputCount,Merge-Yaml' + } + } + + Context 'Mapping precedence and order' { + It 'merges mappings recursively while retaining and appending key positions' { + $base = @' +root: + keep: 1 + replace: old +tail: base +'@ + $overlay = @' +root: + replace: new + added: true +'@ + $expected = @' +root: + keep: 1 + replace: new + added: true +tail: base +'@ | Format-Yaml + + (Merge-Yaml -InputObject @($base, $overlay)) | Should -BeExactly $expected + } + + It 'applies later precedence across three or more streams' { + $first = "value: first`nfirst: true" + $second = "value: second`nsecond: true" + $third = "value: third`nthird: true" + $expected = "value: third`nfirst: true`nsecond: true`nthird: true" | Format-Yaml + + (Merge-Yaml $first, $second, $third) | Should -BeExactly $expected + } + + It 'merges structurally equal complex mapping keys' { + $base = @' +? [region, { port: 443 }] +: { first: 1 } +'@ + $overlay = @' +? + - region + - port: 443 +: { second: 2 } +'@ + $merged = Merge-Yaml $base, $overlay + $result = $merged | ConvertFrom-Yaml -AsHashtable + $entry = @($result.GetEnumerator())[0] + + $result.Count | Should -Be 1 + $entry.Value['first'] | Should -Be 1 + $entry.Value['second'] | Should -Be 2 + } + + It 'does not merge unequal complex keys that share a candidate bucket' { + $base = @' +? [region, { port: 443 }] +: first +'@ + $overlay = @' +? [zone, { port: 443 }] +: second +'@ + $result = Merge-Yaml $base, $overlay | ConvertFrom-Yaml -AsHashtable + + $result.Count | Should -Be 2 + @($result.Values) | Should -Be @('first', 'second') + } + + It 're-buckets a shared structural key after its target is recursively mutated' ` + -ForEach @( + @{ Action = 'Replace' } + @{ Action = 'Error' } + ) { + $base = "target: &key {x: 1}`n? *key`n: base" + $overlay = "target: {y: 2}`n? {x: 1, y: 2}`n: overlay" + + if ($Action -eq 'Error') { + $failure = Get-MergeYamlFailure { + Merge-Yaml $base, $overlay -ConflictAction Error + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlMergeConflict' + $failure.Exception.Message | Should -Match 'input index 1' + return + } + + $result = Merge-Yaml $base, $overlay | ConvertFrom-Yaml -AsHashtable + + $result.Count | Should -Be 2 + $result['target'].Count | Should -Be 2 + @($result.Values) | Should -Contain 'overlay' + } + + It 're-buckets tagged shared keys across later overlay streams' { + $base = @' +target: &key !item { x: 1 } +? *key +: base +'@ + $firstOverlay = 'target: !item { y: 2 }' + $secondOverlay = @' +? !item { x: 1, y: 2 } +: final +'@ + + $merged = Merge-Yaml $base, $firstOverlay, $secondOverlay + $result = $merged | ConvertFrom-Yaml -AsHashtable + $facts = Get-MergeYamlGraphFact -Yaml $merged + + $result.Count | Should -Be 2 + @($result.Values) | Should -Contain 'final' + @($facts.Tags) | Should -Contain '!item' + } + + It 'keeps YAML 1.1 merge syntax as ordinary mapping data' { + $base = @' +<<: + fromBase: true +ordinary: value +'@ + $overlay = @' +<<: + fromOverlay: true +'@ + $result = Merge-Yaml $base, $overlay | ConvertFrom-Yaml -AsHashtable + + @($result.Keys) | Should -Be @('<<', 'ordinary') + $result['<<']['fromBase'] | Should -BeTrue + $result['<<']['fromOverlay'] | Should -BeTrue + $result.Contains('fromBase') | Should -BeFalse + $result.Contains('fromOverlay') | Should -BeFalse + } + } + + Context 'Sequence policies' { + It 'applies the requested compatible-sequence action' -ForEach @( + @{ + Action = 'Replace' + Expected = "items:`n- two`n- three" + } + @{ + Action = 'Append' + Expected = "items:`n- one`n- two`n- two`n- three" + } + @{ + Action = 'Unique' + Expected = "items:`n- one`n- two`n- three" + } + ) { + $base = 'items: [one, two]' + $overlay = 'items: [two, three]' + + $actual = Merge-Yaml $base, $overlay -SequenceAction $Action + + $actual | Should -BeExactly ($Expected | Format-Yaml) + } + + It 'retains equal sequences under every action' -ForEach @( + @{ Action = 'Replace' } + @{ Action = 'Append' } + @{ Action = 'Unique' } + ) { + $base = "items: &items [one, two]`ncopy: *items" + $merged = Merge-Yaml $base, $base -SequenceAction $Action -ConflictAction Error + $result = $merged | ConvertFrom-Yaml -AsHashtable + + $merged | Should -BeExactly ($base | Format-Yaml) + [object]::ReferenceEquals($result['items'], $result['copy']) | + Should -BeTrue + } + + It 'deduplicates scalar, complex, tagged, and cyclic values structurally' { + $base = @' +items: + - one + - { key: value } + - !item tagged + - &cycle { self: *cycle } +'@ + $overlay = @' +items: + - one + - key: value + - !item tagged + - !other tagged + - &other { self: *other } + - added +'@ + $merged = Merge-Yaml $base, $overlay -SequenceAction Unique + $result = $merged | ConvertFrom-Yaml -AsHashtable + $facts = Get-MergeYamlGraphFact -Yaml $merged + + $result['items'].Count | Should -Be 6 + $result['items'][5] | Should -Be 'added' + [object]::ReferenceEquals( + $result['items'][3], + $result['items'][3]['self'] + ) | Should -BeTrue + @($facts.Tags) | Should -Contain '!item' + @($facts.Tags) | Should -Contain '!other' + } + + It 'preserves overlay sharing and cycles while appending' { + $base = 'items: [base]' + $overlay = @' +items: + - &shared { value: overlay } + - *shared + - &cycle { self: *cycle } +'@ + $merged = Merge-Yaml $base, $overlay -SequenceAction Append + $result = $merged | ConvertFrom-Yaml -AsHashtable + + [object]::ReferenceEquals($result['items'][1], $result['items'][2]) | + Should -BeTrue + [object]::ReferenceEquals( + $result['items'][3], + $result['items'][3]['self'] + ) | Should -BeTrue + } + + It 'invalidates a retained cyclic-item index after shared tagged-node mutation' { + $base = @' +target: &cycle !cycle + self: *cycle + x: 1 +items: [*cycle] +'@ + $firstOverlay = 'items: [added]' + $secondOverlay = @' +target: !cycle { y: 2 } +items: + - &other !cycle + self: *other + x: 1 + y: 2 +'@ + + $merged = Merge-Yaml $base, $firstOverlay, $secondOverlay ` + -SequenceAction Unique + $result = $merged | ConvertFrom-Yaml -AsHashtable + + $result['items'].Count | Should -Be 2 + [object]::ReferenceEquals($result['target'], $result['items'][0]) | + Should -BeTrue + $result['target']['y'] | Should -Be 2 + } + + It 'deduplicates structurally equal cycles with different graph lengths' { + $base = 'items: [&self [*self]]' + $overlay = 'items: [&outer [&inner [*outer]], added]' + + $merged = Merge-Yaml $base, $overlay -SequenceAction Unique + $result = $merged | ConvertFrom-Yaml -AsHashtable + + $result['items'].Count | Should -Be 2 + [object]::ReferenceEquals( + $result['items'][0], + $result['items'][0][0] + ) | Should -BeTrue + $result['items'][1] | Should -Be 'added' + } + } + + Context 'Conflict and null policies' { + It 'replaces incompatible scalar values by default' { + (Merge-Yaml 'value: old', 'value: new') | + Should -BeExactly ('value: new' | Format-Yaml) + } + + It 'rejects unequal scalar, kind, and effective-tag conflicts with context' -ForEach @( + @{ Base = 'value: old'; Overlay = 'value: new'; Path = '\$\.value' } + @{ Base = 'value: { key: one }'; Overlay = 'value: [one]'; Path = '\$\.value' } + @{ Base = '!first { key: one }'; Overlay = '!second { key: one }'; Path = '\$' } + ) { + $failure = Get-MergeYamlFailure { + Merge-Yaml $Base, $Overlay -ConflictAction Error + } + + $failure.Exception.Data['YamlErrorId'] | Should -BeExactly 'YamlMergeConflict' + $failure.Exception.Message | Should -Match 'input index 1' + $failure.Exception.Message | Should -Match 'document index 0' + $failure.Exception.Message | Should -Match $Path + } + + It 'retains structurally equal nodes under Error conflict policy' { + $base = '!same { nested: [one, { two: 2 }] }' + $overlay = '!same { nested: [one, { two: 2 }] }' + + (Merge-Yaml $base, $overlay -ConflictAction Error) | + Should -BeExactly ($base | Format-Yaml) + } + + It 'does not apply sequence actions across incompatible effective tags' { + $failure = Get-MergeYamlFailure { + Merge-Yaml '!first [one]', '!second [two]' ` + -SequenceAction Append -ConflictAction Error + } + + $failure.Exception.Data['YamlErrorId'] | Should -BeExactly 'YamlMergeConflict' + $failure.Exception.Message | Should -Match 'sequence tag' + } + + It 'applies nested null replacement and ignoring' { + $base = "root:`n value: retained" + $overlay = "root:`n value: null" + + $replace = Merge-Yaml $base, $overlay | ConvertFrom-Yaml -AsHashtable + $ignore = Merge-Yaml $base, $overlay -NullAction Ignore | + ConvertFrom-Yaml -AsHashtable + + $replace['root']['value'] | Should -BeNullOrEmpty + $ignore['root']['value'] | Should -Be 'retained' + } + + It 'applies root null replacement and ignoring' { + $base = 'value: retained' + + (Merge-Yaml $base, 'null' | ConvertFrom-Yaml) | Should -BeNullOrEmpty + (Merge-Yaml $base, 'null' -NullAction Ignore) | + Should -BeExactly ($base | Format-Yaml) + } + } + + Context 'Tags and graph identity' { + It 'recursively merges compatible standard and unknown mapping tags' -ForEach @( + @{ + Base = '!!map { first: 1 }' + Overlay = '{ second: 2 }' + ExpectedTag = 'tag:yaml.org,2002:map' + } + @{ + Base = '! { first: 1 }' + Overlay = '! { second: 2 }' + ExpectedTag = 'tag:example.test,2026:item' + } + ) { + $merged = Merge-Yaml $Base, $Overlay + $root = Get-TestYamlRepresentationRoot -Yaml $merged + $projected = $merged | ConvertFrom-Yaml -AsHashtable + + $root.Tag | Should -BeExactly $ExpectedTag + $projected['first'] | Should -Be 1 + $projected['second'] | Should -Be 2 + } + + It 'preserves base aliases when their target is recursively merged' { + $base = @' +root: &shared + base: true +copy: *shared +'@ + $overlay = @' +root: + overlay: true +'@ + $result = Merge-Yaml $base, $overlay | ConvertFrom-Yaml -AsHashtable + + $result['root']['base'] | Should -BeTrue + $result['copy']['overlay'] | Should -BeTrue + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + } + + It 'preserves shared overlay mappings across matched base entries' { + $base = @' +first: { base: one } +second: { base: two } +'@ + $overlay = @' +first: &shared { overlay: true } +second: *shared +'@ + $result = Merge-Yaml $base, $overlay | ConvertFrom-Yaml -AsHashtable + + $result['first']['overlay'] | Should -BeTrue + [object]::ReferenceEquals($result['first'], $result['second']) | + Should -BeTrue + } + + It 'preserves a recursively merged overlay cycle' { + $base = 'node: { base: true }' + $overlay = @' +node: &cycle + overlay: true + self: *cycle +'@ + $merged = Merge-Yaml $base, $overlay + $result = $merged | ConvertFrom-Yaml -AsHashtable + + $result['node']['base'] | Should -BeTrue + $result['node']['overlay'] | Should -BeTrue + [object]::ReferenceEquals($result['node'], $result['node']['self']) | + Should -BeTrue + $merged | Test-Yaml | Should -BeTrue + } + } + + Context 'Documents, validation, and limits' { + It 'merges equal positive multi-document streams by document index' { + $base = "---`nfirst: base`n---`nsecond: base" + $overlay = "---`nfirstOverlay: true`n---`nsecond: overlay" + $merged = Merge-Yaml $base, $overlay + $documents = @($merged | ConvertFrom-Yaml -AsHashtable) + + $documents.Count | Should -Be 2 + $documents[0]['first'] | Should -Be 'base' + $documents[0]['firstOverlay'] | Should -BeTrue + $documents[1]['second'] | Should -Be 'overlay' + } + + It 'keeps explicit empty documents as positive document records' { + $base = "---`nvalue: base`n---" + $overlay = "---`nvalue: overlay`n---" + $merged = Merge-Yaml $base, $overlay + $documents = @($merged | ConvertFrom-Yaml -AsHashtable) + + $documents.Count | Should -Be 2 + $documents[0]['value'] | Should -Be 'overlay' + $documents[1] | Should -BeNullOrEmpty + } + + It 'rejects no-document streams with their zero-based input index' -ForEach @( + @{ Empty = '' } + @{ Empty = "# comment only`n" } + ) { + $failure = Get-MergeYamlFailure { + Merge-Yaml 'value: base', $Empty + } + + $failure.Exception.Data['YamlErrorId'] | Should -BeExactly 'YamlMergeEmptyStream' + $failure.Exception.Message | Should -Match 'input index 1' + $failure.Exception.Message | Should -Match '0 documents' + } + + It 'rejects document-count mismatches with expected and actual counts' { + $failure = Get-MergeYamlFailure { + Merge-Yaml 'one: 1', "---`ntwo: 2`n---`nthree: 3" + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlMergeDocumentCountMismatch' + $failure.Exception.Message | Should -Match 'input index 1' + $failure.Exception.Message | Should -Match '2 documents' + $failure.Exception.Message | Should -Match 'expected 1' + } + + It 'preserves parser invalid-input classifications' { + $failure = Get-MergeYamlFailure { + Merge-Yaml 'valid: true', '[invalid' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlInvalidFlowCollection' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlInvalidFlowCollection,Merge-Yaml' + } + + It 'preserves every parser resource classification' -ForEach @( + @{ Yaml = "a:`n b:`n c: value"; Parameters = @{ Depth = 2 } } + @{ Yaml = '[one, two]'; Parameters = @{ MaxNodes = 2 } } + @{ Yaml = "a: &a value`nb: *a"; Parameters = @{ MaxAliases = 0 } } + @{ Yaml = 'value: long'; Parameters = @{ MaxScalarLength = 4 } } + @{ Yaml = '!long value'; Parameters = @{ MaxTagLength = 2 } } + @{ + Yaml = "!a one`n---`n!b two" + Parameters = @{ MaxTotalTagLength = 3 } + } + @{ Yaml = '123'; Parameters = @{ MaxNumericLength = 2 } } + ) { + $parseFailure = Get-MergeYamlFailure { + $Yaml | Format-Yaml @Parameters + } + $mergeFailure = Get-MergeYamlFailure { + Merge-Yaml 'valid: true', $Yaml @Parameters + } + + $mergeFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $mergeFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Merge-Yaml" + } + + It 'enforces the invocation-wide clone node budget' { + $failure = Get-MergeYamlFailure { + Merge-Yaml 'a: 1', '[overlay]' -MaxNodes 3 + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlMergeNodeLimitExceeded' + } + + It 'charges equality and candidate scans to one invocation work budget' { + $base = '[{ a: 1 }, { b: 2 }]' + $overlay = '[{ b: 2 }, { a: 1 }]' + $failure = Get-MergeYamlFailure { + Merge-Yaml $base, $overlay -SequenceAction Unique -MaxNodes 7 + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlMergeWorkLimitExceeded' + $failure.Exception.Data['YamlMergeWorkCount'] | Should -Be 8 + $failure.Exception.Data['YamlMergeWorkLimit'] | Should -Be 7 + } + + It 'keeps disjoint one-key overlay work linear' -ForEach @( + @{ Size = 200 } + @{ Size = 400 } + @{ Size = 800 } + ) { + $streams = [System.Collections.Generic.List[string]]::new() + $streams.Add('base: true') + foreach ($index in 1..$Size) { + $streams.Add("key${index}: ${index}") + } + $limit = 12 * $Size + 50 + + $measurement = Measure-MergeYamlWork -InputObject $streams.ToArray() ` + -Parameters @{ MaxNodes = $limit } + + $measurement.Count | Should -BeLessOrEqual (10 * $Size + 20) + $measurement.Output | Should -Not -BeNullOrEmpty + } + + It 'keeps unique sequence append work linear' -ForEach @( + @{ Size = 200 } + @{ Size = 400 } + @{ Size = 800 } + ) { + $streams = [System.Collections.Generic.List[string]]::new() + $streams.Add('items: []') + foreach ($index in 1..$Size) { + $streams.Add("items: [value${index}]") + } + $limit = 31 * $Size + 100 + + $measurement = Measure-MergeYamlWork -InputObject $streams.ToArray() ` + -Parameters @{ MaxNodes = $limit; SequenceAction = 'Unique' } + + $measurement.Count | Should -BeLessOrEqual (30 * $Size + 20) + $measurement.Output | Should -Not -BeNullOrEmpty + } + + It 'reuses fingerprints for shared complex keys throughout equality' -ForEach @( + @{ Size = 20 } + @{ Size = 40 } + @{ Size = 80 } + ) { + $values = (1..$Size | ForEach-Object { "value$_" }) -join ', ' + $yaml = [System.Collections.Generic.List[string]]::new() + $yaml.Add("shared: &key [$values]") + $yaml.Add('maps:') + foreach ($index in 1..$Size) { + $yaml.Add(' - ? *key') + $yaml.Add(" : value${index}") + } + $text = $yaml -join "`n" + + $measurement = Measure-MergeYamlWork -InputObject @($text, $text) ` + -Parameters @{ MaxNodes = 100000; ConflictAction = 'Error' } + + $measurement.Count | Should -BeLessOrEqual (20 * $Size + 50) + $measurement.Output | Should -Not -BeNullOrEmpty + } + + It 'deduplicates cyclic alias candidates before fingerprint traversal' -ForEach @( + @{ Size = 20 } + @{ Size = 40 } + @{ Size = 80 } + ) { + $base = [System.Collections.Generic.List[string]]::new() + $base.Add('items:') + $base.Add(' - &shared') + $base.Add(' self: *shared') + foreach ($index in 1..$Size) { + $base.Add(" field${index}: ${index}") + } + foreach ($index in 2..$Size) { + $base.Add(' - *shared') + } + $overlay = [System.Collections.Generic.List[string]]::new() + foreach ($line in $base) { + $overlay.Add($line) + } + $overlay.Add(' - added') + + $measurement = Measure-MergeYamlWork -InputObject @( + $base -join "`n" + $overlay -join "`n" + ) -Parameters @{ MaxNodes = 100000; SequenceAction = 'Unique' } + + $measurement.Count | Should -BeLessOrEqual (21 * $Size + 100) + $measurement.Output | Should -Not -BeNullOrEmpty + } + + It 'deduplicates hundreds of aliases before comparing one large mapping' { + $aliasCount = 300 + $entryCount = 200 + $base = [System.Collections.Generic.List[string]]::new() + $base.Add('shared: &shared') + foreach ($index in 1..$entryCount) { + $base.Add(" item${index}: ${index}") + } + foreach ($index in 1..$aliasCount) { + $base.Add("alias${index}: *shared") + } + $overlay = [System.Collections.Generic.List[string]]::new() + $overlay.Add('shared: &shared') + $overlay.Add(' added: true') + foreach ($index in 1..$aliasCount) { + $overlay.Add("alias${index}: *shared") + } + + $measurement = Measure-MergeYamlWork -InputObject @( + $base -join "`n" + $overlay -join "`n" + ) -Parameters @{ MaxNodes = 12000 } + $result = $measurement.Output | ConvertFrom-Yaml -AsHashtable + + $measurement.Count | Should -BeLessOrEqual 7000 + [object]::ReferenceEquals($result['shared'], $result['alias300']) | + Should -BeTrue + } + + It 'enforces resulting alias and tag budgets' -ForEach @( + @{ + Base = "a: &a one`nb: *a" + Overlay = "c: &c two`nd: *c" + Parameters = @{ MaxAliases = 1 } + ErrorId = 'YamlMergeAliasLimitExceeded' + } + @{ + Base = 'a: !x one' + Overlay = 'b: !y two' + Parameters = @{ MaxTotalTagLength = 2 } + ErrorId = 'YamlMergeTagLimitExceeded' + } + ) { + $failure = Get-MergeYamlFailure { + Merge-Yaml $Base, $Overlay @Parameters + } + + $failure.Exception.Data['YamlErrorId'] | Should -BeExactly $ErrorId + } + } + + Context 'Determinism and representation smoke coverage' { + It 'is deterministic, leaves source text semantics unchanged, and self-parses' { + $base = @' +root: &root + first: 1 +copy: *root +'@ + $overlay = @' +root: + second: [two, three] +'@ + $baseBefore = $base | Format-Yaml + $first = Merge-Yaml $base, $overlay -SequenceAction Unique -Indent 4 + $second = Merge-Yaml $base, $overlay -SequenceAction Unique -Indent 4 + + $second | Should -BeExactly $first + ($base | Format-Yaml) | Should -BeExactly $baseBefore + $first | Test-Yaml | Should -BeTrue + $first | Should -BeExactly ($first | Format-Yaml -Indent 4) + } + + It 'retains representation features from selected corpus fixtures' -ForEach @( + @{ Fixture = '2JQS.yaml' } + @{ Fixture = '6BFJ.yaml' } + @{ Fixture = '565N.yaml' } + @{ Fixture = 'SBG9.yaml' } + ) { + $path = Join-Path $PSScriptRoot "fixtures\yaml-test-suite\$Fixture" + $yaml = Get-Content -LiteralPath $path -Raw + + if ($Fixture -eq '2JQS.yaml') { + { Merge-Yaml $yaml, $yaml } | Should -Throw + } else { + $merged = Merge-Yaml $yaml, $yaml -ConflictAction Error + $facts = Get-MergeYamlGraphFact -Yaml $merged + + $merged | Test-Yaml | Should -BeTrue + $facts.DocumentCount | Should -BeGreaterThan 0 + $merged | Should -BeExactly ($merged | Format-Yaml) + } + } + } +} 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..ac5e530 --- /dev/null +++ b/tests/Packaging.Tests.ps1 @@ -0,0 +1,317 @@ +#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() + +$importedYamlModule = Get-Module -Name Yaml | Select-Object -First 1 +$skipArtifactTests = [string]::IsNullOrWhiteSpace($env:PSMODULE_YAML_TEST_ARTIFACT) -and ( + $null -eq $importedYamlModule +) + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') + $repositoryRoot = Split-Path -Parent $PSScriptRoot + $loadedYamlModule = $yamlModule + $artifactManifestPath = if ($null -ne $loadedYamlModule) { + Join-Path $loadedYamlModule.ModuleBase 'Yaml.psd1' + } else { + $null + } +} + +Describe 'Dependency-free package source' { + It 'contains no external parser assembly, license, or notice payload' { + @( + Get-ChildItem -Path (Join-Path $repositoryRoot 'src\assemblies') ` + -File -ErrorAction SilentlyContinue + ).Count | Should -Be 0 + Test-Path (Join-Path $repositoryRoot 'src\licenses\YamlDotNet.LICENSE.txt') | Should -BeFalse + Test-Path (Join-Path $repositoryRoot 'src\THIRD-PARTY-NOTICES.txt') | Should -BeFalse + } + + It 'keeps RequiredAssemblies out of the source manifest' { + $manifest = Import-PowerShellDataFile -Path ( + Join-Path $repositoryRoot 'src\manifest.psd1' + ) + + $manifest.PowerShellVersion | Should -Be '7.6' + @($manifest.CompatiblePSEditions) | Should -Be @('Core') + @($manifest.CompatiblePSEditions) | Should -Not -Contain 'Desktop' + $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse + $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse + } + + It 'declares the generated artifact runtime once in the module header' { + $requirementPath = Join-Path $repositoryRoot 'src\header.ps1' + $source = Get-Content -LiteralPath $requirementPath -Raw + + $source | Should -Match '(?m)^#Requires -Version 7\.6\r?$' + $source | Should -Match '(?m)^#Requires -PSEdition Core\r?$' + } + + It 'contains no external parser references or custom assembly loader' { + $sourceFiles = Get-ChildItem -Path (Join-Path $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 $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', + 'ConvertTo-YamlRepresentationNode.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 $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 $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 $repositoryRoot '.github\PSModule.yml' + ) -Raw + + $configuration | Should -Not -Match '(?ms)Build:\s+Site:\s+.*Skip:\s*true' + Test-Path -Path (Join-Path $repositoryRoot '.github\zensical.toml') | Should -BeTrue + Test-Path -Path (Join-Path $repositoryRoot '.github\mkdocs.yml') | Should -BeFalse + } +} + +Describe 'Generated artifact package' { + It 'has no RequiredAssemblies or packaged DLL and has a complete FileList' ` + -Skip:$skipArtifactTests { + $manifest = Import-PowerShellDataFile -Path $artifactManifestPath + $moduleBase = Split-Path -Parent $artifactManifestPath + + $manifest.PowerShellVersion | Should -Be '7.6' + @($manifest.CompatiblePSEditions) | Should -Be @('Core') + @($manifest.CompatiblePSEditions) | Should -Not -Contain 'Desktop' + $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse + $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse + @($manifest.FunctionsToExport | Sort-Object) | + Should -Be @( + 'ConvertFrom-Yaml', + 'ConvertTo-Yaml', + 'Export-Yaml', + 'Format-Yaml', + 'Import-Yaml', + 'Merge-Yaml', + 'Remove-YamlEntry', + 'Test-Yaml' + ) + @($manifest.FileList) | Should -Contain 'Yaml.psm1' + $packagedFiles = @( + Get-ChildItem -Path $moduleBase -Recurse -File | + Where-Object FullName -NE $artifactManifestPath | + ForEach-Object { + [System.IO.Path]::GetRelativePath($moduleBase, $_.FullName) + } | + Sort-Object + ) + @($manifest.FileList | Sort-Object) | Should -Be $packagedFiles + @($manifest.FileList | Where-Object { $_ -match '\.(?:dll|exe)$' }).Count | Should -Be 0 + @($manifest.FileList | Where-Object { $_ -match 'THIRD-PARTY|YamlDotNet' }).Count | Should -Be 0 + @($manifest.PrivateData.PSData.Tags) | Should -Not -Contain 'PSEdition_Desktop' + @(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.6 Core process and preserves arrays, aliases, and depth' ` + -Skip:($skipArtifactTests -or $null -eq (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $script = @' +$ErrorActionPreference = 'Stop' +$ps = $PSVersionTable.PSVersion +if ($ps -lt [version] '7.6') { + throw "Expected PowerShell 7.6 or newer but got $ps." +} +if ($PSVersionTable.PSEdition -cne 'Core') { + throw "Expected PowerShell Core but got $($PSVersionTable.PSEdition)." +} +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.' +} +$formatted = '{name: Ada, active: true}' | Format-Yaml +if ($formatted -cne "---`n`"name`": `"Ada`"`n`"active`": true") { + throw 'The imported formatter did not normalize 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-runtime=$ps;edition=$($PSVersionTable.PSEdition)" +'@.Replace('__MANIFEST__', $artifactManifestPath.Replace("'", "''")) + + $output = @(& pwsh -NoLogo -NoProfile -Command $script) + $LASTEXITCODE | Should -Be 0 + $runtime = $output | Where-Object { $_ -like 'powershell-runtime=*' } | + Select-Object -Last 1 + + $runtimeMatch = [regex]::Match( + [string] $runtime, + '^powershell-runtime=(?\d+(?:\.\d+){1,3});edition=Core$' + ) + $runtimeMatch.Success | Should -BeTrue + ([version] $runtimeMatch.Groups['Version'].Value) -lt [version] '7.6' | + Should -BeFalse + Write-Information -MessageData $runtime -InformationAction Continue + } + + It 'merges complete streams in a fresh PowerShell 7.6 Core process' ` + -Skip:($skipArtifactTests -or $null -eq (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $script = @' +$ErrorActionPreference = 'Stop' +$ps = $PSVersionTable.PSVersion +if ($ps -lt [version] '7.6') { + throw "Expected PowerShell 7.6 or newer but got $ps." +} +if ($PSVersionTable.PSEdition -cne 'Core') { + throw "Expected PowerShell Core but got $($PSVersionTable.PSEdition)." +} +Import-Module -Name '__MANIFEST__' -Force +$merged = Merge-Yaml -InputObject @( + 'service: { image: example:v1, ports: [80] }', + 'service: { image: example:v2, ports: [443] }' +) +if ($merged -match "`r" -or $merged.EndsWith("`n", [System.StringComparison]::Ordinal)) { + throw 'The imported merge command did not normalize its output contract.' +} +$mergedValue = $merged | ConvertFrom-Yaml -AsHashtable +if ($mergedValue['service']['image'] -cne 'example:v2' -or + $mergedValue['service']['ports'][0] -ne 443) { + throw 'The imported merge command did not apply later stream precedence.' +} +"merge-runtime=$ps;edition=$($PSVersionTable.PSEdition)" +'@.Replace('__MANIFEST__', $artifactManifestPath.Replace("'", "''")) + + $output = @(& pwsh -NoLogo -NoProfile -Command $script) + $LASTEXITCODE | Should -Be 0 + $runtime = $output | Where-Object { $_ -like 'merge-runtime=*' } | + Select-Object -Last 1 + + $runtimeMatch = [regex]::Match( + [string] $runtime, + '^merge-runtime=(?\d+(?:\.\d+){1,3});edition=Core$' + ) + $runtimeMatch.Success | Should -BeTrue + ([version] $runtimeMatch.Groups['Version'].Value) -lt [version] '7.6' | + Should -BeFalse + Write-Information -MessageData $runtime -InformationAction Continue + } + + It 'removes representation entries in a fresh PowerShell 7.6 Core process' ` + -Skip:($skipArtifactTests -or $null -eq (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $script = @' +$ErrorActionPreference = 'Stop' +$ps = $PSVersionTable.PSVersion +if ($ps -lt [version] '7.6') { + throw "Expected PowerShell 7.6 or newer but got $ps." +} +if ($PSVersionTable.PSEdition -cne 'Core') { + throw "Expected PowerShell Core but got $($PSVersionTable.PSEdition)." +} +Import-Module -Name '__MANIFEST__' -Force +$removed = @" +root: &shared + keep: true + drop: false +copy: *shared +"@ | Remove-YamlEntry -Path '/copy/drop' +if ($removed -match "`r" -or $removed.EndsWith("`n", [System.StringComparison]::Ordinal)) { + throw 'The imported removal command did not normalize its output contract.' +} +$value = $removed | ConvertFrom-Yaml -AsHashtable +if ($value['root'].Contains('drop') -or $value['copy'].Contains('drop')) { + throw 'The imported removal command did not mutate the shared mapping.' +} +if (-not [object]::ReferenceEquals($value['root'], $value['copy'])) { + throw 'The imported removal command lost shared mapping identity.' +} +$empty = Remove-YamlEntry "---`nfirst: true`n---`nsecond: true" '' -AllDocuments +if ($empty -cne '') { + throw 'The imported removal command did not remove every selected document.' +} +"remove-runtime=$ps;edition=$($PSVersionTable.PSEdition)" +'@.Replace('__MANIFEST__', $artifactManifestPath.Replace("'", "''")) + + $output = @(& pwsh -NoLogo -NoProfile -Command $script) + $LASTEXITCODE | Should -Be 0 + $runtime = $output | Where-Object { $_ -like 'remove-runtime=*' } | + Select-Object -Last 1 + + $runtimeMatch = [regex]::Match( + [string] $runtime, + '^remove-runtime=(?\d+(?:\.\d+){1,3});edition=Core$' + ) + $runtimeMatch.Success | Should -BeTrue + ([version] $runtimeMatch.Groups['Version'].Value) -lt [version] '7.6' | + Should -BeFalse + Write-Information -MessageData $runtime -InformationAction Continue + } +} diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 new file mode 100644 index 0000000..caa793a --- /dev/null +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -0,0 +1,962 @@ +#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') + + function Get-RemoveYamlFailure { + <# + .SYNOPSIS + Captures one expected terminating Remove-YamlEntry error. + #> + param ( + [Parameter(Mandatory)] + [scriptblock] $Action + ) + + try { + $null = & $Action + } catch { + return $_ + } + throw 'The YAML removal unexpectedly succeeded.' + } + + function Invoke-RemoveYamlAmbiguityProbe { + <# + .SYNOPSIS + Creates an otherwise unreachable ambiguous representation mapping. + #> + $implementation = { + $document = (Read-YamlStreamCore -Yaml 'key: value' -Depth 100 -MaxNodes 100 ` + -MaxAliases 100 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096).Value[0] + $document.Entries.Add([pscustomobject]@{ + Key = $document.Entries[0].Key + Value = $document.Entries[0].Value + }) + $state = [pscustomobject]@{ Count = 0L; MaxNodes = 100 } + $tokens = (ConvertFrom-YamlJsonPointer -Pointer '/key' -State $state).Value + Resolve-YamlRemovalTarget -Document $document -DocumentIndex 0 ` + -Pointer '/key' -Tokens $tokens -State $state + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation + } + return & $loadedModule $implementation + } + + function Measure-RemoveYamlWork { + <# + .SYNOPSIS + Captures the deterministic removal work count. + #> + param ( + [Parameter(Mandatory)] + [string] $Yaml, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string[]] $Path, + + [Parameter()] + [hashtable] $Parameters = @{} + ) + + $records = @( + Remove-YamlEntry -InputObject $Yaml -Path $Path @Parameters -Debug 5>&1 + ) + $debugText = $records | + ForEach-Object ToString | + Where-Object { $_ -like '*Remove-YamlEntry work operations:*' } | + Select-Object -Last 1 + if ($null -eq $debugText -or + $debugText -notmatch 'Remove-YamlEntry work operations: (?\d+)') { + throw 'Remove-YamlEntry did not report its deterministic work count.' + } + $output = $records | + Where-Object { $_ -isnot [System.Management.Automation.DebugRecord] } | + Select-Object -First 1 + + [pscustomobject]@{ + Output = [string] $output + Count = [long] $Matches.WorkCount + } + } + + function Test-RemoveYamlFingerprintCollision { + <# + .SYNOPSIS + Forces representation-key fingerprint candidates through exact graph equality. + #> + 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] + $fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + foreach ($entry in $document.Entries) { + $fingerprintCache[$entry.Key.Id] = 'forced-collision' + } + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + $equalityState = [pscustomobject]@{ + MaxNodes = 100 + FingerprintHasher = $hasher + WorkState = [pscustomobject]@{ Count = 0L; MaxNodes = 100 } + MutationState = [pscustomobject]@{ Version = 0L } + IndexDependents = ( + [System.Collections.Generic.Dictionary[int, object]]::new() + ) + Cache = ( + [System.Collections.Generic.Dictionary[string, bool]]::new( + [System.StringComparer]::Ordinal + ) + ) + InputIndex = 0 + } + $equalityFingerprintCache = ( + [System.Collections.Generic.Dictionary[int, string]]::new() + ) + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $hasher ` + -EqualityState $equalityState ` + -EqualityFingerprintCache $equalityFingerprintCache + } finally { + $hasher.Dispose() + } + return $true + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation $Yaml + } + return & $loadedModule $implementation $Yaml + } +} + +Describe 'Remove-YamlEntry' { + Context 'Public contract' { + It 'exposes one advanced string transformation contract' { + $command = Get-Command -Name Remove-YamlEntry + $inputParameter = $command.Parameters['InputObject'] + $inputAttribute = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $inputAllowsEmpty = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $pathParameter = $command.Parameters['Path'] + $pathAttribute = $pathParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $pathAllowsEmpty = $pathParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $documentIndexRange = $command.Parameters['DocumentIndex'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $indentRange = $command.Parameters['Indent'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $help = Get-Help -Name Remove-YamlEntry -Full + + $command.CmdletBinding | Should -BeTrue + $command.DefaultParameterSet | Should -BeExactly 'Document' + @($command.ParameterSets.Name | Sort-Object) | + Should -Be @('AllDocuments', 'Document') + $command.Parameters.ContainsKey('WhatIf') | Should -BeFalse + $command.Parameters.ContainsKey('Confirm') | Should -BeFalse + $inputParameter.ParameterType | Should -Be ([string[]]) + $inputAttribute.Mandatory | Should -BeTrue + $inputAttribute.Position | Should -Be 0 + $inputAttribute.ValueFromPipeline | Should -BeTrue + $inputAllowsEmpty | Should -Not -BeNullOrEmpty + $pathParameter.ParameterType | Should -Be ([string[]]) + $pathAttribute.Mandatory | Should -BeTrue + $pathAttribute.Position | Should -Be 1 + $pathAllowsEmpty | Should -Not -BeNullOrEmpty + $documentIndexRange.MinRange | Should -Be 0 + $documentIndexRange.MaxRange | Should -Be 2147483647 + $indentRange.MinRange | Should -Be 2 + $indentRange.MaxRange | Should -Be 9 + @($command.OutputType.Type) | Should -Contain ([string]) + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description.Text | Should -Match 'JSON Pointer' + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 3 + } + + It 'separates one-document and all-document selection' { + $command = Get-Command -Name Remove-YamlEntry + $documentSet = $command.ParameterSets | + Where-Object Name -EQ 'Document' + $allSet = $command.ParameterSets | + Where-Object Name -EQ 'AllDocuments' + + ($documentSet.Parameters | Where-Object Name -EQ 'DocumentIndex').IsMandatory | + Should -BeFalse + @($documentSet.Parameters.Name) | Should -Not -Contain 'AllDocuments' + ($allSet.Parameters | Where-Object Name -EQ 'AllDocuments').IsMandatory | + Should -BeTrue + @($allSet.Parameters.Name) | Should -Not -Contain 'DocumentIndex' + } + + It 'mirrors every parser safety range' -ForEach @( + @{ Name = 'Depth'; Minimum = 1; Maximum = 128 } + @{ Name = 'MaxNodes'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxAliases'; Minimum = 0; Maximum = 2147483647 } + @{ Name = 'MaxScalarLength'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxTagLength'; Minimum = 1; Maximum = 1048576 } + @{ Name = 'MaxTotalTagLength'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxNumericLength'; Minimum = 1; Maximum = 1048576 } + ) { + $range = (Get-Command Remove-YamlEntry).Parameters[$Name].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + + $range.MinRange | Should -Be $Minimum + $range.MaxRange | Should -Be $Maximum + } + + It 'aggregates direct and pipeline string records with LF' { + $records = @('root:', ' keep: true', ' drop: false') + $expected = "root:`n keep: true" | Format-Yaml + + (Remove-YamlEntry -InputObject $records -Path '/root/drop') | + Should -BeExactly $expected + ($records | Remove-YamlEntry -Path '/root/drop') | + Should -BeExactly $expected + } + + It 'emits exactly one LF-only string without a final newline' { + $result = @( + Remove-YamlEntry -InputObject "keep: true`r`ndrop: false`r`n" -Path '/drop' + ) + + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType [string] + $result[0] | Should -Not -Match "`r" + $result[0].EndsWith("`n", [System.StringComparison]::Ordinal) | + Should -BeFalse + } + } + + Context 'JSON Pointer and mapping keys' { + It 'addresses empty, numeric-looking, escaped, Unicode, and case-sensitive keys' { + $unicodeKey = [char] 0x00C5 + $yaml = @" +"": empty +"01": numeric-looking +"a/b": slash +"a~b": tilde +"~1": escaped-twice +"$unicodeKey": unicode +Name: upper +name: lower +keep: true +"@ + $actual = Remove-YamlEntry $yaml @( + '/' + '/01' + '/a~1b' + '/a~0b' + '/~01' + "/$unicodeKey" + '/Name' + ) + + $actual | Should -BeExactly ("name: lower`nkeep: true" | Format-Yaml) + } + + It 'matches mapping key code points ordinally' { + $composed = [string] [char] 0x00E9 + $decomposed = 'e' + [char] 0x0301 + $yaml = '"' + $decomposed + '": value' + "`nkeep: true" + + Remove-YamlEntry $yaml ('/' + $composed) -IgnoreMissing | + Should -BeExactly ($yaml | Format-Yaml) + + $result = Remove-YamlEntry $yaml ('/' + $decomposed) | + ConvertFrom-Yaml -AsHashtable + $result.Contains($decomposed) | Should -BeFalse + $result['keep'] | Should -BeTrue + } + + It 'does not treat a linguistically equal unknown tag as a string tag' { + $yaml = "! key: tagged`nkeep: true" + + Remove-YamlEntry $yaml '/key' -IgnoreMissing | + Should -BeExactly ($yaml | Format-Yaml) + } + + It 'treats explicit string keys as strings and plain numeric keys as numbers' { + $yaml = @' +1: numeric +!!str 1: string +'@ + + (Remove-YamlEntry $yaml '/1') | + Should -BeExactly ('1: numeric' | Format-Yaml) + } + + It 'rejects pointers that do not start with slash' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'key: value' 'key' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalInvalidPointer' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlRemovalInvalidPointer,Remove-YamlEntry' + } + + It 'rejects every malformed tilde escape' -ForEach @( + @{ Pointer = '/key~' } + @{ Pointer = '/key~2' } + @{ Pointer = '/key~x' } + @{ Pointer = '/key~~0' } + ) { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'key: value' $Pointer -IgnoreMissing + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalInvalidPointerEscape' + } + + It 'does not guess among ambiguous matching string keys' { + $failure = Get-RemoveYamlFailure { + Invoke-RemoveYamlAmbiguityProbe + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalAmbiguousTarget' + } + + It 'preserves complex, non-string, and unknown-tagged keys as unaddressable' { + $yaml = @' +? [complex] +: sequence-key +2: numeric-key +!key tagged: tagged-key +keep: true +'@ + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/tagged' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + $failure.Exception.Message | Should -Match 'cannot be addressed' + (Remove-YamlEntry $yaml '/tagged' -IgnoreMissing) | + Should -BeExactly ($yaml | Format-Yaml) + } + } + + Context 'Sequence and nested traversal' { + It 'removes nested mapping and sequence entries' { + $yaml = @' +root: + items: + - keep: zero + drop: zero + - keep: one + drop: one +'@ + $expected = @' +root: + items: + - keep: zero + drop: zero + - keep: one +'@ | Format-Yaml + + (Remove-YamlEntry $yaml '/root/items/1/drop') | + Should -BeExactly $expected + } + + It 'accepts only canonical non-negative decimal sequence indexes' -ForEach @( + @{ Token = '-' } + @{ Token = '+1' } + @{ Token = '-1' } + @{ Token = '01' } + @{ Token = '1.0' } + @{ Token = '2147483648' } + ) { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry '[zero, one]' "/$Token" -IgnoreMissing + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalInvalidSequenceIndex' + } + + It 'treats out-of-range sequence indexes as missing' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry '[zero, one]' '/2' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + (Remove-YamlEntry '[zero, one]' '/2' -IgnoreMissing) | + Should -BeExactly ('[zero, one]' | Format-Yaml) + } + + It 'treats traversal through a scalar as missing' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'root: scalar' '/root/child' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + } + } + + Context 'Missing targets and transactions' { + It 'terminates before emitting changes when any path is missing' { + $output = @() + try { + $output = @( + Remove-YamlEntry 'first: 1' @('/first', '/missing') + ) + throw 'The YAML removal unexpectedly succeeded.' + } catch { + $failure = $_ + } + + $output.Count | Should -Be 0 + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + } + + It 'skips only unresolved targets with IgnoreMissing' { + $actual = Remove-YamlEntry 'first: 1' @('/missing', '/first') -IgnoreMissing + + $actual | Should -BeExactly ('{}' | Format-Yaml) + } + + It 'is idempotent when repeated with IgnoreMissing' { + $first = Remove-YamlEntry 'keep: true' '/drop' -IgnoreMissing + $second = Remove-YamlEntry $first '/drop' -IgnoreMissing + + $second | Should -BeExactly $first + } + } + + Context 'Document selection and root removal' { + It 'uses zero-based document index zero by default' { + $yaml = "---`ndrop: first`nkeep: one`n---`ndrop: second`nkeep: two" + $documents = @( + Remove-YamlEntry $yaml '/drop' | + ConvertFrom-Yaml -AsHashtable + ) + + $documents.Count | Should -Be 2 + $documents[0].Contains('drop') | Should -BeFalse + $documents[1]['drop'] | Should -Be 'second' + } + + It 'treats an explicitly false AllDocuments switch as default nested selection' { + $yaml = @' +--- +root: + drop: first + keep: one +--- +root: + drop: second + keep: two +'@ + $defaultResult = Remove-YamlEntry $yaml '/root/drop' + $falseResult = Remove-YamlEntry $yaml '/root/drop' -AllDocuments:$false + $allResult = Remove-YamlEntry $yaml '/root/drop' -AllDocuments + $defaultDocuments = @($defaultResult | ConvertFrom-Yaml -AsHashtable) + $allDocuments = @($allResult | ConvertFrom-Yaml -AsHashtable) + + $falseResult | Should -BeExactly $defaultResult + $defaultDocuments[0]['root'].Contains('drop') | Should -BeFalse + $defaultDocuments[1]['root']['drop'] | Should -Be 'second' + $allDocuments[0]['root'].Contains('drop') | Should -BeFalse + $allDocuments[1]['root'].Contains('drop') | Should -BeFalse + } + + It 'selects one explicit document index' { + $yaml = "---`ndrop: first`n---`ndrop: second" + $documents = @( + Remove-YamlEntry $yaml '/drop' -DocumentIndex 1 | + ConvertFrom-Yaml -AsHashtable + ) + + $documents[0]['drop'] | Should -Be 'first' + $documents[1].Contains('drop') | Should -BeFalse + } + + It 'rejects an unavailable document index even with IgnoreMissing' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'key: value' '/key' -DocumentIndex 1 -IgnoreMissing + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalDocumentIndexOutOfRange' + } + + It 'requires each path in each document unless IgnoreMissing is used' { + $yaml = "---`ndrop: first`n---`nkeep: second" + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/drop' -AllDocuments + } + $documents = @( + Remove-YamlEntry $yaml '/drop' -AllDocuments -IgnoreMissing | + ConvertFrom-Yaml -AsHashtable + ) + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + $documents[0].Contains('drop') | Should -BeFalse + $documents[1]['keep'] | Should -Be 'second' + } + + It 'removes one selected document with an empty pointer' { + $yaml = "---`nfirst: true`n---`nsecond: true`n---`nthird: true" + $documents = @( + Remove-YamlEntry $yaml '' -DocumentIndex 1 | + ConvertFrom-Yaml -AsHashtable + ) + + $documents.Count | Should -Be 2 + $documents[0]['first'] | Should -BeTrue + $documents[1]['third'] | Should -BeTrue + } + + It 'treats an explicitly false AllDocuments switch as default root selection' { + $yaml = "---`nfirst: true`n---`nsecond: true" + $defaultResult = Remove-YamlEntry $yaml '' + $falseResult = Remove-YamlEntry $yaml '' -AllDocuments:$false + $allResult = Remove-YamlEntry $yaml '' -AllDocuments + $defaultDocuments = @($defaultResult | ConvertFrom-Yaml -AsHashtable) + + $falseResult | Should -BeExactly $defaultResult + $defaultDocuments.Count | Should -Be 1 + $defaultDocuments[0]['second'] | Should -BeTrue + $allResult | Should -BeExactly '' + } + + It 'removes all documents with AllDocuments and an empty pointer' { + $result = @( + Remove-YamlEntry "---`nfirst: true`n---`nsecond: true" '' -AllDocuments + ) + + $result.Count | Should -Be 1 + $result[0] | Should -BeExactly '' + } + } + + Context 'Multiple-path ordering and coalescing' { + It 'coalesces duplicate targets' { + (Remove-YamlEntry 'keep: true' @('/keep', '/keep', '/keep')) | + Should -BeExactly ('{}' | Format-Yaml) + } + + It 'lets an ancestor removal subsume its descendant' { + $yaml = "root:`n child: value`nkeep: true" + + (Remove-YamlEntry $yaml @('/root/child', '/root')) | + Should -BeExactly ('keep: true' | Format-Yaml) + } + + It 'removes original sequence indexes in descending order' { + (Remove-YamlEntry '[zero, one, two, three, four]' @('/1', '/3')) | + Should -BeExactly ('[zero, two, four]' | Format-Yaml) + } + + It 'preserves mapping order among surviving entries' { + $yaml = "first: 1`nsecond: 2`nthird: 3`nfourth: 4" + $expected = "first: 1`nthird: 3" | Format-Yaml + + (Remove-YamlEntry $yaml @('/fourth', '/second')) | + Should -BeExactly $expected + } + + It 'coalesces one shared target reached through direct and alias paths' { + $yaml = @' +root: &shared + drop: true + keep: true +copy: *shared +'@ + $result = Remove-YamlEntry $yaml @('/root/drop', '/copy/drop') | + ConvertFrom-Yaml -AsHashtable + + $result['root'].Contains('drop') | Should -BeFalse + $result['copy'].Contains('drop') | Should -BeFalse + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + } + + It 'keeps an independently requested shared mutation when one alias ancestor is removed' { + $yaml = @' +root: &shared + drop: true + keep: true +copy: *shared +'@ + $result = Remove-YamlEntry $yaml @('/copy', '/root/drop') | + ConvertFrom-Yaml -AsHashtable + + $result.Contains('copy') | Should -BeFalse + $result['root'].Contains('drop') | Should -BeFalse + } + + It 'orders shared mapping removals by original index across pointer depths' { + $yaml = @' +a: &shared + x: 1 + y: 2 +b: + c: *shared +'@ + $result = Remove-YamlEntry $yaml @('/a/y', '/b/c/x') | + ConvertFrom-Yaml -AsHashtable + + $result['a'].Count | Should -Be 0 + [object]::ReferenceEquals($result['a'], $result['b']['c']) | + Should -BeTrue + } + + It 'orders shared sequence removals by original index across pointer depths' { + $yaml = "a: &shared [zero, one, two]`nb:`n c: *shared" + $result = Remove-YamlEntry $yaml @('/a/2', '/b/c/0') | + ConvertFrom-Yaml -AsHashtable + + @($result['a']) | Should -Be @('one') + [object]::ReferenceEquals($result['a'], $result['b']['c']) | + Should -BeTrue + } + } + + Context 'Aliases, sharing, and cycles' { + It 'mutates a shared mapping through an alias path' { + $yaml = @' +root: &shared + keep: true + drop: false +copy: *shared +'@ + $result = Remove-YamlEntry $yaml '/copy/drop' | + ConvertFrom-Yaml -AsHashtable + + $result['root'].Contains('drop') | Should -BeFalse + $result['copy'].Contains('drop') | Should -BeFalse + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + } + + It 'removes only an alias edge when that edge is the target' { + $yaml = @' +root: &shared + keep: true +copy: *shared +'@ + $result = Remove-YamlEntry $yaml '/copy' | + ConvertFrom-Yaml -AsHashtable + + $result.Contains('copy') | Should -BeFalse + $result['root']['keep'] | Should -BeTrue + } + + It 'traverses a recursive graph safely and preserves its identity' { + $yaml = @' +node: &node + self: *node + keep: true + drop: false +'@ + $result = Remove-YamlEntry $yaml '/node/self/self/drop' | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('drop') | Should -BeFalse + [object]::ReferenceEquals($result['node'], $result['node']['self']) | + Should -BeTrue + } + + It 'can remove the alias edge that closes a cycle' { + $yaml = @' +node: &node + self: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml '/node/self' | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + + It 'removes an edge that repeats in its own direct cyclic path' { + $yaml = @' +node: &node + self: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml '/node/self/self' | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + + It 'removes an edge that repeats through a longer cycle' { + $yaml = @' +first: &first + next: &second + back: *first + keep: true +'@ + $result = Remove-YamlEntry $yaml '/first/next/back/next/back' | + ConvertFrom-Yaml -AsHashtable + + $result['first']['next'].Contains('back') | Should -BeFalse + $result['first']['next']['keep'] | Should -BeTrue + } + + It 'does not mutually subsume targets reached through cyclic branches' { + $yaml = @' +node: &node + a: *node + b: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml @('/node/a/b', '/node/b/a') | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('a') | Should -BeFalse + $result['node'].Contains('b') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + + It 'coalesces one cyclic target reached through duplicate aliases' { + $yaml = @' +node: &node + self: *node + keep: true +copy: *node +'@ + $result = Remove-YamlEntry $yaml @('/node/self/self', '/copy/self/self') | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + [object]::ReferenceEquals($result['node'], $result['copy']) | + Should -BeTrue + } + + It 'subsumes a cyclic descendant only under a proper requested ancestor' { + $yaml = @' +node: &node + self: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml @('/node/self', '/node/self/self/keep') | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + } + + Context 'Tags and resulting graph validation' { + It 'preserves unaffected explicit and unknown tags' { + $yaml = @' +root: !item + keep: !!str value + drop: false +'@ + $actual = Remove-YamlEntry $yaml '/root/drop' + + $actual | Should -Match '!item' + $actual | Should -Match '!!str' + $actual | Test-Yaml | Should -BeTrue + } + + It 'rejects removals that invalidate a tagged collection shape' { + $yaml = '!!pairs [ { key: value } ]' + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/0/key' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlInvalidTaggedCollection' + } + + It 'rejects post-removal duplicate representation keys' -ForEach @( + @{ + Yaml = @' +cycle: &cycle { self: *cycle } +shared: &shared { x: 1, y: 2 } +? *shared +: first +? { x: 1 } +: second +'@ + } + @{ + Yaml = @' +shared: &shared { x: 1, y: 2 } +set: !!set + ? *shared + ? { x: 1 } +'@ + } + @{ + Yaml = @' +shared: &shared { x: 1, y: 2 } +ordered: !!omap + - ? *shared + : first + - ? { x: 1 } + : second +'@ + } + ) { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $Yaml '/shared/y' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlDuplicateKey' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlDuplicateKey,Remove-YamlEntry' + } + + It 'confirms fingerprint candidates with exact graph equality' -ForEach @( + @{ + Yaml = @' +? { x: 1 } +: first +? { x: 2 } +: second +'@ + } + @{ + Yaml = @' +!!str key: string +! key: tagged +'@ + } + ) { + Test-RemoveYamlFingerprintCollision -Yaml $Yaml | Should -BeTrue + } + } + + Context 'Validation and work limits' { + It 'preserves every parser resource classification' -ForEach @( + @{ Yaml = "a:`n b:`n c: value"; Parameters = @{ Depth = 2 } } + @{ Yaml = '[one, two]'; Parameters = @{ MaxNodes = 2 } } + @{ Yaml = "a: &a value`nb: *a"; Parameters = @{ MaxAliases = 0 } } + @{ Yaml = 'value: long'; Parameters = @{ MaxScalarLength = 4 } } + @{ Yaml = '!long value'; Parameters = @{ MaxTagLength = 2 } } + @{ + Yaml = "!a one`n---`n!b two" + Parameters = @{ MaxTotalTagLength = 3 } + } + @{ Yaml = '123'; Parameters = @{ MaxNumericLength = 2 } } + ) { + $parseFailure = Get-RemoveYamlFailure { + $Yaml | Format-Yaml @Parameters + } + $removeFailure = Get-RemoveYamlFailure { + Remove-YamlEntry $Yaml '' @Parameters + } + + $removeFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $removeFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Remove-YamlEntry" + } + + It 'bounds pointer parsing with the invocation work budget' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry '{}' '/abcdefghij' -IgnoreMissing -MaxNodes 5 + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalWorkLimitExceeded' + $failure.Exception.Data['YamlRemovalWorkLimit'] | Should -Be 5 + } + + It 'bounds duplicate-key graph equality with removal error classification' { + $pairs = 0..9 | ForEach-Object { " key$($_): $($_)" } + $shared = @('shared: &shared') + $pairs + ' drop: true' + $literal = ($pairs | ForEach-Object Trim) -join ', ' + $yaml = (@($shared) + '? *shared' + ': first' + + "? { $literal }" + ': second') -join "`n" + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/shared/drop' -MaxNodes 80 + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalWorkLimitExceeded' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlRemovalWorkLimitExceeded,Remove-YamlEntry' + $failure.Exception.Data['YamlRemovalWorkLimit'] | Should -Be 80 + $failure.Exception.Data['YamlRemovalWorkOperation'] | + Should -BeExactly 'duplicate-key graph comparison' + $failure.Exception.Message | + Should -Match 'duplicate-key graph comparison' + } + + It 'reports deterministic work and produces a result within budget' { + $measurement = Measure-RemoveYamlWork ` + -Yaml "root:`n first: 1`n second: 2`n third: 3" ` + -Path @('/root/first', '/root/third') ` + -Parameters @{ MaxNodes = 1000 } + + $measurement.Count | Should -BeGreaterThan 0 + $measurement.Count | Should -BeLessOrEqual 1000 + $measurement.Output | + Should -BeExactly ("root:`n second: 2" | Format-Yaml) + } + + It 'applies clone, removal, and output budgets independently' { + $items = 0..29 | ForEach-Object { "item$_" } + $yaml = '[' + ($items -join ', ') + ']' + + $result = Remove-YamlEntry $yaml '/0' -MaxNodes 50 | + ConvertFrom-Yaml + + @($result).Count | Should -Be 29 + $result[0] | Should -BeExactly 'item1' + } + } + + Context 'Deterministic representation output' { + It 'is deterministic, normalized, self-parsing, and leaves input semantics unchanged' { + $yaml = @' +root: &root + first: 1 + second: [two, three] +copy: *root +'@ + $before = $yaml | Format-Yaml -Indent 4 + $first = Remove-YamlEntry $yaml @('/root/first', '/copy/second/0') -Indent 4 + $second = Remove-YamlEntry $yaml @('/root/first', '/copy/second/0') -Indent 4 + + $second | Should -BeExactly $first + ($yaml | Format-Yaml -Indent 4) | Should -BeExactly $before + $first | Test-Yaml | Should -BeTrue + $first | Should -BeExactly ($first | Format-Yaml -Indent 4) + } + } +} diff --git a/tests/Specification.Tests.ps1 b/tests/Specification.Tests.ps1 new file mode 100644 index 0000000..c5257f9 --- /dev/null +++ b/tests/Specification.Tests.ps1 @@ -0,0 +1,224 @@ +#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 + , $result.canonical | Should -BeOfType [byte[]] + , $result.generic | Should -BeOfType [byte[]] + [System.Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($result.canonical) + ) | Should -Be '0DD8F84D24840A21A56495526E5B227911D13389109C62194A64B6CCBF3B1400' + } + + It 'projects the legacy ordered map in case J7PZ as an ordered dictionary' { + $result = @' +--- !!omap +- Mark McGwire: 65 +- Sammy Sosa: 63 +- Ken Griffy: 58 +'@ | ConvertFrom-Yaml + + $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + @($result.Keys) | Should -Be @('Mark McGwire', 'Sammy Sosa', 'Ken Griffy') + @($result.Values) | Should -Be @(65, 63, 58) + } +} diff --git a/tests/Test-Yaml.Tests.ps1 b/tests/Test-Yaml.Tests.ps1 new file mode 100644 index 0000000..2793a6e --- /dev/null +++ b/tests/Test-Yaml.Tests.ps1 @@ -0,0 +1,200 @@ +#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 'enforces block and flow-sequence implicit-key limits' { + $emoji = [char]::ConvertFromUtf32(0x1F600) + + ("['multi`n line': value]" | Test-Yaml) | Should -BeFalse + ("[multi`n line: value]" | Test-Yaml) | Should -BeFalse + + foreach ($case in @( + @{ Key = 'k' * 1024; Valid = $true } + @{ Key = 'k' * 1025; Valid = $false } + @{ Key = "'$('k' * 1022)'"; Valid = $true } + @{ Key = "'$('k' * 1023)'"; Valid = $false } + @{ Key = '"' + ('\u0061' * 170) + 'aa"'; Valid = $true } + @{ Key = '"' + ('\u0061' * 170) + 'aaa"'; Valid = $false } + @{ Key = '!local ' + ('k' * 1017); Valid = $true } + @{ Key = '!local ' + ('k' * 1018); Valid = $false } + )) { + ("[$($case.Key)`: value]" | Test-Yaml) | Should -Be $case.Valid + } + + foreach ($length in @(513, 1024, 1025)) { + $key = $emoji * $length + ("[$key`: value]" | Test-Yaml) | + Should -Be ($length -le 1024) + } + + ("['$('k' * 1021)' : value]" | Test-Yaml) | Should -BeTrue + ("['$('k' * 1022)' : value]" | Test-Yaml) | Should -BeFalse + ((('k' * 1023) + ' : value') | Test-Yaml) | Should -BeTrue + ((('k' * 1024) + ' : value') | Test-Yaml) | Should -BeFalse + } + + It 'resolves non-specific tags before comparing representation keys' { + ("! x: one`n!!str x: two" | Test-Yaml) | Should -BeFalse + ("? ! [x]`n: one`n? !!seq [x]`n: two" | Test-Yaml) | Should -BeFalse + ("! x: one`n! x: two" | Test-Yaml) | Should -BeTrue + ("! x: one`n! x: two" | Test-Yaml) | Should -BeFalse + } + + It 'accepts multiline keys in flow mappings' { + ("{'multi`n line': value}" | Test-Yaml) | Should -BeTrue + ("{multi`n line: value}" | Test-Yaml) | Should -BeTrue + } + + It 'does not apply implicit-key limits to ordinary flow mappings' { + $emoji = [char]::ConvertFromUtf32(0x1F600) + + (('{' + ('k' * 1025) + ': value}') | Test-Yaml) | Should -BeTrue + (('{' + ($emoji * 1025) + ': value}') | Test-Yaml) | Should -BeTrue + } + + It 'validates tag directives, tag tokens, and alias properties' { + ("%TAG !! tag:example.com,2000:app/`n---`n!!int value" | Test-Yaml) | + Should -BeTrue + ("%TAG !e! tag:example.com,2000:app/#fragment`n---`n!e!foo value" | + Test-Yaml) | Should -BeTrue + ("%FOO-BAR baz`n---`nvalue" | Test-Yaml) | Should -BeTrue + ("%FOO:B alpha-beta p#q`n---`nvalue" | Test-Yaml) | Should -BeTrue + ("%TAG ! tag:first/`n%TAG ! tag:second/`n---`n!value data" | Test-Yaml) | + Should -BeFalse + ("% FOO baz`n---`nvalue" | Test-Yaml) | Should -BeFalse + ("%`n---`nvalue" | Test-Yaml) | Should -BeFalse + ('!foo%GG value' | Test-Yaml) | Should -BeFalse + ('!foo\bar value' | Test-Yaml) | Should -BeFalse + ('! value' | Test-Yaml) | Should -BeFalse + ('!! value' | Test-Yaml) | Should -BeFalse + ('! value' | Test-Yaml) | Should -BeFalse + ('! value' | + Test-Yaml) | Should -BeTrue + ("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 'rejects tabs that indent compact collections after mapping indicators' { + ("?`t-" | Test-Yaml) | Should -BeFalse + ("? -`n:`t-" | Test-Yaml) | Should -BeFalse + ("?`tkey:" | Test-Yaml) | Should -BeFalse + ("? key:`n:`tkey:" | Test-Yaml) | Should -BeFalse + } + + It 'returns false for duplicate mapping keys' { + ("key: one`nkey: two" | Test-Yaml) | Should -BeFalse + ("1: one`n01: two" | Test-Yaml) | Should -BeFalse + } + + It 'includes effective tags in mapping-key equality' { + ("!foo x: one`n!bar x: two" | Test-Yaml) | Should -BeTrue + ("!foo x: one`n!foo x: two" | Test-Yaml) | Should -BeFalse + (@' +? !foo [x] +: one +? !bar [x] +: two +'@ | Test-Yaml) | Should -BeTrue + (@' +? !foo {x: y} +: one +? !bar {x: y} +: two +'@ | Test-Yaml) | Should -BeTrue + } + + 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' { + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + Mock Read-YamlStream { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } else { + Mock Read-YamlStream -ModuleName $loadedModule.Name { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } + + { 'name: Ada' | Test-Yaml } | + Should -Throw -ExpectedMessage '*unexpected runtime failure*' + } +} diff --git a/tests/TestBootstrap.ps1 b/tests/TestBootstrap.ps1 new file mode 100644 index 0000000..a6e671d --- /dev/null +++ b/tests/TestBootstrap.ps1 @@ -0,0 +1,96 @@ +$artifactManifestOverride = $env:PSMODULE_YAML_TEST_ARTIFACT +$yamlModule = $null +if (-not [string]::IsNullOrWhiteSpace($artifactManifestOverride)) { + $resolvedArtifactManifestPath = ( + Resolve-Path -LiteralPath $artifactManifestOverride -ErrorAction Stop + ).Path + $artifactModuleBase = Split-Path -Parent $resolvedArtifactManifestPath + $yamlModule = Get-Module -Name Yaml | + Where-Object ModuleBase -EQ $artifactModuleBase | + Select-Object -First 1 + if ($null -eq $yamlModule) { + $yamlModule = Import-Module -Name $resolvedArtifactManifestPath -Force -Global -PassThru ` + -ErrorAction Stop | + Where-Object Name -EQ 'Yaml' | + Select-Object -First 1 + } +} + +if ($null -eq $yamlModule) { + $yamlModule = Get-Module -Name 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) +} + +function Get-TestYamlRepresentationRoot { + <# + .SYNOPSIS + Returns observable metadata from the root representation node. + #> + 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] + [pscustomobject]@{ + Kind = $document.Kind + Tag = $document.Tag + HasUnknownTag = $document.HasUnknownTag + Value = $document.Value + } + } + + $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..62c2e50 --- /dev/null +++ b/tests/fixtures/yaml-test-suite/SOURCES.txt @@ -0,0 +1,41 @@ +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 +Latest source release: + v2022-01-17 (verified 2026-07-23) +Source release commit: + 45db50aecf9b1520f8258938c88f396e96f30831 +Data release: + data-2022-01-17 +Data release commit: + 6e6c296ae9c9d2d5c4134b4b64d01b29ac19ff6f +Archive source: + https://github.com/yaml/yaml-test-suite/archive/refs/tags/data-2022-01-17.zip +Archive SHA-256: + 47C173AFFEB480517B30FB77DC8C76FD48609B9B65DD1C1D3D0D0BAEE48D6AA9 + +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..d384622 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-YamlPerfReview.ps1 b/tests/tools/Invoke-YamlPerfReview.ps1 new file mode 100644 index 0000000..66f560a --- /dev/null +++ b/tests/tools/Invoke-YamlPerfReview.ps1 @@ -0,0 +1,200 @@ +[CmdletBinding()] +param ( + [Parameter()] + [ValidateSet('Baseline', 'Regression')] + [string] $Mode = 'Baseline', + + [Parameter()] + [ValidateRange(1, 200)] + [int] $Runs = 12, + + [Parameter()] + [ValidateRange(0, 50)] + [int] $Preheat = 2, + + [Parameter()] + [string] $OutputPath = (Join-Path $PSScriptRoot "..\fixtures\perf\$Mode.json") +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion -lt [version] '7.6' -or $PSVersionTable.PSEdition -cne 'Core') { + throw 'Invoke-YamlPerfReview.ps1 requires PowerShell 7.6+ (Core).' +} + +Import-Module -Name Profiler -ErrorAction Stop +. (Join-Path $PSScriptRoot '..\TestBootstrap.ps1') + +function Get-Percentile { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [double[]] $Values, + + [Parameter(Mandatory)] + [ValidateRange(0.0, 1.0)] + [double] $Percentile + ) + + if ($Values.Count -eq 0) { + return 0.0 + } + + $sorted = @($Values | Sort-Object) + $index = [Math]::Ceiling($Percentile * $sorted.Count) - 1 + $index = [Math]::Max(0, [Math]::Min($index, $sorted.Count - 1)) + return [double] $sorted[$index] +} + +function Measure-Scenario { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + [scriptblock] $Script, + + [Parameter(Mandatory)] + [int] $RunCount, + + [Parameter(Mandatory)] + [int] $WarmupCount + ) + + for ($i = 0; $i -lt $WarmupCount; $i++) { + & $Script > $null + } + + $elapsedMs = [System.Collections.Generic.List[double]]::new() + for ($i = 0; $i -lt $RunCount; $i++) { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + & $Script > $null + $stopwatch.Stop() + $elapsedMs.Add($stopwatch.Elapsed.TotalMilliseconds) + } + + $trace = Trace-Script -ScriptBlock $Script -Preheat 1 -DisableWarning + $topFunctions = @( + $trace.Top50FunctionSelfDuration | + Select-Object -First 5 Function, Module, Line, HitCount, @{ + Name = 'SelfDurationMs' + Expression = { [Math]::Round($_.SelfDuration.TotalMilliseconds, 3) } + }, @{ + Name = 'DurationMs' + Expression = { [Math]::Round($_.Duration.TotalMilliseconds, 3) } + } + ) + + $values = [double[]] $elapsedMs.ToArray() + [pscustomobject]@{ + Name = $Name + Runs = $RunCount + Preheat = $WarmupCount + AverageMs = [Math]::Round(($values | Measure-Object -Average).Average, 3) + MedianMs = [Math]::Round((Get-Percentile -Values $values -Percentile 0.50), 3) + P95Ms = [Math]::Round((Get-Percentile -Values $values -Percentile 0.95), 3) + MinMs = [Math]::Round(($values | Measure-Object -Minimum).Minimum, 3) + MaxMs = [Math]::Round(($values | Measure-Object -Maximum).Maximum, 3) + SamplesMs = $values + TraceTopSelf = $topFunctions + } +} + +Write-Host "Preparing performance fixtures for mode: $Mode" + +$smallYaml = @' +name: small +enabled: true +ports: [80, 443] +'@ + +$mediumYamlBuilder = [System.Text.StringBuilder]::new() +[void] $mediumYamlBuilder.AppendLine('items:') +for ($index = 0; $index -lt 1200; $index++) { + [void] $mediumYamlBuilder.AppendLine(" - id: $index") + [void] $mediumYamlBuilder.AppendLine(" name: item-$index") + [void] $mediumYamlBuilder.AppendLine(" enabled: true") + [void] $mediumYamlBuilder.AppendLine(" value: $($index * 3)") +} +$mediumYaml = $mediumYamlBuilder.ToString().TrimEnd("`r", "`n") + +$overlayYamlBuilder = [System.Text.StringBuilder]::new() +[void] $overlayYamlBuilder.AppendLine('items:') +for ($index = 0; $index -lt 1200; $index++) { + [void] $overlayYamlBuilder.AppendLine(" - id: $index") + [void] $overlayYamlBuilder.AppendLine(" name: item-$index-override") + [void] $overlayYamlBuilder.AppendLine(" enabled: false") +} +$overlayYaml = $overlayYamlBuilder.ToString().TrimEnd("`r", "`n") + +$mediumObject = ConvertFrom-Yaml -Yaml $mediumYaml -NoEnumerate +$tempPath = Join-Path ([System.IO.Path]::GetTempPath()) 'yaml-perf-review.yaml' + +$scenarios = @( + @{ + Name = 'ConvertFrom-Yaml/small' + Script = { ConvertFrom-Yaml -Yaml $smallYaml | Out-Null } + }, + @{ + Name = 'ConvertFrom-Yaml/medium' + Script = { ConvertFrom-Yaml -Yaml $mediumYaml -NoEnumerate | Out-Null } + }, + @{ + Name = 'ConvertTo-Yaml/medium' + Script = { ConvertTo-Yaml -InputObject $mediumObject | Out-Null } + }, + @{ + Name = 'Format-Yaml/medium' + Script = { Format-Yaml -InputObject $mediumYaml -Indent 2 | Out-Null } + }, + @{ + Name = 'Merge-Yaml/medium' + Script = { Merge-Yaml -InputObject @($mediumYaml, $overlayYaml) -SequenceAction Replace | Out-Null } + }, + @{ + Name = 'Remove-YamlEntry/medium' + Script = { Remove-YamlEntry -InputObject $mediumYaml -Path '/items/12/name' -IgnoreMissing | Out-Null } + }, + @{ + Name = 'Test-Yaml/medium' + Script = { Test-Yaml -Yaml $mediumYaml | Out-Null } + }, + @{ + Name = 'Export-Import-Yaml/medium' + Script = { + Export-Yaml -InputObject $mediumObject -Path $tempPath -Force | Out-Null + Import-Yaml -LiteralPath $tempPath -NoEnumerate | Out-Null + } + } +) + +$results = [System.Collections.Generic.List[object]]::new() +foreach ($scenario in $scenarios) { + Write-Host "Measuring $($scenario.Name)" + $results.Add((Measure-Scenario -Name $scenario.Name -Script $scenario.Script -RunCount $Runs -WarmupCount $Preheat)) +} + +if (Test-Path -LiteralPath $tempPath) { + Remove-Item -LiteralPath $tempPath -Force +} + +$outputDirectory = Split-Path -Parent $OutputPath +if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path -LiteralPath $outputDirectory)) { + $null = New-Item -Path $outputDirectory -ItemType Directory -Force +} + +$report = [pscustomobject]@{ + Mode = $Mode + TimestampUtc = [DateTime]::UtcNow.ToString('o') + PowerShell = $PSVersionTable.PSVersion.ToString() + Edition = $PSVersionTable.PSEdition + Runs = $Runs + Preheat = $Preheat + RegressionFail = 'Greater than 5 percent slowdown on critical-path scenarios.' + Scenarios = $results +} + +$report | ConvertTo-Json -Depth 8 | Set-Content -Path $OutputPath -Encoding utf8NoBOM +Write-Host "Performance report written to: $OutputPath" diff --git a/tests/tools/Invoke-YamlTestSuite.ps1 b/tests/tools/Invoke-YamlTestSuite.ps1 new file mode 100644 index 0000000..05c3a0e --- /dev/null +++ b/tests/tools/Invoke-YamlTestSuite.ps1 @@ -0,0 +1,1379 @@ +[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] $CompareEmitYaml, + [switch] $CompareSelfRoundTrip, + [switch] $CompareFormatter +) + +. (Join-Path $PSScriptRoot '..\TestBootstrap.ps1') + +if (-not $PSBoundParameters.ContainsKey('CompareJson') -and + -not $PSBoundParameters.ContainsKey('CompareEvents') -and + -not $PSBoundParameters.ContainsKey('CompareOutYaml') -and + -not $PSBoundParameters.ContainsKey('CompareEmitYaml') -and + -not $PSBoundParameters.ContainsKey('CompareSelfRoundTrip') -and + -not $PSBoundParameters.ContainsKey('CompareFormatter')) { + $CompareJson = $true + $CompareEvents = $true + $CompareOutYaml = $true + $CompareEmitYaml = $true + $CompareSelfRoundTrip = $true + $CompareFormatter = $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, + + [switch] $SortMappings, + + [AllowNull()] + [System.Collections.Generic.HashSet[object]] $OrderedMappings + ) + + 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() + } + if ($Value -is [byte[]]) { + return 'binary:{0}:{1}' -f $Value.Length, [System.Convert]::ToBase64String($Value) + } + if ($Value -is [char]) { + return 'char:{0}' -f [int] $Value + } + if ($Value.GetType().IsEnum) { + return 'enum:{0}:{1}' -f $Value.GetType().FullName, ( + [System.Convert]::ToUInt64($Value, [cultureinfo]::InvariantCulture) + ) + } + if ($Value -is [datetimeoffset]) { + return 'timestamp:{0}:{1}' -f $Value.UtcTicks, $Value.Offset.Ticks + } + if ($Value -is [datetime]) { + return 'datetime:{0}:{1}' -f $Value.Ticks, [int] $Value.Kind + } + if ($Value -is [timespan]) { + return 'timespan:{0}' -f $Value.Ticks + } + if ($Value -is [guid]) { + return 'guid:{0}' -f $Value.ToString('D') + } + if ($Value -is [uri]) { + return 'uri:{0}:{1}' -f $Value.OriginalString.Length, $Value.OriginalString + } + + $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 ($entry in $Value.GetEnumerator()) { + $canonicalKey = ConvertTo-YamlSuiteCanonicalValue -Value $entry.Key ` + -SortMappings:$SortMappings -OrderedMappings $OrderedMappings + $canonicalValue = ConvertTo-YamlSuiteCanonicalValue -Value $entry.Value ` + -SortMappings:$SortMappings -OrderedMappings $OrderedMappings + $entries.Add(('{0}:{1}={2}:{3}' -f + $canonicalKey.Length, + $canonicalKey, + $canonicalValue.Length, + $canonicalValue + )) + } + $isOrdered = $null -ne $OrderedMappings -and $OrderedMappings.Contains($Value) + if ($SortMappings -or -not $isOrdered) { + $entries.Sort([System.StringComparer]::Ordinal) + } + $mappingKind = if ($isOrdered) { 'omap' } else { 'map' } + return '{0}:{1}:{{{2}}}' -f $mappingKind, $entries.Count, ($entries -join '|') + } + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $items = [System.Collections.Generic.List[string]]::new() + foreach ($item in $Value) { + $items.Add(( + ConvertTo-YamlSuiteCanonicalValue -Value $item -SortMappings:$SortMappings ` + -OrderedMappings $OrderedMappings + )) + } + return 'sequence:{0}:[{1}]' -f $items.Count, ($items -join '|') + } + + $serialized = [System.Management.Automation.PSSerializer]::Serialize($Value, 3) + $payload = [System.Convert]::ToBase64String( + [System.Text.Encoding]::UTF8.GetBytes($serialized) + ) + if (-not $Value.GetType().IsValueType) { + return 'unsupported-reference:{0}:{1}:{2}' -f + $Value.GetType().FullName, + [System.Runtime.CompilerServices.RuntimeHelpers]::GetHashCode($Value), + $payload + } + return 'unsupported-value:{0}:{1}' -f $Value.GetType().FullName, $payload +} + +function ConvertTo-YamlSuiteReferenceSignature { + [OutputType([string])] + param ( + [AllowNull()] + [object] $Value, + + [AllowNull()] + [System.Collections.Generic.HashSet[object]] $OrderedMappings + ) + + $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]) { + 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 [byte[]]) { + continue + } + + if ($current -is [System.Collections.IDictionary]) { + foreach ($entry in $current.GetEnumerator()) { + $childPath = '{0}{{{1}}}' -f $frame.Path, ( + ConvertTo-YamlSuiteCanonicalValue -Value $entry.Key ` + -OrderedMappings $OrderedMappings + ) + $stack.Push([pscustomobject]@{ + Value = $entry.Value + Path = "$childPath.value" + }) + $stack.Push([pscustomobject]@{ + Value = $entry.Key + Path = "$childPath.key" + }) + } + 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-YamlSuiteDictionaryReferenceSet { + [OutputType([System.Collections.Generic.HashSet[object]])] + param ( + [AllowNull()] + [object] $Value + ) + + $comparer = [System.Collections.Generic.ReferenceEqualityComparer]::Instance + $dictionaries = [System.Collections.Generic.HashSet[object]]::new($comparer) + $visited = [System.Collections.Generic.HashSet[object]]::new($comparer) + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push($Value) + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if ($null -eq $current -or $current -is [string] -or + $current.GetType().IsValueType) { + continue + } + if ($current -isnot [System.Collections.IDictionary] -and + $current -isnot [System.Collections.IEnumerable]) { + continue + } + if (-not $visited.Add($current)) { + continue + } + if ($current -is [System.Collections.IDictionary]) { + [void] $dictionaries.Add($current) + foreach ($entry in $current.GetEnumerator()) { + $pending.Push($entry.Key) + $pending.Push($entry.Value) + } + continue + } + if ($current -is [byte[]]) { + continue + } + foreach ($item in $current) { + $pending.Push($item) + } + } + Write-Output -InputObject $dictionaries -NoEnumerate +} + +function Test-YamlSuiteBinaryByteArrayProjection { + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [object[]] $ExpectedValues, + + [Parameter(Mandatory)] + [object[]] $ActualValues + ) + + if ($ExpectedValues.Count -ne 1 -or $ActualValues.Count -ne 1) { + return $false + } + $expectedDocument = $ExpectedValues[0] + $actualDocument = $ActualValues[0] + if ($expectedDocument -isnot [System.Collections.IDictionary] -or + $actualDocument -isnot [System.Collections.IDictionary] -or + $expectedDocument.Count -ne 3 -or $actualDocument.Count -ne 3) { + return $false + } + + foreach ($key in @('canonical', 'generic', 'description')) { + if (-not $expectedDocument.Contains($key) -or -not $actualDocument.Contains($key)) { + return $false + } + } + if ($expectedDocument['description'] -isnot [string] -or + $actualDocument['description'] -isnot [string] -or + $actualDocument['description'] -cne $expectedDocument['description']) { + return $false + } + + foreach ($key in @('canonical', 'generic')) { + if ($expectedDocument[$key] -isnot [string] -or + $actualDocument[$key] -isnot [byte[]]) { + return $false + } + $expectedBase64 = $expectedDocument[$key] -replace '\s', '' + $expectedBytes = [System.Convert]::FromBase64String($expectedBase64) + if (-not [System.Linq.Enumerable]::SequenceEqual[byte]( + $expectedBytes, + [byte[]] $actualDocument[$key] + )) { + return $false + } + } + return $true +} + +function Test-YamlSuiteLegacyOrderedMapProjection { + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [object[]] $ExpectedValues, + + [Parameter(Mandatory)] + [object[]] $ActualValues + ) + + if ($ExpectedValues.Count -ne 1 -or $ActualValues.Count -ne 1 -or + $ExpectedValues[0] -is [System.Collections.IDictionary] -or + $ExpectedValues[0] -isnot [System.Collections.IEnumerable] -or + $ActualValues[0] -isnot [System.Collections.Specialized.OrderedDictionary]) { + return $false + } + + $expectedEntries = @($ExpectedValues[0]) + $actualDocument = $ActualValues[0] + $actualKeys = @($actualDocument.Keys) + if ($expectedEntries.Count -ne $actualDocument.Count) { + return $false + } + for ($index = 0; $index -lt $expectedEntries.Count; $index++) { + $expectedEntry = $expectedEntries[$index] + if ($expectedEntry -isnot [System.Collections.IDictionary] -or + $expectedEntry.Count -ne 1) { + return $false + } + $expectedKey = @($expectedEntry.Keys)[0] + if ($expectedKey -isnot [string] -or $actualKeys[$index] -cne $expectedKey -or + (ConvertTo-YamlSuiteCanonicalValue -Value $actualDocument[$expectedKey]) -cne + (ConvertTo-YamlSuiteCanonicalValue -Value $expectedEntry[$expectedKey])) { + return $false + } + } + return $true +} + +function Get-YamlSuiteJsonPolicyReason { + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [object[]] $ExpectedValues, + + [Parameter(Mandatory)] + [object[]] $ActualValues + ) + + if (Test-YamlSuiteBinaryByteArrayProjection -ExpectedValues $ExpectedValues ` + -ActualValues $ActualValues) { + return 'BinaryByteArrayProjection' + } + if (Test-YamlSuiteLegacyOrderedMapProjection -ExpectedValues $ExpectedValues ` + -ActualValues $ActualValues) { + return 'LegacyOrderedMapProjection' + } + return '' + 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) { + $anchorCounter++ + $anchorMap[$anchor] = 'a{0:d3}' -f $anchorCounter + $anchorToken = $anchorMap[$anchor] + } + $parts = [System.Collections.Generic.List[string]]::new() + $parts.Add($prefix) + if ($tag -eq '!') { + $parts.Add('nonSpecificTag=true') + } elseif ($tag) { + $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 Get-YamlSuiteScalarEffectiveTag { + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + Invoke-InYamlModule -ScriptBlock { + param ([pscustomobject] $ScalarNode) + + $resolved = Resolve-YamlScalar -Node $ScalarNode + Get-YamlEffectiveTag -Node $ScalarNode -Value $resolved.Value + } -Arguments @($Node) +} + +function ConvertTo-YamlSuiteActualEvent { + [OutputType([string[]])] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents, + + [switch] $IncludeEffectiveScalarTags + ) + + 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 = 'id:{0}' -f $node.Target.Id + $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 ($IncludeEffectiveScalarTags) { + $parts.Add("tag=$(Get-YamlSuiteScalarEffectiveTag -Node $node)") + if (-not [string]::IsNullOrEmpty($node.Tag)) { + $parts.Add('explicitTag=true') + } elseif ($node.HasUnknownTag) { + $parts.Add('nonSpecificTag=true') + } + } else { + if ([string]::IsNullOrEmpty($node.Tag) -and $node.HasUnknownTag) { + $parts.Add('nonSpecificTag=true') + } elseif ($node.Tag) { + $parts.Add("tag=$($node.Tag)") + } + } + if ($node.Anchor) { + $parts.Add("anchor=$( + Get-YamlSuiteAnchorToken -Key ('id:{0}' -f $node.Id) -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 ([string]::IsNullOrEmpty($node.Tag) -and $node.HasUnknownTag) { + $startParts.Add('nonSpecificTag=true') + } elseif ($node.Tag) { + $startParts.Add("tag=$($node.Tag)") + } + if ($node.Anchor) { + $startParts.Add("anchor=$( + Get-YamlSuiteAnchorToken -Key ('id:{0}' -f $node.Id) -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() + $orderedMappings = [System.Collections.Generic.HashSet[object]]::new( + [System.Collections.Generic.ReferenceEqualityComparer]::Instance + ) + foreach ($node in $Nodes) { + $cache = [System.Collections.Generic.Dictionary[int, object]]::new() + $values.Add((ConvertFrom-YamlNode -Node $node -Cache $cache -AsHashtable).Value) + $visited = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push($node) + while ($pending.Count -gt 0) { + $current = $pending.Pop() + while ($current.Kind -eq 'Alias') { + $current = $current.Target + } + if (-not $visited.Add($current.Id)) { + continue + } + if ($current.Tag -ceq 'tag:yaml.org,2002:omap' -and + $cache.ContainsKey($current.Id)) { + [void] $orderedMappings.Add($cache[$current.Id]) + } + if ($current.Kind -eq 'Sequence') { + foreach ($item in $current.Items) { + $pending.Push($item) + } + } elseif ($current.Kind -eq 'Mapping') { + foreach ($entry in $current.Entries) { + $pending.Push($entry.Key) + $pending.Push($entry.Value) + } + } + } + } + New-YamlValueBox -Value ([pscustomobject]@{ + Values = [object[]] $values.ToArray() + OrderedMappings = $orderedMappings + }) +} +$testYamlSuiteText = { + param ([string] $YamlText) + Test-Yaml -Yaml $YamlText -Depth 128 -MaxNodes 100000 -MaxAliases 1000 ` + -MaxScalarLength 1048576 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` + -MaxNumericLength 4096 +} +$formatYamlSuiteText = { + param ([string] $YamlText) + Format-Yaml -InputObject $YamlText -Depth 128 -MaxNodes 100000 -MaxAliases 1000 ` + -MaxScalarLength 1048576 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` + -MaxNumericLength 4096 +} + +$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 = '' + $emitYamlResult = 'NotApplicable' + $emitYamlReason = '' + $selfRoundTripResult = 'NotApplicable' + $selfRoundTripReason = '' + $formatterResult = 'NotApplicable' + $formatterReason = '' + + $representation = $null + $stream = $null + $projectedValues = $null + $projectedOrderedMappings = $null + $projectedCanonical = $null + $projectedJsonCanonical = $null + $projectedReference = '' + $projectionError = '' + $streamError = '' + $jsonOracleValues = $null + $jsonOracleCanonical = $null + $eventExpected = $null + $eventActual = $null + $jsonExpected = $null + $jsonActual = $null + $outYamlCanonical = $null + $outYamlReference = $null + $emitYamlExpected = $null + $emitYamlActual = $null + $emitYamlExpectedReference = $null + $emitYamlActualReference = $null + $selfRoundTripCanonical = $null + $selfRoundTripReference = $null + $formatterExpected = $null + $formatterActual = $null + $formatterText = $null + $formatterSecond = $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 ($null -ne $representation -and + $_.Exception.Data['YamlErrorId'] -eq 'YamlDuplicateKey') { + $syntaxResult = 'PolicyDifference' + $syntaxReason = 'RepresentationMappingKeyUniqueness' + } else { + $syntaxResult = 'Fail' + $syntaxReason = [string] $_.Exception.Data['YamlErrorId'] + } + $streamError = [string] $_.Exception.Data['YamlErrorId'] + } + } + + if ($null -ne $stream) { + try { + $projected = ( + Invoke-InYamlModule -ScriptBlock $projectYamlSuiteStream -Arguments (, $stream.Value) + ).Value + $projectedValues = $projected.Values + $projectedOrderedMappings = $projected.OrderedMappings + $projectedCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $projectedValues) ` + -OrderedMappings $projectedOrderedMappings + $projectedJsonCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $projectedValues) -SortMappings ` + -OrderedMappings $projectedOrderedMappings + $projectedReference = ConvertTo-YamlSuiteReferenceSignature ` + -Value ([object[]] $projectedValues) ` + -OrderedMappings $projectedOrderedMappings + } catch { + if ($_.Exception.Data.Contains('YamlErrorId')) { + $projectionError = [string] $_.Exception.Data['YamlErrorId'] + } else { + $projectionError = $_.Exception.GetType().Name + } + } + } + + if ($hasJson -and ($CompareJson -or ($CompareEmitYaml -and $hasEmitYaml))) { + $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)) + } + $jsonOracleValues = [object[]] $expectedValues.ToArray() + $jsonOracleCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value $jsonOracleValues -SortMappings + } + + 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 { + $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 { + $jsonExpected = $jsonOracleCanonical + $jsonActual = $projectedJsonCanonical + if ($projectedJsonCanonical -ceq $jsonOracleCanonical) { + $jsonResult = 'Pass' + } else { + $reason = Get-YamlSuiteJsonPolicyReason ` + -ExpectedValues $jsonOracleValues ` + -ActualValues ([object[]] $projectedValues) + if ($reason) { + $jsonResult = 'PolicyDifference' + $jsonReason = $reason + } else { + $jsonResult = 'Fail' + $jsonReason = 'ConstructedValueMismatch' + } + } + } + } + + if ($CompareOutYaml -and $hasOutYaml) { + if ($syntaxResult -eq 'PolicyDifference') { + $outYamlResult = 'PolicyDifference' + $outYamlReason = $syntaxReason + } elseif ($null -eq $stream -or $expectsError -or + $null -eq $projectedValues) { + $outYamlResult = 'NotApplicable' + 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) + $outProjection = ( + Invoke-InYamlModule -ScriptBlock $projectYamlSuiteStream ` + -Arguments (, $outStream.Value) + ).Value + $outValues = $outProjection.Values + $outCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $outValues) ` + -OrderedMappings $outProjection.OrderedMappings + $outReference = ConvertTo-YamlSuiteReferenceSignature ` + -Value ([object[]] $outValues) ` + -OrderedMappings $outProjection.OrderedMappings + $outYamlCanonical = $outCanonical + $outYamlReference = $outReference + if ($outCanonical -cne $projectedCanonical) { + $outYamlResult = 'Fail' + $outYamlReason = 'OutYamlConstructionMismatch' + } elseif ($outReference -cne $projectedReference) { + $outYamlResult = 'Fail' + $outYamlReason = 'OutYamlReferenceMismatch' + } else { + $outYamlResult = 'Pass' + } + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $outYamlResult = 'Fail' + $outYamlReason = [string] $_.Exception.Data['YamlErrorId'] + } else { + throw + } + } + } + } + + if ($CompareEmitYaml -and $hasEmitYaml) { + try { + $emitYaml = [System.IO.File]::ReadAllText( + $emitYamlPath, + [System.Text.UTF8Encoding]::new($false, $true) + ) + $isValidFixture = Invoke-InYamlModule -ScriptBlock $testYamlSuiteText ` + -Arguments @($emitYaml) + if (-not $isValidFixture) { + $emitYamlResult = 'Fail' + $emitYamlReason = 'EmitYamlInvalid' + } else { + $fixtureStream = Invoke-InYamlModule -ScriptBlock $readYamlSuiteStream ` + -Arguments @($emitYaml) + $fixtureProjection = ( + Invoke-InYamlModule -ScriptBlock $projectYamlSuiteStream ` + -Arguments (, $fixtureStream.Value) + ).Value + $fixtureValues = $fixtureProjection.Values + $fixtureCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $fixtureValues) ` + -OrderedMappings $fixtureProjection.OrderedMappings + $fixtureReference = ConvertTo-YamlSuiteReferenceSignature ` + -Value ([object[]] $fixtureValues) ` + -OrderedMappings $fixtureProjection.OrderedMappings + + $fixtureOracleCanonical = $null + $fixtureOracleActual = $fixtureCanonical + $fixtureReferenceMismatch = $false + if ($null -ne $projectedCanonical) { + $fixtureOracleCanonical = $projectedCanonical + $fixtureReferenceMismatch = $fixtureReference -cne $projectedReference + } elseif ($null -ne $jsonOracleCanonical) { + $fixtureOracleCanonical = $jsonOracleCanonical + $fixtureOracleActual = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $fixtureValues) -SortMappings ` + -OrderedMappings $fixtureProjection.OrderedMappings + } + + $emitYamlExpected = $fixtureOracleCanonical + $emitYamlActual = $fixtureOracleActual + if ($null -ne $projectedCanonical) { + $emitYamlExpectedReference = $projectedReference + } + $emitYamlActualReference = $fixtureReference + if ($null -eq $fixtureOracleCanonical) { + $emitYamlResult = 'Fail' + $emitYamlReason = 'EmitYamlOracleUnavailable' + } elseif ($fixtureOracleActual -cne $fixtureOracleCanonical -or + $fixtureReferenceMismatch) { + $emitYamlResult = 'Fail' + $emitYamlReason = 'EmitYamlRepresentationMismatch' + } else { + $emitYamlResult = 'Pass' + } + } + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $emitYamlResult = 'Fail' + $emitYamlReason = [string] $_.Exception.Data['YamlErrorId'] + } else { + throw + } + } + } + + if ($CompareSelfRoundTrip) { + if ($syntaxResult -eq 'PolicyDifference') { + $selfRoundTripResult = 'PolicyDifference' + $selfRoundTripReason = $syntaxReason + } elseif ($null -eq $stream -or $expectsError) { + $selfRoundTripResult = 'NotApplicable' + if ($expectsError) { $selfRoundTripReason = 'InvalidSyntax' } + } elseif ($projectionError) { + $selfRoundTripResult = 'Fail' + $selfRoundTripReason = $projectionError + } 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 $testYamlSuiteText ` + -Arguments @($emittedText) + if (-not $isValidEmit) { + $selfRoundTripResult = 'Fail' + $selfRoundTripReason = 'EmittedYamlInvalid' + } else { + $roundTripStream = Invoke-InYamlModule -ScriptBlock $readYamlSuiteStream ` + -Arguments @($emittedText) + $roundTripProjection = ( + Invoke-InYamlModule -ScriptBlock $projectYamlSuiteStream ` + -Arguments (, $roundTripStream.Value) + ).Value + $roundTripValues = $roundTripProjection.Values + $roundCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $roundTripValues) ` + -OrderedMappings $roundTripProjection.OrderedMappings + $roundReference = ConvertTo-YamlSuiteReferenceSignature ` + -Value ([object[]] $roundTripValues) ` + -OrderedMappings $roundTripProjection.OrderedMappings + $selfRoundTripCanonical = $roundCanonical + $selfRoundTripReference = $roundReference + if ($roundCanonical -ceq $projectedCanonical -and $roundReference -ceq $projectedReference) { + $selfRoundTripResult = 'Pass' + } else { + $projectedDictionaryMappings = Get-YamlSuiteDictionaryReferenceSet ` + -Value ([object[]] $projectedValues) + $roundDictionaryMappings = Get-YamlSuiteDictionaryReferenceSet ` + -Value ([object[]] $roundTripValues) + $projectedProjectionCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $projectedValues) ` + -OrderedMappings $projectedDictionaryMappings + $roundProjectionCanonical = ConvertTo-YamlSuiteCanonicalValue ` + -Value ([object[]] $roundTripValues) ` + -OrderedMappings $roundDictionaryMappings + $projectedProjectionReference = ConvertTo-YamlSuiteReferenceSignature ` + -Value ([object[]] $projectedValues) ` + -OrderedMappings $projectedDictionaryMappings + $roundProjectionReference = ConvertTo-YamlSuiteReferenceSignature ` + -Value ([object[]] $roundTripValues) ` + -OrderedMappings $roundDictionaryMappings + if ($projectedOrderedMappings.Count -gt + $roundTripProjection.OrderedMappings.Count -and + $projectedProjectionCanonical -ceq $roundProjectionCanonical -and + $projectedProjectionReference -ceq $roundProjectionReference) { + $selfRoundTripResult = 'PolicyDifference' + $selfRoundTripReason = 'LegacyOrderedMapProjection' + } else { + $selfRoundTripResult = 'Fail' + $selfRoundTripReason = 'SelfRoundTripMismatch' + } + } + + } + } catch [System.NotSupportedException] { + $selfRoundTripResult = 'Fail' + $selfRoundTripReason = 'UnsupportedEmissionType' + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $selfRoundTripResult = 'Fail' + $selfRoundTripReason = [string] $_.Exception.Data['YamlErrorId'] + } else { + throw + } + } + } + } + + if ($CompareFormatter) { + if ($expectsError) { + try { + $formatterText = Invoke-InYamlModule -ScriptBlock $formatYamlSuiteText ` + -Arguments @($yaml) + $formatterResult = 'Fail' + $formatterReason = 'InvalidInputAccepted' + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $formatterResult = 'Pass' + $formatterReason = 'InvalidInputRejected' + } + } elseif ($null -eq $stream) { + if ($null -ne $representation -and $streamError -ceq 'YamlDuplicateKey') { + $formatterResult = 'NotApplicable' + $formatterReason = 'RepresentationMappingKeyUniqueness' + } else { + $formatterResult = 'Fail' + $formatterReason = if ($streamError) { $streamError } else { 'RepresentationUnavailable' } + } + } else { + try { + $formatterText = [string] ( + Invoke-InYamlModule -ScriptBlock $formatYamlSuiteText -Arguments @($yaml) + ) + $isValidFormat = Invoke-InYamlModule -ScriptBlock $testYamlSuiteText ` + -Arguments @($formatterText) + if (-not $isValidFormat) { + $formatterResult = 'Fail' + $formatterReason = 'FormattedYamlInvalid' + } else { + $formattedRepresentation = Invoke-InYamlModule ` + -ScriptBlock $readYamlSuiteRepresentation -Arguments @($formatterText) + $originalEvents = ConvertTo-YamlSuiteActualEvent -Documents $representation.Value ` + -IncludeEffectiveScalarTags + $formattedEvents = ConvertTo-YamlSuiteActualEvent ` + -Documents $formattedRepresentation.Value -IncludeEffectiveScalarTags + $formatterExpected = $originalEvents -join "`n" + $formatterActual = $formattedEvents -join "`n" + if (-not ( + Compare-YamlSuiteCanonicalList ` + -Left $formattedEvents -Right $originalEvents + )) { + $formatterResult = 'Fail' + $formatterReason = 'RepresentationMismatch' + } else { + $formatterSecond = [string] ( + Invoke-InYamlModule -ScriptBlock $formatYamlSuiteText ` + -Arguments @($formatterText) + ) + if ($formatterSecond -cne $formatterText) { + $formatterResult = 'Fail' + $formatterReason = 'FormatterNotIdempotent' + } else { + $formatterResult = 'Pass' + } + } + } + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $formatterResult = 'Fail' + $formatterReason = [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 + EmitYamlResult = $emitYamlResult + EmitYamlReason = $emitYamlReason + SelfRoundTripResult = $selfRoundTripResult + SelfRoundTripReason = $selfRoundTripReason + FormatterResult = $formatterResult + FormatterReason = $formatterReason + EventExpected = $eventExpected + EventActual = $eventActual + JsonExpected = $jsonExpected + JsonActual = $jsonActual + OutYamlActual = $outYamlCanonical + OutYamlRefs = $outYamlReference + EmitYamlExpected = $emitYamlExpected + EmitYamlActual = $emitYamlActual + EmitYamlExpectedRefs = $emitYamlExpectedReference + EmitYamlActualRefs = $emitYamlActualReference + SelfRoundTripActual = $selfRoundTripCanonical + SelfRoundTripRefs = $selfRoundTripReference + FormatterExpected = $formatterExpected + FormatterActual = $formatterActual + FormatterText = $formatterText + FormatterSecond = $formatterSecond + ProjectedActual = $projectedCanonical + ProjectedRefs = $projectedReference + } +}