From 1f66f22c2b3b08776d1af7044892fe372bfe4a43 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 09:34:30 +0200 Subject: [PATCH 01/10] Add transactional YAML entry removal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/private/Add-YamlRemovalWork.ps1 | 37 + .../private/Assert-YamlRemovalGraph.ps1 | 158 ++++ .../private/ConvertFrom-YamlJsonPointer.ps1 | 66 ++ src/functions/private/Get-YamlRemovalNode.ps1 | 35 + .../private/New-YamlRemovalException.ps1 | 31 + .../Remove-YamlRepresentationTarget.ps1 | 105 +++ .../private/Resolve-YamlRemovalTarget.ps1 | 191 +++++ .../private/Select-YamlRemovalTarget.ps1 | 74 ++ src/functions/public/Remove-YamlEntry.ps1 | 290 ++++++++ tests/Remove-YamlEntry.Tests.ps1 | 696 ++++++++++++++++++ 10 files changed, 1683 insertions(+) create mode 100644 src/functions/private/Add-YamlRemovalWork.ps1 create mode 100644 src/functions/private/Assert-YamlRemovalGraph.ps1 create mode 100644 src/functions/private/ConvertFrom-YamlJsonPointer.ps1 create mode 100644 src/functions/private/Get-YamlRemovalNode.ps1 create mode 100644 src/functions/private/New-YamlRemovalException.ps1 create mode 100644 src/functions/private/Remove-YamlRepresentationTarget.ps1 create mode 100644 src/functions/private/Resolve-YamlRemovalTarget.ps1 create mode 100644 src/functions/private/Select-YamlRemovalTarget.ps1 create mode 100644 src/functions/public/Remove-YamlEntry.ps1 create mode 100644 tests/Remove-YamlEntry.Tests.ps1 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-YamlRemovalGraph.ps1 b/src/functions/private/Assert-YamlRemovalGraph.ps1 new file mode 100644 index 0000000..2cfe116 --- /dev/null +++ b/src/functions/private/Assert-YamlRemovalGraph.ps1 @@ -0,0 +1,158 @@ +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 + }) + } + } +} 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/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/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/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..f44ee81 --- /dev/null +++ b/src/functions/private/Resolve-YamlRemovalTarget.ps1 @@ -0,0 +1,191 @@ +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 + Ancestors = [string[]]::new(0) + 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 ($effectiveTag -cne 'tag:yaml.org,2002:str') { + $hasUnaddressableKey = $true + continue + } + if ([string] $keyNode.Value -ceq $token) { + $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 + Ancestors = [string[]] $ancestors.ToArray() + 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 + Ancestors = [string[]] $ancestors.ToArray() + 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/Select-YamlRemovalTarget.ps1 b/src/functions/private/Select-YamlRemovalTarget.ps1 new file mode 100644 index 0000000..329895d --- /dev/null +++ b/src/functions/private/Select-YamlRemovalTarget.ps1 @@ -0,0 +1,74 @@ +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].AncestorSets.Add([string[]] $target.Ancestors) + continue + } + + $ancestorSets = [System.Collections.Generic.List[object]]::new() + $ancestorSets.Add([string[]] $target.Ancestors) + $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 + AncestorSets = $ancestorSets + Pointer = $target.Pointer + DocumentIndex = $target.DocumentIndex + } + } + + $selectedKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($key in $coalesced.Keys) { + [void] $selectedKeys.Add($key) + } + + $survivors = [System.Collections.Generic.List[object]]::new() + foreach ($candidate in $coalesced.Values) { + $allRequestsSubsumed = $true + foreach ($ancestorSet in $candidate.AncestorSets) { + $requestSubsumed = $false + foreach ($ancestorKey in $ancestorSet) { + Add-YamlRemovalWork -State $State -Operation 'ancestor coalescing' ` + -Node $candidate.Node + if ($selectedKeys.Contains($ancestorKey)) { + $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/public/Remove-YamlEntry.ps1 b/src/functions/public/Remove-YamlEntry.ps1 new file mode 100644 index 0000000..a795a59 --- /dev/null +++ b/src/functions/public/Remove-YamlEntry.ps1 @@ -0,0 +1,290 @@ +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/id', '/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 -or ( + $PSCmdlet.ParameterSetName -ceq 'AllDocuments' + ) + 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/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 new file mode 100644 index 0000000..05471ca --- /dev/null +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -0,0 +1,696 @@ +#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 + } + } +} + +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 '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 '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 '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 + } + } + + 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' + } + } + + 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 '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) + } + } +} From df807088c8c0b4f0d6c7b71547839b65cee51fd9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 09:36:19 +0200 Subject: [PATCH 02/10] Document representation-preserving YAML removal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 26 ++++++++++++++++++++++++++ examples/General.ps1 | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 0c8dc33..1870e85 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The module exports: | `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 @@ -217,6 +218,29 @@ 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/internalId', '/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 @@ -265,6 +289,8 @@ limit violations. Unexpected runtime failures are not suppressed. 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 diff --git a/examples/General.ps1 b/examples/General.ps1 index dc69e5a..72a0af7 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -61,6 +61,11 @@ service: $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 From 06cca7e7344b9ff54696cb80c033c540dac63a00 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 09:36:26 +0200 Subject: [PATCH 03/10] Verify YAML removal package exports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Packaging.Tests.ps1 | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/Packaging.Tests.ps1 b/tests/Packaging.Tests.ps1 index 2824f17..ac5e530 100644 --- a/tests/Packaging.Tests.ps1 +++ b/tests/Packaging.Tests.ps1 @@ -135,6 +135,7 @@ Describe 'Generated artifact package' { 'Format-Yaml', 'Import-Yaml', 'Merge-Yaml', + 'Remove-YamlEntry', 'Test-Yaml' ) @($manifest.FileList) | Should -Contain 'Yaml.psm1' @@ -263,4 +264,54 @@ if ($mergedValue['service']['image'] -cne 'example:v2' -or 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 + } } From e3dfe17043259149d91f87019890d510a3f243f4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 09:43:29 +0200 Subject: [PATCH 04/10] Use documentation-safe pointer examples Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/functions/public/Remove-YamlEntry.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1870e85..8a9a588 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ operation budget. Alias and expanded-tag budgets are also enforced on the result ```powershell $cleanYaml = Get-Content -Path '.\config.yaml' | - Remove-YamlEntry -Path @('/metadata/internalId', '/services/1/deprecated') + 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. diff --git a/src/functions/public/Remove-YamlEntry.ps1 b/src/functions/public/Remove-YamlEntry.ps1 index a795a59..0ef8893 100644 --- a/src/functions/public/Remove-YamlEntry.ps1 +++ b/src/functions/public/Remove-YamlEntry.ps1 @@ -79,7 +79,7 @@ function Remove-YamlEntry { Aggregates file lines and removes one nested mapping entry from document zero. .EXAMPLE - $clean = Remove-YamlEntry -InputObject $yaml -Path @('/metadata/id', '/items/2') + $clean = Remove-YamlEntry -InputObject $yaml -Path @('/metadata/identifier', '/items/2') Resolves both paths against the original graph, then applies them atomically. From 5e50aaf7b3b6e8a345235cfc905b2abc9f4c3c88 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 10:12:15 +0200 Subject: [PATCH 05/10] Validate keys after YAML removal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-YamlRemovalGraph.ps1 | 18 +++++++++ .../private/Get-YamlNodeFingerprint.ps1 | 10 ++++- src/functions/private/Test-YamlNodeGraph.ps1 | 12 ++++-- tests/Remove-YamlEntry.Tests.ps1 | 40 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/functions/private/Assert-YamlRemovalGraph.ps1 b/src/functions/private/Assert-YamlRemovalGraph.ps1 index 2cfe116..1dd3f72 100644 --- a/src/functions/private/Assert-YamlRemovalGraph.ps1 +++ b/src/functions/private/Assert-YamlRemovalGraph.ps1 @@ -143,6 +143,7 @@ function Assert-YamlRemovalGraph { )) } } + } for ($index = $node.Entries.Count - 1; $index -ge 0; $index--) { $pending.Push([pscustomobject]@{ @@ -155,4 +156,21 @@ function Assert-YamlRemovalGraph { }) } } + + $fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + $fingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + $fingerprintWorkState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + try { + foreach ($document in $Documents) { + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher ` + -RemovalWorkState $fingerprintWorkState + } + } finally { + $fingerprintHasher.Dispose() + } } diff --git a/src/functions/private/Get-YamlNodeFingerprint.ps1 b/src/functions/private/Get-YamlNodeFingerprint.ps1 index 37ca9d9..6147873 100644 --- a/src/functions/private/Get-YamlNodeFingerprint.ps1 +++ b/src/functions/private/Get-YamlNodeFingerprint.ps1 @@ -18,7 +18,11 @@ function Get-YamlNodeFingerprint { [System.Collections.Generic.Dictionary[int, string]] $Cache, [Parameter(Mandatory)] - [System.Security.Cryptography.HashAlgorithm] $Hasher + [System.Security.Cryptography.HashAlgorithm] $Hasher, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState ) $root = [pscustomobject]@{ Value = '' } @@ -48,6 +52,10 @@ function Get-YamlNodeFingerprint { [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 ( diff --git a/src/functions/private/Test-YamlNodeGraph.ps1 b/src/functions/private/Test-YamlNodeGraph.ps1 index 5aa88b8..629faf9 100644 --- a/src/functions/private/Test-YamlNodeGraph.ps1 +++ b/src/functions/private/Test-YamlNodeGraph.ps1 @@ -17,7 +17,11 @@ function Test-YamlNodeGraph { [System.Collections.Generic.Dictionary[int, string]] $FingerprintCache, [Parameter(Mandatory)] - [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher + [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState ) $scalarTags = [System.Collections.Generic.HashSet[string]]::new( @@ -94,7 +98,8 @@ function Test-YamlNodeGraph { $keyFingerprint = Get-YamlNodeFingerprint ` -Node $entryNode.Entries[0].Key ` -Active ([System.Collections.Generic.HashSet[int]]::new()) ` - -Cache $FingerprintCache -Hasher $FingerprintHasher + -Cache $FingerprintCache -Hasher $FingerprintHasher ` + -RemovalWorkState $RemovalWorkState if (-not $orderedKeys.Add($keyFingerprint)) { $keyNode = $entryNode.Entries[0].Key throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` @@ -127,7 +132,8 @@ function Test-YamlNodeGraph { $entry = $current.Entries[$index] $fingerprint = Get-YamlNodeFingerprint -Node $entry.Key ` -Active ([System.Collections.Generic.HashSet[int]]::new()) ` - -Cache $FingerprintCache -Hasher $FingerprintHasher + -Cache $FingerprintCache -Hasher $FingerprintHasher ` + -RemovalWorkState $RemovalWorkState if (-not $keys.Add($fingerprint)) { throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End ` -ErrorId 'YamlDuplicateKey' -Message ( diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 index 05471ca..30bb461 100644 --- a/tests/Remove-YamlEntry.Tests.ps1 +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -613,6 +613,46 @@ root: !item $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' + } } Context 'Validation and work limits' { From 1f5d1174152b71cdb3237c11ad4222e7ec7939d8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 10:13:02 +0200 Subject: [PATCH 06/10] Compare YAML removal keys ordinally Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Resolve-YamlRemovalTarget.ps1 | 12 +++++++++-- tests/Remove-YamlEntry.Tests.ps1 | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/functions/private/Resolve-YamlRemovalTarget.ps1 b/src/functions/private/Resolve-YamlRemovalTarget.ps1 index f44ee81..b25a7b1 100644 --- a/src/functions/private/Resolve-YamlRemovalTarget.ps1 +++ b/src/functions/private/Resolve-YamlRemovalTarget.ps1 @@ -67,11 +67,19 @@ function Resolve-YamlRemovalTarget { $resolvedKey = (Resolve-YamlScalar -Node $keyNode).Value $effectiveTag = Get-YamlEffectiveTag -Node $keyNode -Value $resolvedKey - if ($effectiveTag -cne 'tag:yaml.org,2002:str') { + if (-not [string]::Equals( + $effectiveTag, + 'tag:yaml.org,2002:str', + [System.StringComparison]::Ordinal + )) { $hasUnaddressableKey = $true continue } - if ([string] $keyNode.Value -ceq $token) { + if ([string]::Equals( + [string] $keyNode.Value, + $token, + [System.StringComparison]::Ordinal + )) { $matchingIndexes.Add($entryIndex) } } diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 index 30bb461..8142443 100644 --- a/tests/Remove-YamlEntry.Tests.ps1 +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -223,6 +223,27 @@ keep: true $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 From bf4dfbdcc490d94d0f1ac09ea46add703755dac8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 10:33:25 +0200 Subject: [PATCH 07/10] Preserve cyclic YAML removal targets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Resolve-YamlRemovalTarget.ps1 | 6 +- .../private/Select-YamlRemovalTarget.ps1 | 57 +++++++++++---- tests/Remove-YamlEntry.Tests.ps1 | 70 +++++++++++++++++++ 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/functions/private/Resolve-YamlRemovalTarget.ps1 b/src/functions/private/Resolve-YamlRemovalTarget.ps1 index b25a7b1..08b2721 100644 --- a/src/functions/private/Resolve-YamlRemovalTarget.ps1 +++ b/src/functions/private/Resolve-YamlRemovalTarget.ps1 @@ -36,7 +36,7 @@ function Resolve-YamlRemovalTarget { Edge = $Document Node = $Document Depth = 0 - Ancestors = [string[]]::new(0) + Path = [string[]] @($documentKey) Pointer = $Pointer DocumentIndex = $DocumentIndex } @@ -122,7 +122,7 @@ function Resolve-YamlRemovalTarget { Edge = $matchedEntry Node = $matchedEntry.Value Depth = $Tokens.Count - Ancestors = [string[]] $ancestors.ToArray() + Path = [string[]] (@($ancestors.ToArray()) + $edgeKey) Pointer = $Pointer DocumentIndex = $DocumentIndex } @@ -176,7 +176,7 @@ function Resolve-YamlRemovalTarget { Edge = $sequenceItem Node = $sequenceItem Depth = $Tokens.Count - Ancestors = [string[]] $ancestors.ToArray() + Path = [string[]] (@($ancestors.ToArray()) + $edgeKey) Pointer = $Pointer DocumentIndex = $DocumentIndex } diff --git a/src/functions/private/Select-YamlRemovalTarget.ps1 b/src/functions/private/Select-YamlRemovalTarget.ps1 index 329895d..fb541d0 100644 --- a/src/functions/private/Select-YamlRemovalTarget.ps1 +++ b/src/functions/private/Select-YamlRemovalTarget.ps1 @@ -20,12 +20,12 @@ function Select-YamlRemovalTarget { foreach ($target in $Targets) { Add-YamlRemovalWork -State $State -Operation 'target coalescing' -Node $target.Node if ($coalesced.ContainsKey($target.Key)) { - $coalesced[$target.Key].AncestorSets.Add([string[]] $target.Ancestors) + $coalesced[$target.Key].PathSets.Add([string[]] $target.Path) continue } - $ancestorSets = [System.Collections.Generic.List[object]]::new() - $ancestorSets.Add([string[]] $target.Ancestors) + $pathSets = [System.Collections.Generic.List[object]]::new() + $pathSets.Add([string[]] $target.Path) $coalesced[$target.Key] = [pscustomobject]@{ Key = $target.Key Kind = $target.Kind @@ -34,28 +34,59 @@ function Select-YamlRemovalTarget { Edge = $target.Edge Node = $target.Node Depth = $target.Depth - AncestorSets = $ancestorSets + PathSets = $pathSets Pointer = $target.Pointer DocumentIndex = $target.DocumentIndex } } - $selectedKeys = [System.Collections.Generic.HashSet[string]]::new( - [System.StringComparer]::Ordinal - ) - foreach ($key in $coalesced.Keys) { - [void] $selectedKeys.Add($key) + $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 ($ancestorSet in $candidate.AncestorSets) { + foreach ($path in $candidate.PathSets) { $requestSubsumed = $false - foreach ($ancestorKey in $ancestorSet) { - Add-YamlRemovalWork -State $State -Operation 'ancestor coalescing' ` + $pathNode = $pathRoot + for ($pathIndex = 0; $pathIndex -lt $path.Count - 1; $pathIndex++) { + Add-YamlRemovalWork -State $State -Operation 'target path ancestry' ` -Node $candidate.Node - if ($selectedKeys.Contains($ancestorKey)) { + $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 } diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 index 8142443..504b05c 100644 --- a/tests/Remove-YamlEntry.Tests.ps1 +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -609,6 +609,76 @@ node: &node $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' { From 80d4c8c796430ab78186fc1d228167957e284ab0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 10:34:01 +0200 Subject: [PATCH 08/10] Honor false all-document removal switches Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/public/Remove-YamlEntry.ps1 | 4 +-- tests/Remove-YamlEntry.Tests.ps1 | 37 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/functions/public/Remove-YamlEntry.ps1 b/src/functions/public/Remove-YamlEntry.ps1 index 0ef8893..384413e 100644 --- a/src/functions/public/Remove-YamlEntry.ps1 +++ b/src/functions/public/Remove-YamlEntry.ps1 @@ -176,9 +176,7 @@ function Remove-YamlEntry { -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength $sourceDocuments = [object[]] $documentBox.Value - $selectAllDocuments = $AllDocuments.IsPresent -or ( - $PSCmdlet.ParameterSetName -ceq 'AllDocuments' - ) + $selectAllDocuments = $AllDocuments.IsPresent if (-not $selectAllDocuments -and $DocumentIndex -ge $sourceDocuments.Count) { throw (New-YamlRemovalException ` -ErrorId 'YamlRemovalDocumentIndexOutOfRange' -Message ( diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 index 504b05c..0a5c117 100644 --- a/tests/Remove-YamlEntry.Tests.ps1 +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -411,6 +411,30 @@ root: $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 = @( @@ -459,6 +483,19 @@ root: $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 From 17422e39a68a92c841419f28e6d0f1b7c4264598 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 10:57:14 +0200 Subject: [PATCH 09/10] Confirm YAML key fingerprints structurally Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Assert-YamlNodeKeyUnique.ps1 | 61 ++++++++++++ .../private/Assert-YamlRemovalGraph.ps1 | 43 +++++++- .../private/Test-YamlMergeNodeEqual.ps1 | 6 +- src/functions/private/Test-YamlNodeGraph.ps1 | 46 +++++---- tests/Remove-YamlEntry.Tests.ps1 | 97 +++++++++++++++++++ 5 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 src/functions/private/Assert-YamlNodeKeyUnique.ps1 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 index 1dd3f72..db88ce4 100644 --- a/src/functions/private/Assert-YamlRemovalGraph.ps1 +++ b/src/functions/private/Assert-YamlRemovalGraph.ps1 @@ -163,12 +163,47 @@ function Assert-YamlRemovalGraph { 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) { - Test-YamlNodeGraph -Node $document ` - -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` - -FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher ` - -RemovalWorkState $fingerprintWorkState + 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/Test-YamlMergeNodeEqual.ps1 b/src/functions/private/Test-YamlMergeNodeEqual.ps1 index 3eea333..e727c2a 100644 --- a/src/functions/private/Test-YamlMergeNodeEqual.ps1 +++ b/src/functions/private/Test-YamlMergeNodeEqual.ps1 @@ -80,7 +80,11 @@ function Test-YamlMergeNodeEqual { } $leftTag = Get-YamlMergeNodeTag -Node $left $rightTag = Get-YamlMergeNodeTag -Node $right - if ($leftTag -cne $rightTag) { + if (-not [string]::Equals( + $leftTag, + $rightTag, + [System.StringComparison]::Ordinal + )) { $State.Cache[$cacheKey] = $false return $false } diff --git a/src/functions/private/Test-YamlNodeGraph.ps1 b/src/functions/private/Test-YamlNodeGraph.ps1 index 629faf9..a130a64 100644 --- a/src/functions/private/Test-YamlNodeGraph.ps1 +++ b/src/functions/private/Test-YamlNodeGraph.ps1 @@ -21,7 +21,15 @@ function Test-YamlNodeGraph { [Parameter()] [AllowNull()] - [pscustomobject] $RemovalWorkState + [pscustomobject] $RemovalWorkState, + + [Parameter()] + [AllowNull()] + [pscustomobject] $EqualityState, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache ) $scalarTags = [System.Collections.Generic.HashSet[string]]::new( @@ -78,7 +86,7 @@ function Test-YamlNodeGraph { $isPairs = $tag -ceq 'tag:yaml.org,2002:pairs' $isOrderedMap = $tag -ceq 'tag:yaml.org,2002:omap' - $orderedKeys = [System.Collections.Generic.HashSet[string]]::new( + $orderedKeys = [System.Collections.Generic.Dictionary[string, object]]::new( [System.StringComparer]::Ordinal ) for ($index = $current.Items.Count - 1; $index -ge 0; $index--) { @@ -95,18 +103,13 @@ function Test-YamlNodeGraph { )) } if ($isOrderedMap) { - $keyFingerprint = Get-YamlNodeFingerprint ` + Assert-YamlNodeKeyUnique ` -Node $entryNode.Entries[0].Key ` - -Active ([System.Collections.Generic.HashSet[int]]::new()) ` - -Cache $FingerprintCache -Hasher $FingerprintHasher ` - -RemovalWorkState $RemovalWorkState - if (-not $orderedKeys.Add($keyFingerprint)) { - $keyNode = $entryNode.Entries[0].Key - throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` - -ErrorId 'YamlDuplicateKey' -Message ( - 'A duplicate key was found in a YAML ordered mapping.' - )) - } + -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) @@ -125,21 +128,16 @@ function Test-YamlNodeGraph { )) } - $keys = [System.Collections.Generic.HashSet[string]]::new( + $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] - $fingerprint = Get-YamlNodeFingerprint -Node $entry.Key ` - -Active ([System.Collections.Generic.HashSet[int]]::new()) ` - -Cache $FingerprintCache -Hasher $FingerprintHasher ` - -RemovalWorkState $RemovalWorkState - if (-not $keys.Add($fingerprint)) { - throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End ` - -ErrorId 'YamlDuplicateKey' -Message ( - 'A duplicate mapping key is not allowed.' - )) - } + 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 diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 index 0a5c117..f3ae492 100644 --- a/tests/Remove-YamlEntry.Tests.ps1 +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -95,6 +95,63 @@ BeforeAll { 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 + } + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $hasher ` + -EqualityState $equalityState ` + -EqualityFingerprintCache ( + [System.Collections.Generic.Dictionary[int, string]]::new() + ) + } finally { + $hasher.Dispose() + } + return $true + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation $Yaml + } + return & $loadedModule $implementation $Yaml + } } Describe 'Remove-YamlEntry' { @@ -781,6 +838,25 @@ ordered: !!omap $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' { @@ -819,6 +895,27 @@ ordered: !!omap $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" ` From 09414894687a072fd046e217053ba8f89623b944 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 11:05:59 +0200 Subject: [PATCH 10/10] Align YAML collision regression formatting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Remove-YamlEntry.Tests.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 index f3ae492..caa793a 100644 --- a/tests/Remove-YamlEntry.Tests.ps1 +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -133,13 +133,14 @@ BeforeAll { ) 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 ( - [System.Collections.Generic.Dictionary[int, string]]::new() - ) + -EqualityFingerprintCache $equalityFingerprintCache } finally { $hasher.Dispose() }