Private/Install-FoPluginBundle.ps1
|
function Invoke-FoPluginBundleDownload { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$DestinationFile, [Parameter(Mandatory)] [string]$Url, [string]$ExpectedSha256, [bool]$ShowProgress = $true ) $destDir = Split-Path -Parent $DestinationFile if ($destDir -and -not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } $fileName = Split-Path -Leaf $DestinationFile $cacheRoot = $env:FO_PLUGIN_BUNDLE_CACHE_DIR if ($cacheRoot -and $ExpectedSha256) { $cacheDir = Join-Path $cacheRoot $ExpectedSha256.ToLowerInvariant() $cachedPath = Join-Path $cacheDir $fileName if (Test-Path -LiteralPath $cachedPath) { Write-Verbose "Using cached bundle archive: $cachedPath" if ($ShowProgress) { Write-Host "Using cached $fileName ..." } Copy-Item -LiteralPath $cachedPath -Destination $DestinationFile -Force return } } $partFile = "$DestinationFile.part" if (Test-Path -LiteralPath $partFile) { Remove-Item -LiteralPath $partFile -Force -ErrorAction SilentlyContinue } $activity = 'Downloading plugin bundle' Write-Verbose "Downloading plugin bundle from $Url" if ($ShowProgress) { Write-Host "Downloading $fileName ..." } try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $request = [System.Net.HttpWebRequest]::Create($Url) $request.UserAgent = 'PS-FileOptimizer/1.0' $request.AllowAutoRedirect = $true $request.Timeout = 600000 $request.ReadWriteTimeout = 600000 $response = $request.GetResponse() try { $totalBytes = $response.ContentLength $inputStream = $response.GetResponseStream() $outputStream = [System.IO.File]::Open($partFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write) try { $buffer = New-Object byte[] 65536 $bytesRead = 0 $totalRead = 0L while (($bytesRead = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { $outputStream.Write($buffer, 0, $bytesRead) $totalRead += $bytesRead if ($ShowProgress) { if ($totalBytes -gt 0) { $pct = [math]::Min(100, [int](($totalRead * 100) / $totalBytes)) $status = '{0} / {1}' -f (Format-FoFileSize -Bytes $totalRead), (Format-FoFileSize -Bytes $totalBytes) Write-Progress -Activity $activity -Status $status -CurrentOperation $fileName -PercentComplete $pct } else { Write-Progress -Activity $activity -Status (Format-FoFileSize -Bytes $totalRead) -CurrentOperation $fileName } } } } finally { $outputStream.Dispose() $inputStream.Dispose() } } finally { $response.Close() } } catch { if (Test-Path -LiteralPath $partFile) { Remove-Item -LiteralPath $partFile -Force -ErrorAction SilentlyContinue } throw "Failed to download plugin bundle from '$Url'. $($_.Exception.Message)" } finally { if ($ShowProgress) { Write-Progress -Activity $activity -Completed } } Move-Item -LiteralPath $partFile -Destination $DestinationFile -Force if ($cacheRoot -and $ExpectedSha256) { $cacheDir = Join-Path $cacheRoot $ExpectedSha256.ToLowerInvariant() if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } $cachedPath = Join-Path $cacheDir $fileName if (-not (Test-Path -LiteralPath $cachedPath)) { Copy-Item -LiteralPath $DestinationFile -Destination $cachedPath -Force } } if ($ShowProgress) { $size = (Get-Item -LiteralPath $DestinationFile).Length Write-Host "Download complete ($(Format-FoFileSize -Bytes $size))." } } function Publish-FoPluginInstallStage { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$StageDir, [Parameter(Mandatory)] [string]$DestinationPath ) $prev = "$DestinationPath.fo-install-prev" if (Test-Path -LiteralPath $prev) { Remove-Item -LiteralPath $prev -Recurse -Force } $hadDest = Test-Path -LiteralPath $DestinationPath if ($hadDest) { Move-Item -LiteralPath $DestinationPath -Destination $prev -Force } try { Move-Item -LiteralPath $StageDir -Destination $DestinationPath -Force if (Test-Path -LiteralPath $prev) { Remove-Item -LiteralPath $prev -Recurse -Force -ErrorAction SilentlyContinue } } catch { if (-not (Test-Path -LiteralPath $DestinationPath) -and $hadDest -and (Test-Path -LiteralPath $prev)) { Move-Item -LiteralPath $prev -Destination $DestinationPath -Force } throw } } function Copy-FoPluginFilesFromBundle { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$SourcePluginDir, [Parameter(Mandatory)] [string]$DestinationPluginDir, [Parameter(Mandatory)] [string[]]$FileNames, [switch]$Force ) if (-not (Test-Path -LiteralPath $DestinationPluginDir)) { New-Item -ItemType Directory -Path $DestinationPluginDir -Force | Out-Null } $copied = [System.Collections.Generic.List[string]]::new() $skipped = [System.Collections.Generic.List[string]]::new() $missing = [System.Collections.Generic.List[string]]::new() foreach ($name in $FileNames) { $src = Get-ChildItem -LiteralPath $SourcePluginDir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq $name } | Select-Object -First 1 if (-not $src) { $missing.Add($name) continue } $destPath = Join-Path $DestinationPluginDir $src.Name if ((Test-Path -LiteralPath $destPath) -and -not $Force) { $skipped.Add($src.Name) continue } $target = $destPath if ($PSCmdlet.ShouldProcess($target, 'Copy plugin file from bundle')) { Copy-Item -LiteralPath $src.FullName -Destination $target -Force $copied.Add($src.Name) } } return [PSCustomObject]@{ Copied = @($copied) Skipped = @($skipped) Missing = @($missing) } } function Assert-FoPluginBundleArchiveSafe { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArchivePath, [Parameter(Mandatory)] [string]$ExtractRoot ) Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $destFull = [System.IO.Path]::GetFullPath($ExtractRoot) if (-not $destFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $destFull += [System.IO.Path]::DirectorySeparatorChar } $zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) try { foreach ($entry in $zip.Entries) { if ([string]::IsNullOrWhiteSpace($entry.FullName)) { continue } $normalized = $entry.FullName -replace '/', '\' if ($normalized -match '(^|[\\/])\.\.([\\/]|$)') { throw "Unsafe zip entry path: $($entry.FullName)" } $candidate = [System.IO.Path]::GetFullPath((Join-Path $ExtractRoot $entry.FullName)) if (-not $candidate.StartsWith($destFull, [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe zip entry path: $($entry.FullName)" } } } finally { $zip.Dispose() } } function Assert-FoPluginBundleExtractSafe { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ExtractRoot ) $rootFull = [System.IO.Path]::GetFullPath($ExtractRoot) if (-not $rootFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $rootFull += [System.IO.Path]::DirectorySeparatorChar } foreach ($item in (Get-ChildItem -LiteralPath $ExtractRoot -Recurse -Force -ErrorAction SilentlyContinue)) { $full = [System.IO.Path]::GetFullPath($item.FullName) if (-not $full.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe plugin bundle extract path escapes destination: $($item.FullName)" } } } function Install-FoPluginBundleCore { [CmdletBinding(SupportsShouldProcess)] param( [ValidateSet('FullPortable', 'Missing', 'Remove')] [string]$Mode = 'FullPortable', [ValidateSet('Auto', '32', '64')] [string]$Architecture = 'Auto', [string]$DestinationPath, [string]$ArchiveUrl, [string]$ArchiveSha256, [string]$TempDirectory, [switch]$Force, [switch]$AllowUnverifiedDownload, [bool]$ShowProgress = $true ) $resolvedArch = Resolve-FoPluginBundleArchitecture -Architecture $Architecture $folderName = Get-FoPluginBundleFolderName -Architecture $resolvedArch if ($Mode -eq 'Remove') { $removed = Remove-FoInstalledPluginArchitectures -Scope All return [PSCustomObject]@{ Mode = $Mode Architecture = $resolvedArch DestinationPath = $null Downloaded = $false Extracted = $false ExecutablesNeeded = @() FilesCopied = @() FilesSkipped = @() FilesMissing = @() RemovedPaths = $removed Message = if ($removed.Count) { "Removed $($removed.Count) plugin folder(s): $($removed -join '; ')" } else { 'No plugin folders found to remove.' } } } $dest = if ($DestinationPath) { [System.IO.Path]::GetFullPath($DestinationPath) } else { Join-Path $script:FoModuleRoot $folderName } $null = Remove-FoInstalledPluginArchitectures -Scope $resolvedArch -ExcludeFolderNames @($folderName) $bundle = Get-FoPluginBundleSettings -Architecture $Architecture -ArchiveUrl $ArchiveUrl -ArchiveSha256 $ArchiveSha256 ` -AllowUnverifiedDownload:$AllowUnverifiedDownload $url = $bundle.Url $requiredExes = Get-FoRequiredPluginExecutables -Architecture $resolvedArch $exesToInstall = switch ($Mode) { 'FullPortable' { $requiredExes } 'Missing' { Get-FoMissingPluginExecutables -PluginPath $dest -RequiredExecutables $requiredExes } } if ($Mode -eq 'Missing' -and $exesToInstall.Count -eq 0) { return [PSCustomObject]@{ Mode = $Mode DestinationPath = $dest Downloaded = $false Extracted = $false ExecutablesNeeded = @() FilesCopied = @() FilesSkipped = @() FilesMissing = @() Message = 'All required plugin executables are already present.' } } $tempRoot = if ($TempDirectory) { [System.IO.Path]::GetFullPath($TempDirectory) } else { Join-Path ([System.IO.Path]::GetTempPath()) "FoPluginInstall_$(Get-Random)" } $archivePath = Join-Path $tempRoot $bundle.FileName $extractRoot = Join-Path $tempRoot 'extract' $downloaded = $false $extracted = $false $copyResult = $null try { if (-not (Test-Path -LiteralPath $tempRoot)) { New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null } if ($PSCmdlet.ShouldProcess($url, 'Download plugin bundle')) { Invoke-FoPluginBundleDownload -DestinationFile $archivePath -Url $url -ExpectedSha256 $bundle.Sha256 ` -ShowProgress:$ShowProgress Test-FoDownloadedFileSha256 -Path $archivePath -ExpectedSha256 $bundle.Sha256 ` -AllowUnverifiedDownload:$AllowUnverifiedDownload $downloaded = $true } else { return [PSCustomObject]@{ Mode = $Mode DestinationPath = $dest Downloaded = $false Extracted = $false ExecutablesNeeded = $exesToInstall FilesCopied = @() FilesSkipped = @() FilesMissing = @() Message = 'WhatIf: would download bundle and copy plugin files.' } } if ($PSCmdlet.ShouldProcess($archivePath, 'Extract plugin bundle')) { if ($bundle.Format -ne 'zip') { throw "Unsupported plugin bundle format '$($bundle.Format)'. Only zip is supported." } if (-not (Test-Path -LiteralPath $extractRoot)) { New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null } Assert-FoPluginBundleArchiveSafe -ArchivePath $archivePath -ExtractRoot $extractRoot Expand-Archive -LiteralPath $archivePath -DestinationPath $extractRoot -Force Assert-FoPluginBundleExtractSafe -ExtractRoot $extractRoot $extracted = $true } $sourcePlugins = Resolve-FoBundledPluginDirectory -ExtractRoot $extractRoot -ExpectedFolder $bundle.Folder $manifestPath = Find-FoPluginBundleManifestPath -ExtractRoot $extractRoot $manifest = $null $usingPinnedBundle = -not $ArchiveUrl -and -not $env:FO_PLUGIN_BUNDLE_URL if ($manifestPath) { $manifest = Import-FoPluginBundleManifest -Path $manifestPath $verify = Test-FoPluginBundleManifestFiles -Manifest $manifest -PluginDirectory $sourcePlugins if (-not $verify.Ok) { $parts = @() if ($verify.Missing.Count) { $parts += "missing: $($verify.Missing -join ', ')" } if ($verify.Mismatched.Count) { $parts += "hash mismatch: $($verify.Mismatched -join ', ')" } throw "Plugin bundle manifest verification failed ($($parts -join '; '))." } } elseif ($usingPinnedBundle) { throw "Plugin bundle is missing $(Get-FoPluginBundleManifestFileName). Re-download with Install-FoPlugins (bundle format 1.1.0+ required)." } else { Write-Warning "Plugin bundle has no $(Get-FoPluginBundleManifestFileName); installed plugins will not report a bundle version." } $filesToCopy = Get-FoPluginInstallFilePlan -Executables $exesToInstall -SourcePluginDir $sourcePlugins $stageDir = Join-Path $tempRoot 'stage-plugins' if (Test-Path -LiteralPath $dest) { if (Test-Path -LiteralPath $stageDir) { Remove-Item -LiteralPath $stageDir -Recurse -Force } Copy-Item -LiteralPath $dest -Destination $stageDir -Recurse -Force } elseif (-not (Test-Path -LiteralPath $stageDir)) { New-Item -ItemType Directory -Path $stageDir -Force | Out-Null } $copyResult = Copy-FoPluginFilesFromBundle ` -SourcePluginDir $sourcePlugins ` -DestinationPluginDir $stageDir ` -FileNames $filesToCopy ` -Force:$Force if ($copyResult.Missing.Count -gt 0) { throw "Plugin bundle is missing $($copyResult.Missing.Count) required file(s): $($copyResult.Missing -join ', ')" } if ($manifestPath) { Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $stageDir (Get-FoPluginBundleManifestFileName)) -Force } Publish-FoPluginInstallStage -StageDir $stageDir -DestinationPath $dest $stageDir = $null if ($copyResult.Copied.Count -gt 0 -or $manifestPath) { Clear-FoPluginResolveCache } return [PSCustomObject]@{ Mode = $Mode Architecture = $resolvedArch DestinationPath = $dest ArchiveUrl = $url ArchiveFormat = $bundle.Format BundleFolder = $bundle.Folder BundleVersion = if ($manifest) { [string]$manifest.BundleVersion } else { $null } Downloaded = $downloaded Extracted = $extracted ExecutablesNeeded = $exesToInstall FilesPlanned = $filesToCopy FilesCopied = $copyResult.Copied FilesSkipped = $copyResult.Skipped FilesMissing = $copyResult.Missing Message = if ($copyResult.Missing.Count) { "Copied $($copyResult.Copied.Count) file(s); $($copyResult.Missing.Count) not found in bundle." } else { "Copied $($copyResult.Copied.Count) file(s) to $dest." } } } finally { if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } } } |