Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The module exports:
| `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. |
| `Test-Yaml` | Test YAML syntax, tags, duplicate keys, and configured resource limits. |

## Parse YAML
Expand Down Expand Up @@ -174,6 +175,48 @@ $normalized -ceq ($normalized | Format-Yaml -Indent 4)
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.

## Export YAML files

`Export-Yaml` aggregates pipeline records like `ConvertTo-Yaml`, serializes the
Expand Down Expand Up @@ -220,6 +263,8 @@ limit violations. Unexpected runtime failures are not suppressed.
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.
- 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
Expand Down
14 changes: 14 additions & 0 deletions examples/General.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ $normalizedYaml = @'
'@ | 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

# Atomically export one file, then import it with strict decoding.
$configPath = Join-Path $env:TEMP 'yaml-example.yaml'
$config | Export-Yaml -Path $configPath -PassThru
Expand Down
44 changes: 44 additions & 0 deletions src/functions/private/Add-YamlMergeIndexCandidate.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
36 changes: 36 additions & 0 deletions src/functions/private/Add-YamlMergeWork.ps1
Original file line number Diff line number Diff line change
@@ -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
}
125 changes: 125 additions & 0 deletions src/functions/private/Assert-YamlMergeGraph.ps1
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading