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
|
||||
**/runtime-folders.local.json
|
||||
*.local.ini
|
||||
Directory.Build.local.props
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -9,4 +9,12 @@
|
||||
<DebugType>None</DebugType>
|
||||
<AppxSymbolPackageEnabled>false</AppxSymbolPackageEnabled>
|
||||
</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>
|
||||
|
||||
19
README.md
19
README.md
@@ -72,6 +72,13 @@ DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도
|
||||
`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤
|
||||
[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요.
|
||||
|
||||
인수 PC의 **방송 비연결 개발용** Tornado2/PGM에서 실제 송출을 확인할 때는 외부 runtime
|
||||
bundle, 로컬 DB·송출 설정, K3D 라이선스와 독립 승인 해시를 먼저 준비하고
|
||||
[개발 PGM 인수 절차](docs/DEVELOPMENT_LIVE_HANDOFF.md)를 따르세요. Development Live는
|
||||
`Debug|x64` Package에만 적용되며 Release/default `DryRun`은 그대로 유지합니다.
|
||||
runtime bundle은 `New-LegacyRuntimeBundle.ps1`로 만들고 인수 PC에서
|
||||
`Initialize-LegacyRuntimeBundle.ps1`로 별도 승인 SHA-256을 검증해 설치합니다.
|
||||
|
||||
이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요.
|
||||
|
||||
기존 중복 형식 오류(`CS0121`, `CS0436`)는 루트 앱 프로젝트가 하위 Core 소스까지 다시 컴파일하던 문제였으며, 현재 `src\**\*.cs`를 앱 컴파일 대상에서 제외해 해결했습니다. Visual Studio가 이전 진단을 계속 표시하면 `빌드 > 솔루션 정리` 후 다시 빌드하세요.
|
||||
@@ -117,12 +124,12 @@ Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위
|
||||
|
||||
## 구성과 보안
|
||||
|
||||
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 원본 자산을 사용하는 일반
|
||||
Visual Studio 전체 개발 빌드만 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를
|
||||
재현합니다. 소스 전용 빌드는 자격증명 없이 DB 미연결 상태로 시작하고, MSIX는
|
||||
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
|
||||
`database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위,
|
||||
보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
|
||||
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 전체 자산 Debug 빌드,
|
||||
소스 전용 빌드, 게시와 MSIX 모두 이 자격증명 파일을 빌드 입력이나 출력으로 사용하지 않습니다.
|
||||
전체 자산 Debug/MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존
|
||||
`database.local.json`을 사용하고, 소스 전용 빌드는 DB 미연결을 강제합니다. 환경 변수는 선택된
|
||||
파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은
|
||||
[DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
|
||||
|
||||
MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된
|
||||
UI용 `Res` 파일이 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은
|
||||
|
||||
@@ -18,12 +18,17 @@ Oracle ODP.NET Core 23 계열은 TAP 기반 `OpenAsync`/명령 비동기를 지
|
||||
|
||||
## 런타임 설정
|
||||
|
||||
원본 호환 앱은 다음 순서로 DB 설정을 선택합니다.
|
||||
원본 호환 앱은 호환성을 위해 다음 순서로 DB 설정을 선택합니다.
|
||||
|
||||
1. 실행 파일 옆 `Res\MmoneyCoder.ini` — Visual Studio의 일반 개발 출력에서 원본과 같은 방식으로 사용
|
||||
1. 실행 파일 옆 `Res\MmoneyCoder.ini` — 기존 수동 배치와의 호환 경로
|
||||
2. `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` — MSIX용 로컬 overlay
|
||||
3. 기존 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` — INI가 없을 때 fallback
|
||||
|
||||
공식 Debug/Release 빌드와 개발 인수용 런타임 묶음은 첫 번째 파일을 생성하거나 복사하지
|
||||
않습니다. 깨끗한 개발 PC에서는 두 번째 또는 세 번째 사용자 전용 경로만 사용합니다.
|
||||
`MmoneyCoder.ini`가 들어 있는 원본 `Res` 전체를 빌드 출력이나 전달용 ZIP으로 복사하지
|
||||
마세요.
|
||||
|
||||
`MmoneyCoder.ini`의 `[Oracle]`/`[Maria]` 섹션에서 `ConnectionName=host:port/service`,
|
||||
`ID`, `Pass`를 읽습니다. BOM 없는 UTF-8과 CP949를 지원하고 환경 변수는 선택된 파일 값보다
|
||||
우선합니다. 원본 `Res`의 복사본·`.bak`·`.zip`은 사용하지도 배포하지도 않습니다.
|
||||
|
||||
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와
|
||||
대조한 뒤 UI를 구성한다. INI가 임의의 scene/action을 새로 추가할 수는 없다.
|
||||
|
||||
`MmoneyCoder.ini`는 실제 자격증명을 포함하므로 일반 Visual Studio 개발 출력에만
|
||||
`Res\MmoneyCoder.ini`로 복사하고 MSIX Content에는 넣지 않는다. 일반 개발 빌드는 이
|
||||
원본 파일이 없으면 중단하지만, MSIX 생성·게시 빌드는 자격증명 파일 없이도 비민감
|
||||
자산만으로 수행할 수 있다. `Clean`은 실행 출력에 복사했던 INI를 제거한다. 패키지 실행은
|
||||
다음 로컬 overlay를 사용할 수 있다.
|
||||
`MmoneyCoder.ini`는 실제 자격증명을 포함하므로 빌드 입력, 출력, 개발 인수용 런타임
|
||||
묶음과 MSIX Content에 넣지 않는다. 전체 자산 Debug 빌드도 깨끗한 개발 PC와 같은 다음
|
||||
사용자 전용 로컬 overlay 또는 `Config\database.local.json`을 사용한다.
|
||||
|
||||
```text
|
||||
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini
|
||||
|
||||
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
|
||||
bitmap assets. Never map the complete Res directory into WebView2: ordinary
|
||||
local Debug output can also contain credential-bearing MmoneyCoder.ini.
|
||||
bitmap assets. Never map the complete Res directory into WebView2: an
|
||||
operator-selected legacy directory can contain credential-bearing
|
||||
MmoneyCoder.ini.
|
||||
-->
|
||||
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
|
||||
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
|
||||
@@ -216,11 +217,6 @@
|
||||
<Error Condition="'$(LegacyRuntimeAssetsValidationEnabled)' == 'true' and
|
||||
!Exists('$(LegacyCutsSourceRoot)\%(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
|
||||
!Exists('$(LegacyResSourceRoot)\종목.ini')"
|
||||
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||
@@ -242,38 +238,11 @@
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
Do not declare the credential-bearing INI as Content, None, or another item
|
||||
harvested by PackagingOutputs. The MSIX tooling harvests every
|
||||
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.
|
||||
Database credentials are never build inputs. Full Debug, publish and MSIX
|
||||
builds use the same user-only LocalAppData overlay as a clean handoff PC.
|
||||
-->
|
||||
<ItemGroup Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true'">
|
||||
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. -->
|
||||
<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">
|
||||
<Target Name="ScrubLegacyDatabaseIniFromBuildOutput"
|
||||
BeforeTargets="PrepareForBuild">
|
||||
<Delete Files="$(TargetDir)Res\MmoneyCoder.ini"
|
||||
Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" />
|
||||
</Target>
|
||||
|
||||
@@ -187,7 +187,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveDatabaseIniIsPostBuildOutputOnlyAndNeverAPackagingItem()
|
||||
public void DatabaseCredentialsStayInUserLocalConfigurationAndNeverEnterBuildOutputs()
|
||||
{
|
||||
var project = XDocument.Load(ProjectPath);
|
||||
Assert.DoesNotContain(
|
||||
@@ -196,59 +196,49 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
element => (element.Attribute("Include")?.Value ?? string.Empty)
|
||||
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var upToDateInput = Assert.Single(project.Descendants("UpToDateCheckInput"));
|
||||
Assert.Equal(
|
||||
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
|
||||
upToDateInput.Attribute("Include")?.Value);
|
||||
var upToDateBuilt = Assert.Single(project.Descendants("UpToDateCheckBuilt"));
|
||||
Assert.Equal(
|
||||
"$(TargetDir)Res\\MmoneyCoder.ini",
|
||||
upToDateBuilt.Attribute("Include")?.Value);
|
||||
|
||||
var copyTarget = Assert.Single(
|
||||
Assert.DoesNotContain(
|
||||
project.Descendants("UpToDateCheckInput"),
|
||||
element => (element.Attribute("Include")?.Value ?? string.Empty)
|
||||
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(
|
||||
project.Descendants("UpToDateCheckBuilt"),
|
||||
element => (element.Attribute("Include")?.Value ?? string.Empty)
|
||||
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(
|
||||
project.Descendants("Target"),
|
||||
element => string.Equals(
|
||||
element.Attribute("Name")?.Value,
|
||||
"CopyLegacyDatabaseIniToLocalOutput",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Equal("Build", copyTarget.Attribute("AfterTargets")?.Value);
|
||||
Assert.Contains(
|
||||
"GenerateAppxPackageOnBuild",
|
||||
copyTarget.Attribute("Condition")?.Value,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"PublishAppxPackage",
|
||||
copyTarget.Attribute("Condition")?.Value,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
|
||||
copyTarget.Attribute("Inputs")?.Value);
|
||||
Assert.Equal(
|
||||
"$(TargetDir)Res\\MmoneyCoder.ini",
|
||||
copyTarget.Attribute("Outputs")?.Value);
|
||||
var copy = Assert.Single(copyTarget.Elements("Copy"));
|
||||
Assert.Equal(
|
||||
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
|
||||
copy.Attribute("SourceFiles")?.Value);
|
||||
Assert.Equal(
|
||||
"$(TargetDir)Res\\MmoneyCoder.ini",
|
||||
copy.Attribute("DestinationFiles")?.Value);
|
||||
var fileWrite = Assert.Single(copyTarget.Descendants("FileWrites"));
|
||||
Assert.Equal(
|
||||
"$(TargetDir)Res\\MmoneyCoder.ini",
|
||||
fileWrite.Attribute("Include")?.Value);
|
||||
|
||||
var cleanTarget = Assert.Single(
|
||||
Assert.DoesNotContain(
|
||||
project.Descendants("Target"),
|
||||
element => string.Equals(
|
||||
element.Attribute("Name")?.Value,
|
||||
"RemoveLegacyDatabaseIniFromLocalOutput",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Equal("Clean", cleanTarget.Attribute("BeforeTargets")?.Value);
|
||||
var delete = Assert.Single(cleanTarget.Elements("Delete"));
|
||||
|
||||
var scrubTarget = Assert.Single(
|
||||
project.Descendants("Target"),
|
||||
element => string.Equals(
|
||||
element.Attribute("Name")?.Value,
|
||||
"ScrubLegacyDatabaseIniFromBuildOutput",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Equal("PrepareForBuild", scrubTarget.Attribute("BeforeTargets")?.Value);
|
||||
var scrubDelete = Assert.Single(scrubTarget.Elements("Delete"));
|
||||
Assert.Equal(
|
||||
"$(TargetDir)Res\\MmoneyCoder.ini",
|
||||
delete.Attribute("Files")?.Value);
|
||||
scrubDelete.Attribute("Files")?.Value);
|
||||
|
||||
var validationTarget = Assert.Single(
|
||||
project.Descendants("Target"),
|
||||
element => string.Equals(
|
||||
element.Attribute("Name")?.Value,
|
||||
"ValidateLegacyRuntimeAssets",
|
||||
StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
validationTarget.Elements("Error"),
|
||||
element => (element.Attribute("Condition")?.Value ?? string.Empty)
|
||||
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var rejectionTarget = Assert.Single(
|
||||
project.Descendants("Target"),
|
||||
@@ -385,16 +375,12 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
var databaseCopy = Assert.Single(
|
||||
Assert.DoesNotContain(
|
||||
project.Descendants("Target"),
|
||||
element => string.Equals(
|
||||
element.Attribute("Name")?.Value,
|
||||
"CopyLegacyDatabaseIniToLocalOutput",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
"$(LegacyRuntimeAssetsEnabled)",
|
||||
databaseCopy.Attribute("Condition")?.Value,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var mainWindow = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
@@ -447,7 +433,6 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
"$(LegacyResSourceRoot)",
|
||||
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredScene.Identity).t2s",
|
||||
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredBuiltInAsset.Identity)",
|
||||
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
|
||||
"$(LegacyResSourceRoot)\\logo.png",
|
||||
"$(LegacyResSourceRoot)\\종목.ini",
|
||||
"$(LegacyResSourceRoot)\\업종_코스피.ini",
|
||||
@@ -482,17 +467,138 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
Assert.Contains(@"Images\KRX.png", requiredAssets);
|
||||
Assert.Contains(@"Images\NXT.png", requiredAssets);
|
||||
|
||||
var databaseIniError = Assert.Single(
|
||||
Assert.DoesNotContain(
|
||||
target.Elements("Error"),
|
||||
error => (error.Attribute("Condition")?.Value ?? string.Empty)
|
||||
.Contains("$(LegacyResSourceRoot)\\MmoneyCoder.ini", StringComparison.Ordinal));
|
||||
.Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevelopmentLiveHandoffUsesAnIgnoredVerifiedRuntimeRootAndSafeLocalFiles()
|
||||
{
|
||||
var directoryPropsPath = Path.Combine(RepositoryRoot, "Directory.Build.props");
|
||||
var directoryProps = XDocument.Load(directoryPropsPath);
|
||||
var localImport = Assert.Single(directoryProps.Descendants("Import"));
|
||||
Assert.Equal(
|
||||
"$(MSBuildThisFileDirectory)Directory.Build.local.props",
|
||||
localImport.Attribute("Project")?.Value);
|
||||
Assert.Contains(
|
||||
"GenerateAppxPackageOnBuild",
|
||||
databaseIniError.Attribute("Condition")?.Value,
|
||||
"Exists(",
|
||||
localImport.Attribute("Condition")?.Value,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var gitIgnore = File.ReadAllLines(Path.Combine(RepositoryRoot, ".gitignore"));
|
||||
Assert.Contains("Directory.Build.local.props", gitIgnore);
|
||||
|
||||
var initializer = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"scripts",
|
||||
"Initialize-DevelopmentLiveConfig.ps1"));
|
||||
Assert.Contains(
|
||||
"[ValidateSet('127.0.0.1', '::1')]",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("mode = 'DryRun'", initializer, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"trustedLiveOutputEnabled = $true",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"PublishAppxPackage",
|
||||
databaseIniError.Attribute("Condition")?.Value,
|
||||
"maximumReconnectAttempts = 0",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("reconnectEnabled = $false", initializer, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"maximumAutomaticRefreshesPerTakeIn = 0",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"SetAccessRuleProtection($true, $false)",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
var authorizationInvalidation = initializer.IndexOf(
|
||||
"Invalidate-ExistingAuthorization -Path $authorizationPath",
|
||||
StringComparison.Ordinal);
|
||||
var baseConfigurationWrite = initializer.IndexOf(
|
||||
"Write-ProtectedJson $playoutPath",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(authorizationInvalidation >= 0);
|
||||
Assert.True(baseConfigurationWrite > authorizationInvalidation);
|
||||
Assert.Contains(
|
||||
"Write-ProtectedJson $authorizationPath",
|
||||
initializer,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevelopmentRuntimeBundleRequiresIndependentHashAndClosedPayloadValidation()
|
||||
{
|
||||
var bundleCreator = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"scripts",
|
||||
"New-LegacyRuntimeBundle.ps1"));
|
||||
var bundleInitializer = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"scripts",
|
||||
"Initialize-LegacyRuntimeBundle.ps1"));
|
||||
|
||||
Assert.Contains("$expectedResAssetCount = 34", bundleCreator, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Get-GitRepositoryAncestor -Path $sourceRoot",
|
||||
bundleCreator,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"MmoneyCoder material is forbidden",
|
||||
bundleCreator,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"LegacyRuntimeBundle.zip.sha256",
|
||||
bundleCreator,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains(
|
||||
"[string] $ExpectedSha256",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ExpectedSha256 must be a separately delivered",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
var archiveHashValidation = bundleInitializer.IndexOf(
|
||||
"$actualArchiveHash.Equals(",
|
||||
StringComparison.Ordinal);
|
||||
var archiveOpen = bundleInitializer.IndexOf(
|
||||
"New-Object IO.Compression.ZipArchive(",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(archiveHashValidation >= 0);
|
||||
Assert.True(archiveOpen > archiveHashValidation);
|
||||
Assert.Contains(
|
||||
"Archive entry expanded beyond its declared length",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$actualCutsFileCount -le 0",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[Environment+SpecialFolder]::LocalApplicationData",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"Get-NormalizedFullPath -Path $env:LOCALAPPDATA",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Existing Directory.Build.local.props is conditional, duplicated",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Assert-NoReparsePointInTree",
|
||||
bundleInitializer,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user