feat: add verified development live handoff

This commit is contained in:
2026-07-24 14:54:57 +09:00
parent 68a41194c9
commit 8b628908f0
11 changed files with 2355 additions and 105 deletions

1
.gitignore vendored
View File

@@ -39,6 +39,7 @@ Config/playout*.local.json
Config/runtime-folders.local.json Config/runtime-folders.local.json
**/runtime-folders.local.json **/runtime-folders.local.json
*.local.ini *.local.ini
Directory.Build.local.props
.env .env
.env.* .env.*
!.env.example !.env.example

View File

@@ -9,4 +9,12 @@
<DebugType>None</DebugType> <DebugType>None</DebugType>
<AppxSymbolPackageEnabled>false</AppxSymbolPackageEnabled> <AppxSymbolPackageEnabled>false</AppxSymbolPackageEnabled>
</PropertyGroup> </PropertyGroup>
<!--
Machine-local development runtime roots belong outside Git. The handoff
initializer writes this optional file after verifying the separately
delivered Cuts/Res bundle and its SHA-256 manifest.
-->
<Import Project="$(MSBuildThisFileDirectory)Directory.Build.local.props"
Condition="Exists('$(MSBuildThisFileDirectory)Directory.Build.local.props')" />
</Project> </Project>

View File

@@ -72,6 +72,13 @@ DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도
`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤 `MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤
[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요. [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로 실행하세요. 이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요.
기존 중복 형식 오류(`CS0121`, `CS0436`)는 루트 앱 프로젝트가 하위 Core 소스까지 다시 컴파일하던 문제였으며, 현재 `src\**\*.cs`를 앱 컴파일 대상에서 제외해 해결했습니다. Visual Studio가 이전 진단을 계속 표시하면 `빌드 > 솔루션 정리` 후 다시 빌드하세요. 기존 중복 형식 오류(`CS0121`, `CS0436`)는 루트 앱 프로젝트가 하위 Core 소스까지 다시 컴파일하던 문제였으며, 현재 `src\**\*.cs`를 앱 컴파일 대상에서 제외해 해결했습니다. Visual Studio가 이전 진단을 계속 표시하면 `빌드 > 솔루션 정리` 후 다시 빌드하세요.
@@ -117,12 +124,12 @@ Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위
## 구성과 보안 ## 구성과 보안
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 원본 자산을 사용하는 일반 원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 전체 자산 Debug 빌드,
Visual Studio 전체 개발 빌드만 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 소스 전용 빌드, 게시와 MSIX 모두 이 자격증명 파일을 빌드 입력이나 출력으로 사용하지 않습니다.
재현합니다. 소스 전용 빌드는 자격증명 없이 DB 미연결 상태로 시작하고, MSIX는 전체 자산 Debug/MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 `database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 환경 변수는 선택된
`database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, 파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은
보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다. [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된 MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된
UI용 `Res` 파일이 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 UI용 `Res` 파일이 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은

View File

@@ -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 2. `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` — MSIX용 로컬 overlay
3. 기존 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` — INI가 없을 때 fallback 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`, `MmoneyCoder.ini``[Oracle]`/`[Maria]` 섹션에서 `ConnectionName=host:port/service`,
`ID`, `Pass`를 읽습니다. BOM 없는 UTF-8과 CP949를 지원하고 환경 변수는 선택된 파일 값보다 `ID`, `Pass`를 읽습니다. BOM 없는 UTF-8과 CP949를 지원하고 환경 변수는 선택된 파일 값보다
우선합니다. 원본 `Res`의 복사본·`.bak`·`.zip`은 사용하지도 배포하지도 않습니다. 우선합니다. 원본 `Res`의 복사본·`.bak`·`.zip`은 사용하지도 배포하지도 않습니다.

View File

@@ -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 '<read-only-MBN_STOCK_N-bin-Debug>' `
-OutputDirectory '<new-empty-bundle-output-directory>'
```
`-OutputDirectory`를 생략하면 Git 제외
`artifacts\legacy-runtime-bundles\<UTC>-<GUID>` 아래에 새 출력 디렉터리를 만든다. 결과물은
다음 세 파일이다.
```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 '<received-LegacyRuntimeBundle.zip>' `
-ExpectedSha256 '<separately-approved-64-hex-zip-sha256>'
```
저장소 루트가 현재 디렉터리가 아니면 검토한 절대 경로를 `-RepositoryRoot`로 추가한다.
초기화기는 압축을 풀기 전에 별도로 전달받은 ZIP SHA-256을 확인하고, manifest와 각 파일의
경로·길이·SHA-256, reparse/root 이탈과 금지 파일 부재를 검사한다. 성공한 bundle은 기본적으로
다음 Git 밖 경로에 설치한다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\RuntimeBundles\<ZIP-SHA256>
```
그 뒤 저장소 루트의 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 '<initialized-runtime-root>\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 '<approved-host>' `
-OraclePort <approved-port> `
-OracleSid '<approved-sid>' `
-OracleUserName '<approved-user>' `
-MariaDbHost '<approved-host>' `
-MariaDbPort <approved-port> `
-MariaDbDatabase '<approved-database>' `
-MariaDbUserName '<approved-user>'
```
비밀번호는 스크립트의 보안 프롬프트에만 입력한다. 생성 파일은 현재 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 <verified-network-server-tcp-port> `
-NativeSha256 '<independently-approved-64-hex-native-sha256>' `
-InteropSha256 '<independently-approved-64-hex-interop-sha256>'
```
스크립트는 `%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": "<independently-approved-64-hex-native-sha256>",
"interopSha256": "<independently-approved-64-hex-interop-sha256>"
}
```
주석, 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 = '<independently-approved-native-sha256>'
$env:MBN_STOCK_K3D_INTEROP_SHA256 = '<independently-approved-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 <verified-network-server-tcp-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에는 소스, 비밀값 없는 예제와 이 절차만 유지한다.

View File

@@ -99,11 +99,9 @@ UI 이미지와 UI용 INI는 allowlist로 출력·게시·MSIX에 배치한다.
CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의 폐쇄형 C# action catalog와 CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의 폐쇄형 C# action catalog와
대조한 뒤 UI를 구성한다. INI가 임의의 scene/action을 새로 추가할 수는 없다. 대조한 뒤 UI를 구성한다. INI가 임의의 scene/action을 새로 추가할 수는 없다.
`MmoneyCoder.ini`는 실제 자격증명을 포함하므로 일반 Visual Studio 개발 출력에만 `MmoneyCoder.ini`는 실제 자격증명을 포함하므로 빌드 입력, 출력, 개발 인수용 런타임
`Res\MmoneyCoder.ini`로 복사하고 MSIX Content에 넣지 않는다. 일반 개발 빌드는 이 묶음과 MSIX Content에 넣지 않는다. 전체 자산 Debug 빌드도 깨끗한 개발 PC와 같은 다음
원본 파일이 없으면 중단하지만, MSIX 생성·게시 빌드는 자격증명 파일 없이도 비민감 사용자 전용 로컬 overlay 또는 `Config\database.local.json`을 사용한다.
자산만으로 수행할 수 있다. `Clean`은 실행 출력에 복사했던 INI를 제거한다. 패키지 실행은
다음 로컬 overlay를 사용할 수 있다.
```text ```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini

View File

@@ -0,0 +1,238 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('127.0.0.1', '::1')]
[string] $PlayoutHost,
[Parameter(Mandatory)]
[ValidateRange(1, 65535)]
[int] $PlayoutPort,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string] $NativeSha256,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string] $InteropSha256,
[ValidateRange(0, 2147483647)]
[Nullable[int]] $OutputChannel = $null,
[switch] $Force,
[string] $ConfigurationDirectory = (
Join-Path $env:LOCALAPPDATA 'MBN_STOCK_WEBVIEW\Config')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$requiredAuthorization = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH'
$sceneAllowlist = @(
'5001', '5006', '5011', '5016', '50160', '5023', '5024', '5025',
'5026', '5029', '5032', '5037', '5068', '5070', '5072', '5074',
'5076', '5077', '5078', '5079', '5080', '5081', '5082', '5083',
'5084', '5085', '5086', '50860', '5087', '5088', '6001', '6067',
'8001', '8002', '8003', '8018', '8032', '8035', '8040', '8046',
'8051', '8056', '8061', '8067', 'N5001'
)
function Assert-LocalAppDataPath {
param([Parameter(Mandatory)][string] $Path)
$localRoot = [IO.Path]::GetFullPath(
[Environment]::GetFolderPath(
[Environment+SpecialFolder]::LocalApplicationData))
$fullPath = [IO.Path]::GetFullPath($Path)
$rootPrefix = $localRoot.TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $fullPath.StartsWith(
$rootPrefix,
[StringComparison]::OrdinalIgnoreCase)) {
throw 'Development Live configuration must stay under LocalAppData.'
}
return $fullPath
}
function Assert-NoReparsePointChain {
param(
[Parameter(Mandatory)][string] $Directory,
[Parameter(Mandatory)][string] $StopDirectory
)
$current = [IO.Path]::GetFullPath($Directory)
$stop = [IO.Path]::GetFullPath($StopDirectory)
while (-not [string]::Equals(
$current,
$stop,
[StringComparison]::OrdinalIgnoreCase)) {
$item = Get-Item -LiteralPath $current -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Development Live configuration directories cannot be reparse points.'
}
$parent = [IO.Path]::GetDirectoryName($current)
if ([string]::IsNullOrWhiteSpace($parent) -or
[string]::Equals(
$parent,
$current,
[StringComparison]::OrdinalIgnoreCase)) {
throw 'The LocalAppData configuration directory chain is invalid.'
}
$current = $parent
}
}
function Assert-WritableTarget {
param([Parameter(Mandatory)][string] $Path)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
$item = Get-Item -LiteralPath $Path -Force
if ($item.PSIsContainer -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Development Live configuration targets must be ordinary files.'
}
if (-not $Force) {
throw "Configuration already exists. Review it and rerun with -Force: $Path"
}
}
function Set-CurrentUserOnlyAcl {
param([Parameter(Mandatory)][string] $Path)
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$acl = [Security.AccessControl.FileSecurity]::new()
$acl.SetAccessRuleProtection($true, $false)
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
$identity,
[Security.AccessControl.FileSystemRights]::FullControl,
[Security.AccessControl.AccessControlType]::Allow)
$acl.AddAccessRule($rule)
[IO.File]::SetAccessControl($Path, $acl)
}
function Invalidate-ExistingAuthorization {
param([Parameter(Mandatory)][string] $Path)
if (-not [IO.File]::Exists($Path)) {
return
}
$invalidatedPath = (
$Path + '.invalidated.' + [Guid]::NewGuid().ToString('N'))
[IO.File]::Move($Path, $invalidatedPath)
try {
Set-CurrentUserOnlyAcl $invalidatedPath
[IO.File]::Delete($invalidatedPath)
}
catch {
throw (
'The previous Development Live authorization was invalidated but ' +
"could not be removed. Remove this protected file before retrying: $invalidatedPath")
}
if ([IO.File]::Exists($Path)) {
throw 'The previous Development Live authorization could not be invalidated.'
}
}
function Write-ProtectedJson {
param(
[Parameter(Mandatory)][string] $Path,
[Parameter(Mandatory)][string] $Json
)
$temporaryPath = "$Path.tmp.$([Guid]::NewGuid().ToString('N'))"
try {
[IO.File]::WriteAllText(
$temporaryPath,
$Json,
[Text.UTF8Encoding]::new($false))
Set-CurrentUserOnlyAcl $temporaryPath
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
}
finally {
if ([IO.File]::Exists($temporaryPath)) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
}
$configurationRoot = Assert-LocalAppDataPath $ConfigurationDirectory
[IO.Directory]::CreateDirectory($configurationRoot) | Out-Null
$localApplicationData = [IO.Path]::GetFullPath(
[Environment]::GetFolderPath(
[Environment+SpecialFolder]::LocalApplicationData))
Assert-NoReparsePointChain $configurationRoot $localApplicationData
$playoutPath = Join-Path $configurationRoot 'playout.local.json'
$authorizationPath = Join-Path $configurationRoot 'playout.development-live.local.json'
Assert-WritableTarget $playoutPath
Assert-WritableTarget $authorizationPath
$playout = [ordered]@{
mode = 'DryRun'
host = $PlayoutHost
port = $PlayoutPort
tcpMode = 1
clientPort = 0
sceneDirectory = $null
outputChannel = $OutputChannel
layoutIndex = 10
legacySceneFadeDuration = 6
legacySceneBackgroundKind = 'None'
legacySceneBackgroundAssetPath = $null
legacySceneBackgroundVideoLoopCount = 2004
legacySceneBackgroundVideoLoopInfinite = $true
legacyBackgroundDirectory = $null
testProcessWindowTitlePattern = $null
testSceneAllowlist = $sceneAllowlist
trustedLiveOutputEnabled = $true
queueCapacity = 64
connectTimeoutMilliseconds = 5000
operationTimeoutMilliseconds = 5000
disconnectTimeoutMilliseconds = 3000
processPollIntervalMilliseconds = 1000
reconnectDelayMilliseconds = 1000
maximumReconnectAttempts = 0
reconnectEnabled = $false
maximumAutomaticRefreshesPerTakeIn = 0
}
$authorization = [ordered]@{
schemaVersion = 1
mode = 'Live'
authorization = $requiredAuthorization
nativeSha256 = $NativeSha256.ToUpperInvariant()
interopSha256 = $InteropSha256.ToUpperInvariant()
}
$playoutJson = $playout | ConvertTo-Json -Depth 6
$authorizationJson = $authorization | ConvertTo-Json -Depth 3
# In Force mode, invalidate the old authorization before changing the DryRun
# base. Any later failure therefore leaves no valid authorization at the exact
# path consumed by the Debug-only bootstrap.
Invalidate-ExistingAuthorization -Path $authorizationPath
# Write the safe DryRun base first and the new one-launch authorization last.
# Merely creating these files does not connect: Debug, the exact
# --development-live argument, vendor hash verification and runtime gates remain
# mandatory in the application.
Write-ProtectedJson $playoutPath $playoutJson
Write-ProtectedJson $authorizationPath $authorizationJson
Write-Host "Development Live base configuration created at: $playoutPath"
Write-Host "Development Live launch authorization created at: $authorizationPath"
Write-Warning (
'The base file remains DryRun. Use only Debug|x64 with the exact ' +
'Development Live (Package) profile after confirming the local development PGM target.')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $LegacyRuntimeSourceRoot,
[Parameter()]
[string] $OutputDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$manifestFileName = 'LegacyRuntimeBundle.manifest.json'
$archiveFileName = 'LegacyRuntimeBundle.zip'
$archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256'
$expectedResAssetCount = 34
function Get-NormalizedFullPath {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
return [IO.Path]::GetFullPath($Path)
}
function Test-PathIsWithin {
param(
[Parameter(Mandatory = $true)]
[string] $Root,
[Parameter(Mandatory = $true)]
[string] $Candidate
)
$normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar)
$normalizedCandidate = Get-NormalizedFullPath -Path $Candidate
$prefix = $normalizedRoot + [IO.Path]::DirectorySeparatorChar
return $normalizedCandidate.StartsWith(
$prefix,
[StringComparison]::OrdinalIgnoreCase)
}
function Assert-NoReparsePointInExistingAncestry {
param(
[Parameter(Mandatory = $true)]
[string] $Path,
[Parameter(Mandatory = $true)]
[string] $Description
)
$current = Get-NormalizedFullPath -Path $Path
while (-not [string]::IsNullOrEmpty($current)) {
if (Test-Path -LiteralPath $current) {
$attributes = [IO.File]::GetAttributes($current)
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Description traverses a reparse point: $current"
}
}
$trimmed = $current.TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar)
$parent = [IO.Path]::GetDirectoryName($trimmed)
if ([string]::IsNullOrEmpty($parent) -or
$parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
break
}
$current = $parent
}
}
function Assert-SafePayloadRelativePath {
param(
[Parameter(Mandatory = $true)]
[string] $RelativePath
)
$portablePath = $RelativePath.Replace('\', '/')
if (-not $portablePath.Equals(
$portablePath.Normalize([Text.NormalizationForm]::FormC),
[StringComparison]::Ordinal)) {
throw "Runtime payload path must use Unicode NFC normalization: $RelativePath"
}
$segments = @($portablePath.Split('/'))
$unsafeSegments = @($segments | Where-Object {
[string]::IsNullOrWhiteSpace($_) -or $_ -eq '.' -or $_ -eq '..'
})
if ($segments.Count -lt 2 -or $unsafeSegments.Count -gt 0) {
throw "Unsafe runtime payload path: $RelativePath"
}
$invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars()
foreach ($segment in $segments) {
if ($segment.EndsWith(' ', [StringComparison]::Ordinal) -or
$segment.EndsWith('.', [StringComparison]::Ordinal) -or
$segment.IndexOfAny($invalidFileNameCharacters) -ge 0) {
throw "Runtime payload path is not a canonical Windows path: $RelativePath"
}
$deviceStem = $segment.Split('.')[0]
if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
throw "Runtime payload uses a reserved Windows file name: $RelativePath"
}
if ($segment -match '(?i)^(backup|backups|database|databases|license|licenses|cert|certs|credential|credentials|secret|secrets|vendor)$') {
throw "Forbidden runtime payload directory or file name: $RelativePath"
}
}
$leafName = $segments[$segments.Count - 1]
if (-not $leafName.Equals('MmoneyCoder.ico', [StringComparison]::OrdinalIgnoreCase) -and
$leafName -match '(?i)^MmoneyCoder(?:$|[ ._-])') {
throw "Credential-bearing or archived MmoneyCoder material is forbidden: $RelativePath"
}
if ($leafName.Equals('afiedt.buf.txt', [StringComparison]::OrdinalIgnoreCase) -or
$leafName -match '(?i)(?:\.bak|\.backup|\.old|\.orig|\.save|\.tmp|\.temp|~)$' -or
$leafName -match '(?i)(?:^|[ ._-])(backup|copy|\xBCF5\xC0AC\xBCF8)(?:[ ._-]|$)') {
throw "Backup or temporary material is forbidden: $RelativePath"
}
$extension = [IO.Path]::GetExtension($leafName)
$forbiddenExtensions = @(
'.dll', '.ocx', '.tlb',
'.lic', '.license',
'.pfx', '.p12', '.snk', '.cer', '.crt', '.key', '.pem', '.p7b', '.p7c',
'.db', '.sqlite', '.sqlite3', '.mdb', '.accdb', '.mdf', '.ldf', '.sql',
'.zip', '.7z', '.rar',
'.exe', '.com', '.msi'
)
if ($forbiddenExtensions -icontains $extension) {
throw "Forbidden database, vendor, license, certificate, archive, or executable file: $RelativePath"
}
if ($leafName -match '(?i)^(database|credentials?|secrets?|connectionstrings?)(?:[._-]|$)') {
throw "Credential or database configuration is forbidden: $RelativePath"
}
}
function Get-LegacyPackagedResAssetNames {
param(
[Parameter(Mandatory = $true)]
[string] $ProjectPath
)
if (-not (Test-Path -LiteralPath $ProjectPath -PathType Leaf)) {
throw "Legacy parity project was not found: $ProjectPath"
}
[xml] $projectXml = Get-Content -LiteralPath $ProjectPath -Raw -Encoding UTF8
$nodes = @($projectXml.SelectNodes(
"/*[local-name()='Project']/*[local-name()='ItemGroup']/*[local-name()='LegacyPackagedResAsset']"))
if ($nodes.Count -ne $expectedResAssetCount) {
throw ("The project must declare exactly {0} LegacyPackagedResAsset items; found {1}." -f
$expectedResAssetCount,
$nodes.Count)
}
$prefix = '$(LegacyResSourceRoot)\'
$names = New-Object 'System.Collections.Generic.List[string]'
$seen = New-Object 'System.Collections.Generic.HashSet[string]' (
[StringComparer]::OrdinalIgnoreCase)
foreach ($node in $nodes) {
$include = [string] $node.Include
if (-not $include.StartsWith($prefix, [StringComparison]::Ordinal)) {
throw "LegacyPackagedResAsset is outside the closed Res projection: $include"
}
$name = $include.Substring($prefix.Length)
if ([string]::IsNullOrWhiteSpace($name) -or
$name.IndexOfAny(@(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar)) -ge 0 -or
$name -eq '.' -or
$name -eq '..' -or
[IO.Path]::GetFileName($name) -ne $name) {
throw "LegacyPackagedResAsset must be a single Res file name: $include"
}
Assert-SafePayloadRelativePath -RelativePath ('Res/' + $name)
if (-not $seen.Add($name)) {
throw "Duplicate LegacyPackagedResAsset declaration: $name"
}
$names.Add($name)
}
return @($names.ToArray() | Sort-Object)
}
function Get-SafeTreeInventory {
param(
[Parameter(Mandatory = $true)]
[string] $Root
)
$rootPath = Get-NormalizedFullPath -Path $Root
Assert-NoReparsePointInExistingAncestry -Path $rootPath -Description 'Runtime asset tree'
$directories = New-Object 'System.Collections.Generic.List[string]'
$files = New-Object 'System.Collections.Generic.List[string]'
$pending = New-Object 'System.Collections.Generic.Queue[string]'
$pending.Enqueue($rootPath)
while ($pending.Count -gt 0) {
$directory = $pending.Dequeue()
$children = @(Get-ChildItem -LiteralPath $directory -Force | Sort-Object Name)
foreach ($child in $children) {
if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Runtime asset tree contains a reparse point: $($child.FullName)"
}
if ($child.PSIsContainer) {
$directories.Add($child.FullName)
$pending.Enqueue($child.FullName)
}
else {
$files.Add($child.FullName)
}
}
}
return [pscustomobject]@{
Directories = @($directories.ToArray())
Files = @($files.ToArray())
}
}
function Get-RelativeChildPath {
param(
[Parameter(Mandatory = $true)]
[string] $Root,
[Parameter(Mandatory = $true)]
[string] $Child
)
$rootPath = (Get-NormalizedFullPath -Path $Root).TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar)
$childPath = Get-NormalizedFullPath -Path $Child
if (-not (Test-PathIsWithin -Root $rootPath -Candidate $childPath)) {
throw "Path is outside its expected root: $childPath"
}
return $childPath.Substring($rootPath.Length + 1)
}
function Get-GitRepositoryAncestor {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
$current = Get-NormalizedFullPath -Path $Path
while (-not [string]::IsNullOrEmpty($current)) {
if (Test-Path -LiteralPath (Join-Path $current '.git')) {
return $current
}
$trimmed = $current.TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar)
$parent = [IO.Path]::GetDirectoryName($trimmed)
if ([string]::IsNullOrEmpty($parent) -or
$parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
break
}
$current = $parent
}
return $null
}
function Copy-VerifiedPayloadFile {
param(
[Parameter(Mandatory = $true)]
[string] $Source,
[Parameter(Mandatory = $true)]
[string] $Destination,
[Parameter(Mandatory = $true)]
[string] $ManifestPath
)
if (([IO.File]::GetAttributes($Source) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Runtime payload file is a reparse point: $Source"
}
$destinationParent = [IO.Path]::GetDirectoryName($Destination)
if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) {
[IO.Directory]::CreateDirectory($destinationParent) | Out-Null
}
[IO.File]::Copy($Source, $Destination, $false)
$sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash.ToUpperInvariant()
$destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToUpperInvariant()
if (-not $sourceHash.Equals($destinationHash, [StringComparison]::Ordinal)) {
throw "Runtime payload changed while it was copied: $ManifestPath"
}
$destinationFile = Get-Item -LiteralPath $Destination -Force
return [ordered]@{
path = $ManifestPath.Replace('\', '/')
length = [long] $destinationFile.Length
sha256 = $destinationHash
}
}
$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
$projectPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj'
$sourceRoot = Get-NormalizedFullPath -Path $LegacyRuntimeSourceRoot
$cutsRoot = Join-Path $sourceRoot 'Cuts'
$resRoot = Join-Path $sourceRoot 'Res'
if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) {
throw "Legacy runtime source root was not found: $sourceRoot"
}
if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container)) {
throw "Legacy Cuts source directory was not found: $cutsRoot"
}
if (-not (Test-Path -LiteralPath $resRoot -PathType Container)) {
throw "Legacy Res source directory was not found: $resRoot"
}
Assert-NoReparsePointInExistingAncestry -Path $sourceRoot -Description 'Legacy runtime source root'
Assert-NoReparsePointInExistingAncestry -Path $cutsRoot -Description 'Legacy Cuts source directory'
Assert-NoReparsePointInExistingAncestry -Path $resRoot -Description 'Legacy Res source directory'
$resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath)
$cutsInventory = Get-SafeTreeInventory -Root $cutsRoot
if (@($cutsInventory.Files).Count -eq 0) {
throw "Legacy Cuts source directory contains no files: $cutsRoot"
}
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$bundleLeaf = '{0}-{1}' -f
(Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'),
([Guid]::NewGuid().ToString('N'))
$outputRoot = Join-Path $repositoryRoot ('artifacts\legacy-runtime-bundles\' + $bundleLeaf)
}
else {
$outputRoot = Get-NormalizedFullPath -Path $OutputDirectory
}
$outputRoot = Get-NormalizedFullPath -Path $outputRoot
$legacyRepositoryRoot = Get-GitRepositoryAncestor -Path $sourceRoot
if ($outputRoot.Equals(
([IO.Path]::GetPathRoot($outputRoot)).TrimEnd(
[IO.Path]::AltDirectorySeparatorChar),
[StringComparison]::OrdinalIgnoreCase)) {
throw "The output directory cannot be a filesystem root: $outputRoot"
}
if ((Test-PathIsWithin -Root $sourceRoot -Candidate $outputRoot) -or
(Test-PathIsWithin -Root $outputRoot -Candidate $sourceRoot) -or
$outputRoot.Equals($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw 'The bundle output directory must not overlap the read-only runtime source root.'
}
if (-not [string]::IsNullOrWhiteSpace($legacyRepositoryRoot) -and
($outputRoot.Equals(
$legacyRepositoryRoot,
[StringComparison]::OrdinalIgnoreCase) -or
(Test-PathIsWithin `
-Root $legacyRepositoryRoot `
-Candidate $outputRoot))) {
throw 'The bundle output directory must not be inside the read-only legacy repository.'
}
if (Test-Path -LiteralPath $outputRoot) {
throw "The bundle output directory already exists; choose a new directory: $outputRoot"
}
$outputParent = [IO.Path]::GetDirectoryName($outputRoot)
Assert-NoReparsePointInExistingAncestry -Path $outputParent -Description 'Bundle output parent'
[IO.Directory]::CreateDirectory($outputRoot) | Out-Null
Assert-NoReparsePointInExistingAncestry -Path $outputRoot -Description 'Bundle output directory'
$stagingRoot = Join-Path $outputRoot ('.staging-' + [Guid]::NewGuid().ToString('N'))
$stagingCreated = $false
$operationSucceeded = $false
try {
[IO.Directory]::CreateDirectory($stagingRoot) | Out-Null
$stagingCreated = $true
$stagingCutsRoot = Join-Path $stagingRoot 'Cuts'
$stagingResRoot = Join-Path $stagingRoot 'Res'
[IO.Directory]::CreateDirectory($stagingCutsRoot) | Out-Null
[IO.Directory]::CreateDirectory($stagingResRoot) | Out-Null
foreach ($sourceDirectory in @($cutsInventory.Directories)) {
$relativeDirectory = Get-RelativeChildPath -Root $cutsRoot -Child $sourceDirectory
Assert-SafePayloadRelativePath -RelativePath ('Cuts/' + $relativeDirectory.Replace('\', '/'))
[IO.Directory]::CreateDirectory(
(Join-Path $stagingCutsRoot $relativeDirectory)) | Out-Null
}
$manifestFiles = New-Object 'System.Collections.Generic.List[object]'
foreach ($sourceFile in @($cutsInventory.Files | Sort-Object)) {
$relativeFile = Get-RelativeChildPath -Root $cutsRoot -Child $sourceFile
$portableManifestPath = 'Cuts/' + $relativeFile.Replace('\', '/')
Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
$destinationFile = Join-Path $stagingCutsRoot $relativeFile
$manifestFiles.Add((Copy-VerifiedPayloadFile `
-Source $sourceFile `
-Destination $destinationFile `
-ManifestPath $portableManifestPath))
}
foreach ($resAssetName in $resAssetNames) {
$sourceFile = Join-Path $resRoot $resAssetName
if (-not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) {
throw "Required packaged Res asset was not found: $sourceFile"
}
if (-not (Get-NormalizedFullPath -Path $sourceFile).StartsWith(
((Get-NormalizedFullPath -Path $resRoot).TrimEnd('\') + '\'),
[StringComparison]::OrdinalIgnoreCase)) {
throw "Packaged Res asset escaped the Res source directory: $resAssetName"
}
$portableManifestPath = 'Res/' + $resAssetName
Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
$destinationFile = Join-Path $stagingResRoot $resAssetName
$manifestFiles.Add((Copy-VerifiedPayloadFile `
-Source $sourceFile `
-Destination $destinationFile `
-ManifestPath $portableManifestPath))
}
$sortedManifestFiles = @($manifestFiles.ToArray() | Sort-Object { $_.path })
$cutsFileCount = @($sortedManifestFiles | Where-Object {
([string] $_.path).StartsWith('Cuts/', [StringComparison]::Ordinal)
}).Count
$resFileCount = @($sortedManifestFiles | Where-Object {
([string] $_.path).StartsWith('Res/', [StringComparison]::Ordinal)
}).Count
if ($cutsFileCount -ne @($cutsInventory.Files).Count -or
$resFileCount -ne $expectedResAssetCount) {
throw 'The staged runtime payload does not match the closed Cuts/Res projection.'
}
$manifest = [ordered]@{
schemaVersion = 1
bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle'
fileCount = [int] $sortedManifestFiles.Count
cutsFileCount = [int] $cutsFileCount
resFileCount = [int] $resFileCount
files = $sortedManifestFiles
}
$stagedManifestPath = Join-Path $stagingRoot $manifestFileName
$manifestJson = $manifest | ConvertTo-Json -Depth 6
[IO.File]::WriteAllText(
$stagedManifestPath,
($manifestJson + [Environment]::NewLine),
(New-Object Text.UTF8Encoding($false)))
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archivePath = Join-Path $outputRoot $archiveFileName
$archiveStream = New-Object IO.FileStream(
$archivePath,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::Write,
[IO.FileShare]::None)
$archive = $null
try {
$archive = New-Object IO.Compression.ZipArchive(
$archiveStream,
[IO.Compression.ZipArchiveMode]::Create,
$true)
$stagedDirectories = @(Get-ChildItem `
-LiteralPath $stagingRoot `
-Directory `
-Recurse `
-Force |
Sort-Object FullName)
foreach ($stagedDirectory in $stagedDirectories) {
$relativeDirectory = Get-RelativeChildPath `
-Root $stagingRoot `
-Child $stagedDirectory.FullName
$entryName = $relativeDirectory.Replace('\', '/') + '/'
$directoryEntry = $archive.CreateEntry($entryName)
$directoryEntry.ExternalAttributes = [int] [IO.FileAttributes]::Directory
}
$stagedFiles = @(Get-ChildItem `
-LiteralPath $stagingRoot `
-File `
-Recurse `
-Force |
Sort-Object FullName)
foreach ($stagedFile in $stagedFiles) {
$relativeFile = Get-RelativeChildPath `
-Root $stagingRoot `
-Child $stagedFile.FullName
$entryName = $relativeFile.Replace('\', '/')
$fileEntry = $archive.CreateEntry(
$entryName,
[IO.Compression.CompressionLevel]::Optimal)
$sourceStream = [IO.File]::Open(
$stagedFile.FullName,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$entryStream = $fileEntry.Open()
try {
$sourceStream.CopyTo($entryStream)
}
finally {
$entryStream.Dispose()
$sourceStream.Dispose()
}
}
}
finally {
if ($null -ne $archive) {
$archive.Dispose()
}
$archiveStream.Dispose()
}
$externalManifestPath = Join-Path $outputRoot $manifestFileName
[IO.File]::Copy($stagedManifestPath, $externalManifestPath, $false)
$archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant()
$archiveHashPath = Join-Path $outputRoot $archiveHashFileName
[IO.File]::WriteAllText(
$archiveHashPath,
($archiveHash + ' ' + $archiveFileName + [Environment]::NewLine),
[Text.Encoding]::ASCII)
$operationSucceeded = $true
}
finally {
if ($stagingCreated -and (Test-Path -LiteralPath $stagingRoot)) {
if (-not (Test-PathIsWithin -Root $outputRoot -Candidate $stagingRoot) -or
-not ([IO.Path]::GetFileName($stagingRoot)).StartsWith(
'.staging-',
[StringComparison]::Ordinal)) {
throw "Refusing to clean an unexpected staging path: $stagingRoot"
}
Get-SafeTreeInventory -Root $stagingRoot | Out-Null
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
if (-not $operationSucceeded -and (Test-Path -LiteralPath $outputRoot)) {
$remainingItems = @(Get-ChildItem -LiteralPath $outputRoot -Force)
if ($remainingItems.Count -eq 0) {
Remove-Item -LiteralPath $outputRoot -Force
}
}
}
[pscustomobject]@{
OutputDirectory = $outputRoot
ArchivePath = $archivePath
ArchiveSha256 = $archiveHash
ArchiveHashPath = $archiveHashPath
ManifestPath = $externalManifestPath
FileCount = [int] $sortedManifestFiles.Count
CutsFileCount = [int] $cutsFileCount
ResFileCount = [int] $resFileCount
}

View File

@@ -114,8 +114,9 @@
<!-- <!--
Same-origin GraphE previews are a second, closed projection of four public Same-origin GraphE previews are a second, closed projection of four public
bitmap assets. Never map the complete Res directory into WebView2: ordinary bitmap assets. Never map the complete Res directory into WebView2: an
local Debug output can also contain credential-bearing MmoneyCoder.ini. operator-selected legacy directory can contain credential-bearing
MmoneyCoder.ini.
--> -->
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\pie.bmp" /> <LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\Grow.bmp" /> <LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
@@ -216,11 +217,6 @@
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and <Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredBuiltInAsset.Identity)')" !Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredBuiltInAsset.Identity)')"
Text="A required built-in legacy scene asset is missing: Cuts\%(LegacyRequiredBuiltInAsset.Identity)." /> Text="A required built-in legacy scene asset is missing: Cuts\%(LegacyRequiredBuiltInAsset.Identity)." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true' and
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
Text="The required legacy database settings file Res\MmoneyCoder.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and <Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
!Exists('$(LegacyResSourceRoot)\종목.ini')" !Exists('$(LegacyResSourceRoot)\종목.ini')"
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." /> Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
@@ -242,38 +238,11 @@
</Target> </Target>
<!-- <!--
Do not declare the credential-bearing INI as Content, None, or another item Database credentials are never build inputs. Full Debug, publish and MSIX
harvested by PackagingOutputs. The MSIX tooling harvests every builds use the same user-only LocalAppData overlay as a clean handoff PC.
CopyToOutputDirectory item into its loose-package recipe, even when
GenerateAppxPackageOnBuild is false. This post-build copy provides the original
executable-relative layout only for ordinary local output. The CPS-only
up-to-date items below are not package payload candidates.
--> -->
<ItemGroup Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'"> <Target Name="ScrubLegacyDatabaseIniFromBuildOutput"
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. --> BeforeTargets="PrepareForBuild">
<UpToDateCheckInput Include="$(LegacyResSourceRoot)\MmoneyCoder.ini" />
<UpToDateCheckBuilt Include="$(TargetDir)Res\MmoneyCoder.ini" />
</ItemGroup>
<Target Name="CopyLegacyDatabaseIniToLocalOutput"
AfterTargets="Build"
Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini"
Outputs="$(TargetDir)Res\MmoneyCoder.ini"
Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true' and
'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true'">
<MakeDir Directories="$(TargetDir)Res" />
<Copy SourceFiles="$(LegacyResSourceRoot)\MmoneyCoder.ini"
DestinationFiles="$(TargetDir)Res\MmoneyCoder.ini"
SkipUnchangedFiles="true" />
<ItemGroup>
<FileWrites Include="$(TargetDir)Res\MmoneyCoder.ini" />
</ItemGroup>
</Target>
<!-- The credential-bearing local development copy must not survive Clean. -->
<Target Name="RemoveLegacyDatabaseIniFromLocalOutput"
BeforeTargets="Clean">
<Delete Files="$(TargetDir)Res\MmoneyCoder.ini" <Delete Files="$(TargetDir)Res\MmoneyCoder.ini"
Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" /> Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" />
</Target> </Target>

View File

@@ -187,7 +187,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
} }
[Fact] [Fact]
public void ActiveDatabaseIniIsPostBuildOutputOnlyAndNeverAPackagingItem() public void DatabaseCredentialsStayInUserLocalConfigurationAndNeverEnterBuildOutputs()
{ {
var project = XDocument.Load(ProjectPath); var project = XDocument.Load(ProjectPath);
Assert.DoesNotContain( Assert.DoesNotContain(
@@ -196,59 +196,49 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
element => (element.Attribute("Include")?.Value ?? string.Empty) element => (element.Attribute("Include")?.Value ?? string.Empty)
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase)); .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
var upToDateInput = Assert.Single(project.Descendants("UpToDateCheckInput")); Assert.DoesNotContain(
Assert.Equal( project.Descendants("UpToDateCheckInput"),
"$(LegacyResSourceRoot)\\MmoneyCoder.ini", element => (element.Attribute("Include")?.Value ?? string.Empty)
upToDateInput.Attribute("Include")?.Value); .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
var upToDateBuilt = Assert.Single(project.Descendants("UpToDateCheckBuilt")); Assert.DoesNotContain(
Assert.Equal( project.Descendants("UpToDateCheckBuilt"),
"$(TargetDir)Res\\MmoneyCoder.ini", element => (element.Attribute("Include")?.Value ?? string.Empty)
upToDateBuilt.Attribute("Include")?.Value); .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(
var copyTarget = Assert.Single(
project.Descendants("Target"), project.Descendants("Target"),
element => string.Equals( element => string.Equals(
element.Attribute("Name")?.Value, element.Attribute("Name")?.Value,
"CopyLegacyDatabaseIniToLocalOutput", "CopyLegacyDatabaseIniToLocalOutput",
StringComparison.Ordinal)); StringComparison.Ordinal));
Assert.Equal("Build", copyTarget.Attribute("AfterTargets")?.Value); Assert.DoesNotContain(
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(
project.Descendants("Target"), project.Descendants("Target"),
element => string.Equals( element => string.Equals(
element.Attribute("Name")?.Value, element.Attribute("Name")?.Value,
"RemoveLegacyDatabaseIniFromLocalOutput", "RemoveLegacyDatabaseIniFromLocalOutput",
StringComparison.Ordinal)); 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( Assert.Equal(
"$(TargetDir)Res\\MmoneyCoder.ini", "$(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( var rejectionTarget = Assert.Single(
project.Descendants("Target"), project.Descendants("Target"),
@@ -385,16 +375,12 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
StringComparison.Ordinal); StringComparison.Ordinal);
}); });
var databaseCopy = Assert.Single( Assert.DoesNotContain(
project.Descendants("Target"), project.Descendants("Target"),
element => string.Equals( element => string.Equals(
element.Attribute("Name")?.Value, element.Attribute("Name")?.Value,
"CopyLegacyDatabaseIniToLocalOutput", "CopyLegacyDatabaseIniToLocalOutput",
StringComparison.Ordinal)); StringComparison.Ordinal));
Assert.Contains(
"$(LegacyRuntimeAssetsEnabled)",
databaseCopy.Attribute("Condition")?.Value,
StringComparison.Ordinal);
var mainWindow = File.ReadAllText(Path.Combine( var mainWindow = File.ReadAllText(Path.Combine(
RepositoryRoot, RepositoryRoot,
@@ -447,7 +433,6 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
"$(LegacyResSourceRoot)", "$(LegacyResSourceRoot)",
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredScene.Identity).t2s", "$(LegacyCutsSourceRoot)\\%(LegacyRequiredScene.Identity).t2s",
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredBuiltInAsset.Identity)", "$(LegacyCutsSourceRoot)\\%(LegacyRequiredBuiltInAsset.Identity)",
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
"$(LegacyResSourceRoot)\\logo.png", "$(LegacyResSourceRoot)\\logo.png",
"$(LegacyResSourceRoot)\\종목.ini", "$(LegacyResSourceRoot)\\종목.ini",
"$(LegacyResSourceRoot)\\업종_코스피.ini", "$(LegacyResSourceRoot)\\업종_코스피.ini",
@@ -482,17 +467,138 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
Assert.Contains(@"Images\KRX.png", requiredAssets); Assert.Contains(@"Images\KRX.png", requiredAssets);
Assert.Contains(@"Images\NXT.png", requiredAssets); Assert.Contains(@"Images\NXT.png", requiredAssets);
var databaseIniError = Assert.Single( Assert.DoesNotContain(
target.Elements("Error"), target.Elements("Error"),
error => (error.Attribute("Condition")?.Value ?? string.Empty) 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( Assert.Contains(
"GenerateAppxPackageOnBuild", "Exists(",
databaseIniError.Attribute("Condition")?.Value, 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); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"PublishAppxPackage", "maximumReconnectAttempts = 0",
databaseIniError.Attribute("Condition")?.Value, 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); StringComparison.Ordinal);
} }