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