#Requires -Version 5.1 [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $LegacyRuntimeSourceRoot, [Parameter()] [string] $OutputDirectory ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $manifestFileName = 'LegacyRuntimeBundle.manifest.json' $archiveFileName = 'LegacyRuntimeBundle.zip' $archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256' $expectedResAssetCount = 34 function Get-NormalizedFullPath { param( [Parameter(Mandatory = $true)] [string] $Path ) return [IO.Path]::GetFullPath($Path) } function Test-PathIsWithin { param( [Parameter(Mandatory = $true)] [string] $Root, [Parameter(Mandatory = $true)] [string] $Candidate ) $normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $normalizedCandidate = Get-NormalizedFullPath -Path $Candidate $prefix = $normalizedRoot + [IO.Path]::DirectorySeparatorChar return $normalizedCandidate.StartsWith( $prefix, [StringComparison]::OrdinalIgnoreCase) } function Assert-NoReparsePointInExistingAncestry { param( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [string] $Description ) $current = Get-NormalizedFullPath -Path $Path while (-not [string]::IsNullOrEmpty($current)) { if (Test-Path -LiteralPath $current) { $attributes = [IO.File]::GetAttributes($current) if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$Description traverses a reparse point: $current" } } $trimmed = $current.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $parent = [IO.Path]::GetDirectoryName($trimmed) if ([string]::IsNullOrEmpty($parent) -or $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) { break } $current = $parent } } function Assert-SafePayloadRelativePath { param( [Parameter(Mandatory = $true)] [string] $RelativePath ) $portablePath = $RelativePath.Replace('\', '/') if (-not $portablePath.Equals( $portablePath.Normalize([Text.NormalizationForm]::FormC), [StringComparison]::Ordinal)) { throw "Runtime payload path must use Unicode NFC normalization: $RelativePath" } $segments = @($portablePath.Split('/')) $unsafeSegments = @($segments | Where-Object { [string]::IsNullOrWhiteSpace($_) -or $_ -eq '.' -or $_ -eq '..' }) if ($segments.Count -lt 2 -or $unsafeSegments.Count -gt 0) { throw "Unsafe runtime payload path: $RelativePath" } $invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars() foreach ($segment in $segments) { if ($segment.EndsWith(' ', [StringComparison]::Ordinal) -or $segment.EndsWith('.', [StringComparison]::Ordinal) -or $segment.IndexOfAny($invalidFileNameCharacters) -ge 0) { throw "Runtime payload path is not a canonical Windows path: $RelativePath" } $deviceStem = $segment.Split('.')[0] if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') { throw "Runtime payload uses a reserved Windows file name: $RelativePath" } if ($segment -match '(?i)^(backup|backups|database|databases|license|licenses|cert|certs|credential|credentials|secret|secrets|vendor)$') { throw "Forbidden runtime payload directory or file name: $RelativePath" } } $leafName = $segments[$segments.Count - 1] if (-not $leafName.Equals('MmoneyCoder.ico', [StringComparison]::OrdinalIgnoreCase) -and $leafName -match '(?i)^MmoneyCoder(?:$|[ ._-])') { throw "Credential-bearing or archived MmoneyCoder material is forbidden: $RelativePath" } if ($leafName.Equals('afiedt.buf.txt', [StringComparison]::OrdinalIgnoreCase) -or $leafName -match '(?i)(?:\.bak|\.backup|\.old|\.orig|\.save|\.tmp|\.temp|~)$' -or $leafName -match '(?i)(?:^|[ ._-])(backup|copy|\xBCF5\xC0AC\xBCF8)(?:[ ._-]|$)') { throw "Backup or temporary material is forbidden: $RelativePath" } $extension = [IO.Path]::GetExtension($leafName) $forbiddenExtensions = @( '.dll', '.ocx', '.tlb', '.lic', '.license', '.pfx', '.p12', '.snk', '.cer', '.crt', '.key', '.pem', '.p7b', '.p7c', '.db', '.sqlite', '.sqlite3', '.mdb', '.accdb', '.mdf', '.ldf', '.sql', '.zip', '.7z', '.rar', '.exe', '.com', '.msi' ) if ($forbiddenExtensions -icontains $extension) { throw "Forbidden database, vendor, license, certificate, archive, or executable file: $RelativePath" } if ($leafName -match '(?i)^(database|credentials?|secrets?|connectionstrings?)(?:[._-]|$)') { throw "Credential or database configuration is forbidden: $RelativePath" } } function Get-LegacyPackagedResAssetNames { param( [Parameter(Mandatory = $true)] [string] $ProjectPath ) if (-not (Test-Path -LiteralPath $ProjectPath -PathType Leaf)) { throw "Legacy parity project was not found: $ProjectPath" } [xml] $projectXml = Get-Content -LiteralPath $ProjectPath -Raw -Encoding UTF8 $nodes = @($projectXml.SelectNodes( "/*[local-name()='Project']/*[local-name()='ItemGroup']/*[local-name()='LegacyPackagedResAsset']")) if ($nodes.Count -ne $expectedResAssetCount) { throw ("The project must declare exactly {0} LegacyPackagedResAsset items; found {1}." -f $expectedResAssetCount, $nodes.Count) } $prefix = '$(LegacyResSourceRoot)\' $names = New-Object 'System.Collections.Generic.List[string]' $seen = New-Object 'System.Collections.Generic.HashSet[string]' ( [StringComparer]::OrdinalIgnoreCase) foreach ($node in $nodes) { $include = [string] $node.Include if (-not $include.StartsWith($prefix, [StringComparison]::Ordinal)) { throw "LegacyPackagedResAsset is outside the closed Res projection: $include" } $name = $include.Substring($prefix.Length) if ([string]::IsNullOrWhiteSpace($name) -or $name.IndexOfAny(@( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)) -ge 0 -or $name -eq '.' -or $name -eq '..' -or [IO.Path]::GetFileName($name) -ne $name) { throw "LegacyPackagedResAsset must be a single Res file name: $include" } Assert-SafePayloadRelativePath -RelativePath ('Res/' + $name) if (-not $seen.Add($name)) { throw "Duplicate LegacyPackagedResAsset declaration: $name" } $names.Add($name) } return @($names.ToArray() | Sort-Object) } function Get-SafeTreeInventory { param( [Parameter(Mandatory = $true)] [string] $Root ) $rootPath = Get-NormalizedFullPath -Path $Root Assert-NoReparsePointInExistingAncestry -Path $rootPath -Description 'Runtime asset tree' $directories = New-Object 'System.Collections.Generic.List[string]' $files = New-Object 'System.Collections.Generic.List[string]' $pending = New-Object 'System.Collections.Generic.Queue[string]' $pending.Enqueue($rootPath) while ($pending.Count -gt 0) { $directory = $pending.Dequeue() $children = @(Get-ChildItem -LiteralPath $directory -Force | Sort-Object Name) foreach ($child in $children) { if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Runtime asset tree contains a reparse point: $($child.FullName)" } if ($child.PSIsContainer) { $directories.Add($child.FullName) $pending.Enqueue($child.FullName) } else { $files.Add($child.FullName) } } } return [pscustomobject]@{ Directories = @($directories.ToArray()) Files = @($files.ToArray()) } } function Get-RelativeChildPath { param( [Parameter(Mandatory = $true)] [string] $Root, [Parameter(Mandatory = $true)] [string] $Child ) $rootPath = (Get-NormalizedFullPath -Path $Root).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $childPath = Get-NormalizedFullPath -Path $Child if (-not (Test-PathIsWithin -Root $rootPath -Candidate $childPath)) { throw "Path is outside its expected root: $childPath" } return $childPath.Substring($rootPath.Length + 1) } function Get-GitRepositoryAncestor { param( [Parameter(Mandatory = $true)] [string] $Path ) $current = Get-NormalizedFullPath -Path $Path while (-not [string]::IsNullOrEmpty($current)) { if (Test-Path -LiteralPath (Join-Path $current '.git')) { return $current } $trimmed = $current.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) $parent = [IO.Path]::GetDirectoryName($trimmed) if ([string]::IsNullOrEmpty($parent) -or $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) { break } $current = $parent } return $null } function Copy-VerifiedPayloadFile { param( [Parameter(Mandatory = $true)] [string] $Source, [Parameter(Mandatory = $true)] [string] $Destination, [Parameter(Mandatory = $true)] [string] $ManifestPath ) if (([IO.File]::GetAttributes($Source) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Runtime payload file is a reparse point: $Source" } $destinationParent = [IO.Path]::GetDirectoryName($Destination) if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) { [IO.Directory]::CreateDirectory($destinationParent) | Out-Null } [IO.File]::Copy($Source, $Destination, $false) $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash.ToUpperInvariant() $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToUpperInvariant() if (-not $sourceHash.Equals($destinationHash, [StringComparison]::Ordinal)) { throw "Runtime payload changed while it was copied: $ManifestPath" } $destinationFile = Get-Item -LiteralPath $Destination -Force return [ordered]@{ path = $ManifestPath.Replace('\', '/') length = [long] $destinationFile.Length sha256 = $destinationHash } } $repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..') $projectPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj' $sourceRoot = Get-NormalizedFullPath -Path $LegacyRuntimeSourceRoot $cutsRoot = Join-Path $sourceRoot 'Cuts' $resRoot = Join-Path $sourceRoot 'Res' if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) { throw "Legacy runtime source root was not found: $sourceRoot" } if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container)) { throw "Legacy Cuts source directory was not found: $cutsRoot" } if (-not (Test-Path -LiteralPath $resRoot -PathType Container)) { throw "Legacy Res source directory was not found: $resRoot" } Assert-NoReparsePointInExistingAncestry -Path $sourceRoot -Description 'Legacy runtime source root' Assert-NoReparsePointInExistingAncestry -Path $cutsRoot -Description 'Legacy Cuts source directory' Assert-NoReparsePointInExistingAncestry -Path $resRoot -Description 'Legacy Res source directory' $resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath) $cutsInventory = Get-SafeTreeInventory -Root $cutsRoot if (@($cutsInventory.Files).Count -eq 0) { throw "Legacy Cuts source directory contains no files: $cutsRoot" } if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $bundleLeaf = '{0}-{1}' -f (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'), ([Guid]::NewGuid().ToString('N')) $outputRoot = Join-Path $repositoryRoot ('artifacts\legacy-runtime-bundles\' + $bundleLeaf) } else { $outputRoot = Get-NormalizedFullPath -Path $OutputDirectory } $outputRoot = Get-NormalizedFullPath -Path $outputRoot $legacyRepositoryRoot = Get-GitRepositoryAncestor -Path $sourceRoot if ($outputRoot.Equals( ([IO.Path]::GetPathRoot($outputRoot)).TrimEnd( [IO.Path]::AltDirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) { throw "The output directory cannot be a filesystem root: $outputRoot" } if ((Test-PathIsWithin -Root $sourceRoot -Candidate $outputRoot) -or (Test-PathIsWithin -Root $outputRoot -Candidate $sourceRoot) -or $outputRoot.Equals($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'The bundle output directory must not overlap the read-only runtime source root.' } if (-not [string]::IsNullOrWhiteSpace($legacyRepositoryRoot) -and ($outputRoot.Equals( $legacyRepositoryRoot, [StringComparison]::OrdinalIgnoreCase) -or (Test-PathIsWithin ` -Root $legacyRepositoryRoot ` -Candidate $outputRoot))) { throw 'The bundle output directory must not be inside the read-only legacy repository.' } if (Test-Path -LiteralPath $outputRoot) { throw "The bundle output directory already exists; choose a new directory: $outputRoot" } $outputParent = [IO.Path]::GetDirectoryName($outputRoot) Assert-NoReparsePointInExistingAncestry -Path $outputParent -Description 'Bundle output parent' [IO.Directory]::CreateDirectory($outputRoot) | Out-Null Assert-NoReparsePointInExistingAncestry -Path $outputRoot -Description 'Bundle output directory' $stagingRoot = Join-Path $outputRoot ('.staging-' + [Guid]::NewGuid().ToString('N')) $stagingCreated = $false $operationSucceeded = $false try { [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null $stagingCreated = $true $stagingCutsRoot = Join-Path $stagingRoot 'Cuts' $stagingResRoot = Join-Path $stagingRoot 'Res' [IO.Directory]::CreateDirectory($stagingCutsRoot) | Out-Null [IO.Directory]::CreateDirectory($stagingResRoot) | Out-Null foreach ($sourceDirectory in @($cutsInventory.Directories)) { $relativeDirectory = Get-RelativeChildPath -Root $cutsRoot -Child $sourceDirectory Assert-SafePayloadRelativePath -RelativePath ('Cuts/' + $relativeDirectory.Replace('\', '/')) [IO.Directory]::CreateDirectory( (Join-Path $stagingCutsRoot $relativeDirectory)) | Out-Null } $manifestFiles = New-Object 'System.Collections.Generic.List[object]' foreach ($sourceFile in @($cutsInventory.Files | Sort-Object)) { $relativeFile = Get-RelativeChildPath -Root $cutsRoot -Child $sourceFile $portableManifestPath = 'Cuts/' + $relativeFile.Replace('\', '/') Assert-SafePayloadRelativePath -RelativePath $portableManifestPath $destinationFile = Join-Path $stagingCutsRoot $relativeFile $manifestFiles.Add((Copy-VerifiedPayloadFile ` -Source $sourceFile ` -Destination $destinationFile ` -ManifestPath $portableManifestPath)) } foreach ($resAssetName in $resAssetNames) { $sourceFile = Join-Path $resRoot $resAssetName if (-not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) { throw "Required packaged Res asset was not found: $sourceFile" } if (-not (Get-NormalizedFullPath -Path $sourceFile).StartsWith( ((Get-NormalizedFullPath -Path $resRoot).TrimEnd('\') + '\'), [StringComparison]::OrdinalIgnoreCase)) { throw "Packaged Res asset escaped the Res source directory: $resAssetName" } $portableManifestPath = 'Res/' + $resAssetName Assert-SafePayloadRelativePath -RelativePath $portableManifestPath $destinationFile = Join-Path $stagingResRoot $resAssetName $manifestFiles.Add((Copy-VerifiedPayloadFile ` -Source $sourceFile ` -Destination $destinationFile ` -ManifestPath $portableManifestPath)) } $sortedManifestFiles = @($manifestFiles.ToArray() | Sort-Object { $_.path }) $cutsFileCount = @($sortedManifestFiles | Where-Object { ([string] $_.path).StartsWith('Cuts/', [StringComparison]::Ordinal) }).Count $resFileCount = @($sortedManifestFiles | Where-Object { ([string] $_.path).StartsWith('Res/', [StringComparison]::Ordinal) }).Count if ($cutsFileCount -ne @($cutsInventory.Files).Count -or $resFileCount -ne $expectedResAssetCount) { throw 'The staged runtime payload does not match the closed Cuts/Res projection.' } $manifest = [ordered]@{ schemaVersion = 1 bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle' fileCount = [int] $sortedManifestFiles.Count cutsFileCount = [int] $cutsFileCount resFileCount = [int] $resFileCount files = $sortedManifestFiles } $stagedManifestPath = Join-Path $stagingRoot $manifestFileName $manifestJson = $manifest | ConvertTo-Json -Depth 6 [IO.File]::WriteAllText( $stagedManifestPath, ($manifestJson + [Environment]::NewLine), (New-Object Text.UTF8Encoding($false))) Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $archivePath = Join-Path $outputRoot $archiveFileName $archiveStream = New-Object IO.FileStream( $archivePath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) $archive = $null try { $archive = New-Object IO.Compression.ZipArchive( $archiveStream, [IO.Compression.ZipArchiveMode]::Create, $true) $stagedDirectories = @(Get-ChildItem ` -LiteralPath $stagingRoot ` -Directory ` -Recurse ` -Force | Sort-Object FullName) foreach ($stagedDirectory in $stagedDirectories) { $relativeDirectory = Get-RelativeChildPath ` -Root $stagingRoot ` -Child $stagedDirectory.FullName $entryName = $relativeDirectory.Replace('\', '/') + '/' $directoryEntry = $archive.CreateEntry($entryName) $directoryEntry.ExternalAttributes = [int] [IO.FileAttributes]::Directory } $stagedFiles = @(Get-ChildItem ` -LiteralPath $stagingRoot ` -File ` -Recurse ` -Force | Sort-Object FullName) foreach ($stagedFile in $stagedFiles) { $relativeFile = Get-RelativeChildPath ` -Root $stagingRoot ` -Child $stagedFile.FullName $entryName = $relativeFile.Replace('\', '/') $fileEntry = $archive.CreateEntry( $entryName, [IO.Compression.CompressionLevel]::Optimal) $sourceStream = [IO.File]::Open( $stagedFile.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $entryStream = $fileEntry.Open() try { $sourceStream.CopyTo($entryStream) } finally { $entryStream.Dispose() $sourceStream.Dispose() } } } finally { if ($null -ne $archive) { $archive.Dispose() } $archiveStream.Dispose() } $externalManifestPath = Join-Path $outputRoot $manifestFileName [IO.File]::Copy($stagedManifestPath, $externalManifestPath, $false) $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant() $archiveHashPath = Join-Path $outputRoot $archiveHashFileName [IO.File]::WriteAllText( $archiveHashPath, ($archiveHash + ' ' + $archiveFileName + [Environment]::NewLine), [Text.Encoding]::ASCII) $operationSucceeded = $true } finally { if ($stagingCreated -and (Test-Path -LiteralPath $stagingRoot)) { if (-not (Test-PathIsWithin -Root $outputRoot -Candidate $stagingRoot) -or -not ([IO.Path]::GetFileName($stagingRoot)).StartsWith( '.staging-', [StringComparison]::Ordinal)) { throw "Refusing to clean an unexpected staging path: $stagingRoot" } Get-SafeTreeInventory -Root $stagingRoot | Out-Null Remove-Item -LiteralPath $stagingRoot -Recurse -Force } if (-not $operationSucceeded -and (Test-Path -LiteralPath $outputRoot)) { $remainingItems = @(Get-ChildItem -LiteralPath $outputRoot -Force) if ($remainingItems.Count -eq 0) { Remove-Item -LiteralPath $outputRoot -Force } } } [pscustomobject]@{ OutputDirectory = $outputRoot ArchivePath = $archivePath ArchiveSha256 = $archiveHash ArchiveHashPath = $archiveHashPath ManifestPath = $externalManifestPath FileCount = [int] $sortedManifestFiles.Count CutsFileCount = [int] $cutsFileCount ResFileCount = [int] $resFileCount }