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