feat: add verified development live handoff
This commit is contained in:
238
scripts/Initialize-DevelopmentLiveConfig.ps1
Normal file
238
scripts/Initialize-DevelopmentLiveConfig.ps1
Normal file
@@ -0,0 +1,238 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('127.0.0.1', '::1')]
|
||||
[string] $PlayoutHost,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateRange(1, 65535)]
|
||||
[int] $PlayoutPort,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
|
||||
[string] $NativeSha256,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
|
||||
[string] $InteropSha256,
|
||||
|
||||
[ValidateRange(0, 2147483647)]
|
||||
[Nullable[int]] $OutputChannel = $null,
|
||||
|
||||
[switch] $Force,
|
||||
|
||||
[string] $ConfigurationDirectory = (
|
||||
Join-Path $env:LOCALAPPDATA 'MBN_STOCK_WEBVIEW\Config')
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$requiredAuthorization = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH'
|
||||
$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'
|
||||
)
|
||||
|
||||
function Assert-LocalAppDataPath {
|
||||
param([Parameter(Mandatory)][string] $Path)
|
||||
|
||||
$localRoot = [IO.Path]::GetFullPath(
|
||||
[Environment]::GetFolderPath(
|
||||
[Environment+SpecialFolder]::LocalApplicationData))
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
$rootPrefix = $localRoot.TrimEnd(
|
||||
[IO.Path]::DirectorySeparatorChar,
|
||||
[IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
||||
|
||||
if (-not $fullPath.StartsWith(
|
||||
$rootPrefix,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Development Live configuration must stay under LocalAppData.'
|
||||
}
|
||||
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Assert-NoReparsePointChain {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Directory,
|
||||
[Parameter(Mandatory)][string] $StopDirectory
|
||||
)
|
||||
|
||||
$current = [IO.Path]::GetFullPath($Directory)
|
||||
$stop = [IO.Path]::GetFullPath($StopDirectory)
|
||||
while (-not [string]::Equals(
|
||||
$current,
|
||||
$stop,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
$item = Get-Item -LiteralPath $current -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Development Live configuration directories cannot be reparse points.'
|
||||
}
|
||||
|
||||
$parent = [IO.Path]::GetDirectoryName($current)
|
||||
if ([string]::IsNullOrWhiteSpace($parent) -or
|
||||
[string]::Equals(
|
||||
$parent,
|
||||
$current,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'The LocalAppData configuration directory chain is invalid.'
|
||||
}
|
||||
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-WritableTarget {
|
||||
param([Parameter(Mandatory)][string] $Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Development Live configuration targets must be ordinary files.'
|
||||
}
|
||||
|
||||
if (-not $Force) {
|
||||
throw "Configuration already exists. Review it and rerun with -Force: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Set-CurrentUserOnlyAcl {
|
||||
param([Parameter(Mandatory)][string] $Path)
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$acl = [Security.AccessControl.FileSecurity]::new()
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$identity,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[Security.AccessControl.AccessControlType]::Allow)
|
||||
$acl.AddAccessRule($rule)
|
||||
[IO.File]::SetAccessControl($Path, $acl)
|
||||
}
|
||||
|
||||
function Invalidate-ExistingAuthorization {
|
||||
param([Parameter(Mandatory)][string] $Path)
|
||||
|
||||
if (-not [IO.File]::Exists($Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
$invalidatedPath = (
|
||||
$Path + '.invalidated.' + [Guid]::NewGuid().ToString('N'))
|
||||
[IO.File]::Move($Path, $invalidatedPath)
|
||||
try {
|
||||
Set-CurrentUserOnlyAcl $invalidatedPath
|
||||
[IO.File]::Delete($invalidatedPath)
|
||||
}
|
||||
catch {
|
||||
throw (
|
||||
'The previous Development Live authorization was invalidated but ' +
|
||||
"could not be removed. Remove this protected file before retrying: $invalidatedPath")
|
||||
}
|
||||
|
||||
if ([IO.File]::Exists($Path)) {
|
||||
throw 'The previous Development Live authorization could not be invalidated.'
|
||||
}
|
||||
}
|
||||
|
||||
function Write-ProtectedJson {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Path,
|
||||
[Parameter(Mandatory)][string] $Json
|
||||
)
|
||||
|
||||
$temporaryPath = "$Path.tmp.$([Guid]::NewGuid().ToString('N'))"
|
||||
try {
|
||||
[IO.File]::WriteAllText(
|
||||
$temporaryPath,
|
||||
$Json,
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
Set-CurrentUserOnlyAcl $temporaryPath
|
||||
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
|
||||
}
|
||||
finally {
|
||||
if ([IO.File]::Exists($temporaryPath)) {
|
||||
Remove-Item -LiteralPath $temporaryPath -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$configurationRoot = Assert-LocalAppDataPath $ConfigurationDirectory
|
||||
[IO.Directory]::CreateDirectory($configurationRoot) | Out-Null
|
||||
$localApplicationData = [IO.Path]::GetFullPath(
|
||||
[Environment]::GetFolderPath(
|
||||
[Environment+SpecialFolder]::LocalApplicationData))
|
||||
Assert-NoReparsePointChain $configurationRoot $localApplicationData
|
||||
|
||||
$playoutPath = Join-Path $configurationRoot 'playout.local.json'
|
||||
$authorizationPath = Join-Path $configurationRoot 'playout.development-live.local.json'
|
||||
Assert-WritableTarget $playoutPath
|
||||
Assert-WritableTarget $authorizationPath
|
||||
|
||||
$playout = [ordered]@{
|
||||
mode = 'DryRun'
|
||||
host = $PlayoutHost
|
||||
port = $PlayoutPort
|
||||
tcpMode = 1
|
||||
clientPort = 0
|
||||
sceneDirectory = $null
|
||||
outputChannel = $OutputChannel
|
||||
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 = $requiredAuthorization
|
||||
nativeSha256 = $NativeSha256.ToUpperInvariant()
|
||||
interopSha256 = $InteropSha256.ToUpperInvariant()
|
||||
}
|
||||
|
||||
$playoutJson = $playout | ConvertTo-Json -Depth 6
|
||||
$authorizationJson = $authorization | ConvertTo-Json -Depth 3
|
||||
|
||||
# In Force mode, invalidate the old authorization before changing the DryRun
|
||||
# base. Any later failure therefore leaves no valid authorization at the exact
|
||||
# path consumed by the Debug-only bootstrap.
|
||||
Invalidate-ExistingAuthorization -Path $authorizationPath
|
||||
|
||||
# Write the safe DryRun base first and the new one-launch authorization last.
|
||||
# Merely creating these files does not connect: Debug, the exact
|
||||
# --development-live argument, vendor hash verification and runtime gates remain
|
||||
# mandatory in the application.
|
||||
Write-ProtectedJson $playoutPath $playoutJson
|
||||
Write-ProtectedJson $authorizationPath $authorizationJson
|
||||
|
||||
Write-Host "Development Live base configuration created at: $playoutPath"
|
||||
Write-Host "Development Live launch authorization created at: $authorizationPath"
|
||||
Write-Warning (
|
||||
'The base file remains DryRun. Use only Debug|x64 with the exact ' +
|
||||
'Development Live (Package) profile after confirming the local development PGM target.')
|
||||
1018
scripts/Initialize-LegacyRuntimeBundle.ps1
Normal file
1018
scripts/Initialize-LegacyRuntimeBundle.ps1
Normal file
File diff suppressed because it is too large
Load Diff
566
scripts/New-LegacyRuntimeBundle.ps1
Normal file
566
scripts/New-LegacyRuntimeBundle.ps1
Normal file
@@ -0,0 +1,566 @@
|
||||
#Requires -Version 5.1
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $LegacyRuntimeSourceRoot,
|
||||
|
||||
[Parameter()]
|
||||
[string] $OutputDirectory
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$manifestFileName = 'LegacyRuntimeBundle.manifest.json'
|
||||
$archiveFileName = 'LegacyRuntimeBundle.zip'
|
||||
$archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256'
|
||||
$expectedResAssetCount = 34
|
||||
|
||||
function Get-NormalizedFullPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
return [IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
|
||||
function Test-PathIsWithin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Root,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Candidate
|
||||
)
|
||||
|
||||
$normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd(
|
||||
[IO.Path]::DirectorySeparatorChar,
|
||||
[IO.Path]::AltDirectorySeparatorChar)
|
||||
$normalizedCandidate = Get-NormalizedFullPath -Path $Candidate
|
||||
$prefix = $normalizedRoot + [IO.Path]::DirectorySeparatorChar
|
||||
return $normalizedCandidate.StartsWith(
|
||||
$prefix,
|
||||
[StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Assert-NoReparsePointInExistingAncestry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Description
|
||||
)
|
||||
|
||||
$current = Get-NormalizedFullPath -Path $Path
|
||||
while (-not [string]::IsNullOrEmpty($current)) {
|
||||
if (Test-Path -LiteralPath $current) {
|
||||
$attributes = [IO.File]::GetAttributes($current)
|
||||
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "$Description traverses a reparse point: $current"
|
||||
}
|
||||
}
|
||||
|
||||
$trimmed = $current.TrimEnd(
|
||||
[IO.Path]::DirectorySeparatorChar,
|
||||
[IO.Path]::AltDirectorySeparatorChar)
|
||||
$parent = [IO.Path]::GetDirectoryName($trimmed)
|
||||
if ([string]::IsNullOrEmpty($parent) -or
|
||||
$parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
break
|
||||
}
|
||||
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SafePayloadRelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $RelativePath
|
||||
)
|
||||
|
||||
$portablePath = $RelativePath.Replace('\', '/')
|
||||
if (-not $portablePath.Equals(
|
||||
$portablePath.Normalize([Text.NormalizationForm]::FormC),
|
||||
[StringComparison]::Ordinal)) {
|
||||
throw "Runtime payload path must use Unicode NFC normalization: $RelativePath"
|
||||
}
|
||||
$segments = @($portablePath.Split('/'))
|
||||
$unsafeSegments = @($segments | Where-Object {
|
||||
[string]::IsNullOrWhiteSpace($_) -or $_ -eq '.' -or $_ -eq '..'
|
||||
})
|
||||
if ($segments.Count -lt 2 -or $unsafeSegments.Count -gt 0) {
|
||||
throw "Unsafe runtime payload path: $RelativePath"
|
||||
}
|
||||
|
||||
$invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars()
|
||||
foreach ($segment in $segments) {
|
||||
if ($segment.EndsWith(' ', [StringComparison]::Ordinal) -or
|
||||
$segment.EndsWith('.', [StringComparison]::Ordinal) -or
|
||||
$segment.IndexOfAny($invalidFileNameCharacters) -ge 0) {
|
||||
throw "Runtime payload path is not a canonical Windows path: $RelativePath"
|
||||
}
|
||||
$deviceStem = $segment.Split('.')[0]
|
||||
if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
|
||||
throw "Runtime payload uses a reserved Windows file name: $RelativePath"
|
||||
}
|
||||
if ($segment -match '(?i)^(backup|backups|database|databases|license|licenses|cert|certs|credential|credentials|secret|secrets|vendor)$') {
|
||||
throw "Forbidden runtime payload directory or file name: $RelativePath"
|
||||
}
|
||||
}
|
||||
|
||||
$leafName = $segments[$segments.Count - 1]
|
||||
if (-not $leafName.Equals('MmoneyCoder.ico', [StringComparison]::OrdinalIgnoreCase) -and
|
||||
$leafName -match '(?i)^MmoneyCoder(?:$|[ ._-])') {
|
||||
throw "Credential-bearing or archived MmoneyCoder material is forbidden: $RelativePath"
|
||||
}
|
||||
|
||||
if ($leafName.Equals('afiedt.buf.txt', [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$leafName -match '(?i)(?:\.bak|\.backup|\.old|\.orig|\.save|\.tmp|\.temp|~)$' -or
|
||||
$leafName -match '(?i)(?:^|[ ._-])(backup|copy|\xBCF5\xC0AC\xBCF8)(?:[ ._-]|$)') {
|
||||
throw "Backup or temporary material is forbidden: $RelativePath"
|
||||
}
|
||||
|
||||
$extension = [IO.Path]::GetExtension($leafName)
|
||||
$forbiddenExtensions = @(
|
||||
'.dll', '.ocx', '.tlb',
|
||||
'.lic', '.license',
|
||||
'.pfx', '.p12', '.snk', '.cer', '.crt', '.key', '.pem', '.p7b', '.p7c',
|
||||
'.db', '.sqlite', '.sqlite3', '.mdb', '.accdb', '.mdf', '.ldf', '.sql',
|
||||
'.zip', '.7z', '.rar',
|
||||
'.exe', '.com', '.msi'
|
||||
)
|
||||
if ($forbiddenExtensions -icontains $extension) {
|
||||
throw "Forbidden database, vendor, license, certificate, archive, or executable file: $RelativePath"
|
||||
}
|
||||
|
||||
if ($leafName -match '(?i)^(database|credentials?|secrets?|connectionstrings?)(?:[._-]|$)') {
|
||||
throw "Credential or database configuration is forbidden: $RelativePath"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LegacyPackagedResAssetNames {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $ProjectPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ProjectPath -PathType Leaf)) {
|
||||
throw "Legacy parity project was not found: $ProjectPath"
|
||||
}
|
||||
|
||||
[xml] $projectXml = Get-Content -LiteralPath $ProjectPath -Raw -Encoding UTF8
|
||||
$nodes = @($projectXml.SelectNodes(
|
||||
"/*[local-name()='Project']/*[local-name()='ItemGroup']/*[local-name()='LegacyPackagedResAsset']"))
|
||||
if ($nodes.Count -ne $expectedResAssetCount) {
|
||||
throw ("The project must declare exactly {0} LegacyPackagedResAsset items; found {1}." -f
|
||||
$expectedResAssetCount,
|
||||
$nodes.Count)
|
||||
}
|
||||
|
||||
$prefix = '$(LegacyResSourceRoot)\'
|
||||
$names = New-Object 'System.Collections.Generic.List[string]'
|
||||
$seen = New-Object 'System.Collections.Generic.HashSet[string]' (
|
||||
[StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($node in $nodes) {
|
||||
$include = [string] $node.Include
|
||||
if (-not $include.StartsWith($prefix, [StringComparison]::Ordinal)) {
|
||||
throw "LegacyPackagedResAsset is outside the closed Res projection: $include"
|
||||
}
|
||||
|
||||
$name = $include.Substring($prefix.Length)
|
||||
if ([string]::IsNullOrWhiteSpace($name) -or
|
||||
$name.IndexOfAny(@(
|
||||
[IO.Path]::DirectorySeparatorChar,
|
||||
[IO.Path]::AltDirectorySeparatorChar)) -ge 0 -or
|
||||
$name -eq '.' -or
|
||||
$name -eq '..' -or
|
||||
[IO.Path]::GetFileName($name) -ne $name) {
|
||||
throw "LegacyPackagedResAsset must be a single Res file name: $include"
|
||||
}
|
||||
|
||||
Assert-SafePayloadRelativePath -RelativePath ('Res/' + $name)
|
||||
if (-not $seen.Add($name)) {
|
||||
throw "Duplicate LegacyPackagedResAsset declaration: $name"
|
||||
}
|
||||
|
||||
$names.Add($name)
|
||||
}
|
||||
|
||||
return @($names.ToArray() | Sort-Object)
|
||||
}
|
||||
|
||||
function Get-SafeTreeInventory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Root
|
||||
)
|
||||
|
||||
$rootPath = Get-NormalizedFullPath -Path $Root
|
||||
Assert-NoReparsePointInExistingAncestry -Path $rootPath -Description 'Runtime asset tree'
|
||||
$directories = New-Object 'System.Collections.Generic.List[string]'
|
||||
$files = New-Object 'System.Collections.Generic.List[string]'
|
||||
$pending = New-Object 'System.Collections.Generic.Queue[string]'
|
||||
$pending.Enqueue($rootPath)
|
||||
|
||||
while ($pending.Count -gt 0) {
|
||||
$directory = $pending.Dequeue()
|
||||
$children = @(Get-ChildItem -LiteralPath $directory -Force | Sort-Object Name)
|
||||
foreach ($child in $children) {
|
||||
if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Runtime asset tree contains a reparse point: $($child.FullName)"
|
||||
}
|
||||
|
||||
if ($child.PSIsContainer) {
|
||||
$directories.Add($child.FullName)
|
||||
$pending.Enqueue($child.FullName)
|
||||
}
|
||||
else {
|
||||
$files.Add($child.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Directories = @($directories.ToArray())
|
||||
Files = @($files.ToArray())
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RelativeChildPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Root,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Child
|
||||
)
|
||||
|
||||
$rootPath = (Get-NormalizedFullPath -Path $Root).TrimEnd(
|
||||
[IO.Path]::DirectorySeparatorChar,
|
||||
[IO.Path]::AltDirectorySeparatorChar)
|
||||
$childPath = Get-NormalizedFullPath -Path $Child
|
||||
if (-not (Test-PathIsWithin -Root $rootPath -Candidate $childPath)) {
|
||||
throw "Path is outside its expected root: $childPath"
|
||||
}
|
||||
|
||||
return $childPath.Substring($rootPath.Length + 1)
|
||||
}
|
||||
|
||||
function Get-GitRepositoryAncestor {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
$current = Get-NormalizedFullPath -Path $Path
|
||||
while (-not [string]::IsNullOrEmpty($current)) {
|
||||
if (Test-Path -LiteralPath (Join-Path $current '.git')) {
|
||||
return $current
|
||||
}
|
||||
$trimmed = $current.TrimEnd(
|
||||
[IO.Path]::DirectorySeparatorChar,
|
||||
[IO.Path]::AltDirectorySeparatorChar)
|
||||
$parent = [IO.Path]::GetDirectoryName($trimmed)
|
||||
if ([string]::IsNullOrEmpty($parent) -or
|
||||
$parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
break
|
||||
}
|
||||
$current = $parent
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Copy-VerifiedPayloadFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Source,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Destination,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $ManifestPath
|
||||
)
|
||||
|
||||
if (([IO.File]::GetAttributes($Source) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Runtime payload file is a reparse point: $Source"
|
||||
}
|
||||
|
||||
$destinationParent = [IO.Path]::GetDirectoryName($Destination)
|
||||
if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) {
|
||||
[IO.Directory]::CreateDirectory($destinationParent) | Out-Null
|
||||
}
|
||||
|
||||
[IO.File]::Copy($Source, $Destination, $false)
|
||||
$sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash.ToUpperInvariant()
|
||||
$destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToUpperInvariant()
|
||||
if (-not $sourceHash.Equals($destinationHash, [StringComparison]::Ordinal)) {
|
||||
throw "Runtime payload changed while it was copied: $ManifestPath"
|
||||
}
|
||||
|
||||
$destinationFile = Get-Item -LiteralPath $Destination -Force
|
||||
return [ordered]@{
|
||||
path = $ManifestPath.Replace('\', '/')
|
||||
length = [long] $destinationFile.Length
|
||||
sha256 = $destinationHash
|
||||
}
|
||||
}
|
||||
|
||||
$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
|
||||
$projectPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj'
|
||||
$sourceRoot = Get-NormalizedFullPath -Path $LegacyRuntimeSourceRoot
|
||||
$cutsRoot = Join-Path $sourceRoot 'Cuts'
|
||||
$resRoot = Join-Path $sourceRoot 'Res'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) {
|
||||
throw "Legacy runtime source root was not found: $sourceRoot"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container)) {
|
||||
throw "Legacy Cuts source directory was not found: $cutsRoot"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $resRoot -PathType Container)) {
|
||||
throw "Legacy Res source directory was not found: $resRoot"
|
||||
}
|
||||
|
||||
Assert-NoReparsePointInExistingAncestry -Path $sourceRoot -Description 'Legacy runtime source root'
|
||||
Assert-NoReparsePointInExistingAncestry -Path $cutsRoot -Description 'Legacy Cuts source directory'
|
||||
Assert-NoReparsePointInExistingAncestry -Path $resRoot -Description 'Legacy Res source directory'
|
||||
|
||||
$resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath)
|
||||
$cutsInventory = Get-SafeTreeInventory -Root $cutsRoot
|
||||
if (@($cutsInventory.Files).Count -eq 0) {
|
||||
throw "Legacy Cuts source directory contains no files: $cutsRoot"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
||||
$bundleLeaf = '{0}-{1}' -f
|
||||
(Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'),
|
||||
([Guid]::NewGuid().ToString('N'))
|
||||
$outputRoot = Join-Path $repositoryRoot ('artifacts\legacy-runtime-bundles\' + $bundleLeaf)
|
||||
}
|
||||
else {
|
||||
$outputRoot = Get-NormalizedFullPath -Path $OutputDirectory
|
||||
}
|
||||
$outputRoot = Get-NormalizedFullPath -Path $outputRoot
|
||||
$legacyRepositoryRoot = Get-GitRepositoryAncestor -Path $sourceRoot
|
||||
|
||||
if ($outputRoot.Equals(
|
||||
([IO.Path]::GetPathRoot($outputRoot)).TrimEnd(
|
||||
[IO.Path]::AltDirectorySeparatorChar),
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The output directory cannot be a filesystem root: $outputRoot"
|
||||
}
|
||||
if ((Test-PathIsWithin -Root $sourceRoot -Candidate $outputRoot) -or
|
||||
(Test-PathIsWithin -Root $outputRoot -Candidate $sourceRoot) -or
|
||||
$outputRoot.Equals($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'The bundle output directory must not overlap the read-only runtime source root.'
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($legacyRepositoryRoot) -and
|
||||
($outputRoot.Equals(
|
||||
$legacyRepositoryRoot,
|
||||
[StringComparison]::OrdinalIgnoreCase) -or
|
||||
(Test-PathIsWithin `
|
||||
-Root $legacyRepositoryRoot `
|
||||
-Candidate $outputRoot))) {
|
||||
throw 'The bundle output directory must not be inside the read-only legacy repository.'
|
||||
}
|
||||
if (Test-Path -LiteralPath $outputRoot) {
|
||||
throw "The bundle output directory already exists; choose a new directory: $outputRoot"
|
||||
}
|
||||
|
||||
$outputParent = [IO.Path]::GetDirectoryName($outputRoot)
|
||||
Assert-NoReparsePointInExistingAncestry -Path $outputParent -Description 'Bundle output parent'
|
||||
[IO.Directory]::CreateDirectory($outputRoot) | Out-Null
|
||||
Assert-NoReparsePointInExistingAncestry -Path $outputRoot -Description 'Bundle output directory'
|
||||
|
||||
$stagingRoot = Join-Path $outputRoot ('.staging-' + [Guid]::NewGuid().ToString('N'))
|
||||
$stagingCreated = $false
|
||||
$operationSucceeded = $false
|
||||
try {
|
||||
[IO.Directory]::CreateDirectory($stagingRoot) | Out-Null
|
||||
$stagingCreated = $true
|
||||
$stagingCutsRoot = Join-Path $stagingRoot 'Cuts'
|
||||
$stagingResRoot = Join-Path $stagingRoot 'Res'
|
||||
[IO.Directory]::CreateDirectory($stagingCutsRoot) | Out-Null
|
||||
[IO.Directory]::CreateDirectory($stagingResRoot) | Out-Null
|
||||
|
||||
foreach ($sourceDirectory in @($cutsInventory.Directories)) {
|
||||
$relativeDirectory = Get-RelativeChildPath -Root $cutsRoot -Child $sourceDirectory
|
||||
Assert-SafePayloadRelativePath -RelativePath ('Cuts/' + $relativeDirectory.Replace('\', '/'))
|
||||
[IO.Directory]::CreateDirectory(
|
||||
(Join-Path $stagingCutsRoot $relativeDirectory)) | Out-Null
|
||||
}
|
||||
|
||||
$manifestFiles = New-Object 'System.Collections.Generic.List[object]'
|
||||
foreach ($sourceFile in @($cutsInventory.Files | Sort-Object)) {
|
||||
$relativeFile = Get-RelativeChildPath -Root $cutsRoot -Child $sourceFile
|
||||
$portableManifestPath = 'Cuts/' + $relativeFile.Replace('\', '/')
|
||||
Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
|
||||
$destinationFile = Join-Path $stagingCutsRoot $relativeFile
|
||||
$manifestFiles.Add((Copy-VerifiedPayloadFile `
|
||||
-Source $sourceFile `
|
||||
-Destination $destinationFile `
|
||||
-ManifestPath $portableManifestPath))
|
||||
}
|
||||
|
||||
foreach ($resAssetName in $resAssetNames) {
|
||||
$sourceFile = Join-Path $resRoot $resAssetName
|
||||
if (-not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) {
|
||||
throw "Required packaged Res asset was not found: $sourceFile"
|
||||
}
|
||||
|
||||
if (-not (Get-NormalizedFullPath -Path $sourceFile).StartsWith(
|
||||
((Get-NormalizedFullPath -Path $resRoot).TrimEnd('\') + '\'),
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Packaged Res asset escaped the Res source directory: $resAssetName"
|
||||
}
|
||||
|
||||
$portableManifestPath = 'Res/' + $resAssetName
|
||||
Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
|
||||
$destinationFile = Join-Path $stagingResRoot $resAssetName
|
||||
$manifestFiles.Add((Copy-VerifiedPayloadFile `
|
||||
-Source $sourceFile `
|
||||
-Destination $destinationFile `
|
||||
-ManifestPath $portableManifestPath))
|
||||
}
|
||||
|
||||
$sortedManifestFiles = @($manifestFiles.ToArray() | Sort-Object { $_.path })
|
||||
$cutsFileCount = @($sortedManifestFiles | Where-Object {
|
||||
([string] $_.path).StartsWith('Cuts/', [StringComparison]::Ordinal)
|
||||
}).Count
|
||||
$resFileCount = @($sortedManifestFiles | Where-Object {
|
||||
([string] $_.path).StartsWith('Res/', [StringComparison]::Ordinal)
|
||||
}).Count
|
||||
if ($cutsFileCount -ne @($cutsInventory.Files).Count -or
|
||||
$resFileCount -ne $expectedResAssetCount) {
|
||||
throw 'The staged runtime payload does not match the closed Cuts/Res projection.'
|
||||
}
|
||||
|
||||
$manifest = [ordered]@{
|
||||
schemaVersion = 1
|
||||
bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle'
|
||||
fileCount = [int] $sortedManifestFiles.Count
|
||||
cutsFileCount = [int] $cutsFileCount
|
||||
resFileCount = [int] $resFileCount
|
||||
files = $sortedManifestFiles
|
||||
}
|
||||
$stagedManifestPath = Join-Path $stagingRoot $manifestFileName
|
||||
$manifestJson = $manifest | ConvertTo-Json -Depth 6
|
||||
[IO.File]::WriteAllText(
|
||||
$stagedManifestPath,
|
||||
($manifestJson + [Environment]::NewLine),
|
||||
(New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archivePath = Join-Path $outputRoot $archiveFileName
|
||||
$archiveStream = New-Object IO.FileStream(
|
||||
$archivePath,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None)
|
||||
$archive = $null
|
||||
try {
|
||||
$archive = New-Object IO.Compression.ZipArchive(
|
||||
$archiveStream,
|
||||
[IO.Compression.ZipArchiveMode]::Create,
|
||||
$true)
|
||||
|
||||
$stagedDirectories = @(Get-ChildItem `
|
||||
-LiteralPath $stagingRoot `
|
||||
-Directory `
|
||||
-Recurse `
|
||||
-Force |
|
||||
Sort-Object FullName)
|
||||
foreach ($stagedDirectory in $stagedDirectories) {
|
||||
$relativeDirectory = Get-RelativeChildPath `
|
||||
-Root $stagingRoot `
|
||||
-Child $stagedDirectory.FullName
|
||||
$entryName = $relativeDirectory.Replace('\', '/') + '/'
|
||||
$directoryEntry = $archive.CreateEntry($entryName)
|
||||
$directoryEntry.ExternalAttributes = [int] [IO.FileAttributes]::Directory
|
||||
}
|
||||
|
||||
$stagedFiles = @(Get-ChildItem `
|
||||
-LiteralPath $stagingRoot `
|
||||
-File `
|
||||
-Recurse `
|
||||
-Force |
|
||||
Sort-Object FullName)
|
||||
foreach ($stagedFile in $stagedFiles) {
|
||||
$relativeFile = Get-RelativeChildPath `
|
||||
-Root $stagingRoot `
|
||||
-Child $stagedFile.FullName
|
||||
$entryName = $relativeFile.Replace('\', '/')
|
||||
$fileEntry = $archive.CreateEntry(
|
||||
$entryName,
|
||||
[IO.Compression.CompressionLevel]::Optimal)
|
||||
$sourceStream = [IO.File]::Open(
|
||||
$stagedFile.FullName,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$entryStream = $fileEntry.Open()
|
||||
try {
|
||||
$sourceStream.CopyTo($entryStream)
|
||||
}
|
||||
finally {
|
||||
$entryStream.Dispose()
|
||||
$sourceStream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $archive) {
|
||||
$archive.Dispose()
|
||||
}
|
||||
$archiveStream.Dispose()
|
||||
}
|
||||
|
||||
$externalManifestPath = Join-Path $outputRoot $manifestFileName
|
||||
[IO.File]::Copy($stagedManifestPath, $externalManifestPath, $false)
|
||||
$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant()
|
||||
$archiveHashPath = Join-Path $outputRoot $archiveHashFileName
|
||||
[IO.File]::WriteAllText(
|
||||
$archiveHashPath,
|
||||
($archiveHash + ' ' + $archiveFileName + [Environment]::NewLine),
|
||||
[Text.Encoding]::ASCII)
|
||||
|
||||
$operationSucceeded = $true
|
||||
}
|
||||
finally {
|
||||
if ($stagingCreated -and (Test-Path -LiteralPath $stagingRoot)) {
|
||||
if (-not (Test-PathIsWithin -Root $outputRoot -Candidate $stagingRoot) -or
|
||||
-not ([IO.Path]::GetFileName($stagingRoot)).StartsWith(
|
||||
'.staging-',
|
||||
[StringComparison]::Ordinal)) {
|
||||
throw "Refusing to clean an unexpected staging path: $stagingRoot"
|
||||
}
|
||||
|
||||
Get-SafeTreeInventory -Root $stagingRoot | Out-Null
|
||||
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if (-not $operationSucceeded -and (Test-Path -LiteralPath $outputRoot)) {
|
||||
$remainingItems = @(Get-ChildItem -LiteralPath $outputRoot -Force)
|
||||
if ($remainingItems.Count -eq 0) {
|
||||
Remove-Item -LiteralPath $outputRoot -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
OutputDirectory = $outputRoot
|
||||
ArchivePath = $archivePath
|
||||
ArchiveSha256 = $archiveHash
|
||||
ArchiveHashPath = $archiveHashPath
|
||||
ManifestPath = $externalManifestPath
|
||||
FileCount = [int] $sortedManifestFiles.Count
|
||||
CutsFileCount = [int] $cutsFileCount
|
||||
ResFileCount = [int] $resFileCount
|
||||
}
|
||||
Reference in New Issue
Block a user