Files
MBN_STOCK_WEBVIEW/scripts/Initialize-LegacyRuntimeBundle.ps1

1019 lines
38 KiB
PowerShell

#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
}
}