Private/Expand-CabArchive.ps1

function Expand-CabArchive {
    [CmdletBinding(SupportsShouldProcess = $false)]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [System.String] $Path,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [System.String] $DestinationPath
    )

    if (-not (Test-IsWindows)) {
        throw "$($MyInvocation.MyCommand): Expand-CabArchive is only supported on Windows."
    }

    if (-not (Test-Path -LiteralPath $Path -PathType "Leaf")) {
        throw "Cannot find CAB file '$Path'."
    }
    $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)

    try {
        $DestinationPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DestinationPath)
        $DestinationPath = [System.IO.Path]::GetFullPath($DestinationPath)
    }
    catch {
        throw "Invalid destination path '$DestinationPath': $($_.Exception.Message)"
    }
    if (Test-Path -LiteralPath $DestinationPath -PathType "Leaf") {
        throw "Destination path '$DestinationPath' is a file; a directory path is required."
    }
    $DestinationParent = Split-Path -Path $DestinationPath -Parent
    if (-not (Test-Path -LiteralPath $DestinationParent -PathType "Container")) {
        throw "Cannot find destination parent directory '$DestinationParent'."
    }
    if (Test-Path -LiteralPath $DestinationPath -PathType "Container") {
        $DestinationItem = Get-Item -LiteralPath $DestinationPath -Force
        if ($DestinationItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
            throw "Destination directory '$DestinationPath' cannot be a reparse point."
        }
    }

    $StageRoot = [System.IO.Path]::Combine($DestinationPath, ".Expand-CabArchive-$([System.Guid]::NewGuid().ToString())")
    $ShellStage = [System.IO.Path]::Combine($StageRoot, "Shell")
    $FallbackStage = [System.IO.Path]::Combine($StageRoot, "Expand")
    $BackupStage = [System.IO.Path]::Combine($StageRoot, "Backup")
    $ExpectedFiles = @()
    $KeepStage = $false
    $DestinationCreated = $false
    $OperationSucceeded = $false
    $UnsafeCabEntryError = $null

    $NormalizeRelativePath = {
        param ([System.String] $Name)

        $RelativeName = $Name.Replace("/", "\")
        if ([System.IO.Path]::IsPathRooted($RelativeName) -or $RelativeName -match "^[A-Za-z]:") {
            throw "CAB item path '$Name' is rooted."
        }
        $Segments = @($RelativeName -split "\\")
        if ($Segments.Count -eq 0 -or @($Segments | Where-Object { [System.String]::IsNullOrWhiteSpace($_) -or $_ -eq "." -or $_ -eq ".." }).Count -gt 0) {
            throw "CAB item path '$Name' contains an invalid path segment."
        }
        $InvalidCharacters = [System.IO.Path]::GetInvalidFileNameChars()
        foreach ($Segment in $Segments) {
            if ($Segment.IndexOfAny($InvalidCharacters) -ge 0 -or
                $Segment -match '[<>:"/\\|?*\x00-\x1F]' -or
                $Segment -match '[ .]$' -or
                $Segment -match '^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)') {
                throw "CAB item path '$Name' contains an invalid file name."
            }
        }
        return ($Segments -join "\")
    }

    $GetMissingExpectedFiles = {
        param ($Expected, $Files, $Root)

        $FilePaths = New-Object -TypeName System.Collections.ArrayList
        foreach ($File in $Files) {
            $null = $FilePaths.Add($File.FullName.Substring($Root.Length).TrimStart([System.Char[]]@('\', '/')))
        }
        $Missing = @()
        foreach ($ExpectedFile in $Expected) {
            $MatchedIndex = -1
            for ($Index = 0; $Index -lt $FilePaths.Count; $Index++) {
                if ($ExpectedFile.RelativePath.Contains("\")) {
                    $MatchesExpected = $FilePaths[$Index] -ieq $ExpectedFile.RelativePath
                }
                else {
                    $MatchesExpected = [System.IO.Path]::GetFileName($FilePaths[$Index]) -ieq $ExpectedFile.RelativePath
                }
                if ($MatchesExpected) {
                    $MatchedIndex = $Index
                    break
                }
            }
            if ($MatchedIndex -lt 0) {
                $Missing += $ExpectedFile.RelativePath
            }
            else {
                $FilePaths.RemoveAt($MatchedIndex)
            }
        }
        return $Missing
    }

    try {
        if (-not (Test-Path -LiteralPath $DestinationPath -PathType "Container")) {
            $null = [System.IO.Directory]::CreateDirectory($DestinationPath)
            $DestinationCreated = $true
        }
        $ShellSucceeded = $false
        $StagePath = $null

        try {
            $Shell = New-Object -ComObject "Shell.Application"
            $SourceCab = $Shell.NameSpace($Path)
            if ($null -eq $SourceCab) {
                throw "Shell.Application could not open CAB file '$Path'."
            }
            $CabItems = @($SourceCab.Items())
            $ExpectedFiles = @()
            foreach ($CabItem in $CabItems) {
                if ($null -eq $CabItem -or $CabItem.IsFolder) {
                    continue
                }
                $ItemName = [System.String]$CabItem.Name
                if ([System.String]::IsNullOrWhiteSpace($ItemName)) {
                    continue
                }
                try {
                    $RelativePath = & $NormalizeRelativePath $ItemName
                }
                catch {
                    $UnsafeCabEntryError = $_.Exception.Message
                    throw
                }
                $ExpectedFiles += [PSCustomObject]@{ RelativePath = $RelativePath }
            }
            if ($ExpectedFiles.Count -eq 0) {
                throw "Shell.Application found no files in CAB file '$Path'."
            }

            foreach ($File in $ExpectedFiles) {
                Write-Verbose -Message "$($MyInvocation.MyCommand): CAB file contains: '$($File.RelativePath)'."
            }

            $null = [System.IO.Directory]::CreateDirectory($ShellStage)
            $DestinationFolder = $Shell.NameSpace($ShellStage)
            if ($null -eq $DestinationFolder) {
                throw "Shell.Application could not open staging directory '$ShellStage'."
            }

            Write-Verbose -Message "$($MyInvocation.MyCommand): Expanding CAB file '$Path' to staging directory '$ShellStage'."
            $DestinationFolder.CopyHere($SourceCab.Items(), 0x1014)

            $Timer = [System.Diagnostics.Stopwatch]::StartNew()
            $PreviousSnapshot = $null
            $StableSnapshots = 0
            $MissingFiles = @($ExpectedFiles | ForEach-Object { $_.RelativePath })
            do {
                $ShellFiles = @(Get-ChildItem -LiteralPath $ShellStage -File -Recurse -Force -ErrorAction "Stop")
                $MissingFiles = @(& $GetMissingExpectedFiles $ExpectedFiles $ShellFiles $ShellStage)
                if ($MissingFiles.Count -eq 0) {
                    $Snapshot = @(
                        $ShellFiles |
                            ForEach-Object {
                                "$($_.FullName.Substring($ShellStage.Length).TrimStart([System.Char[]]@('\', '/'))):$($_.Length)"
                            } |
                            Sort-Object
                    ) -join "|"
                    if ($Snapshot -eq $PreviousSnapshot) {
                        $StableSnapshots++
                    }
                    else {
                        $PreviousSnapshot = $Snapshot
                        $StableSnapshots = 0
                    }
                    if ($StableSnapshots -ge 3) {
                        break
                    }
                }
                else {
                    $StableSnapshots = 0
                }
                Start-Sleep -Milliseconds 500
            } while ($Timer.Elapsed.TotalSeconds -lt 120)
            $Timer.Stop()

            if ($MissingFiles.Count -gt 0 -or $StableSnapshots -lt 3) {
                throw "Shell.Application.CopyHere did not finish extracting all CAB files before the timeout. Missing: $($MissingFiles -join ', ')."
            }

            $ShellSucceeded = $true
            $StagePath = $ShellStage
        }
        catch {
            Write-Verbose -Message "$($MyInvocation.MyCommand): $($_.Exception.Message)"
            Write-Verbose -Message "$($MyInvocation.MyCommand): Falling back to expand.exe."
            $ShellError = $_.Exception.Message
        }

        if ($null -ne $UnsafeCabEntryError) {
            throw "$($MyInvocation.MyCommand): Refusing to extract unsafe CAB entry: $UnsafeCabEntryError"
        }

        if (-not $ShellSucceeded) {
            $ExpandExe = Join-Path -Path $Env:SystemRoot -ChildPath "System32\expand.exe"
            if (-not (Test-Path -LiteralPath $ExpandExe -PathType "Leaf")) {
                throw "$($MyInvocation.MyCommand): Shell extraction failed: $ShellError expand.exe was not found at '$ExpandExe'."
            }

            try {
                if ($ExpectedFiles.Count -eq 0) {
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Listing CAB contents with expand.exe to verify fallback completeness."
                    $LASTEXITCODE = 0
                    $CabListing = @(& $ExpandExe -D $Path 2>&1 | ForEach-Object { $_.ToString() })
                    if ($LASTEXITCODE -ne 0) {
                        throw "expand.exe could not list CAB contents (exit code $LASTEXITCODE)."
                    }
                    foreach ($ListingLine in $CabListing) {
                        if ($ListingLine -match '^\s{3,}(.+?)\s*$') {
                            $RelativePath = & $NormalizeRelativePath $Matches[1]
                            $ExpectedFiles += [PSCustomObject]@{ RelativePath = $RelativePath }
                        }
                    }
                    if ($ExpectedFiles.Count -eq 0) {
                        throw "expand.exe did not return a verifiable CAB file list."
                    }
                }

                $null = [System.IO.Directory]::CreateDirectory($FallbackStage)
                Write-Verbose -Message "$($MyInvocation.MyCommand): Expanding CAB file '$Path' with expand.exe to staging directory '$FallbackStage'."
                $LASTEXITCODE = 0
                & $ExpandExe $Path $FallbackStage | Out-Null
                if ($LASTEXITCODE -ne 0) {
                    throw "expand.exe exited with code $LASTEXITCODE."
                }
            }
            catch {
                throw "$($MyInvocation.MyCommand): Shell extraction failed: $ShellError expand.exe fallback failed: $($_.Exception.Message)"
            }

            $ExtractedFiles = @(Get-ChildItem -LiteralPath $FallbackStage -File -Recurse -Force -ErrorAction "Stop")
            $MissingFiles = @(& $GetMissingExpectedFiles $ExpectedFiles $ExtractedFiles $FallbackStage)
            if ($MissingFiles.Count -gt 0) {
                throw "$($MyInvocation.MyCommand): expand.exe did not extract all CAB files. Missing: $($MissingFiles -join ', ')."
            }
            $StagePath = $FallbackStage
        }

        $StageEntries = @(Get-ChildItem -LiteralPath $StagePath -Force -Recurse -ErrorAction "Stop" | Sort-Object -Property "FullName")
        $ReparsePoints = @($StageEntries | Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint })
        if ($ReparsePoints.Count -gt 0) {
            throw "$($MyInvocation.MyCommand): Extraction produced reparse points, which are not safe to promote."
        }
        $ExtractedFiles = @($StageEntries | Where-Object { -not $_.PSIsContainer })
        if ($ExtractedFiles.Count -eq 0) {
            throw "$($MyInvocation.MyCommand): Extraction produced no files."
        }

        $Promotions = @()
        $SeenTargets = @{}
        foreach ($File in $ExtractedFiles) {
            $RelativePath = $File.FullName.Substring($StagePath.Length).TrimStart([System.Char[]]@('\', '/'))
            $RelativePath = & $NormalizeRelativePath $RelativePath
            $DestinationFile = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($DestinationPath, $RelativePath))
            $DestinationPrefix = $DestinationPath.TrimEnd([System.Char[]]@('\', '/')) + [System.IO.Path]::DirectorySeparatorChar
            if (-not $DestinationFile.StartsWith($DestinationPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "$($MyInvocation.MyCommand): Extracted path '$RelativePath' resolves outside destination '$DestinationPath'."
            }
            if ($SeenTargets.ContainsKey($DestinationFile)) {
                throw "$($MyInvocation.MyCommand): Multiple CAB entries resolve to '$DestinationFile'."
            }
            $SeenTargets[$DestinationFile] = $true

            $Promotions += [PSCustomObject]@{
                Source = $File.FullName
                Target = $DestinationFile
                RelativePath = $RelativePath
                Backup = [System.IO.Path]::Combine($BackupStage, $RelativePath)
                HadBackup = $false
                Installed = $false
            }
        }

        $CreatedDirectories = @()
        $PromotionRecords = @()
        try {
            foreach ($Promotion in $Promotions) {
                $Parent = [System.IO.Path]::GetDirectoryName($Promotion.Target)
                $MissingParents = @()
                $CurrentParent = $Parent
                while ($CurrentParent.StartsWith($DestinationPath, [System.StringComparison]::OrdinalIgnoreCase) -and
                    -not (Test-Path -LiteralPath $CurrentParent -PathType "Container")) {
                    $MissingParents += $CurrentParent
                    $CurrentParent = [System.IO.Path]::GetDirectoryName($CurrentParent)
                }
                [array]::Reverse($MissingParents)
                foreach ($Directory in $MissingParents) {
                    if (Test-Path -LiteralPath $Directory -PathType "Leaf") {
                        throw "Cannot create destination directory '$Directory' because a file already exists there."
                    }
                    $null = [System.IO.Directory]::CreateDirectory($Directory)
                    $CreatedDirectories += $Directory
                }

                $CurrentParent = $Parent
                while ($CurrentParent.StartsWith($DestinationPath, [System.StringComparison]::OrdinalIgnoreCase)) {
                    $DirectoryItem = Get-Item -LiteralPath $CurrentParent -Force -ErrorAction "Stop"
                    if ($DirectoryItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
                        throw "Destination path '$CurrentParent' contains a reparse point."
                    }
                    if ($CurrentParent -ieq $DestinationPath) {
                        break
                    }
                    $CurrentParent = [System.IO.Path]::GetDirectoryName($CurrentParent)
                }

                if (Test-Path -LiteralPath $Promotion.Target) {
                    if (-not (Test-Path -LiteralPath $Promotion.Target -PathType "Leaf")) {
                        throw "Cannot replace destination directory '$($Promotion.Target)' with an extracted file."
                    }
                    $null = [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($Promotion.Backup))
                }

                $Record = [PSCustomObject]@{
                    Promotion = $Promotion
                    HadBackup = $false
                    Installed = $false
                }
                $PromotionRecords += $Record
                if (Test-Path -LiteralPath $Promotion.Target -PathType "Leaf") {
                    Move-Item -LiteralPath $Promotion.Target -Destination $Promotion.Backup -ErrorAction "Stop"
                    $Record.HadBackup = $true
                }
                Move-Item -LiteralPath $Promotion.Source -Destination $Promotion.Target -ErrorAction "Stop"
                $Record.Installed = $true
                Write-Verbose -Message "$($MyInvocation.MyCommand): Expanded item '$($Promotion.Target)' to '$DestinationPath'."
            }
        }
        catch {
            $PromotionError = $_.Exception.Message
            $RollbackErrors = @()
            [array]::Reverse($PromotionRecords)
            foreach ($Record in $PromotionRecords) {
                try {
                    if ($Record.Installed -and (Test-Path -LiteralPath $Record.Promotion.Target)) {
                        Remove-Item -LiteralPath $Record.Promotion.Target -Force -ErrorAction "Stop"
                    }
                    if ($Record.HadBackup -and (Test-Path -LiteralPath $Record.Promotion.Backup -PathType "Leaf")) {
                        Move-Item -LiteralPath $Record.Promotion.Backup -Destination $Record.Promotion.Target -ErrorAction "Stop"
                    }
                }
                catch {
                    $RollbackErrors += $_.Exception.Message
                }
            }
            [array]::Reverse($CreatedDirectories)
            foreach ($Directory in $CreatedDirectories) {
                try {
                    Remove-Item -LiteralPath $Directory -Force -ErrorAction "Stop"
                }
                catch {
                    $RollbackErrors += $_.Exception.Message
                }
            }
            if ($RollbackErrors.Count -gt 0) {
                $KeepStage = $true
                throw "$($MyInvocation.MyCommand): Promotion failed: $PromotionError Rollback was incomplete: $($RollbackErrors -join '; '). Backups/staging retained at '$StageRoot'."
            }
            throw "$($MyInvocation.MyCommand): Promotion failed; changes were rolled back: $PromotionError"
        }

        $OperationSucceeded = $true
        return @($Promotions | ForEach-Object { $_.Target })
    }
    finally {
        if (-not $KeepStage) {
            Remove-Item -LiteralPath $StageRoot -Recurse -Force -ErrorAction "SilentlyContinue"
        }
        if (-not $OperationSucceeded -and $DestinationCreated -and
            (Test-Path -LiteralPath $DestinationPath -PathType "Container") -and
            [System.IO.Directory]::GetFileSystemEntries($DestinationPath).Length -eq 0) {
            [System.IO.Directory]::Delete($DestinationPath)
        }
    }
}