From 483b2785a0937655d24e00ec2a7ee1e496b994ee Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 26 Jul 2026 23:11:29 +0900 Subject: [PATCH] feat: initialize existing development playout PCs --- README.md | 74 +- docs/DEVELOPMENT_LIVE_HANDOFF.md | 156 +- scripts/Initialize-ExistingDevelopmentPc.ps1 | 2285 +++++++++++++++++ .../DatabaseRuntime.cs | 117 + .../App.xaml.cs | 9 + .../MBN_STOCK_WEBVIEW.LegacyParityApp.csproj | 14 +- .../MainWindow.Playout.cs | 15 +- .../MainWindow.xaml.cs | 37 +- .../DevelopmentLiveLaunchBootstrap.cs | 61 +- .../Configuration/PlayoutOptions.cs | 13 + .../Configuration/PlayoutOptionsLoader.cs | 351 +++ .../Configuration/ValidatedPlayoutOptions.cs | 47 + .../LegacyIniDatabaseOptionsLoaderTests.cs | 121 + ...gacyDevelopmentLiveStartupContractTests.cs | 107 + ...gacyRuntimeAssetDeploymentContractTests.cs | 396 ++- .../DevelopmentLiveLaunchBootstrapTests.cs | 42 +- .../PlayoutOptionsLoaderTests.cs | 243 ++ .../PlayoutSafetyValidationTests.cs | 15 + 18 files changed, 4029 insertions(+), 74 deletions(-) create mode 100644 scripts/Initialize-ExistingDevelopmentPc.ps1 diff --git a/README.md b/README.md index dcc88db..095a8ce 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑 ## Visual Studio 2026에서 실행 +실제 송출을 확인할 기존 개발 PC는 바로 F5하지 말고 아래의 +`기존 설치가 있는 개발 PC에서 실제 송출` 초기화를 먼저 실행합니다. 화면만 안전하게 확인하는 +새 PC는 다음 순서를 사용합니다. + 1. `MBN_STOCK_WEBVIEW.sln`을 엽니다. 2. 솔루션 구성을 `Debug`, 플랫폼을 `x64`로 선택합니다. 3. 공유 시작 프로필 `Legacy Parity App (VS F5)` 또는 @@ -68,16 +72,63 @@ DB 검색과 실제 장면 송출은 사용할 수 없습니다. Test/Live 송 DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도 이전 전체 빌드의 `Cuts`, `Res` 또는 DB 설정을 잘못 재사용하지 않도록 `bin\SourceOnly`와 `obj\SourceOnly`에 분리됩니다. -전체 장면·DB 기능이 필요한 개발자는 Git에 자산이나 자격증명을 추가하지 말고 승인된 -`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤 -[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요. +### 기존 설치가 있는 개발 PC에서 실제 송출 -인수 PC의 **방송 비연결 개발용** Tornado2/PGM에서 실제 송출을 확인할 때는 외부 runtime -bundle, 로컬 DB·송출 설정, K3D 라이선스와 독립 승인 해시를 먼저 준비하고 -[개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)를 따르세요. Development Live는 -`Debug|x64` Package에만 적용되며 Release/default `DryRun`은 그대로 유지합니다. -runtime bundle은 `New-LegacyRuntimeBundle.ps1`로 만들고 인수 PC에서 -`Initialize-LegacyRuntimeBundle.ps1`로 별도 승인 SHA-256을 검증해 설치합니다. +상사 PC처럼 `Cuts`, `Res\MmoneyCoder.ini`, Tornado2/K3D x64와 장비 라이선스가 이미 있는 +**방송 비연결 개발 장비**에서는 Git에 자산을 넣거나 runtime ZIP을 다시 전달할 필요가 없습니다. +다만 Git은 이 로컬 항목과 실제 PGM endpoint·승인 해시를 의도적으로 포함하지 않으므로, +무설정 첫 F5만으로 실제 송출하는 것은 불가능합니다. 잘못된 과거 출력이나 다른 Tornado 자산을 +자동 선택하지 않도록 최초 clone마다 Visual Studio를 열기 전에 저장소 루트에서 다음 초기화를 +한 번 실행합니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\Initialize-ExistingDevelopmentPc.ps1 ` + -LegacyRuntimeSourceRoot '' ` + -ConfigureDevelopmentLive ` + -PlayoutHost 127.0.0.1 ` + -PlayoutPort ` + -NativeSha256 '<독립 승인된 native DLL SHA-256>' ` + -InteropSha256 '<독립 승인된 Interop DLL SHA-256>' +``` + +`-LegacyRuntimeSourceRoot`를 생략하면 정확한 기본 형제 경로를 먼저 확인하고, 없으면 Windows +폴더 선택기를 엽니다. 여기서는 `Cuts`와 `Res`를 함께 포함하는 `bin\Debug` 공통 부모를 +선택하거나, 편의를 위해 그 바로 아래의 `Cuts` 또는 `Res`를 선택해도 공통 부모로 +정규화됩니다. DB INI가 선택한 `Res\MmoneyCoder.ini`가 아닌 다른 위치에 있으면 검토한 +절대 경로를 `-DatabaseIniPath`로 지정합니다. 기존 승인 프로필이 별도 출력 채널을 사용하는 +경우에만 확인된 숫자를 `-OutputChannel`로 추가합니다. + +초기화기는 기존 자산을 검증한 뒤 사용자 전용 LocalAppData에 검증된 runtime 복사본을 만들고, +Git 제외 `Directory.Build.local.props`가 그 복사본을 `Required` 모드로 사용하게 합니다. +DB INI도 사용자 전용 LocalAppData overlay로 복사해 ACL을 보호하고, K3D x64 등록을 읽기 +전용으로 점검합니다. 위 Live 옵션을 모두 준 경우에만 별도 승인 해시를 실제 등록 파일과 +대조한 뒤 보호된 Development Live 설정을 마지막에 새로 발급합니다. Debug x64 빌드 출력은 +선택한 manifest와 경로·길이·SHA-256·파일 집합이 정확히 일치하고 SourceOnly가 아닌지까지 +자동 확인합니다. 중간 단계가 실패하면 기존 runtime 연결과 DB overlay를 원래 내용·ACL로 +되돌리고 Live 승인 파일을 남기지 않습니다. 이 과정은 Tornado에 `CONNECT`하거나 장면을 +송출하지 않습니다. + +초기화가 성공하면 Visual Studio에서 `Debug|x64`, `Legacy Parity App (VS F5)`, +`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`를 선택해 F5로 시작합니다. +앱이나 빌드 출력이 `SourceOnly` 또는 `DryRun`이라고 표시되면 송출을 시도하지 말고 초기화를 +바로잡습니다. 최초 실제 확인은 [개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)에 따라 +`5001` 하나로 진행합니다. Development Live는 이 Debug 프로세스에만 적용되며 Release와 기본 +프로필은 계속 `DryRun`입니다. 이 프로필은 보호된 LocalAppData 설정만 읽고 상속된 +`MBN_STOCK_PLAYOUT_*`·DB 환경 변수와 기존 운영자 scene/resource/background 선택을 무시합니다. +endpoint는 숫자형 loopback, scene root는 실행 파일 옆 검증된 `Cuts`로 다시 고정하며 strict +검증이 실패하면 Live를 승인하지 않고 안전한 DryRun으로 시작합니다. + +같은 clone에서는 로컬 연결 파일이 Git에 덮어써지지 않으므로 이후에는 `git pull` 후 F5로 +확인할 수 있습니다. 저장소를 새 폴더에 다시 clone하거나 자산·DB·K3D·PGM 설정이 바뀌면 +초기화를 다시 실행합니다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 계속 시도하지 +말고 초기화기를 다시 실행합니다. 상사에게 저장소 주소를 전달하기 전에는 이 초기화 기능이 +상사가 받는 기본 브랜치에 통합되어 있는지 확인합니다. + +해당 PC에 `Cuts`/`Res`가 없을 때만 +`New-LegacyRuntimeBundle.ps1`과 `Initialize-LegacyRuntimeBundle.ps1`의 검증 ZIP 절차를 +대안으로 사용합니다. 자세한 두 흐름은 [개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)와 +[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)에 있습니다. 이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요. @@ -127,8 +178,9 @@ Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위 원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 전체 자산 Debug 빌드, 소스 전용 빌드, 게시와 MSIX 모두 이 자격증명 파일을 빌드 입력이나 출력으로 사용하지 않습니다. 전체 자산 Debug/MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 -`database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 환경 변수는 선택된 -파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 +`database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 일반 실행에서는 +환경 변수가 선택된 파일보다 우선하지만, 검증된 Development Live 프로필은 이 override를 +의도적으로 무시하고 위 LocalAppData 파일만 사용합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다. MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된 diff --git a/docs/DEVELOPMENT_LIVE_HANDOFF.md b/docs/DEVELOPMENT_LIVE_HANDOFF.md index 71b7cc9..1107b3f 100644 --- a/docs/DEVELOPMENT_LIVE_HANDOFF.md +++ b/docs/DEVELOPMENT_LIVE_HANDOFF.md @@ -13,17 +13,21 @@ 유효하다. `Release`와 일반 설치본의 기본 모드는 계속 `DryRun`이다. Release를 Live로 바꾸거나 사용자·시스템 환경 변수에 Live 승인을 영구 저장하지 않는다. -## 인수 전에 별도로 전달할 항목 +## Git 밖에서 확인하거나 준비할 항목 -다음 항목은 저장소가 아니라 승인된 사내 전달 경로로 제공한다. +상사 PC에 이미 `Cuts`, `Res\MmoneyCoder.ini`, Tornado2/K3D x64와 장비 라이선스가 있다면 +같은 항목을 다시 전달하지 않는다. 대신 기존 설치 위치가 이번 인수 대상이 맞는지 확인하고, +다음 값은 저장소가 아니라 승인된 사내 기록이나 전달 경로에서 확인한다. -- `New-LegacyRuntimeBundle.ps1`로 생성한 runtime bundle ZIP과 별도 신뢰 경로로 전달할 - 기대 SHA-256. - DB endpoint와 계정 정보. 메신저 화면, 작업 기록 또는 Git에 값을 남기지 않는다. -- Tornado2/K3D x64 벤더 설치본과 해당 장비용 라이선스 설치 절차. +- 기존 `Cuts`와 `Res`의 공통 부모 경로, 그리고 실제 자격증명 INI 경로. +- Tornado2/K3D x64 설치본과 해당 장비용 라이선스 상태. - 벤더 배포본 또는 관리자 검수로 **독립 승인된** native DLL과 Interop DLL의 SHA-256 두 개. - 개발 PGM의 실제 Network Server TCP port, 창 제목, 출력 라우팅과 담당자 확인 정보. +해당 PC에 승인된 `Cuts`/`Res`가 없을 때만 `New-LegacyRuntimeBundle.ps1`로 생성한 runtime +bundle ZIP과 별도 신뢰 경로로 전달할 기대 SHA-256을 추가로 준비한다. + 현재 설치된 DLL의 해시를 그 자리에서 계산한 뒤 같은 값을 승인값으로 삼지 않는다. 라이선스 파일, DLL, 인증서 또는 자산을 앱 폴더에 임의 복사해 등록·라이선스 검사를 우회하지 않는다. @@ -40,7 +44,88 @@ 한 항목이라도 불명확하면 이후 명령을 보내지 않는다. -## 2. 검증된 Git 밖 runtime bundle 설치 +## 2. 기존 자산 보유 PC: clone 후 1회 초기화 + +상사 PC처럼 승인된 자산과 벤더 설치가 이미 있는 경우에는 이 흐름을 우선 사용한다. 저장소를 +clone한 뒤 Visual Studio를 열기 전에 저장소 루트의 PowerShell에서 다음 초기화기를 한 번 +실행한다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\Initialize-ExistingDevelopmentPc.ps1 ` + -LegacyRuntimeSourceRoot '' ` + -ConfigureDevelopmentLive ` + -PlayoutHost 127.0.0.1 ` + -PlayoutPort ` + -NativeSha256 '<독립 승인된 native DLL SHA-256>' ` + -InteropSha256 '<독립 승인된 Interop DLL SHA-256>' +``` + +DB INI가 `<공통 부모>\Res\MmoneyCoder.ini`가 아닌 다른 위치에 있다면 검토한 절대 경로를 +`-DatabaseIniPath`로 추가한다. `-LegacyRuntimeSourceRoot`를 생략했을 때는 스크립트가 +정확한 기본 형제 저장소 경로를 먼저 확인하고, 찾지 못하면 폴더 선택기를 연다. 선택기에서는 +`Cuts`와 `Res`를 함께 포함하는 `bin\Debug` 공통 부모를 선택한다. 그 바로 아래의 `Cuts` +또는 `Res`를 선택해도 스크립트가 공통 부모로 정규화한다. 이름이 비슷한 백업 폴더나 과거의 +다른 빌드 출력은 선택하지 않는다. 비대화형 실행에서 선택기를 금지하려면 +`-NoFolderPicker`를 추가하며, 이 경우 기본 형제 경로가 없으면 명시적 경로 없이 진행하지 +않는다. +기존 승인 프로필이 별도 출력 채널을 사용하는 경우에만 확인된 숫자를 +`-OutputChannel `로 추가하고, 기본 player를 사용하는 경우에는 생략한다. + +초기화기는 다음 작업을 한 번에 수행해야 한다. + +- 로컬 `Cuts`/`Res` tree와 프로젝트가 요구하는 자산 계약을 읽기 전용으로 검증하고, 임시 + bundle을 거쳐 사용자 전용 + `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\RuntimeBundles\`에 검증된 복사본을 설치한다. +- 저장소에서 제외되는 `Directory.Build.local.props`가 그 설치 root를 + `LegacyRuntimeAssetsMode=Required`로 사용하게 한다. +- 기존 DB INI를 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`로 복사하고 현재 + Windows 사용자 전용 ACL을 적용한다. +- K3D x64 등록 상태를 읽기 전용으로 점검한다. +- `-ConfigureDevelopmentLive`를 지정했을 때만 port와 독립 승인 해시를 검증하고 보호된 로컬 + Development Live 설정을 모든 선행 검증이 끝난 마지막 단계에서 새로 발급한다. +- 별도 `-SkipBuild`를 주지 않은 기본 실행에서는 Debug x64 전체 자산 빌드 후 선택된 runtime + root, Cuts/Res의 경로·길이·SHA-256·전체 파일 집합, SourceOnly가 아님과 DB INI가 빌드 + 출력에 들어가지 않았음을 검증한다. + +초기화 과정 자체는 Tornado2/PGM에 `CONNECT`하거나 PREPARE, TAKE IN, NEXT, TAKE OUT 등의 +명령을 보내지 않는다. 초기화 도중 오류가 나면 F5로 송출을 시도하지 말고 원인부터 바로잡는다. +`-ConfigureDevelopmentLive`와 `-SkipBuild`는 함께 사용할 수 없으므로 실제 송출 설정 전에 +전체 자산 빌드 검증을 생략할 수 없다. runtime 연결이나 DB overlay를 갱신한 뒤 선행 단계가 +실패하면 기존 파일의 정확한 내용과 ACL을 복구하고, Live 승인 파일은 제거된 상태로 둔다. + +기본 초기화가 이미 빌드를 확인한다. 필요하면 같은 조건이 유지되는지 별도 MSBuild 인자나 환경 +변수 없이 다음 명령으로 다시 확인할 수 있다. + +```powershell +dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj ` + -c Debug ` + -p:Platform=x64 +``` + +출력에 전체 runtime 자산이 활성화되었다는 메시지가 있어야 한다. 소스 전용 모드, +`bin\SourceOnly` 또는 `DryRun`만 표시되면 Development Live를 진행하지 않는다. +위 권장 명령의 최종 요약은 `K3DStatus=Valid`, `K3DComActivated=false`, +`BuildVerified=true`, `DevelopmentLiveRequested=true`여야 한다. +`DevelopmentLiveWritten=true`, `DevelopmentLiveReused=false`여야 한다. 경로와 해시가 포함된 +전체 출력은 Git이나 일반 메신저에 붙이지 않는다. + +같은 자산·DB·Live 설정으로 다시 실행하면 검증된 runtime과 DB는 재사용하지만, 기존 Live +승인은 먼저 무효화하고 전체 검증이 다시 성공한 뒤 새로 발급한다. 기존 값이 달라졌다면 자동으로 +덮어쓰지 않으며, 변경 대상을 먼저 검토한 뒤 필요한 항목에만 각각 +`-ReplaceRuntimeBinding`, `-ReplaceDatabaseProfile`, `-ReplaceLiveConfig`를 명시한다. + +같은 clone에서는 이후 `git pull` 뒤 Visual Studio 2026에서 `Debug|x64`, 시작 대상 +`Legacy Parity App (VS F5)`, 실행 프로필 +`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`를 선택해 F5로 시작할 수 +있다. 저장소를 새 폴더에 다시 clone하거나 자산·DB·K3D·PGM 설정이 바뀐 경우에는 초기화기를 +다시 실행한다. pull 뒤 Required 자산 검증이 실패하는 경우에도 F5를 반복하지 말고 초기화기를 +다시 실행한다. 최초 실제 동작 확인은 아래 최소 인수 시퀀스의 `5001` 하나로 제한한다. + +## 3. 자산이 없는 PC 대안: 검증된 Git 밖 runtime bundle 설치 + +기존 자산 보유 PC에서 2단계 초기화가 성공했다면 이 단계는 건너뛴다. 이 단계는 승인된 +`Cuts`/`Res`가 없는 새 개발 PC에만 사용하는 대안이다. 원본 runtime tree를 새 PC에 직접 복사하거나 수동으로 ZIP을 만들지 않는다. 원본을 보유한 지정 개발 장비에서 읽기 전용 `bin\Debug`을 입력으로 다음 스크립트를 실행한다. @@ -125,15 +210,33 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar 않는다. bundle 초기화 결과와 `Directory.Build.local.props`를 고친 뒤 전체 자산 빌드부터 다시 확인한다. -## 3. DB 로컬 설정 +## 4. DB 로컬 설정 확인 또는 대안 -Debug/Release와 Package 앱의 DB 설정은 모두 실행 사용자 전용 경로에 만든다. +2단계의 기존 PC 초기화는 검증한 `MmoneyCoder.ini`를 다음 실행 사용자 전용 경로에 이미 +복사한다. + +```text +%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini +``` + +파일 내용의 원본 일치 여부와 현재 Windows 사용자 전용 ACL을 확인하되 값을 화면 캡처나 로그에 +남기지 않는다. 기존 파일이 원본과 다르면 초기화기는 자동으로 덮어쓰지 않는다. 대상이 정확한지 +확인한 뒤 2단계 명령에 `-ReplaceDatabaseProfile`을 추가한다. 이 INI는 빌드 입력·출력이나 +runtime bundle에 포함되지 않는다. + +3단계의 외부 bundle은 DB 파일을 의도적으로 포함하지 않으므로 그 흐름에서는 다음 JSON +초기화가 필요하다. 기존 INI를 사용하지 않고 새 JSON 설정으로 전환하기로 명시적으로 결정한 +경우에는 **아래 INI overlay가 존재하지 않는 것을 먼저 확인한 뒤에만** 2단계 초기화에 +`-SkipDatabaseProfile`을 주고 같은 대안을 사용한다. 이 옵션은 기존 INI를 삭제하거나 +비활성화하지 않으며, INI가 남아 있으면 앱이 JSON보다 먼저 사용한다. 기존 overlay가 있는 +PC의 전환은 이 절차에서 임의 삭제하지 말고 별도 검토된 자격증명 제거·전환 작업으로 처리한다. +JSON 경로는 다음과 같다. ```text %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json ``` -저장소 루트에서 기존 초기화 스크립트를 사용한다. Oracle이 service name을 쓰는 환경이면 +저장소 루트에서 초기화 스크립트를 사용한다. Oracle이 service name을 쓰는 환경이면 `-OracleSid` 대신 `-OracleServiceName`을 사용한다. ```powershell @@ -154,10 +257,12 @@ powershell -NoProfile -ExecutionPolicy Bypass ` 검증으로 넘어가지 않는다. DB 연결 확인은 [DB 운영 가이드](DATABASE.md)의 read-only smoke만 먼저 수행하고, 실패 시 Tornado 명령을 보내지 않는다. -## 4. K3D 설치, 라이선스와 승인 해시 확인 +## 5. K3D 설치, 라이선스와 승인 해시 확인 벤더 절차로 Tornado2/K3D x64와 장비 라이선스를 먼저 설치한다. 저장소의 점검은 레지스트리와 -파일을 읽을 뿐 COM을 활성화하지 않는다. +파일을 읽을 뿐 COM을 활성화하지 않는다. 2단계 초기화는 이 검사를 항상 수행하고 `Valid`, +`ComActivated=false`가 아니면 로컬 설정을 완료하지 않는다. 다음 명령은 동일한 검사를 별도로 +다시 확인할 때 사용한다. ```powershell powershell -NoProfile -ExecutionPolicy Bypass ` @@ -169,9 +274,12 @@ Registry64, TypeLib/CLSID 양방향 매핑, `Apartment`, AMD64 PE, HKCU override 확인한다. native와 Interop의 실제 파일은 각각 독립 승인된 SHA-256과 일치해야 하며 둘 중 하나라도 다르면 중단한다. -## 5. 보호된 로컬 송출 설정 +## 6. 보호된 로컬 송출 설정 -저장소 루트에서 다음 스크립트를 실행해 두 파일을 함께 만든다. port는 이번에 Tornado2 화면에서 +2단계 초기화에 `-ConfigureDevelopmentLive`와 필수 값을 주었다면 아래 설정 두 파일도 +마지막 단계에서 검증 후 새로 발급된다. 기존 승인은 매번 먼저 무효화하며 runtime·DB·Debug +빌드가 모두 성공하지 않으면 새 Live 승인 파일을 남기지 않는다. 자산 없는 PC의 3단계 bundle 흐름처럼 설정만 별도로 +초기화해야 할 때는 저장소 루트에서 다음 스크립트를 실행한다. port는 이번에 Tornado2 화면에서 직접 확인한 값이고, 두 해시는 독립 승인값이어야 한다. 기존 승인 프로필이 별도 출력 채널을 사용할 때만 검증된 숫자를 `-OutputChannel`로 추가하며, 기본 player를 쓰는 경우에는 생략한다. @@ -203,7 +311,7 @@ powershell -NoProfile -ExecutionPolicy Bypass ` | `host` | 로컬 PGM이면 숫자형 loopback `127.0.0.1`. `localhost` 또는 원격 주소를 추정하지 않는다. | | `port` | Tornado2 화면에서 이번에 직접 확인한 Network Server TCP port | | `tcpMode` / `clientPort` | `1` / `0` | -| `sceneDirectory` | `null`. 검증된 bundle이 빌드 출력에 배치한 기본 `Cuts`를 사용한다. | +| `sceneDirectory` | `null`. 검증된 로컬 runtime이 빌드 출력에 배치한 기본 `Cuts`를 사용한다. | | `outputChannel` | 승인된 개발 PGM 라우팅 값. 기존 승인 프로필이 기본 player를 쓰는 경우에만 `null` | | `testSceneAllowlist` | 아래 active alias 45개만 허용 | | `trustedLiveOutputEnabled` | `true` | @@ -221,6 +329,14 @@ powershell -NoProfile -ExecutionPolicy Bypass ` `8010`, `8086`, 임의 파일명과 절대 scene 파일 경로는 allowlist에 추가하지 않는다. 실제 endpoint, 채널, 경로와 allowlist가 든 이 파일을 Git이나 지원 첨부파일에 넣지 않는다. +Development Live로 승인된 프로세스는 이 26개 속성을 strict JSON으로 다시 읽으며, 중복·추가 +속성, 대소문자 차이, 잘못된 타입을 거부한다. 상속된 `MBN_STOCK_PLAYOUT_*` 환경 변수와 기존 +운영자 scene/resource/background 선택은 적용하지 않는다. endpoint는 숫자형 loopback, +scene root는 실행 파일 옆 검증된 `Cuts`로 마지막에 다시 고정한다. DB도 +`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`를 우선하고, 없을 때만 보호된 +`Config\database.local.json`을 사용하며 DB 환경 변수는 무시한다. 이 strict 경로가 성립하지 +않으면 Live로 진행하지 않고 안전한 DryRun으로 시작한다. + ### `playout.development-live.local.json` 경로는 다음과 같다. @@ -245,7 +361,7 @@ powershell -NoProfile -ExecutionPolicy Bypass ` 현재 설치 파일에서 즉석 계산한 값으로 placeholder를 채우지 않는다. 자세한 strict 파일 규칙은 [Visual Studio Development Live 설정](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)을 따른다. -## 6. 정확한 로컬 PGM 연결 전용 확인 +## 7. 정확한 로컬 PGM 연결 전용 확인 Network Monitoring을 먼저 연다. 독립 승인 해시는 이 진단을 실행할 현재 PowerShell 프로세스에만 설정한 뒤 실제 port로 connect-only 진단을 한 번 실행한다. @@ -272,15 +388,15 @@ timeout, `OutcomeUnknown`, 대상 PID·시작 시각·listener 변경 또는 결 진단을 반복하지 않는다. 새 명령을 보내지 말고 PGM과 Network Monitoring 상태를 읽기 전용으로 확인한 뒤 새 인수 회차를 계획한다. -## 7. Development Live 최소 인수 시퀀스 +## 8. Development Live 최소 인수 시퀀스 1. 기존 앱 인스턴스를 정상 종료한다. 2. Visual Studio에서 구성 `Debug`, 플랫폼 `x64`, 시작 대상 `Legacy Parity App (VS F5)`를 선택한다. 3. 실행 프로필을 `MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)`로 선택하고 F5로 시작한다. -4. 앱이 소스 전용 또는 DryRun으로 시작했다면 실제 명령을 시도하지 않는다. runtime bundle, - 시작 인자와 두 로컬 파일을 고친 뒤 앱을 새로 시작한다. +4. 앱이 소스 전용 또는 DryRun으로 시작했다면 실제 명령을 시도하지 않는다. runtime 초기화와 + 로컬 binding, 시작 인자와 두 로컬 파일을 고친 뒤 앱을 새로 시작한다. 5. 최초 인수는 자산이 완전한 사전 합의 scene 하나만 사용한다. 기본 최소 회차는 active alias `5001`의 `CONNECT 1회 → PREPARE 1회 → 화면·Network Monitoring 확인 → TAKE IN 1회 → 화면 확인 → TAKE OUT 1회 → DISCONNECT 1회`다. @@ -293,7 +409,7 @@ timeout, `OutcomeUnknown`, 대상 PID·시작 시각·listener 변경 또는 결 allowlist 실패가 발생하면 다음 동작, 같은 동작 반복, 반대 동작이나 추측성 cleanup을 하지 않는다. PGM/Tornado2를 강제 종료하지 말고 상태를 보존한다. -## 8. DryRun 복귀와 인수 완료 조건 +## 9. DryRun 복귀와 인수 완료 조건 Live 앱을 종료하면 부트스트랩이 만든 Live 환경은 프로세스와 함께 사라져야 한다. `playout.local.json`의 `mode`는 계속 `DryRun`이어야 한다. 다음으로 @@ -304,7 +420,7 @@ Live 앱을 종료하면 부트스트랩이 만든 Live 환경은 프로세스 다음 항목이 모두 맞아야 인수를 완료한다. - 지정 개발 PC와 정확한 로컬 PGM 대상 확인 -- 외부 runtime bundle과 허용 scene 검증 +- 기존 자산 또는 외부 bundle에서 설치한 검증된 로컬 runtime과 허용 scene 확인 - K3D x64 등록, 장비 라이선스와 두 독립 승인 해시 확인 - 개발 DB read-only smoke 성공 - 두 `%LOCALAPPDATA%` 설정 파일의 내용·ACL 검증 diff --git a/scripts/Initialize-ExistingDevelopmentPc.ps1 b/scripts/Initialize-ExistingDevelopmentPc.ps1 new file mode 100644 index 0000000..fda4c54 --- /dev/null +++ b/scripts/Initialize-ExistingDevelopmentPc.ps1 @@ -0,0 +1,2285 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [string] $LegacyRuntimeSourceRoot, + + [switch] $NoFolderPicker, + + [string] $DatabaseIniPath, + + [switch] $SkipDatabaseProfile, + + [switch] $ReplaceRuntimeBinding, + + [switch] $ReplaceDatabaseProfile, + + [switch] $ConfigureDevelopmentLive, + + [string] $PlayoutHost, + + [int] $PlayoutPort, + + [string] $NativeSha256, + + [string] $InteropSha256, + + [ValidateRange(0, 2147483647)] + [Nullable[int]] $OutputChannel = $null, + + [switch] $ReplaceLiveConfig, + + [switch] $SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$maximumDatabaseIniLength = 1MB +$maximumManifestLength = 4MB +$expectedResAssetCount = 34 +$manifestFileName = 'LegacyRuntimeBundle.manifest.json' +$archiveFileName = 'LegacyRuntimeBundle.zip' +$archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256' + +function Get-NormalizedFullPath { + param([Parameter(Mandatory = $true)][string] $Path) + + return [IO.Path]::GetFullPath($Path) +} + +function Test-PathIsWithin { + param( + [Parameter(Mandatory = $true)][string] $Root, + [Parameter(Mandatory = $true)][string] $Candidate + ) + + $normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + $normalizedCandidate = Get-NormalizedFullPath -Path $Candidate + return $normalizedCandidate.StartsWith( + $normalizedRoot + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-SamePath { + param( + [Parameter(Mandatory = $true)][string] $Left, + [Parameter(Mandatory = $true)][string] $Right + ) + + return (Get-NormalizedFullPath -Path $Left).TrimEnd('\', '/').Equals( + (Get-NormalizedFullPath -Path $Right).TrimEnd('\', '/'), + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-DirectCutsAndResRootCandidate { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $RepositoryRoot + ) + + try { + $candidate = Get-NormalizedFullPath -Path $Path + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + return $false + } + + $cuts = Get-NormalizedFullPath -Path (Join-Path $candidate 'Cuts') + $res = Get-NormalizedFullPath -Path (Join-Path $candidate 'Res') + $hasDirectRoots = (Test-Path -LiteralPath $cuts -PathType Container) -and + (Test-Path -LiteralPath $res -PathType Container) -and + (Test-SamePath -Left ([IO.Path]::GetDirectoryName($cuts)) -Right $candidate) -and + (Test-SamePath -Left ([IO.Path]::GetDirectoryName($res)) -Right $candidate) + if (-not $hasDirectRoots) { + return $false + } + + Assert-LegacyRuntimeRoot ` + -Root $candidate ` + -RepositoryRoot $RepositoryRoot + return $true + } + catch { + return $false + } +} + +function Assert-NoReparsePointInExistingAncestry { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $Description + ) + + $current = Get-NormalizedFullPath -Path $Path + while (-not [string]::IsNullOrWhiteSpace($current)) { + if (Test-Path -LiteralPath $current) { + $attributes = [IO.File]::GetAttributes($current) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description traverses a reparse point: $current" + } + } + + $trimmed = $current.TrimEnd('\', '/') + $parent = [IO.Path]::GetDirectoryName($trimmed) + if ([string]::IsNullOrWhiteSpace($parent) -or + $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) { + break + } + + $current = $parent + } +} + +function Assert-NoReparsePointInTree { + param( + [Parameter(Mandatory = $true)][string] $Root, + [Parameter(Mandatory = $true)][string] $Description + ) + + Assert-NoReparsePointInExistingAncestry -Path $Root -Description $Description + foreach ($item in @(Get-ChildItem -LiteralPath $Root -Recurse -Force)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description contains a reparse point: $($item.FullName)" + } + } +} + +function Assert-FixedLocalPath { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $Description + ) + + $fullPath = Get-NormalizedFullPath -Path $Path + if ($fullPath.StartsWith('\\', [StringComparison]::Ordinal)) { + throw "$Description must be on a local fixed drive." + } + + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { + throw "$Description does not have a filesystem root." + } + + $drive = New-Object IO.DriveInfo($pathRoot) + if (-not $drive.IsReady -or $drive.DriveType -ne [IO.DriveType]::Fixed) { + throw "$Description must be on a ready local fixed drive." + } +} + +function Assert-RegularFile { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $Description + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "$Description was not found as an ordinary file: $Path" + } + + Assert-NoReparsePointInExistingAncestry -Path $Path -Description $Description + $item = Get-Item -LiteralPath $Path -Force + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description must be an ordinary file: $Path" + } + + return $item +} + +function Get-UserOnlyAcl { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $acl = New-Object Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object Security.AccessControl.FileSystemAccessRule( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + return $acl +} + +function Set-UserOnlyDirectoryAcl { + param([Parameter(Mandatory = $true)][string] $Path) + + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $rule = New-Object Security.AccessControl.FileSystemAccessRule( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + ([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [Security.AccessControl.InheritanceFlags]::ObjectInherit), + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + [IO.Directory]::SetAccessControl($Path, $acl) +} + +function Test-UserOnlyFileAcl { + param([Parameter(Mandatory = $true)][string] $Path) + + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $acl = [IO.File]::GetAccessControl($Path) + if (-not $acl.AreAccessRulesProtected) { + return $false + } + + $rules = @($acl.GetAccessRules( + $true, + $false, + [Security.Principal.NTAccount])) + return $rules.Count -eq 1 -and + $rules[0].IdentityReference.Value.Equals( + $identity, + [StringComparison]::OrdinalIgnoreCase) -and + $rules[0].AccessControlType -eq + [Security.AccessControl.AccessControlType]::Allow -and + (($rules[0].FileSystemRights -band + [Security.AccessControl.FileSystemRights]::FullControl) -eq + [Security.AccessControl.FileSystemRights]::FullControl) + } + catch { + return $false + } +} + +function Get-FileAccessSddl { + param([Parameter(Mandatory = $true)][string] $Path) + + $acl = [IO.File]::GetAccessControl( + $Path, + [Security.AccessControl.AccessControlSections]::Access) + return $acl.GetSecurityDescriptorSddlForm( + [Security.AccessControl.AccessControlSections]::Access) +} + +function Assert-ExactObjectProperties { + param( + [Parameter(Mandatory = $true)][object] $Value, + [Parameter(Mandatory = $true)][string[]] $Names, + [Parameter(Mandatory = $true)][string] $Description + ) + + if ($null -eq $Value -or $Value -isnot [pscustomobject]) { + throw "$Description must be one PSCustomObject." + } + + $actualNames = @($Value.PSObject.Properties | ForEach-Object { + [string] $_.Name + }) + if ($actualNames.Count -ne $Names.Count) { + throw "$Description returned an unexpected property count." + } + for ($index = 0; $index -lt $Names.Count; $index++) { + if (-not $actualNames[$index].Equals( + $Names[$index], + [StringComparison]::Ordinal)) { + throw "$Description returned unexpected, reordered, or case-changed properties." + } + } +} + +function Test-ExactUtf8Text { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $ExpectedText + ) + + $actualBytes = [IO.File]::ReadAllBytes($Path) + $expectedBytes = (New-Object Text.UTF8Encoding($false)).GetBytes($ExpectedText) + if ($actualBytes.Length -ne $expectedBytes.Length) { + return $false + } + for ($index = 0; $index -lt $expectedBytes.Length; $index++) { + if ($actualBytes[$index] -ne $expectedBytes[$index]) { + return $false + } + } + return $true +} + +function New-FileRollbackSnapshot { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $BackupDirectory, + [Parameter(Mandatory = $true)][string] $Description + ) + + $destination = Get-NormalizedFullPath -Path $Path + if (-not (Test-Path -LiteralPath $destination)) { + return [pscustomobject]@{ + Path = $destination + Existed = $false + Length = [long] 0 + Sha256 = $null + AccessSddl = $null + BackupPath = $null + OriginalBytes = $null + } + } + + $item = Assert-RegularFile -Path $destination -Description $Description + $sourceHash = ( + Get-FileHash -LiteralPath $destination -Algorithm SHA256 + ).Hash.ToUpperInvariant() + $accessSddl = Get-FileAccessSddl -Path $destination + $backupPath = Join-Path $BackupDirectory ( + [Guid]::NewGuid().ToString('N') + '.rollback') + [IO.File]::Copy($destination, $backupPath, $false) + + # A credential backup is protected immediately after the copy, before its + # content is read or used for any later rollback decision. + [IO.File]::SetAccessControl($backupPath, (Get-UserOnlyAcl)) + $backupItem = Assert-RegularFile ` + -Path $backupPath ` + -Description "$Description rollback backup" + $backupHash = ( + Get-FileHash -LiteralPath $backupPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if ($backupItem.Length -ne $item.Length -or + -not $backupHash.Equals($sourceHash, [StringComparison]::Ordinal) -or + -not (Test-UserOnlyFileAcl -Path $backupPath)) { + throw "$Description rollback backup failed its immediate ACL/hash verification." + } + + return [pscustomobject]@{ + Path = $destination + Existed = $true + Length = [long] $backupItem.Length + Sha256 = $backupHash + AccessSddl = $accessSddl + BackupPath = $backupPath + OriginalBytes = [IO.File]::ReadAllBytes($backupPath) + } +} + +function Restore-FileRollbackSnapshot { + param( + [Parameter(Mandatory = $true)][pscustomobject] $Snapshot, + [Parameter(Mandatory = $true)][string] $Description + ) + + $destination = Get-NormalizedFullPath -Path ([string] $Snapshot.Path) + $directory = [IO.Path]::GetDirectoryName($destination) + if (-not [bool] $Snapshot.Existed) { + if (Test-Path -LiteralPath $destination) { + [void](Assert-RegularFile -Path $destination -Description $Description) + [IO.File]::Delete($destination) + } + if (Test-Path -LiteralPath $destination) { + throw "$Description rollback could not remove a newly created file." + } + return + } + + [IO.Directory]::CreateDirectory($directory) | Out-Null + Assert-NoReparsePointInExistingAncestry -Path $directory -Description $Description + $temporaryPath = Join-Path $directory ( + '.' + [IO.Path]::GetFileName($destination) + '.' + + [Guid]::NewGuid().ToString('N') + '.restore') + try { + [IO.File]::WriteAllBytes( + $temporaryPath, + [byte[]] $Snapshot.OriginalBytes) + [IO.File]::SetAccessControl($temporaryPath, (Get-UserOnlyAcl)) + $temporaryItem = Assert-RegularFile ` + -Path $temporaryPath ` + -Description "$Description rollback temporary file" + $temporaryHash = ( + Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if ($temporaryItem.Length -ne [long] $Snapshot.Length -or + -not $temporaryHash.Equals( + [string] $Snapshot.Sha256, + [StringComparison]::Ordinal) -or + -not (Test-UserOnlyFileAcl -Path $temporaryPath)) { + throw "$Description rollback bytes failed verification." + } + + $originalAcl = New-Object Security.AccessControl.FileSecurity + $originalAcl.SetSecurityDescriptorSddlForm( + [string] $Snapshot.AccessSddl, + [Security.AccessControl.AccessControlSections]::Access) + [IO.File]::SetAccessControl($temporaryPath, $originalAcl) + + if (Test-Path -LiteralPath $destination) { + [void](Assert-RegularFile -Path $destination -Description $Description) + [IO.File]::Replace($temporaryPath, $destination, $null) + $temporaryPath = $null + } + else { + [IO.File]::Move($temporaryPath, $destination) + $temporaryPath = $null + } + [IO.File]::SetAccessControl($destination, $originalAcl) + + $restoredItem = Assert-RegularFile ` + -Path $destination ` + -Description "$Description restored file" + $restoredHash = ( + Get-FileHash -LiteralPath $destination -Algorithm SHA256 + ).Hash.ToUpperInvariant() + $restoredSddl = Get-FileAccessSddl -Path $destination + if ($restoredItem.Length -ne [long] $Snapshot.Length -or + -not $restoredHash.Equals( + [string] $Snapshot.Sha256, + [StringComparison]::Ordinal) -or + -not $restoredSddl.Equals( + [string] $Snapshot.AccessSddl, + [StringComparison]::Ordinal)) { + throw "$Description rollback did not restore the exact bytes and ACL." + } + } + finally { + if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and + (Test-Path -LiteralPath $temporaryPath)) { + [IO.File]::Delete($temporaryPath) + } + } +} + +function Remove-OwnedTemporaryDirectory { + param( + [Parameter(Mandatory = $true)][string] $Parent, + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $Description + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return + } + if (-not (Test-PathIsWithin -Root $Parent -Candidate $Path) -or + [IO.Path]::GetFileName($Path) -notmatch '^[0-9a-f]{32}$') { + throw "Refusing to clean an unexpected $Description path: $Path" + } + Assert-NoReparsePointInTree -Root $Path -Description $Description + [IO.Directory]::Delete($Path, $true) + if (Test-Path -LiteralPath $Path) { + throw "$Description was not completely removed." + } +} + +function Remove-DevelopmentLiveAuthorizationFailClosed { + param([Parameter(Mandatory = $true)][string] $Path) + + $authorizationPath = Get-NormalizedFullPath -Path $Path + Assert-NoReparsePointInExistingAncestry ` + -Path $authorizationPath ` + -Description 'Development Live authorization' + if (-not (Test-Path -LiteralPath $authorizationPath)) { + return + } + [void](Assert-RegularFile ` + -Path $authorizationPath ` + -Description 'Development Live authorization') + + $invalidatedPath = ( + $authorizationPath + '.invalidated.' + [Guid]::NewGuid().ToString('N')) + try { + [IO.File]::Move($authorizationPath, $invalidatedPath) + } + catch { + $moveError = $_ + try { + [IO.File]::SetAccessControl($authorizationPath, (Get-UserOnlyAcl)) + [IO.File]::Delete($authorizationPath) + } + catch { + throw [InvalidOperationException]::new( + 'Development Live authorization could not be invalidated at its exact path.', + $moveError.Exception) + } + if (Test-Path -LiteralPath $authorizationPath) { + throw [InvalidOperationException]::new( + 'Development Live authorization remains at its exact path after invalidation.', + $moveError.Exception) + } + return + } + + try { + [IO.File]::SetAccessControl($invalidatedPath, (Get-UserOnlyAcl)) + [IO.File]::Delete($invalidatedPath) + } + catch { + throw [InvalidOperationException]::new( + 'The authorization exact path is invalidated, but its protected tombstone could not be removed.', + $_.Exception) + } + if (Test-Path -LiteralPath $authorizationPath) { + throw 'Development Live authorization was recreated during fail-closed invalidation.' + } +} + +function Select-LegacyRuntimeRoot { + param( + [Parameter(Mandatory = $true)][string] $RepositoryRoot, + [AllowNull()][string] $RequestedRoot, + [switch] $DisablePicker + ) + + if (-not [string]::IsNullOrWhiteSpace($RequestedRoot)) { + $requestedPath = Get-NormalizedFullPath -Path $RequestedRoot + $requestedLeaf = [IO.Path]::GetFileName($requestedPath.TrimEnd('\', '/')) + if ($requestedLeaf.Equals('Cuts', [StringComparison]::OrdinalIgnoreCase) -or + $requestedLeaf.Equals('Res', [StringComparison]::OrdinalIgnoreCase)) { + $requestedPath = Get-NormalizedFullPath -Path ( + [IO.Path]::GetDirectoryName($requestedPath)) + } + return $requestedPath + } + + $siblingCandidate = Get-NormalizedFullPath -Path ( + Join-Path $RepositoryRoot '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug') + if (Test-DirectCutsAndResRootCandidate ` + -Path $siblingCandidate ` + -RepositoryRoot $RepositoryRoot) { + return $siblingCandidate + } + + if ($DisablePicker) { + throw ( + "The exact sibling runtime was not found at '$siblingCandidate'. " + + 'Pass -LegacyRuntimeSourceRoot explicitly.') + } + + Add-Type -AssemblyName System.Windows.Forms + $dialog = New-Object System.Windows.Forms.FolderBrowserDialog + try { + $dialog.Description = ( + 'Select the bin\Debug folder that directly contains both Cuts and Res.') + $dialog.ShowNewFolderButton = $false + if ($dialog.ShowDialog() -ne [Windows.Forms.DialogResult]::OK) { + throw 'Legacy runtime folder selection was cancelled.' + } + + $selectedPath = Get-NormalizedFullPath -Path $dialog.SelectedPath + $selectedLeaf = [IO.Path]::GetFileName($selectedPath.TrimEnd('\', '/')) + if ($selectedLeaf.Equals('Cuts', [StringComparison]::OrdinalIgnoreCase) -or + $selectedLeaf.Equals('Res', [StringComparison]::OrdinalIgnoreCase)) { + $selectedPath = Get-NormalizedFullPath -Path ( + [IO.Path]::GetDirectoryName($selectedPath)) + } + return $selectedPath + } + finally { + $dialog.Dispose() + } +} + +function Assert-LegacyRuntimeRoot { + param( + [Parameter(Mandatory = $true)][string] $Root, + [Parameter(Mandatory = $true)][string] $RepositoryRoot + ) + + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { + throw "Legacy runtime source root was not found: $Root" + } + + Assert-FixedLocalPath -Path $Root -Description 'Legacy runtime source root' + Assert-NoReparsePointInTree -Root $Root -Description 'Legacy runtime source tree' + + if ((Test-SamePath -Left $Root -Right $RepositoryRoot) -or + (Test-PathIsWithin -Root $RepositoryRoot -Candidate $Root) -or + (Test-PathIsWithin -Root $Root -Candidate $RepositoryRoot)) { + throw 'Legacy runtime source root must be outside and must not overlap this repository.' + } + + $cutsRoot = Get-NormalizedFullPath -Path (Join-Path $Root 'Cuts') + $resRoot = Get-NormalizedFullPath -Path (Join-Path $Root 'Res') + if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container) -or + -not (Test-Path -LiteralPath $resRoot -PathType Container) -or + -not (Test-SamePath -Left ([IO.Path]::GetDirectoryName($cutsRoot)) -Right $Root) -or + -not (Test-SamePath -Left ([IO.Path]::GetDirectoryName($resRoot)) -Right $Root)) { + throw 'The selected folder must be the single common parent of Cuts and Res.' + } +} + +function Get-ExistingRuntimeBinding { + param( + [Parameter(Mandatory = $true)][string] $RepositoryRoot, + [Parameter(Mandatory = $true)][string] $RuntimeStorageRoot + ) + + $propsPath = Join-Path $RepositoryRoot 'Directory.Build.local.props' + if (-not (Test-Path -LiteralPath $propsPath)) { + return $null + } + + [void](Assert-RegularFile -Path $propsPath -Description 'Local build props') + [xml] $xml = Get-Content -LiteralPath $propsPath -Raw -Encoding UTF8 + $projects = @($xml.SelectNodes("/*[local-name()='Project']")) + $groups = @($xml.SelectNodes( + "/*[local-name()='Project']/*[local-name()='PropertyGroup']")) + $roots = @($xml.SelectNodes( + "/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeSourceRoot']")) + $modes = @($xml.SelectNodes( + "/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeAssetsMode']")) + if ($projects.Count -ne 1 -or $projects[0].Attributes.Count -ne 0 -or + $groups.Count -ne 1 -or $groups[0].Attributes.Count -ne 0 -or + $roots.Count -ne 1 -or $roots[0].Attributes.Count -ne 0 -or + $modes.Count -ne 1 -or $modes[0].Attributes.Count -ne 0 -or + @($groups[0].ChildNodes | Where-Object { + $_.NodeType -eq [Xml.XmlNodeType]::Element + }).Count -ne 2 -or + -not ([string] $modes[0].InnerText).Equals( + 'Required', + [StringComparison]::Ordinal)) { + throw 'Existing Directory.Build.local.props is not the exact generated Required runtime binding.' + } + + $installedRoot = Get-NormalizedFullPath -Path ([string] $roots[0].InnerText) + if (-not (Test-Path -LiteralPath $installedRoot -PathType Container) -or + -not (Test-PathIsWithin -Root $RuntimeStorageRoot -Candidate $installedRoot)) { + throw 'Existing runtime binding is not an installed LocalAppData runtime bundle.' + } + Assert-NoReparsePointInTree -Root $installedRoot -Description 'Installed runtime bundle' + + $manifestPath = Join-Path $installedRoot $manifestFileName + [void](Assert-RegularFile -Path $manifestPath -Description 'Installed runtime manifest') + return [pscustomobject]@{ + PropsPath = $propsPath + InstalledRoot = $installedRoot + ManifestPath = $manifestPath + ManifestSha256 = ( + Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + } +} + +function Test-JsonIntegerValue { + param([AllowNull()][object] $Value) + + return $Value -is [byte] -or + $Value -is [int16] -or + $Value -is [int32] -or + $Value -is [int64] -or + $Value -is [uint16] -or + $Value -is [uint32] +} + +function Get-VerifiedRuntimeManifest { + param([Parameter(Mandatory = $true)][string] $ManifestPath) + + $manifestItem = Assert-RegularFile ` + -Path $ManifestPath ` + -Description 'Generated runtime manifest' + if ($manifestItem.Length -le 0 -or + $manifestItem.Length -gt $maximumManifestLength) { + throw 'Generated runtime manifest is empty or exceeds the 4 MiB safety limit.' + } + + $rawManifest = Get-Content -LiteralPath $ManifestPath -Raw -Encoding UTF8 + try { + $manifest = $rawManifest | ConvertFrom-Json + } + catch { + throw [InvalidDataException]::new( + 'Generated runtime manifest is not valid JSON.', + $_.Exception) + } + Assert-ExactObjectProperties ` + -Value $manifest ` + -Names @( + 'schemaVersion', + 'bundleType', + 'fileCount', + 'cutsFileCount', + 'resFileCount', + 'files') ` + -Description 'Generated runtime manifest' + + if ($manifest.schemaVersion -isnot [int32] -or + [int] $manifest.schemaVersion -ne 1 -or + $manifest.bundleType -isnot [string] -or + [string] $manifest.bundleType -cne + 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle' -or + $manifest.fileCount -isnot [int32] -or + $manifest.cutsFileCount -isnot [int32] -or + $manifest.resFileCount -isnot [int32]) { + throw 'Generated runtime manifest header types or values are invalid.' + } + + $records = @($manifest.files) + $canonicalRecords = New-Object 'System.Collections.Generic.List[object]' + $seenPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( + [StringComparer]::OrdinalIgnoreCase) + $cutsCount = 0 + $resCount = 0 + foreach ($record in $records) { + Assert-ExactObjectProperties ` + -Value $record ` + -Names @('path', 'length', 'sha256') ` + -Description 'Generated runtime manifest file record' + if ($record.path -isnot [string] -or + -not (Test-JsonIntegerValue -Value $record.length) -or + [long] $record.length -lt 0 -or + $record.sha256 -isnot [string] -or + [string] $record.sha256 -cnotmatch '^[0-9A-F]{64}$') { + throw 'Generated runtime manifest file record types are invalid.' + } + + $relativePath = [string] $record.path + if ([string]::IsNullOrWhiteSpace($relativePath) -or + $relativePath.Contains('\') -or + $relativePath.StartsWith('/', [StringComparison]::Ordinal) -or + $relativePath.EndsWith('/', [StringComparison]::Ordinal) -or + $relativePath.Split('/') -contains '..' -or + -not $seenPaths.Add($relativePath)) { + throw "Generated runtime manifest contains an unsafe or duplicate path: $relativePath" + } + if ($relativePath.StartsWith('Cuts/', [StringComparison]::Ordinal)) { + $cutsCount++ + } + elseif ($relativePath.StartsWith('Res/', [StringComparison]::Ordinal)) { + $resCount++ + } + else { + throw "Generated runtime manifest contains a file outside Cuts/Res: $relativePath" + } + if ([IO.Path]::GetFileName($relativePath).Equals( + 'MmoneyCoder.ini', + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Generated runtime manifest contains the credential-bearing database INI.' + } + + $canonicalRecords.Add([ordered]@{ + path = $relativePath + length = [long] $record.length + sha256 = [string] $record.sha256 + }) + } + + if ([int] $manifest.fileCount -ne $records.Count -or + [int] $manifest.cutsFileCount -ne $cutsCount -or + [int] $manifest.resFileCount -ne $resCount -or + $cutsCount -le 0 -or + $resCount -ne $expectedResAssetCount) { + throw 'Generated runtime manifest counts do not match its exact file set.' + } + + $canonicalManifest = [ordered]@{ + schemaVersion = 1 + bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle' + fileCount = [int] $records.Count + cutsFileCount = [int] $cutsCount + resFileCount = [int] $resCount + files = @($canonicalRecords.ToArray()) + } + $expectedText = ( + Get-CanonicalJsonText -Value $canonicalManifest -Depth 6 + ) + [Environment]::NewLine + if (-not (Test-ExactUtf8Text ` + -Path $ManifestPath ` + -ExpectedText $expectedText)) { + throw ( + 'Generated runtime manifest is not the exact canonical JSON contract; ' + + 'duplicate/extra/case/type/format changes are rejected.') + } + + return [pscustomobject]@{ + Path = Get-NormalizedFullPath -Path $ManifestPath + Sha256 = ( + Get-FileHash -LiteralPath $ManifestPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + FileCount = [int] $records.Count + CutsFileCount = [int] $cutsCount + ResFileCount = [int] $resCount + Records = @($canonicalRecords.ToArray()) + CanonicalText = $expectedText + } +} + +function Assert-InstalledRuntimeMatchesManifest { + param( + [Parameter(Mandatory = $true)][string] $InstalledRoot, + [Parameter(Mandatory = $true)][string] $ManifestPath + ) + + $verifiedManifest = Get-VerifiedRuntimeManifest -ManifestPath $ManifestPath + Assert-NoReparsePointInTree ` + -Root $InstalledRoot ` + -Description 'Installed runtime bundle' + $expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( + [StringComparer]::OrdinalIgnoreCase) + foreach ($record in @($verifiedManifest.Records)) { + $relativePath = [string] $record.path + if ([string]::IsNullOrWhiteSpace($relativePath) -or + $relativePath.Contains('\') -or + $relativePath.StartsWith('/', [StringComparison]::Ordinal) -or + $relativePath.Split('/') -contains '..') { + throw "Generated runtime manifest contains an unsafe path: $relativePath" + } + + $path = Join-Path $InstalledRoot $relativePath.Replace('/', '\') + if (-not (Test-PathIsWithin -Root $InstalledRoot -Candidate $path)) { + throw "Generated runtime manifest escaped the installed root: $relativePath" + } + if (-not $expectedPaths.Add($relativePath)) { + throw "Generated runtime manifest contains a duplicate path: $relativePath" + } + $item = Assert-RegularFile -Path $path -Description 'Installed runtime payload' + if ([long] $item.Length -ne [long] $record.length -or + -not (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.Equals( + [string] $record.sha256, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Installed runtime payload differs from the selected source: $relativePath" + } + } + + [void] $expectedPaths.Add($manifestFileName) + $actualPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( + [StringComparer]::OrdinalIgnoreCase) + $rootPrefix = $InstalledRoot.TrimEnd('\', '/') + '\' + foreach ($file in @(Get-ChildItem ` + -LiteralPath $InstalledRoot ` + -File ` + -Recurse ` + -Force)) { + $relativePath = $file.FullName.Substring($rootPrefix.Length).Replace('\', '/') + if (-not $actualPaths.Add($relativePath) -or + -not $expectedPaths.Contains($relativePath)) { + throw "Installed runtime bundle contains an unexpected file: $relativePath" + } + } + if ($actualPaths.Count -ne $expectedPaths.Count) { + throw 'Installed runtime bundle does not match the exact manifest file set.' + } + + $installedManifestPath = Join-Path $InstalledRoot $manifestFileName + $installedManifestTextMatches = Test-ExactUtf8Text ` + -Path $installedManifestPath ` + -ExpectedText ([string] $verifiedManifest.CanonicalText) + $installedManifestHash = ( + Get-FileHash ` + -LiteralPath $installedManifestPath ` + -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if (-not $installedManifestTextMatches -or + -not $installedManifestHash.Equals( + [string] $verifiedManifest.Sha256, + [StringComparison]::Ordinal)) { + throw 'Installed runtime manifest differs from the exact generated manifest.' + } +} + +function Get-VerifiedBundleCreatorResult { + param( + [Parameter(Mandatory = $true)][object] $Result, + [Parameter(Mandatory = $true)][string] $BundleOutput + ) + + Assert-ExactObjectProperties ` + -Value $Result ` + -Names @( + 'OutputDirectory', + 'ArchivePath', + 'ArchiveSha256', + 'ArchiveHashPath', + 'ManifestPath', + 'FileCount', + 'CutsFileCount', + 'ResFileCount') ` + -Description 'Runtime bundle creation result' + foreach ($propertyName in @( + 'OutputDirectory', + 'ArchivePath', + 'ArchiveSha256', + 'ArchiveHashPath', + 'ManifestPath')) { + if ($Result.$propertyName -isnot [string] -or + [string]::IsNullOrWhiteSpace([string] $Result.$propertyName)) { + throw "Runtime bundle creation result property '$propertyName' has an invalid type." + } + } + foreach ($propertyName in @('FileCount', 'CutsFileCount', 'ResFileCount')) { + if ($Result.$propertyName -isnot [int32]) { + throw "Runtime bundle creation result property '$propertyName' must be Int32." + } + } + if ([string] $Result.ArchiveSha256 -cnotmatch '^[0-9A-F]{64}$') { + throw 'Runtime bundle creation result has a non-canonical archive SHA-256.' + } + + $outputRoot = Get-NormalizedFullPath -Path $BundleOutput + $archivePath = Get-NormalizedFullPath -Path ([string] $Result.ArchivePath) + $hashPath = Get-NormalizedFullPath -Path ([string] $Result.ArchiveHashPath) + $manifestPath = Get-NormalizedFullPath -Path ([string] $Result.ManifestPath) + if (-not (Test-SamePath ` + -Left ([string] $Result.OutputDirectory) ` + -Right $outputRoot) -or + -not (Test-SamePath ` + -Left $archivePath ` + -Right (Join-Path $outputRoot $archiveFileName)) -or + -not (Test-SamePath ` + -Left $hashPath ` + -Right (Join-Path $outputRoot $archiveHashFileName)) -or + -not (Test-SamePath ` + -Left $manifestPath ` + -Right (Join-Path $outputRoot $manifestFileName)) -or + -not (Test-PathIsWithin -Root $outputRoot -Candidate $archivePath) -or + -not (Test-PathIsWithin -Root $outputRoot -Candidate $hashPath) -or + -not (Test-PathIsWithin -Root $outputRoot -Candidate $manifestPath)) { + throw 'Runtime bundle creation result paths escaped or changed the exact output contract.' + } + Assert-NoReparsePointInTree ` + -Root $outputRoot ` + -Description 'Created runtime bundle output' + [void](Assert-RegularFile -Path $archivePath -Description 'Created runtime archive') + [void](Assert-RegularFile -Path $hashPath -Description 'Created runtime archive hash') + $actualArchiveHash = ( + Get-FileHash -LiteralPath $archivePath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if (-not $actualArchiveHash.Equals( + [string] $Result.ArchiveSha256, + [StringComparison]::Ordinal)) { + throw 'Runtime bundle creation result archive SHA-256 does not match its file.' + } + $expectedHashText = ( + [string] $Result.ArchiveSha256 + ' ' + $archiveFileName + + [Environment]::NewLine) + if (-not (Test-ExactUtf8Text ` + -Path $hashPath ` + -ExpectedText $expectedHashText)) { + throw 'Runtime bundle creation result hash sidecar is not canonical.' + } + + $manifest = Get-VerifiedRuntimeManifest -ManifestPath $manifestPath + if ([int] $Result.FileCount -ne [int] $manifest.FileCount -or + [int] $Result.CutsFileCount -ne [int] $manifest.CutsFileCount -or + [int] $Result.ResFileCount -ne [int] $manifest.ResFileCount -or + [int] $Result.FileCount -ne + ([int] $Result.CutsFileCount + [int] $Result.ResFileCount)) { + throw 'Runtime bundle creation result counts do not match its manifest.' + } + + return [pscustomobject]@{ + Result = $Result + OutputDirectory = $outputRoot + ArchivePath = $archivePath + ArchiveSha256 = $actualArchiveHash + Manifest = $manifest + } +} + +function Get-VerifiedBundleInitializerResult { + param( + [Parameter(Mandatory = $true)][object] $Result, + [Parameter(Mandatory = $true)][pscustomobject] $VerifiedBundle, + [Parameter(Mandatory = $true)][string] $ShadowRepositoryRoot, + [Parameter(Mandatory = $true)][string] $RuntimeStorageRoot + ) + + Assert-ExactObjectProperties ` + -Value $Result ` + -Names @( + 'InstalledRoot', + 'ArchivePath', + 'ArchiveSha256', + 'ManifestSha256', + 'FileCount', + 'CutsFileCount', + 'ResFileCount', + 'LocalBuildPropsPath') ` + -Description 'Runtime bundle initialization result' + foreach ($propertyName in @( + 'InstalledRoot', + 'ArchivePath', + 'ArchiveSha256', + 'ManifestSha256', + 'LocalBuildPropsPath')) { + if ($Result.$propertyName -isnot [string] -or + [string]::IsNullOrWhiteSpace([string] $Result.$propertyName)) { + throw "Runtime bundle initialization result property '$propertyName' has an invalid type." + } + } + foreach ($propertyName in @('FileCount', 'CutsFileCount', 'ResFileCount')) { + if ($Result.$propertyName -isnot [int32]) { + throw "Runtime bundle initialization result property '$propertyName' must be Int32." + } + } + if ([string] $Result.ArchiveSha256 -cnotmatch '^[0-9A-F]{64}$' -or + [string] $Result.ManifestSha256 -cnotmatch '^[0-9A-F]{64}$') { + throw 'Runtime bundle initialization result contains a non-canonical SHA-256.' + } + + $installedRoot = Get-NormalizedFullPath -Path ([string] $Result.InstalledRoot) + $localPropsPath = Get-NormalizedFullPath -Path ( + [string] $Result.LocalBuildPropsPath) + $archivePathMatches = Test-SamePath ` + -Left ([string] $Result.ArchivePath) ` + -Right ([string] $VerifiedBundle.ArchivePath) + if (-not $archivePathMatches -or + -not ([string] $Result.ArchiveSha256).Equals( + [string] $VerifiedBundle.ArchiveSha256, + [StringComparison]::Ordinal) -or + -not ([string] $Result.ManifestSha256).Equals( + [string] $VerifiedBundle.Manifest.Sha256, + [StringComparison]::Ordinal) -or + -not (Test-PathIsWithin ` + -Root $RuntimeStorageRoot ` + -Candidate $installedRoot) -or + -not ([IO.Path]::GetDirectoryName($installedRoot)).Equals( + (Get-NormalizedFullPath -Path $RuntimeStorageRoot).TrimEnd('\', '/'), + [StringComparison]::OrdinalIgnoreCase) -or + -not ([IO.Path]::GetFileName($installedRoot)).Equals( + [string] $VerifiedBundle.ArchiveSha256, + [StringComparison]::Ordinal) -or + -not (Test-SamePath ` + -Left $localPropsPath ` + -Right (Join-Path $ShadowRepositoryRoot 'Directory.Build.local.props')) -or + -not (Test-PathIsWithin ` + -Root $ShadowRepositoryRoot ` + -Candidate $localPropsPath)) { + throw 'Runtime bundle initialization result paths or hashes violate containment.' + } + if ([int] $Result.FileCount -ne [int] $VerifiedBundle.Manifest.FileCount -or + [int] $Result.CutsFileCount -ne [int] $VerifiedBundle.Manifest.CutsFileCount -or + [int] $Result.ResFileCount -ne [int] $VerifiedBundle.Manifest.ResFileCount) { + throw 'Runtime bundle initialization result counts differ from the verified manifest.' + } + + Assert-NoReparsePointInTree ` + -Root $installedRoot ` + -Description 'Initialized runtime bundle' + [void](Assert-RegularFile ` + -Path $localPropsPath ` + -Description 'Generated local runtime binding') + Assert-InstalledRuntimeMatchesManifest ` + -InstalledRoot $installedRoot ` + -ManifestPath ([string] $VerifiedBundle.Manifest.Path) + $shadowBinding = Get-ExistingRuntimeBinding ` + -RepositoryRoot $ShadowRepositoryRoot ` + -RuntimeStorageRoot $RuntimeStorageRoot + if ($null -eq $shadowBinding -or + -not (Test-SamePath ` + -Left $shadowBinding.InstalledRoot ` + -Right $installedRoot) -or + -not $shadowBinding.ManifestSha256.Equals( + [string] $VerifiedBundle.Manifest.Sha256, + [StringComparison]::Ordinal)) { + throw 'Generated local runtime binding does not match the initialized bundle.' + } + + return [pscustomobject]@{ + InstalledRoot = $installedRoot + ArchiveSha256 = [string] $Result.ArchiveSha256 + ManifestSha256 = [string] $Result.ManifestSha256 + FileCount = [int] $Result.FileCount + CutsFileCount = [int] $Result.CutsFileCount + ResFileCount = [int] $Result.ResFileCount + LocalBuildPropsPath = $localPropsPath + } +} + +function Get-VerifiedK3DRegistration { + param([Parameter(Mandatory = $true)][object[]] $Reports) + + if ($Reports.Count -ne 1) { + throw 'The read-only K3D x64 registration inspection did not return one result.' + } + $registration = $Reports[0] + Assert-ExactObjectProperties ` + -Value $registration ` + -Names @( + 'Status', + 'RegistryHive', + 'RegistryView', + 'CurrentUserOverrides', + 'TypeLibId', + 'TypeLibVersion', + 'TypeLibTarget', + 'TypeLibPath', + 'TypeLibMachine', + 'Classes', + 'PowerShellProcessIs64Bit', + 'ComActivated') ` + -Description 'K3D registration inspection result' + foreach ($propertyName in @( + 'Status', + 'RegistryHive', + 'RegistryView', + 'CurrentUserOverrides', + 'TypeLibId', + 'TypeLibVersion', + 'TypeLibTarget', + 'TypeLibPath', + 'TypeLibMachine')) { + if ($registration.$propertyName -isnot [string]) { + throw "K3D registration property '$propertyName' must be a string." + } + } + if ($registration.PowerShellProcessIs64Bit -isnot [bool] -or + $registration.ComActivated -isnot [bool] -or + [string] $registration.Status -cne 'Valid' -or + [string] $registration.RegistryHive -cne 'HKLM' -or + [string] $registration.RegistryView -cne 'Registry64' -or + [string] $registration.CurrentUserOverrides -cne 'None' -or + [string] $registration.TypeLibId -cne + '{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}' -or + [string] $registration.TypeLibVersion -cne '1.0' -or + [string] $registration.TypeLibTarget -cne 'win64' -or + [string] $registration.TypeLibMachine -cne 'AMD64' -or + $registration.PowerShellProcessIs64Bit -ne $true -or + $registration.ComActivated -ne $false) { + throw 'The read-only K3D x64 registration inspection result is invalid.' + } + + $expectedClasses = [ordered]@{ + KAEngine = [ordered]@{ + ClassId = '{D756CDBE-AA31-42B2-9CC7-018753CA61BF}' + ProgId = 'K3DAsyncEngine.KAEngine.1' + } + KAEventHandler = [ordered]@{ + ClassId = '{39828C77-EFF0-4E59-979B-8673C028C718}' + ProgId = 'K3DAsyncEngine.KAEventHandler.1' + } + } + $classes = @($registration.Classes) + if ($classes.Count -ne 2) { + throw 'K3D registration inspection must return exactly two classes.' + } + $seenClassNames = New-Object 'System.Collections.Generic.HashSet[string]' ( + [StringComparer]::Ordinal) + foreach ($classReport in $classes) { + Assert-ExactObjectProperties ` + -Value $classReport ` + -Names @( + 'Name', + 'ClassId', + 'ProgId', + 'ServerPath', + 'ServerMachine', + 'ThreadingModel', + 'ReciprocalMapping') ` + -Description 'K3D class registration result' + foreach ($propertyName in @( + 'Name', + 'ClassId', + 'ProgId', + 'ServerPath', + 'ServerMachine', + 'ThreadingModel')) { + if ($classReport.$propertyName -isnot [string]) { + throw "K3D class registration property '$propertyName' must be a string." + } + } + if ($classReport.ReciprocalMapping -isnot [bool]) { + throw 'K3D class ReciprocalMapping must be Boolean.' + } + $name = [string] $classReport.Name + if (-not $expectedClasses.Contains($name) -or + -not $seenClassNames.Add($name) -or + [string] $classReport.ClassId -cne + [string] $expectedClasses[$name].ClassId -or + [string] $classReport.ProgId -cne + [string] $expectedClasses[$name].ProgId -or + [string] $classReport.ServerMachine -cne 'AMD64' -or + [string] $classReport.ThreadingModel -cne 'Apartment' -or + $classReport.ReciprocalMapping -ne $true) { + throw 'The read-only K3D x64 class registration shape is invalid.' + } + } + + return $registration +} + +function Set-RuntimeBindingAtomically { + param( + [Parameter(Mandatory = $true)][string] $GeneratedPropsPath, + [Parameter(Mandatory = $true)][string] $RepositoryRoot, + [switch] $Replace + ) + + [void](Assert-RegularFile ` + -Path $GeneratedPropsPath ` + -Description 'Generated local runtime binding') + $generatedHash = ( + Get-FileHash -LiteralPath $GeneratedPropsPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + $destination = Join-Path $RepositoryRoot 'Directory.Build.local.props' + $temporary = Join-Path $RepositoryRoot ( + '.Directory.Build.local.props.' + [Guid]::NewGuid().ToString('N') + '.tmp') + $backup = Join-Path $RepositoryRoot ( + '.Directory.Build.local.props.' + [Guid]::NewGuid().ToString('N') + '.bak') + $preserveBackup = $false + try { + [IO.File]::Copy($GeneratedPropsPath, $temporary, $false) + if (Test-Path -LiteralPath $destination) { + if (-not $Replace) { + throw 'Refusing to replace the existing runtime binding.' + } + [IO.File]::Replace($temporary, $destination, $backup) + $temporary = $null + try { + [xml] $verification = Get-Content ` + -LiteralPath $destination ` + -Raw ` + -Encoding UTF8 + if ($null -eq $verification.Project.PropertyGroup.LegacyRuntimeSourceRoot -or + [string] $verification.Project.PropertyGroup.LegacyRuntimeAssetsMode -cne + 'Required' -or + -not (Get-FileHash ` + -LiteralPath $destination ` + -Algorithm SHA256).Hash.Equals( + $generatedHash, + [StringComparison]::Ordinal)) { + throw 'The atomically replaced runtime binding failed verification.' + } + } + catch { + try { + [IO.File]::Replace($backup, $destination, $null) + $backup = $null + } + catch { + $preserveBackup = $true + throw + } + throw + } + } + else { + [IO.File]::Move($temporary, $destination) + $temporary = $null + if (-not (Get-FileHash ` + -LiteralPath $destination ` + -Algorithm SHA256).Hash.Equals( + $generatedHash, + [StringComparison]::Ordinal)) { + [IO.File]::Delete($destination) + throw 'The atomically installed runtime binding failed verification.' + } + } + } + finally { + if (-not [string]::IsNullOrWhiteSpace($temporary) -and + (Test-Path -LiteralPath $temporary)) { + [IO.File]::Delete($temporary) + } + if (-not $preserveBackup -and + -not [string]::IsNullOrWhiteSpace($backup) -and + (Test-Path -LiteralPath $backup)) { + [IO.File]::Delete($backup) + } + } +} + +function Get-CanonicalJsonText { + param( + [Parameter(Mandatory = $true)][object] $Value, + [Parameter(Mandatory = $true)][int] $Depth + ) + + return ($Value | ConvertTo-Json -Depth $Depth) +} + +function Test-ExistingJsonContract { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $ExpectedJson + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $false + } + try { + $item = Assert-RegularFile -Path $Path -Description 'Development Live configuration' + if ($item.Length -le 0 -or $item.Length -gt 1MB -or + -not (Test-UserOnlyFileAcl -Path $Path)) { + return $false + } + # Exact UTF-8 bytes are the contract. This rejects duplicate/extra keys, + # case changes, type coercion, reordered properties, alternate whitespace, + # and a BOM instead of normalizing them through ConvertFrom-Json. + return Test-ExactUtf8Text -Path $Path -ExpectedText $ExpectedJson + } + catch { + return $false + } +} + +function Assert-ExistingJsonContract { + param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][string] $ExpectedJson, + [Parameter(Mandatory = $true)][string] $Description + ) + + if (-not (Test-ExistingJsonContract ` + -Path $Path ` + -ExpectedJson $ExpectedJson)) { + throw "$Description failed its exact JSON/ACL verification." + } +} + +function Get-DevelopmentLivePreflight { + param( + [Parameter(Mandatory = $true)][string] $ConfigurationRoot, + [Parameter(Mandatory = $true)][string] $HostName, + [Parameter(Mandatory = $true)][int] $Port, + [Parameter(Mandatory = $true)][string] $ApprovedNativeHash, + [Parameter(Mandatory = $true)][string] $ApprovedInteropHash, + [AllowNull()][Nullable[int]] $Channel, + [switch] $AllowReplacement + ) + + $sceneAllowlist = @( + '5001', '5006', '5011', '5016', '50160', '5023', '5024', '5025', + '5026', '5029', '5032', '5037', '5068', '5070', '5072', '5074', + '5076', '5077', '5078', '5079', '5080', '5081', '5082', '5083', + '5084', '5085', '5086', '50860', '5087', '5088', '6001', '6067', + '8001', '8002', '8003', '8018', '8032', '8035', '8040', '8046', + '8051', '8056', '8061', '8067', 'N5001') + $playout = [ordered]@{ + mode = 'DryRun' + host = $HostName + port = $Port + tcpMode = 1 + clientPort = 0 + sceneDirectory = $null + outputChannel = $Channel + layoutIndex = 10 + legacySceneFadeDuration = 6 + legacySceneBackgroundKind = 'None' + legacySceneBackgroundAssetPath = $null + legacySceneBackgroundVideoLoopCount = 2004 + legacySceneBackgroundVideoLoopInfinite = $true + legacyBackgroundDirectory = $null + testProcessWindowTitlePattern = $null + testSceneAllowlist = $sceneAllowlist + trustedLiveOutputEnabled = $true + queueCapacity = 64 + connectTimeoutMilliseconds = 5000 + operationTimeoutMilliseconds = 5000 + disconnectTimeoutMilliseconds = 3000 + processPollIntervalMilliseconds = 1000 + reconnectDelayMilliseconds = 1000 + maximumReconnectAttempts = 0 + reconnectEnabled = $false + maximumAutomaticRefreshesPerTakeIn = 0 + } + $authorization = [ordered]@{ + schemaVersion = 1 + mode = 'Live' + authorization = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH' + nativeSha256 = $ApprovedNativeHash.ToUpperInvariant() + interopSha256 = $ApprovedInteropHash.ToUpperInvariant() + } + $playoutJson = Get-CanonicalJsonText -Value $playout -Depth 6 + $authorizationJson = Get-CanonicalJsonText -Value $authorization -Depth 3 + + $playoutPath = Join-Path $ConfigurationRoot 'playout.local.json' + $authorizationPath = Join-Path $ConfigurationRoot ( + 'playout.development-live.local.json') + Assert-NoReparsePointInExistingAncestry ` + -Path $ConfigurationRoot ` + -Description 'Development Live configuration directory' + $playoutExists = Test-Path -LiteralPath $playoutPath + $authorizationExists = Test-Path -LiteralPath $authorizationPath + if ($playoutExists) { + [void](Assert-RegularFile ` + -Path $playoutPath ` + -Description 'Development Live base configuration') + } + if ($authorizationExists) { + [void](Assert-RegularFile ` + -Path $authorizationPath ` + -Description 'Development Live authorization') + } + $playoutMatches = -not $playoutExists -or + (Test-ExistingJsonContract ` + -Path $playoutPath ` + -ExpectedJson $playoutJson) + $authorizationMatches = -not $authorizationExists -or + (Test-ExistingJsonContract ` + -Path $authorizationPath ` + -ExpectedJson $authorizationJson) + if ((-not $playoutMatches -or -not $authorizationMatches) -and + -not $AllowReplacement) { + # A mismatched file is never left usable merely because replacement was + # refused. The caller asked to configure Live, so failure is fail-closed. + Remove-DevelopmentLiveAuthorizationFailClosed -Path $authorizationPath + throw ( + 'Existing Development Live files differ from the exact requested ' + + 'endpoint/hash contract. Re-run with -ReplaceLiveConfig.') + } + + return [pscustomobject]@{ + # Every requested setup invalidates any old one-launch authorization and + # issues a new file only after runtime/DB/build/cleanup success. + NeedsWrite = $true + NeedsForce = [bool] ($playoutExists -or $authorizationExists) + PlayoutPath = $playoutPath + AuthorizationPath = $authorizationPath + PlayoutJson = $playoutJson + AuthorizationJson = $authorizationJson + } +} + +function Copy-DatabaseProfileAtomically { + param( + [Parameter(Mandatory = $true)][string] $Source, + [Parameter(Mandatory = $true)][string] $Destination, + [Parameter(Mandatory = $true)][string] $ExpectedHash, + [switch] $Replace + ) + + $directory = [IO.Path]::GetDirectoryName($Destination) + [IO.Directory]::CreateDirectory($directory) | Out-Null + Assert-NoReparsePointInExistingAncestry ` + -Path $directory ` + -Description 'Local database profile directory' + Set-UserOnlyDirectoryAcl -Path $directory + + $temporaryPath = Join-Path $directory ( + '.MmoneyCoder.' + [Guid]::NewGuid().ToString('N') + '.tmp') + $backupPath = Join-Path $directory ( + '.MmoneyCoder.' + [Guid]::NewGuid().ToString('N') + '.bak') + $preserveBackup = $false + $previousDestinationLength = [long] 0 + $previousDestinationHash = $null + try { + [IO.File]::Copy($Source, $temporaryPath, $false) + $temporaryItem = Assert-RegularFile ` + -Path $temporaryPath ` + -Description 'Temporary database profile' + if ($temporaryItem.Length -le 0 -or + $temporaryItem.Length -gt $maximumDatabaseIniLength -or + -not (Get-FileHash -LiteralPath $temporaryPath -Algorithm SHA256).Hash.Equals( + $ExpectedHash, + [StringComparison]::Ordinal)) { + throw 'Database profile changed while it was copied.' + } + [IO.File]::SetAccessControl($temporaryPath, (Get-UserOnlyAcl)) + + if (Test-Path -LiteralPath $Destination) { + if (-not $Replace) { + throw 'Refusing to replace the existing database profile.' + } + $previousDestinationItem = Assert-RegularFile ` + -Path $Destination ` + -Description 'Previous installed database profile' + $previousDestinationLength = [long] $previousDestinationItem.Length + $previousDestinationHash = ( + Get-FileHash -LiteralPath $Destination -Algorithm SHA256 + ).Hash.ToUpperInvariant() + [IO.File]::Replace($temporaryPath, $Destination, $backupPath) + $temporaryPath = $null + } + else { + [IO.File]::Move($temporaryPath, $Destination) + $temporaryPath = $null + } + + try { + if (Test-Path -LiteralPath $backupPath) { + # File.Replace creates a second copy of the old credential file. + # Protect and hash-check that copy in the immediately following + # statements before continuing with the new destination. + [IO.File]::SetAccessControl($backupPath, (Get-UserOnlyAcl)) + $backupItem = Assert-RegularFile ` + -Path $backupPath ` + -Description 'Previous database profile rollback backup' + $backupHash = ( + Get-FileHash -LiteralPath $backupPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if ($backupItem.Length -ne $previousDestinationLength -or + -not $backupHash.Equals( + $previousDestinationHash, + [StringComparison]::Ordinal) -or + -not (Test-UserOnlyFileAcl -Path $backupPath)) { + throw 'Previous database profile backup failed immediate ACL/hash verification.' + } + } + [IO.File]::SetAccessControl($Destination, (Get-UserOnlyAcl)) + $installedItem = Assert-RegularFile ` + -Path $Destination ` + -Description 'Installed database profile' + if ($installedItem.Length -le 0 -or + $installedItem.Length -gt $maximumDatabaseIniLength -or + -not (Get-FileHash ` + -LiteralPath $Destination ` + -Algorithm SHA256).Hash.Equals( + $ExpectedHash, + [StringComparison]::Ordinal) -or + -not (Test-UserOnlyFileAcl -Path $Destination)) { + throw 'The atomically installed database profile failed verification.' + } + } + catch { + if (Test-Path -LiteralPath $backupPath) { + try { + [IO.File]::Replace($backupPath, $Destination, $null) + $backupPath = $null + } + catch { + $preserveBackup = $true + throw + } + } + elseif (Test-Path -LiteralPath $Destination) { + [IO.File]::Delete($Destination) + } + throw + } + } + finally { + if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and + (Test-Path -LiteralPath $temporaryPath)) { + [IO.File]::Delete($temporaryPath) + } + if (-not $preserveBackup -and + -not [string]::IsNullOrWhiteSpace($backupPath) -and + (Test-Path -LiteralPath $backupPath)) { + [IO.File]::Delete($backupPath) + } + } +} + +function Assert-DebugRuntimePayloadMatchesManifest { + param( + [Parameter(Mandatory = $true)][string] $TargetDirectory, + [Parameter(Mandatory = $true)][string] $ManifestPath, + [Parameter(Mandatory = $true)][string] $ExpectedManifestSha256 + ) + + $targetRoot = Get-NormalizedFullPath -Path $TargetDirectory + Assert-NoReparsePointInTree ` + -Root $targetRoot ` + -Description 'Debug x64 output' + $manifest = Get-VerifiedRuntimeManifest -ManifestPath $ManifestPath + if (-not ([string] $manifest.Sha256).Equals( + $ExpectedManifestSha256, + [StringComparison]::Ordinal)) { + throw 'Debug x64 verification received an unexpected runtime manifest.' + } + + $expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( + [StringComparer]::OrdinalIgnoreCase) + foreach ($record in @($manifest.Records)) { + $relativePath = [string] $record.path + if (-not $expectedPaths.Add($relativePath)) { + throw "Debug x64 manifest contains a duplicate path: $relativePath" + } + $outputPath = Get-NormalizedFullPath -Path ( + Join-Path $targetRoot $relativePath.Replace('/', '\')) + if (-not (Test-PathIsWithin -Root $targetRoot -Candidate $outputPath)) { + throw "Debug x64 manifest path escaped the output: $relativePath" + } + $outputItem = Assert-RegularFile ` + -Path $outputPath ` + -Description 'Debug x64 runtime payload' + $outputHash = ( + Get-FileHash -LiteralPath $outputPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if ($outputItem.Length -ne [long] $record.length -or + -not $outputHash.Equals( + [string] $record.sha256, + [StringComparison]::Ordinal)) { + throw "Debug x64 runtime payload differs from the manifest: $relativePath" + } + } + + $actualPaths = New-Object 'System.Collections.Generic.HashSet[string]' ( + [StringComparer]::OrdinalIgnoreCase) + $targetPrefix = $targetRoot.TrimEnd('\', '/') + '\' + foreach ($assetRootName in @('Cuts', 'Res')) { + $assetRoot = Join-Path $targetRoot $assetRootName + if (-not (Test-Path -LiteralPath $assetRoot -PathType Container) -or + -not (Test-SamePath ` + -Left ([IO.Path]::GetDirectoryName($assetRoot)) ` + -Right $targetRoot)) { + throw "Debug x64 $assetRootName output directory is missing." + } + Assert-NoReparsePointInTree ` + -Root $assetRoot ` + -Description "Debug x64 $assetRootName output" + foreach ($file in @(Get-ChildItem ` + -LiteralPath $assetRoot ` + -File ` + -Recurse ` + -Force)) { + $relativePath = $file.FullName.Substring( + $targetPrefix.Length).Replace('\', '/') + if (-not $actualPaths.Add($relativePath) -or + -not $expectedPaths.Contains($relativePath)) { + throw "Debug x64 runtime output contains an unexpected file: $relativePath" + } + } + } + if ($actualPaths.Count -ne $expectedPaths.Count) { + throw 'Debug x64 runtime output does not match the exact manifest file set.' + } + + return [pscustomobject]@{ + CutsFileCount = [int] $manifest.CutsFileCount + ResFileCount = [int] $manifest.ResFileCount + } +} + +function Invoke-DebugBuildAndVerify { + param( + [Parameter(Mandatory = $true)][string] $ProjectPath, + [Parameter(Mandatory = $true)][string] $ExpectedRuntimeRoot, + [Parameter(Mandatory = $true)][string] $ExpectedManifestPath, + [Parameter(Mandatory = $true)][string] $ExpectedManifestSha256 + ) + + & dotnet build $ProjectPath -c Debug '-p:Platform=x64' | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "Debug x64 build failed with exit code $LASTEXITCODE." + } + + $propertyText = (& dotnet msbuild $ProjectPath ` + '-p:Configuration=Debug' ` + '-p:Platform=x64' ` + '-getProperty:TargetDir' ` + '-getProperty:LegacyRuntimeAssetsEnabled' ` + '-getProperty:LegacyRuntimeAssetsMode' ` + '-getProperty:LegacyRuntimeSourceRoot' | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "Could not query Debug x64 build properties (exit code $LASTEXITCODE)." + } + $propertyEnvelope = $propertyText | ConvertFrom-Json + Assert-ExactObjectProperties ` + -Value $propertyEnvelope ` + -Names @('Properties') ` + -Description 'Debug x64 MSBuild property envelope' + $properties = $propertyEnvelope.Properties + Assert-ExactObjectProperties ` + -Value $properties ` + -Names @( + 'TargetDir', + 'LegacyRuntimeAssetsEnabled', + 'LegacyRuntimeAssetsMode', + 'LegacyRuntimeSourceRoot') ` + -Description 'Debug x64 MSBuild properties' + foreach ($propertyName in @( + 'TargetDir', + 'LegacyRuntimeAssetsEnabled', + 'LegacyRuntimeAssetsMode', + 'LegacyRuntimeSourceRoot')) { + if ($properties.$propertyName -isnot [string]) { + throw "Debug x64 MSBuild property '$propertyName' must be a string." + } + } + if ([string] $properties.LegacyRuntimeAssetsEnabled -cne 'true' -or + [string] $properties.LegacyRuntimeAssetsMode -cne 'Required' -or + -not (Test-SamePath ` + -Left ([string] $properties.LegacyRuntimeSourceRoot) ` + -Right $ExpectedRuntimeRoot)) { + throw 'Debug x64 unexpectedly resolved as SourceOnly or used another runtime root.' + } + + $targetDir = Get-NormalizedFullPath -Path ([string] $properties.TargetDir + ) + $projectDirectory = Get-NormalizedFullPath -Path ( + [IO.Path]::GetDirectoryName($ProjectPath)) + $expectedBinRoot = Join-Path $projectDirectory 'bin' + if ($targetDir.IndexOf( + ([IO.Path]::DirectorySeparatorChar + 'SourceOnly' + + [IO.Path]::DirectorySeparatorChar), + [StringComparison]::OrdinalIgnoreCase) -ge 0 -or + -not (Test-Path -LiteralPath $targetDir -PathType Container) -or + -not (Test-PathIsWithin -Root $expectedBinRoot -Candidate $targetDir)) { + throw 'Debug x64 target directory is missing or is a SourceOnly output.' + } + + $payload = Assert-DebugRuntimePayloadMatchesManifest ` + -TargetDirectory $targetDir ` + -ManifestPath $ExpectedManifestPath ` + -ExpectedManifestSha256 $ExpectedManifestSha256 + if (@(Get-ChildItem -LiteralPath $targetDir -File -Recurse -Force | + Where-Object { + $_.Name.Equals('MmoneyCoder.ini', [StringComparison]::OrdinalIgnoreCase) + }).Count -ne 0) { + throw 'Credential-bearing MmoneyCoder.ini entered the Debug build output.' + } + + return [pscustomobject]@{ + TargetDirectory = $targetDir + CutsFileCount = [int] $payload.CutsFileCount + ResFileCount = [int] $payload.ResFileCount + SourceOnly = $false + } +} + +$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..') +$projectPath = Join-Path $repositoryRoot ( + 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj') +$bundleCreatorPath = Join-Path $PSScriptRoot 'New-LegacyRuntimeBundle.ps1' +$bundleInitializerPath = Join-Path $PSScriptRoot 'Initialize-LegacyRuntimeBundle.ps1' +$registrationInspectorPath = Join-Path $PSScriptRoot 'Inspect-K3DRegistration.ps1' +$liveInitializerPath = Join-Path $PSScriptRoot 'Initialize-DevelopmentLiveConfig.ps1' + +foreach ($requiredScript in @( + $bundleCreatorPath, + $bundleInitializerPath, + $registrationInspectorPath, + $liveInitializerPath)) { + [void](Assert-RegularFile -Path $requiredScript -Description 'Required setup script') +} + +if ($ReplaceLiveConfig -and -not $ConfigureDevelopmentLive) { + throw '-ReplaceLiveConfig requires -ConfigureDevelopmentLive.' +} +if ($ConfigureDevelopmentLive -and $SkipBuild) { + throw '-ConfigureDevelopmentLive requires the default Debug x64 build verification.' +} +if (-not $ConfigureDevelopmentLive -and + (-not [string]::IsNullOrWhiteSpace($PlayoutHost) -or + $PlayoutPort -ne 0 -or + -not [string]::IsNullOrWhiteSpace($NativeSha256) -or + -not [string]::IsNullOrWhiteSpace($InteropSha256) -or + $null -ne $OutputChannel)) { + throw 'Development Live endpoint/hash options require -ConfigureDevelopmentLive.' +} + +$runtimeSourceRoot = Select-LegacyRuntimeRoot ` + -RepositoryRoot $repositoryRoot ` + -RequestedRoot $LegacyRuntimeSourceRoot ` + -DisablePicker:$NoFolderPicker +Assert-LegacyRuntimeRoot -Root $runtimeSourceRoot -RepositoryRoot $repositoryRoot + +# This is a read-only registry/file inspection. It never activates a COM class. +$registrationReports = @(& $registrationInspectorPath) +$registration = Get-VerifiedK3DRegistration -Reports $registrationReports +$registeredClasses = @($registration.Classes) +$nativePath = Get-NormalizedFullPath -Path ([string] $registration.TypeLibPath) +[void](Assert-RegularFile -Path $nativePath -Description 'Registered K3D native DLL') +Assert-FixedLocalPath -Path $nativePath -Description 'Registered K3D native DLL' +foreach ($classReport in $registeredClasses) { + if (-not (Test-SamePath ` + -Left ([string] $classReport.ServerPath) ` + -Right $nativePath)) { + throw 'The K3D classes and type library do not resolve to one native DLL.' + } +} + +$releaseDirectory = [IO.Directory]::GetParent($nativePath) +$x64Directory = if ($null -ne $releaseDirectory) { $releaseDirectory.Parent } else { $null } +$dllDirectory = if ($null -ne $x64Directory) { $x64Directory.Parent } else { $null } +$vendorRoot = if ($null -ne $dllDirectory) { $dllDirectory.Parent } else { $null } +if ($null -eq $vendorRoot -or + -not ([IO.Path]::GetFileName($nativePath)).Equals( + 'K3DAsyncEngine.dll', + [StringComparison]::OrdinalIgnoreCase) -or + -not $releaseDirectory.Name.Equals('Release', [StringComparison]::OrdinalIgnoreCase) -or + -not $x64Directory.Name.Equals('x64', [StringComparison]::OrdinalIgnoreCase) -or + -not $dllDirectory.Name.Equals('DLL', [StringComparison]::OrdinalIgnoreCase)) { + throw 'The registered K3D native DLL is outside the supported vendor layout.' +} +$interopPath = Get-NormalizedFullPath -Path ( + Join-Path $vendorRoot.FullName 'Bin\x64\C#\Interop.K3DAsyncEngineLib.dll') +[void](Assert-RegularFile -Path $interopPath -Description 'Derived K3D interop assembly') +Assert-FixedLocalPath -Path $interopPath -Description 'Derived K3D interop assembly' + +$localAppData = [Environment]::GetFolderPath( + [Environment+SpecialFolder]::LocalApplicationData) +if ([string]::IsNullOrWhiteSpace($localAppData)) { + throw 'The Windows LocalApplicationData known folder is unavailable.' +} +$localAppDataRoot = Get-NormalizedFullPath -Path $localAppData +Assert-FixedLocalPath -Path $localAppDataRoot -Description 'LocalAppData' +Assert-NoReparsePointInExistingAncestry ` + -Path $localAppDataRoot ` + -Description 'LocalAppData' + +# Preflight a requested Live replacement before any setup write. The caller +# supplies both approval hashes; this script only verifies them against files. +$livePreflight = $null +$liveAuthorizationInvalidated = $false +if ($ConfigureDevelopmentLive) { + $configurationRoot = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\Config' + $prospectiveAuthorizationPath = Join-Path $configurationRoot ( + 'playout.development-live.local.json') + try { + if ($PlayoutHost -cne '127.0.0.1' -and $PlayoutHost -cne '::1') { + throw 'Development Live playout host must be exactly 127.0.0.1 or ::1.' + } + if ($PlayoutPort -lt 1 -or $PlayoutPort -gt 65535) { + throw 'Development Live playout port must be between 1 and 65535.' + } + if ([string]::IsNullOrWhiteSpace($NativeSha256) -or + $NativeSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or + [string]::IsNullOrWhiteSpace($InteropSha256) -or + $InteropSha256 -notmatch '^[0-9A-Fa-f]{64}$') { + throw 'Independent native and interop SHA-256 approvals are both required.' + } + + $livePreflight = Get-DevelopmentLivePreflight ` + -ConfigurationRoot $configurationRoot ` + -HostName $PlayoutHost ` + -Port $PlayoutPort ` + -ApprovedNativeHash $NativeSha256 ` + -ApprovedInteropHash $InteropSha256 ` + -Channel $OutputChannel ` + -AllowReplacement:$ReplaceLiveConfig + } + catch { + $livePreflightError = $_ + try { + Remove-DevelopmentLiveAuthorizationFailClosed ` + -Path $prospectiveAuthorizationPath + } + catch { + throw [InvalidOperationException]::new( + ( + 'Development Live preflight failed and its existing ' + + 'authorization could not be invalidated: ' + $_.Exception.Message + ), + $livePreflightError.Exception) + } + throw $livePreflightError + } + + # Invalidate the previous one-launch authorization before database/runtime + # preflight and before any setup write. Every subsequent failure remains + # fail-closed at the exact path consumed by the app. + Remove-DevelopmentLiveAuthorizationFailClosed ` + -Path ([string] $livePreflight.AuthorizationPath) + if (Test-Path -LiteralPath ([string] $livePreflight.AuthorizationPath)) { + throw 'Development Live authorization still exists before setup preflight.' + } + $liveAuthorizationInvalidated = $true + + $actualNativeHash = ( + Get-FileHash -LiteralPath $nativePath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + $actualInteropHash = ( + Get-FileHash -LiteralPath $interopPath -Algorithm SHA256 + ).Hash.ToUpperInvariant() + if (-not $actualNativeHash.Equals( + $NativeSha256, + [StringComparison]::OrdinalIgnoreCase) -or + -not $actualInteropHash.Equals( + $InteropSha256, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'An independently supplied K3D SHA-256 does not match the registered files.' + } +} + +$databaseSource = $null +$databaseDestination = Join-Path $localAppDataRoot ( + 'MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini') +$databaseSourceHash = $null +$databaseNeedsWrite = $false +$databaseNeedsReplace = $false +if (-not $SkipDatabaseProfile) { + $databaseSource = if ([string]::IsNullOrWhiteSpace($DatabaseIniPath)) { + Join-Path $runtimeSourceRoot 'Res\MmoneyCoder.ini' + } + else { + if (-not [IO.Path]::IsPathRooted($DatabaseIniPath)) { + throw '-DatabaseIniPath must be an absolute path.' + } + Get-NormalizedFullPath -Path $DatabaseIniPath + } + Assert-FixedLocalPath -Path $databaseSource -Description 'Legacy database INI' + if ((Test-SamePath -Left $databaseSource -Right $repositoryRoot) -or + (Test-PathIsWithin -Root $repositoryRoot -Candidate $databaseSource)) { + throw 'Database credentials must not be sourced from inside this repository.' + } + $databaseItem = Assert-RegularFile ` + -Path $databaseSource ` + -Description 'Legacy database INI' + if ($databaseItem.Length -le 0 -or + $databaseItem.Length -gt $maximumDatabaseIniLength) { + throw 'Legacy database INI is empty or exceeds the 1 MiB safety limit.' + } + $databaseSourceHash = ( + Get-FileHash -LiteralPath $databaseSource -Algorithm SHA256 + ).Hash.ToUpperInvariant() + + if (Test-Path -LiteralPath $databaseDestination) { + $databaseDestinationItem = Assert-RegularFile ` + -Path $databaseDestination ` + -Description 'Installed database profile' + if ($databaseDestinationItem.Length -le 0 -or + $databaseDestinationItem.Length -gt $maximumDatabaseIniLength) { + throw 'Installed database profile is empty or exceeds the 1 MiB safety limit.' + } + $sameDatabase = $databaseDestinationItem.Length -eq $databaseItem.Length -and + (Get-FileHash ` + -LiteralPath $databaseDestination ` + -Algorithm SHA256).Hash.Equals( + $databaseSourceHash, + [StringComparison]::Ordinal) -and + (Test-UserOnlyFileAcl -Path $databaseDestination) + if (-not $sameDatabase) { + if (-not $ReplaceDatabaseProfile) { + throw ( + 'The installed database profile differs or is not user-only. ' + + 'Re-run with -ReplaceDatabaseProfile.') + } + $databaseNeedsWrite = $true + $databaseNeedsReplace = $true + } + } + else { + $databaseNeedsWrite = $true + } +} +elseif (-not [string]::IsNullOrWhiteSpace($DatabaseIniPath) -or + $ReplaceDatabaseProfile) { + throw '-DatabaseIniPath/-ReplaceDatabaseProfile cannot be used with -SkipDatabaseProfile.' +} + +$runtimeStorageRoot = Join-Path $localAppDataRoot ( + 'MBN_STOCK_WEBVIEW\RuntimeBundles') +$existingRuntime = Get-ExistingRuntimeBinding ` + -RepositoryRoot $repositoryRoot ` + -RuntimeStorageRoot $runtimeStorageRoot + +$setupParent = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\SetupTemp' +$rollbackParent = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\SetupRollback' +Assert-NoReparsePointInExistingAncestry ` + -Path $setupParent ` + -Description 'Setup temporary directory' +Assert-NoReparsePointInExistingAncestry ` + -Path $rollbackParent ` + -Description 'Setup rollback directory' +$setupRoot = Join-Path $setupParent ([Guid]::NewGuid().ToString('N')) +$rollbackRoot = Join-Path $rollbackParent ([Guid]::NewGuid().ToString('N')) + +$runtimeResult = $null +$buildResult = $null +$databaseInstalled = $false +$liveConfigured = $false +$runtimeBindingMutationStarted = $false +$databaseMutationStarted = $false +$propsSnapshot = $null +$databaseSnapshot = $null +$transactionCommitted = $false +$transactionError = $null +try { + [IO.Directory]::CreateDirectory($setupParent) | Out-Null + [IO.Directory]::CreateDirectory($rollbackParent) | Out-Null + Assert-NoReparsePointInExistingAncestry ` + -Path $setupParent ` + -Description 'Setup temporary directory' + Assert-NoReparsePointInExistingAncestry ` + -Path $rollbackParent ` + -Description 'Setup rollback directory' + [IO.Directory]::CreateDirectory($setupRoot) | Out-Null + [IO.Directory]::CreateDirectory($rollbackRoot) | Out-Null + Set-UserOnlyDirectoryAcl -Path $setupRoot + Set-UserOnlyDirectoryAcl -Path $rollbackRoot + Assert-NoReparsePointInExistingAncestry ` + -Path $setupRoot ` + -Description 'Owned setup temporary directory' + Assert-NoReparsePointInExistingAncestry ` + -Path $rollbackRoot ` + -Description 'Owned setup rollback directory' + + $propsSnapshot = New-FileRollbackSnapshot ` + -Path (Join-Path $repositoryRoot 'Directory.Build.local.props') ` + -BackupDirectory $rollbackRoot ` + -Description 'Local build props' + if (-not $SkipDatabaseProfile) { + $databaseSnapshot = New-FileRollbackSnapshot ` + -Path $databaseDestination ` + -BackupDirectory $rollbackRoot ` + -Description 'Installed database profile' + } + + $bundleOutput = Join-Path $setupRoot 'bundle' + $bundleResults = @(& $bundleCreatorPath ` + -LegacyRuntimeSourceRoot $runtimeSourceRoot ` + -OutputDirectory $bundleOutput) + if ($bundleResults.Count -ne 1) { + throw 'Runtime bundle creation did not return one result.' + } + $verifiedBundle = Get-VerifiedBundleCreatorResult ` + -Result $bundleResults[0] ` + -BundleOutput $bundleOutput + $bundleResult = $verifiedBundle.Result + $newManifestPath = [string] $verifiedBundle.Manifest.Path + $newManifestHash = [string] $verifiedBundle.Manifest.Sha256 + + $sameRuntime = $null -ne $existingRuntime -and + $existingRuntime.ManifestSha256.Equals( + $newManifestHash, + [StringComparison]::Ordinal) + if ($sameRuntime) { + Assert-InstalledRuntimeMatchesManifest ` + -InstalledRoot $existingRuntime.InstalledRoot ` + -ManifestPath $newManifestPath + $runtimeResult = [pscustomobject]@{ + InstalledRoot = $existingRuntime.InstalledRoot + ManifestSha256 = $newManifestHash + ManifestPath = $existingRuntime.ManifestPath + CutsFileCount = [int] $verifiedBundle.Manifest.CutsFileCount + ResFileCount = [int] $verifiedBundle.Manifest.ResFileCount + Reused = $true + } + } + else { + if ($null -ne $existingRuntime -and -not $ReplaceRuntimeBinding) { + throw ( + 'The selected Cuts/Res manifest differs from the installed runtime ' + + 'binding. Re-run with -ReplaceRuntimeBinding.') + } + + # Install and fully verify the new immutable bundle while the current + # repository binding remains untouched. A minimal shadow repository lets + # the existing hardened initializer generate its exact Required props. + $shadowRepositoryRoot = Join-Path $setupRoot 'repository' + $shadowProjectDirectory = Join-Path $shadowRepositoryRoot ( + 'src\MBN_STOCK_WEBVIEW.LegacyParityApp') + [IO.Directory]::CreateDirectory($shadowProjectDirectory) | Out-Null + [IO.File]::Copy( + (Join-Path $repositoryRoot '.gitignore'), + (Join-Path $shadowRepositoryRoot '.gitignore'), + $false) + [IO.File]::Copy( + (Join-Path $repositoryRoot 'Directory.Build.props'), + (Join-Path $shadowRepositoryRoot 'Directory.Build.props'), + $false) + [IO.File]::Copy( + $projectPath, + (Join-Path $shadowProjectDirectory ( + 'MBN_STOCK_WEBVIEW.LegacyParityApp.csproj')), + $false) + + $initializerResults = @(& $bundleInitializerPath ` + -ZipPath ([string] $bundleResult.ArchivePath) ` + -ExpectedSha256 ([string] $bundleResult.ArchiveSha256) ` + -RepositoryRoot $shadowRepositoryRoot) + if ($initializerResults.Count -ne 1) { + throw 'Runtime bundle initialization did not return one result.' + } + $initialized = Get-VerifiedBundleInitializerResult ` + -Result $initializerResults[0] ` + -VerifiedBundle $verifiedBundle ` + -ShadowRepositoryRoot $shadowRepositoryRoot ` + -RuntimeStorageRoot $runtimeStorageRoot + + $runtimeBindingMutationStarted = $true + Set-RuntimeBindingAtomically ` + -GeneratedPropsPath $initialized.LocalBuildPropsPath ` + -RepositoryRoot $repositoryRoot ` + -Replace:($null -ne $existingRuntime) + $verifiedBinding = Get-ExistingRuntimeBinding ` + -RepositoryRoot $repositoryRoot ` + -RuntimeStorageRoot $runtimeStorageRoot + if ($null -eq $verifiedBinding -or + -not (Test-SamePath ` + -Left $verifiedBinding.InstalledRoot ` + -Right ([string] $initialized.InstalledRoot)) -or + -not $verifiedBinding.ManifestSha256.Equals( + [string] $initialized.ManifestSha256, + [StringComparison]::Ordinal)) { + throw 'The atomically installed runtime binding failed final verification.' + } + Assert-InstalledRuntimeMatchesManifest ` + -InstalledRoot $verifiedBinding.InstalledRoot ` + -ManifestPath $newManifestPath + + $runtimeResult = [pscustomobject]@{ + InstalledRoot = $verifiedBinding.InstalledRoot + ManifestSha256 = $verifiedBinding.ManifestSha256 + ManifestPath = $verifiedBinding.ManifestPath + CutsFileCount = [int] $initialized.CutsFileCount + ResFileCount = [int] $initialized.ResFileCount + Reused = $false + } + } + + if ($databaseNeedsWrite) { + $databaseMutationStarted = $true + Copy-DatabaseProfileAtomically ` + -Source $databaseSource ` + -Destination $databaseDestination ` + -ExpectedHash $databaseSourceHash ` + -Replace:$databaseNeedsReplace + $databaseInstalled = $true + } + + if (-not $SkipBuild) { + $buildResult = Invoke-DebugBuildAndVerify ` + -ProjectPath $projectPath ` + -ExpectedRuntimeRoot $runtimeResult.InstalledRoot ` + -ExpectedManifestPath $runtimeResult.ManifestPath ` + -ExpectedManifestSha256 $runtimeResult.ManifestSha256 + if ([int] $buildResult.CutsFileCount -ne + [int] $runtimeResult.CutsFileCount -or + [int] $buildResult.ResFileCount -ne + [int] $runtimeResult.ResFileCount) { + throw 'Debug x64 payload count mismatch after exact manifest verification.' + } + } + + # Both working data and rollback copies must be gone before a new Live + # authorization can be issued. A cleanup failure is a transaction failure. + Remove-OwnedTemporaryDirectory ` + -Parent $setupParent ` + -Path $setupRoot ` + -Description 'setup temporary directory' + Remove-OwnedTemporaryDirectory ` + -Parent $rollbackParent ` + -Path $rollbackRoot ` + -Description 'setup rollback directory' + $transactionCommitted = $true +} +catch { + $transactionError = $_ +} + +if (-not $transactionCommitted) { + $secondaryFailures = New-Object 'System.Collections.Generic.List[string]' + + if ($databaseMutationStarted -and $null -ne $databaseSnapshot) { + try { + Restore-FileRollbackSnapshot ` + -Snapshot $databaseSnapshot ` + -Description 'Installed database profile' + } + catch { + $secondaryFailures.Add( + 'Database rollback failed: ' + $_.Exception.Message) + } + } + if ($runtimeBindingMutationStarted -and $null -ne $propsSnapshot) { + try { + Restore-FileRollbackSnapshot ` + -Snapshot $propsSnapshot ` + -Description 'Local build props' + } + catch { + $secondaryFailures.Add( + 'Runtime binding rollback failed: ' + $_.Exception.Message) + } + } + + foreach ($cleanupTarget in @( + [pscustomobject]@{ + Parent = $setupParent + Path = $setupRoot + Description = 'setup temporary directory' + }, + [pscustomobject]@{ + Parent = $rollbackParent + Path = $rollbackRoot + Description = 'setup rollback directory' + })) { + try { + Remove-OwnedTemporaryDirectory ` + -Parent $cleanupTarget.Parent ` + -Path $cleanupTarget.Path ` + -Description $cleanupTarget.Description + } + catch { + $secondaryFailures.Add( + $cleanupTarget.Description + ' cleanup failed: ' + + $_.Exception.Message) + } + } + + if ($ConfigureDevelopmentLive -and $liveAuthorizationInvalidated) { + try { + Remove-DevelopmentLiveAuthorizationFailClosed ` + -Path ([string] $livePreflight.AuthorizationPath) + } + catch { + $secondaryFailures.Add( + 'Development Live fail-closed cleanup failed: ' + + $_.Exception.Message) + } + } + + if ($null -eq $transactionError) { + $transactionError = [System.Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new( + 'Existing development PC initialization did not commit.'), + 'InitializationDidNotCommit', + [System.Management.Automation.ErrorCategory]::InvalidResult, + $null) + } + if ($secondaryFailures.Count -gt 0) { + throw [InvalidOperationException]::new( + ( + 'Existing development PC initialization failed. Original failure: ' + + $transactionError.Exception.Message + ' Secondary recovery failure(s): ' + + ($secondaryFailures -join ' | ') + ), + $transactionError.Exception) + } + throw $transactionError +} + +# Live authorization is the final mutation, after build verification and after +# temporary cleanup. No COM activation, PGM command, or database connection is +# performed by this setup script. +if ($ConfigureDevelopmentLive -and $livePreflight.NeedsWrite) { + $liveArguments = @{ + PlayoutHost = $PlayoutHost + PlayoutPort = $PlayoutPort + NativeSha256 = $NativeSha256 + InteropSha256 = $InteropSha256 + ConfigurationDirectory = $configurationRoot + } + if ($null -ne $OutputChannel) { + $liveArguments.OutputChannel = $OutputChannel + } + if ($livePreflight.NeedsForce) { + $liveArguments.Force = $true + } + try { + $liveResults = @(& $liveInitializerPath @liveArguments) + if ($liveResults.Count -ne 0) { + throw 'Development Live initializer returned unexpected success-stream output.' + } + Assert-ExistingJsonContract ` + -Path ([string] $livePreflight.PlayoutPath) ` + -ExpectedJson ([string] $livePreflight.PlayoutJson) ` + -Description 'Development Live base configuration' + Assert-ExistingJsonContract ` + -Path ([string] $livePreflight.AuthorizationPath) ` + -ExpectedJson ([string] $livePreflight.AuthorizationJson) ` + -Description 'Development Live authorization' + $liveConfigured = $true + } + catch { + $liveError = $_ + try { + Remove-DevelopmentLiveAuthorizationFailClosed ` + -Path ([string] $livePreflight.AuthorizationPath) + } + catch { + throw [InvalidOperationException]::new( + ( + 'Development Live configuration failed and fail-closed ' + + 'authorization cleanup also failed: ' + $_.Exception.Message + ), + $liveError.Exception) + } + throw $liveError + } +} + +[pscustomobject]@{ + RuntimeSourceRoot = $runtimeSourceRoot + InstalledRuntimeRoot = $runtimeResult.InstalledRoot + RuntimeManifestSha256 = $runtimeResult.ManifestSha256 + RuntimeBindingReused = [bool] $runtimeResult.Reused + DatabaseProfilePath = if ($SkipDatabaseProfile) { $null } else { + $databaseDestination + } + DatabaseProfileWritten = $databaseInstalled + K3DStatus = [string] $registration.Status + K3DComActivated = [bool] $registration.ComActivated + BuildVerified = $null -ne $buildResult + BuildOutputPath = if ($null -eq $buildResult) { $null } else { + $buildResult.TargetDirectory + } + DevelopmentLiveRequested = [bool] $ConfigureDevelopmentLive + DevelopmentLiveWritten = $liveConfigured + DevelopmentLiveReused = $false +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs index 6983813..1cbd0a1 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs @@ -62,6 +62,73 @@ public sealed class DatabaseRuntime return CreateDefault(fallbackConfigurationPath); } + /// + /// Creates the Development Live database runtime from the one protected + /// current-user LocalAppData legacy INI, or from the protected LocalAppData + /// JSON when that INI is absent. Unlike the ordinary compatibility path, this + /// never probes executable or operator-selected Res folders and ignores all + /// database environment overrides. + /// + public static DatabaseRuntime CreateDevelopmentLiveLocalOverride() + { + var localApplicationData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + return CreateDevelopmentLiveLocalOverride(localApplicationData); + } + + internal static DatabaseRuntime CreateDevelopmentLiveLocalOverride( + string localApplicationData) + { + try + { + ArgumentException.ThrowIfNullOrWhiteSpace(localApplicationData); + var localRoot = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(localApplicationData)); + var resourceDirectory = Path.Combine( + localRoot, + "MBN_STOCK_WEBVIEW", + "Res"); + var legacyIniPath = Path.Combine(resourceDirectory, "MmoneyCoder.ini"); + if (TryEnsureOrdinaryFile(legacyIniPath)) + { + EnsureNoReparsePointDirectoryChain(resourceDirectory, localRoot); + return Create( + new LegacyIniDatabaseOptionsLoader(_ => null).Load(legacyIniPath)); + } + + var configurationDirectory = Path.Combine( + localRoot, + "MBN_STOCK_WEBVIEW", + "Config"); + EnsureNoReparsePointDirectoryChain(configurationDirectory, localRoot); + var jsonPath = Path.Combine( + configurationDirectory, + "database.local.json"); + EnsureOrdinaryFile(jsonPath); + var options = new DatabaseOptionsLoader(_ => null).Load(jsonPath); + if (!options.IsConfigured(MMoneyCoderSharp.Data.DataSourceKind.Oracle) || + !options.IsConfigured(MMoneyCoderSharp.Data.DataSourceKind.MariaDb)) + { + throw new DatabaseConfigurationException( + "Development Live database configuration is unavailable."); + } + + return Create(options); + } + catch (DatabaseConfigurationException) + { + throw; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + InvalidDataException or NotSupportedException or + System.Security.SecurityException) + { + throw new DatabaseConfigurationException( + "Development Live database configuration is unavailable."); + } + } + private static string? ResolveLegacyOverridePath() { var localAppData = Environment.GetFolderPath( @@ -75,6 +142,56 @@ public sealed class DatabaseRuntime "MmoneyCoder.ini"); } + private static void EnsureNoReparsePointDirectoryChain( + string directory, + string trustedRoot) + { + var current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trustedRoot)); + while (!string.Equals(current, root, StringComparison.OrdinalIgnoreCase)) + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(); + } + + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrWhiteSpace(parent) || + string.Equals(parent, current, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException(); + } + + current = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent)); + } + } + + private static void EnsureOrdinaryFile(string path) + { + var attributes = File.GetAttributes(Path.GetFullPath(path)); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new InvalidDataException(); + } + } + + private static bool TryEnsureOrdinaryFile(string path) + { + try + { + EnsureOrdinaryFile(path); + return true; + } + catch (FileNotFoundException) + { + return false; + } + catch (DirectoryNotFoundException) + { + return false; + } + } + public static DatabaseRuntime Create(DatabaseOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs index 0c0abab..aab7cb4 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs @@ -10,6 +10,10 @@ public partial class App : Application private Window? _window; private AppInstance? _mainInstance; + internal static bool IsDevelopmentLiveLaunch { get; private set; } + + internal static bool IsDevelopmentLiveLaunchRequested { get; private set; } + public App() { InitializeComponent(); @@ -55,9 +59,14 @@ public partial class App : Application : DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( args.Arguments, Environment.GetCommandLineArgs()); + IsDevelopmentLiveLaunchRequested = string.Equals( + launchArguments, + DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, + StringComparison.Ordinal); var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply( launchArguments, isDebugBuild); + IsDevelopmentLiveLaunch = developmentLive.IsApplied; System.Diagnostics.Debug.WriteLine( $"Development Live bootstrap: {developmentLive.Code}"); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj index a21fa9a..89c7274 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -178,9 +178,10 @@ PreserveNewest @@ -192,10 +193,13 @@ + Text="Legacy runtime assets were not found. Building the safe source-only DryRun app. Run scripts\Initialize-ExistingDevelopmentPc.ps1 before Development Live." /> + + Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Run scripts\Initialize-ExistingDevelopmentPc.ps1 to create the verified local Required binding, or use /p:LegacyRuntimeAssetsMode=Auto for a source-only DryRun build." /> diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index 4959616..a445bb5 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -34,10 +34,17 @@ public sealed partial class MainWindow { var explicitLocalConfigurationExists = File.Exists( PlayoutOptionsLoader.DefaultPath); - _playoutOptions = PlayoutOptionsLoader.Load( - operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory, - operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory, - forceSafeDryRun: IsSourceOnlyBuild); + _playoutOptions = + _isDevelopmentLiveLaunch && + !_developmentLiveStartupBlocked + ? PlayoutOptionsLoader.LoadDevelopmentLive( + baseDirectory: AppContext.BaseDirectory) + : PlayoutOptionsLoader.Load( + operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory, + operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory, + forceSafeDryRun: IsSourceOnlyBuild || + _isDevelopmentLiveLaunchRequested || + _developmentLiveStartupBlocked); ResetRefreshState(); var startupComposition = LegacyParityStartupCompositionResolver.Resolve( _playoutOptions, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index cd8862b..162d618 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -35,6 +35,8 @@ public sealed partial class MainWindow : Window } private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly bool _isDevelopmentLiveLaunch; + private readonly bool _isDevelopmentLiveLaunchRequested; private readonly SemaphoreSlim _intentGate = new(1, 1); private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1); private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1); @@ -49,6 +51,7 @@ public sealed partial class MainWindow : Window private LegacyOperatorSettings _operatorSettings; private DatabaseRuntime? _databaseRuntime; private bool _closing; + private bool _developmentLiveStartupBlocked; private bool _interactionMetricsRefreshQueued; private bool _intentBusy; private bool _windowSubclassAttached; @@ -59,6 +62,8 @@ public sealed partial class MainWindow : Window public MainWindow() { + _isDevelopmentLiveLaunch = App.IsDevelopmentLiveLaunch; + _isDevelopmentLiveLaunchRequested = App.IsDevelopmentLiveLaunchRequested; // Capture and remove Gate A bearer material before XAML can create WebView2. // Only the native process keeps the capability digest for this launch. _playoutLaunchAuthorization = @@ -73,15 +78,22 @@ public sealed partial class MainWindow : Window EnsureCloseConfirmationAttached(); var localApplicationData = Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData); - var runtimeCutMenuOverridePath = string.IsNullOrWhiteSpace(localApplicationData) + var runtimeCutMenuOverridePath = + _isDevelopmentLiveLaunch || + string.IsNullOrWhiteSpace(localApplicationData) ? null : Path.Combine( localApplicationData, "MBN_STOCK_WEBVIEW", "Res", "종목.ini"); - var selectedResourceDirectory = _appliedOperatorSettings.ResourceDirectory; - var cutMenuComposition = selectedResourceDirectory is null + var selectedResourceDirectory = _isDevelopmentLiveLaunch + ? null + : _appliedOperatorSettings.ResourceDirectory; + var cutMenuComposition = _isDevelopmentLiveLaunch + ? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory( + AppContext.BaseDirectory) + : selectedResourceDirectory is null ? LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( runtimeCutMenuOverridePath, AppContext.BaseDirectory) @@ -111,6 +123,7 @@ public sealed partial class MainWindow : Window IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService; CorePagePlanProvider? fixedPagePlanProvider = null; string? initializationError = null; + var databaseInitializationSucceeded = false; try { if (IsSourceOnlyBuild) @@ -127,6 +140,8 @@ public sealed partial class MainWindow : Window : Path.Combine(selectedResourceDirectory, "MmoneyCoder.ini"); _databaseRuntime = _playoutLaunchAuthorization.IsGateA ? DatabaseRuntime.CreateDefault() + : _isDevelopmentLiveLaunch + ? DatabaseRuntime.CreateDevelopmentLiveLocalOverride() : DatabaseRuntime.CreateLegacyExecutableDefault( AppContext.BaseDirectory, legacyOverridePath: selectedLegacyDatabaseIni, @@ -185,9 +200,12 @@ public sealed partial class MainWindow : Window new LegacyStockMasterIdentityValidationService(_databaseRuntime.Executor); operatorCatalogSchemaValidationService = new LegacyOperatorCatalogSchemaValidationService(_databaseRuntime.Executor); + databaseInitializationSucceeded = true; } catch { + _databaseRuntime = null; + fixedPagePlanProvider = null; initializationError = IsSourceOnlyBuild ? "소스 전용 DryRun에서는 데이터베이스 연결을 사용하지 않습니다." : "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요."; @@ -225,6 +243,14 @@ public sealed partial class MainWindow : Window DataQueryExecutor.Reset(); } + if (_isDevelopmentLiveLaunch && !databaseInitializationSucceeded) + { + // Development Live is one fail-closed startup transaction. A + // missing or partially initialized protected DB runtime must not + // leave the independently armed playout gate eligible for use. + _developmentLiveStartupBlocked = true; + } + _industryWorkflow = new LegacyIndustrySelectionWorkflow( industrySelectionService, actionLayout: runtimeUiCatalogs.IndustryLayout); @@ -310,7 +336,10 @@ public sealed partial class MainWindow : Window if (!_closing) { StartDatabaseHealthMonitor(); - await ConnectPlayoutAsync(_lifetimeCancellation.Token); + if (!_developmentLiveStartupBlocked) + { + await ConnectPlayoutAsync(_lifetimeCancellation.Token); + } } } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/DevelopmentLiveLaunchBootstrap.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/DevelopmentLiveLaunchBootstrap.cs index ebc7a23..ef882f5 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/DevelopmentLiveLaunchBootstrap.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/DevelopmentLiveLaunchBootstrap.cs @@ -57,6 +57,14 @@ internal static class DevelopmentLiveLaunchBootstrap InteropSha256Property }; + private static readonly string[] ArmedEnvironmentNames = + [ + InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable, + InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, + ModeEnvironmentVariable, + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable + ]; + internal static string DefaultPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", @@ -119,7 +127,9 @@ internal static class DevelopmentLiveLaunchBootstrap if (string.IsNullOrWhiteSpace(authorizationFilePath)) { - return Rejected("development-live-authorization-unavailable"); + return RejectedAndDisarm( + platform, + "development-live-authorization-unavailable"); } byte[] json; @@ -131,19 +141,25 @@ internal static class DevelopmentLiveLaunchBootstrap } catch (Exception exception) when (IsExpectedFileFailure(exception)) { - return Rejected("development-live-authorization-unavailable"); + return RejectedAndDisarm( + platform, + "development-live-authorization-unavailable"); } if (!TryParseAuthorization(json, out var nativeSha256, out var interopSha256)) { - return Rejected("development-live-authorization-invalid"); + return RejectedAndDisarm( + platform, + "development-live-authorization-invalid"); } return TryApplyEnvironment(platform, nativeSha256, interopSha256) ? new DevelopmentLiveBootstrapResult( DevelopmentLiveBootstrapStatus.Applied, "development-live-applied") - : Rejected("development-live-environment-failed"); + : RejectedAndDisarm( + platform, + "development-live-environment-failed"); } private static bool TryParseAuthorization( @@ -240,17 +256,10 @@ internal static class DevelopmentLiveLaunchBootstrap string nativeSha256, string interopSha256) { - string[] names = - [ - InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable, - InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, - ModeEnvironmentVariable, - PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable - ]; var originals = new Dictionary(StringComparer.Ordinal); try { - foreach (var name in names) + foreach (var name in ArmedEnvironmentNames) { originals.Add(name, platform.GetProcessEnvironmentVariable(name)); } @@ -271,9 +280,9 @@ internal static class DevelopmentLiveLaunchBootstrap } catch (Exception exception) when (IsExpectedEnvironmentFailure(exception)) { - for (var index = names.Length - 1; index >= 0; index--) + for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--) { - var name = names[index]; + var name = ArmedEnvironmentNames[index]; if (!originals.TryGetValue(name, out var original)) { continue; @@ -296,6 +305,30 @@ internal static class DevelopmentLiveLaunchBootstrap } } + private static DevelopmentLiveBootstrapResult RejectedAndDisarm( + IDevelopmentLiveBootstrapPlatform platform, + string code) + { + // An exact Debug request that failed validation must not fall through to + // inherited process state from an earlier shell or Visual Studio session. + // The app also forces this requested-but-not-applied state to DryRun. + for (var index = ArmedEnvironmentNames.Length - 1; index >= 0; index--) + { + var name = ArmedEnvironmentNames[index]; + try + { + platform.SetProcessEnvironmentVariable(name, null); + } + catch (Exception exception) when (IsExpectedEnvironmentFailure(exception)) + { + // MainWindow's request provenance remains the final fail-closed + // boundary even if the host denies cleanup of a stale value. + } + } + + return Rejected(code); + } + private static string? StrictString(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString() : null; diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs index c843394..b9255f8 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs @@ -5,6 +5,19 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration; public sealed class PlayoutOptions { + /// + /// Process-local provenance set only by the exact Debug Development Live + /// bootstrap loader. It is not a JSON or environment configuration surface. + /// + internal bool DevelopmentLiveBootstrapApplied { get; set; } + + /// + /// Canonical executable-relative Cuts root captured by the Development Live + /// loader. Final validation uses it to prevent later replacement of the + /// verified build payload. + /// + internal string? DevelopmentLiveExecutableSceneDirectory { get; set; } + public PlayoutMode Mode { get; set; } = PlayoutMode.DryRun; public string Host { get; set; } = "127.0.0.1"; diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index ec50386..4973884 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -1,3 +1,4 @@ +using System.Security; using System.Text.Json; using System.Text.Json.Serialization; using MBN_STOCK_WEBVIEW.Core.Playout; @@ -7,12 +8,55 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration; public static class PlayoutOptionsLoader { + internal const int MaximumDevelopmentLiveConfigurationFileBytes = 64 * 1024; + public const string LiveAuthorizationEnvironmentVariable = "MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT"; public const string RequiredLiveAuthorizationValue = "I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH"; + private static readonly string[] DevelopmentLiveSceneAllowlist = + [ + "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" + ]; + + private static readonly IReadOnlySet DevelopmentLiveProperties = + new HashSet(StringComparer.Ordinal) + { + "mode", + "host", + "port", + "tcpMode", + "clientPort", + "sceneDirectory", + "outputChannel", + "layoutIndex", + "legacySceneFadeDuration", + "legacySceneBackgroundKind", + "legacySceneBackgroundAssetPath", + "legacySceneBackgroundVideoLoopCount", + "legacySceneBackgroundVideoLoopInfinite", + "legacyBackgroundDirectory", + "testProcessWindowTitlePattern", + "testSceneAllowlist", + "trustedLiveOutputEnabled", + "queueCapacity", + "connectTimeoutMilliseconds", + "operationTimeoutMilliseconds", + "disconnectTimeoutMilliseconds", + "processPollIntervalMilliseconds", + "reconnectDelayMilliseconds", + "maximumReconnectAttempts", + "reconnectEnabled", + "maximumAutomaticRefreshesPerTakeIn" + }; + public static string DefaultPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", @@ -43,6 +87,32 @@ public static class PlayoutOptionsLoader return options; } + /// + /// Loads the inert LocalAppData base profile for an already-applied exact + /// Debug Development Live bootstrap. This path deliberately ignores every + /// process environment override and native operator folder selection. Live + /// mode is armed from bootstrap provenance, and the scene root is pinned to + /// the verified Cuts payload beside the executable. + /// + internal static PlayoutOptions LoadDevelopmentLive( + string? path = null, + string? baseDirectory = null) + { + var options = LoadDevelopmentLiveJson( + path ?? DefaultPath, + requireProtectedDefaultPath: path is null); + EnsureDevelopmentLiveBaseContract(options); + + options.Mode = PlayoutMode.Live; + options.SceneDirectory = null; + ApplyExecutableAssetDefaults( + options, + baseDirectory ?? AppContext.BaseDirectory); + options.DevelopmentLiveExecutableSceneDirectory = options.SceneDirectory; + options.DevelopmentLiveBootstrapApplied = true; + return options; + } + /// /// Applies only the two non-command asset roots selected by the native settings /// screen. Process environment values are applied afterwards and therefore retain @@ -111,6 +181,287 @@ public static class PlayoutOptionsLoader } } + private static void EnsureDevelopmentLiveBaseContract(PlayoutOptions options) + { + if (options.Mode != PlayoutMode.DryRun || + !IsLiteralLoopback(options.Host) || + options.Port is < 1 or > 65_535 || + options.TcpMode != 1 || + options.ClientPort != 0 || + options.OutputChannel is < 0 || + options.LayoutIndex != 10 || + !string.IsNullOrWhiteSpace(options.SceneDirectory) || + options.LegacySceneFadeDuration != 6 || + options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None || + !string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath) || + options.LegacySceneBackgroundVideoLoopCount != 2004 || + !options.LegacySceneBackgroundVideoLoopInfinite || + !string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) || + !string.IsNullOrWhiteSpace(options.TestProcessWindowTitlePattern) || + options.TestSceneAllowlist is null || + !options.TestSceneAllowlist.SequenceEqual( + DevelopmentLiveSceneAllowlist, + StringComparer.Ordinal) || + !options.TrustedLiveOutputEnabled || + options.QueueCapacity != 64 || + options.ConnectTimeoutMilliseconds != 5_000 || + options.OperationTimeoutMilliseconds != 5_000 || + options.DisconnectTimeoutMilliseconds != 3_000 || + options.ProcessPollIntervalMilliseconds != 1_000 || + options.ReconnectDelayMilliseconds != 1_000 || + options.ReconnectEnabled || + options.MaximumReconnectAttempts != 0 || + options.MaximumAutomaticRefreshesPerTakeIn != 0) + { + throw new PlayoutConfigurationException( + "Development Live requires the exact protected local base configuration."); + } + } + + private static PlayoutOptions LoadDevelopmentLiveJson( + string path, + bool requireProtectedDefaultPath) + { + try + { + var fullPath = Path.GetFullPath(path); + if (requireProtectedDefaultPath) + { + EnsureProtectedDevelopmentLivePath(fullPath); + } + + var attributes = File.GetAttributes(fullPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new InvalidDataException(); + } + + byte[] json; + using (var stream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + FileOptions.SequentialScan)) + { + if (stream.Length is <= 0 or > + MaximumDevelopmentLiveConfigurationFileBytes) + { + throw new InvalidDataException(); + } + + json = new byte[checked((int)stream.Length)]; + stream.ReadExactly(json); + } + + using var document = JsonDocument.Parse( + json, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 4 + }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException(); + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var property in root.EnumerateObject()) + { + if (!DevelopmentLiveProperties.Contains(property.Name) || + !seen.Add(property.Name)) + { + throw new InvalidDataException(); + } + } + + if (seen.Count != DevelopmentLiveProperties.Count || + !IsExactString(root, "mode", "DryRun") || + !IsLoopbackString(root, "host") || + !IsIntegerInRange(root, "port", 1, 65_535) || + !IsExactInteger(root, "tcpMode", 1) || + !IsExactInteger(root, "clientPort", 0) || + !IsNull(root, "sceneDirectory") || + !IsNullableNonNegativeInteger(root, "outputChannel") || + !IsExactInteger(root, "layoutIndex", 10) || + !IsExactInteger(root, "legacySceneFadeDuration", 6) || + !IsExactString(root, "legacySceneBackgroundKind", "None") || + !IsNull(root, "legacySceneBackgroundAssetPath") || + !IsExactInteger(root, "legacySceneBackgroundVideoLoopCount", 2004) || + !IsExactBoolean(root, "legacySceneBackgroundVideoLoopInfinite", true) || + !IsNull(root, "legacyBackgroundDirectory") || + !IsNull(root, "testProcessWindowTitlePattern") || + !IsExactStringArray( + root, + "testSceneAllowlist", + DevelopmentLiveSceneAllowlist) || + !IsExactBoolean(root, "trustedLiveOutputEnabled", true) || + !IsExactInteger(root, "queueCapacity", 64) || + !IsExactInteger(root, "connectTimeoutMilliseconds", 5_000) || + !IsExactInteger(root, "operationTimeoutMilliseconds", 5_000) || + !IsExactInteger(root, "disconnectTimeoutMilliseconds", 3_000) || + !IsExactInteger(root, "processPollIntervalMilliseconds", 1_000) || + !IsExactInteger(root, "reconnectDelayMilliseconds", 1_000) || + !IsExactInteger(root, "maximumReconnectAttempts", 0) || + !IsExactBoolean(root, "reconnectEnabled", false) || + !IsExactInteger(root, "maximumAutomaticRefreshesPerTakeIn", 0)) + { + throw new InvalidDataException(); + } + + return JsonSerializer.Deserialize( + json, + JsonOptions()) + ?? throw new InvalidDataException(); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or SecurityException or + InvalidDataException or ArgumentException or NotSupportedException or + JsonException) + { + throw new PlayoutConfigurationException( + "Development Live local configuration is invalid.", exception); + } + } + + private static void EnsureProtectedDevelopmentLivePath(string fullPath) + { + var localApplicationData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localApplicationData)) + { + throw new InvalidDataException(); + } + + var localRoot = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(localApplicationData)); + var expectedPath = Path.Combine( + localRoot, + "MBN_STOCK_WEBVIEW", + "Config", + "playout.local.json"); + if (!string.Equals(fullPath, expectedPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException(); + } + + var directory = Path.GetDirectoryName(fullPath); + while (!string.IsNullOrWhiteSpace(directory) && + !string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase)) + { + if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(); + } + + directory = Path.GetDirectoryName(directory); + } + + if (!string.Equals(directory, localRoot, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException(); + } + } + + private static bool IsExactString( + JsonElement root, + string propertyName, + string expected) + { + var value = root.GetProperty(propertyName); + return value.ValueKind == JsonValueKind.String && + string.Equals(value.GetString(), expected, StringComparison.Ordinal); + } + + private static bool IsLoopbackString(JsonElement root, string propertyName) + { + var value = root.GetProperty(propertyName); + return value.ValueKind == JsonValueKind.String && + IsLiteralLoopback(value.GetString()); + } + + private static bool IsLiteralLoopback(string? host) => + string.Equals(host, "127.0.0.1", StringComparison.Ordinal) || + string.Equals(host, "::1", StringComparison.Ordinal); + + private static bool IsIntegerInRange( + JsonElement root, + string propertyName, + int minimum, + int maximum) + { + var value = root.GetProperty(propertyName); + return value.ValueKind == JsonValueKind.Number && + value.TryGetInt32(out var parsed) && + parsed >= minimum && + parsed <= maximum; + } + + private static bool IsExactInteger( + JsonElement root, + string propertyName, + int expected) => + IsIntegerInRange(root, propertyName, expected, expected); + + private static bool IsNullableNonNegativeInteger( + JsonElement root, + string propertyName) + { + var value = root.GetProperty(propertyName); + return value.ValueKind == JsonValueKind.Null || + value.ValueKind == JsonValueKind.Number && + value.TryGetInt32(out var parsed) && + parsed >= 0; + } + + private static bool IsExactBoolean( + JsonElement root, + string propertyName, + bool expected) + { + var value = root.GetProperty(propertyName); + return expected + ? value.ValueKind == JsonValueKind.True + : value.ValueKind == JsonValueKind.False; + } + + private static bool IsNull(JsonElement root, string propertyName) => + root.GetProperty(propertyName).ValueKind == JsonValueKind.Null; + + private static bool IsExactStringArray( + JsonElement root, + string propertyName, + IReadOnlyList expected) + { + var value = root.GetProperty(propertyName); + if (value.ValueKind != JsonValueKind.Array || + value.GetArrayLength() != expected.Count) + { + return false; + } + + var index = 0; + foreach (var item in value.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || + !string.Equals( + item.GetString(), + expected[index], + StringComparison.Ordinal)) + { + return false; + } + + index++; + } + + return true; + } + private static PlayoutOptions LoadJson(string path) { if (!File.Exists(path)) diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs index b2965ea..4cf225b 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using System.Net; using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; @@ -125,6 +126,52 @@ internal sealed record ValidatedPlayoutOptions( throw Invalid(nameof(options.TestSceneAllowlist)); } + if (options.DevelopmentLiveBootstrapApplied) + { + if (options.Mode != PlayoutMode.Live) + { + throw Invalid(nameof(options.Mode)); + } + + if (!IsLiteralLoopback(host)) + { + throw Invalid(nameof(options.Host)); + } + + string? expectedSceneDirectory; + try + { + expectedSceneDirectory = string.IsNullOrWhiteSpace( + options.DevelopmentLiveExecutableSceneDirectory) + ? null + : Path.TrimEndingDirectorySeparator(Path.GetFullPath( + options.DevelopmentLiveExecutableSceneDirectory)); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + System.Security.SecurityException) + { + throw Invalid(nameof(options.SceneDirectory)); + } + + if (expectedSceneDirectory is null || + !string.Equals( + sceneDirectory, + expectedSceneDirectory, + StringComparison.OrdinalIgnoreCase) || + options.LegacySceneFadeDuration != 6 || + options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None || + !string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath) || + !string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) || + options.ReconnectEnabled || + options.MaximumReconnectAttempts != 0 || + options.MaximumAutomaticRefreshesPerTakeIn != 0) + { + throw new PlayoutConfigurationException( + "Development Live configuration no longer matches its protected startup contract."); + } + } + if (options.Mode == PlayoutMode.Test) { if (!IsLiteralLoopback(host)) diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs index 76fd32b..6974fc5 100644 --- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs @@ -178,6 +178,127 @@ public sealed class LegacyIniDatabaseOptionsLoaderTests Assert.Equal("selected.maria.invalid", runtime.Options.MariaDb.Host); } + [Fact] + public void Runtime_DevelopmentLiveUsesOnlyExactLocalAppDataIni() + { + using var localApplicationData = TemporaryDirectory.Create(); + var resourceDirectory = Path.Combine( + localApplicationData.Path, + "MBN_STOCK_WEBVIEW", + "Res"); + Directory.CreateDirectory(resourceDirectory); + File.WriteAllBytes( + Path.Combine(resourceDirectory, "MmoneyCoder.ini"), + TemporaryLegacyIni.ValidUtf8Bytes()); + + const string environmentName = "MBN_STOCK_ORACLE_HOST"; + var originalEnvironment = Environment.GetEnvironmentVariable(environmentName); + DatabaseRuntime runtime; + try + { + Environment.SetEnvironmentVariable( + environmentName, + "stale-environment.invalid"); + runtime = DatabaseRuntime.CreateDevelopmentLiveLocalOverride( + localApplicationData.Path); + } + finally + { + Environment.SetEnvironmentVariable( + environmentName, + originalEnvironment); + } + + Assert.Equal("oracle.dev.invalid", runtime.Options.Oracle.Host); + Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host); + } + + [Fact] + public void Runtime_DevelopmentLiveFailsClosedWhenBothProtectedProfilesAreMissing() + { + using var localApplicationData = TemporaryDirectory.Create(); + Directory.CreateDirectory(Path.Combine( + localApplicationData.Path, + "MBN_STOCK_WEBVIEW", + "Config")); + + Assert.Throws( + () => DatabaseRuntime.CreateDevelopmentLiveLocalOverride( + localApplicationData.Path)); + } + + [Fact] + public void Runtime_DevelopmentLiveFallsBackToProtectedLocalAppDataJson() + { + using var localApplicationData = TemporaryDirectory.Create(); + var configurationDirectory = Path.Combine( + localApplicationData.Path, + "MBN_STOCK_WEBVIEW", + "Config"); + Directory.CreateDirectory(configurationDirectory); + File.WriteAllText( + Path.Combine(configurationDirectory, "database.local.json"), + """ + { + "oracle": { + "host": "json.oracle.invalid", + "port": 1521, + "sid": "DEVORA", + "serviceName": null, + "userName": "oracle-user", + "password": "oracle-password" + }, + "mariaDb": { + "host": "json.maria.invalid", + "port": 3306, + "database": "dev_stock", + "userName": "maria-user", + "password": "maria-password", + "tlsMode": "Required" + } + } + """); + + var runtime = DatabaseRuntime.CreateDevelopmentLiveLocalOverride( + localApplicationData.Path); + + Assert.Equal("json.oracle.invalid", runtime.Options.Oracle.Host); + Assert.Equal("json.maria.invalid", runtime.Options.MariaDb.Host); + Assert.Equal(MariaDbTlsMode.Required, runtime.Options.MariaDb.TlsMode); + } + + [Fact] + public void Runtime_DevelopmentLiveRejectsNonOrdinaryIniTarget() + { + using var localApplicationData = TemporaryDirectory.Create(); + var iniDirectory = Path.Combine( + localApplicationData.Path, + "MBN_STOCK_WEBVIEW", + "Res", + "MmoneyCoder.ini"); + Directory.CreateDirectory(iniDirectory); + + Assert.Throws( + () => DatabaseRuntime.CreateDevelopmentLiveLocalOverride( + localApplicationData.Path)); + } + + [Fact] + public void Runtime_DevelopmentLiveRejectsNonOrdinaryJsonTarget() + { + using var localApplicationData = TemporaryDirectory.Create(); + var jsonDirectory = Path.Combine( + localApplicationData.Path, + "MBN_STOCK_WEBVIEW", + "Config", + "database.local.json"); + Directory.CreateDirectory(jsonDirectory); + + Assert.Throws( + () => DatabaseRuntime.CreateDevelopmentLiveLocalOverride( + localApplicationData.Path)); + } + [Fact] public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs index 4b282c9..223fec8 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs @@ -98,6 +98,113 @@ public sealed class LegacyDevelopmentLiveStartupContractTests Assert.Contains("args.Arguments", app, StringComparison.Ordinal); Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal); Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal); + Assert.Contains( + "IsDevelopmentLiveLaunchRequested = string.Equals(", + app, + StringComparison.Ordinal); + Assert.Contains( + "IsDevelopmentLiveLaunch = developmentLive.IsApplied;", + app, + StringComparison.Ordinal); + } + + [Fact] + public void AppliedDevelopmentLiveUsesOnlyProtectedStartupInputs() + { + var window = File.ReadAllText(Path.Combine(ParityRoot, "MainWindow.xaml.cs")); + var playout = File.ReadAllText(Path.Combine( + ParityRoot, + "MainWindow.Playout.cs")); + + Assert.Contains( + "_isDevelopmentLiveLaunch = App.IsDevelopmentLiveLaunch;", + window, + StringComparison.Ordinal); + Assert.Contains( + "_isDevelopmentLiveLaunchRequested = App.IsDevelopmentLiveLaunchRequested;", + window, + StringComparison.Ordinal); + Assert.Contains( + "? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(", + window, + StringComparison.Ordinal); + Assert.Contains( + "? null\n : _appliedOperatorSettings.ResourceDirectory;", + window.ReplaceLineEndings("\n"), + StringComparison.Ordinal); + Assert.Contains( + "DatabaseRuntime.CreateDevelopmentLiveLocalOverride()", + window, + StringComparison.Ordinal); + Assert.Contains( + "? PlayoutOptionsLoader.LoadDevelopmentLive(", + playout, + StringComparison.Ordinal); + Assert.Contains( + "IsSourceOnlyBuild ||\n _isDevelopmentLiveLaunchRequested", + playout.ReplaceLineEndings("\n"), + StringComparison.Ordinal); + } + + [Fact] + public void DevelopmentLiveDatabaseFailureForcesDryRunAndSkipsAutomaticConnect() + { + var window = File.ReadAllText(Path.Combine(ParityRoot, "MainWindow.xaml.cs")) + .ReplaceLineEndings("\n"); + var playout = File.ReadAllText(Path.Combine( + ParityRoot, + "MainWindow.Playout.cs")) + .ReplaceLineEndings("\n"); + + var blockAssignment = window.IndexOf( + "_developmentLiveStartupBlocked = true;", + StringComparison.Ordinal); + var successAssignment = window.IndexOf( + "databaseInitializationSucceeded = true;", + StringComparison.Ordinal); + var runtimeReset = window.IndexOf( + "_databaseRuntime = null;", + successAssignment, + StringComparison.Ordinal); + var pagePlanReset = window.IndexOf( + "fixedPagePlanProvider = null;", + successAssignment, + StringComparison.Ordinal); + var playoutInitialization = window.IndexOf( + "InitializePlayoutRuntime();", + StringComparison.Ordinal); + Assert.True(successAssignment >= 0); + Assert.True(runtimeReset > successAssignment); + Assert.True(pagePlanReset > successAssignment); + Assert.True(blockAssignment > runtimeReset); + Assert.True(playoutInitialization > blockAssignment); + Assert.Contains( + "var databaseInitializationSucceeded = false;", + window, + StringComparison.Ordinal); + Assert.Contains( + "if (_isDevelopmentLiveLaunch && !databaseInitializationSucceeded)\n" + + " {\n" + + " // Development Live is one fail-closed startup transaction.", + window, + StringComparison.Ordinal); + Assert.Contains( + "_isDevelopmentLiveLaunch &&\n" + + " !_developmentLiveStartupBlocked\n" + + " ? PlayoutOptionsLoader.LoadDevelopmentLive(", + playout, + StringComparison.Ordinal); + Assert.Contains( + "_isDevelopmentLiveLaunchRequested ||\n" + + " _developmentLiveStartupBlocked", + playout, + StringComparison.Ordinal); + Assert.Contains( + "if (!_developmentLiveStartupBlocked)\n" + + " {\n" + + " await ConnectPlayoutAsync(_lifetimeCancellation.Token);", + window, + StringComparison.Ordinal); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs index bd0a0ad..6b21fc8 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs @@ -333,7 +333,10 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests element.Attribute("Name")?.Value, "ValidateLegacyRuntimeAssets", StringComparison.Ordinal)); - var sourceOnlyMessage = Assert.Single(validationTarget.Elements("Message")); + var sourceOnlyMessage = Assert.Single( + validationTarget.Elements("Message"), + message => (message.Attribute("Condition")?.Value ?? string.Empty) + .Contains("$(LegacyRuntimeAssetsMode)", StringComparison.Ordinal)); Assert.Contains( "$(LegacyRuntimeAssetsMode)", sourceOnlyMessage.Attribute("Condition")?.Value, @@ -342,6 +345,21 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "source-only DryRun", sourceOnlyMessage.Attribute("Text")?.Value, StringComparison.Ordinal); + Assert.Contains( + "Initialize-ExistingDevelopmentPc.ps1", + sourceOnlyMessage.Attribute("Text")?.Value, + StringComparison.Ordinal); + + var fullRuntimeMessage = Assert.Single( + validationTarget.Elements("Message"), + message => (message.Attribute("Text")?.Value ?? string.Empty) + .Contains( + "Verified full legacy runtime assets are enabled", + StringComparison.Ordinal)); + Assert.Contains( + "Verified full legacy runtime assets are enabled", + fullRuntimeMessage.Attribute("Text")?.Value, + StringComparison.Ordinal); var runtimeValidationErrors = validationTarget.Elements("Error") .Where(error => (error.Attribute("Condition")?.Value ?? string.Empty) @@ -353,6 +371,11 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "$(LegacyRuntimeAssetsValidationEnabled)", error.Attribute("Condition")?.Value, StringComparison.Ordinal)); + Assert.Contains( + runtimeValidationErrors, + error => (error.Attribute("Text")?.Value ?? string.Empty).Contains( + "Initialize-ExistingDevelopmentPc.ps1", + StringComparison.Ordinal)); string[] externalContentIncludes = [ @@ -602,6 +625,377 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests StringComparison.Ordinal); } + [Fact] + public void ExistingDevelopmentPcInitializerUsesLocalAssetsWithoutPerformingLiveActions() + { + var initializer = File.ReadAllText(Path.Combine( + RepositoryRoot, + "scripts", + "Initialize-ExistingDevelopmentPc.ps1")); + + Assert.Contains("[string] $LegacyRuntimeSourceRoot", initializer, + StringComparison.Ordinal); + Assert.Contains("[switch] $NoFolderPicker", initializer, + StringComparison.Ordinal); + Assert.Contains( + @"..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Test-DirectCutsAndResRootCandidate", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "-Path $siblingCandidate", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "System.Windows.Forms.FolderBrowserDialog", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "$selectedLeaf.Equals('Cuts'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "$selectedLeaf.Equals('Res'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "single common parent of Cuts and Res", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "must be on a ready local fixed drive", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "must be outside and must not overlap this repository", + initializer, + StringComparison.Ordinal); + Assert.Contains("Assert-NoReparsePointInTree", initializer, + StringComparison.Ordinal); + + Assert.Contains("'New-LegacyRuntimeBundle.ps1'", initializer, + StringComparison.Ordinal); + Assert.Contains("'Initialize-LegacyRuntimeBundle.ps1'", initializer, + StringComparison.Ordinal); + Assert.Contains( + "$existingRuntime.ManifestSha256.Equals(", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Assert-InstalledRuntimeMatchesManifest", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Installed runtime bundle contains an unexpected file", + initializer, + StringComparison.Ordinal); + Assert.Contains("[switch] $ReplaceRuntimeBinding", initializer, + StringComparison.Ordinal); + Assert.Contains("Set-RuntimeBindingAtomically", initializer, + StringComparison.Ordinal); + Assert.Contains( + "[IO.File]::Replace($temporary, $destination, $backup)", + initializer, + StringComparison.Ordinal); + var shadowInstall = initializer.IndexOf( + "-RepositoryRoot $shadowRepositoryRoot", + StringComparison.Ordinal); + var bindingReplace = initializer.IndexOf( + "Set-RuntimeBindingAtomically", + shadowInstall, + StringComparison.Ordinal); + Assert.True(shadowInstall >= 0); + Assert.True(bindingReplace > shadowInstall); + + Assert.Contains("[string] $DatabaseIniPath", initializer, + StringComparison.Ordinal); + Assert.Contains("[IO.Path]::IsPathRooted($DatabaseIniPath)", initializer, + StringComparison.Ordinal); + Assert.Contains("[switch] $SkipDatabaseProfile", initializer, + StringComparison.Ordinal); + Assert.Contains("[switch] $ReplaceDatabaseProfile", initializer, + StringComparison.Ordinal); + Assert.Contains(@"Res\MmoneyCoder.ini", initializer, + StringComparison.Ordinal); + Assert.Contains("$maximumDatabaseIniLength = 1MB", initializer, + StringComparison.Ordinal); + Assert.Contains("Copy-DatabaseProfileAtomically", initializer, + StringComparison.Ordinal); + Assert.Contains( + "[IO.File]::Replace($temporaryPath, $Destination, $backupPath)", + initializer, + StringComparison.Ordinal); + Assert.Contains("SetAccessRuleProtection($true, $false)", initializer, + StringComparison.Ordinal); + + Assert.Contains("'Inspect-K3DRegistration.ps1'", initializer, + StringComparison.Ordinal); + Assert.Contains("Get-VerifiedK3DRegistration -Reports $registrationReports", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "[string] $registration.RegistryHive -cne 'HKLM'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "[string] $registration.RegistryView -cne 'Registry64'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "[string] $registration.CurrentUserOverrides -cne 'None'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "[string] $classReport.ThreadingModel -cne 'Apartment'", + initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("Activator.CreateInstance", initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("Connect(", initializer, StringComparison.Ordinal); + Assert.DoesNotContain("TAKE IN", initializer, StringComparison.Ordinal); + Assert.DoesNotContain("TAKE OUT", initializer, StringComparison.Ordinal); + + Assert.Contains("[switch] $ConfigureDevelopmentLive", initializer, + StringComparison.Ordinal); + Assert.Contains("[switch] $ReplaceLiveConfig", initializer, + StringComparison.Ordinal); + Assert.Contains( + "-ConfigureDevelopmentLive requires the default Debug x64 build verification.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Independent native and interop SHA-256 approvals are both required.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Get-FileHash -LiteralPath $nativePath -Algorithm SHA256", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Get-FileHash -LiteralPath $interopPath -Algorithm SHA256", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "$PlayoutHost -cne '127.0.0.1'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Existing Development Live files differ from the exact requested", + initializer, + StringComparison.Ordinal); + + Assert.Contains("& dotnet build $ProjectPath -c Debug '-p:Platform=x64'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "'-getProperty:LegacyRuntimeAssetsEnabled'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "unexpectedly resolved as SourceOnly", + initializer, + StringComparison.Ordinal); + Assert.Contains("Debug x64 payload count mismatch", initializer, + StringComparison.Ordinal); + Assert.Contains( + "Credential-bearing MmoneyCoder.ini entered the Debug build output.", + initializer, + StringComparison.Ordinal); + + var runtimeWrite = initializer.LastIndexOf( + "Set-RuntimeBindingAtomically", + StringComparison.Ordinal); + var databaseWrite = initializer.LastIndexOf( + "Copy-DatabaseProfileAtomically", + StringComparison.Ordinal); + var build = initializer.LastIndexOf( + "Invoke-DebugBuildAndVerify", + StringComparison.Ordinal); + var cleanup = initializer.LastIndexOf( + "-Path $rollbackRoot", + StringComparison.Ordinal); + var liveWrite = initializer.LastIndexOf( + "& $liveInitializerPath @liveArguments", + StringComparison.Ordinal); + Assert.True(runtimeWrite >= 0); + Assert.True(databaseWrite > runtimeWrite); + Assert.True(build > databaseWrite); + Assert.True(cleanup > build); + Assert.True(liveWrite > cleanup); + } + + [Fact] + public void ExistingDevelopmentPcInitializerRollsBackAndReissuesLiveFailClosed() + { + var initializer = File.ReadAllText(Path.Combine( + RepositoryRoot, + "scripts", + "Initialize-ExistingDevelopmentPc.ps1")); + + Assert.Contains("function New-FileRollbackSnapshot", initializer, + StringComparison.Ordinal); + Assert.Contains("function Restore-FileRollbackSnapshot", initializer, + StringComparison.Ordinal); + Assert.Contains("OriginalBytes = [IO.File]::ReadAllBytes($backupPath)", + initializer, + StringComparison.Ordinal); + Assert.Contains("AccessSddl = $accessSddl", initializer, + StringComparison.Ordinal); + Assert.Contains( + "$Description rollback backup failed its immediate ACL/hash verification.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Previous database profile backup failed immediate ACL/hash verification.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "$Description rollback did not restore the exact bytes and ACL.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Set-UserOnlyDirectoryAcl -Path $rollbackRoot", + initializer, + StringComparison.Ordinal); + + var databaseMutation = initializer.LastIndexOf( + "$databaseMutationStarted = $true", + StringComparison.Ordinal); + var databaseWrite = initializer.LastIndexOf( + "Copy-DatabaseProfileAtomically", + StringComparison.Ordinal); + var databaseRollback = initializer.LastIndexOf( + "-Snapshot $databaseSnapshot", + StringComparison.Ordinal); + var runtimeMutation = initializer.LastIndexOf( + "$runtimeBindingMutationStarted = $true", + StringComparison.Ordinal); + var runtimeWrite = initializer.LastIndexOf( + "Set-RuntimeBindingAtomically", + StringComparison.Ordinal); + var runtimeRollback = initializer.LastIndexOf( + "-Snapshot $propsSnapshot", + StringComparison.Ordinal); + Assert.True(databaseMutation >= 0 && databaseMutation < databaseWrite); + Assert.True(runtimeMutation >= 0 && runtimeMutation < runtimeWrite); + Assert.True(databaseRollback > databaseWrite); + Assert.True(runtimeRollback > runtimeWrite); + Assert.Contains("Original failure:", initializer, StringComparison.Ordinal); + Assert.Contains("Secondary recovery failure(s):", initializer, + StringComparison.Ordinal); + + Assert.Contains("function Assert-ExactObjectProperties", initializer, + StringComparison.Ordinal); + Assert.Contains( + "-Description 'Runtime bundle creation result'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "-Description 'Runtime bundle initialization result'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "-Description 'K3D registration inspection result'", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Runtime bundle creation result paths escaped or changed the exact output contract.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Runtime bundle initialization result paths or hashes violate containment.", + initializer, + StringComparison.Ordinal); + + Assert.Contains("function Get-VerifiedRuntimeManifest", initializer, + StringComparison.Ordinal); + Assert.Contains( + "duplicate/extra/case/type/format changes are rejected.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "function Assert-DebugRuntimePayloadMatchesManifest", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Debug x64 runtime payload differs from the manifest:", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Debug x64 runtime output contains an unexpected file:", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "Debug x64 runtime output does not match the exact manifest file set.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "-Root $targetRoot", + initializer, + StringComparison.Ordinal); + + Assert.Contains("function Test-ExactUtf8Text", initializer, + StringComparison.Ordinal); + Assert.Contains( + "case changes, type coercion, reordered properties", + initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("Get-JsonContractText", initializer, + StringComparison.Ordinal); + var livePreflightStart = initializer.IndexOf( + "function Get-DevelopmentLivePreflight", + StringComparison.Ordinal); + var livePreflightEnd = initializer.IndexOf( + "function Copy-DatabaseProfileAtomically", + livePreflightStart, + StringComparison.Ordinal); + Assert.True(livePreflightStart >= 0 && livePreflightEnd > livePreflightStart); + var livePreflightFunction = initializer[livePreflightStart..livePreflightEnd]; + Assert.DoesNotContain("NeedsWrite = $false", livePreflightFunction, + StringComparison.Ordinal); + Assert.Contains("DevelopmentLiveReused = $false", initializer, + StringComparison.Ordinal); + + var firstPersistentLiveInvalidation = initializer.IndexOf( + "# Invalidate the previous one-launch authorization before database/runtime", + StringComparison.Ordinal); + var setupDirectoryCreation = initializer.IndexOf( + "[IO.Directory]::CreateDirectory($setupParent)", + StringComparison.Ordinal); + var buildVerification = initializer.LastIndexOf( + "Invoke-DebugBuildAndVerify", + StringComparison.Ordinal); + var setupCleanup = initializer.LastIndexOf( + "-Path $rollbackRoot", + StringComparison.Ordinal); + var liveIssue = initializer.LastIndexOf( + "$liveResults = @(& $liveInitializerPath @liveArguments)", + StringComparison.Ordinal); + var liveJsonVerification = initializer.LastIndexOf( + "-ExpectedJson ([string] $livePreflight.AuthorizationJson)", + StringComparison.Ordinal); + var failClosedLiveRemoval = initializer.LastIndexOf( + "Remove-DevelopmentLiveAuthorizationFailClosed", + StringComparison.Ordinal); + Assert.True(firstPersistentLiveInvalidation >= 0); + Assert.True(firstPersistentLiveInvalidation < setupDirectoryCreation); + Assert.True(buildVerification < setupCleanup); + Assert.True(setupCleanup < liveIssue); + Assert.True(liveIssue < liveJsonVerification); + Assert.True(liveJsonVerification < failClosedLiveRemoval); + Assert.Contains( + "Development Live initializer returned unexpected success-stream output.", + initializer, + StringComparison.Ordinal); + Assert.Contains( + "$Description failed its exact JSON/ACL verification.", + initializer, + StringComparison.Ordinal); + } + [Fact] public void RepositoryDoesNotContainCopiedLegacyRuntimeTrees() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DevelopmentLiveLaunchBootstrapTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DevelopmentLiveLaunchBootstrapTests.cs index c7195c1..7ad68f1 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DevelopmentLiveLaunchBootstrapTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DevelopmentLiveLaunchBootstrapTests.cs @@ -189,7 +189,7 @@ public sealed class DevelopmentLiveLaunchBootstrapTests } [Fact] - public void EnvironmentFailure_RestoresEveryPreviousProcessValue() + public void EnvironmentFailure_DisarmsEveryPreviousProcessValue() { var platform = FakePlatform.Valid(); platform.Environment[ @@ -209,20 +209,32 @@ public sealed class DevelopmentLiveLaunchBootstrapTests Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); Assert.Equal("development-live-environment-failed", result.Code); - Assert.Equal( - "old-native", - platform.Environment[ - InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable]); - Assert.Equal( - "old-interop", - platform.Environment[ - InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable]); - Assert.Equal( - "DryRun", - platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable]); - Assert.Equal( - "old-authorization", - platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable]); + Assert.Empty(platform.Environment); + } + + [Fact] + public void RejectedExactDebugRequest_DisarmsInheritedLiveGates() + { + var platform = new FakePlatform(Encoding.UTF8.GetBytes("{}")); + platform.Environment[ + InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable] = + new string('A', 64); + platform.Environment[ + InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable] = + new string('B', 64); + platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable] = + DevelopmentLiveLaunchBootstrap.RequiredMode; + platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable] = + PlayoutOptionsLoader.RequiredLiveAuthorizationValue; + + var result = DevelopmentLiveLaunchBootstrap.TryApply( + DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, + isDebugBuild: true, + "authorization.json", + platform); + + Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status); + Assert.Empty(platform.Environment); } public static TheoryData InvalidAuthorizationJson() => new() diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index f2ade04..77532bc 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -88,6 +88,210 @@ public sealed class PlayoutOptionsLoaderTests Assert.Empty(options.TestSceneAllowlist); } + [Fact] + public void Load_RejectedDevelopmentLiveRequestCannotInheritLiveEnvironment() + { + using var environment = ClearedEnvironment() + .Set("MBN_STOCK_PLAYOUT_MODE", "Live") + .Set("MBN_STOCK_PLAYOUT_HOST", "127.0.0.1") + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson()); + + var options = PlayoutOptionsLoader.Load( + file.Path, + operatorSceneDirectory: "C:\\operator-scenes", + operatorBackgroundDirectory: "C:\\operator-backgrounds", + forceSafeDryRun: true); + var validated = ValidatedPlayoutOptions.Create(options); + + Assert.Equal(PlayoutMode.DryRun, validated.Mode); + Assert.False(validated.TrustedLiveOutputEnabled); + Assert.Null(validated.SceneDirectory); + Assert.Empty(validated.TestSceneAllowlist); + } + + [Fact] + public void LoadDevelopmentLive_IgnoresEnvironmentAndPinsExecutableCuts() + { + using var environment = ClearedEnvironment() + .Set("MBN_STOCK_PLAYOUT_MODE", "Disabled") + .Set("MBN_STOCK_PLAYOUT_HOST", "stale-remote-host.invalid") + .Set("MBN_STOCK_PLAYOUT_PORT", "32001") + .Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "99") + .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\stale-scenes") + .Set( + "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY", + "C:\\stale-backgrounds") + .Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "true") + .Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "9"); + using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson()); + var executableRoot = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + var cuts = Path.Combine(executableRoot, "Cuts"); + Directory.CreateDirectory(cuts); + try + { + var options = PlayoutOptionsLoader.LoadDevelopmentLive( + file.Path, + executableRoot); + + Assert.True(options.DevelopmentLiveBootstrapApplied); + Assert.Equal(PlayoutMode.Live, options.Mode); + Assert.Equal("127.0.0.1", options.Host); + Assert.Equal(31001, options.Port); + Assert.Equal(7, options.OutputChannel); + Assert.Equal(Path.GetFullPath(cuts), options.SceneDirectory); + Assert.Equal( + Path.GetFullPath(cuts), + options.DevelopmentLiveExecutableSceneDirectory); + Assert.Null(options.LegacyBackgroundDirectory); + Assert.False(options.ReconnectEnabled); + Assert.Equal(0, options.MaximumReconnectAttempts); + + var validated = ValidatedPlayoutOptions.Create(options); + Assert.Equal("127.0.0.1", validated.Host); + Assert.Equal(Path.GetFullPath(cuts), validated.SceneDirectory); + Assert.False(validated.ReconnectEnabled); + Assert.Equal(0, validated.MaximumReconnectAttempts); + } + finally + { + Directory.Delete(executableRoot, recursive: true); + } + } + + [Fact] + public void LoadDevelopmentLive_RejectsBaseProfileThatCanReconnect() + { + using var environment = ClearedEnvironment(); + using var file = TemporaryJsonFile.Create( + DevelopmentLiveBaseJson().Replace( + "\"reconnectEnabled\": false", + "\"reconnectEnabled\": true", + StringComparison.Ordinal)); + + var exception = Assert.Throws( + () => PlayoutOptionsLoader.LoadDevelopmentLive(file.Path, "C:\\runtime")); + + Assert.Contains( + "local configuration is invalid", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void Validate_DevelopmentLiveRequiresLiteralLoopbackAtFinalBoundary() + { + using var environment = ClearedEnvironment(); + using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson()); + var executableRoot = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(executableRoot, "Cuts")); + try + { + var options = PlayoutOptionsLoader.LoadDevelopmentLive( + file.Path, + executableRoot); + options.Host = "playout.dev.invalid"; + + var exception = Assert.Throws( + () => ValidatedPlayoutOptions.Create(options)); + + Assert.Contains( + nameof(PlayoutOptions.Host), + exception.Message, + StringComparison.Ordinal); + } + finally + { + Directory.Delete(executableRoot, recursive: true); + } + } + + [Fact] + public void LoadDevelopmentLive_RejectsNonCanonicalJsonAndAllowlistExpansion() + { + using var environment = ClearedEnvironment(); + var canonical = DevelopmentLiveBaseJson(); + var closingBrace = canonical.LastIndexOf('}'); + Assert.True(closingBrace > 0); + string[] invalidConfigurations = + [ + canonical.Replace( + "\"mode\": \"DryRun\"", + "\"Mode\": \"DryRun\"", + StringComparison.Ordinal), + canonical.Replace( + "\"mode\": \"DryRun\"", + "\"mode\": \"DryRun\", \"mode\": \"DryRun\"", + StringComparison.Ordinal), + canonical.Replace( + "\"port\": 31001", + "\"port\": \"31001\"", + StringComparison.Ordinal), + canonical[..closingBrace] + ",\n}", + canonical.Replace("{", "{/* comment */", StringComparison.Ordinal), + canonical[..closingBrace] + ",\n \"unexpected\": true\n}", + canonical.Replace( + "\"N5001\"", + "\"N5001\", \"9999\"", + StringComparison.Ordinal) + ]; + + foreach (var json in invalidConfigurations) + { + using var file = TemporaryJsonFile.Create(json); + + var exception = Assert.Throws( + () => PlayoutOptionsLoader.LoadDevelopmentLive( + file.Path, + "C:\\runtime")); + + Assert.Equal( + "Development Live local configuration is invalid.", + exception.Message); + } + } + + [Fact] + public void Validate_DevelopmentLiveRejectsSceneRootReplacementAfterLoading() + { + using var environment = ClearedEnvironment(); + using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson()); + var executableRoot = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + var replacementRoot = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(executableRoot, "Cuts")); + Directory.CreateDirectory(replacementRoot); + try + { + var options = PlayoutOptionsLoader.LoadDevelopmentLive( + file.Path, + executableRoot); + options.SceneDirectory = replacementRoot; + + var exception = Assert.Throws( + () => ValidatedPlayoutOptions.Create(options)); + + Assert.Contains( + "protected startup contract", + exception.Message, + StringComparison.Ordinal); + } + finally + { + Directory.Delete(executableRoot, recursive: true); + Directory.Delete(replacementRoot, recursive: true); + } + } + [Fact] public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable() { @@ -335,4 +539,43 @@ public sealed class PlayoutOptionsLoaderTests return result; } + + private static string DevelopmentLiveBaseJson() => + """ + { + "mode": "DryRun", + "host": "127.0.0.1", + "port": 31001, + "tcpMode": 1, + "clientPort": 0, + "sceneDirectory": null, + "outputChannel": 7, + "layoutIndex": 10, + "legacySceneFadeDuration": 6, + "legacySceneBackgroundKind": "None", + "legacySceneBackgroundAssetPath": null, + "legacySceneBackgroundVideoLoopCount": 2004, + "legacySceneBackgroundVideoLoopInfinite": true, + "legacyBackgroundDirectory": null, + "testProcessWindowTitlePattern": null, + "testSceneAllowlist": [ + "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" + ], + "trustedLiveOutputEnabled": true, + "queueCapacity": 64, + "connectTimeoutMilliseconds": 5000, + "operationTimeoutMilliseconds": 5000, + "disconnectTimeoutMilliseconds": 3000, + "processPollIntervalMilliseconds": 1000, + "reconnectDelayMilliseconds": 1000, + "maximumReconnectAttempts": 0, + "reconnectEnabled": false, + "maximumAutomaticRefreshesPerTakeIn": 0 + } + """; } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs index fbbd4a8..bdfdb55 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs @@ -258,6 +258,21 @@ public sealed class PlayoutSafetyValidationTests : IDisposable Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal); } + [Fact] + public void Validate_OrdinaryLiveModeRetainsConfiguredRemoteEndpointBehavior() + { + var options = ValidTestOptions(); + options.Mode = PlayoutMode.Live; + options.Host = "playout.dev.invalid"; + options.OutputChannel = null; + options.TestProcessWindowTitlePattern = null; + options.TrustedLiveOutputEnabled = true; + + var validated = ValidatedPlayoutOptions.Create(options); + + Assert.Equal("playout.dev.invalid", validated.Host); + } + [Theory] [InlineData(".*")] [InlineData("PGM")]