MSIX.Compare.ps1
|
# ============================================================================= # Compare-MsixPackage # ----------------------------------------------------------------------------- # Diffs two MSIX packages by: # - Identity / Properties / Capabilities / Dependencies (manifest) # - Application list (Id, Executable, Extensions categories) # - File list (added / removed / size-changed / hash-changed) # - Signing state (signer thumbprint + status) # # The diff is structured so it can be fed into a CI build to gate releases: # $diff.HasChanges # $diff.ManifestChanges # $diff.FileChanges # $diff.SigningChanges # ============================================================================= function _MsixUnpackForCompare { param([string]$PackagePath, [string]$Tag) $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item $PackagePath $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-$Tag" $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess $r 'MakeAppx unpack' return $workspace } function _ComparePackageManifest { param([xml]$LeftManifest, [xml]$RightManifest) $changes = New-Object System.Collections.Generic.List[object] function _Add { param([string]$Field, $Left, $Right) if ($Left -ne $Right) { $changes.Add([pscustomobject]@{ Field = $Field; Left = $Left; Right = $Right }) } } _Add -Field 'Identity.Name' -Left $LeftManifest.Package.Identity.Name -Right $RightManifest.Package.Identity.Name _Add -Field 'Identity.Publisher' -Left $LeftManifest.Package.Identity.Publisher -Right $RightManifest.Package.Identity.Publisher _Add -Field 'Identity.Version' -Left $LeftManifest.Package.Identity.Version -Right $RightManifest.Package.Identity.Version _Add -Field 'Identity.ProcessorArchitecture' -Left $LeftManifest.Package.Identity.ProcessorArchitecture -Right $RightManifest.Package.Identity.ProcessorArchitecture _Add -Field 'Properties.DisplayName' -Left $LeftManifest.Package.Properties.DisplayName -Right $RightManifest.Package.Properties.DisplayName _Add -Field 'Properties.PublisherDisplayName' -Left $LeftManifest.Package.Properties.PublisherDisplayName -Right $RightManifest.Package.Properties.PublisherDisplayName _Add -Field 'Dependencies.MinVersion' -Left $LeftManifest.Package.Dependencies.TargetDeviceFamily.MinVersion -Right $RightManifest.Package.Dependencies.TargetDeviceFamily.MinVersion _Add -Field 'Dependencies.MaxVersionTested' -Left $LeftManifest.Package.Dependencies.TargetDeviceFamily.MaxVersionTested -Right $RightManifest.Package.Dependencies.TargetDeviceFamily.MaxVersionTested # Capability set diff $leftCaps = @($LeftManifest.Package.Capabilities.Capability | ForEach-Object { $_.Name }) | Sort-Object $rightCaps = @($RightManifest.Package.Capabilities.Capability | ForEach-Object { $_.Name }) | Sort-Object if (($leftCaps -join '|') -ne ($rightCaps -join '|')) { $changes.Add([pscustomobject]@{ Field = 'Capabilities' Left = $leftCaps -join ', ' Right = $rightCaps -join ', ' }) } # Application diff (by Id) $leftApps = @($LeftManifest.Package.Applications.Application) | ForEach-Object { @{ Id=$_.Id; Exe=$_.Executable } } $rightApps = @($RightManifest.Package.Applications.Application) | ForEach-Object { @{ Id=$_.Id; Exe=$_.Executable } } foreach ($l in $leftApps) { $r = $rightApps | Where-Object { $_.Id -eq $l.Id } | Select-Object -First 1 if (-not $r) { $changes.Add([pscustomobject]@{ Field = "Application[$($l.Id)]"; Left = $l.Exe; Right = '<removed>' }) } elseif ($l.Exe -ne $r.Exe) { $changes.Add([pscustomobject]@{ Field = "Application[$($l.Id)].Executable"; Left = $l.Exe; Right = $r.Exe }) } } foreach ($r in $rightApps) { if (-not ($leftApps | Where-Object { $_.Id -eq $r.Id })) { $changes.Add([pscustomobject]@{ Field = "Application[$($r.Id)]"; Left = '<absent>'; Right = $r.Exe }) } } return ,$changes } function _CompareFileSets { param([string]$LeftRoot, [string]$RightRoot) $sha = [System.Security.Cryptography.SHA256]::Create() function _Snapshot([string]$root) { Get-ChildItem $root -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { $rel = $_.FullName.Substring($root.Length + 1) $hash = $null try { $stream = [IO.File]::OpenRead($_.FullName) try { $hash = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') } finally { $stream.Dispose() } } catch { Write-MsixLog Debug "Hash failed for $($_.FullName): $_" } [pscustomobject]@{ Rel = $rel Size = $_.Length Hash = $hash } } } $left = @(_Snapshot $LeftRoot) $right = @(_Snapshot $RightRoot) $leftIndex = @{}; foreach ($f in $left) { $leftIndex[$f.Rel] = $f } $rightIndex = @{}; foreach ($f in $right) { $rightIndex[$f.Rel] = $f } $diff = New-Object System.Collections.Generic.List[object] foreach ($k in $leftIndex.Keys) { if (-not $rightIndex.ContainsKey($k)) { $diff.Add([pscustomobject]@{ Path = $k; Status = 'Removed'; LeftSize = $leftIndex[$k].Size; RightSize = $null }) } else { $l = $leftIndex[$k]; $r = $rightIndex[$k] if ($l.Hash -ne $r.Hash) { $diff.Add([pscustomobject]@{ Path = $k Status = if ($l.Size -ne $r.Size) { 'Modified-Size' } else { 'Modified-Content' } LeftSize = $l.Size RightSize = $r.Size }) } } } foreach ($k in $rightIndex.Keys) { if (-not $leftIndex.ContainsKey($k)) { $diff.Add([pscustomobject]@{ Path = $k; Status = 'Added'; LeftSize = $null; RightSize = $rightIndex[$k].Size }) } } return ,$diff } function Compare-MsixPackage { <# .SYNOPSIS Diffs two .msix files. Returns a structured result with manifest / file / signing changes. .DESCRIPTION Unpacks both packages into temporary workspaces and compares: - Manifest Identity (Name, Publisher, Version, Architecture), Properties (DisplayName, PublisherDisplayName), Dependencies (MinVersion, MaxVersionTested), Capabilities, and the Application list (by Id and Executable). - Files Per relative path, computed as Added / Removed / Modified-Size / Modified-Content (SHA256). Paths matching -ExcludePathPattern are skipped (default list ignores AppxBlockMap, [Content_Types].xml, AppxSignature -- which churn on every build). - Signing Get-AuthenticodeSignature on both files; Status, Thumbprint, Subject are diffed. Workspaces are cleaned up afterwards. The output is designed to be consumed by CI gates: `if (-not $diff.HasChanges) { ... }`. .PARAMETER LeftPath The 'old' / baseline .msix file. .PARAMETER RightPath The 'new' / candidate .msix file. .PARAMETER ExcludePathPattern Regexes of file relative paths to ignore in the file diff. Default is '\\AppxBlockMap', '\\\[Content_Types\]', '\\AppxSignature' -- package metadata that changes on every rebuild and is rarely interesting. .OUTPUTS [pscustomobject] with: - LeftPath, RightPath the inputs. - HasChanges [bool] true if any of the three change-sets below is non-empty. - ManifestChanges list of @{ Field; Left; Right }. - FileChanges list of @{ Path; Status; LeftSize; RightSize } where Status is one of Added, Removed, Modified-Size, Modified-Content. - SigningChanges list of @{ Field; Left; Right } for Status, Thumbprint, Subject. .EXAMPLE $diff = Compare-MsixPackage -LeftPath old.msix -RightPath new.msix $diff.HasChanges $diff.ManifestChanges | Format-Table $diff.FileChanges | Format-Table .EXAMPLE # Skip auto-generated package metadata (this is also the default) Compare-MsixPackage -LeftPath a.msix -RightPath b.msix ` -ExcludePathPattern '\\AppxBlockMap','\\\[Content_Types\]','\\AppxSignature' .EXAMPLE # CI gate: fail the build when anything other than the version bumps $diff = Compare-MsixPackage -LeftPath baseline.msix -RightPath candidate.msix if ($diff.FileChanges.Count -gt 0 -or $diff.SigningChanges.Count -gt 0) { throw 'Unexpected change set' } #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$LeftPath, [Parameter(Mandatory)] [string]$RightPath, [string[]]$ExcludePathPattern = @('\\AppxBlockMap', '\\\[Content_Types\]', '\\AppxSignature') ) $leftWs = _MsixUnpackForCompare $LeftPath 'left' $rightWs = _MsixUnpackForCompare $RightPath 'right' try { $null = Test-MsixManifest "$leftWs\AppxManifest.xml" $null = Test-MsixManifest "$rightWs\AppxManifest.xml" [xml]$leftManifest = Get-MsixManifest "$leftWs\AppxManifest.xml" [xml]$rightManifest = Get-MsixManifest "$rightWs\AppxManifest.xml" $manifestChanges = _ComparePackageManifest -LeftManifest $leftManifest -RightManifest $rightManifest $fileChanges = _CompareFileSets -LeftRoot $leftWs -RightRoot $rightWs if ($ExcludePathPattern) { $fileChanges = $fileChanges | Where-Object { $row = $_ ($ExcludePathPattern | Where-Object { $row.Path -match $_ }).Count -eq 0 } } # Signing state $sigL = Get-AuthenticodeSignature -FilePath $LeftPath $sigR = Get-AuthenticodeSignature -FilePath $RightPath $signingChanges = New-Object System.Collections.Generic.List[object] if ($sigL.Status -ne $sigR.Status) { $signingChanges.Add([pscustomobject]@{ Field='Status'; Left=$sigL.Status; Right=$sigR.Status }) } if ($sigL.SignerCertificate.Thumbprint -ne $sigR.SignerCertificate.Thumbprint) { $signingChanges.Add([pscustomobject]@{ Field='Thumbprint'; Left=$sigL.SignerCertificate.Thumbprint; Right=$sigR.SignerCertificate.Thumbprint }) } if ($sigL.SignerCertificate.Subject -ne $sigR.SignerCertificate.Subject) { $signingChanges.Add([pscustomobject]@{ Field='Subject'; Left=$sigL.SignerCertificate.Subject; Right=$sigR.SignerCertificate.Subject }) } $hasChanges = ($manifestChanges.Count -gt 0) -or ($fileChanges.Count -gt 0) -or ($signingChanges.Count -gt 0) return [pscustomobject]@{ LeftPath = $LeftPath RightPath = $RightPath HasChanges = $hasChanges ManifestChanges = $manifestChanges FileChanges = $fileChanges SigningChanges = $signingChanges } } finally { Remove-Item $leftWs -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $rightWs -Recurse -Force -ErrorAction SilentlyContinue } } |