Private/Model/Compare-PpaSnapshot.ps1
|
# Compare-PpaSnapshot.ps1 - the delta differ (Wave 4 spec 4.2-4.4). Fully offline: # consumes two LOADED snapshots (Import-PpaSnapshot), produces the delta model the # renderer draws. No tenant reads anywhere in delta mode. # # Identity contract (spec 3.3/4.3 addenda): _key and guid are OPAQUE STRINGS. # The rename-reconciliation pass is NON-EMPTY ORDINAL STRING EQUALITY on guid - # no GUID format parsing or validation. Fixture slug guids are first-class inputs. # # NOTE: this file must PARSE under Windows PowerShell 5.1 (the module loader # dot-sources everything on both engines); delta only EXECUTES on 7+ via the # Test-PpaDeltaEngine gate at the entry point. No PS7-only syntax here. # ASCII-only source. Set-StrictMode -Off function Test-PpaDeltaEngine { # The injectable engine gate (spec section 1, floor raised at C-fix 1): delta # requires PS 7.5+ because the loader depends on ConvertFrom-Json -DateKind # String (7.5+) to keep date-like leaves as verbatim strings. Tests redefine # this function to exercise the refusal without a too-old host. $v = $PSVersionTable.PSVersion return (($v.Major -gt 7) -or ($v.Major -eq 7 -and $v.Minor -ge 5)) } function Get-PpaSignificantPropertyMap { $path = Join-Path $PSScriptRoot 'ppa-significant-properties.json' return ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json) } function Test-PpaDeltaLeaf { param($Value) if ($null -eq $Value) { return $true } if ($Value -is [string] -or $Value -is [System.ValueType]) { return $true } return $false } function Test-PpaDeltaValueEqual { # Strict structural equality for property values. Strings compare ordinal # case-sensitive; nested objects compare by property set + values; nested # (non-declared) arrays compare order-sensitively. The absent == '' rule is # applied by the CALLER at the top property level only. param($X, $Y) $xl = Test-PpaDeltaLeaf $X; $yl = Test-PpaDeltaLeaf $Y if ($xl -ne $yl) { return $false } if ($xl) { if ($null -eq $X -and $null -eq $Y) { return $true } if ($null -eq $X -or $null -eq $Y) { return $false } if ($X -is [string] -or $Y -is [string]) { return ([string]$X -ceq [string]$Y) } return ($X -eq $Y) } $xArr = ($X -isnot [System.Management.Automation.PSCustomObject]) $yArr = ($Y -isnot [System.Management.Automation.PSCustomObject]) if ($xArr -ne $yArr) { return $false } if ($xArr) { $xa = @($X); $ya = @($Y) if ($xa.Count -ne $ya.Count) { return $false } for ($i = 0; $i -lt $xa.Count; $i++) { if (-not (Test-PpaDeltaValueEqual $xa[$i] $ya[$i])) { return $false } } return $true } $xn = @($X.PSObject.Properties | ForEach-Object { $_.Name }) | Sort-Object $yn = @($Y.PSObject.Properties | ForEach-Object { $_.Name }) | Sort-Object if (($xn -join '|') -cne ($yn -join '|')) { return $false } foreach ($n in $xn) { if (-not (Test-PpaDeltaValueEqual $X.$n $Y.$n)) { return $false } } return $true } function Get-PpaDeltaArrayCanon { # Order-insensitive canonical form for DECLARED array properties: each element # becomes a canonical string, the list is sorted, then joined. Two arrays with # the same members in any order canonicalize identically. param($Value) $sep = [string][char]31 $parts = New-Object System.Collections.Generic.List[string] foreach ($e in @($Value)) { if ($null -eq $e) { $parts.Add('~null') } elseif (Test-PpaDeltaLeaf $e) { $parts.Add('s:' + [string]$e) } else { $parts.Add('j:' + (ConvertTo-Json -InputObject $e -Compress -Depth 8)) } } return (@($parts | Sort-Object) -join $sep) } function ConvertTo-PpaDeltaDisplay { # Human-readable rendering of a property value in a change record. param($Value) if ($null -eq $Value) { return '' } if (Test-PpaDeltaLeaf $Value) { return [string]$Value } if ($Value -isnot [System.Management.Automation.PSCustomObject]) { $items = @($Value | ForEach-Object { ConvertTo-PpaDeltaDisplay $_ }) return ('[' + ($items -join ', ') + ']') } return (ConvertTo-Json -InputObject $Value -Compress -Depth 8) } function Compare-PpaSnapshotObject { # Property-level compare of one matched object pair. Returns change records. # Skips identity metadata (_key/_keySource) and denylisted properties; treats # absent property == empty string (A.5 carry-forward); declared arrays compare # order-insensitively. param($FromObj, $ToObj, [string[]]$DeclaredArrays, [string[]]$Denylist, [string[]]$Significant) $changes = New-Object System.Collections.Generic.List[object] $names = New-Object System.Collections.Generic.List[string] foreach ($p in $FromObj.PSObject.Properties) { if ($names -notcontains $p.Name) { $names.Add($p.Name) } } foreach ($p in $ToObj.PSObject.Properties) { if ($names -notcontains $p.Name) { $names.Add($p.Name) } } foreach ($n in $names) { if ($n -eq '_key' -or $n -eq '_keySource') { continue } if ($Denylist -contains $n) { continue } $fv = $null; $tv = $null $fHas = ($FromObj.PSObject.Properties.Name -contains $n) $tHas = ($ToObj.PSObject.Properties.Name -contains $n) if ($fHas) { $fv = $FromObj.$n } if ($tHas) { $tv = $ToObj.$n } # absent == '' (and null == '') at the top property level: MinValue/all-zeros # placeholders normalize to '' upstream, so absent-vs-'' is never a change. if (-not $fHas -or $null -eq $fv) { $fv = '' } if (-not $tHas -or $null -eq $tv) { $tv = '' } $equal = $false if ($DeclaredArrays -contains $n) { $equal = ((Get-PpaDeltaArrayCanon $fv) -ceq (Get-PpaDeltaArrayCanon $tv)) } else { if (($fv -is [string]) -and ($tv -isnot [string]) -and (Test-PpaDeltaLeaf $tv) -and $fv -eq '') { # '' vs non-string leaf: real difference; fall through to strict compare } $equal = Test-PpaDeltaValueEqual $fv $tv } if (-not $equal) { $changes.Add([pscustomobject][ordered]@{ property = $n from = ConvertTo-PpaDeltaDisplay $fv to = ConvertTo-PpaDeltaDisplay $tv significant = [bool]($Significant -contains $n) }) } } return $changes.ToArray() } function Compare-PpaSnapshotPair { # Two loaded snapshots -> the delta model (spec 4.2-4.4). Throws on tenant # mismatch (unless -AllowTenantMismatch); warns on absent tenantId and on # reversed capturedAt (never auto-swaps). [CmdletBinding()] param( [Parameter(Mandatory = $true)] $From, [Parameter(Mandatory = $true)] $To, [switch]$AllowTenantMismatch ) $schema = Get-PpaSnapshotSchema $denyFile = Get-PpaPostureDenylist $sigMap = Get-PpaSignificantPropertyMap $globalDeny = @($denyFile.global | ForEach-Object { [string]$_ }) # ---- pre-compare validation (4.2) ---- $fromTenant = [string]$From.tenantId $toTenant = [string]$To.tenantId if ([string]::IsNullOrEmpty($fromTenant) -or [string]::IsNullOrEmpty($toTenant)) { Write-Warning 'A snapshot has no tenantId recorded; proceeding without the same-tenant check.' } elseif ($fromTenant -cne $toTenant) { if (-not $AllowTenantMismatch) { throw ("Snapshots are from different tenants ('{0}' vs '{1}'). Re-check the inputs, or pass -AllowTenantMismatch to compare anyway." -f $fromTenant, $toTenant) } Write-Warning ("Comparing snapshots from DIFFERENT tenants ('{0}' vs '{1}') because -AllowTenantMismatch was passed." -f $fromTenant, $toTenant) } $culture = [System.Globalization.CultureInfo]::InvariantCulture $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal $fromDt = [datetime]::Parse([string]$From.capturedAt, $culture, $styles) $toDt = [datetime]::Parse([string]$To.capturedAt, $culture, $styles) $spanDays = [int][math]::Round(($toDt - $fromDt).TotalDays) if ($fromDt -gt $toDt) { Write-Warning ("-DeltaFrom ({0}) is newer than -DeltaTo ({1}); comparing as given - the report reads right to left. Not auto-swapping." -f $From.capturedAt, $To.capturedAt) } $denylistNote = $null if ([string]$From.denylistVersion -cne [string]$To.denylistVersion) { $denylistNote = ("Informational: the snapshots recorded denylist versions '{0}' and '{1}'; this comparison used the current tool's list (v{2})." -f $From.denylistVersion, $To.denylistVersion, $denyFile.version) } $readable = @('Populated', 'Empty') $fromRun = @($From.sectionsRun | ForEach-Object { [string]$_ }) $toRun = @($To.sectionsRun | ForEach-Object { [string]$_ }) $allIds = New-Object System.Collections.Generic.List[string] foreach ($id in $fromRun) { if ($allIds -notcontains $id) { $allIds.Add($id) } } foreach ($id in $toRun) { if ($allIds -notcontains $id) { $allIds.Add($id) } } $typeDefs = @($schema.types.PSObject.Properties | Sort-Object Name) $sections = New-Object System.Collections.Generic.List[object] foreach ($id in $allIds) { $inFrom = ($fromRun -contains $id) $inTo = ($toRun -contains $id) $rec = [ordered]@{ id = $id; state = ''; reason = '' fromOutcome = [string]$From.collectorOutcomes.$id toOutcome = [string]$To.collectorOutcomes.$id visibilityNote = '' added = @(); removed = @(); modified = @() findingChanges = @(); findingNotices = @(); unchangedCount = 0 identityWarning = $false } # 4.3: compared iff present in BOTH sectionsRun - NEVER mass Added/Removed. if (-not ($inFrom -and $inTo)) { $rec.state = 'NotCompared' $side = '' if (-not $inFrom) { $side = 'snapshot A (-DeltaFrom)' } else { $side = 'snapshot B (-DeltaTo)' } $rec.reason = ("section {0} was not run in {1}, so it cannot be compared." -f $id, $side) $sections.Add([pscustomobject]$rec) continue } $sectionTypes = @($typeDefs | Where-Object { [string]$_.Value.section -eq $id }) # Visibility precedence: either side degraded -> suppress object-level diff. $fo = $rec.fromOutcome; $to2 = $rec.toOutcome if (($readable -notcontains $fo) -or ($readable -notcontains $to2)) { $rec.state = 'VisibilityChanged' if (($readable -contains $to2) -and ($readable -notcontains $fo)) { $n = 0 foreach ($t in $sectionTypes) { if ($To.objects.PSObject.Properties.Name -contains $t.Name) { $n += @($To.objects.$($t.Name)).Count } } $rec.visibilityNote = ("{0} -> {1}: {2} object(s) now observable." -f $fo, $to2, $n) } elseif (($readable -contains $fo) -and ($readable -notcontains $to2)) { $rec.visibilityNote = ("{0} -> {1}: object-level comparison suppressed until visibility returns." -f $fo, $to2) } elseif ($fo -ceq $to2) { # C-fix 3: equal degraded outcomes must never imply a change. $rec.visibilityNote = ("visibility unchanged - not readable on either side ({0})" -f $fo) } else { $rec.visibilityNote = ("{0} -> {1}: visibility degraded on both sides; nothing object-level can be asserted." -f $fo, $to2) } } else { $rec.state = 'Compared' $added = New-Object System.Collections.Generic.List[object] $removed = New-Object System.Collections.Generic.List[object] $modified = New-Object System.Collections.Generic.List[object] $unchanged = 0 foreach ($t in $sectionTypes) { $typeName = $t.Name $declared = @($t.Value.arrays | ForEach-Object { [string]$_ }) $deny = @($globalDeny) if ($denyFile.perType.PSObject.Properties.Name -contains $typeName) { $deny += @($denyFile.perType.$typeName | ForEach-Object { [string]$_ }) } $sig = @() if ($sigMap.PSObject.Properties.Name -contains $typeName) { $sig = @($sigMap.$typeName | ForEach-Object { [string]$_ }) } $fromArr = @(); $toArr = @() if ($From.objects.PSObject.Properties.Name -contains $typeName) { $fromArr = @($From.objects.$typeName) } if ($To.objects.PSObject.Properties.Name -contains $typeName) { $toArr = @($To.objects.$typeName) } $fromMap = New-Object 'System.Collections.Generic.Dictionary[string,object]' ([System.StringComparer]::Ordinal) foreach ($o in $fromArr) { $fromMap[[string]$o._key] = $o } $toMap = New-Object 'System.Collections.Generic.Dictionary[string,object]' ([System.StringComparer]::Ordinal) foreach ($o in $toArr) { $toMap[[string]$o._key] = $o } $removedCand = New-Object System.Collections.Generic.List[object] $addedCand = New-Object System.Collections.Generic.List[object] foreach ($k in $fromMap.Keys) { if ($toMap.ContainsKey($k)) { $fObj = $fromMap[$k]; $tObj = $toMap[$k] $changes = @(Compare-PpaSnapshotObject -FromObj $fObj -ToObj $tObj -DeclaredArrays $declared -Denylist $deny -Significant $sig) if ($changes.Count -eq 0) { $unchanged++ } else { $m = [ordered]@{ type = $typeName; key = $k; name = [string]$tObj.name renamed = $false; renameFrom = ''; renameTo = '' changes = $changes } $nameChange = @($changes | Where-Object { $_.property -eq 'name' }) if ($nameChange.Count -gt 0) { $m.renamed = $true; $m.renameFrom = [string]$fObj.name; $m.renameTo = [string]$tObj.name } $modified.Add([pscustomobject]$m) } } else { $removedCand.Add($fromMap[$k]) } } foreach ($k in $toMap.Keys) { if (-not $fromMap.ContainsKey($k)) { $addedCand.Add($toMap[$k]) } } # GUID-equality reconciliation (spec 4.3 + A.5 addendum): pair # key-divergent objects by NON-EMPTY ordinal string equality on guid, # so renames never appear as Removed+Added. $addedByGuid = New-Object 'System.Collections.Generic.Dictionary[string,object]' ([System.StringComparer]::Ordinal) foreach ($o in $addedCand) { $g = [string]$o.guid if (-not [string]::IsNullOrEmpty($g) -and -not $addedByGuid.ContainsKey($g)) { $addedByGuid[$g] = $o } } $stillRemoved = New-Object System.Collections.Generic.List[object] foreach ($o in $removedCand) { $g = [string]$o.guid if (-not [string]::IsNullOrEmpty($g) -and $addedByGuid.ContainsKey($g)) { $pair = $addedByGuid[$g] [void]$addedByGuid.Remove($g) $addedCand.Remove($pair) | Out-Null $changes = @(Compare-PpaSnapshotObject -FromObj $o -ToObj $pair -DeclaredArrays $declared -Denylist $deny -Significant $sig) $modified.Add([pscustomobject][ordered]@{ type = $typeName; key = [string]$pair._key; name = [string]$pair.name renamed = $true; renameFrom = [string]$o.name; renameTo = [string]$pair.name changes = $changes }) } else { $stillRemoved.Add($o) } } foreach ($o in $stillRemoved) { $removed.Add([pscustomobject]@{ type = $typeName; key = [string]$o._key; name = [string]$o.name }) } foreach ($o in $addedCand) { $added.Add([pscustomobject]@{ type = $typeName; key = [string]$o._key; name = [string]$o.name }) } } $rec.added = $added.ToArray() $rec.removed = $removed.ToArray() $rec.modified = $modified.ToArray() $rec.unchangedCount = $unchanged # Identity-failure heuristic (4.4): everything moved, nothing matched. $rec.identityWarning = (($added.Count -gt 0) -and ($removed.Count -gt 0) -and ($modified.Count -eq 0) -and ($unchanged -eq 0)) } # FindingChanged: compared for every section run on both sides (only the # OBJECT-level diff is visibility-suppressed). Severity is reserved: while # null on both sides it is ignored entirely and never rendered. $fFind = @($From.findings | Where-Object { [string]$_.section -eq $id }) $tFind = @($To.findings | Where-Object { [string]$_.section -eq $id }) $fc = New-Object System.Collections.Generic.List[object] $fn = New-Object System.Collections.Generic.List[object] # C-fix 2: a check present in only one snapshot is never silent - it is a # NotCompared-flavored notice by checkId (no new taxonomy member). The # missing side is labeled older/newer by capture time (from is older when # the span is non-negative). $olderLabel = 'older'; $newerLabel = 'newer' if ($spanDays -lt 0) { $olderLabel = 'newer'; $newerLabel = 'older' } foreach ($ff in $fFind) { if (@($tFind | Where-Object { [string]$_.checkId -eq [string]$ff.checkId }).Count -eq 0) { $fn.Add([pscustomobject]@{ checkId = [string]$ff.checkId; reason = ("check not present in the {0} snapshot - likely tool version difference" -f $newerLabel) }) } } foreach ($tf2 in $tFind) { if (@($fFind | Where-Object { [string]$_.checkId -eq [string]$tf2.checkId }).Count -eq 0) { $fn.Add([pscustomobject]@{ checkId = [string]$tf2.checkId; reason = ("check not present in the {0} snapshot - likely tool version difference" -f $olderLabel) }) } } foreach ($ff in $fFind) { $tf = @($tFind | Where-Object { [string]$_.checkId -eq [string]$ff.checkId }) if ($tf.Count -eq 0) { continue } $tf = $tf[0] $statusChanged = ([string]$ff.status -cne [string]$tf.status) $sevChanged = $false if ($null -ne $ff.severity -or $null -ne $tf.severity) { $sevChanged = ([string]$ff.severity -cne [string]$tf.severity) } if ($statusChanged -or $sevChanged) { $r = [ordered]@{ checkId = [string]$ff.checkId; title = [string]$tf.title fromStatus = [string]$ff.status; toStatus = [string]$tf.status } if ($sevChanged) { $r.fromSeverity = [string]$ff.severity; $r.toSeverity = [string]$tf.severity } $fc.Add([pscustomobject]$r) } } $rec.findingChanges = $fc.ToArray() $rec.findingNotices = $fn.ToArray() $sections.Add([pscustomobject]$rec) } return [pscustomobject][ordered]@{ from = [pscustomobject][ordered]@{ snapshotId = [string]$From.snapshotId; capturedAt = [string]$From.capturedAt tenantId = $From.tenantId; toolVersion = [string]$From.toolVersion denylistVersion = [string]$From.denylistVersion } to = [pscustomobject][ordered]@{ snapshotId = [string]$To.snapshotId; capturedAt = [string]$To.capturedAt tenantId = $To.tenantId; toolVersion = [string]$To.toolVersion denylistVersion = [string]$To.denylistVersion } spanDays = $spanDays denylistNote = $denylistNote sections = $sections.ToArray() } } |