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/dependabot.yml b/.github/dependabot.yml index 53188fe..0da2fac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,4 +11,6 @@ updates: - dependencies - github-actions schedule: - interval: weekly + interval: daily + cooldown: + default-days: 7 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..5f13361 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,12 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@11117919e65242d3388727819a751f74ad24ea9e # v5.5.0 - secrets: inherit + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@b11b310e461f08ee99055b0827ef7909dea7110a # v6.1.14 + 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/.gitignore b/.gitignore index 456ca0f..9c0e369 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,32 @@ -# VS Code files for those working on multiple tools +# Editor and developer tooling .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace - -# Local History for Visual Studio Code .history/* +.idea/ + +# Operating system files +.DS_Store +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +desktop.ini # PSModule framework outputs folder outputs/* -# .Net build output +# .NET build output bin/ obj/ libs/ + +# PowerShell and Pester test/coverage artifacts +testResults*.xml +coverage*.xml +*.coverage +*.coverage.xml \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9566e12 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Agents + +This repository follows the canonical PSModule agent guidance. + +- Start with the local `README.md` and `CONTRIBUTING.md`. +- Use PSModule documentation as source of truth: https://github.com/PSModule/docs +- Follow MSX ways-of-working guidance for workflow, PR format, and review gates: https://msxorg.github.io/docs/ + +Use small, focused commits and keep pull requests scoped to one delivery leaf unless you are executing a documented bootstrap integration merge. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b942e1c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,44 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [psmodule@psmodule.io](mailto:psmodule@psmodule.io). All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at: +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..586283e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing to Yaml + +Thank you for contributing to `PSModule/Yaml`. + +Please follow the canonical contribution flow from MSX and PSModule: + +- MSX contribution workflow: https://msxorg.github.io/docs/Ways-of-Working/Contribution-Workflow/ +- MSX PR format: https://msxorg.github.io/docs/Ways-of-Working/PR-Format/ +- PSModule standards: https://github.com/PSModule/docs + +## Ground rules + +1. Open or link a delivery leaf issue (Task/Bug) before implementation. +2. Use a dedicated branch and open a draft PR early. +3. Use micro-commits (one logical change per commit). +4. Run the required checks before marking a PR ready. +5. Keep PR scope tight and route out-of-scope findings to follow-up issues. + +## Conduct + +Please follow this repository's [Code of Conduct](./CODE_OF_CONDUCT.md). 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/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..61b071a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security Policy + +## Supported Versions + +Only the latest version of this module is supported. If a fix is made, it will only be added to the latest version. + +## Reporting a security issue + +If a security issue is found please do not make a public issue, as this could put other users at risk. +Instead, please mail us on [psmodule@psmodule.io](mailto:psmodule@psmodule.io). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..90cf738 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,13 @@ +# Support + +Depending on the issue and kind of support needed there are different ways to get help. + +## PSModule support + +To get support, please use issues on this GitHub repository. +For inquiries that are not suited for an issue, contact us via [email](mailto:psmodule@psmodule.io). + +## PSGallery support + +- [Mail](mailto:cgadmin@microsoft.com) +- [PowerShell/PowerShellGallery - Issues](https://github.com/PowerShell/PowerShellGallery/issues) 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..445707e --- /dev/null +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -0,0 +1,143 @@ +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 + + .LINK + https://github.com/PSModule/Yaml#parse-yaml + #> + [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..5ca0fc0 --- /dev/null +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -0,0 +1,154 @@ +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 + + .LINK + https://github.com/PSModule/Yaml#serialize-powershell-values + #> + [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..f5d245e --- /dev/null +++ b/src/functions/public/Export-Yaml.ps1 @@ -0,0 +1,1170 @@ +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://github.com/PSModule/Yaml#export-yaml-files + #> + [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..7626438 --- /dev/null +++ b/src/functions/public/Format-Yaml.ps1 @@ -0,0 +1,126 @@ +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 + + .LINK + https://github.com/PSModule/Yaml#format-yaml-streams + #> + [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..a283f8c --- /dev/null +++ b/src/functions/public/Import-Yaml.ps1 @@ -0,0 +1,648 @@ +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://github.com/PSModule/Yaml#import-yaml-files + #> + [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..3d4620c --- /dev/null +++ b/src/functions/public/Merge-Yaml.ps1 @@ -0,0 +1,280 @@ +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#merge-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..ba0a0f1 --- /dev/null +++ b/src/functions/public/Test-Yaml.ps1 @@ -0,0 +1,103 @@ +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 + + .LINK + https://github.com/PSModule/Yaml#validate-yaml + #> + [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..de856c5 --- /dev/null +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -0,0 +1,664 @@ +#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' { + 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..f1d76ac --- /dev/null +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -0,0 +1,665 @@ +#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 'Yaml' { + 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..6342cb9 --- /dev/null +++ b/tests/Export-Yaml.Tests.ps1 @@ -0,0 +1,703 @@ +#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' { + 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 'Yaml' { + 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..7ad17e2 --- /dev/null +++ b/tests/Import-Yaml.Tests.ps1 @@ -0,0 +1,534 @@ +#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' { + 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..5ba68fe --- /dev/null +++ b/tests/Merge-Yaml.Tests.ps1 @@ -0,0 +1,901 @@ +#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 'Yaml' { + 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..538c9ca --- /dev/null +++ b/tests/Packaging.Tests.ps1 @@ -0,0 +1,302 @@ +#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 runtime requirements out of removed source manifest and header files' { + Test-Path (Join-Path $repositoryRoot 'src\manifest.psd1') | Should -BeFalse + Test-Path (Join-Path $repositoryRoot 'src\header.ps1') | Should -BeFalse + } + + 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..c4eb0b5 --- /dev/null +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -0,0 +1,964 @@ +#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 '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..583d147 --- /dev/null +++ b/tests/Test-Yaml.Tests.ps1 @@ -0,0 +1,202 @@ +#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' { + 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..e5d9f40 --- /dev/null +++ b/tests/TestBootstrap.ps1 @@ -0,0 +1,90 @@ +$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) { + throw 'The Yaml module is not loaded. Test-ModuleLocal must import the built module before tests run.' +} + +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 + } +}