2286 lines
89 KiB
PowerShell
2286 lines
89 KiB
PowerShell
#Requires -Version 5.1
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $LegacyRuntimeSourceRoot,
|
|
|
|
[switch] $NoFolderPicker,
|
|
|
|
[string] $DatabaseIniPath,
|
|
|
|
[switch] $SkipDatabaseProfile,
|
|
|
|
[switch] $ReplaceRuntimeBinding,
|
|
|
|
[switch] $ReplaceDatabaseProfile,
|
|
|
|
[switch] $ConfigureDevelopmentLive,
|
|
|
|
[string] $PlayoutHost,
|
|
|
|
[int] $PlayoutPort,
|
|
|
|
[string] $NativeSha256,
|
|
|
|
[string] $InteropSha256,
|
|
|
|
[ValidateRange(0, 2147483647)]
|
|
[Nullable[int]] $OutputChannel = $null,
|
|
|
|
[switch] $ReplaceLiveConfig,
|
|
|
|
[switch] $SkipBuild
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$maximumDatabaseIniLength = 1MB
|
|
$maximumManifestLength = 4MB
|
|
$expectedResAssetCount = 34
|
|
$manifestFileName = 'LegacyRuntimeBundle.manifest.json'
|
|
$archiveFileName = 'LegacyRuntimeBundle.zip'
|
|
$archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256'
|
|
|
|
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
|
|
return $normalizedCandidate.StartsWith(
|
|
$normalizedRoot + [IO.Path]::DirectorySeparatorChar,
|
|
[StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
|
|
function Test-SamePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Left,
|
|
[Parameter(Mandatory = $true)][string] $Right
|
|
)
|
|
|
|
return (Get-NormalizedFullPath -Path $Left).TrimEnd('\', '/').Equals(
|
|
(Get-NormalizedFullPath -Path $Right).TrimEnd('\', '/'),
|
|
[StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
|
|
function Test-DirectCutsAndResRootCandidate {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $RepositoryRoot
|
|
)
|
|
|
|
try {
|
|
$candidate = Get-NormalizedFullPath -Path $Path
|
|
if (-not (Test-Path -LiteralPath $candidate -PathType Container)) {
|
|
return $false
|
|
}
|
|
|
|
$cuts = Get-NormalizedFullPath -Path (Join-Path $candidate 'Cuts')
|
|
$res = Get-NormalizedFullPath -Path (Join-Path $candidate 'Res')
|
|
$hasDirectRoots = (Test-Path -LiteralPath $cuts -PathType Container) -and
|
|
(Test-Path -LiteralPath $res -PathType Container) -and
|
|
(Test-SamePath -Left ([IO.Path]::GetDirectoryName($cuts)) -Right $candidate) -and
|
|
(Test-SamePath -Left ([IO.Path]::GetDirectoryName($res)) -Right $candidate)
|
|
if (-not $hasDirectRoots) {
|
|
return $false
|
|
}
|
|
|
|
Assert-LegacyRuntimeRoot `
|
|
-Root $candidate `
|
|
-RepositoryRoot $RepositoryRoot
|
|
return $true
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Assert-NoReparsePointInExistingAncestry {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
$current = Get-NormalizedFullPath -Path $Path
|
|
while (-not [string]::IsNullOrWhiteSpace($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('\', '/')
|
|
$parent = [IO.Path]::GetDirectoryName($trimmed)
|
|
if ([string]::IsNullOrWhiteSpace($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
|
|
foreach ($item in @(Get-ChildItem -LiteralPath $Root -Recurse -Force)) {
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "$Description contains a reparse point: $($item.FullName)"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-FixedLocalPath {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
$fullPath = Get-NormalizedFullPath -Path $Path
|
|
if ($fullPath.StartsWith('\\', [StringComparison]::Ordinal)) {
|
|
throw "$Description must be on a local fixed drive."
|
|
}
|
|
|
|
$pathRoot = [IO.Path]::GetPathRoot($fullPath)
|
|
if ([string]::IsNullOrWhiteSpace($pathRoot)) {
|
|
throw "$Description does not have a filesystem root."
|
|
}
|
|
|
|
$drive = New-Object IO.DriveInfo($pathRoot)
|
|
if (-not $drive.IsReady -or $drive.DriveType -ne [IO.DriveType]::Fixed) {
|
|
throw "$Description must be on a ready local fixed drive."
|
|
}
|
|
}
|
|
|
|
function Assert-RegularFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
throw "$Description was not found as an ordinary file: $Path"
|
|
}
|
|
|
|
Assert-NoReparsePointInExistingAncestry -Path $Path -Description $Description
|
|
$item = Get-Item -LiteralPath $Path -Force
|
|
if ($item.PSIsContainer -or
|
|
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "$Description must be an ordinary file: $Path"
|
|
}
|
|
|
|
return $item
|
|
}
|
|
|
|
function Get-UserOnlyAcl {
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
$acl = New-Object Security.AccessControl.FileSecurity
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
$rule = New-Object Security.AccessControl.FileSystemAccessRule(
|
|
$identity,
|
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
|
[Security.AccessControl.AccessControlType]::Allow)
|
|
$acl.AddAccessRule($rule)
|
|
return $acl
|
|
}
|
|
|
|
function Set-UserOnlyDirectoryAcl {
|
|
param([Parameter(Mandatory = $true)][string] $Path)
|
|
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
$acl = New-Object Security.AccessControl.DirectorySecurity
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
$rule = New-Object Security.AccessControl.FileSystemAccessRule(
|
|
$identity,
|
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
|
([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor
|
|
[Security.AccessControl.InheritanceFlags]::ObjectInherit),
|
|
[Security.AccessControl.PropagationFlags]::None,
|
|
[Security.AccessControl.AccessControlType]::Allow)
|
|
$acl.AddAccessRule($rule)
|
|
[IO.Directory]::SetAccessControl($Path, $acl)
|
|
}
|
|
|
|
function Test-UserOnlyFileAcl {
|
|
param([Parameter(Mandatory = $true)][string] $Path)
|
|
|
|
try {
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
$acl = [IO.File]::GetAccessControl($Path)
|
|
if (-not $acl.AreAccessRulesProtected) {
|
|
return $false
|
|
}
|
|
|
|
$rules = @($acl.GetAccessRules(
|
|
$true,
|
|
$false,
|
|
[Security.Principal.NTAccount]))
|
|
return $rules.Count -eq 1 -and
|
|
$rules[0].IdentityReference.Value.Equals(
|
|
$identity,
|
|
[StringComparison]::OrdinalIgnoreCase) -and
|
|
$rules[0].AccessControlType -eq
|
|
[Security.AccessControl.AccessControlType]::Allow -and
|
|
(($rules[0].FileSystemRights -band
|
|
[Security.AccessControl.FileSystemRights]::FullControl) -eq
|
|
[Security.AccessControl.FileSystemRights]::FullControl)
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-FileAccessSddl {
|
|
param([Parameter(Mandatory = $true)][string] $Path)
|
|
|
|
$acl = [IO.File]::GetAccessControl(
|
|
$Path,
|
|
[Security.AccessControl.AccessControlSections]::Access)
|
|
return $acl.GetSecurityDescriptorSddlForm(
|
|
[Security.AccessControl.AccessControlSections]::Access)
|
|
}
|
|
|
|
function Assert-ExactObjectProperties {
|
|
param(
|
|
[Parameter(Mandatory = $true)][object] $Value,
|
|
[Parameter(Mandatory = $true)][string[]] $Names,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
if ($null -eq $Value -or $Value -isnot [pscustomobject]) {
|
|
throw "$Description must be one PSCustomObject."
|
|
}
|
|
|
|
$actualNames = @($Value.PSObject.Properties | ForEach-Object {
|
|
[string] $_.Name
|
|
})
|
|
if ($actualNames.Count -ne $Names.Count) {
|
|
throw "$Description returned an unexpected property count."
|
|
}
|
|
for ($index = 0; $index -lt $Names.Count; $index++) {
|
|
if (-not $actualNames[$index].Equals(
|
|
$Names[$index],
|
|
[StringComparison]::Ordinal)) {
|
|
throw "$Description returned unexpected, reordered, or case-changed properties."
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-ExactUtf8Text {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedText
|
|
)
|
|
|
|
$actualBytes = [IO.File]::ReadAllBytes($Path)
|
|
$expectedBytes = (New-Object Text.UTF8Encoding($false)).GetBytes($ExpectedText)
|
|
if ($actualBytes.Length -ne $expectedBytes.Length) {
|
|
return $false
|
|
}
|
|
for ($index = 0; $index -lt $expectedBytes.Length; $index++) {
|
|
if ($actualBytes[$index] -ne $expectedBytes[$index]) {
|
|
return $false
|
|
}
|
|
}
|
|
return $true
|
|
}
|
|
|
|
function New-FileRollbackSnapshot {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $BackupDirectory,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
$destination = Get-NormalizedFullPath -Path $Path
|
|
if (-not (Test-Path -LiteralPath $destination)) {
|
|
return [pscustomobject]@{
|
|
Path = $destination
|
|
Existed = $false
|
|
Length = [long] 0
|
|
Sha256 = $null
|
|
AccessSddl = $null
|
|
BackupPath = $null
|
|
OriginalBytes = $null
|
|
}
|
|
}
|
|
|
|
$item = Assert-RegularFile -Path $destination -Description $Description
|
|
$sourceHash = (
|
|
Get-FileHash -LiteralPath $destination -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
$accessSddl = Get-FileAccessSddl -Path $destination
|
|
$backupPath = Join-Path $BackupDirectory (
|
|
[Guid]::NewGuid().ToString('N') + '.rollback')
|
|
[IO.File]::Copy($destination, $backupPath, $false)
|
|
|
|
# A credential backup is protected immediately after the copy, before its
|
|
# content is read or used for any later rollback decision.
|
|
[IO.File]::SetAccessControl($backupPath, (Get-UserOnlyAcl))
|
|
$backupItem = Assert-RegularFile `
|
|
-Path $backupPath `
|
|
-Description "$Description rollback backup"
|
|
$backupHash = (
|
|
Get-FileHash -LiteralPath $backupPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if ($backupItem.Length -ne $item.Length -or
|
|
-not $backupHash.Equals($sourceHash, [StringComparison]::Ordinal) -or
|
|
-not (Test-UserOnlyFileAcl -Path $backupPath)) {
|
|
throw "$Description rollback backup failed its immediate ACL/hash verification."
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Path = $destination
|
|
Existed = $true
|
|
Length = [long] $backupItem.Length
|
|
Sha256 = $backupHash
|
|
AccessSddl = $accessSddl
|
|
BackupPath = $backupPath
|
|
OriginalBytes = [IO.File]::ReadAllBytes($backupPath)
|
|
}
|
|
}
|
|
|
|
function Restore-FileRollbackSnapshot {
|
|
param(
|
|
[Parameter(Mandatory = $true)][pscustomobject] $Snapshot,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
$destination = Get-NormalizedFullPath -Path ([string] $Snapshot.Path)
|
|
$directory = [IO.Path]::GetDirectoryName($destination)
|
|
if (-not [bool] $Snapshot.Existed) {
|
|
if (Test-Path -LiteralPath $destination) {
|
|
[void](Assert-RegularFile -Path $destination -Description $Description)
|
|
[IO.File]::Delete($destination)
|
|
}
|
|
if (Test-Path -LiteralPath $destination) {
|
|
throw "$Description rollback could not remove a newly created file."
|
|
}
|
|
return
|
|
}
|
|
|
|
[IO.Directory]::CreateDirectory($directory) | Out-Null
|
|
Assert-NoReparsePointInExistingAncestry -Path $directory -Description $Description
|
|
$temporaryPath = Join-Path $directory (
|
|
'.' + [IO.Path]::GetFileName($destination) + '.' +
|
|
[Guid]::NewGuid().ToString('N') + '.restore')
|
|
try {
|
|
[IO.File]::WriteAllBytes(
|
|
$temporaryPath,
|
|
[byte[]] $Snapshot.OriginalBytes)
|
|
[IO.File]::SetAccessControl($temporaryPath, (Get-UserOnlyAcl))
|
|
$temporaryItem = Assert-RegularFile `
|
|
-Path $temporaryPath `
|
|
-Description "$Description rollback temporary file"
|
|
$temporaryHash = (
|
|
Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if ($temporaryItem.Length -ne [long] $Snapshot.Length -or
|
|
-not $temporaryHash.Equals(
|
|
[string] $Snapshot.Sha256,
|
|
[StringComparison]::Ordinal) -or
|
|
-not (Test-UserOnlyFileAcl -Path $temporaryPath)) {
|
|
throw "$Description rollback bytes failed verification."
|
|
}
|
|
|
|
$originalAcl = New-Object Security.AccessControl.FileSecurity
|
|
$originalAcl.SetSecurityDescriptorSddlForm(
|
|
[string] $Snapshot.AccessSddl,
|
|
[Security.AccessControl.AccessControlSections]::Access)
|
|
[IO.File]::SetAccessControl($temporaryPath, $originalAcl)
|
|
|
|
if (Test-Path -LiteralPath $destination) {
|
|
[void](Assert-RegularFile -Path $destination -Description $Description)
|
|
[IO.File]::Replace($temporaryPath, $destination, $null)
|
|
$temporaryPath = $null
|
|
}
|
|
else {
|
|
[IO.File]::Move($temporaryPath, $destination)
|
|
$temporaryPath = $null
|
|
}
|
|
[IO.File]::SetAccessControl($destination, $originalAcl)
|
|
|
|
$restoredItem = Assert-RegularFile `
|
|
-Path $destination `
|
|
-Description "$Description restored file"
|
|
$restoredHash = (
|
|
Get-FileHash -LiteralPath $destination -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
$restoredSddl = Get-FileAccessSddl -Path $destination
|
|
if ($restoredItem.Length -ne [long] $Snapshot.Length -or
|
|
-not $restoredHash.Equals(
|
|
[string] $Snapshot.Sha256,
|
|
[StringComparison]::Ordinal) -or
|
|
-not $restoredSddl.Equals(
|
|
[string] $Snapshot.AccessSddl,
|
|
[StringComparison]::Ordinal)) {
|
|
throw "$Description rollback did not restore the exact bytes and ACL."
|
|
}
|
|
}
|
|
finally {
|
|
if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and
|
|
(Test-Path -LiteralPath $temporaryPath)) {
|
|
[IO.File]::Delete($temporaryPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Remove-OwnedTemporaryDirectory {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Parent,
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
return
|
|
}
|
|
if (-not (Test-PathIsWithin -Root $Parent -Candidate $Path) -or
|
|
[IO.Path]::GetFileName($Path) -notmatch '^[0-9a-f]{32}$') {
|
|
throw "Refusing to clean an unexpected $Description path: $Path"
|
|
}
|
|
Assert-NoReparsePointInTree -Root $Path -Description $Description
|
|
[IO.Directory]::Delete($Path, $true)
|
|
if (Test-Path -LiteralPath $Path) {
|
|
throw "$Description was not completely removed."
|
|
}
|
|
}
|
|
|
|
function Remove-DevelopmentLiveAuthorizationFailClosed {
|
|
param([Parameter(Mandatory = $true)][string] $Path)
|
|
|
|
$authorizationPath = Get-NormalizedFullPath -Path $Path
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $authorizationPath `
|
|
-Description 'Development Live authorization'
|
|
if (-not (Test-Path -LiteralPath $authorizationPath)) {
|
|
return
|
|
}
|
|
[void](Assert-RegularFile `
|
|
-Path $authorizationPath `
|
|
-Description 'Development Live authorization')
|
|
|
|
$invalidatedPath = (
|
|
$authorizationPath + '.invalidated.' + [Guid]::NewGuid().ToString('N'))
|
|
try {
|
|
[IO.File]::Move($authorizationPath, $invalidatedPath)
|
|
}
|
|
catch {
|
|
$moveError = $_
|
|
try {
|
|
[IO.File]::SetAccessControl($authorizationPath, (Get-UserOnlyAcl))
|
|
[IO.File]::Delete($authorizationPath)
|
|
}
|
|
catch {
|
|
throw [InvalidOperationException]::new(
|
|
'Development Live authorization could not be invalidated at its exact path.',
|
|
$moveError.Exception)
|
|
}
|
|
if (Test-Path -LiteralPath $authorizationPath) {
|
|
throw [InvalidOperationException]::new(
|
|
'Development Live authorization remains at its exact path after invalidation.',
|
|
$moveError.Exception)
|
|
}
|
|
return
|
|
}
|
|
|
|
try {
|
|
[IO.File]::SetAccessControl($invalidatedPath, (Get-UserOnlyAcl))
|
|
[IO.File]::Delete($invalidatedPath)
|
|
}
|
|
catch {
|
|
throw [InvalidOperationException]::new(
|
|
'The authorization exact path is invalidated, but its protected tombstone could not be removed.',
|
|
$_.Exception)
|
|
}
|
|
if (Test-Path -LiteralPath $authorizationPath) {
|
|
throw 'Development Live authorization was recreated during fail-closed invalidation.'
|
|
}
|
|
}
|
|
|
|
function Select-LegacyRuntimeRoot {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $RepositoryRoot,
|
|
[AllowNull()][string] $RequestedRoot,
|
|
[switch] $DisablePicker
|
|
)
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($RequestedRoot)) {
|
|
$requestedPath = Get-NormalizedFullPath -Path $RequestedRoot
|
|
$requestedLeaf = [IO.Path]::GetFileName($requestedPath.TrimEnd('\', '/'))
|
|
if ($requestedLeaf.Equals('Cuts', [StringComparison]::OrdinalIgnoreCase) -or
|
|
$requestedLeaf.Equals('Res', [StringComparison]::OrdinalIgnoreCase)) {
|
|
$requestedPath = Get-NormalizedFullPath -Path (
|
|
[IO.Path]::GetDirectoryName($requestedPath))
|
|
}
|
|
return $requestedPath
|
|
}
|
|
|
|
$siblingCandidate = Get-NormalizedFullPath -Path (
|
|
Join-Path $RepositoryRoot '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug')
|
|
if (Test-DirectCutsAndResRootCandidate `
|
|
-Path $siblingCandidate `
|
|
-RepositoryRoot $RepositoryRoot) {
|
|
return $siblingCandidate
|
|
}
|
|
|
|
if ($DisablePicker) {
|
|
throw (
|
|
"The exact sibling runtime was not found at '$siblingCandidate'. " +
|
|
'Pass -LegacyRuntimeSourceRoot explicitly.')
|
|
}
|
|
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
try {
|
|
$dialog.Description = (
|
|
'Select the bin\Debug folder that directly contains both Cuts and Res.')
|
|
$dialog.ShowNewFolderButton = $false
|
|
if ($dialog.ShowDialog() -ne [Windows.Forms.DialogResult]::OK) {
|
|
throw 'Legacy runtime folder selection was cancelled.'
|
|
}
|
|
|
|
$selectedPath = Get-NormalizedFullPath -Path $dialog.SelectedPath
|
|
$selectedLeaf = [IO.Path]::GetFileName($selectedPath.TrimEnd('\', '/'))
|
|
if ($selectedLeaf.Equals('Cuts', [StringComparison]::OrdinalIgnoreCase) -or
|
|
$selectedLeaf.Equals('Res', [StringComparison]::OrdinalIgnoreCase)) {
|
|
$selectedPath = Get-NormalizedFullPath -Path (
|
|
[IO.Path]::GetDirectoryName($selectedPath))
|
|
}
|
|
return $selectedPath
|
|
}
|
|
finally {
|
|
$dialog.Dispose()
|
|
}
|
|
}
|
|
|
|
function Assert-LegacyRuntimeRoot {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Root,
|
|
[Parameter(Mandatory = $true)][string] $RepositoryRoot
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
|
|
throw "Legacy runtime source root was not found: $Root"
|
|
}
|
|
|
|
Assert-FixedLocalPath -Path $Root -Description 'Legacy runtime source root'
|
|
Assert-NoReparsePointInTree -Root $Root -Description 'Legacy runtime source tree'
|
|
|
|
if ((Test-SamePath -Left $Root -Right $RepositoryRoot) -or
|
|
(Test-PathIsWithin -Root $RepositoryRoot -Candidate $Root) -or
|
|
(Test-PathIsWithin -Root $Root -Candidate $RepositoryRoot)) {
|
|
throw 'Legacy runtime source root must be outside and must not overlap this repository.'
|
|
}
|
|
|
|
$cutsRoot = Get-NormalizedFullPath -Path (Join-Path $Root 'Cuts')
|
|
$resRoot = Get-NormalizedFullPath -Path (Join-Path $Root 'Res')
|
|
if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container) -or
|
|
-not (Test-Path -LiteralPath $resRoot -PathType Container) -or
|
|
-not (Test-SamePath -Left ([IO.Path]::GetDirectoryName($cutsRoot)) -Right $Root) -or
|
|
-not (Test-SamePath -Left ([IO.Path]::GetDirectoryName($resRoot)) -Right $Root)) {
|
|
throw 'The selected folder must be the single common parent of Cuts and Res.'
|
|
}
|
|
}
|
|
|
|
function Get-ExistingRuntimeBinding {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $RepositoryRoot,
|
|
[Parameter(Mandatory = $true)][string] $RuntimeStorageRoot
|
|
)
|
|
|
|
$propsPath = Join-Path $RepositoryRoot 'Directory.Build.local.props'
|
|
if (-not (Test-Path -LiteralPath $propsPath)) {
|
|
return $null
|
|
}
|
|
|
|
[void](Assert-RegularFile -Path $propsPath -Description 'Local build props')
|
|
[xml] $xml = Get-Content -LiteralPath $propsPath -Raw -Encoding UTF8
|
|
$projects = @($xml.SelectNodes("/*[local-name()='Project']"))
|
|
$groups = @($xml.SelectNodes(
|
|
"/*[local-name()='Project']/*[local-name()='PropertyGroup']"))
|
|
$roots = @($xml.SelectNodes(
|
|
"/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeSourceRoot']"))
|
|
$modes = @($xml.SelectNodes(
|
|
"/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeAssetsMode']"))
|
|
if ($projects.Count -ne 1 -or $projects[0].Attributes.Count -ne 0 -or
|
|
$groups.Count -ne 1 -or $groups[0].Attributes.Count -ne 0 -or
|
|
$roots.Count -ne 1 -or $roots[0].Attributes.Count -ne 0 -or
|
|
$modes.Count -ne 1 -or $modes[0].Attributes.Count -ne 0 -or
|
|
@($groups[0].ChildNodes | Where-Object {
|
|
$_.NodeType -eq [Xml.XmlNodeType]::Element
|
|
}).Count -ne 2 -or
|
|
-not ([string] $modes[0].InnerText).Equals(
|
|
'Required',
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'Existing Directory.Build.local.props is not the exact generated Required runtime binding.'
|
|
}
|
|
|
|
$installedRoot = Get-NormalizedFullPath -Path ([string] $roots[0].InnerText)
|
|
if (-not (Test-Path -LiteralPath $installedRoot -PathType Container) -or
|
|
-not (Test-PathIsWithin -Root $RuntimeStorageRoot -Candidate $installedRoot)) {
|
|
throw 'Existing runtime binding is not an installed LocalAppData runtime bundle.'
|
|
}
|
|
Assert-NoReparsePointInTree -Root $installedRoot -Description 'Installed runtime bundle'
|
|
|
|
$manifestPath = Join-Path $installedRoot $manifestFileName
|
|
[void](Assert-RegularFile -Path $manifestPath -Description 'Installed runtime manifest')
|
|
return [pscustomobject]@{
|
|
PropsPath = $propsPath
|
|
InstalledRoot = $installedRoot
|
|
ManifestPath = $manifestPath
|
|
ManifestSha256 = (
|
|
Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
}
|
|
}
|
|
|
|
function Test-JsonIntegerValue {
|
|
param([AllowNull()][object] $Value)
|
|
|
|
return $Value -is [byte] -or
|
|
$Value -is [int16] -or
|
|
$Value -is [int32] -or
|
|
$Value -is [int64] -or
|
|
$Value -is [uint16] -or
|
|
$Value -is [uint32]
|
|
}
|
|
|
|
function Get-VerifiedRuntimeManifest {
|
|
param([Parameter(Mandatory = $true)][string] $ManifestPath)
|
|
|
|
$manifestItem = Assert-RegularFile `
|
|
-Path $ManifestPath `
|
|
-Description 'Generated runtime manifest'
|
|
if ($manifestItem.Length -le 0 -or
|
|
$manifestItem.Length -gt $maximumManifestLength) {
|
|
throw 'Generated runtime manifest is empty or exceeds the 4 MiB safety limit.'
|
|
}
|
|
|
|
$rawManifest = Get-Content -LiteralPath $ManifestPath -Raw -Encoding UTF8
|
|
try {
|
|
$manifest = $rawManifest | ConvertFrom-Json
|
|
}
|
|
catch {
|
|
throw [InvalidDataException]::new(
|
|
'Generated runtime manifest is not valid JSON.',
|
|
$_.Exception)
|
|
}
|
|
Assert-ExactObjectProperties `
|
|
-Value $manifest `
|
|
-Names @(
|
|
'schemaVersion',
|
|
'bundleType',
|
|
'fileCount',
|
|
'cutsFileCount',
|
|
'resFileCount',
|
|
'files') `
|
|
-Description 'Generated runtime manifest'
|
|
|
|
if ($manifest.schemaVersion -isnot [int32] -or
|
|
[int] $manifest.schemaVersion -ne 1 -or
|
|
$manifest.bundleType -isnot [string] -or
|
|
[string] $manifest.bundleType -cne
|
|
'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle' -or
|
|
$manifest.fileCount -isnot [int32] -or
|
|
$manifest.cutsFileCount -isnot [int32] -or
|
|
$manifest.resFileCount -isnot [int32]) {
|
|
throw 'Generated runtime manifest header types or values are invalid.'
|
|
}
|
|
|
|
$records = @($manifest.files)
|
|
$canonicalRecords = New-Object 'System.Collections.Generic.List[object]'
|
|
$seenPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
|
|
[StringComparer]::OrdinalIgnoreCase)
|
|
$cutsCount = 0
|
|
$resCount = 0
|
|
foreach ($record in $records) {
|
|
Assert-ExactObjectProperties `
|
|
-Value $record `
|
|
-Names @('path', 'length', 'sha256') `
|
|
-Description 'Generated runtime manifest file record'
|
|
if ($record.path -isnot [string] -or
|
|
-not (Test-JsonIntegerValue -Value $record.length) -or
|
|
[long] $record.length -lt 0 -or
|
|
$record.sha256 -isnot [string] -or
|
|
[string] $record.sha256 -cnotmatch '^[0-9A-F]{64}$') {
|
|
throw 'Generated runtime manifest file record types are invalid.'
|
|
}
|
|
|
|
$relativePath = [string] $record.path
|
|
if ([string]::IsNullOrWhiteSpace($relativePath) -or
|
|
$relativePath.Contains('\') -or
|
|
$relativePath.StartsWith('/', [StringComparison]::Ordinal) -or
|
|
$relativePath.EndsWith('/', [StringComparison]::Ordinal) -or
|
|
$relativePath.Split('/') -contains '..' -or
|
|
-not $seenPaths.Add($relativePath)) {
|
|
throw "Generated runtime manifest contains an unsafe or duplicate path: $relativePath"
|
|
}
|
|
if ($relativePath.StartsWith('Cuts/', [StringComparison]::Ordinal)) {
|
|
$cutsCount++
|
|
}
|
|
elseif ($relativePath.StartsWith('Res/', [StringComparison]::Ordinal)) {
|
|
$resCount++
|
|
}
|
|
else {
|
|
throw "Generated runtime manifest contains a file outside Cuts/Res: $relativePath"
|
|
}
|
|
if ([IO.Path]::GetFileName($relativePath).Equals(
|
|
'MmoneyCoder.ini',
|
|
[StringComparison]::OrdinalIgnoreCase)) {
|
|
throw 'Generated runtime manifest contains the credential-bearing database INI.'
|
|
}
|
|
|
|
$canonicalRecords.Add([ordered]@{
|
|
path = $relativePath
|
|
length = [long] $record.length
|
|
sha256 = [string] $record.sha256
|
|
})
|
|
}
|
|
|
|
if ([int] $manifest.fileCount -ne $records.Count -or
|
|
[int] $manifest.cutsFileCount -ne $cutsCount -or
|
|
[int] $manifest.resFileCount -ne $resCount -or
|
|
$cutsCount -le 0 -or
|
|
$resCount -ne $expectedResAssetCount) {
|
|
throw 'Generated runtime manifest counts do not match its exact file set.'
|
|
}
|
|
|
|
$canonicalManifest = [ordered]@{
|
|
schemaVersion = 1
|
|
bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle'
|
|
fileCount = [int] $records.Count
|
|
cutsFileCount = [int] $cutsCount
|
|
resFileCount = [int] $resCount
|
|
files = @($canonicalRecords.ToArray())
|
|
}
|
|
$expectedText = (
|
|
Get-CanonicalJsonText -Value $canonicalManifest -Depth 6
|
|
) + [Environment]::NewLine
|
|
if (-not (Test-ExactUtf8Text `
|
|
-Path $ManifestPath `
|
|
-ExpectedText $expectedText)) {
|
|
throw (
|
|
'Generated runtime manifest is not the exact canonical JSON contract; ' +
|
|
'duplicate/extra/case/type/format changes are rejected.')
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Path = Get-NormalizedFullPath -Path $ManifestPath
|
|
Sha256 = (
|
|
Get-FileHash -LiteralPath $ManifestPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
FileCount = [int] $records.Count
|
|
CutsFileCount = [int] $cutsCount
|
|
ResFileCount = [int] $resCount
|
|
Records = @($canonicalRecords.ToArray())
|
|
CanonicalText = $expectedText
|
|
}
|
|
}
|
|
|
|
function Assert-InstalledRuntimeMatchesManifest {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $InstalledRoot,
|
|
[Parameter(Mandatory = $true)][string] $ManifestPath
|
|
)
|
|
|
|
$verifiedManifest = Get-VerifiedRuntimeManifest -ManifestPath $ManifestPath
|
|
Assert-NoReparsePointInTree `
|
|
-Root $InstalledRoot `
|
|
-Description 'Installed runtime bundle'
|
|
$expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
|
|
[StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($record in @($verifiedManifest.Records)) {
|
|
$relativePath = [string] $record.path
|
|
if ([string]::IsNullOrWhiteSpace($relativePath) -or
|
|
$relativePath.Contains('\') -or
|
|
$relativePath.StartsWith('/', [StringComparison]::Ordinal) -or
|
|
$relativePath.Split('/') -contains '..') {
|
|
throw "Generated runtime manifest contains an unsafe path: $relativePath"
|
|
}
|
|
|
|
$path = Join-Path $InstalledRoot $relativePath.Replace('/', '\')
|
|
if (-not (Test-PathIsWithin -Root $InstalledRoot -Candidate $path)) {
|
|
throw "Generated runtime manifest escaped the installed root: $relativePath"
|
|
}
|
|
if (-not $expectedPaths.Add($relativePath)) {
|
|
throw "Generated runtime manifest contains a duplicate path: $relativePath"
|
|
}
|
|
$item = Assert-RegularFile -Path $path -Description 'Installed runtime payload'
|
|
if ([long] $item.Length -ne [long] $record.length -or
|
|
-not (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.Equals(
|
|
[string] $record.sha256,
|
|
[StringComparison]::OrdinalIgnoreCase)) {
|
|
throw "Installed runtime payload differs from the selected source: $relativePath"
|
|
}
|
|
}
|
|
|
|
[void] $expectedPaths.Add($manifestFileName)
|
|
$actualPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
|
|
[StringComparer]::OrdinalIgnoreCase)
|
|
$rootPrefix = $InstalledRoot.TrimEnd('\', '/') + '\'
|
|
foreach ($file in @(Get-ChildItem `
|
|
-LiteralPath $InstalledRoot `
|
|
-File `
|
|
-Recurse `
|
|
-Force)) {
|
|
$relativePath = $file.FullName.Substring($rootPrefix.Length).Replace('\', '/')
|
|
if (-not $actualPaths.Add($relativePath) -or
|
|
-not $expectedPaths.Contains($relativePath)) {
|
|
throw "Installed runtime bundle contains an unexpected file: $relativePath"
|
|
}
|
|
}
|
|
if ($actualPaths.Count -ne $expectedPaths.Count) {
|
|
throw 'Installed runtime bundle does not match the exact manifest file set.'
|
|
}
|
|
|
|
$installedManifestPath = Join-Path $InstalledRoot $manifestFileName
|
|
$installedManifestTextMatches = Test-ExactUtf8Text `
|
|
-Path $installedManifestPath `
|
|
-ExpectedText ([string] $verifiedManifest.CanonicalText)
|
|
$installedManifestHash = (
|
|
Get-FileHash `
|
|
-LiteralPath $installedManifestPath `
|
|
-Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if (-not $installedManifestTextMatches -or
|
|
-not $installedManifestHash.Equals(
|
|
[string] $verifiedManifest.Sha256,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'Installed runtime manifest differs from the exact generated manifest.'
|
|
}
|
|
}
|
|
|
|
function Get-VerifiedBundleCreatorResult {
|
|
param(
|
|
[Parameter(Mandatory = $true)][object] $Result,
|
|
[Parameter(Mandatory = $true)][string] $BundleOutput
|
|
)
|
|
|
|
Assert-ExactObjectProperties `
|
|
-Value $Result `
|
|
-Names @(
|
|
'OutputDirectory',
|
|
'ArchivePath',
|
|
'ArchiveSha256',
|
|
'ArchiveHashPath',
|
|
'ManifestPath',
|
|
'FileCount',
|
|
'CutsFileCount',
|
|
'ResFileCount') `
|
|
-Description 'Runtime bundle creation result'
|
|
foreach ($propertyName in @(
|
|
'OutputDirectory',
|
|
'ArchivePath',
|
|
'ArchiveSha256',
|
|
'ArchiveHashPath',
|
|
'ManifestPath')) {
|
|
if ($Result.$propertyName -isnot [string] -or
|
|
[string]::IsNullOrWhiteSpace([string] $Result.$propertyName)) {
|
|
throw "Runtime bundle creation result property '$propertyName' has an invalid type."
|
|
}
|
|
}
|
|
foreach ($propertyName in @('FileCount', 'CutsFileCount', 'ResFileCount')) {
|
|
if ($Result.$propertyName -isnot [int32]) {
|
|
throw "Runtime bundle creation result property '$propertyName' must be Int32."
|
|
}
|
|
}
|
|
if ([string] $Result.ArchiveSha256 -cnotmatch '^[0-9A-F]{64}$') {
|
|
throw 'Runtime bundle creation result has a non-canonical archive SHA-256.'
|
|
}
|
|
|
|
$outputRoot = Get-NormalizedFullPath -Path $BundleOutput
|
|
$archivePath = Get-NormalizedFullPath -Path ([string] $Result.ArchivePath)
|
|
$hashPath = Get-NormalizedFullPath -Path ([string] $Result.ArchiveHashPath)
|
|
$manifestPath = Get-NormalizedFullPath -Path ([string] $Result.ManifestPath)
|
|
if (-not (Test-SamePath `
|
|
-Left ([string] $Result.OutputDirectory) `
|
|
-Right $outputRoot) -or
|
|
-not (Test-SamePath `
|
|
-Left $archivePath `
|
|
-Right (Join-Path $outputRoot $archiveFileName)) -or
|
|
-not (Test-SamePath `
|
|
-Left $hashPath `
|
|
-Right (Join-Path $outputRoot $archiveHashFileName)) -or
|
|
-not (Test-SamePath `
|
|
-Left $manifestPath `
|
|
-Right (Join-Path $outputRoot $manifestFileName)) -or
|
|
-not (Test-PathIsWithin -Root $outputRoot -Candidate $archivePath) -or
|
|
-not (Test-PathIsWithin -Root $outputRoot -Candidate $hashPath) -or
|
|
-not (Test-PathIsWithin -Root $outputRoot -Candidate $manifestPath)) {
|
|
throw 'Runtime bundle creation result paths escaped or changed the exact output contract.'
|
|
}
|
|
Assert-NoReparsePointInTree `
|
|
-Root $outputRoot `
|
|
-Description 'Created runtime bundle output'
|
|
[void](Assert-RegularFile -Path $archivePath -Description 'Created runtime archive')
|
|
[void](Assert-RegularFile -Path $hashPath -Description 'Created runtime archive hash')
|
|
$actualArchiveHash = (
|
|
Get-FileHash -LiteralPath $archivePath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if (-not $actualArchiveHash.Equals(
|
|
[string] $Result.ArchiveSha256,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'Runtime bundle creation result archive SHA-256 does not match its file.'
|
|
}
|
|
$expectedHashText = (
|
|
[string] $Result.ArchiveSha256 + ' ' + $archiveFileName +
|
|
[Environment]::NewLine)
|
|
if (-not (Test-ExactUtf8Text `
|
|
-Path $hashPath `
|
|
-ExpectedText $expectedHashText)) {
|
|
throw 'Runtime bundle creation result hash sidecar is not canonical.'
|
|
}
|
|
|
|
$manifest = Get-VerifiedRuntimeManifest -ManifestPath $manifestPath
|
|
if ([int] $Result.FileCount -ne [int] $manifest.FileCount -or
|
|
[int] $Result.CutsFileCount -ne [int] $manifest.CutsFileCount -or
|
|
[int] $Result.ResFileCount -ne [int] $manifest.ResFileCount -or
|
|
[int] $Result.FileCount -ne
|
|
([int] $Result.CutsFileCount + [int] $Result.ResFileCount)) {
|
|
throw 'Runtime bundle creation result counts do not match its manifest.'
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Result = $Result
|
|
OutputDirectory = $outputRoot
|
|
ArchivePath = $archivePath
|
|
ArchiveSha256 = $actualArchiveHash
|
|
Manifest = $manifest
|
|
}
|
|
}
|
|
|
|
function Get-VerifiedBundleInitializerResult {
|
|
param(
|
|
[Parameter(Mandatory = $true)][object] $Result,
|
|
[Parameter(Mandatory = $true)][pscustomobject] $VerifiedBundle,
|
|
[Parameter(Mandatory = $true)][string] $ShadowRepositoryRoot,
|
|
[Parameter(Mandatory = $true)][string] $RuntimeStorageRoot
|
|
)
|
|
|
|
Assert-ExactObjectProperties `
|
|
-Value $Result `
|
|
-Names @(
|
|
'InstalledRoot',
|
|
'ArchivePath',
|
|
'ArchiveSha256',
|
|
'ManifestSha256',
|
|
'FileCount',
|
|
'CutsFileCount',
|
|
'ResFileCount',
|
|
'LocalBuildPropsPath') `
|
|
-Description 'Runtime bundle initialization result'
|
|
foreach ($propertyName in @(
|
|
'InstalledRoot',
|
|
'ArchivePath',
|
|
'ArchiveSha256',
|
|
'ManifestSha256',
|
|
'LocalBuildPropsPath')) {
|
|
if ($Result.$propertyName -isnot [string] -or
|
|
[string]::IsNullOrWhiteSpace([string] $Result.$propertyName)) {
|
|
throw "Runtime bundle initialization result property '$propertyName' has an invalid type."
|
|
}
|
|
}
|
|
foreach ($propertyName in @('FileCount', 'CutsFileCount', 'ResFileCount')) {
|
|
if ($Result.$propertyName -isnot [int32]) {
|
|
throw "Runtime bundle initialization result property '$propertyName' must be Int32."
|
|
}
|
|
}
|
|
if ([string] $Result.ArchiveSha256 -cnotmatch '^[0-9A-F]{64}$' -or
|
|
[string] $Result.ManifestSha256 -cnotmatch '^[0-9A-F]{64}$') {
|
|
throw 'Runtime bundle initialization result contains a non-canonical SHA-256.'
|
|
}
|
|
|
|
$installedRoot = Get-NormalizedFullPath -Path ([string] $Result.InstalledRoot)
|
|
$localPropsPath = Get-NormalizedFullPath -Path (
|
|
[string] $Result.LocalBuildPropsPath)
|
|
$archivePathMatches = Test-SamePath `
|
|
-Left ([string] $Result.ArchivePath) `
|
|
-Right ([string] $VerifiedBundle.ArchivePath)
|
|
if (-not $archivePathMatches -or
|
|
-not ([string] $Result.ArchiveSha256).Equals(
|
|
[string] $VerifiedBundle.ArchiveSha256,
|
|
[StringComparison]::Ordinal) -or
|
|
-not ([string] $Result.ManifestSha256).Equals(
|
|
[string] $VerifiedBundle.Manifest.Sha256,
|
|
[StringComparison]::Ordinal) -or
|
|
-not (Test-PathIsWithin `
|
|
-Root $RuntimeStorageRoot `
|
|
-Candidate $installedRoot) -or
|
|
-not ([IO.Path]::GetDirectoryName($installedRoot)).Equals(
|
|
(Get-NormalizedFullPath -Path $RuntimeStorageRoot).TrimEnd('\', '/'),
|
|
[StringComparison]::OrdinalIgnoreCase) -or
|
|
-not ([IO.Path]::GetFileName($installedRoot)).Equals(
|
|
[string] $VerifiedBundle.ArchiveSha256,
|
|
[StringComparison]::Ordinal) -or
|
|
-not (Test-SamePath `
|
|
-Left $localPropsPath `
|
|
-Right (Join-Path $ShadowRepositoryRoot 'Directory.Build.local.props')) -or
|
|
-not (Test-PathIsWithin `
|
|
-Root $ShadowRepositoryRoot `
|
|
-Candidate $localPropsPath)) {
|
|
throw 'Runtime bundle initialization result paths or hashes violate containment.'
|
|
}
|
|
if ([int] $Result.FileCount -ne [int] $VerifiedBundle.Manifest.FileCount -or
|
|
[int] $Result.CutsFileCount -ne [int] $VerifiedBundle.Manifest.CutsFileCount -or
|
|
[int] $Result.ResFileCount -ne [int] $VerifiedBundle.Manifest.ResFileCount) {
|
|
throw 'Runtime bundle initialization result counts differ from the verified manifest.'
|
|
}
|
|
|
|
Assert-NoReparsePointInTree `
|
|
-Root $installedRoot `
|
|
-Description 'Initialized runtime bundle'
|
|
[void](Assert-RegularFile `
|
|
-Path $localPropsPath `
|
|
-Description 'Generated local runtime binding')
|
|
Assert-InstalledRuntimeMatchesManifest `
|
|
-InstalledRoot $installedRoot `
|
|
-ManifestPath ([string] $VerifiedBundle.Manifest.Path)
|
|
$shadowBinding = Get-ExistingRuntimeBinding `
|
|
-RepositoryRoot $ShadowRepositoryRoot `
|
|
-RuntimeStorageRoot $RuntimeStorageRoot
|
|
if ($null -eq $shadowBinding -or
|
|
-not (Test-SamePath `
|
|
-Left $shadowBinding.InstalledRoot `
|
|
-Right $installedRoot) -or
|
|
-not $shadowBinding.ManifestSha256.Equals(
|
|
[string] $VerifiedBundle.Manifest.Sha256,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'Generated local runtime binding does not match the initialized bundle.'
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
InstalledRoot = $installedRoot
|
|
ArchiveSha256 = [string] $Result.ArchiveSha256
|
|
ManifestSha256 = [string] $Result.ManifestSha256
|
|
FileCount = [int] $Result.FileCount
|
|
CutsFileCount = [int] $Result.CutsFileCount
|
|
ResFileCount = [int] $Result.ResFileCount
|
|
LocalBuildPropsPath = $localPropsPath
|
|
}
|
|
}
|
|
|
|
function Get-VerifiedK3DRegistration {
|
|
param([Parameter(Mandatory = $true)][object[]] $Reports)
|
|
|
|
if ($Reports.Count -ne 1) {
|
|
throw 'The read-only K3D x64 registration inspection did not return one result.'
|
|
}
|
|
$registration = $Reports[0]
|
|
Assert-ExactObjectProperties `
|
|
-Value $registration `
|
|
-Names @(
|
|
'Status',
|
|
'RegistryHive',
|
|
'RegistryView',
|
|
'CurrentUserOverrides',
|
|
'TypeLibId',
|
|
'TypeLibVersion',
|
|
'TypeLibTarget',
|
|
'TypeLibPath',
|
|
'TypeLibMachine',
|
|
'Classes',
|
|
'PowerShellProcessIs64Bit',
|
|
'ComActivated') `
|
|
-Description 'K3D registration inspection result'
|
|
foreach ($propertyName in @(
|
|
'Status',
|
|
'RegistryHive',
|
|
'RegistryView',
|
|
'CurrentUserOverrides',
|
|
'TypeLibId',
|
|
'TypeLibVersion',
|
|
'TypeLibTarget',
|
|
'TypeLibPath',
|
|
'TypeLibMachine')) {
|
|
if ($registration.$propertyName -isnot [string]) {
|
|
throw "K3D registration property '$propertyName' must be a string."
|
|
}
|
|
}
|
|
if ($registration.PowerShellProcessIs64Bit -isnot [bool] -or
|
|
$registration.ComActivated -isnot [bool] -or
|
|
[string] $registration.Status -cne 'Valid' -or
|
|
[string] $registration.RegistryHive -cne 'HKLM' -or
|
|
[string] $registration.RegistryView -cne 'Registry64' -or
|
|
[string] $registration.CurrentUserOverrides -cne 'None' -or
|
|
[string] $registration.TypeLibId -cne
|
|
'{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}' -or
|
|
[string] $registration.TypeLibVersion -cne '1.0' -or
|
|
[string] $registration.TypeLibTarget -cne 'win64' -or
|
|
[string] $registration.TypeLibMachine -cne 'AMD64' -or
|
|
$registration.PowerShellProcessIs64Bit -ne $true -or
|
|
$registration.ComActivated -ne $false) {
|
|
throw 'The read-only K3D x64 registration inspection result is invalid.'
|
|
}
|
|
|
|
$expectedClasses = [ordered]@{
|
|
KAEngine = [ordered]@{
|
|
ClassId = '{D756CDBE-AA31-42B2-9CC7-018753CA61BF}'
|
|
ProgId = 'K3DAsyncEngine.KAEngine.1'
|
|
}
|
|
KAEventHandler = [ordered]@{
|
|
ClassId = '{39828C77-EFF0-4E59-979B-8673C028C718}'
|
|
ProgId = 'K3DAsyncEngine.KAEventHandler.1'
|
|
}
|
|
}
|
|
$classes = @($registration.Classes)
|
|
if ($classes.Count -ne 2) {
|
|
throw 'K3D registration inspection must return exactly two classes.'
|
|
}
|
|
$seenClassNames = New-Object 'System.Collections.Generic.HashSet[string]' (
|
|
[StringComparer]::Ordinal)
|
|
foreach ($classReport in $classes) {
|
|
Assert-ExactObjectProperties `
|
|
-Value $classReport `
|
|
-Names @(
|
|
'Name',
|
|
'ClassId',
|
|
'ProgId',
|
|
'ServerPath',
|
|
'ServerMachine',
|
|
'ThreadingModel',
|
|
'ReciprocalMapping') `
|
|
-Description 'K3D class registration result'
|
|
foreach ($propertyName in @(
|
|
'Name',
|
|
'ClassId',
|
|
'ProgId',
|
|
'ServerPath',
|
|
'ServerMachine',
|
|
'ThreadingModel')) {
|
|
if ($classReport.$propertyName -isnot [string]) {
|
|
throw "K3D class registration property '$propertyName' must be a string."
|
|
}
|
|
}
|
|
if ($classReport.ReciprocalMapping -isnot [bool]) {
|
|
throw 'K3D class ReciprocalMapping must be Boolean.'
|
|
}
|
|
$name = [string] $classReport.Name
|
|
if (-not $expectedClasses.Contains($name) -or
|
|
-not $seenClassNames.Add($name) -or
|
|
[string] $classReport.ClassId -cne
|
|
[string] $expectedClasses[$name].ClassId -or
|
|
[string] $classReport.ProgId -cne
|
|
[string] $expectedClasses[$name].ProgId -or
|
|
[string] $classReport.ServerMachine -cne 'AMD64' -or
|
|
[string] $classReport.ThreadingModel -cne 'Apartment' -or
|
|
$classReport.ReciprocalMapping -ne $true) {
|
|
throw 'The read-only K3D x64 class registration shape is invalid.'
|
|
}
|
|
}
|
|
|
|
return $registration
|
|
}
|
|
|
|
function Set-RuntimeBindingAtomically {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $GeneratedPropsPath,
|
|
[Parameter(Mandatory = $true)][string] $RepositoryRoot,
|
|
[switch] $Replace
|
|
)
|
|
|
|
[void](Assert-RegularFile `
|
|
-Path $GeneratedPropsPath `
|
|
-Description 'Generated local runtime binding')
|
|
$generatedHash = (
|
|
Get-FileHash -LiteralPath $GeneratedPropsPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
$destination = Join-Path $RepositoryRoot 'Directory.Build.local.props'
|
|
$temporary = Join-Path $RepositoryRoot (
|
|
'.Directory.Build.local.props.' + [Guid]::NewGuid().ToString('N') + '.tmp')
|
|
$backup = Join-Path $RepositoryRoot (
|
|
'.Directory.Build.local.props.' + [Guid]::NewGuid().ToString('N') + '.bak')
|
|
$preserveBackup = $false
|
|
try {
|
|
[IO.File]::Copy($GeneratedPropsPath, $temporary, $false)
|
|
if (Test-Path -LiteralPath $destination) {
|
|
if (-not $Replace) {
|
|
throw 'Refusing to replace the existing runtime binding.'
|
|
}
|
|
[IO.File]::Replace($temporary, $destination, $backup)
|
|
$temporary = $null
|
|
try {
|
|
[xml] $verification = Get-Content `
|
|
-LiteralPath $destination `
|
|
-Raw `
|
|
-Encoding UTF8
|
|
if ($null -eq $verification.Project.PropertyGroup.LegacyRuntimeSourceRoot -or
|
|
[string] $verification.Project.PropertyGroup.LegacyRuntimeAssetsMode -cne
|
|
'Required' -or
|
|
-not (Get-FileHash `
|
|
-LiteralPath $destination `
|
|
-Algorithm SHA256).Hash.Equals(
|
|
$generatedHash,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'The atomically replaced runtime binding failed verification.'
|
|
}
|
|
}
|
|
catch {
|
|
try {
|
|
[IO.File]::Replace($backup, $destination, $null)
|
|
$backup = $null
|
|
}
|
|
catch {
|
|
$preserveBackup = $true
|
|
throw
|
|
}
|
|
throw
|
|
}
|
|
}
|
|
else {
|
|
[IO.File]::Move($temporary, $destination)
|
|
$temporary = $null
|
|
if (-not (Get-FileHash `
|
|
-LiteralPath $destination `
|
|
-Algorithm SHA256).Hash.Equals(
|
|
$generatedHash,
|
|
[StringComparison]::Ordinal)) {
|
|
[IO.File]::Delete($destination)
|
|
throw 'The atomically installed runtime binding failed verification.'
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
if (-not [string]::IsNullOrWhiteSpace($temporary) -and
|
|
(Test-Path -LiteralPath $temporary)) {
|
|
[IO.File]::Delete($temporary)
|
|
}
|
|
if (-not $preserveBackup -and
|
|
-not [string]::IsNullOrWhiteSpace($backup) -and
|
|
(Test-Path -LiteralPath $backup)) {
|
|
[IO.File]::Delete($backup)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-CanonicalJsonText {
|
|
param(
|
|
[Parameter(Mandatory = $true)][object] $Value,
|
|
[Parameter(Mandatory = $true)][int] $Depth
|
|
)
|
|
|
|
return ($Value | ConvertTo-Json -Depth $Depth)
|
|
}
|
|
|
|
function Test-ExistingJsonContract {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedJson
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
return $false
|
|
}
|
|
try {
|
|
$item = Assert-RegularFile -Path $Path -Description 'Development Live configuration'
|
|
if ($item.Length -le 0 -or $item.Length -gt 1MB -or
|
|
-not (Test-UserOnlyFileAcl -Path $Path)) {
|
|
return $false
|
|
}
|
|
# Exact UTF-8 bytes are the contract. This rejects duplicate/extra keys,
|
|
# case changes, type coercion, reordered properties, alternate whitespace,
|
|
# and a BOM instead of normalizing them through ConvertFrom-Json.
|
|
return Test-ExactUtf8Text -Path $Path -ExpectedText $ExpectedJson
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Assert-ExistingJsonContract {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Path,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedJson,
|
|
[Parameter(Mandatory = $true)][string] $Description
|
|
)
|
|
|
|
if (-not (Test-ExistingJsonContract `
|
|
-Path $Path `
|
|
-ExpectedJson $ExpectedJson)) {
|
|
throw "$Description failed its exact JSON/ACL verification."
|
|
}
|
|
}
|
|
|
|
function Get-DevelopmentLivePreflight {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $ConfigurationRoot,
|
|
[Parameter(Mandatory = $true)][string] $HostName,
|
|
[Parameter(Mandatory = $true)][int] $Port,
|
|
[Parameter(Mandatory = $true)][string] $ApprovedNativeHash,
|
|
[Parameter(Mandatory = $true)][string] $ApprovedInteropHash,
|
|
[AllowNull()][Nullable[int]] $Channel,
|
|
[switch] $AllowReplacement
|
|
)
|
|
|
|
$sceneAllowlist = @(
|
|
'5001', '5006', '5011', '5016', '50160', '5023', '5024', '5025',
|
|
'5026', '5029', '5032', '5037', '5068', '5070', '5072', '5074',
|
|
'5076', '5077', '5078', '5079', '5080', '5081', '5082', '5083',
|
|
'5084', '5085', '5086', '50860', '5087', '5088', '6001', '6067',
|
|
'8001', '8002', '8003', '8018', '8032', '8035', '8040', '8046',
|
|
'8051', '8056', '8061', '8067', 'N5001')
|
|
$playout = [ordered]@{
|
|
mode = 'DryRun'
|
|
host = $HostName
|
|
port = $Port
|
|
tcpMode = 1
|
|
clientPort = 0
|
|
sceneDirectory = $null
|
|
outputChannel = $Channel
|
|
layoutIndex = 10
|
|
legacySceneFadeDuration = 6
|
|
legacySceneBackgroundKind = 'None'
|
|
legacySceneBackgroundAssetPath = $null
|
|
legacySceneBackgroundVideoLoopCount = 2004
|
|
legacySceneBackgroundVideoLoopInfinite = $true
|
|
legacyBackgroundDirectory = $null
|
|
testProcessWindowTitlePattern = $null
|
|
testSceneAllowlist = $sceneAllowlist
|
|
trustedLiveOutputEnabled = $true
|
|
queueCapacity = 64
|
|
connectTimeoutMilliseconds = 5000
|
|
operationTimeoutMilliseconds = 5000
|
|
disconnectTimeoutMilliseconds = 3000
|
|
processPollIntervalMilliseconds = 1000
|
|
reconnectDelayMilliseconds = 1000
|
|
maximumReconnectAttempts = 0
|
|
reconnectEnabled = $false
|
|
maximumAutomaticRefreshesPerTakeIn = 0
|
|
}
|
|
$authorization = [ordered]@{
|
|
schemaVersion = 1
|
|
mode = 'Live'
|
|
authorization = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH'
|
|
nativeSha256 = $ApprovedNativeHash.ToUpperInvariant()
|
|
interopSha256 = $ApprovedInteropHash.ToUpperInvariant()
|
|
}
|
|
$playoutJson = Get-CanonicalJsonText -Value $playout -Depth 6
|
|
$authorizationJson = Get-CanonicalJsonText -Value $authorization -Depth 3
|
|
|
|
$playoutPath = Join-Path $ConfigurationRoot 'playout.local.json'
|
|
$authorizationPath = Join-Path $ConfigurationRoot (
|
|
'playout.development-live.local.json')
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $ConfigurationRoot `
|
|
-Description 'Development Live configuration directory'
|
|
$playoutExists = Test-Path -LiteralPath $playoutPath
|
|
$authorizationExists = Test-Path -LiteralPath $authorizationPath
|
|
if ($playoutExists) {
|
|
[void](Assert-RegularFile `
|
|
-Path $playoutPath `
|
|
-Description 'Development Live base configuration')
|
|
}
|
|
if ($authorizationExists) {
|
|
[void](Assert-RegularFile `
|
|
-Path $authorizationPath `
|
|
-Description 'Development Live authorization')
|
|
}
|
|
$playoutMatches = -not $playoutExists -or
|
|
(Test-ExistingJsonContract `
|
|
-Path $playoutPath `
|
|
-ExpectedJson $playoutJson)
|
|
$authorizationMatches = -not $authorizationExists -or
|
|
(Test-ExistingJsonContract `
|
|
-Path $authorizationPath `
|
|
-ExpectedJson $authorizationJson)
|
|
if ((-not $playoutMatches -or -not $authorizationMatches) -and
|
|
-not $AllowReplacement) {
|
|
# A mismatched file is never left usable merely because replacement was
|
|
# refused. The caller asked to configure Live, so failure is fail-closed.
|
|
Remove-DevelopmentLiveAuthorizationFailClosed -Path $authorizationPath
|
|
throw (
|
|
'Existing Development Live files differ from the exact requested ' +
|
|
'endpoint/hash contract. Re-run with -ReplaceLiveConfig.')
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
# Every requested setup invalidates any old one-launch authorization and
|
|
# issues a new file only after runtime/DB/build/cleanup success.
|
|
NeedsWrite = $true
|
|
NeedsForce = [bool] ($playoutExists -or $authorizationExists)
|
|
PlayoutPath = $playoutPath
|
|
AuthorizationPath = $authorizationPath
|
|
PlayoutJson = $playoutJson
|
|
AuthorizationJson = $authorizationJson
|
|
}
|
|
}
|
|
|
|
function Copy-DatabaseProfileAtomically {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $Source,
|
|
[Parameter(Mandatory = $true)][string] $Destination,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedHash,
|
|
[switch] $Replace
|
|
)
|
|
|
|
$directory = [IO.Path]::GetDirectoryName($Destination)
|
|
[IO.Directory]::CreateDirectory($directory) | Out-Null
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $directory `
|
|
-Description 'Local database profile directory'
|
|
Set-UserOnlyDirectoryAcl -Path $directory
|
|
|
|
$temporaryPath = Join-Path $directory (
|
|
'.MmoneyCoder.' + [Guid]::NewGuid().ToString('N') + '.tmp')
|
|
$backupPath = Join-Path $directory (
|
|
'.MmoneyCoder.' + [Guid]::NewGuid().ToString('N') + '.bak')
|
|
$preserveBackup = $false
|
|
$previousDestinationLength = [long] 0
|
|
$previousDestinationHash = $null
|
|
try {
|
|
[IO.File]::Copy($Source, $temporaryPath, $false)
|
|
$temporaryItem = Assert-RegularFile `
|
|
-Path $temporaryPath `
|
|
-Description 'Temporary database profile'
|
|
if ($temporaryItem.Length -le 0 -or
|
|
$temporaryItem.Length -gt $maximumDatabaseIniLength -or
|
|
-not (Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256).Hash.Equals(
|
|
$ExpectedHash,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'Database profile changed while it was copied.'
|
|
}
|
|
[IO.File]::SetAccessControl($temporaryPath, (Get-UserOnlyAcl))
|
|
|
|
if (Test-Path -LiteralPath $Destination) {
|
|
if (-not $Replace) {
|
|
throw 'Refusing to replace the existing database profile.'
|
|
}
|
|
$previousDestinationItem = Assert-RegularFile `
|
|
-Path $Destination `
|
|
-Description 'Previous installed database profile'
|
|
$previousDestinationLength = [long] $previousDestinationItem.Length
|
|
$previousDestinationHash = (
|
|
Get-FileHash -LiteralPath $Destination -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
[IO.File]::Replace($temporaryPath, $Destination, $backupPath)
|
|
$temporaryPath = $null
|
|
}
|
|
else {
|
|
[IO.File]::Move($temporaryPath, $Destination)
|
|
$temporaryPath = $null
|
|
}
|
|
|
|
try {
|
|
if (Test-Path -LiteralPath $backupPath) {
|
|
# File.Replace creates a second copy of the old credential file.
|
|
# Protect and hash-check that copy in the immediately following
|
|
# statements before continuing with the new destination.
|
|
[IO.File]::SetAccessControl($backupPath, (Get-UserOnlyAcl))
|
|
$backupItem = Assert-RegularFile `
|
|
-Path $backupPath `
|
|
-Description 'Previous database profile rollback backup'
|
|
$backupHash = (
|
|
Get-FileHash -LiteralPath $backupPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if ($backupItem.Length -ne $previousDestinationLength -or
|
|
-not $backupHash.Equals(
|
|
$previousDestinationHash,
|
|
[StringComparison]::Ordinal) -or
|
|
-not (Test-UserOnlyFileAcl -Path $backupPath)) {
|
|
throw 'Previous database profile backup failed immediate ACL/hash verification.'
|
|
}
|
|
}
|
|
[IO.File]::SetAccessControl($Destination, (Get-UserOnlyAcl))
|
|
$installedItem = Assert-RegularFile `
|
|
-Path $Destination `
|
|
-Description 'Installed database profile'
|
|
if ($installedItem.Length -le 0 -or
|
|
$installedItem.Length -gt $maximumDatabaseIniLength -or
|
|
-not (Get-FileHash `
|
|
-LiteralPath $Destination `
|
|
-Algorithm SHA256).Hash.Equals(
|
|
$ExpectedHash,
|
|
[StringComparison]::Ordinal) -or
|
|
-not (Test-UserOnlyFileAcl -Path $Destination)) {
|
|
throw 'The atomically installed database profile failed verification.'
|
|
}
|
|
}
|
|
catch {
|
|
if (Test-Path -LiteralPath $backupPath) {
|
|
try {
|
|
[IO.File]::Replace($backupPath, $Destination, $null)
|
|
$backupPath = $null
|
|
}
|
|
catch {
|
|
$preserveBackup = $true
|
|
throw
|
|
}
|
|
}
|
|
elseif (Test-Path -LiteralPath $Destination) {
|
|
[IO.File]::Delete($Destination)
|
|
}
|
|
throw
|
|
}
|
|
}
|
|
finally {
|
|
if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and
|
|
(Test-Path -LiteralPath $temporaryPath)) {
|
|
[IO.File]::Delete($temporaryPath)
|
|
}
|
|
if (-not $preserveBackup -and
|
|
-not [string]::IsNullOrWhiteSpace($backupPath) -and
|
|
(Test-Path -LiteralPath $backupPath)) {
|
|
[IO.File]::Delete($backupPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-DebugRuntimePayloadMatchesManifest {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $TargetDirectory,
|
|
[Parameter(Mandatory = $true)][string] $ManifestPath,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedManifestSha256
|
|
)
|
|
|
|
$targetRoot = Get-NormalizedFullPath -Path $TargetDirectory
|
|
Assert-NoReparsePointInTree `
|
|
-Root $targetRoot `
|
|
-Description 'Debug x64 output'
|
|
$manifest = Get-VerifiedRuntimeManifest -ManifestPath $ManifestPath
|
|
if (-not ([string] $manifest.Sha256).Equals(
|
|
$ExpectedManifestSha256,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'Debug x64 verification received an unexpected runtime manifest.'
|
|
}
|
|
|
|
$expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
|
|
[StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($record in @($manifest.Records)) {
|
|
$relativePath = [string] $record.path
|
|
if (-not $expectedPaths.Add($relativePath)) {
|
|
throw "Debug x64 manifest contains a duplicate path: $relativePath"
|
|
}
|
|
$outputPath = Get-NormalizedFullPath -Path (
|
|
Join-Path $targetRoot $relativePath.Replace('/', '\'))
|
|
if (-not (Test-PathIsWithin -Root $targetRoot -Candidate $outputPath)) {
|
|
throw "Debug x64 manifest path escaped the output: $relativePath"
|
|
}
|
|
$outputItem = Assert-RegularFile `
|
|
-Path $outputPath `
|
|
-Description 'Debug x64 runtime payload'
|
|
$outputHash = (
|
|
Get-FileHash -LiteralPath $outputPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if ($outputItem.Length -ne [long] $record.length -or
|
|
-not $outputHash.Equals(
|
|
[string] $record.sha256,
|
|
[StringComparison]::Ordinal)) {
|
|
throw "Debug x64 runtime payload differs from the manifest: $relativePath"
|
|
}
|
|
}
|
|
|
|
$actualPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
|
|
[StringComparer]::OrdinalIgnoreCase)
|
|
$targetPrefix = $targetRoot.TrimEnd('\', '/') + '\'
|
|
foreach ($assetRootName in @('Cuts', 'Res')) {
|
|
$assetRoot = Join-Path $targetRoot $assetRootName
|
|
if (-not (Test-Path -LiteralPath $assetRoot -PathType Container) -or
|
|
-not (Test-SamePath `
|
|
-Left ([IO.Path]::GetDirectoryName($assetRoot)) `
|
|
-Right $targetRoot)) {
|
|
throw "Debug x64 $assetRootName output directory is missing."
|
|
}
|
|
Assert-NoReparsePointInTree `
|
|
-Root $assetRoot `
|
|
-Description "Debug x64 $assetRootName output"
|
|
foreach ($file in @(Get-ChildItem `
|
|
-LiteralPath $assetRoot `
|
|
-File `
|
|
-Recurse `
|
|
-Force)) {
|
|
$relativePath = $file.FullName.Substring(
|
|
$targetPrefix.Length).Replace('\', '/')
|
|
if (-not $actualPaths.Add($relativePath) -or
|
|
-not $expectedPaths.Contains($relativePath)) {
|
|
throw "Debug x64 runtime output contains an unexpected file: $relativePath"
|
|
}
|
|
}
|
|
}
|
|
if ($actualPaths.Count -ne $expectedPaths.Count) {
|
|
throw 'Debug x64 runtime output does not match the exact manifest file set.'
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
CutsFileCount = [int] $manifest.CutsFileCount
|
|
ResFileCount = [int] $manifest.ResFileCount
|
|
}
|
|
}
|
|
|
|
function Invoke-DebugBuildAndVerify {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string] $ProjectPath,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedRuntimeRoot,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedManifestPath,
|
|
[Parameter(Mandatory = $true)][string] $ExpectedManifestSha256
|
|
)
|
|
|
|
& dotnet build $ProjectPath -c Debug '-p:Platform=x64' |
|
|
ForEach-Object { Write-Host $_ }
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Debug x64 build failed with exit code $LASTEXITCODE."
|
|
}
|
|
|
|
$propertyText = (& dotnet msbuild $ProjectPath `
|
|
'-p:Configuration=Debug' `
|
|
'-p:Platform=x64' `
|
|
'-getProperty:TargetDir' `
|
|
'-getProperty:LegacyRuntimeAssetsEnabled' `
|
|
'-getProperty:LegacyRuntimeAssetsMode' `
|
|
'-getProperty:LegacyRuntimeSourceRoot' | Out-String)
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Could not query Debug x64 build properties (exit code $LASTEXITCODE)."
|
|
}
|
|
$propertyEnvelope = $propertyText | ConvertFrom-Json
|
|
Assert-ExactObjectProperties `
|
|
-Value $propertyEnvelope `
|
|
-Names @('Properties') `
|
|
-Description 'Debug x64 MSBuild property envelope'
|
|
$properties = $propertyEnvelope.Properties
|
|
Assert-ExactObjectProperties `
|
|
-Value $properties `
|
|
-Names @(
|
|
'TargetDir',
|
|
'LegacyRuntimeAssetsEnabled',
|
|
'LegacyRuntimeAssetsMode',
|
|
'LegacyRuntimeSourceRoot') `
|
|
-Description 'Debug x64 MSBuild properties'
|
|
foreach ($propertyName in @(
|
|
'TargetDir',
|
|
'LegacyRuntimeAssetsEnabled',
|
|
'LegacyRuntimeAssetsMode',
|
|
'LegacyRuntimeSourceRoot')) {
|
|
if ($properties.$propertyName -isnot [string]) {
|
|
throw "Debug x64 MSBuild property '$propertyName' must be a string."
|
|
}
|
|
}
|
|
if ([string] $properties.LegacyRuntimeAssetsEnabled -cne 'true' -or
|
|
[string] $properties.LegacyRuntimeAssetsMode -cne 'Required' -or
|
|
-not (Test-SamePath `
|
|
-Left ([string] $properties.LegacyRuntimeSourceRoot) `
|
|
-Right $ExpectedRuntimeRoot)) {
|
|
throw 'Debug x64 unexpectedly resolved as SourceOnly or used another runtime root.'
|
|
}
|
|
|
|
$targetDir = Get-NormalizedFullPath -Path ([string] $properties.TargetDir
|
|
)
|
|
$projectDirectory = Get-NormalizedFullPath -Path (
|
|
[IO.Path]::GetDirectoryName($ProjectPath))
|
|
$expectedBinRoot = Join-Path $projectDirectory 'bin'
|
|
if ($targetDir.IndexOf(
|
|
([IO.Path]::DirectorySeparatorChar + 'SourceOnly' +
|
|
[IO.Path]::DirectorySeparatorChar),
|
|
[StringComparison]::OrdinalIgnoreCase) -ge 0 -or
|
|
-not (Test-Path -LiteralPath $targetDir -PathType Container) -or
|
|
-not (Test-PathIsWithin -Root $expectedBinRoot -Candidate $targetDir)) {
|
|
throw 'Debug x64 target directory is missing or is a SourceOnly output.'
|
|
}
|
|
|
|
$payload = Assert-DebugRuntimePayloadMatchesManifest `
|
|
-TargetDirectory $targetDir `
|
|
-ManifestPath $ExpectedManifestPath `
|
|
-ExpectedManifestSha256 $ExpectedManifestSha256
|
|
if (@(Get-ChildItem -LiteralPath $targetDir -File -Recurse -Force |
|
|
Where-Object {
|
|
$_.Name.Equals('MmoneyCoder.ini', [StringComparison]::OrdinalIgnoreCase)
|
|
}).Count -ne 0) {
|
|
throw 'Credential-bearing MmoneyCoder.ini entered the Debug build output.'
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
TargetDirectory = $targetDir
|
|
CutsFileCount = [int] $payload.CutsFileCount
|
|
ResFileCount = [int] $payload.ResFileCount
|
|
SourceOnly = $false
|
|
}
|
|
}
|
|
|
|
$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
|
|
$projectPath = Join-Path $repositoryRoot (
|
|
'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj')
|
|
$bundleCreatorPath = Join-Path $PSScriptRoot 'New-LegacyRuntimeBundle.ps1'
|
|
$bundleInitializerPath = Join-Path $PSScriptRoot 'Initialize-LegacyRuntimeBundle.ps1'
|
|
$registrationInspectorPath = Join-Path $PSScriptRoot 'Inspect-K3DRegistration.ps1'
|
|
$liveInitializerPath = Join-Path $PSScriptRoot 'Initialize-DevelopmentLiveConfig.ps1'
|
|
|
|
foreach ($requiredScript in @(
|
|
$bundleCreatorPath,
|
|
$bundleInitializerPath,
|
|
$registrationInspectorPath,
|
|
$liveInitializerPath)) {
|
|
[void](Assert-RegularFile -Path $requiredScript -Description 'Required setup script')
|
|
}
|
|
|
|
if ($ReplaceLiveConfig -and -not $ConfigureDevelopmentLive) {
|
|
throw '-ReplaceLiveConfig requires -ConfigureDevelopmentLive.'
|
|
}
|
|
if ($ConfigureDevelopmentLive -and $SkipBuild) {
|
|
throw '-ConfigureDevelopmentLive requires the default Debug x64 build verification.'
|
|
}
|
|
if (-not $ConfigureDevelopmentLive -and
|
|
(-not [string]::IsNullOrWhiteSpace($PlayoutHost) -or
|
|
$PlayoutPort -ne 0 -or
|
|
-not [string]::IsNullOrWhiteSpace($NativeSha256) -or
|
|
-not [string]::IsNullOrWhiteSpace($InteropSha256) -or
|
|
$null -ne $OutputChannel)) {
|
|
throw 'Development Live endpoint/hash options require -ConfigureDevelopmentLive.'
|
|
}
|
|
|
|
$runtimeSourceRoot = Select-LegacyRuntimeRoot `
|
|
-RepositoryRoot $repositoryRoot `
|
|
-RequestedRoot $LegacyRuntimeSourceRoot `
|
|
-DisablePicker:$NoFolderPicker
|
|
Assert-LegacyRuntimeRoot -Root $runtimeSourceRoot -RepositoryRoot $repositoryRoot
|
|
|
|
# This is a read-only registry/file inspection. It never activates a COM class.
|
|
$registrationReports = @(& $registrationInspectorPath)
|
|
$registration = Get-VerifiedK3DRegistration -Reports $registrationReports
|
|
$registeredClasses = @($registration.Classes)
|
|
$nativePath = Get-NormalizedFullPath -Path ([string] $registration.TypeLibPath)
|
|
[void](Assert-RegularFile -Path $nativePath -Description 'Registered K3D native DLL')
|
|
Assert-FixedLocalPath -Path $nativePath -Description 'Registered K3D native DLL'
|
|
foreach ($classReport in $registeredClasses) {
|
|
if (-not (Test-SamePath `
|
|
-Left ([string] $classReport.ServerPath) `
|
|
-Right $nativePath)) {
|
|
throw 'The K3D classes and type library do not resolve to one native DLL.'
|
|
}
|
|
}
|
|
|
|
$releaseDirectory = [IO.Directory]::GetParent($nativePath)
|
|
$x64Directory = if ($null -ne $releaseDirectory) { $releaseDirectory.Parent } else { $null }
|
|
$dllDirectory = if ($null -ne $x64Directory) { $x64Directory.Parent } else { $null }
|
|
$vendorRoot = if ($null -ne $dllDirectory) { $dllDirectory.Parent } else { $null }
|
|
if ($null -eq $vendorRoot -or
|
|
-not ([IO.Path]::GetFileName($nativePath)).Equals(
|
|
'K3DAsyncEngine.dll',
|
|
[StringComparison]::OrdinalIgnoreCase) -or
|
|
-not $releaseDirectory.Name.Equals('Release', [StringComparison]::OrdinalIgnoreCase) -or
|
|
-not $x64Directory.Name.Equals('x64', [StringComparison]::OrdinalIgnoreCase) -or
|
|
-not $dllDirectory.Name.Equals('DLL', [StringComparison]::OrdinalIgnoreCase)) {
|
|
throw 'The registered K3D native DLL is outside the supported vendor layout.'
|
|
}
|
|
$interopPath = Get-NormalizedFullPath -Path (
|
|
Join-Path $vendorRoot.FullName 'Bin\x64\C#\Interop.K3DAsyncEngineLib.dll')
|
|
[void](Assert-RegularFile -Path $interopPath -Description 'Derived K3D interop assembly')
|
|
Assert-FixedLocalPath -Path $interopPath -Description 'Derived K3D interop assembly'
|
|
|
|
$localAppData = [Environment]::GetFolderPath(
|
|
[Environment+SpecialFolder]::LocalApplicationData)
|
|
if ([string]::IsNullOrWhiteSpace($localAppData)) {
|
|
throw 'The Windows LocalApplicationData known folder is unavailable.'
|
|
}
|
|
$localAppDataRoot = Get-NormalizedFullPath -Path $localAppData
|
|
Assert-FixedLocalPath -Path $localAppDataRoot -Description 'LocalAppData'
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $localAppDataRoot `
|
|
-Description 'LocalAppData'
|
|
|
|
# Preflight a requested Live replacement before any setup write. The caller
|
|
# supplies both approval hashes; this script only verifies them against files.
|
|
$livePreflight = $null
|
|
$liveAuthorizationInvalidated = $false
|
|
if ($ConfigureDevelopmentLive) {
|
|
$configurationRoot = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\Config'
|
|
$prospectiveAuthorizationPath = Join-Path $configurationRoot (
|
|
'playout.development-live.local.json')
|
|
try {
|
|
if ($PlayoutHost -cne '127.0.0.1' -and $PlayoutHost -cne '::1') {
|
|
throw 'Development Live playout host must be exactly 127.0.0.1 or ::1.'
|
|
}
|
|
if ($PlayoutPort -lt 1 -or $PlayoutPort -gt 65535) {
|
|
throw 'Development Live playout port must be between 1 and 65535.'
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($NativeSha256) -or
|
|
$NativeSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or
|
|
[string]::IsNullOrWhiteSpace($InteropSha256) -or
|
|
$InteropSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
|
|
throw 'Independent native and interop SHA-256 approvals are both required.'
|
|
}
|
|
|
|
$livePreflight = Get-DevelopmentLivePreflight `
|
|
-ConfigurationRoot $configurationRoot `
|
|
-HostName $PlayoutHost `
|
|
-Port $PlayoutPort `
|
|
-ApprovedNativeHash $NativeSha256 `
|
|
-ApprovedInteropHash $InteropSha256 `
|
|
-Channel $OutputChannel `
|
|
-AllowReplacement:$ReplaceLiveConfig
|
|
}
|
|
catch {
|
|
$livePreflightError = $_
|
|
try {
|
|
Remove-DevelopmentLiveAuthorizationFailClosed `
|
|
-Path $prospectiveAuthorizationPath
|
|
}
|
|
catch {
|
|
throw [InvalidOperationException]::new(
|
|
(
|
|
'Development Live preflight failed and its existing ' +
|
|
'authorization could not be invalidated: ' + $_.Exception.Message
|
|
),
|
|
$livePreflightError.Exception)
|
|
}
|
|
throw $livePreflightError
|
|
}
|
|
|
|
# Invalidate the previous one-launch authorization before database/runtime
|
|
# preflight and before any setup write. Every subsequent failure remains
|
|
# fail-closed at the exact path consumed by the app.
|
|
Remove-DevelopmentLiveAuthorizationFailClosed `
|
|
-Path ([string] $livePreflight.AuthorizationPath)
|
|
if (Test-Path -LiteralPath ([string] $livePreflight.AuthorizationPath)) {
|
|
throw 'Development Live authorization still exists before setup preflight.'
|
|
}
|
|
$liveAuthorizationInvalidated = $true
|
|
|
|
$actualNativeHash = (
|
|
Get-FileHash -LiteralPath $nativePath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
$actualInteropHash = (
|
|
Get-FileHash -LiteralPath $interopPath -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
if (-not $actualNativeHash.Equals(
|
|
$NativeSha256,
|
|
[StringComparison]::OrdinalIgnoreCase) -or
|
|
-not $actualInteropHash.Equals(
|
|
$InteropSha256,
|
|
[StringComparison]::OrdinalIgnoreCase)) {
|
|
throw 'An independently supplied K3D SHA-256 does not match the registered files.'
|
|
}
|
|
}
|
|
|
|
$databaseSource = $null
|
|
$databaseDestination = Join-Path $localAppDataRoot (
|
|
'MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini')
|
|
$databaseSourceHash = $null
|
|
$databaseNeedsWrite = $false
|
|
$databaseNeedsReplace = $false
|
|
if (-not $SkipDatabaseProfile) {
|
|
$databaseSource = if ([string]::IsNullOrWhiteSpace($DatabaseIniPath)) {
|
|
Join-Path $runtimeSourceRoot 'Res\MmoneyCoder.ini'
|
|
}
|
|
else {
|
|
if (-not [IO.Path]::IsPathRooted($DatabaseIniPath)) {
|
|
throw '-DatabaseIniPath must be an absolute path.'
|
|
}
|
|
Get-NormalizedFullPath -Path $DatabaseIniPath
|
|
}
|
|
Assert-FixedLocalPath -Path $databaseSource -Description 'Legacy database INI'
|
|
if ((Test-SamePath -Left $databaseSource -Right $repositoryRoot) -or
|
|
(Test-PathIsWithin -Root $repositoryRoot -Candidate $databaseSource)) {
|
|
throw 'Database credentials must not be sourced from inside this repository.'
|
|
}
|
|
$databaseItem = Assert-RegularFile `
|
|
-Path $databaseSource `
|
|
-Description 'Legacy database INI'
|
|
if ($databaseItem.Length -le 0 -or
|
|
$databaseItem.Length -gt $maximumDatabaseIniLength) {
|
|
throw 'Legacy database INI is empty or exceeds the 1 MiB safety limit.'
|
|
}
|
|
$databaseSourceHash = (
|
|
Get-FileHash -LiteralPath $databaseSource -Algorithm SHA256
|
|
).Hash.ToUpperInvariant()
|
|
|
|
if (Test-Path -LiteralPath $databaseDestination) {
|
|
$databaseDestinationItem = Assert-RegularFile `
|
|
-Path $databaseDestination `
|
|
-Description 'Installed database profile'
|
|
if ($databaseDestinationItem.Length -le 0 -or
|
|
$databaseDestinationItem.Length -gt $maximumDatabaseIniLength) {
|
|
throw 'Installed database profile is empty or exceeds the 1 MiB safety limit.'
|
|
}
|
|
$sameDatabase = $databaseDestinationItem.Length -eq $databaseItem.Length -and
|
|
(Get-FileHash `
|
|
-LiteralPath $databaseDestination `
|
|
-Algorithm SHA256).Hash.Equals(
|
|
$databaseSourceHash,
|
|
[StringComparison]::Ordinal) -and
|
|
(Test-UserOnlyFileAcl -Path $databaseDestination)
|
|
if (-not $sameDatabase) {
|
|
if (-not $ReplaceDatabaseProfile) {
|
|
throw (
|
|
'The installed database profile differs or is not user-only. ' +
|
|
'Re-run with -ReplaceDatabaseProfile.')
|
|
}
|
|
$databaseNeedsWrite = $true
|
|
$databaseNeedsReplace = $true
|
|
}
|
|
}
|
|
else {
|
|
$databaseNeedsWrite = $true
|
|
}
|
|
}
|
|
elseif (-not [string]::IsNullOrWhiteSpace($DatabaseIniPath) -or
|
|
$ReplaceDatabaseProfile) {
|
|
throw '-DatabaseIniPath/-ReplaceDatabaseProfile cannot be used with -SkipDatabaseProfile.'
|
|
}
|
|
|
|
$runtimeStorageRoot = Join-Path $localAppDataRoot (
|
|
'MBN_STOCK_WEBVIEW\RuntimeBundles')
|
|
$existingRuntime = Get-ExistingRuntimeBinding `
|
|
-RepositoryRoot $repositoryRoot `
|
|
-RuntimeStorageRoot $runtimeStorageRoot
|
|
|
|
$setupParent = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\SetupTemp'
|
|
$rollbackParent = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\SetupRollback'
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $setupParent `
|
|
-Description 'Setup temporary directory'
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $rollbackParent `
|
|
-Description 'Setup rollback directory'
|
|
$setupRoot = Join-Path $setupParent ([Guid]::NewGuid().ToString('N'))
|
|
$rollbackRoot = Join-Path $rollbackParent ([Guid]::NewGuid().ToString('N'))
|
|
|
|
$runtimeResult = $null
|
|
$buildResult = $null
|
|
$databaseInstalled = $false
|
|
$liveConfigured = $false
|
|
$runtimeBindingMutationStarted = $false
|
|
$databaseMutationStarted = $false
|
|
$propsSnapshot = $null
|
|
$databaseSnapshot = $null
|
|
$transactionCommitted = $false
|
|
$transactionError = $null
|
|
try {
|
|
[IO.Directory]::CreateDirectory($setupParent) | Out-Null
|
|
[IO.Directory]::CreateDirectory($rollbackParent) | Out-Null
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $setupParent `
|
|
-Description 'Setup temporary directory'
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $rollbackParent `
|
|
-Description 'Setup rollback directory'
|
|
[IO.Directory]::CreateDirectory($setupRoot) | Out-Null
|
|
[IO.Directory]::CreateDirectory($rollbackRoot) | Out-Null
|
|
Set-UserOnlyDirectoryAcl -Path $setupRoot
|
|
Set-UserOnlyDirectoryAcl -Path $rollbackRoot
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $setupRoot `
|
|
-Description 'Owned setup temporary directory'
|
|
Assert-NoReparsePointInExistingAncestry `
|
|
-Path $rollbackRoot `
|
|
-Description 'Owned setup rollback directory'
|
|
|
|
$propsSnapshot = New-FileRollbackSnapshot `
|
|
-Path (Join-Path $repositoryRoot 'Directory.Build.local.props') `
|
|
-BackupDirectory $rollbackRoot `
|
|
-Description 'Local build props'
|
|
if (-not $SkipDatabaseProfile) {
|
|
$databaseSnapshot = New-FileRollbackSnapshot `
|
|
-Path $databaseDestination `
|
|
-BackupDirectory $rollbackRoot `
|
|
-Description 'Installed database profile'
|
|
}
|
|
|
|
$bundleOutput = Join-Path $setupRoot 'bundle'
|
|
$bundleResults = @(& $bundleCreatorPath `
|
|
-LegacyRuntimeSourceRoot $runtimeSourceRoot `
|
|
-OutputDirectory $bundleOutput)
|
|
if ($bundleResults.Count -ne 1) {
|
|
throw 'Runtime bundle creation did not return one result.'
|
|
}
|
|
$verifiedBundle = Get-VerifiedBundleCreatorResult `
|
|
-Result $bundleResults[0] `
|
|
-BundleOutput $bundleOutput
|
|
$bundleResult = $verifiedBundle.Result
|
|
$newManifestPath = [string] $verifiedBundle.Manifest.Path
|
|
$newManifestHash = [string] $verifiedBundle.Manifest.Sha256
|
|
|
|
$sameRuntime = $null -ne $existingRuntime -and
|
|
$existingRuntime.ManifestSha256.Equals(
|
|
$newManifestHash,
|
|
[StringComparison]::Ordinal)
|
|
if ($sameRuntime) {
|
|
Assert-InstalledRuntimeMatchesManifest `
|
|
-InstalledRoot $existingRuntime.InstalledRoot `
|
|
-ManifestPath $newManifestPath
|
|
$runtimeResult = [pscustomobject]@{
|
|
InstalledRoot = $existingRuntime.InstalledRoot
|
|
ManifestSha256 = $newManifestHash
|
|
ManifestPath = $existingRuntime.ManifestPath
|
|
CutsFileCount = [int] $verifiedBundle.Manifest.CutsFileCount
|
|
ResFileCount = [int] $verifiedBundle.Manifest.ResFileCount
|
|
Reused = $true
|
|
}
|
|
}
|
|
else {
|
|
if ($null -ne $existingRuntime -and -not $ReplaceRuntimeBinding) {
|
|
throw (
|
|
'The selected Cuts/Res manifest differs from the installed runtime ' +
|
|
'binding. Re-run with -ReplaceRuntimeBinding.')
|
|
}
|
|
|
|
# Install and fully verify the new immutable bundle while the current
|
|
# repository binding remains untouched. A minimal shadow repository lets
|
|
# the existing hardened initializer generate its exact Required props.
|
|
$shadowRepositoryRoot = Join-Path $setupRoot 'repository'
|
|
$shadowProjectDirectory = Join-Path $shadowRepositoryRoot (
|
|
'src\MBN_STOCK_WEBVIEW.LegacyParityApp')
|
|
[IO.Directory]::CreateDirectory($shadowProjectDirectory) | Out-Null
|
|
[IO.File]::Copy(
|
|
(Join-Path $repositoryRoot '.gitignore'),
|
|
(Join-Path $shadowRepositoryRoot '.gitignore'),
|
|
$false)
|
|
[IO.File]::Copy(
|
|
(Join-Path $repositoryRoot 'Directory.Build.props'),
|
|
(Join-Path $shadowRepositoryRoot 'Directory.Build.props'),
|
|
$false)
|
|
[IO.File]::Copy(
|
|
$projectPath,
|
|
(Join-Path $shadowProjectDirectory (
|
|
'MBN_STOCK_WEBVIEW.LegacyParityApp.csproj')),
|
|
$false)
|
|
|
|
$initializerResults = @(& $bundleInitializerPath `
|
|
-ZipPath ([string] $bundleResult.ArchivePath) `
|
|
-ExpectedSha256 ([string] $bundleResult.ArchiveSha256) `
|
|
-RepositoryRoot $shadowRepositoryRoot)
|
|
if ($initializerResults.Count -ne 1) {
|
|
throw 'Runtime bundle initialization did not return one result.'
|
|
}
|
|
$initialized = Get-VerifiedBundleInitializerResult `
|
|
-Result $initializerResults[0] `
|
|
-VerifiedBundle $verifiedBundle `
|
|
-ShadowRepositoryRoot $shadowRepositoryRoot `
|
|
-RuntimeStorageRoot $runtimeStorageRoot
|
|
|
|
$runtimeBindingMutationStarted = $true
|
|
Set-RuntimeBindingAtomically `
|
|
-GeneratedPropsPath $initialized.LocalBuildPropsPath `
|
|
-RepositoryRoot $repositoryRoot `
|
|
-Replace:($null -ne $existingRuntime)
|
|
$verifiedBinding = Get-ExistingRuntimeBinding `
|
|
-RepositoryRoot $repositoryRoot `
|
|
-RuntimeStorageRoot $runtimeStorageRoot
|
|
if ($null -eq $verifiedBinding -or
|
|
-not (Test-SamePath `
|
|
-Left $verifiedBinding.InstalledRoot `
|
|
-Right ([string] $initialized.InstalledRoot)) -or
|
|
-not $verifiedBinding.ManifestSha256.Equals(
|
|
[string] $initialized.ManifestSha256,
|
|
[StringComparison]::Ordinal)) {
|
|
throw 'The atomically installed runtime binding failed final verification.'
|
|
}
|
|
Assert-InstalledRuntimeMatchesManifest `
|
|
-InstalledRoot $verifiedBinding.InstalledRoot `
|
|
-ManifestPath $newManifestPath
|
|
|
|
$runtimeResult = [pscustomobject]@{
|
|
InstalledRoot = $verifiedBinding.InstalledRoot
|
|
ManifestSha256 = $verifiedBinding.ManifestSha256
|
|
ManifestPath = $verifiedBinding.ManifestPath
|
|
CutsFileCount = [int] $initialized.CutsFileCount
|
|
ResFileCount = [int] $initialized.ResFileCount
|
|
Reused = $false
|
|
}
|
|
}
|
|
|
|
if ($databaseNeedsWrite) {
|
|
$databaseMutationStarted = $true
|
|
Copy-DatabaseProfileAtomically `
|
|
-Source $databaseSource `
|
|
-Destination $databaseDestination `
|
|
-ExpectedHash $databaseSourceHash `
|
|
-Replace:$databaseNeedsReplace
|
|
$databaseInstalled = $true
|
|
}
|
|
|
|
if (-not $SkipBuild) {
|
|
$buildResult = Invoke-DebugBuildAndVerify `
|
|
-ProjectPath $projectPath `
|
|
-ExpectedRuntimeRoot $runtimeResult.InstalledRoot `
|
|
-ExpectedManifestPath $runtimeResult.ManifestPath `
|
|
-ExpectedManifestSha256 $runtimeResult.ManifestSha256
|
|
if ([int] $buildResult.CutsFileCount -ne
|
|
[int] $runtimeResult.CutsFileCount -or
|
|
[int] $buildResult.ResFileCount -ne
|
|
[int] $runtimeResult.ResFileCount) {
|
|
throw 'Debug x64 payload count mismatch after exact manifest verification.'
|
|
}
|
|
}
|
|
|
|
# Both working data and rollback copies must be gone before a new Live
|
|
# authorization can be issued. A cleanup failure is a transaction failure.
|
|
Remove-OwnedTemporaryDirectory `
|
|
-Parent $setupParent `
|
|
-Path $setupRoot `
|
|
-Description 'setup temporary directory'
|
|
Remove-OwnedTemporaryDirectory `
|
|
-Parent $rollbackParent `
|
|
-Path $rollbackRoot `
|
|
-Description 'setup rollback directory'
|
|
$transactionCommitted = $true
|
|
}
|
|
catch {
|
|
$transactionError = $_
|
|
}
|
|
|
|
if (-not $transactionCommitted) {
|
|
$secondaryFailures = New-Object 'System.Collections.Generic.List[string]'
|
|
|
|
if ($databaseMutationStarted -and $null -ne $databaseSnapshot) {
|
|
try {
|
|
Restore-FileRollbackSnapshot `
|
|
-Snapshot $databaseSnapshot `
|
|
-Description 'Installed database profile'
|
|
}
|
|
catch {
|
|
$secondaryFailures.Add(
|
|
'Database rollback failed: ' + $_.Exception.Message)
|
|
}
|
|
}
|
|
if ($runtimeBindingMutationStarted -and $null -ne $propsSnapshot) {
|
|
try {
|
|
Restore-FileRollbackSnapshot `
|
|
-Snapshot $propsSnapshot `
|
|
-Description 'Local build props'
|
|
}
|
|
catch {
|
|
$secondaryFailures.Add(
|
|
'Runtime binding rollback failed: ' + $_.Exception.Message)
|
|
}
|
|
}
|
|
|
|
foreach ($cleanupTarget in @(
|
|
[pscustomobject]@{
|
|
Parent = $setupParent
|
|
Path = $setupRoot
|
|
Description = 'setup temporary directory'
|
|
},
|
|
[pscustomobject]@{
|
|
Parent = $rollbackParent
|
|
Path = $rollbackRoot
|
|
Description = 'setup rollback directory'
|
|
})) {
|
|
try {
|
|
Remove-OwnedTemporaryDirectory `
|
|
-Parent $cleanupTarget.Parent `
|
|
-Path $cleanupTarget.Path `
|
|
-Description $cleanupTarget.Description
|
|
}
|
|
catch {
|
|
$secondaryFailures.Add(
|
|
$cleanupTarget.Description + ' cleanup failed: ' +
|
|
$_.Exception.Message)
|
|
}
|
|
}
|
|
|
|
if ($ConfigureDevelopmentLive -and $liveAuthorizationInvalidated) {
|
|
try {
|
|
Remove-DevelopmentLiveAuthorizationFailClosed `
|
|
-Path ([string] $livePreflight.AuthorizationPath)
|
|
}
|
|
catch {
|
|
$secondaryFailures.Add(
|
|
'Development Live fail-closed cleanup failed: ' +
|
|
$_.Exception.Message)
|
|
}
|
|
}
|
|
|
|
if ($null -eq $transactionError) {
|
|
$transactionError = [System.Management.Automation.ErrorRecord]::new(
|
|
[InvalidOperationException]::new(
|
|
'Existing development PC initialization did not commit.'),
|
|
'InitializationDidNotCommit',
|
|
[System.Management.Automation.ErrorCategory]::InvalidResult,
|
|
$null)
|
|
}
|
|
if ($secondaryFailures.Count -gt 0) {
|
|
throw [InvalidOperationException]::new(
|
|
(
|
|
'Existing development PC initialization failed. Original failure: ' +
|
|
$transactionError.Exception.Message + ' Secondary recovery failure(s): ' +
|
|
($secondaryFailures -join ' | ')
|
|
),
|
|
$transactionError.Exception)
|
|
}
|
|
throw $transactionError
|
|
}
|
|
|
|
# Live authorization is the final mutation, after build verification and after
|
|
# temporary cleanup. No COM activation, PGM command, or database connection is
|
|
# performed by this setup script.
|
|
if ($ConfigureDevelopmentLive -and $livePreflight.NeedsWrite) {
|
|
$liveArguments = @{
|
|
PlayoutHost = $PlayoutHost
|
|
PlayoutPort = $PlayoutPort
|
|
NativeSha256 = $NativeSha256
|
|
InteropSha256 = $InteropSha256
|
|
ConfigurationDirectory = $configurationRoot
|
|
}
|
|
if ($null -ne $OutputChannel) {
|
|
$liveArguments.OutputChannel = $OutputChannel
|
|
}
|
|
if ($livePreflight.NeedsForce) {
|
|
$liveArguments.Force = $true
|
|
}
|
|
try {
|
|
$liveResults = @(& $liveInitializerPath @liveArguments)
|
|
if ($liveResults.Count -ne 0) {
|
|
throw 'Development Live initializer returned unexpected success-stream output.'
|
|
}
|
|
Assert-ExistingJsonContract `
|
|
-Path ([string] $livePreflight.PlayoutPath) `
|
|
-ExpectedJson ([string] $livePreflight.PlayoutJson) `
|
|
-Description 'Development Live base configuration'
|
|
Assert-ExistingJsonContract `
|
|
-Path ([string] $livePreflight.AuthorizationPath) `
|
|
-ExpectedJson ([string] $livePreflight.AuthorizationJson) `
|
|
-Description 'Development Live authorization'
|
|
$liveConfigured = $true
|
|
}
|
|
catch {
|
|
$liveError = $_
|
|
try {
|
|
Remove-DevelopmentLiveAuthorizationFailClosed `
|
|
-Path ([string] $livePreflight.AuthorizationPath)
|
|
}
|
|
catch {
|
|
throw [InvalidOperationException]::new(
|
|
(
|
|
'Development Live configuration failed and fail-closed ' +
|
|
'authorization cleanup also failed: ' + $_.Exception.Message
|
|
),
|
|
$liveError.Exception)
|
|
}
|
|
throw $liveError
|
|
}
|
|
}
|
|
|
|
[pscustomobject]@{
|
|
RuntimeSourceRoot = $runtimeSourceRoot
|
|
InstalledRuntimeRoot = $runtimeResult.InstalledRoot
|
|
RuntimeManifestSha256 = $runtimeResult.ManifestSha256
|
|
RuntimeBindingReused = [bool] $runtimeResult.Reused
|
|
DatabaseProfilePath = if ($SkipDatabaseProfile) { $null } else {
|
|
$databaseDestination
|
|
}
|
|
DatabaseProfileWritten = $databaseInstalled
|
|
K3DStatus = [string] $registration.Status
|
|
K3DComActivated = [bool] $registration.ComActivated
|
|
BuildVerified = $null -ne $buildResult
|
|
BuildOutputPath = if ($null -eq $buildResult) { $null } else {
|
|
$buildResult.TargetDirectory
|
|
}
|
|
DevelopmentLiveRequested = [bool] $ConfigureDevelopmentLive
|
|
DevelopmentLiveWritten = $liveConfigured
|
|
DevelopmentLiveReused = $false
|
|
}
|