1019 lines
38 KiB
PowerShell
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
|
|
}
|
|
}
|