#Requires -Version 5.1 [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $ZipPath, [Parameter(Mandatory = $true)] [string] $ExpectedSha256, [Parameter()] [string] $RepositoryRoot ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $manifestFileName = 'LegacyRuntimeBundle.manifest.json' $expectedResAssetCount = 34 $maximumEntryCount = 10000 $maximumManifestLength = 4MB $maximumFileLength = 1GB $maximumPayloadLength = 2GB 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-NoReparsePointInTree { param( [Parameter(Mandatory = $true)] [string] $Root, [Parameter(Mandatory = $true)] [string] $Description ) Assert-NoReparsePointInExistingAncestry -Path $Root -Description $Description $pending = New-Object 'System.Collections.Generic.Queue[string]' $pending.Enqueue((Get-NormalizedFullPath -Path $Root)) while ($pending.Count -gt 0) { $directory = $pending.Dequeue() foreach ($child in @(Get-ChildItem -LiteralPath $directory -Force)) { if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$Description contains a reparse point: $($child.FullName)" } if ($child.PSIsContainer) { $pending.Enqueue($child.FullName) } } } } function Assert-SafePayloadRelativePath { param( [Parameter(Mandatory = $true)] [string] $RelativePath ) $portablePath = $RelativePath.Replace('\', '/') $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" } foreach ($segment in $segments) { 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 Assert-SafeArchiveEntryPath { param( [Parameter(Mandatory = $true)] [string] $EntryPath, [Parameter(Mandatory = $true)] [bool] $IsDirectory ) if ([string]::IsNullOrWhiteSpace($EntryPath) -or $EntryPath.IndexOf([char] 0) -ge 0 -or $EntryPath.Contains('\') -or $EntryPath.StartsWith('/', [StringComparison]::Ordinal) -or $EntryPath -match '^[A-Za-z]:' -or $EntryPath.Contains(':')) { throw "Unsafe archive entry path: $EntryPath" } if (-not $EntryPath.Equals( $EntryPath.Normalize([Text.NormalizationForm]::FormC), [StringComparison]::Ordinal)) { throw "Archive entry path must use Unicode NFC normalization: $EntryPath" } if ($IsDirectory) { if (-not $EntryPath.EndsWith('/', [StringComparison]::Ordinal)) { throw "Archive directory entry is not canonical: $EntryPath" } $candidate = $EntryPath.Substring(0, $EntryPath.Length - 1) } else { if ($EntryPath.EndsWith('/', [StringComparison]::Ordinal)) { throw "Archive file entry is not canonical: $EntryPath" } $candidate = $EntryPath } $segments = @($candidate.Split('/')) if ($segments.Count -eq 0) { throw "Unsafe archive entry path: $EntryPath" } $invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars() foreach ($segment in $segments) { if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.' -or $segment -eq '..' -or $segment.EndsWith(' ', [StringComparison]::Ordinal) -or $segment.EndsWith('.', [StringComparison]::Ordinal) -or $segment.IndexOfAny($invalidFileNameCharacters) -ge 0) { throw "Unsafe archive entry path: $EntryPath" } $deviceStem = $segment.Split('.')[0] if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') { throw "Archive entry uses a reserved Windows file name: $EntryPath" } } return $candidate } function Test-ArchiveEntryIsReparsePoint { param( [Parameter(Mandatory = $true)] [IO.Compression.ZipArchiveEntry] $Entry ) $externalAttributes = [BitConverter]::ToUInt32( [BitConverter]::GetBytes([int] $Entry.ExternalAttributes), 0) $dosAttributes = $externalAttributes -band [uint32] 0x0000FFFF $unixFileType = ($externalAttributes -shr 16) -band [uint32] 0x0000F000 return (($dosAttributes -band [uint32] [IO.FileAttributes]::ReparsePoint) -ne 0 -or $unixFileType -eq [uint32] 0x0000A000) } 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 Convert-BytesToUpperHex { param( [Parameter(Mandatory = $true)] [byte[]] $Bytes ) return ([BitConverter]::ToString($Bytes)).Replace('-', '') } function Copy-ArchiveEntryToNewFile { param( [Parameter(Mandatory = $true)] [IO.Compression.ZipArchiveEntry] $Entry, [Parameter(Mandatory = $true)] [string] $DestinationPath ) $entryStream = $Entry.Open() $destinationStream = New-Object IO.FileStream( $DestinationPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $buffer = New-Object byte[] 81920 $written = [long] 0 while (($read = $entryStream.Read($buffer, 0, $buffer.Length)) -gt 0) { if ($written -gt ([long] $Entry.Length - [long] $read)) { throw "Archive entry expanded beyond its declared length: $($Entry.FullName)" } $destinationStream.Write($buffer, 0, $read) $written += [long] $read } if ($written -ne [long] $Entry.Length) { throw "Archive entry length changed during extraction: $($Entry.FullName)" } $destinationStream.Flush() } finally { $destinationStream.Dispose() $entryStream.Dispose() } } function Get-RequiredJsonProperty { param( [Parameter(Mandatory = $true)] [object] $Object, [Parameter(Mandatory = $true)] [string] $Name ) $property = $Object.PSObject.Properties[$Name] if ($null -eq $property) { throw "Bundle manifest is missing required property: $Name" } return $property.Value } function ConvertTo-ManifestInteger { param( [Parameter(Mandatory = $true)] [object] $Value, [Parameter(Mandatory = $true)] [string] $Name ) if (-not ($Value -is [byte] -or $Value -is [int16] -or $Value -is [int32] -or $Value -is [int64])) { throw "Bundle manifest property must be an integer: $Name" } $result = [long] $Value if ($result -lt 0) { throw "Bundle manifest property cannot be negative: $Name" } return $result } function Assert-RepositorySupportsLocalProps { param( [Parameter(Mandatory = $true)] [string] $Root ) $gitIgnorePath = Join-Path $Root '.gitignore' $directoryBuildPropsPath = Join-Path $Root 'Directory.Build.props' if (-not (Test-Path -LiteralPath $gitIgnorePath -PathType Leaf)) { throw "Repository .gitignore was not found: $gitIgnorePath" } if (-not (Test-Path -LiteralPath $directoryBuildPropsPath -PathType Leaf)) { throw "Directory.Build.props was not found: $directoryBuildPropsPath" } $ignoreLines = @([IO.File]::ReadAllLines($gitIgnorePath)) $hasLocalPropsIgnore = $false foreach ($line in $ignoreLines) { $trimmed = $line.Trim() if ($trimmed -match '^/?Directory\.Build\.local\.props$') { $hasLocalPropsIgnore = $true } if ($trimmed -match '^!/?Directory\.Build\.local\.props$') { throw 'Directory.Build.local.props is explicitly unignored by .gitignore.' } } if (-not $hasLocalPropsIgnore) { throw 'Directory.Build.local.props must be ignored before runtime initialization.' } [xml] $directoryBuildPropsXml = Get-Content ` -LiteralPath $directoryBuildPropsPath ` -Raw ` -Encoding UTF8 $imports = @($directoryBuildPropsXml.SelectNodes( "/*[local-name()='Project']/*[local-name()='Import']")) $matchingImports = @($imports | Where-Object { $projectAttribute = [string] $_.Project $projectAttribute.Replace('\', '/').EndsWith( 'Directory.Build.local.props', [StringComparison]::OrdinalIgnoreCase) }) if ($matchingImports.Count -eq 0) { throw 'Directory.Build.props must conditionally import Directory.Build.local.props.' } } function Assert-InstalledBundle { param( [Parameter(Mandatory = $true)] [string] $InstalledRoot, [Parameter(Mandatory = $true)] [System.Collections.Generic.Dictionary[string, object]] $ManifestRecords, [Parameter(Mandatory = $true)] [string] $ExpectedManifestHash ) if (-not (Test-Path -LiteralPath $InstalledRoot -PathType Container)) { throw "Installed runtime bundle directory was not found: $InstalledRoot" } Assert-NoReparsePointInExistingAncestry ` -Path $InstalledRoot ` -Description 'Installed runtime bundle' $expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( [StringComparer]::OrdinalIgnoreCase) foreach ($manifestPath in $ManifestRecords.Keys) { $expectedPaths.Add($manifestPath) | Out-Null } $expectedPaths.Add($manifestFileName) | Out-Null $actualPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( [StringComparer]::OrdinalIgnoreCase) $pending = New-Object 'System.Collections.Generic.Queue[string]' $pending.Enqueue($InstalledRoot) while ($pending.Count -gt 0) { $directory = $pending.Dequeue() foreach ($child in @(Get-ChildItem -LiteralPath $directory -Force)) { if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Installed runtime bundle contains a reparse point: $($child.FullName)" } if ($child.PSIsContainer) { $pending.Enqueue($child.FullName) continue } $relativePath = $child.FullName.Substring( $InstalledRoot.TrimEnd('\').Length + 1).Replace('\', '/') if (-not $actualPaths.Add($relativePath)) { throw "Installed runtime bundle contains a duplicate path: $relativePath" } if (-not $expectedPaths.Contains($relativePath)) { throw "Installed runtime bundle contains an unexpected file: $relativePath" } } } if ($actualPaths.Count -ne $expectedPaths.Count) { $missing = @($expectedPaths | Where-Object { -not $actualPaths.Contains($_) }) throw "Installed runtime bundle is missing files: $($missing -join ', ')" } foreach ($manifestPath in $ManifestRecords.Keys) { $record = $ManifestRecords[$manifestPath] $nativeRelativePath = $manifestPath.Replace( '/', [IO.Path]::DirectorySeparatorChar) $installedFilePath = Join-Path $InstalledRoot $nativeRelativePath $installedFile = Get-Item -LiteralPath $installedFilePath -Force if ([long] $installedFile.Length -ne [long] $record.length) { throw "Installed runtime bundle file length mismatch: $manifestPath" } $actualHash = (Get-FileHash ` -LiteralPath $installedFilePath ` -Algorithm SHA256).Hash.ToUpperInvariant() if (-not $actualHash.Equals( [string] $record.sha256, [StringComparison]::Ordinal)) { throw "Installed runtime bundle file hash mismatch: $manifestPath" } } $installedManifestPath = Join-Path $InstalledRoot $manifestFileName $actualManifestHash = (Get-FileHash ` -LiteralPath $installedManifestPath ` -Algorithm SHA256).Hash.ToUpperInvariant() if (-not $actualManifestHash.Equals( $ExpectedManifestHash, [StringComparison]::Ordinal)) { throw 'Installed runtime bundle manifest does not match the verified archive manifest.' } } function Ensure-LocalBuildProps { param( [Parameter(Mandatory = $true)] [string] $Root, [Parameter(Mandatory = $true)] [string] $InstalledRoot ) $propsPath = Join-Path $Root 'Directory.Build.local.props' if (Test-Path -LiteralPath $propsPath) { if (-not (Test-Path -LiteralPath $propsPath -PathType Leaf)) { throw "Local build props path is not a regular file: $propsPath" } Assert-NoReparsePointInExistingAncestry ` -Path $propsPath ` -Description 'Local build props' [xml] $existingXml = Get-Content -LiteralPath $propsPath -Raw -Encoding UTF8 $projectNodes = @($existingXml.SelectNodes( "/*[local-name()='Project']")) $propertyGroups = @($existingXml.SelectNodes( "/*[local-name()='Project']/*[local-name()='PropertyGroup']")) $rootNodes = @($existingXml.SelectNodes( "/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeSourceRoot']")) $modeNodes = @($existingXml.SelectNodes( "/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeAssetsMode']")) if ($projectNodes.Count -ne 1 -or $projectNodes[0].Attributes.Count -ne 0 -or @($projectNodes[0].ChildNodes | Where-Object { $_.NodeType -eq [Xml.XmlNodeType]::Element }).Count -ne 1 -or $propertyGroups.Count -ne 1 -or $rootNodes.Count -ne 1 -or $modeNodes.Count -ne 1 -or $propertyGroups[0].Attributes.Count -ne 0 -or @($propertyGroups[0].ChildNodes | Where-Object { $_.NodeType -eq [Xml.XmlNodeType]::Element }).Count -ne 2 -or $rootNodes[0].Attributes.Count -ne 0 -or $modeNodes[0].Attributes.Count -ne 0) { throw 'Existing Directory.Build.local.props is conditional, duplicated, or differs from the generated shape.' } $existingRoot = Get-NormalizedFullPath -Path ([string] $rootNodes[0].InnerText) if (-not $existingRoot.Equals( (Get-NormalizedFullPath -Path $InstalledRoot), [StringComparison]::OrdinalIgnoreCase) -or -not ([string] $modeNodes[0].InnerText).Equals( 'Required', [StringComparison]::Ordinal)) { throw 'Existing Directory.Build.local.props selects a different runtime bundle or mode; it was not overwritten.' } return $propsPath } $temporaryPropsPath = Join-Path $Root ( '.Directory.Build.local.props.' + [Guid]::NewGuid().ToString('N') + '.tmp') try { $settings = New-Object Xml.XmlWriterSettings $settings.Encoding = New-Object Text.UTF8Encoding($false) $settings.Indent = $true $settings.NewLineChars = [Environment]::NewLine $settings.NewLineHandling = [Xml.NewLineHandling]::Replace $writer = [Xml.XmlWriter]::Create($temporaryPropsPath, $settings) try { $writer.WriteStartDocument() $writer.WriteStartElement('Project') $writer.WriteStartElement('PropertyGroup') $writer.WriteElementString( 'LegacyRuntimeSourceRoot', (Get-NormalizedFullPath -Path $InstalledRoot)) $writer.WriteElementString('LegacyRuntimeAssetsMode', 'Required') $writer.WriteEndElement() $writer.WriteEndElement() $writer.WriteEndDocument() } finally { $writer.Dispose() } Assert-NoReparsePointInExistingAncestry ` -Path $temporaryPropsPath ` -Description 'Temporary local build props' [IO.File]::Move($temporaryPropsPath, $propsPath) } finally { if (Test-Path -LiteralPath $temporaryPropsPath) { Remove-Item -LiteralPath $temporaryPropsPath -Force } } return $propsPath } if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { $repositoryPath = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..') } else { $repositoryPath = Get-NormalizedFullPath -Path $RepositoryRoot } if (-not (Test-Path -LiteralPath $repositoryPath -PathType Container)) { throw "Repository root was not found: $repositoryPath" } Assert-NoReparsePointInExistingAncestry ` -Path $repositoryPath ` -Description 'Repository root' Assert-RepositorySupportsLocalProps -Root $repositoryPath $projectPath = Join-Path $repositoryPath 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj' $resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath) $allowedResPaths = New-Object 'System.Collections.Generic.Dictionary[string, string]' ( [StringComparer]::OrdinalIgnoreCase) foreach ($resAssetName in $resAssetNames) { $allowedResPaths.Add(('Res/' + $resAssetName), ('Res/' + $resAssetName)) } $archivePath = Get-NormalizedFullPath -Path $ZipPath if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { throw "Legacy runtime bundle archive was not found: $archivePath" } Assert-NoReparsePointInExistingAncestry ` -Path $archivePath ` -Description 'Legacy runtime bundle archive' $normalizedExpectedHash = $ExpectedSha256.Trim().ToUpperInvariant() if ($normalizedExpectedHash -notmatch '^[0-9A-F]{64}$') { throw 'ExpectedSha256 must be a separately delivered 64-character SHA-256 value.' } Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $archiveStream = [IO.File]::Open( $archivePath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $archive = $null $stagingRoot = $null try { $sha256 = [Security.Cryptography.SHA256]::Create() try { $actualArchiveHash = Convert-BytesToUpperHex -Bytes ( $sha256.ComputeHash($archiveStream)) } finally { $sha256.Dispose() } if (-not $actualArchiveHash.Equals( $normalizedExpectedHash, [StringComparison]::Ordinal)) { throw ("Legacy runtime bundle SHA-256 mismatch. Expected {0}; actual {1}." -f $normalizedExpectedHash, $actualArchiveHash) } $archiveStream.Position = 0 $archive = New-Object IO.Compression.ZipArchive( $archiveStream, [IO.Compression.ZipArchiveMode]::Read, $true) $entries = @($archive.Entries) if ($entries.Count -eq 0 -or $entries.Count -gt $maximumEntryCount) { throw "Legacy runtime bundle has an invalid entry count: $($entries.Count)" } $canonicalEntries = New-Object 'System.Collections.Generic.Dictionary[string, bool]' ( [StringComparer]::OrdinalIgnoreCase) $payloadEntries = New-Object 'System.Collections.Generic.Dictionary[string, object]' ( [StringComparer]::OrdinalIgnoreCase) $directoryEntries = New-Object 'System.Collections.Generic.List[object]' $manifestEntry = $null $totalPayloadLength = [long] 0 foreach ($entry in $entries) { if (Test-ArchiveEntryIsReparsePoint -Entry $entry) { throw "Archive entry is a symbolic link or reparse point: $($entry.FullName)" } $isDirectory = $entry.FullName.EndsWith('/', [StringComparison]::Ordinal) $canonicalPath = Assert-SafeArchiveEntryPath ` -EntryPath $entry.FullName ` -IsDirectory $isDirectory if ($canonicalEntries.ContainsKey($canonicalPath)) { throw "Archive contains a duplicate or file/directory-colliding path: $canonicalPath" } $canonicalEntries.Add($canonicalPath, $isDirectory) if ($isDirectory) { if ([long] $entry.Length -ne 0 -or -not ($canonicalPath.Equals( 'Cuts', [StringComparison]::Ordinal) -or $canonicalPath.Equals( 'Res', [StringComparison]::Ordinal) -or $canonicalPath.StartsWith( 'Cuts/', [StringComparison]::Ordinal))) { throw "Archive contains an unexpected directory: $($entry.FullName)" } $directoryEntries.Add($entry) continue } if ([long] $entry.Length -gt $maximumFileLength) { throw "Archive entry exceeds the per-file size limit: $canonicalPath" } if ($canonicalPath.Equals( $manifestFileName, [StringComparison]::Ordinal)) { if ($null -ne $manifestEntry) { throw 'Archive contains more than one bundle manifest.' } if ([long] $entry.Length -gt $maximumManifestLength) { throw 'Bundle manifest exceeds the size limit.' } $manifestEntry = $entry continue } if ($canonicalPath.StartsWith('Cuts/', [StringComparison]::Ordinal)) { Assert-SafePayloadRelativePath -RelativePath $canonicalPath } elseif ($canonicalPath.StartsWith('Res/', [StringComparison]::Ordinal)) { Assert-SafePayloadRelativePath -RelativePath $canonicalPath if (-not $allowedResPaths.ContainsKey($canonicalPath) -or $allowedResPaths[$canonicalPath] -cne $canonicalPath) { throw "Archive contains a Res file outside the project allowlist: $canonicalPath" } } else { throw "Archive contains a file outside Cuts/Res: $canonicalPath" } if ($totalPayloadLength -gt ($maximumPayloadLength - [long] $entry.Length)) { throw 'Archive payload exceeds the total size limit.' } $totalPayloadLength += [long] $entry.Length $payloadEntries.Add($canonicalPath, $entry) } foreach ($canonicalPath in $canonicalEntries.Keys) { $segments = @($canonicalPath.Split('/')) for ($segmentIndex = 1; $segmentIndex -lt $segments.Count; $segmentIndex++) { $ancestorPath = ($segments[0..($segmentIndex - 1)] -join '/') if ($canonicalEntries.ContainsKey($ancestorPath) -and -not $canonicalEntries[$ancestorPath]) { throw ("Archive file is also the parent of another entry: {0}" -f $ancestorPath) } } } if ($null -eq $manifestEntry) { throw 'Archive does not contain LegacyRuntimeBundle.manifest.json.' } $manifestStream = $manifestEntry.Open() $manifestMemory = New-Object IO.MemoryStream try { $manifestStream.CopyTo($manifestMemory) $manifestBytes = $manifestMemory.ToArray() } finally { $manifestMemory.Dispose() $manifestStream.Dispose() } $manifestSha = [Security.Cryptography.SHA256]::Create() try { $manifestHash = Convert-BytesToUpperHex -Bytes ( $manifestSha.ComputeHash($manifestBytes)) } finally { $manifestSha.Dispose() } $strictUtf8 = New-Object Text.UTF8Encoding($false, $true) $manifestJson = $strictUtf8.GetString($manifestBytes) if ($manifestJson.Length -gt 0 -and $manifestJson[0] -eq [char] 0xFEFF) { $manifestJson = $manifestJson.Substring(1) } try { $manifest = $manifestJson | ConvertFrom-Json } catch { throw "Bundle manifest is not valid UTF-8 JSON: $($_.Exception.Message)" } $schemaVersion = ConvertTo-ManifestInteger ` -Value (Get-RequiredJsonProperty -Object $manifest -Name 'schemaVersion') ` -Name 'schemaVersion' $bundleType = [string] ( Get-RequiredJsonProperty -Object $manifest -Name 'bundleType') $manifestFileCount = ConvertTo-ManifestInteger ` -Value (Get-RequiredJsonProperty -Object $manifest -Name 'fileCount') ` -Name 'fileCount' $manifestCutsFileCount = ConvertTo-ManifestInteger ` -Value (Get-RequiredJsonProperty -Object $manifest -Name 'cutsFileCount') ` -Name 'cutsFileCount' $manifestResFileCount = ConvertTo-ManifestInteger ` -Value (Get-RequiredJsonProperty -Object $manifest -Name 'resFileCount') ` -Name 'resFileCount' $manifestFileRecords = @( Get-RequiredJsonProperty -Object $manifest -Name 'files') if ($schemaVersion -ne 1 -or -not $bundleType.Equals( 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle', [StringComparison]::Ordinal)) { throw 'Bundle manifest schema or bundle type is unsupported.' } $manifestRecords = New-Object 'System.Collections.Generic.Dictionary[string, object]' ( [StringComparer]::OrdinalIgnoreCase) $actualCutsFileCount = 0 $actualResFileCount = 0 foreach ($record in $manifestFileRecords) { $recordPath = [string] ( Get-RequiredJsonProperty -Object $record -Name 'path') $recordLength = ConvertTo-ManifestInteger ` -Value (Get-RequiredJsonProperty -Object $record -Name 'length') ` -Name ('files[{0}].length' -f $recordPath) $recordHash = [string] ( Get-RequiredJsonProperty -Object $record -Name 'sha256') if ($recordHash -notmatch '^[0-9A-Fa-f]{64}$') { throw "Bundle manifest has an invalid file SHA-256: $recordPath" } $recordHash = $recordHash.ToUpperInvariant() $canonicalRecordPath = Assert-SafeArchiveEntryPath ` -EntryPath $recordPath ` -IsDirectory $false if ($canonicalRecordPath -cne $recordPath) { throw "Bundle manifest path is not canonical: $recordPath" } Assert-SafePayloadRelativePath -RelativePath $recordPath if ($manifestRecords.ContainsKey($recordPath)) { throw "Bundle manifest contains a duplicate path: $recordPath" } $manifestRecords.Add( $recordPath, [pscustomobject]@{ length = [long] $recordLength sha256 = $recordHash }) if (-not $payloadEntries.ContainsKey($recordPath) -or $payloadEntries[$recordPath].FullName -cne $recordPath) { throw "Bundle manifest references a file absent from the archive: $recordPath" } if ([long] $payloadEntries[$recordPath].Length -ne [long] $recordLength) { throw "Bundle manifest length does not match the archive: $recordPath" } if ($recordPath.StartsWith('Cuts/', [StringComparison]::Ordinal)) { $actualCutsFileCount++ } elseif ($recordPath.StartsWith('Res/', [StringComparison]::Ordinal)) { if (-not $allowedResPaths.ContainsKey($recordPath) -or $allowedResPaths[$recordPath] -cne $recordPath) { throw "Bundle manifest contains a Res file outside the project allowlist: $recordPath" } $actualResFileCount++ } else { throw "Bundle manifest contains a file outside Cuts/Res: $recordPath" } } if ($manifestRecords.Count -ne $payloadEntries.Count -or $manifestFileCount -ne $manifestRecords.Count -or $manifestCutsFileCount -ne $actualCutsFileCount -or $manifestResFileCount -ne $actualResFileCount -or $actualResFileCount -ne $expectedResAssetCount -or $actualCutsFileCount -le 0) { throw 'Bundle manifest counts or file set do not match the closed archive payload.' } foreach ($payloadPath in $payloadEntries.Keys) { if (-not $manifestRecords.ContainsKey($payloadPath)) { throw "Archive contains a payload file absent from the manifest: $payloadPath" } } $knownLocalAppData = [Environment]::GetFolderPath( [Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($knownLocalAppData)) { throw 'The Windows LocalApplicationData known folder is unavailable.' } $localAppDataRoot = Get-NormalizedFullPath -Path $knownLocalAppData $bundleStorageRoot = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\RuntimeBundles' Assert-NoReparsePointInExistingAncestry ` -Path $bundleStorageRoot ` -Description 'Runtime bundle storage' [IO.Directory]::CreateDirectory($bundleStorageRoot) | Out-Null Assert-NoReparsePointInExistingAncestry ` -Path $bundleStorageRoot ` -Description 'Runtime bundle storage' $installedRoot = Join-Path $bundleStorageRoot $normalizedExpectedHash if (Test-Path -LiteralPath $installedRoot) { Assert-InstalledBundle ` -InstalledRoot $installedRoot ` -ManifestRecords $manifestRecords ` -ExpectedManifestHash $manifestHash } else { $stagingRoot = Join-Path $bundleStorageRoot ( '.installing-' + [Guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null Assert-NoReparsePointInExistingAncestry ` -Path $stagingRoot ` -Description 'Runtime bundle staging directory' foreach ($directoryEntry in @($directoryEntries.ToArray() | Sort-Object FullName)) { $directoryPath = $directoryEntry.FullName.TrimEnd('/').Replace( '/', [IO.Path]::DirectorySeparatorChar) $destinationDirectory = Join-Path $stagingRoot $directoryPath if (-not (Test-PathIsWithin ` -Root $stagingRoot ` -Candidate $destinationDirectory)) { throw "Archive directory escaped the staging root: $($directoryEntry.FullName)" } [IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null Assert-NoReparsePointInExistingAncestry ` -Path $destinationDirectory ` -Description 'Extracted runtime directory' } $entriesToExtract = New-Object 'System.Collections.Generic.List[object]' foreach ($payloadEntry in $payloadEntries.Values) { $entriesToExtract.Add($payloadEntry) } $entriesToExtract.Add($manifestEntry) foreach ($entry in @($entriesToExtract.ToArray() | Sort-Object FullName)) { $nativeRelativePath = $entry.FullName.Replace( '/', [IO.Path]::DirectorySeparatorChar) $destinationPath = Join-Path $stagingRoot $nativeRelativePath if (-not (Test-PathIsWithin ` -Root $stagingRoot ` -Candidate $destinationPath)) { throw "Archive file escaped the staging root: $($entry.FullName)" } $destinationParent = [IO.Path]::GetDirectoryName($destinationPath) [IO.Directory]::CreateDirectory($destinationParent) | Out-Null Assert-NoReparsePointInExistingAncestry ` -Path $destinationParent ` -Description 'Extracted runtime file parent' Copy-ArchiveEntryToNewFile ` -Entry $entry ` -DestinationPath $destinationPath } Assert-InstalledBundle ` -InstalledRoot $stagingRoot ` -ManifestRecords $manifestRecords ` -ExpectedManifestHash $manifestHash try { [IO.Directory]::Move($stagingRoot, $installedRoot) $stagingRoot = $null } catch { if (Test-Path -LiteralPath $installedRoot -PathType Container) { Assert-InstalledBundle ` -InstalledRoot $installedRoot ` -ManifestRecords $manifestRecords ` -ExpectedManifestHash $manifestHash } else { throw } } } $localPropsPath = Ensure-LocalBuildProps ` -Root $repositoryPath ` -InstalledRoot $installedRoot [pscustomobject]@{ InstalledRoot = $installedRoot ArchivePath = $archivePath ArchiveSha256 = $actualArchiveHash ManifestSha256 = $manifestHash FileCount = [int] $manifestRecords.Count CutsFileCount = [int] $actualCutsFileCount ResFileCount = [int] $actualResFileCount LocalBuildPropsPath = $localPropsPath } } finally { if ($null -ne $archive) { $archive.Dispose() } $archiveStream.Dispose() if (-not [string]::IsNullOrWhiteSpace($stagingRoot) -and (Test-Path -LiteralPath $stagingRoot)) { if (-not (Test-PathIsWithin ` -Root $bundleStorageRoot ` -Candidate $stagingRoot) -or -not ([IO.Path]::GetFileName($stagingRoot)).StartsWith( '.installing-', [StringComparison]::Ordinal)) { throw "Refusing to clean an unexpected staging path: $stagingRoot" } Assert-NoReparsePointInTree ` -Root $stagingRoot ` -Description 'Runtime bundle staging cleanup' Remove-Item -LiteralPath $stagingRoot -Recurse -Force } }