Files
MBN_STOCK_WEBVIEW/scripts/Initialize-ExistingDevelopmentPc.ps1

2577 lines
100 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,
[switch] $PinRegisteredK3D,
[switch] $ReplaceK3DPin,
[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/K3D verification succeeds.
NeedsWrite = $true
NeedsForce = [bool] ($playoutExists -or $authorizationExists)
PlayoutPath = $playoutPath
AuthorizationPath = $authorizationPath
PlayoutJson = $playoutJson
AuthorizationJson = $authorizationJson
}
}
function Get-K3DPinPreflight {
param(
[Parameter(Mandatory = $true)][string] $ConfigurationRoot,
[Parameter(Mandatory = $true)][string] $ApprovedNativeHash,
[Parameter(Mandatory = $true)][string] $ApprovedInteropHash,
[switch] $AllowReplacement
)
$pin = [ordered]@{
schemaVersion = 1
nativeSha256 = $ApprovedNativeHash.ToUpperInvariant()
interopSha256 = $ApprovedInteropHash.ToUpperInvariant()
}
$pinJson = Get-CanonicalJsonText -Value $pin -Depth 3
$pinPath = Join-Path $ConfigurationRoot 'k3d-pins.local.json'
Assert-NoReparsePointInExistingAncestry `
-Path $ConfigurationRoot `
-Description 'K3D pin configuration directory'
$pinExists = Test-Path -LiteralPath $pinPath
if ($pinExists) {
[void](Assert-RegularFile `
-Path $pinPath `
-Description 'K3D persistent pin')
}
$pinMatches = $pinExists -and
(Test-ExistingJsonContract -Path $pinPath -ExpectedJson $pinJson)
if ($pinExists -and -not $pinMatches -and -not $AllowReplacement) {
throw (
'The installed K3D files differ from the persistent local pin. ' +
'Automatic pinning cannot approve a changed DLL. Independently ' +
'verify the vendor files before using -ReplaceK3DPin with supplied hashes.')
}
return [pscustomobject]@{
NeedsWrite = [bool] (-not $pinExists -or -not $pinMatches)
NeedsReplace = [bool] $pinExists
Path = $pinPath
Json = $pinJson
}
}
function Write-ProtectedUtf8TextAtomically {
param(
[Parameter(Mandatory = $true)][string] $Path,
[Parameter(Mandatory = $true)][string] $ExpectedText,
[Parameter(Mandatory = $true)][string] $Description,
[switch] $Replace
)
$destination = Get-NormalizedFullPath -Path $Path
$directory = [IO.Path]::GetDirectoryName($destination)
[IO.Directory]::CreateDirectory($directory) | Out-Null
Assert-NoReparsePointInExistingAncestry `
-Path $directory `
-Description "$Description directory"
Set-UserOnlyDirectoryAcl -Path $directory
$temporaryPath = Join-Path $directory (
'.' + [IO.Path]::GetFileName($destination) + '.' +
[Guid]::NewGuid().ToString('N') + '.tmp')
$rollbackSnapshot = New-FileRollbackSnapshot `
-Path $destination `
-BackupDirectory $directory `
-Description $Description
$mutationStarted = $false
$preserveRollbackBackup = $false
try {
[IO.File]::WriteAllText(
$temporaryPath,
$ExpectedText,
(New-Object Text.UTF8Encoding($false)))
[IO.File]::SetAccessControl($temporaryPath, (Get-UserOnlyAcl))
if (-not (Test-ExactUtf8Text `
-Path $temporaryPath `
-ExpectedText $ExpectedText) -or
-not (Test-UserOnlyFileAcl -Path $temporaryPath)) {
throw "$Description temporary file failed exact JSON/ACL verification."
}
if (Test-Path -LiteralPath $destination) {
if (-not $Replace) {
throw "Refusing to replace the existing $Description."
}
[void](Assert-RegularFile -Path $destination -Description $Description)
$mutationStarted = $true
[IO.File]::Replace($temporaryPath, $destination, $null)
$temporaryPath = $null
}
else {
$mutationStarted = $true
[IO.File]::Move($temporaryPath, $destination)
$temporaryPath = $null
}
[IO.File]::SetAccessControl($destination, (Get-UserOnlyAcl))
if (-not (Test-ExactUtf8Text `
-Path $destination `
-ExpectedText $ExpectedText) -or
-not (Test-UserOnlyFileAcl -Path $destination)) {
throw "The atomically installed $Description failed exact JSON/ACL verification."
}
}
catch {
$writeError = $_
if ($mutationStarted) {
try {
Restore-FileRollbackSnapshot `
-Snapshot $rollbackSnapshot `
-Description $Description
}
catch {
$preserveRollbackBackup = $true
throw [InvalidOperationException]::new(
(
"$Description write failed and its previous trust " +
'anchor could not be restored: ' + $_.Exception.Message
),
$writeError.Exception)
}
}
throw $writeError
}
finally {
if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and
(Test-Path -LiteralPath $temporaryPath)) {
[IO.File]::Delete($temporaryPath)
}
if (-not $preserveRollbackBackup -and
$null -ne $rollbackSnapshot -and
-not [string]::IsNullOrWhiteSpace(
[string] $rollbackSnapshot.BackupPath) -and
(Test-Path -LiteralPath ([string] $rollbackSnapshot.BackupPath))) {
[IO.File]::Delete([string] $rollbackSnapshot.BackupPath)
}
}
}
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] $DotNetPath,
[Parameter(Mandatory = $true)][string] $ProjectPath,
[Parameter(Mandatory = $true)][string] $ExpectedRuntimeRoot,
[Parameter(Mandatory = $true)][string] $ExpectedManifestPath,
[Parameter(Mandatory = $true)][string] $ExpectedManifestSha256
)
$runtimeRootProperty = "-p:LegacyRuntimeSourceRoot=$ExpectedRuntimeRoot"
& $DotNetPath build $ProjectPath `
-c Debug `
'-p:Platform=x64' `
'-p:LegacyRuntimeAssetsMode=Required' `
$runtimeRootProperty |
ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
throw "Debug x64 build failed with exit code $LASTEXITCODE."
}
$propertyText = (& $DotNetPath msbuild $ProjectPath `
'-p:Configuration=Debug' `
'-p:Platform=x64' `
'-p:LegacyRuntimeAssetsMode=Required' `
$runtimeRootProperty `
'-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
}
}
$setupMutex = [Threading.Mutex]::new(
$false,
'Local\MBN_STOCK_WEBVIEW.ExistingDevelopmentPcInitialization.v1')
$setupMutexOwned = $false
try {
try {
$setupMutexOwned = $setupMutex.WaitOne(0)
}
catch [Threading.AbandonedMutexException] {
# The prior process ended unexpectedly. Atomic local files are re-preflighted
# below so this run can safely complete the setup forward from that state.
$setupMutexOwned = $true
}
if (-not $setupMutexOwned) {
throw 'Another existing-development-PC initialization is already running.'
}
$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 ($ReplaceK3DPin -and -not $ConfigureDevelopmentLive) {
throw '-ReplaceK3DPin requires -ConfigureDevelopmentLive.'
}
if ($ReplaceK3DPin -and $PinRegisteredK3D) {
throw (
'-ReplaceK3DPin cannot be combined with -PinRegisteredK3D. ' +
'A replacement requires independently supplied native and interop hashes.')
}
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
$PinRegisteredK3D -or
$ReplaceK3DPin -or
$null -ne $OutputChannel)) {
throw 'Development Live endpoint/hash options require -ConfigureDevelopmentLive.'
}
$dotnetPath = $null
if (-not $SkipBuild) {
$dotnetCommand = Get-Command -Name 'dotnet.exe' -CommandType Application `
-ErrorAction Stop | Select-Object -First 1
if ($null -eq $dotnetCommand -or
[string]::IsNullOrWhiteSpace([string] $dotnetCommand.Source)) {
throw 'A .NET SDK dotnet.exe could not be resolved for build verification.'
}
$dotnetPath = Get-NormalizedFullPath -Path ([string] $dotnetCommand.Source)
[void](Assert-RegularFile -Path $dotnetPath -Description '.NET SDK host')
Assert-FixedLocalPath -Path $dotnetPath -Description '.NET SDK host'
}
$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
# either supplies both independent approval hashes or explicitly requests
# first-use pinning of the already verified HKLM x64 K3D registration. Both
# paths keep the exact file hashes in the one-launch authorization and the app
# verifies them again before loading either vendor binary.
$livePreflight = $null
$k3dPinPreflight = $null
$k3dPinWritten = $false
$liveAuthorizationInvalidated = $false
$approvedNativeSha256 = $null
$approvedInteropSha256 = $null
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.'
}
$actualNativeHash = (
Get-FileHash -LiteralPath $nativePath -Algorithm SHA256
).Hash.ToUpperInvariant()
$actualInteropHash = (
Get-FileHash -LiteralPath $interopPath -Algorithm SHA256
).Hash.ToUpperInvariant()
if ($PinRegisteredK3D) {
if (-not [string]::IsNullOrWhiteSpace($NativeSha256) -or
-not [string]::IsNullOrWhiteSpace($InteropSha256)) {
throw (
'-PinRegisteredK3D cannot be combined with independently ' +
'supplied K3D hashes.')
}
$approvedNativeSha256 = $actualNativeHash
$approvedInteropSha256 = $actualInteropHash
}
else {
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.'
}
$approvedNativeSha256 = $NativeSha256.ToUpperInvariant()
$approvedInteropSha256 = $InteropSha256.ToUpperInvariant()
if (-not $actualNativeHash.Equals(
$approvedNativeSha256,
[StringComparison]::Ordinal) -or
-not $actualInteropHash.Equals(
$approvedInteropSha256,
[StringComparison]::Ordinal)) {
throw 'An independently supplied K3D SHA-256 does not match the registered files.'
}
}
$k3dPinPreflight = Get-K3DPinPreflight `
-ConfigurationRoot $configurationRoot `
-ApprovedNativeHash $approvedNativeSha256 `
-ApprovedInteropHash $approvedInteropSha256 `
-AllowReplacement:$ReplaceK3DPin
if ($k3dPinPreflight.NeedsWrite) {
# This is a durable trust anchor, not transactional runtime state.
# Preserve the first-seen/independently replaced pin even when a
# later DB, build, Live-config, or cleanup step fails. Otherwise a
# changed DLL could be automatically accepted on the next attempt.
Write-ProtectedUtf8TextAtomically `
-Path ([string] $k3dPinPreflight.Path) `
-ExpectedText ([string] $k3dPinPreflight.Json) `
-Description 'K3D persistent pin' `
-Replace:$k3dPinPreflight.NeedsReplace
$k3dPinWritten = $true
}
$livePreflight = Get-DevelopmentLivePreflight `
-ConfigurationRoot $configurationRoot `
-HostName $PlayoutHost `
-Port $PlayoutPort `
-ApprovedNativeHash $approvedNativeSha256 `
-ApprovedInteropHash $approvedInteropSha256 `
-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
}
$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
$pendingRuntimeBinding = $null
$buildResult = $null
$databaseInstalled = $false
$liveConfigured = $false
$runtimeBindingMutationStarted = $false
$databaseMutationStarted = $false
$propsSnapshot = $null
$databaseSnapshot = $null
$playoutSnapshot = $null
$playoutMutationStarted = $false
$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'
}
if ($ConfigureDevelopmentLive) {
$playoutSnapshot = New-FileRollbackSnapshot `
-Path ([string] $livePreflight.PlayoutPath) `
-BackupDirectory $rollbackRoot `
-Description 'Development Live base configuration'
}
$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
$runtimeResult = [pscustomobject]@{
InstalledRoot = [string] $initialized.InstalledRoot
ManifestSha256 = [string] $initialized.ManifestSha256
ManifestPath = Join-Path `
([string] $initialized.InstalledRoot) `
$manifestFileName
CutsFileCount = [int] $initialized.CutsFileCount
ResFileCount = [int] $initialized.ResFileCount
Reused = $false
}
$pendingRuntimeBinding = [pscustomobject]@{
GeneratedPropsPath = [string] $initialized.LocalBuildPropsPath
Replace = [bool] ($null -ne $existingRuntime)
}
}
if (-not $SkipBuild) {
$buildResult = Invoke-DebugBuildAndVerify `
-DotNetPath $dotnetPath `
-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.'
}
}
if ($databaseNeedsWrite) {
$databaseMutationStarted = $true
Copy-DatabaseProfileAtomically `
-Source $databaseSource `
-Destination $databaseDestination `
-ExpectedHash $databaseSourceHash `
-Replace:$databaseNeedsReplace
$databaseInstalled = $true
}
if ($ConfigureDevelopmentLive) {
[void](Assert-RegularFile `
-Path $nativePath `
-Description 'Registered K3D native DLL before authorization')
[void](Assert-RegularFile `
-Path $interopPath `
-Description 'K3D interop assembly before authorization')
$finalNativeHash = (
Get-FileHash -LiteralPath $nativePath -Algorithm SHA256
).Hash.ToUpperInvariant()
$finalInteropHash = (
Get-FileHash -LiteralPath $interopPath -Algorithm SHA256
).Hash.ToUpperInvariant()
if (-not $finalNativeHash.Equals(
$approvedNativeSha256,
[StringComparison]::Ordinal) -or
-not $finalInteropHash.Equals(
$approvedInteropSha256,
[StringComparison]::Ordinal)) {
throw (
'A K3D DLL changed during setup. The durable pin remains at ' +
'the originally approved hash, and no Live authorization was issued.')
}
}
# Live authorization is the final functional mutation. 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 = $approvedNativeSha256
InteropSha256 = $approvedInteropSha256
ConfigurationDirectory = $configurationRoot
}
if ($null -ne $OutputChannel) {
$liveArguments.OutputChannel = $OutputChannel
}
if ($livePreflight.NeedsForce) {
$liveArguments.Force = $true
}
$playoutMutationStarted = $true
$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
}
# Directory.Build.local.props is the durable success marker for a fresh
# clone. Keep it source-only until runtime/build/DB/K3D/Live are complete so
# a stopped setup returns to this first-run UI on the next F5.
if ($null -ne $pendingRuntimeBinding) {
$runtimeBindingMutationStarted = $true
Set-RuntimeBindingAtomically `
-GeneratedPropsPath ([string] $pendingRuntimeBinding.GeneratedPropsPath) `
-RepositoryRoot $repositoryRoot `
-Replace:([bool] $pendingRuntimeBinding.Replace)
$verifiedBinding = Get-ExistingRuntimeBinding `
-RepositoryRoot $repositoryRoot `
-RuntimeStorageRoot $runtimeStorageRoot
if ($null -eq $verifiedBinding -or
-not (Test-SamePath `
-Left $verifiedBinding.InstalledRoot `
-Right ([string] $runtimeResult.InstalledRoot)) -or
-not $verifiedBinding.ManifestSha256.Equals(
[string] $runtimeResult.ManifestSha256,
[StringComparison]::Ordinal)) {
throw 'The atomically installed runtime binding failed final verification.'
}
Assert-InstalledRuntimeMatchesManifest `
-InstalledRoot $verifiedBinding.InstalledRoot `
-ManifestPath $runtimeResult.ManifestPath
}
# A cleanup failure is still a transaction failure. Rollback snapshots are
# also held in memory so rollback remains possible if cleanup only partly ran.
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]'
# Always fail closed before restoring any build/runtime state.
if ($ConfigureDevelopmentLive -and $liveAuthorizationInvalidated) {
try {
Remove-DevelopmentLiveAuthorizationFailClosed `
-Path ([string] $livePreflight.AuthorizationPath)
}
catch {
$secondaryFailures.Add(
'Development Live fail-closed cleanup failed: ' +
$_.Exception.Message)
}
}
if ($playoutMutationStarted -and $null -ne $playoutSnapshot) {
try {
Restore-FileRollbackSnapshot `
-Snapshot $playoutSnapshot `
-Description 'Development Live base configuration'
}
catch {
$secondaryFailures.Add(
'Development Live base configuration rollback failed: ' +
$_.Exception.Message)
}
}
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 ($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
}
[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
K3DPinPath = if ($ConfigureDevelopmentLive) {
[string] $k3dPinPreflight.Path
} else {
$null
}
K3DPinWritten = $k3dPinWritten
K3DPinReused = [bool] (
$ConfigureDevelopmentLive -and -not $k3dPinPreflight.NeedsWrite)
BuildVerified = $null -ne $buildResult
BuildOutputPath = if ($null -eq $buildResult) { $null } else {
$buildResult.TargetDirectory
}
DevelopmentLiveRequested = [bool] $ConfigureDevelopmentLive
DevelopmentLiveWritten = $liveConfigured
DevelopmentLiveReused = $false
}
}
finally {
try {
if ($setupMutexOwned) {
$setupMutex.ReleaseMutex()
}
}
finally {
$setupMutex.Dispose()
}
}