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); }