diff --git a/.gitignore b/.gitignore
index c0702ef..7b07424 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ Config/playout*.local.json
Config/runtime-folders.local.json
**/runtime-folders.local.json
*.local.ini
+Directory.Build.local.props
.env
.env.*
!.env.example
diff --git a/Directory.Build.props b/Directory.Build.props
index 9d9c636..97e397d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -9,4 +9,12 @@
None
false
+
+
+
diff --git a/README.md b/README.md
index 1140595..dcc88db 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,13 @@ DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도
`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤
[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요.
+인수 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을 검증해 설치합니다.
+
이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요.
기존 중복 형식 오류(`CS0121`, `CS0436`)는 루트 앱 프로젝트가 하위 Core 소스까지 다시 컴파일하던 문제였으며, 현재 `src\**\*.cs`를 앱 컴파일 대상에서 제외해 해결했습니다. Visual Studio가 이전 진단을 계속 표시하면 `빌드 > 솔루션 정리` 후 다시 빌드하세요.
@@ -117,12 +124,12 @@ Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위
## 구성과 보안
-원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 원본 자산을 사용하는 일반
-Visual Studio 전체 개발 빌드만 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를
-재현합니다. 소스 전용 빌드는 자격증명 없이 DB 미연결 상태로 시작하고, MSIX는
-`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
-`database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위,
-보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
+원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 전체 자산 Debug 빌드,
+소스 전용 빌드, 게시와 MSIX 모두 이 자격증명 파일을 빌드 입력이나 출력으로 사용하지 않습니다.
+전체 자산 Debug/MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
+`database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 환경 변수는 선택된
+파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은
+[DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된
UI용 `Res` 파일이 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
index da7cf8a..95891fe 100644
--- a/docs/DATABASE.md
+++ b/docs/DATABASE.md
@@ -18,12 +18,17 @@ Oracle ODP.NET Core 23 계열은 TAP 기반 `OpenAsync`/명령 비동기를 지
## 런타임 설정
-원본 호환 앱은 다음 순서로 DB 설정을 선택합니다.
+원본 호환 앱은 호환성을 위해 다음 순서로 DB 설정을 선택합니다.
-1. 실행 파일 옆 `Res\MmoneyCoder.ini` — Visual Studio의 일반 개발 출력에서 원본과 같은 방식으로 사용
+1. 실행 파일 옆 `Res\MmoneyCoder.ini` — 기존 수동 배치와의 호환 경로
2. `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` — MSIX용 로컬 overlay
3. 기존 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` — INI가 없을 때 fallback
+공식 Debug/Release 빌드와 개발 인수용 런타임 묶음은 첫 번째 파일을 생성하거나 복사하지
+않습니다. 깨끗한 개발 PC에서는 두 번째 또는 세 번째 사용자 전용 경로만 사용합니다.
+`MmoneyCoder.ini`가 들어 있는 원본 `Res` 전체를 빌드 출력이나 전달용 ZIP으로 복사하지
+마세요.
+
`MmoneyCoder.ini`의 `[Oracle]`/`[Maria]` 섹션에서 `ConnectionName=host:port/service`,
`ID`, `Pass`를 읽습니다. BOM 없는 UTF-8과 CP949를 지원하고 환경 변수는 선택된 파일 값보다
우선합니다. 원본 `Res`의 복사본·`.bak`·`.zip`은 사용하지도 배포하지도 않습니다.
diff --git a/docs/DEVELOPMENT_LIVE_HANDOFF.md b/docs/DEVELOPMENT_LIVE_HANDOFF.md
new file mode 100644
index 0000000..71b7cc9
--- /dev/null
+++ b/docs/DEVELOPMENT_LIVE_HANDOFF.md
@@ -0,0 +1,334 @@
+# 개발 PGM 인수 PC 실제 송출 확인 절차
+
+## 적용 범위
+
+이 절차는 상사 또는 인수 담당자의 PC가 **방송에 연결되지 않은 지정 개발 장비**이고, 그
+장비의 로컬 Tornado2 `PGM`이 개발 검증 대상임을 확인한 경우에만 사용한다. 창 제목이 `PGM`인
+것만으로 개발 장비임이 증명되지는 않는다. 실제 방송 PROGRAM일 가능성이 있거나 라우팅을
+확인할 수 없으면 시작하지 말고 [운영·비지정 환경 승인 게이트](PLAYOUT_OPERATIONS.md#운영비지정-환경-승인-게이트)를
+적용한다.
+
+이 절차가 여는 Live 경로는 `Debug|x64`의
+`MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)` 한 프로세스에만
+유효하다. `Release`와 일반 설치본의 기본 모드는 계속 `DryRun`이다. Release를 Live로 바꾸거나
+사용자·시스템 환경 변수에 Live 승인을 영구 저장하지 않는다.
+
+## 인수 전에 별도로 전달할 항목
+
+다음 항목은 저장소가 아니라 승인된 사내 전달 경로로 제공한다.
+
+- `New-LegacyRuntimeBundle.ps1`로 생성한 runtime bundle ZIP과 별도 신뢰 경로로 전달할
+ 기대 SHA-256.
+- DB endpoint와 계정 정보. 메신저 화면, 작업 기록 또는 Git에 값을 남기지 않는다.
+- Tornado2/K3D x64 벤더 설치본과 해당 장비용 라이선스 설치 절차.
+- 벤더 배포본 또는 관리자 검수로 **독립 승인된** native DLL과 Interop DLL의 SHA-256 두 개.
+- 개발 PGM의 실제 Network Server TCP port, 창 제목, 출력 라우팅과 담당자 확인 정보.
+
+현재 설치된 DLL의 해시를 그 자리에서 계산한 뒤 같은 값을 승인값으로 삼지 않는다. 라이선스
+파일, DLL, 인증서 또는 자산을 앱 폴더에 임의 복사해 등록·라이선스 검사를 우회하지 않는다.
+
+## 1. 개발 장비와 대상 고정
+
+담당자 두 사람이 다음 내용을 먼저 확인한다.
+
+1. 이 PC와 로컬 Tornado2/PGM은 개발용이며 실제 방송 라우팅에 영향이 없다.
+2. `Tornado2*` 프로세스는 정확히 하나이고 주 창 제목은 승인값과 정확히 일치한다.
+3. Tornado2의 `Tools > Option > Control > Network Server > TCP Port`에 표시된 값을 직접
+ 읽는다. 문서나 예제의 `30001`을 추정값으로 사용하지 않는다.
+4. 해당 TCP LISTEN 소유 PID와 Tornado2 PID가 같고, 실행 도중 프로세스 세대가 바뀌지 않는다.
+5. `View > Network Monitoring Window`와 PGM 화면을 동시에 관찰할 담당자가 있다.
+
+한 항목이라도 불명확하면 이후 명령을 보내지 않는다.
+
+## 2. 검증된 Git 밖 runtime bundle 설치
+
+원본 runtime tree를 새 PC에 직접 복사하거나 수동으로 ZIP을 만들지 않는다. 원본을 보유한
+지정 개발 장비에서 읽기 전용 `bin\Debug`을 입력으로 다음 스크립트를 실행한다.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass `
+ -File .\scripts\New-LegacyRuntimeBundle.ps1 `
+ -LegacyRuntimeSourceRoot '' `
+ -OutputDirectory ''
+```
+
+`-OutputDirectory`를 생략하면 Git 제외
+`artifacts\legacy-runtime-bundles\-` 아래에 새 출력 디렉터리를 만든다. 결과물은
+다음 세 파일이다.
+
+```text
+LegacyRuntimeBundle.zip
+LegacyRuntimeBundle.manifest.json
+LegacyRuntimeBundle.zip.sha256
+```
+
+생성기는 전체 `Cuts` tree와 프로젝트가 허용한 비민감 `Res` 파일 34개만 폐쇄형으로 묶는다.
+`MmoneyCoder.ini`와 그 복사본·백업, DB 파일, 자격증명, vendor DLL, 라이선스, 인증서,
+실행 파일과 임의 archive는 거부한다. 따라서 `MmoneyCoder.ini`는 Debug를 포함한 어떤 빌드
+입력·출력이나 runtime bundle에도 필요하지 않으며 넣어서는 안 된다.
+
+ZIP은 승인된 사내 전달 경로로 보내고, `LegacyRuntimeBundle.zip`의 기대 SHA-256은 ZIP 또는
+동봉 `.sha256` 파일과 **다른 신뢰 경로**로 인수자에게 전달한다. 같은 폴더의 해시 파일만 보고
+ZIP을 신뢰하지 않는다.
+
+새 PC의 저장소 루트에서 ZIP을 직접 풀지 말고 다음 초기화 스크립트를 사용한다.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass `
+ -File .\scripts\Initialize-LegacyRuntimeBundle.ps1 `
+ -ZipPath '' `
+ -ExpectedSha256 ''
+```
+
+저장소 루트가 현재 디렉터리가 아니면 검토한 절대 경로를 `-RepositoryRoot`로 추가한다.
+초기화기는 압축을 풀기 전에 별도로 전달받은 ZIP SHA-256을 확인하고, manifest와 각 파일의
+경로·길이·SHA-256, reparse/root 이탈과 금지 파일 부재를 검사한다. 성공한 bundle은 기본적으로
+다음 Git 밖 경로에 설치한다.
+
+```text
+%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\RuntimeBundles\
+```
+
+그 뒤 저장소 루트의 Git 제외 `Directory.Build.local.props`를 만들어
+`LegacyRuntimeAssetsMode=Required`와 검증된 설치 root를 고정한다. 이 파일을 손으로 만들거나
+경로를 환경 변수로 임시 주입하지 않는다. ZIP, 설치 root와 `Directory.Build.local.props`도
+Git에 추가하지 않는다.
+
+초기화기가 출력한 설치 root의 `Cuts`를 대상으로 coverage를 확인한다.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass `
+ -File .\scripts\Test-LegacyCutCoverage.ps1 `
+ -CutRoot '\Cuts'
+```
+
+현재 authoritative 자산에도 다음 영상 14개는 없다.
+
+- `s5006`: `Video\큐브배경.vrv` 1개
+- `s6001`: `Video\20201008_<국가>.vrv` 13개
+
+coverage가 이 14개를 누락으로 보고하는 동안 관련 action은 PREPARE하지 않는다. 다른 scene,
+built-in asset, 빈 파일, root 이탈 또는 reparse 항목이 하나라도 발견되면 인수를 중단한다.
+이 스크립트는 자산이 하나라도 없으면 의도적으로 실패 종료하므로, 결과가 정확히 알려진 14개
+영상 누락뿐인 경우에도 전체 coverage `Passed`로 기록하지 않고 영향 없는 scene만 제한적으로
+인수한다. 누락 영상을 비슷한 파일로 대체하지 않는다.
+
+초기화 뒤에는 별도 MSBuild 경로 인자나 환경 변수 없이 다음 빌드가 성공하는지 확인한다.
+
+```powershell
+dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj `
+ -c Debug `
+ -p:Platform=x64
+```
+
+앱이 소스 전용 모드라고 표시되거나 `bin\SourceOnly`에서 시작되면 Development Live를 진행하지
+않는다. bundle 초기화 결과와 `Directory.Build.local.props`를 고친 뒤 전체 자산 빌드부터 다시
+확인한다.
+
+## 3. DB 로컬 설정
+
+Debug/Release와 Package 앱의 DB 설정은 모두 실행 사용자 전용 경로에 만든다.
+
+```text
+%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json
+```
+
+저장소 루트에서 기존 초기화 스크립트를 사용한다. Oracle이 service name을 쓰는 환경이면
+`-OracleSid` 대신 `-OracleServiceName`을 사용한다.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass `
+ -File .\scripts\Initialize-DatabaseConfig.ps1 `
+ -OracleHost '' `
+ -OraclePort `
+ -OracleSid '' `
+ -OracleUserName '' `
+ -MariaDbHost '' `
+ -MariaDbPort `
+ -MariaDbDatabase '' `
+ -MariaDbUserName ''
+```
+
+비밀번호는 스크립트의 보안 프롬프트에만 입력한다. 생성 파일은 현재 Windows 사용자만 읽고
+수정할 수 있어야 한다. 개발 DB가 아니라 운영 DB를 가리키거나 endpoint가 불명확하면 송출
+검증으로 넘어가지 않는다. DB 연결 확인은 [DB 운영 가이드](DATABASE.md)의 read-only smoke만
+먼저 수행하고, 실패 시 Tornado 명령을 보내지 않는다.
+
+## 4. K3D 설치, 라이선스와 승인 해시 확인
+
+벤더 절차로 Tornado2/K3D x64와 장비 라이선스를 먼저 설치한다. 저장소의 점검은 레지스트리와
+파일을 읽을 뿐 COM을 활성화하지 않는다.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass `
+ -File .\scripts\Inspect-K3DRegistration.ps1
+```
+
+Registry64, TypeLib/CLSID 양방향 매핑, `Apartment`, AMD64 PE, HKCU override 부재 검사가 모두
+성공해야 한다. 점검 성공은 라이선스 성공을 대신하지 않으므로 벤더 방식으로 라이선스도 별도
+확인한다. native와 Interop의 실제 파일은 각각 독립 승인된 SHA-256과 일치해야 하며 둘 중 하나라도
+다르면 중단한다.
+
+## 5. 보호된 로컬 송출 설정
+
+저장소 루트에서 다음 스크립트를 실행해 두 파일을 함께 만든다. port는 이번에 Tornado2 화면에서
+직접 확인한 값이고, 두 해시는 독립 승인값이어야 한다. 기존 승인 프로필이 별도 출력 채널을
+사용할 때만 검증된 숫자를 `-OutputChannel`로 추가하며, 기본 player를 쓰는 경우에는 생략한다.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass `
+ -File .\scripts\Initialize-DevelopmentLiveConfig.ps1 `
+ -PlayoutHost 127.0.0.1 `
+ -PlayoutPort `
+ -NativeSha256 '' `
+ -InteropSha256 ''
+```
+
+스크립트는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config` 아래의 일반 파일 두 개를 원자적으로
+생성하고 현재 Windows 사용자 전용 ACL을 적용한다. LocalAppData 상위 경로에 reparse point가
+있거나 기존 파일이 있으면 중단한다. 기존 파일을 덮어쓸 때는 내용을 별도로 검토하고 대상이
+정확한 경우에만 `-Force`를 명시한다. `-Force` 갱신은 기존 Live 승인 파일을 먼저 무효화하므로
+두 파일 중 하나라도 쓰기에 실패하면 송출 승인이 남지 않는다. 경로·endpoint·해시를 Git이나
+실행 로그에 복사하지 않는다.
+
+### `playout.local.json`
+
+초기화 스크립트가 만든 파일을 읽기 전용으로 검토해 다음 계약과 일치하는지 확인한다.
+[`Config/playout.example.json`](../Config/playout.example.json)은 비밀값 없는 구조
+참고용일 뿐 수동 복사본으로 사용하지 않는다.
+
+| 항목 | 인수 값 |
+|---|---|
+| `mode` | 항상 `DryRun`. Development Live 프로세스만 일시적으로 Live로 덮어쓴다. |
+| `host` | 로컬 PGM이면 숫자형 loopback `127.0.0.1`. `localhost` 또는 원격 주소를 추정하지 않는다. |
+| `port` | Tornado2 화면에서 이번에 직접 확인한 Network Server TCP port |
+| `tcpMode` / `clientPort` | `1` / `0` |
+| `sceneDirectory` | `null`. 검증된 bundle이 빌드 출력에 배치한 기본 `Cuts`를 사용한다. |
+| `outputChannel` | 승인된 개발 PGM 라우팅 값. 기존 승인 프로필이 기본 player를 쓰는 경우에만 `null` |
+| `testSceneAllowlist` | 아래 active alias 45개만 허용 |
+| `trustedLiveOutputEnabled` | `true` |
+| `reconnectEnabled` / `maximumReconnectAttempts` | 인수 회차에서는 `false` / `0` |
+| `maximumAutomaticRefreshesPerTakeIn` | 최초 인수 회차에서는 `0` |
+
+```text
+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
+```
+
+`8010`, `8086`, 임의 파일명과 절대 scene 파일 경로는 allowlist에 추가하지 않는다. 실제 endpoint,
+채널, 경로와 allowlist가 든 이 파일을 Git이나 지원 첨부파일에 넣지 않는다.
+
+### `playout.development-live.local.json`
+
+경로는 다음과 같다.
+
+```text
+%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.development-live.local.json
+```
+
+파일에는 정확히 다음 다섯 속성만 둔다.
+
+```json
+{
+ "schemaVersion": 1,
+ "mode": "Live",
+ "authorization": "I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH",
+ "nativeSha256": "",
+ "interopSha256": ""
+}
+```
+
+주석, trailing comma, 중복·추가 속성을 넣지 않는다. 두 해시와 승인 근거는 Git 밖에 보관한다.
+현재 설치 파일에서 즉석 계산한 값으로 placeholder를 채우지 않는다. 자세한 strict 파일 규칙은
+[Visual Studio Development Live 설정](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)을 따른다.
+
+## 6. 정확한 로컬 PGM 연결 전용 확인
+
+Network Monitoring을 먼저 연다. 독립 승인 해시는 이 진단을 실행할 현재 PowerShell
+프로세스에만 설정한 뒤 실제 port로 connect-only 진단을 한 번 실행한다.
+
+```powershell
+$env:MBN_STOCK_K3D_NATIVE_SHA256 = ''
+$env:MBN_STOCK_K3D_INTEROP_SHA256 = ''
+
+dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
+ -c Debug -p:Platform=x64 -- `
+ --pgm-connect-diagnostic `
+ --i-understand-this-will-contact-current-pgm-tornado-via-ktap-connect-and-disconnect-only `
+ --host 127.0.0.1 `
+ --port `
+ --expected-pgm-window-title PGM
+```
+
+이 진단은 KTAP `Connect → Disconnect`만 수행하고 render API를 노출하지 않지만 PGM 제어
+세션에는 접촉한다. 성공 결과의 `completed: true`, `outcomeUnknown: false`,
+`renderCommandSurfaceExposed: false`, `renderCommandAttempted: false`와 같은 시각의 Network
+Monitoring `HELLO` 성공을 사람이 함께 확인한다.
+
+timeout, `OutcomeUnknown`, 대상 PID·시작 시각·listener 변경 또는 결과 불일치가 생기면 같은
+진단을 반복하지 않는다. 새 명령을 보내지 말고 PGM과 Network Monitoring 상태를 읽기 전용으로
+확인한 뒤 새 인수 회차를 계획한다.
+
+## 7. 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,
+ 시작 인자와 두 로컬 파일을 고친 뒤 앱을 새로 시작한다.
+5. 최초 인수는 자산이 완전한 사전 합의 scene 하나만 사용한다. 기본 최소 회차는 active alias
+ `5001`의 `CONNECT 1회 → PREPARE 1회 → 화면·Network Monitoring 확인 → TAKE IN 1회
+ → 화면 확인 → TAKE OUT 1회 → DISCONNECT 1회`다.
+6. PREPARE 성공과 PGM 상태를 확인하기 전에는 TAKE IN을 누르지 않는다. NEXT, Page NEXT,
+ timer refresh, 다른 scene은 최초 최소 회차에 추가하지 않는다.
+7. TAKE OUT과 DISCONNECT 결과가 명확히 성공한 뒤 앱을 정상 종료한다.
+
+각 단계에서 앱 결과, lifecycle callback, PGM 화면과 Network Monitoring 기록이 서로 맞아야
+한다. timeout, `OutcomeUnknown`, callback 누락, 화면 불일치, 대상 identity 변경 또는 license/hash/
+allowlist 실패가 발생하면 다음 동작, 같은 동작 반복, 반대 동작이나 추측성 cleanup을 하지 않는다.
+PGM/Tornado2를 강제 종료하지 말고 상태를 보존한다.
+
+## 8. DryRun 복귀와 인수 완료 조건
+
+Live 앱을 종료하면 부트스트랩이 만든 Live 환경은 프로세스와 함께 사라져야 한다.
+`playout.local.json`의 `mode`는 계속 `DryRun`이어야 한다. 다음으로
+`MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)` 프로필을 실행해 실제 KTAP
+통신 없이 UI가 시작되는지 확인한다. Release 검증도 이 DryRun 프로필로만 수행하며
+`--development-live`를 Release Live 수단으로 사용하지 않는다.
+
+다음 항목이 모두 맞아야 인수를 완료한다.
+
+- 지정 개발 PC와 정확한 로컬 PGM 대상 확인
+- 외부 runtime bundle과 허용 scene 검증
+- K3D x64 등록, 장비 라이선스와 두 독립 승인 해시 확인
+- 개발 DB read-only smoke 성공
+- 두 `%LOCALAPPDATA%` 설정 파일의 내용·ACL 검증
+- connect-only 진단과 최소 5001 회차가 retry 0, `OutcomeUnknown=false`로 종료
+- 최종 TAKE OUT/IDLE, DISCONNECT와 Explicit DryRun 재시작 확인
+- 누락 영상 14개 관련 action이 제한 상태임을 인수자에게 고지
+
+실제 endpoint, 자격증명, 해시, 자산 경로, 화면 캡처와 Network Monitoring 증거는 승인된
+Git 밖 인수 기록에만 보관한다.
+
+## Git에 넣지 않는 항목
+
+- `Cuts`, `.t2s`, image, texture, `.vrv` 영상과 방송 자산
+- `LegacyRuntimeBundle.zip`, manifest·해시 파일, 설치된 `RuntimeBundles`와
+ `Directory.Build.local.props`
+- `Res\MmoneyCoder.ini`, `database.local.json`과 DB 자격증명
+- `playout.local.json`, `playout.development-live.local.json`,
+ `runtime-folders.local.json`
+- Tornado/K3D native DLL, Interop DLL, 라이선스 파일·키
+- native/Interop 승인 해시와 승인 근거
+- MSIX 서명 인증서, 개인 키와 암호
+- 실제 host, port, output channel, PGM 정보
+- 실제 출력 캡처, Network Monitoring 화면과 민감 로그
+
+Debug Package 빌드가 로컬 실행을 위해 승인 runtime 자산을 stage하더라도 그 출력,
+`AppPackages` 또는 설치 파일을 소스 저장소나 일반 첨부파일로 올리지 않는다. 고객 배포본으로
+재사용하지도 않는다. Git에는 소스, 비밀값 없는 예제와 이 절차만 유지한다.
diff --git a/docs/LEGACY_RUNTIME_ASSETS.md b/docs/LEGACY_RUNTIME_ASSETS.md
index 2f4cddd..7d3642a 100644
--- a/docs/LEGACY_RUNTIME_ASSETS.md
+++ b/docs/LEGACY_RUNTIME_ASSETS.md
@@ -99,11 +99,9 @@ UI 이미지와 UI용 INI는 allowlist로 출력·게시·MSIX에 배치한다.
CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의 폐쇄형 C# action catalog와
대조한 뒤 UI를 구성한다. INI가 임의의 scene/action을 새로 추가할 수는 없다.
-`MmoneyCoder.ini`는 실제 자격증명을 포함하므로 일반 Visual Studio 개발 출력에만
-`Res\MmoneyCoder.ini`로 복사하고 MSIX Content에는 넣지 않는다. 일반 개발 빌드는 이
-원본 파일이 없으면 중단하지만, MSIX 생성·게시 빌드는 자격증명 파일 없이도 비민감
-자산만으로 수행할 수 있다. `Clean`은 실행 출력에 복사했던 INI를 제거한다. 패키지 실행은
-다음 로컬 overlay를 사용할 수 있다.
+`MmoneyCoder.ini`는 실제 자격증명을 포함하므로 빌드 입력, 출력, 개발 인수용 런타임
+묶음과 MSIX Content에 넣지 않는다. 전체 자산 Debug 빌드도 깨끗한 개발 PC와 같은 다음
+사용자 전용 로컬 overlay 또는 `Config\database.local.json`을 사용한다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini
diff --git a/scripts/Initialize-DevelopmentLiveConfig.ps1 b/scripts/Initialize-DevelopmentLiveConfig.ps1
new file mode 100644
index 0000000..1eb3e9a
--- /dev/null
+++ b/scripts/Initialize-DevelopmentLiveConfig.ps1
@@ -0,0 +1,238 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [ValidateSet('127.0.0.1', '::1')]
+ [string] $PlayoutHost,
+
+ [Parameter(Mandatory)]
+ [ValidateRange(1, 65535)]
+ [int] $PlayoutPort,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9A-Fa-f]{64}$')]
+ [string] $NativeSha256,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9A-Fa-f]{64}$')]
+ [string] $InteropSha256,
+
+ [ValidateRange(0, 2147483647)]
+ [Nullable[int]] $OutputChannel = $null,
+
+ [switch] $Force,
+
+ [string] $ConfigurationDirectory = (
+ Join-Path $env:LOCALAPPDATA 'MBN_STOCK_WEBVIEW\Config')
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$requiredAuthorization = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH'
+$sceneAllowlist = @(
+ '5001', '5006', '5011', '5016', '50160', '5023', '5024', '5025',
+ '5026', '5029', '5032', '5037', '5068', '5070', '5072', '5074',
+ '5076', '5077', '5078', '5079', '5080', '5081', '5082', '5083',
+ '5084', '5085', '5086', '50860', '5087', '5088', '6001', '6067',
+ '8001', '8002', '8003', '8018', '8032', '8035', '8040', '8046',
+ '8051', '8056', '8061', '8067', 'N5001'
+)
+
+function Assert-LocalAppDataPath {
+ param([Parameter(Mandatory)][string] $Path)
+
+ $localRoot = [IO.Path]::GetFullPath(
+ [Environment]::GetFolderPath(
+ [Environment+SpecialFolder]::LocalApplicationData))
+ $fullPath = [IO.Path]::GetFullPath($Path)
+ $rootPrefix = $localRoot.TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
+
+ if (-not $fullPath.StartsWith(
+ $rootPrefix,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Development Live configuration must stay under LocalAppData.'
+ }
+
+ return $fullPath
+}
+
+function Assert-NoReparsePointChain {
+ param(
+ [Parameter(Mandatory)][string] $Directory,
+ [Parameter(Mandatory)][string] $StopDirectory
+ )
+
+ $current = [IO.Path]::GetFullPath($Directory)
+ $stop = [IO.Path]::GetFullPath($StopDirectory)
+ while (-not [string]::Equals(
+ $current,
+ $stop,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ $item = Get-Item -LiteralPath $current -Force
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Development Live configuration directories cannot be reparse points.'
+ }
+
+ $parent = [IO.Path]::GetDirectoryName($current)
+ if ([string]::IsNullOrWhiteSpace($parent) -or
+ [string]::Equals(
+ $parent,
+ $current,
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'The LocalAppData configuration directory chain is invalid.'
+ }
+
+ $current = $parent
+ }
+}
+
+function Assert-WritableTarget {
+ param([Parameter(Mandatory)][string] $Path)
+
+ if (-not (Test-Path -LiteralPath $Path)) {
+ return
+ }
+
+ $item = Get-Item -LiteralPath $Path -Force
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'Development Live configuration targets must be ordinary files.'
+ }
+
+ if (-not $Force) {
+ throw "Configuration already exists. Review it and rerun with -Force: $Path"
+ }
+}
+
+function Set-CurrentUserOnlyAcl {
+ param([Parameter(Mandatory)][string] $Path)
+
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
+ $acl = [Security.AccessControl.FileSecurity]::new()
+ $acl.SetAccessRuleProtection($true, $false)
+ $rule = [Security.AccessControl.FileSystemAccessRule]::new(
+ $identity,
+ [Security.AccessControl.FileSystemRights]::FullControl,
+ [Security.AccessControl.AccessControlType]::Allow)
+ $acl.AddAccessRule($rule)
+ [IO.File]::SetAccessControl($Path, $acl)
+}
+
+function Invalidate-ExistingAuthorization {
+ param([Parameter(Mandatory)][string] $Path)
+
+ if (-not [IO.File]::Exists($Path)) {
+ return
+ }
+
+ $invalidatedPath = (
+ $Path + '.invalidated.' + [Guid]::NewGuid().ToString('N'))
+ [IO.File]::Move($Path, $invalidatedPath)
+ try {
+ Set-CurrentUserOnlyAcl $invalidatedPath
+ [IO.File]::Delete($invalidatedPath)
+ }
+ catch {
+ throw (
+ 'The previous Development Live authorization was invalidated but ' +
+ "could not be removed. Remove this protected file before retrying: $invalidatedPath")
+ }
+
+ if ([IO.File]::Exists($Path)) {
+ throw 'The previous Development Live authorization could not be invalidated.'
+ }
+}
+
+function Write-ProtectedJson {
+ param(
+ [Parameter(Mandatory)][string] $Path,
+ [Parameter(Mandatory)][string] $Json
+ )
+
+ $temporaryPath = "$Path.tmp.$([Guid]::NewGuid().ToString('N'))"
+ try {
+ [IO.File]::WriteAllText(
+ $temporaryPath,
+ $Json,
+ [Text.UTF8Encoding]::new($false))
+ Set-CurrentUserOnlyAcl $temporaryPath
+ Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
+ }
+ finally {
+ if ([IO.File]::Exists($temporaryPath)) {
+ Remove-Item -LiteralPath $temporaryPath -Force
+ }
+ }
+}
+
+$configurationRoot = Assert-LocalAppDataPath $ConfigurationDirectory
+[IO.Directory]::CreateDirectory($configurationRoot) | Out-Null
+$localApplicationData = [IO.Path]::GetFullPath(
+ [Environment]::GetFolderPath(
+ [Environment+SpecialFolder]::LocalApplicationData))
+Assert-NoReparsePointChain $configurationRoot $localApplicationData
+
+$playoutPath = Join-Path $configurationRoot 'playout.local.json'
+$authorizationPath = Join-Path $configurationRoot 'playout.development-live.local.json'
+Assert-WritableTarget $playoutPath
+Assert-WritableTarget $authorizationPath
+
+$playout = [ordered]@{
+ mode = 'DryRun'
+ host = $PlayoutHost
+ port = $PlayoutPort
+ tcpMode = 1
+ clientPort = 0
+ sceneDirectory = $null
+ outputChannel = $OutputChannel
+ layoutIndex = 10
+ legacySceneFadeDuration = 6
+ legacySceneBackgroundKind = 'None'
+ legacySceneBackgroundAssetPath = $null
+ legacySceneBackgroundVideoLoopCount = 2004
+ legacySceneBackgroundVideoLoopInfinite = $true
+ legacyBackgroundDirectory = $null
+ testProcessWindowTitlePattern = $null
+ testSceneAllowlist = $sceneAllowlist
+ trustedLiveOutputEnabled = $true
+ queueCapacity = 64
+ connectTimeoutMilliseconds = 5000
+ operationTimeoutMilliseconds = 5000
+ disconnectTimeoutMilliseconds = 3000
+ processPollIntervalMilliseconds = 1000
+ reconnectDelayMilliseconds = 1000
+ maximumReconnectAttempts = 0
+ reconnectEnabled = $false
+ maximumAutomaticRefreshesPerTakeIn = 0
+}
+
+$authorization = [ordered]@{
+ schemaVersion = 1
+ mode = 'Live'
+ authorization = $requiredAuthorization
+ nativeSha256 = $NativeSha256.ToUpperInvariant()
+ interopSha256 = $InteropSha256.ToUpperInvariant()
+}
+
+$playoutJson = $playout | ConvertTo-Json -Depth 6
+$authorizationJson = $authorization | ConvertTo-Json -Depth 3
+
+# In Force mode, invalidate the old authorization before changing the DryRun
+# base. Any later failure therefore leaves no valid authorization at the exact
+# path consumed by the Debug-only bootstrap.
+Invalidate-ExistingAuthorization -Path $authorizationPath
+
+# Write the safe DryRun base first and the new one-launch authorization last.
+# Merely creating these files does not connect: Debug, the exact
+# --development-live argument, vendor hash verification and runtime gates remain
+# mandatory in the application.
+Write-ProtectedJson $playoutPath $playoutJson
+Write-ProtectedJson $authorizationPath $authorizationJson
+
+Write-Host "Development Live base configuration created at: $playoutPath"
+Write-Host "Development Live launch authorization created at: $authorizationPath"
+Write-Warning (
+ 'The base file remains DryRun. Use only Debug|x64 with the exact ' +
+ 'Development Live (Package) profile after confirming the local development PGM target.')
diff --git a/scripts/Initialize-LegacyRuntimeBundle.ps1 b/scripts/Initialize-LegacyRuntimeBundle.ps1
new file mode 100644
index 0000000..1d45148
--- /dev/null
+++ b/scripts/Initialize-LegacyRuntimeBundle.ps1
@@ -0,0 +1,1018 @@
+#Requires -Version 5.1
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $ZipPath,
+
+ [Parameter(Mandatory = $true)]
+ [string] $ExpectedSha256,
+
+ [Parameter()]
+ [string] $RepositoryRoot
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$manifestFileName = 'LegacyRuntimeBundle.manifest.json'
+$expectedResAssetCount = 34
+$maximumEntryCount = 10000
+$maximumManifestLength = 4MB
+$maximumFileLength = 1GB
+$maximumPayloadLength = 2GB
+
+function Get-NormalizedFullPath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path
+ )
+
+ return [IO.Path]::GetFullPath($Path)
+}
+
+function Test-PathIsWithin {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Candidate
+ )
+
+ $normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)
+ $normalizedCandidate = Get-NormalizedFullPath -Path $Candidate
+ $prefix = $normalizedRoot + [IO.Path]::DirectorySeparatorChar
+ return $normalizedCandidate.StartsWith(
+ $prefix,
+ [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Assert-NoReparsePointInExistingAncestry {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Description
+ )
+
+ $current = Get-NormalizedFullPath -Path $Path
+ while (-not [string]::IsNullOrEmpty($current)) {
+ if (Test-Path -LiteralPath $current) {
+ $attributes = [IO.File]::GetAttributes($current)
+ if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "$Description traverses a reparse point: $current"
+ }
+ }
+
+ $trimmed = $current.TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)
+ $parent = [IO.Path]::GetDirectoryName($trimmed)
+ if ([string]::IsNullOrEmpty($parent) -or
+ $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
+ break
+ }
+
+ $current = $parent
+ }
+}
+
+function Assert-NoReparsePointInTree {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Description
+ )
+
+ Assert-NoReparsePointInExistingAncestry -Path $Root -Description $Description
+ $pending = New-Object 'System.Collections.Generic.Queue[string]'
+ $pending.Enqueue((Get-NormalizedFullPath -Path $Root))
+ while ($pending.Count -gt 0) {
+ $directory = $pending.Dequeue()
+ foreach ($child in @(Get-ChildItem -LiteralPath $directory -Force)) {
+ if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "$Description contains a reparse point: $($child.FullName)"
+ }
+ if ($child.PSIsContainer) {
+ $pending.Enqueue($child.FullName)
+ }
+ }
+ }
+}
+
+function Assert-SafePayloadRelativePath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $RelativePath
+ )
+
+ $portablePath = $RelativePath.Replace('\', '/')
+ $segments = @($portablePath.Split('/'))
+ $unsafeSegments = @($segments | Where-Object {
+ [string]::IsNullOrWhiteSpace($_) -or $_ -eq '.' -or $_ -eq '..'
+ })
+ if ($segments.Count -lt 2 -or $unsafeSegments.Count -gt 0) {
+ throw "Unsafe runtime payload path: $RelativePath"
+ }
+
+ foreach ($segment in $segments) {
+ if ($segment -match '(?i)^(backup|backups|database|databases|license|licenses|cert|certs|credential|credentials|secret|secrets|vendor)$') {
+ throw "Forbidden runtime payload directory or file name: $RelativePath"
+ }
+ }
+
+ $leafName = $segments[$segments.Count - 1]
+ if (-not $leafName.Equals('MmoneyCoder.ico', [StringComparison]::OrdinalIgnoreCase) -and
+ $leafName -match '(?i)^MmoneyCoder(?:$|[ ._-])') {
+ throw "Credential-bearing or archived MmoneyCoder material is forbidden: $RelativePath"
+ }
+
+ if ($leafName.Equals('afiedt.buf.txt', [StringComparison]::OrdinalIgnoreCase) -or
+ $leafName -match '(?i)(?:\.bak|\.backup|\.old|\.orig|\.save|\.tmp|\.temp|~)$' -or
+ $leafName -match '(?i)(?:^|[ ._-])(backup|copy|\xBCF5\xC0AC\xBCF8)(?:[ ._-]|$)') {
+ throw "Backup or temporary material is forbidden: $RelativePath"
+ }
+
+ $extension = [IO.Path]::GetExtension($leafName)
+ $forbiddenExtensions = @(
+ '.dll', '.ocx', '.tlb',
+ '.lic', '.license',
+ '.pfx', '.p12', '.snk', '.cer', '.crt', '.key', '.pem', '.p7b', '.p7c',
+ '.db', '.sqlite', '.sqlite3', '.mdb', '.accdb', '.mdf', '.ldf', '.sql',
+ '.zip', '.7z', '.rar',
+ '.exe', '.com', '.msi'
+ )
+ if ($forbiddenExtensions -icontains $extension) {
+ throw "Forbidden database, vendor, license, certificate, archive, or executable file: $RelativePath"
+ }
+
+ if ($leafName -match '(?i)^(database|credentials?|secrets?|connectionstrings?)(?:[._-]|$)') {
+ throw "Credential or database configuration is forbidden: $RelativePath"
+ }
+}
+
+function Assert-SafeArchiveEntryPath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $EntryPath,
+
+ [Parameter(Mandatory = $true)]
+ [bool] $IsDirectory
+ )
+
+ if ([string]::IsNullOrWhiteSpace($EntryPath) -or
+ $EntryPath.IndexOf([char] 0) -ge 0 -or
+ $EntryPath.Contains('\') -or
+ $EntryPath.StartsWith('/', [StringComparison]::Ordinal) -or
+ $EntryPath -match '^[A-Za-z]:' -or
+ $EntryPath.Contains(':')) {
+ throw "Unsafe archive entry path: $EntryPath"
+ }
+ if (-not $EntryPath.Equals(
+ $EntryPath.Normalize([Text.NormalizationForm]::FormC),
+ [StringComparison]::Ordinal)) {
+ throw "Archive entry path must use Unicode NFC normalization: $EntryPath"
+ }
+
+ if ($IsDirectory) {
+ if (-not $EntryPath.EndsWith('/', [StringComparison]::Ordinal)) {
+ throw "Archive directory entry is not canonical: $EntryPath"
+ }
+ $candidate = $EntryPath.Substring(0, $EntryPath.Length - 1)
+ }
+ else {
+ if ($EntryPath.EndsWith('/', [StringComparison]::Ordinal)) {
+ throw "Archive file entry is not canonical: $EntryPath"
+ }
+ $candidate = $EntryPath
+ }
+
+ $segments = @($candidate.Split('/'))
+ if ($segments.Count -eq 0) {
+ throw "Unsafe archive entry path: $EntryPath"
+ }
+
+ $invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars()
+ foreach ($segment in $segments) {
+ if ([string]::IsNullOrWhiteSpace($segment) -or
+ $segment -eq '.' -or
+ $segment -eq '..' -or
+ $segment.EndsWith(' ', [StringComparison]::Ordinal) -or
+ $segment.EndsWith('.', [StringComparison]::Ordinal) -or
+ $segment.IndexOfAny($invalidFileNameCharacters) -ge 0) {
+ throw "Unsafe archive entry path: $EntryPath"
+ }
+
+ $deviceStem = $segment.Split('.')[0]
+ if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
+ throw "Archive entry uses a reserved Windows file name: $EntryPath"
+ }
+ }
+
+ return $candidate
+}
+
+function Test-ArchiveEntryIsReparsePoint {
+ param(
+ [Parameter(Mandatory = $true)]
+ [IO.Compression.ZipArchiveEntry] $Entry
+ )
+
+ $externalAttributes = [BitConverter]::ToUInt32(
+ [BitConverter]::GetBytes([int] $Entry.ExternalAttributes),
+ 0)
+ $dosAttributes = $externalAttributes -band [uint32] 0x0000FFFF
+ $unixFileType = ($externalAttributes -shr 16) -band [uint32] 0x0000F000
+ return (($dosAttributes -band [uint32] [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $unixFileType -eq [uint32] 0x0000A000)
+}
+
+function Get-LegacyPackagedResAssetNames {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $ProjectPath
+ )
+
+ if (-not (Test-Path -LiteralPath $ProjectPath -PathType Leaf)) {
+ throw "Legacy parity project was not found: $ProjectPath"
+ }
+
+ [xml] $projectXml = Get-Content -LiteralPath $ProjectPath -Raw -Encoding UTF8
+ $nodes = @($projectXml.SelectNodes(
+ "/*[local-name()='Project']/*[local-name()='ItemGroup']/*[local-name()='LegacyPackagedResAsset']"))
+ if ($nodes.Count -ne $expectedResAssetCount) {
+ throw ("The project must declare exactly {0} LegacyPackagedResAsset items; found {1}." -f
+ $expectedResAssetCount,
+ $nodes.Count)
+ }
+
+ $prefix = '$(LegacyResSourceRoot)\'
+ $names = New-Object 'System.Collections.Generic.List[string]'
+ $seen = New-Object 'System.Collections.Generic.HashSet[string]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ foreach ($node in $nodes) {
+ $include = [string] $node.Include
+ if (-not $include.StartsWith($prefix, [StringComparison]::Ordinal)) {
+ throw "LegacyPackagedResAsset is outside the closed Res projection: $include"
+ }
+
+ $name = $include.Substring($prefix.Length)
+ if ([string]::IsNullOrWhiteSpace($name) -or
+ $name.IndexOfAny(@(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)) -ge 0 -or
+ $name -eq '.' -or
+ $name -eq '..' -or
+ [IO.Path]::GetFileName($name) -ne $name) {
+ throw "LegacyPackagedResAsset must be a single Res file name: $include"
+ }
+
+ Assert-SafePayloadRelativePath -RelativePath ('Res/' + $name)
+ if (-not $seen.Add($name)) {
+ throw "Duplicate LegacyPackagedResAsset declaration: $name"
+ }
+
+ $names.Add($name)
+ }
+
+ return @($names.ToArray() | Sort-Object)
+}
+
+function Convert-BytesToUpperHex {
+ param(
+ [Parameter(Mandatory = $true)]
+ [byte[]] $Bytes
+ )
+
+ return ([BitConverter]::ToString($Bytes)).Replace('-', '')
+}
+
+function Copy-ArchiveEntryToNewFile {
+ param(
+ [Parameter(Mandatory = $true)]
+ [IO.Compression.ZipArchiveEntry] $Entry,
+
+ [Parameter(Mandatory = $true)]
+ [string] $DestinationPath
+ )
+
+ $entryStream = $Entry.Open()
+ $destinationStream = New-Object IO.FileStream(
+ $DestinationPath,
+ [IO.FileMode]::CreateNew,
+ [IO.FileAccess]::Write,
+ [IO.FileShare]::None)
+ try {
+ $buffer = New-Object byte[] 81920
+ $written = [long] 0
+ while (($read = $entryStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
+ if ($written -gt ([long] $Entry.Length - [long] $read)) {
+ throw "Archive entry expanded beyond its declared length: $($Entry.FullName)"
+ }
+ $destinationStream.Write($buffer, 0, $read)
+ $written += [long] $read
+ }
+ if ($written -ne [long] $Entry.Length) {
+ throw "Archive entry length changed during extraction: $($Entry.FullName)"
+ }
+ $destinationStream.Flush()
+ }
+ finally {
+ $destinationStream.Dispose()
+ $entryStream.Dispose()
+ }
+}
+
+function Get-RequiredJsonProperty {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object] $Object,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Name
+ )
+
+ $property = $Object.PSObject.Properties[$Name]
+ if ($null -eq $property) {
+ throw "Bundle manifest is missing required property: $Name"
+ }
+
+ return $property.Value
+}
+
+function ConvertTo-ManifestInteger {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object] $Value,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Name
+ )
+
+ if (-not ($Value -is [byte] -or
+ $Value -is [int16] -or
+ $Value -is [int32] -or
+ $Value -is [int64])) {
+ throw "Bundle manifest property must be an integer: $Name"
+ }
+
+ $result = [long] $Value
+ if ($result -lt 0) {
+ throw "Bundle manifest property cannot be negative: $Name"
+ }
+ return $result
+}
+
+function Assert-RepositorySupportsLocalProps {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root
+ )
+
+ $gitIgnorePath = Join-Path $Root '.gitignore'
+ $directoryBuildPropsPath = Join-Path $Root 'Directory.Build.props'
+ if (-not (Test-Path -LiteralPath $gitIgnorePath -PathType Leaf)) {
+ throw "Repository .gitignore was not found: $gitIgnorePath"
+ }
+ if (-not (Test-Path -LiteralPath $directoryBuildPropsPath -PathType Leaf)) {
+ throw "Directory.Build.props was not found: $directoryBuildPropsPath"
+ }
+
+ $ignoreLines = @([IO.File]::ReadAllLines($gitIgnorePath))
+ $hasLocalPropsIgnore = $false
+ foreach ($line in $ignoreLines) {
+ $trimmed = $line.Trim()
+ if ($trimmed -match '^/?Directory\.Build\.local\.props$') {
+ $hasLocalPropsIgnore = $true
+ }
+ if ($trimmed -match '^!/?Directory\.Build\.local\.props$') {
+ throw 'Directory.Build.local.props is explicitly unignored by .gitignore.'
+ }
+ }
+ if (-not $hasLocalPropsIgnore) {
+ throw 'Directory.Build.local.props must be ignored before runtime initialization.'
+ }
+
+ [xml] $directoryBuildPropsXml = Get-Content `
+ -LiteralPath $directoryBuildPropsPath `
+ -Raw `
+ -Encoding UTF8
+ $imports = @($directoryBuildPropsXml.SelectNodes(
+ "/*[local-name()='Project']/*[local-name()='Import']"))
+ $matchingImports = @($imports | Where-Object {
+ $projectAttribute = [string] $_.Project
+ $projectAttribute.Replace('\', '/').EndsWith(
+ 'Directory.Build.local.props',
+ [StringComparison]::OrdinalIgnoreCase)
+ })
+ if ($matchingImports.Count -eq 0) {
+ throw 'Directory.Build.props must conditionally import Directory.Build.local.props.'
+ }
+}
+
+function Assert-InstalledBundle {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $InstalledRoot,
+
+ [Parameter(Mandatory = $true)]
+ [System.Collections.Generic.Dictionary[string, object]] $ManifestRecords,
+
+ [Parameter(Mandatory = $true)]
+ [string] $ExpectedManifestHash
+ )
+
+ if (-not (Test-Path -LiteralPath $InstalledRoot -PathType Container)) {
+ throw "Installed runtime bundle directory was not found: $InstalledRoot"
+ }
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $InstalledRoot `
+ -Description 'Installed runtime bundle'
+
+ $expectedPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ foreach ($manifestPath in $ManifestRecords.Keys) {
+ $expectedPaths.Add($manifestPath) | Out-Null
+ }
+ $expectedPaths.Add($manifestFileName) | Out-Null
+
+ $actualPaths = New-Object 'System.Collections.Generic.HashSet[string]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ $pending = New-Object 'System.Collections.Generic.Queue[string]'
+ $pending.Enqueue($InstalledRoot)
+ while ($pending.Count -gt 0) {
+ $directory = $pending.Dequeue()
+ foreach ($child in @(Get-ChildItem -LiteralPath $directory -Force)) {
+ if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Installed runtime bundle contains a reparse point: $($child.FullName)"
+ }
+
+ if ($child.PSIsContainer) {
+ $pending.Enqueue($child.FullName)
+ continue
+ }
+
+ $relativePath = $child.FullName.Substring(
+ $InstalledRoot.TrimEnd('\').Length + 1).Replace('\', '/')
+ if (-not $actualPaths.Add($relativePath)) {
+ throw "Installed runtime bundle contains a duplicate path: $relativePath"
+ }
+ if (-not $expectedPaths.Contains($relativePath)) {
+ throw "Installed runtime bundle contains an unexpected file: $relativePath"
+ }
+ }
+ }
+
+ if ($actualPaths.Count -ne $expectedPaths.Count) {
+ $missing = @($expectedPaths | Where-Object { -not $actualPaths.Contains($_) })
+ throw "Installed runtime bundle is missing files: $($missing -join ', ')"
+ }
+
+ foreach ($manifestPath in $ManifestRecords.Keys) {
+ $record = $ManifestRecords[$manifestPath]
+ $nativeRelativePath = $manifestPath.Replace(
+ '/',
+ [IO.Path]::DirectorySeparatorChar)
+ $installedFilePath = Join-Path $InstalledRoot $nativeRelativePath
+ $installedFile = Get-Item -LiteralPath $installedFilePath -Force
+ if ([long] $installedFile.Length -ne [long] $record.length) {
+ throw "Installed runtime bundle file length mismatch: $manifestPath"
+ }
+ $actualHash = (Get-FileHash `
+ -LiteralPath $installedFilePath `
+ -Algorithm SHA256).Hash.ToUpperInvariant()
+ if (-not $actualHash.Equals(
+ [string] $record.sha256,
+ [StringComparison]::Ordinal)) {
+ throw "Installed runtime bundle file hash mismatch: $manifestPath"
+ }
+ }
+
+ $installedManifestPath = Join-Path $InstalledRoot $manifestFileName
+ $actualManifestHash = (Get-FileHash `
+ -LiteralPath $installedManifestPath `
+ -Algorithm SHA256).Hash.ToUpperInvariant()
+ if (-not $actualManifestHash.Equals(
+ $ExpectedManifestHash,
+ [StringComparison]::Ordinal)) {
+ throw 'Installed runtime bundle manifest does not match the verified archive manifest.'
+ }
+}
+
+function Ensure-LocalBuildProps {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root,
+
+ [Parameter(Mandatory = $true)]
+ [string] $InstalledRoot
+ )
+
+ $propsPath = Join-Path $Root 'Directory.Build.local.props'
+ if (Test-Path -LiteralPath $propsPath) {
+ if (-not (Test-Path -LiteralPath $propsPath -PathType Leaf)) {
+ throw "Local build props path is not a regular file: $propsPath"
+ }
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $propsPath `
+ -Description 'Local build props'
+ [xml] $existingXml = Get-Content -LiteralPath $propsPath -Raw -Encoding UTF8
+ $projectNodes = @($existingXml.SelectNodes(
+ "/*[local-name()='Project']"))
+ $propertyGroups = @($existingXml.SelectNodes(
+ "/*[local-name()='Project']/*[local-name()='PropertyGroup']"))
+ $rootNodes = @($existingXml.SelectNodes(
+ "/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeSourceRoot']"))
+ $modeNodes = @($existingXml.SelectNodes(
+ "/*[local-name()='Project']/*[local-name()='PropertyGroup']/*[local-name()='LegacyRuntimeAssetsMode']"))
+ if ($projectNodes.Count -ne 1 -or
+ $projectNodes[0].Attributes.Count -ne 0 -or
+ @($projectNodes[0].ChildNodes | Where-Object {
+ $_.NodeType -eq [Xml.XmlNodeType]::Element
+ }).Count -ne 1 -or
+ $propertyGroups.Count -ne 1 -or
+ $rootNodes.Count -ne 1 -or
+ $modeNodes.Count -ne 1 -or
+ $propertyGroups[0].Attributes.Count -ne 0 -or
+ @($propertyGroups[0].ChildNodes | Where-Object {
+ $_.NodeType -eq [Xml.XmlNodeType]::Element
+ }).Count -ne 2 -or
+ $rootNodes[0].Attributes.Count -ne 0 -or
+ $modeNodes[0].Attributes.Count -ne 0) {
+ throw 'Existing Directory.Build.local.props is conditional, duplicated, or differs from the generated shape.'
+ }
+
+ $existingRoot = Get-NormalizedFullPath -Path ([string] $rootNodes[0].InnerText)
+ if (-not $existingRoot.Equals(
+ (Get-NormalizedFullPath -Path $InstalledRoot),
+ [StringComparison]::OrdinalIgnoreCase) -or
+ -not ([string] $modeNodes[0].InnerText).Equals(
+ 'Required',
+ [StringComparison]::Ordinal)) {
+ throw 'Existing Directory.Build.local.props selects a different runtime bundle or mode; it was not overwritten.'
+ }
+
+ return $propsPath
+ }
+
+ $temporaryPropsPath = Join-Path $Root (
+ '.Directory.Build.local.props.' + [Guid]::NewGuid().ToString('N') + '.tmp')
+ try {
+ $settings = New-Object Xml.XmlWriterSettings
+ $settings.Encoding = New-Object Text.UTF8Encoding($false)
+ $settings.Indent = $true
+ $settings.NewLineChars = [Environment]::NewLine
+ $settings.NewLineHandling = [Xml.NewLineHandling]::Replace
+ $writer = [Xml.XmlWriter]::Create($temporaryPropsPath, $settings)
+ try {
+ $writer.WriteStartDocument()
+ $writer.WriteStartElement('Project')
+ $writer.WriteStartElement('PropertyGroup')
+ $writer.WriteElementString(
+ 'LegacyRuntimeSourceRoot',
+ (Get-NormalizedFullPath -Path $InstalledRoot))
+ $writer.WriteElementString('LegacyRuntimeAssetsMode', 'Required')
+ $writer.WriteEndElement()
+ $writer.WriteEndElement()
+ $writer.WriteEndDocument()
+ }
+ finally {
+ $writer.Dispose()
+ }
+
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $temporaryPropsPath `
+ -Description 'Temporary local build props'
+ [IO.File]::Move($temporaryPropsPath, $propsPath)
+ }
+ finally {
+ if (Test-Path -LiteralPath $temporaryPropsPath) {
+ Remove-Item -LiteralPath $temporaryPropsPath -Force
+ }
+ }
+
+ return $propsPath
+}
+
+if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
+ $repositoryPath = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
+}
+else {
+ $repositoryPath = Get-NormalizedFullPath -Path $RepositoryRoot
+}
+if (-not (Test-Path -LiteralPath $repositoryPath -PathType Container)) {
+ throw "Repository root was not found: $repositoryPath"
+}
+Assert-NoReparsePointInExistingAncestry `
+ -Path $repositoryPath `
+ -Description 'Repository root'
+Assert-RepositorySupportsLocalProps -Root $repositoryPath
+
+$projectPath = Join-Path $repositoryPath 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj'
+$resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath)
+$allowedResPaths = New-Object 'System.Collections.Generic.Dictionary[string, string]' (
+ [StringComparer]::OrdinalIgnoreCase)
+foreach ($resAssetName in $resAssetNames) {
+ $allowedResPaths.Add(('Res/' + $resAssetName), ('Res/' + $resAssetName))
+}
+
+$archivePath = Get-NormalizedFullPath -Path $ZipPath
+if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) {
+ throw "Legacy runtime bundle archive was not found: $archivePath"
+}
+Assert-NoReparsePointInExistingAncestry `
+ -Path $archivePath `
+ -Description 'Legacy runtime bundle archive'
+
+$normalizedExpectedHash = $ExpectedSha256.Trim().ToUpperInvariant()
+if ($normalizedExpectedHash -notmatch '^[0-9A-F]{64}$') {
+ throw 'ExpectedSha256 must be a separately delivered 64-character SHA-256 value.'
+}
+
+Add-Type -AssemblyName System.IO.Compression
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+$archiveStream = [IO.File]::Open(
+ $archivePath,
+ [IO.FileMode]::Open,
+ [IO.FileAccess]::Read,
+ [IO.FileShare]::Read)
+$archive = $null
+$stagingRoot = $null
+try {
+ $sha256 = [Security.Cryptography.SHA256]::Create()
+ try {
+ $actualArchiveHash = Convert-BytesToUpperHex -Bytes (
+ $sha256.ComputeHash($archiveStream))
+ }
+ finally {
+ $sha256.Dispose()
+ }
+ if (-not $actualArchiveHash.Equals(
+ $normalizedExpectedHash,
+ [StringComparison]::Ordinal)) {
+ throw ("Legacy runtime bundle SHA-256 mismatch. Expected {0}; actual {1}." -f
+ $normalizedExpectedHash,
+ $actualArchiveHash)
+ }
+ $archiveStream.Position = 0
+ $archive = New-Object IO.Compression.ZipArchive(
+ $archiveStream,
+ [IO.Compression.ZipArchiveMode]::Read,
+ $true)
+
+ $entries = @($archive.Entries)
+ if ($entries.Count -eq 0 -or $entries.Count -gt $maximumEntryCount) {
+ throw "Legacy runtime bundle has an invalid entry count: $($entries.Count)"
+ }
+
+ $canonicalEntries = New-Object 'System.Collections.Generic.Dictionary[string, bool]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ $payloadEntries = New-Object 'System.Collections.Generic.Dictionary[string, object]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ $directoryEntries = New-Object 'System.Collections.Generic.List[object]'
+ $manifestEntry = $null
+ $totalPayloadLength = [long] 0
+
+ foreach ($entry in $entries) {
+ if (Test-ArchiveEntryIsReparsePoint -Entry $entry) {
+ throw "Archive entry is a symbolic link or reparse point: $($entry.FullName)"
+ }
+
+ $isDirectory = $entry.FullName.EndsWith('/', [StringComparison]::Ordinal)
+ $canonicalPath = Assert-SafeArchiveEntryPath `
+ -EntryPath $entry.FullName `
+ -IsDirectory $isDirectory
+ if ($canonicalEntries.ContainsKey($canonicalPath)) {
+ throw "Archive contains a duplicate or file/directory-colliding path: $canonicalPath"
+ }
+ $canonicalEntries.Add($canonicalPath, $isDirectory)
+
+ if ($isDirectory) {
+ if ([long] $entry.Length -ne 0 -or
+ -not ($canonicalPath.Equals(
+ 'Cuts',
+ [StringComparison]::Ordinal) -or
+ $canonicalPath.Equals(
+ 'Res',
+ [StringComparison]::Ordinal) -or
+ $canonicalPath.StartsWith(
+ 'Cuts/',
+ [StringComparison]::Ordinal))) {
+ throw "Archive contains an unexpected directory: $($entry.FullName)"
+ }
+ $directoryEntries.Add($entry)
+ continue
+ }
+
+ if ([long] $entry.Length -gt $maximumFileLength) {
+ throw "Archive entry exceeds the per-file size limit: $canonicalPath"
+ }
+
+ if ($canonicalPath.Equals(
+ $manifestFileName,
+ [StringComparison]::Ordinal)) {
+ if ($null -ne $manifestEntry) {
+ throw 'Archive contains more than one bundle manifest.'
+ }
+ if ([long] $entry.Length -gt $maximumManifestLength) {
+ throw 'Bundle manifest exceeds the size limit.'
+ }
+ $manifestEntry = $entry
+ continue
+ }
+
+ if ($canonicalPath.StartsWith('Cuts/', [StringComparison]::Ordinal)) {
+ Assert-SafePayloadRelativePath -RelativePath $canonicalPath
+ }
+ elseif ($canonicalPath.StartsWith('Res/', [StringComparison]::Ordinal)) {
+ Assert-SafePayloadRelativePath -RelativePath $canonicalPath
+ if (-not $allowedResPaths.ContainsKey($canonicalPath) -or
+ $allowedResPaths[$canonicalPath] -cne $canonicalPath) {
+ throw "Archive contains a Res file outside the project allowlist: $canonicalPath"
+ }
+ }
+ else {
+ throw "Archive contains a file outside Cuts/Res: $canonicalPath"
+ }
+
+ if ($totalPayloadLength -gt ($maximumPayloadLength - [long] $entry.Length)) {
+ throw 'Archive payload exceeds the total size limit.'
+ }
+ $totalPayloadLength += [long] $entry.Length
+ $payloadEntries.Add($canonicalPath, $entry)
+ }
+
+ foreach ($canonicalPath in $canonicalEntries.Keys) {
+ $segments = @($canonicalPath.Split('/'))
+ for ($segmentIndex = 1; $segmentIndex -lt $segments.Count; $segmentIndex++) {
+ $ancestorPath = ($segments[0..($segmentIndex - 1)] -join '/')
+ if ($canonicalEntries.ContainsKey($ancestorPath) -and
+ -not $canonicalEntries[$ancestorPath]) {
+ throw ("Archive file is also the parent of another entry: {0}" -f
+ $ancestorPath)
+ }
+ }
+ }
+
+ if ($null -eq $manifestEntry) {
+ throw 'Archive does not contain LegacyRuntimeBundle.manifest.json.'
+ }
+
+ $manifestStream = $manifestEntry.Open()
+ $manifestMemory = New-Object IO.MemoryStream
+ try {
+ $manifestStream.CopyTo($manifestMemory)
+ $manifestBytes = $manifestMemory.ToArray()
+ }
+ finally {
+ $manifestMemory.Dispose()
+ $manifestStream.Dispose()
+ }
+ $manifestSha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $manifestHash = Convert-BytesToUpperHex -Bytes (
+ $manifestSha.ComputeHash($manifestBytes))
+ }
+ finally {
+ $manifestSha.Dispose()
+ }
+ $strictUtf8 = New-Object Text.UTF8Encoding($false, $true)
+ $manifestJson = $strictUtf8.GetString($manifestBytes)
+ if ($manifestJson.Length -gt 0 -and $manifestJson[0] -eq [char] 0xFEFF) {
+ $manifestJson = $manifestJson.Substring(1)
+ }
+ try {
+ $manifest = $manifestJson | ConvertFrom-Json
+ }
+ catch {
+ throw "Bundle manifest is not valid UTF-8 JSON: $($_.Exception.Message)"
+ }
+
+ $schemaVersion = ConvertTo-ManifestInteger `
+ -Value (Get-RequiredJsonProperty -Object $manifest -Name 'schemaVersion') `
+ -Name 'schemaVersion'
+ $bundleType = [string] (
+ Get-RequiredJsonProperty -Object $manifest -Name 'bundleType')
+ $manifestFileCount = ConvertTo-ManifestInteger `
+ -Value (Get-RequiredJsonProperty -Object $manifest -Name 'fileCount') `
+ -Name 'fileCount'
+ $manifestCutsFileCount = ConvertTo-ManifestInteger `
+ -Value (Get-RequiredJsonProperty -Object $manifest -Name 'cutsFileCount') `
+ -Name 'cutsFileCount'
+ $manifestResFileCount = ConvertTo-ManifestInteger `
+ -Value (Get-RequiredJsonProperty -Object $manifest -Name 'resFileCount') `
+ -Name 'resFileCount'
+ $manifestFileRecords = @(
+ Get-RequiredJsonProperty -Object $manifest -Name 'files')
+ if ($schemaVersion -ne 1 -or
+ -not $bundleType.Equals(
+ 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle',
+ [StringComparison]::Ordinal)) {
+ throw 'Bundle manifest schema or bundle type is unsupported.'
+ }
+
+ $manifestRecords = New-Object 'System.Collections.Generic.Dictionary[string, object]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ $actualCutsFileCount = 0
+ $actualResFileCount = 0
+ foreach ($record in $manifestFileRecords) {
+ $recordPath = [string] (
+ Get-RequiredJsonProperty -Object $record -Name 'path')
+ $recordLength = ConvertTo-ManifestInteger `
+ -Value (Get-RequiredJsonProperty -Object $record -Name 'length') `
+ -Name ('files[{0}].length' -f $recordPath)
+ $recordHash = [string] (
+ Get-RequiredJsonProperty -Object $record -Name 'sha256')
+ if ($recordHash -notmatch '^[0-9A-Fa-f]{64}$') {
+ throw "Bundle manifest has an invalid file SHA-256: $recordPath"
+ }
+ $recordHash = $recordHash.ToUpperInvariant()
+
+ $canonicalRecordPath = Assert-SafeArchiveEntryPath `
+ -EntryPath $recordPath `
+ -IsDirectory $false
+ if ($canonicalRecordPath -cne $recordPath) {
+ throw "Bundle manifest path is not canonical: $recordPath"
+ }
+ Assert-SafePayloadRelativePath -RelativePath $recordPath
+ if ($manifestRecords.ContainsKey($recordPath)) {
+ throw "Bundle manifest contains a duplicate path: $recordPath"
+ }
+ $manifestRecords.Add(
+ $recordPath,
+ [pscustomobject]@{
+ length = [long] $recordLength
+ sha256 = $recordHash
+ })
+ if (-not $payloadEntries.ContainsKey($recordPath) -or
+ $payloadEntries[$recordPath].FullName -cne $recordPath) {
+ throw "Bundle manifest references a file absent from the archive: $recordPath"
+ }
+ if ([long] $payloadEntries[$recordPath].Length -ne [long] $recordLength) {
+ throw "Bundle manifest length does not match the archive: $recordPath"
+ }
+
+ if ($recordPath.StartsWith('Cuts/', [StringComparison]::Ordinal)) {
+ $actualCutsFileCount++
+ }
+ elseif ($recordPath.StartsWith('Res/', [StringComparison]::Ordinal)) {
+ if (-not $allowedResPaths.ContainsKey($recordPath) -or
+ $allowedResPaths[$recordPath] -cne $recordPath) {
+ throw "Bundle manifest contains a Res file outside the project allowlist: $recordPath"
+ }
+ $actualResFileCount++
+ }
+ else {
+ throw "Bundle manifest contains a file outside Cuts/Res: $recordPath"
+ }
+ }
+
+ if ($manifestRecords.Count -ne $payloadEntries.Count -or
+ $manifestFileCount -ne $manifestRecords.Count -or
+ $manifestCutsFileCount -ne $actualCutsFileCount -or
+ $manifestResFileCount -ne $actualResFileCount -or
+ $actualResFileCount -ne $expectedResAssetCount -or
+ $actualCutsFileCount -le 0) {
+ throw 'Bundle manifest counts or file set do not match the closed archive payload.'
+ }
+ foreach ($payloadPath in $payloadEntries.Keys) {
+ if (-not $manifestRecords.ContainsKey($payloadPath)) {
+ throw "Archive contains a payload file absent from the manifest: $payloadPath"
+ }
+ }
+
+ $knownLocalAppData = [Environment]::GetFolderPath(
+ [Environment+SpecialFolder]::LocalApplicationData)
+ if ([string]::IsNullOrWhiteSpace($knownLocalAppData)) {
+ throw 'The Windows LocalApplicationData known folder is unavailable.'
+ }
+ $localAppDataRoot = Get-NormalizedFullPath -Path $knownLocalAppData
+ $bundleStorageRoot = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\RuntimeBundles'
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $bundleStorageRoot `
+ -Description 'Runtime bundle storage'
+ [IO.Directory]::CreateDirectory($bundleStorageRoot) | Out-Null
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $bundleStorageRoot `
+ -Description 'Runtime bundle storage'
+
+ $installedRoot = Join-Path $bundleStorageRoot $normalizedExpectedHash
+ if (Test-Path -LiteralPath $installedRoot) {
+ Assert-InstalledBundle `
+ -InstalledRoot $installedRoot `
+ -ManifestRecords $manifestRecords `
+ -ExpectedManifestHash $manifestHash
+ }
+ else {
+ $stagingRoot = Join-Path $bundleStorageRoot (
+ '.installing-' + [Guid]::NewGuid().ToString('N'))
+ [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $stagingRoot `
+ -Description 'Runtime bundle staging directory'
+
+ foreach ($directoryEntry in @($directoryEntries.ToArray() | Sort-Object FullName)) {
+ $directoryPath = $directoryEntry.FullName.TrimEnd('/').Replace(
+ '/',
+ [IO.Path]::DirectorySeparatorChar)
+ $destinationDirectory = Join-Path $stagingRoot $directoryPath
+ if (-not (Test-PathIsWithin `
+ -Root $stagingRoot `
+ -Candidate $destinationDirectory)) {
+ throw "Archive directory escaped the staging root: $($directoryEntry.FullName)"
+ }
+ [IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $destinationDirectory `
+ -Description 'Extracted runtime directory'
+ }
+
+ $entriesToExtract = New-Object 'System.Collections.Generic.List[object]'
+ foreach ($payloadEntry in $payloadEntries.Values) {
+ $entriesToExtract.Add($payloadEntry)
+ }
+ $entriesToExtract.Add($manifestEntry)
+ foreach ($entry in @($entriesToExtract.ToArray() | Sort-Object FullName)) {
+ $nativeRelativePath = $entry.FullName.Replace(
+ '/',
+ [IO.Path]::DirectorySeparatorChar)
+ $destinationPath = Join-Path $stagingRoot $nativeRelativePath
+ if (-not (Test-PathIsWithin `
+ -Root $stagingRoot `
+ -Candidate $destinationPath)) {
+ throw "Archive file escaped the staging root: $($entry.FullName)"
+ }
+ $destinationParent = [IO.Path]::GetDirectoryName($destinationPath)
+ [IO.Directory]::CreateDirectory($destinationParent) | Out-Null
+ Assert-NoReparsePointInExistingAncestry `
+ -Path $destinationParent `
+ -Description 'Extracted runtime file parent'
+
+ Copy-ArchiveEntryToNewFile `
+ -Entry $entry `
+ -DestinationPath $destinationPath
+ }
+
+ Assert-InstalledBundle `
+ -InstalledRoot $stagingRoot `
+ -ManifestRecords $manifestRecords `
+ -ExpectedManifestHash $manifestHash
+ try {
+ [IO.Directory]::Move($stagingRoot, $installedRoot)
+ $stagingRoot = $null
+ }
+ catch {
+ if (Test-Path -LiteralPath $installedRoot -PathType Container) {
+ Assert-InstalledBundle `
+ -InstalledRoot $installedRoot `
+ -ManifestRecords $manifestRecords `
+ -ExpectedManifestHash $manifestHash
+ }
+ else {
+ throw
+ }
+ }
+ }
+
+ $localPropsPath = Ensure-LocalBuildProps `
+ -Root $repositoryPath `
+ -InstalledRoot $installedRoot
+
+ [pscustomobject]@{
+ InstalledRoot = $installedRoot
+ ArchivePath = $archivePath
+ ArchiveSha256 = $actualArchiveHash
+ ManifestSha256 = $manifestHash
+ FileCount = [int] $manifestRecords.Count
+ CutsFileCount = [int] $actualCutsFileCount
+ ResFileCount = [int] $actualResFileCount
+ LocalBuildPropsPath = $localPropsPath
+ }
+}
+finally {
+ if ($null -ne $archive) {
+ $archive.Dispose()
+ }
+ $archiveStream.Dispose()
+ if (-not [string]::IsNullOrWhiteSpace($stagingRoot) -and
+ (Test-Path -LiteralPath $stagingRoot)) {
+ if (-not (Test-PathIsWithin `
+ -Root $bundleStorageRoot `
+ -Candidate $stagingRoot) -or
+ -not ([IO.Path]::GetFileName($stagingRoot)).StartsWith(
+ '.installing-',
+ [StringComparison]::Ordinal)) {
+ throw "Refusing to clean an unexpected staging path: $stagingRoot"
+ }
+ Assert-NoReparsePointInTree `
+ -Root $stagingRoot `
+ -Description 'Runtime bundle staging cleanup'
+ Remove-Item -LiteralPath $stagingRoot -Recurse -Force
+ }
+}
diff --git a/scripts/New-LegacyRuntimeBundle.ps1 b/scripts/New-LegacyRuntimeBundle.ps1
new file mode 100644
index 0000000..ac79e4f
--- /dev/null
+++ b/scripts/New-LegacyRuntimeBundle.ps1
@@ -0,0 +1,566 @@
+#Requires -Version 5.1
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $LegacyRuntimeSourceRoot,
+
+ [Parameter()]
+ [string] $OutputDirectory
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$manifestFileName = 'LegacyRuntimeBundle.manifest.json'
+$archiveFileName = 'LegacyRuntimeBundle.zip'
+$archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256'
+$expectedResAssetCount = 34
+
+function Get-NormalizedFullPath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path
+ )
+
+ return [IO.Path]::GetFullPath($Path)
+}
+
+function Test-PathIsWithin {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Candidate
+ )
+
+ $normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)
+ $normalizedCandidate = Get-NormalizedFullPath -Path $Candidate
+ $prefix = $normalizedRoot + [IO.Path]::DirectorySeparatorChar
+ return $normalizedCandidate.StartsWith(
+ $prefix,
+ [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Assert-NoReparsePointInExistingAncestry {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Description
+ )
+
+ $current = Get-NormalizedFullPath -Path $Path
+ while (-not [string]::IsNullOrEmpty($current)) {
+ if (Test-Path -LiteralPath $current) {
+ $attributes = [IO.File]::GetAttributes($current)
+ if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "$Description traverses a reparse point: $current"
+ }
+ }
+
+ $trimmed = $current.TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)
+ $parent = [IO.Path]::GetDirectoryName($trimmed)
+ if ([string]::IsNullOrEmpty($parent) -or
+ $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
+ break
+ }
+
+ $current = $parent
+ }
+}
+
+function Assert-SafePayloadRelativePath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $RelativePath
+ )
+
+ $portablePath = $RelativePath.Replace('\', '/')
+ if (-not $portablePath.Equals(
+ $portablePath.Normalize([Text.NormalizationForm]::FormC),
+ [StringComparison]::Ordinal)) {
+ throw "Runtime payload path must use Unicode NFC normalization: $RelativePath"
+ }
+ $segments = @($portablePath.Split('/'))
+ $unsafeSegments = @($segments | Where-Object {
+ [string]::IsNullOrWhiteSpace($_) -or $_ -eq '.' -or $_ -eq '..'
+ })
+ if ($segments.Count -lt 2 -or $unsafeSegments.Count -gt 0) {
+ throw "Unsafe runtime payload path: $RelativePath"
+ }
+
+ $invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars()
+ foreach ($segment in $segments) {
+ if ($segment.EndsWith(' ', [StringComparison]::Ordinal) -or
+ $segment.EndsWith('.', [StringComparison]::Ordinal) -or
+ $segment.IndexOfAny($invalidFileNameCharacters) -ge 0) {
+ throw "Runtime payload path is not a canonical Windows path: $RelativePath"
+ }
+ $deviceStem = $segment.Split('.')[0]
+ if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
+ throw "Runtime payload uses a reserved Windows file name: $RelativePath"
+ }
+ if ($segment -match '(?i)^(backup|backups|database|databases|license|licenses|cert|certs|credential|credentials|secret|secrets|vendor)$') {
+ throw "Forbidden runtime payload directory or file name: $RelativePath"
+ }
+ }
+
+ $leafName = $segments[$segments.Count - 1]
+ if (-not $leafName.Equals('MmoneyCoder.ico', [StringComparison]::OrdinalIgnoreCase) -and
+ $leafName -match '(?i)^MmoneyCoder(?:$|[ ._-])') {
+ throw "Credential-bearing or archived MmoneyCoder material is forbidden: $RelativePath"
+ }
+
+ if ($leafName.Equals('afiedt.buf.txt', [StringComparison]::OrdinalIgnoreCase) -or
+ $leafName -match '(?i)(?:\.bak|\.backup|\.old|\.orig|\.save|\.tmp|\.temp|~)$' -or
+ $leafName -match '(?i)(?:^|[ ._-])(backup|copy|\xBCF5\xC0AC\xBCF8)(?:[ ._-]|$)') {
+ throw "Backup or temporary material is forbidden: $RelativePath"
+ }
+
+ $extension = [IO.Path]::GetExtension($leafName)
+ $forbiddenExtensions = @(
+ '.dll', '.ocx', '.tlb',
+ '.lic', '.license',
+ '.pfx', '.p12', '.snk', '.cer', '.crt', '.key', '.pem', '.p7b', '.p7c',
+ '.db', '.sqlite', '.sqlite3', '.mdb', '.accdb', '.mdf', '.ldf', '.sql',
+ '.zip', '.7z', '.rar',
+ '.exe', '.com', '.msi'
+ )
+ if ($forbiddenExtensions -icontains $extension) {
+ throw "Forbidden database, vendor, license, certificate, archive, or executable file: $RelativePath"
+ }
+
+ if ($leafName -match '(?i)^(database|credentials?|secrets?|connectionstrings?)(?:[._-]|$)') {
+ throw "Credential or database configuration is forbidden: $RelativePath"
+ }
+}
+
+function Get-LegacyPackagedResAssetNames {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $ProjectPath
+ )
+
+ if (-not (Test-Path -LiteralPath $ProjectPath -PathType Leaf)) {
+ throw "Legacy parity project was not found: $ProjectPath"
+ }
+
+ [xml] $projectXml = Get-Content -LiteralPath $ProjectPath -Raw -Encoding UTF8
+ $nodes = @($projectXml.SelectNodes(
+ "/*[local-name()='Project']/*[local-name()='ItemGroup']/*[local-name()='LegacyPackagedResAsset']"))
+ if ($nodes.Count -ne $expectedResAssetCount) {
+ throw ("The project must declare exactly {0} LegacyPackagedResAsset items; found {1}." -f
+ $expectedResAssetCount,
+ $nodes.Count)
+ }
+
+ $prefix = '$(LegacyResSourceRoot)\'
+ $names = New-Object 'System.Collections.Generic.List[string]'
+ $seen = New-Object 'System.Collections.Generic.HashSet[string]' (
+ [StringComparer]::OrdinalIgnoreCase)
+ foreach ($node in $nodes) {
+ $include = [string] $node.Include
+ if (-not $include.StartsWith($prefix, [StringComparison]::Ordinal)) {
+ throw "LegacyPackagedResAsset is outside the closed Res projection: $include"
+ }
+
+ $name = $include.Substring($prefix.Length)
+ if ([string]::IsNullOrWhiteSpace($name) -or
+ $name.IndexOfAny(@(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)) -ge 0 -or
+ $name -eq '.' -or
+ $name -eq '..' -or
+ [IO.Path]::GetFileName($name) -ne $name) {
+ throw "LegacyPackagedResAsset must be a single Res file name: $include"
+ }
+
+ Assert-SafePayloadRelativePath -RelativePath ('Res/' + $name)
+ if (-not $seen.Add($name)) {
+ throw "Duplicate LegacyPackagedResAsset declaration: $name"
+ }
+
+ $names.Add($name)
+ }
+
+ return @($names.ToArray() | Sort-Object)
+}
+
+function Get-SafeTreeInventory {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root
+ )
+
+ $rootPath = Get-NormalizedFullPath -Path $Root
+ Assert-NoReparsePointInExistingAncestry -Path $rootPath -Description 'Runtime asset tree'
+ $directories = New-Object 'System.Collections.Generic.List[string]'
+ $files = New-Object 'System.Collections.Generic.List[string]'
+ $pending = New-Object 'System.Collections.Generic.Queue[string]'
+ $pending.Enqueue($rootPath)
+
+ while ($pending.Count -gt 0) {
+ $directory = $pending.Dequeue()
+ $children = @(Get-ChildItem -LiteralPath $directory -Force | Sort-Object Name)
+ foreach ($child in $children) {
+ if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Runtime asset tree contains a reparse point: $($child.FullName)"
+ }
+
+ if ($child.PSIsContainer) {
+ $directories.Add($child.FullName)
+ $pending.Enqueue($child.FullName)
+ }
+ else {
+ $files.Add($child.FullName)
+ }
+ }
+ }
+
+ return [pscustomobject]@{
+ Directories = @($directories.ToArray())
+ Files = @($files.ToArray())
+ }
+}
+
+function Get-RelativeChildPath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Root,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Child
+ )
+
+ $rootPath = (Get-NormalizedFullPath -Path $Root).TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)
+ $childPath = Get-NormalizedFullPath -Path $Child
+ if (-not (Test-PathIsWithin -Root $rootPath -Candidate $childPath)) {
+ throw "Path is outside its expected root: $childPath"
+ }
+
+ return $childPath.Substring($rootPath.Length + 1)
+}
+
+function Get-GitRepositoryAncestor {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path
+ )
+
+ $current = Get-NormalizedFullPath -Path $Path
+ while (-not [string]::IsNullOrEmpty($current)) {
+ if (Test-Path -LiteralPath (Join-Path $current '.git')) {
+ return $current
+ }
+ $trimmed = $current.TrimEnd(
+ [IO.Path]::DirectorySeparatorChar,
+ [IO.Path]::AltDirectorySeparatorChar)
+ $parent = [IO.Path]::GetDirectoryName($trimmed)
+ if ([string]::IsNullOrEmpty($parent) -or
+ $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
+ break
+ }
+ $current = $parent
+ }
+
+ return $null
+}
+
+function Copy-VerifiedPayloadFile {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Source,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Destination,
+
+ [Parameter(Mandatory = $true)]
+ [string] $ManifestPath
+ )
+
+ if (([IO.File]::GetAttributes($Source) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw "Runtime payload file is a reparse point: $Source"
+ }
+
+ $destinationParent = [IO.Path]::GetDirectoryName($Destination)
+ if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) {
+ [IO.Directory]::CreateDirectory($destinationParent) | Out-Null
+ }
+
+ [IO.File]::Copy($Source, $Destination, $false)
+ $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash.ToUpperInvariant()
+ $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToUpperInvariant()
+ if (-not $sourceHash.Equals($destinationHash, [StringComparison]::Ordinal)) {
+ throw "Runtime payload changed while it was copied: $ManifestPath"
+ }
+
+ $destinationFile = Get-Item -LiteralPath $Destination -Force
+ return [ordered]@{
+ path = $ManifestPath.Replace('\', '/')
+ length = [long] $destinationFile.Length
+ sha256 = $destinationHash
+ }
+}
+
+$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
+$projectPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj'
+$sourceRoot = Get-NormalizedFullPath -Path $LegacyRuntimeSourceRoot
+$cutsRoot = Join-Path $sourceRoot 'Cuts'
+$resRoot = Join-Path $sourceRoot 'Res'
+
+if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) {
+ throw "Legacy runtime source root was not found: $sourceRoot"
+}
+if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container)) {
+ throw "Legacy Cuts source directory was not found: $cutsRoot"
+}
+if (-not (Test-Path -LiteralPath $resRoot -PathType Container)) {
+ throw "Legacy Res source directory was not found: $resRoot"
+}
+
+Assert-NoReparsePointInExistingAncestry -Path $sourceRoot -Description 'Legacy runtime source root'
+Assert-NoReparsePointInExistingAncestry -Path $cutsRoot -Description 'Legacy Cuts source directory'
+Assert-NoReparsePointInExistingAncestry -Path $resRoot -Description 'Legacy Res source directory'
+
+$resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath)
+$cutsInventory = Get-SafeTreeInventory -Root $cutsRoot
+if (@($cutsInventory.Files).Count -eq 0) {
+ throw "Legacy Cuts source directory contains no files: $cutsRoot"
+}
+
+if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
+ $bundleLeaf = '{0}-{1}' -f
+ (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'),
+ ([Guid]::NewGuid().ToString('N'))
+ $outputRoot = Join-Path $repositoryRoot ('artifacts\legacy-runtime-bundles\' + $bundleLeaf)
+}
+else {
+ $outputRoot = Get-NormalizedFullPath -Path $OutputDirectory
+}
+$outputRoot = Get-NormalizedFullPath -Path $outputRoot
+$legacyRepositoryRoot = Get-GitRepositoryAncestor -Path $sourceRoot
+
+if ($outputRoot.Equals(
+ ([IO.Path]::GetPathRoot($outputRoot)).TrimEnd(
+ [IO.Path]::AltDirectorySeparatorChar),
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "The output directory cannot be a filesystem root: $outputRoot"
+}
+if ((Test-PathIsWithin -Root $sourceRoot -Candidate $outputRoot) -or
+ (Test-PathIsWithin -Root $outputRoot -Candidate $sourceRoot) -or
+ $outputRoot.Equals($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'The bundle output directory must not overlap the read-only runtime source root.'
+}
+if (-not [string]::IsNullOrWhiteSpace($legacyRepositoryRoot) -and
+ ($outputRoot.Equals(
+ $legacyRepositoryRoot,
+ [StringComparison]::OrdinalIgnoreCase) -or
+ (Test-PathIsWithin `
+ -Root $legacyRepositoryRoot `
+ -Candidate $outputRoot))) {
+ throw 'The bundle output directory must not be inside the read-only legacy repository.'
+}
+if (Test-Path -LiteralPath $outputRoot) {
+ throw "The bundle output directory already exists; choose a new directory: $outputRoot"
+}
+
+$outputParent = [IO.Path]::GetDirectoryName($outputRoot)
+Assert-NoReparsePointInExistingAncestry -Path $outputParent -Description 'Bundle output parent'
+[IO.Directory]::CreateDirectory($outputRoot) | Out-Null
+Assert-NoReparsePointInExistingAncestry -Path $outputRoot -Description 'Bundle output directory'
+
+$stagingRoot = Join-Path $outputRoot ('.staging-' + [Guid]::NewGuid().ToString('N'))
+$stagingCreated = $false
+$operationSucceeded = $false
+try {
+ [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null
+ $stagingCreated = $true
+ $stagingCutsRoot = Join-Path $stagingRoot 'Cuts'
+ $stagingResRoot = Join-Path $stagingRoot 'Res'
+ [IO.Directory]::CreateDirectory($stagingCutsRoot) | Out-Null
+ [IO.Directory]::CreateDirectory($stagingResRoot) | Out-Null
+
+ foreach ($sourceDirectory in @($cutsInventory.Directories)) {
+ $relativeDirectory = Get-RelativeChildPath -Root $cutsRoot -Child $sourceDirectory
+ Assert-SafePayloadRelativePath -RelativePath ('Cuts/' + $relativeDirectory.Replace('\', '/'))
+ [IO.Directory]::CreateDirectory(
+ (Join-Path $stagingCutsRoot $relativeDirectory)) | Out-Null
+ }
+
+ $manifestFiles = New-Object 'System.Collections.Generic.List[object]'
+ foreach ($sourceFile in @($cutsInventory.Files | Sort-Object)) {
+ $relativeFile = Get-RelativeChildPath -Root $cutsRoot -Child $sourceFile
+ $portableManifestPath = 'Cuts/' + $relativeFile.Replace('\', '/')
+ Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
+ $destinationFile = Join-Path $stagingCutsRoot $relativeFile
+ $manifestFiles.Add((Copy-VerifiedPayloadFile `
+ -Source $sourceFile `
+ -Destination $destinationFile `
+ -ManifestPath $portableManifestPath))
+ }
+
+ foreach ($resAssetName in $resAssetNames) {
+ $sourceFile = Join-Path $resRoot $resAssetName
+ if (-not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) {
+ throw "Required packaged Res asset was not found: $sourceFile"
+ }
+
+ if (-not (Get-NormalizedFullPath -Path $sourceFile).StartsWith(
+ ((Get-NormalizedFullPath -Path $resRoot).TrimEnd('\') + '\'),
+ [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Packaged Res asset escaped the Res source directory: $resAssetName"
+ }
+
+ $portableManifestPath = 'Res/' + $resAssetName
+ Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
+ $destinationFile = Join-Path $stagingResRoot $resAssetName
+ $manifestFiles.Add((Copy-VerifiedPayloadFile `
+ -Source $sourceFile `
+ -Destination $destinationFile `
+ -ManifestPath $portableManifestPath))
+ }
+
+ $sortedManifestFiles = @($manifestFiles.ToArray() | Sort-Object { $_.path })
+ $cutsFileCount = @($sortedManifestFiles | Where-Object {
+ ([string] $_.path).StartsWith('Cuts/', [StringComparison]::Ordinal)
+ }).Count
+ $resFileCount = @($sortedManifestFiles | Where-Object {
+ ([string] $_.path).StartsWith('Res/', [StringComparison]::Ordinal)
+ }).Count
+ if ($cutsFileCount -ne @($cutsInventory.Files).Count -or
+ $resFileCount -ne $expectedResAssetCount) {
+ throw 'The staged runtime payload does not match the closed Cuts/Res projection.'
+ }
+
+ $manifest = [ordered]@{
+ schemaVersion = 1
+ bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle'
+ fileCount = [int] $sortedManifestFiles.Count
+ cutsFileCount = [int] $cutsFileCount
+ resFileCount = [int] $resFileCount
+ files = $sortedManifestFiles
+ }
+ $stagedManifestPath = Join-Path $stagingRoot $manifestFileName
+ $manifestJson = $manifest | ConvertTo-Json -Depth 6
+ [IO.File]::WriteAllText(
+ $stagedManifestPath,
+ ($manifestJson + [Environment]::NewLine),
+ (New-Object Text.UTF8Encoding($false)))
+
+ Add-Type -AssemblyName System.IO.Compression
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $archivePath = Join-Path $outputRoot $archiveFileName
+ $archiveStream = New-Object IO.FileStream(
+ $archivePath,
+ [IO.FileMode]::CreateNew,
+ [IO.FileAccess]::Write,
+ [IO.FileShare]::None)
+ $archive = $null
+ try {
+ $archive = New-Object IO.Compression.ZipArchive(
+ $archiveStream,
+ [IO.Compression.ZipArchiveMode]::Create,
+ $true)
+
+ $stagedDirectories = @(Get-ChildItem `
+ -LiteralPath $stagingRoot `
+ -Directory `
+ -Recurse `
+ -Force |
+ Sort-Object FullName)
+ foreach ($stagedDirectory in $stagedDirectories) {
+ $relativeDirectory = Get-RelativeChildPath `
+ -Root $stagingRoot `
+ -Child $stagedDirectory.FullName
+ $entryName = $relativeDirectory.Replace('\', '/') + '/'
+ $directoryEntry = $archive.CreateEntry($entryName)
+ $directoryEntry.ExternalAttributes = [int] [IO.FileAttributes]::Directory
+ }
+
+ $stagedFiles = @(Get-ChildItem `
+ -LiteralPath $stagingRoot `
+ -File `
+ -Recurse `
+ -Force |
+ Sort-Object FullName)
+ foreach ($stagedFile in $stagedFiles) {
+ $relativeFile = Get-RelativeChildPath `
+ -Root $stagingRoot `
+ -Child $stagedFile.FullName
+ $entryName = $relativeFile.Replace('\', '/')
+ $fileEntry = $archive.CreateEntry(
+ $entryName,
+ [IO.Compression.CompressionLevel]::Optimal)
+ $sourceStream = [IO.File]::Open(
+ $stagedFile.FullName,
+ [IO.FileMode]::Open,
+ [IO.FileAccess]::Read,
+ [IO.FileShare]::Read)
+ $entryStream = $fileEntry.Open()
+ try {
+ $sourceStream.CopyTo($entryStream)
+ }
+ finally {
+ $entryStream.Dispose()
+ $sourceStream.Dispose()
+ }
+ }
+ }
+ finally {
+ if ($null -ne $archive) {
+ $archive.Dispose()
+ }
+ $archiveStream.Dispose()
+ }
+
+ $externalManifestPath = Join-Path $outputRoot $manifestFileName
+ [IO.File]::Copy($stagedManifestPath, $externalManifestPath, $false)
+ $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant()
+ $archiveHashPath = Join-Path $outputRoot $archiveHashFileName
+ [IO.File]::WriteAllText(
+ $archiveHashPath,
+ ($archiveHash + ' ' + $archiveFileName + [Environment]::NewLine),
+ [Text.Encoding]::ASCII)
+
+ $operationSucceeded = $true
+}
+finally {
+ if ($stagingCreated -and (Test-Path -LiteralPath $stagingRoot)) {
+ if (-not (Test-PathIsWithin -Root $outputRoot -Candidate $stagingRoot) -or
+ -not ([IO.Path]::GetFileName($stagingRoot)).StartsWith(
+ '.staging-',
+ [StringComparison]::Ordinal)) {
+ throw "Refusing to clean an unexpected staging path: $stagingRoot"
+ }
+
+ Get-SafeTreeInventory -Root $stagingRoot | Out-Null
+ Remove-Item -LiteralPath $stagingRoot -Recurse -Force
+ }
+
+ if (-not $operationSucceeded -and (Test-Path -LiteralPath $outputRoot)) {
+ $remainingItems = @(Get-ChildItem -LiteralPath $outputRoot -Force)
+ if ($remainingItems.Count -eq 0) {
+ Remove-Item -LiteralPath $outputRoot -Force
+ }
+ }
+}
+
+[pscustomobject]@{
+ OutputDirectory = $outputRoot
+ ArchivePath = $archivePath
+ ArchiveSha256 = $archiveHash
+ ArchiveHashPath = $archiveHashPath
+ ManifestPath = $externalManifestPath
+ FileCount = [int] $sortedManifestFiles.Count
+ CutsFileCount = [int] $cutsFileCount
+ ResFileCount = [int] $resFileCount
+}
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 f40d562..a21fa9a 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj
@@ -114,8 +114,9 @@
@@ -216,11 +217,6 @@
-
@@ -242,38 +238,11 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs
index 00baef9..bd0a0ad 100644
--- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs
@@ -187,7 +187,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
}
[Fact]
- public void ActiveDatabaseIniIsPostBuildOutputOnlyAndNeverAPackagingItem()
+ public void DatabaseCredentialsStayInUserLocalConfigurationAndNeverEnterBuildOutputs()
{
var project = XDocument.Load(ProjectPath);
Assert.DoesNotContain(
@@ -196,59 +196,49 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
element => (element.Attribute("Include")?.Value ?? string.Empty)
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
- var upToDateInput = Assert.Single(project.Descendants("UpToDateCheckInput"));
- Assert.Equal(
- "$(LegacyResSourceRoot)\\MmoneyCoder.ini",
- upToDateInput.Attribute("Include")?.Value);
- var upToDateBuilt = Assert.Single(project.Descendants("UpToDateCheckBuilt"));
- Assert.Equal(
- "$(TargetDir)Res\\MmoneyCoder.ini",
- upToDateBuilt.Attribute("Include")?.Value);
-
- var copyTarget = Assert.Single(
+ Assert.DoesNotContain(
+ project.Descendants("UpToDateCheckInput"),
+ element => (element.Attribute("Include")?.Value ?? string.Empty)
+ .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(
+ project.Descendants("UpToDateCheckBuilt"),
+ element => (element.Attribute("Include")?.Value ?? string.Empty)
+ .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"CopyLegacyDatabaseIniToLocalOutput",
StringComparison.Ordinal));
- Assert.Equal("Build", copyTarget.Attribute("AfterTargets")?.Value);
- Assert.Contains(
- "GenerateAppxPackageOnBuild",
- copyTarget.Attribute("Condition")?.Value,
- StringComparison.Ordinal);
- Assert.Contains(
- "PublishAppxPackage",
- copyTarget.Attribute("Condition")?.Value,
- StringComparison.Ordinal);
- Assert.Equal(
- "$(LegacyResSourceRoot)\\MmoneyCoder.ini",
- copyTarget.Attribute("Inputs")?.Value);
- Assert.Equal(
- "$(TargetDir)Res\\MmoneyCoder.ini",
- copyTarget.Attribute("Outputs")?.Value);
- var copy = Assert.Single(copyTarget.Elements("Copy"));
- Assert.Equal(
- "$(LegacyResSourceRoot)\\MmoneyCoder.ini",
- copy.Attribute("SourceFiles")?.Value);
- Assert.Equal(
- "$(TargetDir)Res\\MmoneyCoder.ini",
- copy.Attribute("DestinationFiles")?.Value);
- var fileWrite = Assert.Single(copyTarget.Descendants("FileWrites"));
- Assert.Equal(
- "$(TargetDir)Res\\MmoneyCoder.ini",
- fileWrite.Attribute("Include")?.Value);
-
- var cleanTarget = Assert.Single(
+ Assert.DoesNotContain(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"RemoveLegacyDatabaseIniFromLocalOutput",
StringComparison.Ordinal));
- Assert.Equal("Clean", cleanTarget.Attribute("BeforeTargets")?.Value);
- var delete = Assert.Single(cleanTarget.Elements("Delete"));
+
+ var scrubTarget = Assert.Single(
+ project.Descendants("Target"),
+ element => string.Equals(
+ element.Attribute("Name")?.Value,
+ "ScrubLegacyDatabaseIniFromBuildOutput",
+ StringComparison.Ordinal));
+ Assert.Equal("PrepareForBuild", scrubTarget.Attribute("BeforeTargets")?.Value);
+ var scrubDelete = Assert.Single(scrubTarget.Elements("Delete"));
Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini",
- delete.Attribute("Files")?.Value);
+ scrubDelete.Attribute("Files")?.Value);
+
+ var validationTarget = Assert.Single(
+ project.Descendants("Target"),
+ element => string.Equals(
+ element.Attribute("Name")?.Value,
+ "ValidateLegacyRuntimeAssets",
+ StringComparison.Ordinal));
+ Assert.DoesNotContain(
+ validationTarget.Elements("Error"),
+ element => (element.Attribute("Condition")?.Value ?? string.Empty)
+ .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
var rejectionTarget = Assert.Single(
project.Descendants("Target"),
@@ -385,16 +375,12 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
StringComparison.Ordinal);
});
- var databaseCopy = Assert.Single(
+ Assert.DoesNotContain(
project.Descendants("Target"),
element => string.Equals(
element.Attribute("Name")?.Value,
"CopyLegacyDatabaseIniToLocalOutput",
StringComparison.Ordinal));
- Assert.Contains(
- "$(LegacyRuntimeAssetsEnabled)",
- databaseCopy.Attribute("Condition")?.Value,
- StringComparison.Ordinal);
var mainWindow = File.ReadAllText(Path.Combine(
RepositoryRoot,
@@ -447,7 +433,6 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
"$(LegacyResSourceRoot)",
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredScene.Identity).t2s",
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredBuiltInAsset.Identity)",
- "$(LegacyResSourceRoot)\\MmoneyCoder.ini",
"$(LegacyResSourceRoot)\\logo.png",
"$(LegacyResSourceRoot)\\종목.ini",
"$(LegacyResSourceRoot)\\업종_코스피.ini",
@@ -482,17 +467,138 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
Assert.Contains(@"Images\KRX.png", requiredAssets);
Assert.Contains(@"Images\NXT.png", requiredAssets);
- var databaseIniError = Assert.Single(
+ Assert.DoesNotContain(
target.Elements("Error"),
error => (error.Attribute("Condition")?.Value ?? string.Empty)
- .Contains("$(LegacyResSourceRoot)\\MmoneyCoder.ini", StringComparison.Ordinal));
+ .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void DevelopmentLiveHandoffUsesAnIgnoredVerifiedRuntimeRootAndSafeLocalFiles()
+ {
+ var directoryPropsPath = Path.Combine(RepositoryRoot, "Directory.Build.props");
+ var directoryProps = XDocument.Load(directoryPropsPath);
+ var localImport = Assert.Single(directoryProps.Descendants("Import"));
+ Assert.Equal(
+ "$(MSBuildThisFileDirectory)Directory.Build.local.props",
+ localImport.Attribute("Project")?.Value);
Assert.Contains(
- "GenerateAppxPackageOnBuild",
- databaseIniError.Attribute("Condition")?.Value,
+ "Exists(",
+ localImport.Attribute("Condition")?.Value,
+ StringComparison.Ordinal);
+
+ var gitIgnore = File.ReadAllLines(Path.Combine(RepositoryRoot, ".gitignore"));
+ Assert.Contains("Directory.Build.local.props", gitIgnore);
+
+ var initializer = File.ReadAllText(Path.Combine(
+ RepositoryRoot,
+ "scripts",
+ "Initialize-DevelopmentLiveConfig.ps1"));
+ Assert.Contains(
+ "[ValidateSet('127.0.0.1', '::1')]",
+ initializer,
+ StringComparison.Ordinal);
+ Assert.Contains("mode = 'DryRun'", initializer, StringComparison.Ordinal);
+ Assert.Contains(
+ "trustedLiveOutputEnabled = $true",
+ initializer,
StringComparison.Ordinal);
Assert.Contains(
- "PublishAppxPackage",
- databaseIniError.Attribute("Condition")?.Value,
+ "maximumReconnectAttempts = 0",
+ initializer,
+ StringComparison.Ordinal);
+ Assert.Contains("reconnectEnabled = $false", initializer, StringComparison.Ordinal);
+ Assert.Contains(
+ "maximumAutomaticRefreshesPerTakeIn = 0",
+ initializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH",
+ initializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "SetAccessRuleProtection($true, $false)",
+ initializer,
+ StringComparison.Ordinal);
+ var authorizationInvalidation = initializer.IndexOf(
+ "Invalidate-ExistingAuthorization -Path $authorizationPath",
+ StringComparison.Ordinal);
+ var baseConfigurationWrite = initializer.IndexOf(
+ "Write-ProtectedJson $playoutPath",
+ StringComparison.Ordinal);
+ Assert.True(authorizationInvalidation >= 0);
+ Assert.True(baseConfigurationWrite > authorizationInvalidation);
+ Assert.Contains(
+ "Write-ProtectedJson $authorizationPath",
+ initializer,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DevelopmentRuntimeBundleRequiresIndependentHashAndClosedPayloadValidation()
+ {
+ var bundleCreator = File.ReadAllText(Path.Combine(
+ RepositoryRoot,
+ "scripts",
+ "New-LegacyRuntimeBundle.ps1"));
+ var bundleInitializer = File.ReadAllText(Path.Combine(
+ RepositoryRoot,
+ "scripts",
+ "Initialize-LegacyRuntimeBundle.ps1"));
+
+ Assert.Contains("$expectedResAssetCount = 34", bundleCreator, StringComparison.Ordinal);
+ Assert.Contains(
+ "Get-GitRepositoryAncestor -Path $sourceRoot",
+ bundleCreator,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "MmoneyCoder material is forbidden",
+ bundleCreator,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "LegacyRuntimeBundle.zip.sha256",
+ bundleCreator,
+ StringComparison.Ordinal);
+
+ Assert.Contains(
+ "[string] $ExpectedSha256",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "ExpectedSha256 must be a separately delivered",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ var archiveHashValidation = bundleInitializer.IndexOf(
+ "$actualArchiveHash.Equals(",
+ StringComparison.Ordinal);
+ var archiveOpen = bundleInitializer.IndexOf(
+ "New-Object IO.Compression.ZipArchive(",
+ StringComparison.Ordinal);
+ Assert.True(archiveHashValidation >= 0);
+ Assert.True(archiveOpen > archiveHashValidation);
+ Assert.Contains(
+ "Archive entry expanded beyond its declared length",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$actualCutsFileCount -le 0",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "[Environment+SpecialFolder]::LocalApplicationData",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "Get-NormalizedFullPath -Path $env:LOCALAPPDATA",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "Existing Directory.Build.local.props is conditional, duplicated",
+ bundleInitializer,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "Assert-NoReparsePointInTree",
+ bundleInitializer,
StringComparison.Ordinal);
}