diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccff4ce0..096f4d60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,12 @@ name: Release Gajae Code App on: workflow_dispatch: + inputs: + publish: + description: Publish the verified draft after all final checks + required: false + default: false + type: boolean permissions: contents: read @@ -156,6 +162,7 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + GAJAE_UPDATER_PUBLIC_KEY: ${{ vars.GAJAE_UPDATER_PUBLIC_KEY }} outputs: artifact_name: ${{ steps.metadata.outputs.artifact_name }} release_tag: ${{ steps.metadata.outputs.release_tag }} @@ -193,7 +200,36 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Require release signing credentials - run: node scripts/release/check-signing-readiness.mjs --mode ci + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + node scripts/release/check-signing-readiness.mjs --mode ci + test -n "${TAURI_SIGNING_PRIVATE_KEY:-}" + test -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" + test -n "${GAJAE_UPDATER_PUBLIC_KEY:-}" + + - name: Install and verify Minisign 0.12 + run: | + set -euo pipefail + brew install minisign + test "$(minisign -v)" = "minisign 0.12" + + - name: Prepare updater public key and reviewed source notes + run: | + set -euo pipefail + PUBLIC_KEY_FILE="$RUNNER_TEMP/gajae-updater.pub" + NOTES_FILE="$RUNNER_TEMP/gajae-release-notes.txt" + printf '%s' "$GAJAE_UPDATER_PUBLIC_KEY" > "$PUBLIC_KEY_FILE" + chmod 600 "$PUBLIC_KEY_FILE" + git show -s --format=%B "$GITHUB_SHA" > "$NOTES_FILE" + chmod 600 "$NOTES_FILE" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + { + echo "GAJAE_UPDATER_PUBLIC_KEY_FILE=$PUBLIC_KEY_FILE" + echo "GAJAE_RELEASE_NOTES_FILE=$NOTES_FILE" + } >> "$GITHUB_ENV" - name: Set up Rust run: | @@ -283,28 +319,26 @@ jobs: (cd "$(dirname "$DMG")" && shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256") spctl -a -t open --context context:primary-signature -vv "$DMG" - - name: Stage and verify canonical desktop assets + - name: Build canonical macOS updater assets env: ASSET_NAME: ${{ steps.metadata.outputs.artifact_name }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail - SOURCE="src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/$ASSET_NAME" - test -f "$SOURCE" - test -f "$SOURCE.sha256" - ( - cd "$(dirname "$SOURCE")" - shasum -a 256 --check "$(basename "$SOURCE.sha256")" - ) - mkdir release-assets - cp "$SOURCE" "$SOURCE.sha256" release-assets/ - assets=(release-assets/*) - if [ "${#assets[@]}" -ne 2 ] || - [ ! -f "release-assets/$ASSET_NAME" ] || - [ ! -f "release-assets/$ASSET_NAME.sha256" ]; then - echo "Desktop release staging must contain only the canonical DMG and checksum." >&2 - exit 1 - fi - hdiutil verify "release-assets/$ASSET_NAME" + APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Gajae Code App.app" + DMG="src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/$ASSET_NAME" + PUB_DATE="$(date -u -r "$(git show -s --format=%ct "$GITHUB_SHA")" +%Y-%m-%dT%H:%M:%SZ)" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + node scripts/release/make-macos-updater.mjs \ + --app "$APP" \ + --dmg "$DMG" \ + --output "$RUNNER_TEMP/gajae-macos-assets" \ + --commit "$GITHUB_SHA" \ + --team-id "$APPLE_TEAM_ID" \ + --updater-public-key-file "$GAJAE_UPDATER_PUBLIC_KEY_FILE" \ + --notes-file "$GAJAE_RELEASE_NOTES_FILE" \ + --pub-date "$PUB_DATE" - name: Smoke the app installed from the DMG env: @@ -315,7 +349,7 @@ jobs: set -euo pipefail MOUNT_POINT="$RUNNER_TEMP/gajae-app-dmg" mkdir "$MOUNT_POINT" - hdiutil attach "release-assets/$ASSET_NAME" -nobrowse -readonly -mountpoint "$MOUNT_POINT" + hdiutil attach "$RUNNER_TEMP/gajae-macos-assets/$ASSET_NAME" -nobrowse -readonly -mountpoint "$MOUNT_POINT" trap 'hdiutil detach "$MOUNT_POINT"' EXIT APP="$MOUNT_POINT/Gajae Code App.app" test -d "$APP" @@ -347,7 +381,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: gajae-app-desktop-release - path: release-assets/* + path: ${{ runner.temp }}/gajae-macos-assets/* if-no-files-found: error - name: Remove the signing keychain @@ -425,9 +459,9 @@ jobs: - build - desktop-macos - ubuntu-24-compatibility - runs-on: ubuntu-22.04 + runs-on: macos-14 environment: release - timeout-minutes: 15 + timeout-minutes: 30 permissions: contents: write steps: @@ -441,6 +475,41 @@ jobs: exit 1 fi + - name: Checkout exact release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + architecture: arm64 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install and verify Minisign 0.12 + run: | + set -euo pipefail + brew install minisign + test "$(minisign -v)" = "minisign 0.12" + + - name: Prepare updater public key + env: + GAJAE_UPDATER_PUBLIC_KEY: ${{ vars.GAJAE_UPDATER_PUBLIC_KEY }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -n "${GAJAE_UPDATER_PUBLIC_KEY:-}" + PUBLIC_KEY_FILE="$RUNNER_TEMP/gajae-updater.pub" + printf '%s' "$GAJAE_UPDATER_PUBLIC_KEY" > "$PUBLIC_KEY_FILE" + chmod 600 "$PUBLIC_KEY_FILE" + echo "GAJAE_UPDATER_PUBLIC_KEY_FILE=$PUBLIC_KEY_FILE" >> "$GITHUB_ENV" + - name: Download canonical server release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -461,53 +530,58 @@ jobs: DESKTOP_RELEASE_TAG: ${{ needs.desktop-macos.outputs.release_tag }} run: | set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" if [ "$SERVER_RELEASE_TAG" != "$DESKTOP_RELEASE_TAG" ]; then echo "Server and desktop release tags do not match." >&2 exit 1 fi shopt -s nullglob assets=(release-assets/*) - if [ "${#assets[@]}" -ne 4 ] || + if [ "${#assets[@]}" -ne 8 ] || [ ! -f "release-assets/$SERVER_ASSET_NAME" ] || [ ! -f "release-assets/$SERVER_ASSET_NAME.sha256" ] || - [ ! -f "release-assets/$DESKTOP_ASSET_NAME" ] || - [ ! -f "release-assets/$DESKTOP_ASSET_NAME.sha256" ]; then + [ ! -f "release-assets/$DESKTOP_ASSET_NAME" ]; then echo "Refusing to publish non-canonical release assets." >&2 exit 1 fi - ( - cd release-assets - sha256sum --check "$SERVER_ASSET_NAME.sha256" - sha256sum --check "$DESKTOP_ASSET_NAME.sha256" - ) + VERSION="${SERVER_RELEASE_TAG#v}" + for asset in \ + "gajae-app-desktop-${VERSION}-macos-arm64.dmg" \ + "gajae-app-desktop-${VERSION}-macos-arm64.dmg.sha256" \ + "gajae-app-desktop-${VERSION}-macos-arm64.app.tar.gz" \ + "gajae-app-desktop-${VERSION}-macos-arm64.app.tar.gz.sig" \ + "gajae-app-desktop-${VERSION}-macos-arm64.app.tar.gz.sha256" \ + "desktop-update.json"; do + test -f "release-assets/$asset" + done - - name: Create GitHub Release + - name: Verify, stage and publish the release draft env: GH_TOKEN: ${{ github.token }} - SERVER_ASSET_NAME: ${{ needs.build.outputs.artifact_name }} - DESKTOP_ASSET_NAME: ${{ needs.desktop-macos.outputs.artifact_name }} RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + RELEASE_PUBLISH: ${{ inputs.publish }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - prerelease_args=() - if [[ "$RELEASE_TAG" == *-* ]]; then - prerelease_args+=(--prerelease) + test -n "${APPLE_TEAM_ID:-}" + publish_args=() + if [[ "$RELEASE_PUBLISH" == "true" ]]; then + publish_args+=(--publish) fi - gh release create "$RELEASE_TAG" \ - "release-assets/$SERVER_ASSET_NAME" \ - "release-assets/$SERVER_ASSET_NAME.sha256" \ - "release-assets/$DESKTOP_ASSET_NAME" \ - "release-assets/$DESKTOP_ASSET_NAME.sha256" \ + node scripts/release/ci-release.mjs \ --repo "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ - --title "Gajae Code App $RELEASE_TAG" \ - "${prerelease_args[@]}" \ - --generate-notes + --tag "$RELEASE_TAG" \ + --commit "$GITHUB_SHA" \ + --team-id "$APPLE_TEAM_ID" \ + --assets-dir release-assets \ + --updater-public-key-file "$GAJAE_UPDATER_PUBLIC_KEY_FILE" \ + "${publish_args[@]}" # A release created with GITHUB_TOKEN does not raise `release: published`, # so a separate notification workflow listening for that event never runs. # The lane that publishes announces. - name: Announce the release + if: ${{ inputs.publish == true }} env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} SERVER_ASSET_NAME: ${{ needs.build.outputs.artifact_name }} diff --git a/README.md b/README.md index f2d00f21..058ca9a4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Gajae Code App is a self-hosted web and desktop interface for [Gajae Code](https ## Quick Start -**macOS (Apple Silicon, macOS 11+) — the desktop app.** Download the DMG from [Releases](https://github.com/devswha/gajae-code-app/releases/latest), verify it, drag it to Applications, open it. Since v2.0.0-beta.7 the image is signed with a Developer ID and notarized by Apple; Gatekeeper opens it like any other app. +**macOS (Apple Silicon, macOS 13+) — the desktop app.** Download the DMG from [Releases](https://github.com/devswha/gajae-code-app/releases/latest), verify it, drag it to Applications, open it. Since v2.0.0-beta.7 the image is signed with a Developer ID and notarized by Apple; Gatekeeper opens it like any other app. ```bash cd ~/Downloads diff --git a/docs/DESKTOP-QA-PROFILE.md b/docs/DESKTOP-QA-PROFILE.md index bb127694..43668883 100644 --- a/docs/DESKTOP-QA-PROFILE.md +++ b/docs/DESKTOP-QA-PROFILE.md @@ -37,11 +37,11 @@ the filesystem profile; keep its UUID with the QA evidence. Deleting the QA directory alone does not erase that WebKit store. QA profiles are not portable. No production browser profile is inspected or copied by this mechanism. -macOS 11–13 still support normal app launches, but QA mode refuses startup -there rather than silently falling back to WebKit's default store. Other +The bundled runtime requires macOS 13 or later. On macOS 13, QA mode refuses +startup rather than silently falling back to WebKit's default store. Other platforms reject this option. A disposable OS account remains useful for -first-install permissions/LaunchServices testing and is required on older -macOS versions. Profile-based GUI checks do not claim clean-machine coverage. +first-install permissions/LaunchServices testing and is required for QA on +macOS 13. Profile-based GUI checks do not claim clean-machine coverage. Record the source commit/tree, artifact hashes, profile UUIDs and separate results for launch, sign-in link, settings/project persistence, fresh-profile diff --git a/docs/DESKTOP-TAURI-VERIFICATION.md b/docs/DESKTOP-TAURI-VERIFICATION.md index 2f80d0f9..58431b62 100644 --- a/docs/DESKTOP-TAURI-VERIFICATION.md +++ b/docs/DESKTOP-TAURI-VERIFICATION.md @@ -19,10 +19,21 @@ do not establish Linux package or GUI compatibility. > stapled and accepted by Gatekeeper (record below). Nothing has been tagged or > published from it: the packaged smoke run from the mounted image failed > because the payload's `elkjs` exclusion broke worker start-up outside the -> repository tree. **Fixed the same day** (record below): a first-party stub +> the repository tree. **Fixed the same day** (record below): a first-party stub > now stands in for the removed package, and every packaged smoke runs from a > copy outside the checkout. The next signed build starts from that HEAD. +> **Loader-floor correction (2026-09-07): historical signed beta.8/beta.9 +> bundles declare `LSMinimumSystemVersion=11.0`, but the bundled Bun 1.4.0 +> Mach-O carries `LC_BUILD_VERSION minos 13.0` (the other inspected runtime +> binaries were 11.0). The release verifier now requires a pinned 13.0 +> minimum and independently checks the desktop/server executables, Bun/Rust +> runtimes, and bounded native modules with `xcrun vtool -show-build`; it does +> not trust `Info.plist` alone. The historical six-asset builder run therefore +> demonstrates signing/inventory tooling but is rejected by this stricter +> guard, and is not new release acceptance or signing evidence. The static +> loader floor does not establish that execution on macOS 13 succeeds.** + ## Build the artifacts (on the Mac) ```sh diff --git a/docs/MACOS-UPDATER-HANDOFF.md b/docs/MACOS-UPDATER-HANDOFF.md new file mode 100644 index 00000000..b73c1233 --- /dev/null +++ b/docs/MACOS-UPDATER-HANDOFF.md @@ -0,0 +1,150 @@ +# macOS 자동 업데이트 — 남은 작업 인계 + +## 2026-09-07 재개: 준비 경로 구현 + +사용자가 이 작업에서 구현 재개와 Astra xhigh 병렬 작업을 승인했다. 아래 +14:36 중단 기록은 과거 상태이며, 이번 준비 경로의 결과로 대체되는 항목은 +여기에 명시한다. 기존 GJC 원장/완료 영수증은 편집하지 않았다. + +- `updater_manifest.rs`의 컴파일 오류·fixture 경로와 빌드 바인딩의 정상 GitHub + slug 거부 오류를 수정했다. Cargo.lock을 기존 고정 버전으로 오프라인 동기화했다. +- `updater_archive.rs`: 추출하지 않는 gzip/tar/PAX 검사, 전체 파일/모드/해시/링크 + inventory, Info.plist·payload·런타임 closure·arm64 shell 검사. +- `updater_discovery.rs`: 3×30 페이지/30초 단위의 재개 가능한 탐색, 관찰한 최대 + desktop 버전, 불완전 탐색 표시, asset ID 재확인, 제한된 HTTPS redirect/다운로드, + Retry-After. GitHub의 원자적 스냅샷이나 전역 최신 버전 증명은 아니다. +- `updater_store.rs` / `updater_signature.rs`: owner-only/descriptor 기반 캐시, + exclusive stage와 fsync/atomic ready 게시, 취소된 stage 정리, crash orphan 용량 + 상한, 실제 Minisign 검증. 재로드 시 같은 bytes의 서명·digest·전체 inventory를 + 다시 확인한다. cache ready는 설치 허가나 설치 성공이 아니다. +- `updater.rs`: 단일 준비 owner/generation, opt-out 영속화와 취소, 수동 확인과 + 설치 동의 분리, 6시간±10분 주기/1·5·30분 재시도, wake coalescing. 서버 health와 + navigate 성공 뒤에만 연결했고 서버 실패/종료 시 현재 준비 generation을 취소한다. +- `updater_binding.rs`: disabled/일반 dev/QA 바인딩 불일치는 업데이트 I/O 전 거부. + QA root뿐 아니라 실제 실행 app와 데이터 root도 비교한다. QA HTTPS CA는 고정 + `${GJC_UPDATE_QA_ROOT}/updater-ca.pem`의 owner-only 공개 인증서를 **빌드 시** 읽어 + 컴파일에 포함한다. 런타임 CA 입력이나 TLS 검증 비활성화는 없다. +- `expected_payload.rs`: 설치 payload의 package와 런타임 manifest를 빌드에 결합한 + 독립 기대값과 비교한 뒤에만 서버를 시작한다. ready/health의 기존 독립 버전 + 검증도 유지한다. + +### 아직 연결하지 않은 기능 + +**전체 자동 업데이트 기능은 미완료다.** `installation_available`은 false이며 +production updater 빌드를 활성화하거나 공개 릴리스하지 않았다. G001의 실제 +OS 승인/취소·writer 종료·macOS 13 및 Linux 검증, 다음 시작 설치/attempt +writer/resolver/복구, G003의 main-view-bound bridge·전체 producer admission·안전 +재시작, Settings/About UI, G004의 실제 서명된 A→B/GUI/사용자 데이터 보존 검증은 +그대로 남아 있다. 일반 브라우저나 원격 SPA에 새 native capability를 주지 않았다. + +준비 owner의 설정/수동 확인 메서드는 내부 계약 및 테스트만 있으며, 아직 사용자 +설정 화면이나 API에 노출하지 않았다. bridge/UI 완료로 오인하지 않는다. 설치를 +연결하기 전에 아래 G001/G003 차단 조건을 충족해야 한다. + +### 이번 재개 검증 + +- 전체 `npm run verify` 통과(기존 채팅 UI 변경이 섞인 로컬 작업 트리 기준). +- 릴리스 도구: 234 tests, 224 pass, 10 Linux-only skip, 0 fail. +- desktop locked Rust tests: 164 pass + 1 opt-in archive fixture ignore, + build-binding 9 pass, probe 19 pass. 별도 real archive fixture는 실제 실행해 통과했다. + `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` 통과. +- 실제 private-CA HTTPS 테스트: 기본 trust client는 UnknownIssuer로 거부하고 + HTTP 요청 0, 컴파일용 CA를 추가한 client는 HTTP 요청 1로 성공했다. PEM/DER + 구문은 고정 WebPKI 파서로 검사한다. CA 자기서명/유효기간 인증 증명과는 구분한다. +- 독립 리뷰의 종료/ready 표시/owner retirement 경쟁 조건을 수정했다. 종료는 + cache fsync 잠금을 기다리지 않으며, snapshot은 epoch를 확인하고, retirement와 + 겹친 healthy/수동 요청은 하나의 owner가 이어받는다. 관련 회귀 테스트 12개 통과. +- 기존 아카이브의 20,673개 inventory 항목을 JS 생산자와 읽기 전용으로 대조했다. + 이는 Apple 코드 서명·공증·모든 Mach-O의 OS floor·실제 설치/실행 증거가 아니다. + 대상은 기존 beta.9/desktop 0.2.3, SHA256 + `dda009d8e3d51a89fce3c61968fe386f1fcaad9954628aab4ea56ae6b8ffbe63`이다. + 그 fixture의 기대 floor는 역사적 Info.plist/shell 값 11.0이며 Bun의 13.0 + loader floor 불일치를 해결한 새 릴리스로 인정하지 않았다. +- 기존 채팅 UI/번역 변경은 수정하지 않았다. 실제 설치·인증창 실험·production + signing/key provisioning·release publication은 실행하지 않았다. + +QA root는 기존 `--qa-profile` 절차로 먼저 초기화하고 테스트 앱을 종료한 뒤, +그 root 안에 `updater-ca.pem`과 테스트 `.app`을 배치한다. 일반 QA의 nonempty +directory 보호를 우회하지 않는다. 인증서 fixture 테스트는 `openssl`을 사용하며 +인증서/키는 소유한 임시 폴더 안에서만 생성·정리한다. OS trust store는 변경하지 않는다. + +--- + +작성: 2026-09-07 14:36 KST. 사용자가 장시간 실행을 중단하고 **문서로 남은 작업만 인계**하도록 요청했다. 추가 구현·설치 실험·검증을 자동 재개하지 않는다. 완료 선언이 아니다. + +## 재개 기준 + +- 기존 목표를 유지한다. G001 안전성 검증 미완료, G002 릴리스 도구 완료 기록 유지, G003 네이티브 수명주기/설정 미완료, G004 최종 실증 미완료. +- 원장: `.gjc/_session-01a076d3-db4a-760a-ae8e-1bad69cdb5b8/ultragoal/{goals.json,ledger.jsonl}`. +- 승인 기준: 같은 세션의 `plans/ralplan/01a076d3-db4a-760a-ae8e-1bad69cdb5b8/pending-approval.md` 및 참조된 `stage-03-revision.md`. 최종 SHA `60faa93543369cbfa326c8bfd8d6ff9f5b6e447c1fd60fb088e95a978221216a`. 미완결 stage04 리뷰를 승인 계획으로 바꾸지 않는다. +- 작업 트리에 채팅 UI·번역·릴리스·네이티브 변경이 섞여 있고 커밋되지 않았다. 일괄 되돌리기/스테이징/커밋 금지. 원본 `docs/plans/macos-auto-update.md`도 보존한다. +- 인계 시 `12-UpdaterArchive`, `15-ExpectedPayload`를 실제 paused 상태로 확인했다. 재승인 후 필요하면 같은 에이전트를 재개하고, 동일 작업을 새 에이전트에게 중복 배정하지 않는다. + +## 1. 먼저 현재 미검증 변경을 확인 + +아래 코드는 작성됐지만 **이번 변경에 대한 부모 테스트·빌드·포맷·통합 검증은 아직 실행하지 않았다**. 과거 통과 결과를 적용하지 않는다. + +| 파일 | 상태 / 남은 확인 | +|---|---| +| `src-tauri/src/updater_manifest.rs` | 엄격한 네이티브 매니페스트 파서 작성. 64KiB, 중복/미지 키, 날짜, 채널, 버전, 정규 URL 검증 테스트 미실행. | +| `shared/fixtures/desktop-update-manifest.json` | JS/Rust 공용 스키마 fixture. 서명은 구문 테스트용이며 실제 암호 검증 증거가 아니다. | +| `scripts/release/updater-artifacts.test.mjs` | 공용 fixture와 생산자 일치 테스트 추가, 미실행. | +| `src-tauri/src/main.rs` | macOS 파서 모듈 선언 추가. 준비 coordinator는 연결되지 않았다. | +| `src-tauri/{build.rs,Cargo.toml,update_build_binding.rs,tests/update_build_binding.rs}` | 빌드 바인딩 구현 및 후속 보완 중 정지. lock 갱신/컴파일/테스트 필요. | +| `src-tauri/src/updater_transport.rs`, `src-tauri/examples/updater_probe.rs` | 부모가 `fetch_response`, 제한된 Location/Retry-After, URL 토큰 오류 제거, `InvalidHeader` 및 테스트 추가. 새 API 사용처와 경고까지 확인 필요. | + +인계 시 `src-tauri/src/{updater_archive,updater,updater_store}.rs`는 **존재하지 않음**을 확인했다. 특히 archive 담당자는 설계/조사 중 정지했으므로 구현 완료로 오인하지 않는다. + +핵심 계약: + +- `parse_manifest(bytes, &ProductIdentity { repository, artifact_prefix }) -> Result`. +- `Manifest`는 `semver::Version`, `Channel`, `minimum_system_version`, `archive_url`, `signature`, `commit`, `notes`, `pub_date`를 소유한다. 버전 비교는 `cmp_precedence` 사용. +- 현재 JS `strictVersion`은 build metadata를 거부한다. Rust도 이에 맞췄다. 이를 완전한 SemVer 입력 지원으로 과장하거나 생산자 의미를 무단 변경하지 않는다. +- 빌드 입력: `GJC_UPDATE_MODE`, `GJC_UPDATE_FEED_ORIGIN`, `GJC_UPDATE_PUBKEY`, `GJC_UPDATE_QA_ROOT`. 기본 disabled, production은 release/정규 origin/공개키 필수, QA는 컴파일에 바인딩된 소유자 전용 정규 임시 루트 필수. 실행 시 실제 `QaProfile` 일치 검증은 아직 연결되지 않았다. +- 후속 빌드 요청: `GJC_UPDATE_PRODUCT_NAME`, `GJC_UPDATE_PACKAGE_NAME` 출력 및 macOS 직접 의존성 tar `0.4.46`, flate2 `1.1.9`, plist `1.7.0`, sha2 `0.10.9`. 완료 여부를 파일에서 확인해야 한다. 새 버전 탐색 불필요. + +## 2. G003: 설치와 분리된 준비 경로 구현 + +승인 계획은 설치 차단과 별개로 네이티브 다운로드/준비 작업을 허용한다. 다음 순서로 연결한다. + +- [ ] 네이티브 읽기 전용 archive 검사: 유지보수되는 tar/gzip/plist 사용, 압축 250MiB/전체 확장 1GiB, 메타데이터/경로/멤버 한도, bounded PAX, 경로 탈출·중복·특수 파일·위험 링크 거부. 추출/설치는 하지 않는다. +- [ ] 전체 파일/모드/해시/링크 inventory와 서명된 Info.plist·payload package 정보를 매니페스트/제품 identity에 결합. arm64 확인. 코드 서명/설치 완료 증거와 구분한다. +- [ ] 정규 GitHub release discovery: 페이지/시간 제한, 관찰 후보 중 desktop 버전 최댓값, 불완전 조회를 ‘최신’으로 표시하지 않기, 채널 정책. +- [ ] native redirect 허용 정책, Retry-After, 제한된 archive 다운로드 → 실제 Minisign 검증 → 위 archive 검사. +- [ ] 소유자 전용 exclusive staging, fsync/원자적 게시, 전체 inventory와 digest/서명/릴리스·asset identity 보존. 재로드 시 다시 검증하고 파일 존재만 믿지 않기. +- [ ] 단일 실행 generation, 동의 영속화/취소, 건강한 서버 시작 후 실행, 주기·jitter·wake coalescing·재시도. disabled/일반 dev/바인딩 불일치 QA는 네트워크·쓰기 0. +- [ ] `supervisor.rs`의 실제 health 및 navigate 성공 뒤 `ready = true` 위치에 준비 경로만 연결. 설치·재시작·attempt 제거 권한은 추가하지 않는다. + +## 3. G001 및 설치 통합의 차단 조건 + +- [ ] 선택된 updater **2.6.0**에서 실제 macOS 승인/취소 및 작업 종료·privileged writer 부재 증명. 2.11 분석을 선택 버전 증거로 사용하지 않는다. +- [ ] 취소 후 전체 A 무결성과 작업 종료가 모두 입증돼야 보류/시작 가능. 과거 auth runner의 `passed:true`는 사용자 취소나 writer 종료 증명이 아니다. +- [ ] durable attempt 게시/종료 판정, 모든 startup/Retry/quit/restart 경로의 소유권·gate. 현재 `updater_attempt.rs`는 존재 여부 차단 reader일 뿐 writer/resolver가 아니다. +- [ ] 불확실한 설치/승인 후 실패는 embedded recovery, 서버와 Retry 차단. timeout/heartbeat/health/version을 근거로 installer를 버리거나 재시도하지 않는다. +- [ ] 실제 macOS 13 실행 및 Linux locked build/패키지/런타임 inertness. macOS 26 측정과 정적 dependency 확인은 대체 증거가 아니다. + +이 조건 전에는 product 설치 경로, attempt 자동 제거, 커스텀 installer/rollback을 연결하지 않는다. + +## 4. 나머지 제품 기능 및 최종 검증 + +- [ ] 현재 main webview/navigation epoch에 결합된 인증된 native/backend bridge. 로그 프레임·복사 쿠키·브라우저·다른 창·이전 epoch는 권한 없음. +- [ ] HTTP/WS 및 모든 내부 producer의 zero-gap admission/accounting, 실제 업무 종료와 owned server exit 증명 후에만 수동 재시작. PTY/승인/위임/백그라운드/미확인 작업은 busy. +- [ ] Settings → About 및 embedded applying/recovery UI, 자동 업데이트 opt-out/진행/오류/메모, 실제 시스템 인증창 사전 안내. 채팅 입력창 컨트롤 추가 금지. +- [ ] 교정된 updater-enabled QA A→B에서 설치 전 서버 차단, 성공 후 독립 버전/health, 사용자 데이터·origin·draft·프로젝트/worktree 보존을 실제 확인. +- [ ] 전체 변경 합집합 검증, cleaner/architect/red-team/terminal critic, 기존 runbook 갱신. production key custody·Apple 서명·공개 릴리스는 별도 승인 사항. + +## 재사용할 증거 / 재개 검증 + +기존 네트워크·서명·lock·릴리스 증거를 다시 만들지 말고 재사용한다. `dist-native/updater-evidence/p0-network-L07XVN/`에 크기/지연/TLS 거부 원시 기록과 해시가 있다. `untrusted-tls-manifest.json`은 unknown-CA, HTTP 요청/본문 0, 전체 A 불변 증거다. 모두 **G0 전체 완료나 실제 OS13/설치 종료 증거는 아니다**. + +재개 시 현재 파일과 Cargo.lock을 먼저 맞춘 뒤 변경 합집합에 대해 실행할 출발점: + +```sh +. "$HOME/.nvm/nvm.sh" && nvm use 22 +node --test scripts/release/updater-artifacts.test.mjs +. "$HOME/.cargo/env" +env -u TAURI_CONFIG CARGO_NET_OFFLINE=true cargo +1.85.1 test --locked --manifest-path src-tauri/Cargo.toml +env -u TAURI_CONFIG CARGO_NET_OFFLINE=true cargo +1.85.1 test --locked --manifest-path src-tauri/Cargo.toml --example updater_probe +``` + +위 명령은 인계 중 실행하지 않았다. compile/unused 경고, 테스트 실패, 신규 계약 불일치를 먼저 해결하고 전체 gate와 실제 플랫폼 검증으로 확장한다. `/tmp` fixture feed는 종료됐을 수 있으며 TLS 인증서 만료를 확인한 후 사용한다. 설치·인증 실험을 자동 반복하지 않는다. diff --git a/docs/plans/macos-auto-update.md b/docs/plans/macos-auto-update.md new file mode 100644 index 00000000..8f4ae741 --- /dev/null +++ b/docs/plans/macos-auto-update.md @@ -0,0 +1,182 @@ +# macOS 자동 업데이트 구현 계획 + +상태: 구현 승인 전 계획 초안. 제품 코드·키·릴리스·배포 환경 변경 없음. +범위: macOS 데스크톱부터. 현재 배포 대상에 맞춰 Apple Silicon(arm64)을 1차 대상으로 한다. Intel/universal, Linux, Windows, 웹 셀프호스트 서버 업데이트는 제외한다. + +## 1. 사용자 경험과 기본 정책 + +목표는 새 배포를 앱이 알아서 내려받고 적용하는 것이다. 채팅 도중 갑자기 종료시키는 기능이 아니다. + +- 정식 배포 앱에서 자동 확인·다운로드를 기본 활성화한다. 설정 > 정보에 끄기와 수동 확인을 제공한다. +- 서버가 정상 기동한 뒤 최초 확인, 이후 6시간 간격에 작은 jitter를 둔다. 절전 복귀 때는 마지막 확인 시각을 기준으로 중복 요청을 합친다. 네트워크 실패는 제한된 backoff로 재시도하고 앱 실행을 막지 않는다. +- 다운로드·서명 검증은 백그라운드에서 한다. 앱 사용 중에는 번들 파일을 교체하지 않는다. +- 검증 완료한 업데이트는 **다음 정상 앱 시작 때, 서버·worker를 시작하기 전에 자동 적용**한다. 실행 중에 임의로 자동 재시작하지 않는다. +- 바로 적용하려는 사용자를 위해 설정 화면에 `업데이트 후 재시작`을 제공한다. 전체 실행 상태가 안전하지 않으면 이유를 표시하고 적용을 보류한다. 강제 종료 옵션은 제공하지 않는다. +- 설정 > 정보에 현재 앱 버전, 데스크톱 빌드 버전, 업데이트 버전, 진행률, 대기 사유, 재시도, 릴리스 노트를 표시한다. 채팅 입력줄에는 버튼이나 상시 배너를 추가하지 않는다. 설치 준비 완료 안내는 한 번만 표시한다. +- 수동 확인은 자동 업데이트를 꺼도 가능하다. 자동 업데이트를 끄면 예약 설치도 취소하며, 수동 적용은 별도 명시적 동작이다. +- 베타 설치는 베타와 정식 릴리스를, 정식 설치는 정식 릴리스만 받는다. 더 낮거나 같은 데스크톱 버전은 설치하지 않는다. 1차에는 채널 전환 UI를 추가하지 않는다. +- 이미 배포된 updater 없는 beta.9는 원격으로 이 기능을 받을 수 없다. **updater가 들어간 최초 버전은 DMG로 한 번 설치해야 한다.** 그다음 배포부터 자동 업데이트한다. + +이 정책과 arm64 우선 범위는 본 계획의 권장 기본값이다. 구현 승인은 이 기본값까지 포함하는 것으로 정리한다. + +## 2. 확인한 현재 구조 + +| 근거 | 현재 상태 / 영향 | +| --- | --- | +| `src-tauri/Cargo.toml` | Tauri `=2.6.0`, Rust 최소 `1.85`, updater 의존성 없음. 최신 updater를 그대로 추가할 수 있다고 가정하면 안 된다. | +| `src-tauri/tauri.conf.json` | DMG 배포, 외부 server binary와 `server-payload` 포함. 기본 signingIdentity는 ad-hoc이며 배포 파이프라인에서 별도 서명한다. | +| `src-tauri/scripts/tauri.mjs` | `package.json.desktopVersion`을 Tauri version에 주입하고 Cargo version과 일치를 검사한다. | +| `package.json` | 제품 버전 `2.0.0-beta.9`, desktopVersion `0.2.3`. 두 버전은 서로 다른 용도다. | +| `.github/workflows/release.yml` | arm64 앱 빌드 → Developer ID 서명 → 앱 notarize/staple → DMG → DMG notarize/staple. 현재 desktop 허용 산출물은 DMG와 checksum 두 개뿐이다. | +| `scripts/release/LOCAL-RELEASE.md`, `local-release.mjs` | 로컬 서명 빌드를 검증한 기존 draft만 명시적으로 publish하는 별도 경로가 있다. CI만 바꾸면 로컬 릴리스는 빠진다. | +| `src/hooks/useVersionCheck.ts` | GitHub `releases/latest` 기반 알림뿐이며 설치 기능은 없다. 숫자 분할 비교는 beta.9 → beta.10 같은 prerelease 비교에 적합하지 않다. | +| `src/components/settings/view/tabs/AboutTab.tsx` | 기존 버전 표시·릴리스 링크가 있다. 데스크톱 업데이트 UI는 이 위치를 확장한다. | + +공식 Tauri updater는 서명이 필수이며 macOS 업데이트 입력은 DMG가 아닌 `.app.tar.gz`다. 다운로드와 설치 API가 분리돼 있으므로 앱 사용 중 다운로드만 하고 설치를 미룰 수 있다. 공식 문서에서 확인한 최신 updater 2.11.0은 Tauri ^2.10을 요구한다. 현재 정확히 고정한 Tauri 2.6과 바로 결합하지 않는다. + +## 3. 아키텍처 결정 + +### 3.1 네이티브가 업데이트를 소유 + +- Tauri 공식 Rust updater를 사용한다. 자체 앱 교체기·shell 다운로드 설치기를 만들지 않는다. +- Rust update coordinator가 확인, 다운로드, 검증, staging, 설치, 재시작 상태의 유일한 소유자다. React는 제한된 조회/설정/수동 확인/재시작 요청만 보낸다. +- 브라우저·Tailscale 웹 접속에는 네이티브 업데이트 권한과 UI를 노출하지 않는다. `window` 속성 존재만으로 권한을 판단하지 않는다. +- 현재 loopback SPA는 의도적으로 Tauri IPC 권한이 없다. 이를 유지한다. UI는 기존 desktop bootstrap cookie/Origin 인증을 거쳐 sidecar의 제한된 update 요청을 사용하고, Rust coordinator와 sidecar는 실행별 비밀값으로 인증된 전용 제어 채널을 추가한다. Rust가 비밀값을 보관하고 sidecar 소유권을 확인해야 하며 브라우저로 비밀값을 전달하지 않는다. +- 서버는 native 상태의 전달자일 뿐 설치 권한을 갖지 않는다. native가 허용된 요청 종류·현재 상태·실행 소유권을 재검증한다. 임의 URL, 설치 경로, 키, 명령행 인자는 프런트엔드에서 받지 않는다. updater plugin 권한이나 remote IPC capability를 SPA에 추가하지 않는다. +- Linux 빌드와 QA/dev 실행은 자동 다운로드/설치 대상에서 제외한다. 별도 업데이트 QA 빌드는 격리된 HOME, 전용 키와 피드로만 시험한다. + +### 3.2 릴리스·버전 계약 + +- 업데이트 비교 기준은 Tauri가 실제 사용하는 `desktopVersion`이다. 제품 버전은 사용자 표시와 GitHub 태그에 유지한다. +- 모든 updater 대상 배포는 desktopVersion도 반드시 증가시킨다. CI와 로컬 publisher가 이전 공개 배포와 비교해 중복·역행을 차단한다. +- 1차는 기존 GitHub Releases를 배포 원본으로 사용한다. 새 업데이트 서버는 운영하지 않는다. +- 네이티브가 공개 releases 목록을 제한된 pagination과 ETag 캐시로 조회한다. draft 제외, 허용 채널, `darwin-aarch64` 대상, 검증 가능한 updater manifest가 있는 후보만 취급한다. `/releases/latest` 하나로 베타 채널을 처리하지 않는다. +- 릴리스별 정적 `desktop-update.json`에 표준 Tauri 필드 `version`, `notes`, `pub_date`, `platforms.darwin-aarch64.{url,signature}`와 제품 버전/채널/최소 OS 정보를 둔다. `version`은 desktopVersion이다. +- 선택된 release tag의 immutable asset URL만 사용한다. repository는 `shared/productIdentity.js`의 canonical identity에서 유도하고 네이티브 생성 설정과 identity 검증에 연결한다. UI 문자열을 URL 권한으로 사용하지 않는다. +- HTTPS 강제, 다운로드 redirect 정책도 검증한다. 업데이트 서명은 내장 공개키로 확인한다. checksum은 무결성 보조 자료이며 서명 대체물이 아니다. +- 공개 metadata를 artifact 서명으로 보호된 것처럼 취급하지 않는다. 검증된 archive 내부의 번들 버전·identifier·architecture·최소 OS가 선택한 manifest와 일치해야 한다. 이 검사는 안전한 archive 검증 방식으로 수행하고 직접 설치기를 만들지 않는다. + +### 3.3 정확한 서명 순서 + +1. 동일 source commit에서 앱과 포함된 Node/Bun/native/server payload를 빌드한다. +2. 기존 절차대로 Developer ID 서명, notarization, stapling, 복사 후 검증을 완료한다. +3. **최종 `.app`에서** updater `.app.tar.gz`를 생성한다. +4. 그 최종 archive를 별도의 Tauri updater private key로 서명하고 `.sig`, checksum, manifest를 생성한다. +5. 기존 DMG와 새로운 updater archive가 동일 제품/desktop 버전·번들을 담는지 검증한다. +6. 모든 asset을 draft에 업로드하고 기존 CI/로컬 acceptance를 확장해 검증한 뒤에만 publish한다. + +현재 Tauri build 직후 만든 updater archive는 이후 Developer ID 서명/stapling 이전 바이트일 수 있다. `createUpdaterArtifacts: true`만 켜서 그 초기 산출물을 게시하면 안 된다. Tauri 호환 포맷의 최종 archive 생성·서명 단계를 release tooling에 명시한다. + +Updater 개인키는 Apple Developer ID와 별개다. 보호된 release secret 또는 기존 로컬 보안 저장소에서만 사용하고 repo·앱·로그에는 넣지 않는다. 생성·백업·복구 책임자가 정해져야 첫 updater-enabled 공개 배포를 할 수 있다. 키 유실 시 기존 설치에 업데이트를 더 배포하지 못할 수 있으므로 수동 재설치 복구 절차를 문서화한다. + +### 3.4 상태와 안전한 적용 + +상태 흐름: + +`idle → checking → downloading → verifying → ready → applying → restarting → idle` + +`deferred`는 설치 보류, `error`는 재시도 가능한 실패를 별도 표시한다. 중복 확인·다운로드·설치는 coordinator의 단일 작업으로 합친다. + +- staging은 앱 번들 밖 전용 사용자 cache에 보관하고 원자적 rename으로 완료를 기록한다. 불완전 다운로드는 설치 후보가 아니다. +- 재시작 뒤 cache를 신뢰하지 않고 **실제 설치할 동일 bytes의 서명을 재검증**한다. Tauri `download()`의 이전 성공이 파일 재로드 후 `install()`까지 보장한다고 가정하지 않는다. 선택한 plugin의 재검증 API가 없으면 유지보수되는 동일 서명 검증 라이브러리를 사용한다. +- 시작 시 single-instance/소유권 확보 후, 이전 소유 server/process가 없음을 확인한 뒤에만 적용한다. 일반 서버 시작과 updater 적용이 경합하지 않게 한다. +- 시작 적용 위치는 `main.rs`의 `setup`에서 instance lock 획득 이후, `supervisor::start()` 이전이다. Tauri 2.6의 main-thread `restart()`는 종료 callback을 건너뛸 수 있으므로 sidecar 시작 이후에 호출하지 않는다. 업데이트 재실행의 자식이 부모 lock을 보고 조용히 종료하는 race를 bounded lock handoff/retry로 해결하고, lock 소유권 공백에 일반 두 번째 instance가 끼는 경우도 검증한다. +- 선택 updater의 `Update` 생성에 온라인 `check()`가 필요한지 P0에서 확인한다. 필요한 경우 시작 시 짧은 timeout으로 staged 버전과 동일한 manifest를 재확인하고, offline이면 기존 앱을 시작하며 적용을 미룬다. 다운로드 완료가 곧 오프라인 다음 실행 설치 보장이라는 의미는 아니다. +- 실행 중 `업데이트 후 재시작`은 백엔드의 전체 실행 상태와 원자적인 새 작업 admission 차단이 필요하다. 보고 있는 채팅의 `isProcessing`만으로 idle을 판정하지 않는다. +- 준비 중 worker, delegated task, goal 자동 continuation, 도구 실행, 대기 중 사용자 승인, queued send, 자동화 및 PTY 활동 등 restart 영향 범위를 포함한다. 모르는 상태·timeout·소유권 불명은 설치 불가다. +- React draft/첨부 및 필요한 UI 상태를 저장한 뒤 backend drain을 확인한다. drain은 설치 직전까지 유지하며 새로운 요청은 조용히 버리지 않고 명시적으로 재시도/보류 응답을 준다. +- 기존 supervisor 종료 및 단일 인스턴스 경로를 재사용한다. 업데이트 전용 무조건 kill 또는 별도 server spawn 경로를 만들지 않는다. 안전 종료 실패 시 업데이트를 보류한다. +- `SidecarLifecycle::begin_shutdown()`의 현재 shutdown fence는 되돌리는 API가 없다. 새 작업 drain은 이 fence 진입 전에 취소 가능해야 한다. fence 진입 뒤 설치 실패 시 기존 앱의 정상 재실행 또는 recovery로 이어지는 명시적 경로를 설계하고, 단순히 `supervisor::start()`를 다시 호출해 복구된다고 가정하지 않는다. +- 현재 macOS Cmd-Q/Apple quit는 preventable `ExitRequested`를 거치지 않을 수 있어 `RunEvent::Exit`의 bounded `blocking_shutdown()`이 보완한다. 이 종료 callback 안에서 업데이트 다운로드·설치를 수행하지 않는다. 서버 종료 대기 30초 초과를 설치 안전 확인으로 취급하지 않는다. +- 재시작한 앱은 desktopVersion, packaged server `/health`, 번들 런타임 기동을 확인하고 성공 상태를 기록한다. 실패 시 recovery UI와 진단/수동 재설치를 제공하고 무한 재설치·재시작 루프를 막는다. +- `/health`의 버전은 새 서버가 보고한 값끼리만 비교하지 않고 build-time expected payload version과 비교한다. bundle identifier와 영속 `desktop-port`를 유지해 같은 WebKit origin의 설정·draft가 보존돼야 한다. 업데이트로 재실행할 때 macOS Apple Event deep link도 저장·한 번만 재전달한다. + +### 3.5 실패·지원 경계 + +- 오프라인, 429, 잘못된 manifest, signature mismatch, 디스크 부족, 다운로드 중 종료: 현재 버전으로 계속 사용하며 자동 재시작하지 않는다. +- DMG mount, App Translocation, 읽기 전용 설치 위치, 다른 소유자의 설치: 자동 적용을 차단하고 정상 설치 위치로 이동/관리자 설치 안내를 제공한다. 자동 sudo 또는 권한 상승은 하지 않는다. +- 업데이트는 앱 번들만 교체한다. `~/.gajae-app`, agent 설정/인증, transcript와 사용자의 Git worktree는 삭제·이동하지 않는다. +- 1차는 **설치 이후 건강 상태에 따른 자동 버전 rollback을 보장하지 않는다.** 플러그인의 설치 실패 복구 범위는 실제 선택 버전 소스로 확인한다. 새 버전의 데이터 변경까지 되돌리는 기능은 별도 설계가 필요하다. 실패 시 업데이트 재시도 차단, 기존 데이터 보존, 명시적인 수동 복구가 acceptance다. + +## 4. 구현 순서와 완료 조건 + +### P0 — 호환성·계약 확정 + +대상: `src-tauri/Cargo.toml`, `Cargo.lock`, `tauri.conf.json`, `src-tauri/scripts/tauri.mjs`, `scripts/release/desktop-platforms.mjs`. + +- 공식 updater 중 보안상 적합하고 현재 Tauri/Rust와 호환되는 정확한 버전을 선정한다. 안전한 호환 버전이 없으면 Tauri/runtime/Rust 업그레이드를 같은 단계의 명시적 선행 작업으로 포함한다. +- 버전 비교, macOS 최소 버전, artifact 포맷, install의 실패 복구·캐시 재검증·재시작 동작을 선택 버전 기준으로 확인한다. +- 전용 QA key로 격리된 A → B bundle 교체를 검증한다. 개인키 생성과 공개 배포는 별도 승인된 운영 단계다. +- 완료: 정상 교체, 잘못된 키/서명 거부, 지원하지 않는 OS/arch 거부를 증명하고 의존성 잠금을 확정한다. 이 단계 실패 시 뒤 단계 설치 구현을 진행하지 않는다. + +### P1 — 릴리스 산출물과 배포 검증 + +대상: `.github/workflows/release.yml`, `scripts/release/local-release.mjs`, `local-release-macos.mjs`, 기존 관련 테스트, 신규 updater archive/manifest 생성 스크립트. + +- 최종 서명된 앱에서 archive/signature/manifest를 생성한다. +- 현재 두 개 desktop asset/전체 네 개 asset 제한을 정확한 새 allowlist로 갱신한다. 임의 파일 허용으로 완화하지 않는다. +- CI와 로컬 draft verifier 모두 최종 bytes, 서명, 버전 매핑, OS/arch, DMG와 archive의 동등성을 확인한다. +- 완료: asset 누락, 버전 역행, 다른 bundle, 잘못된 signature, 미서명 앱은 publish 불가. 공개 release 수정 없이 격리 fixture로 실패 경로를 검증한다. + +### P2 — 네이티브 다운로드와 다음 시작 자동 적용 + +대상: 신규 `src-tauri/src/updater.rs`, `main.rs`, `supervisor.rs`, `lifecycle.rs`, 네이티브 테스트. + +- coordinator, 채널별 release 선택, check/backoff, staging/revalidation, 시작 시 적용을 구현한다. +- single-instance와 supervisor ownership을 지키고 플러그인 설치 실패 시 정상 실행 또는 recovery로 안전하게 분기한다. +- 완료: 이전 실행 중 번들 변경 없음, 재기동 후 최종 bytes 재검증, 올바른 버전/서버 기동, 실패 반복 루프 없음. + +### P3 — 안전 재시작과 좁은 UI bridge + +대상: `desktop_origin.rs`, `main.rs`, capabilities, backend 작업 admission/상태 경계, 신규 desktop update bridge와 테스트. + +- backend runtime 소유자 기반 prepare/commit/cancel drain 계약을 구현한다. 정확한 backend 파일은 실행 전 admission 경로를 조사해 이 단계의 구현 작업을 분할한다. 웹소켓/REST/자동화/goal continuation 모두 같은 차단 경계에 들어가야 한다. +- 기존 desktop bootstrap cookie/Origin 방어를 재사용하는 제한된 SPA→sidecar 경로와, native 소유 실행별 인증 채널을 구현한다. 현재 notification bridge의 프런트엔드 형태는 참고하되 이미 native 양방향 IPC가 있다고 가정하지 않는다. 일반 웹 인증으로는 update 요청을 할 수 없다. +- 완료: idle 확인 직후 새 작업 요청 race에서도 작업 중 재시작 없음. Tailscale 웹, 외부 origin, 다른 window의 설치 요청은 거부. lifecycle 종료·재시작 및 단일 인스턴스 테스트 통과. + +### P4 — 설정 UI와 버전 표시 통합 + +대상: `AboutTab.tsx`, `useVersionCheck.ts`, desktop update hook/bridge, 관련 locale 및 DOM 테스트. + +- 기존 About 화면을 확장한다. 데스크톱에서는 native snapshot만 업데이트 상태의 source of truth로 쓴다. +- 웹에서는 설치 UI를 표시하지 않는다. 기존 웹 버전 안내를 유지하되 prerelease 숫자 분할 비교는 표준 SemVer로 교체해 모순된 최신 버전 안내를 없앤다. +- 진행률 unknown-size 다운로드, offline, deferred, manual retry, 자동 업데이트 off, restart pending 상태를 테스트한다. +- 완료: 채팅 입력 UI에 새 controls 없음. 키보드·한/영 UI, 창 크기 변화, 오류 접근성 검증. 브라우저 성공을 네이티브 설치 검증으로 보고하지 않는다. + +### P5 — 실제 서명된 두 버전으로 최종 수용 + +- 동일 테스트 계정의 격리 데이터로 서명·공증된 A → B를 검증한다. DMG 초기 설치와 updater archive 경로를 각각 검증한다. +- 생산 HOME/실제 대화/실제 배포 release를 실험 대상으로 사용하지 않는다. +- `npm run verify`에 더해 `cargo fmt --manifest-path src-tauri/Cargo.toml -- --check`, `cargo test --locked --manifest-path src-tauri/Cargo.toml` 및 실제 macOS updater e2e를 수행한다. 의존성 선택에 따라 추가 Rust lint를 포함한다. +- packaging, signature/notarization, updater install, 데이터 보존, GUI acceptance 증거를 분리한다. +- 기존 `docs/DESKTOP-TAURI-VERIFICATION.md`, `scripts/release/LOCAL-RELEASE.md`, signing-readiness 문서와 release acceptance 안내를 수정한다. +- 이후 승인된 최초 updater-enabled DMG를 배포하고, 다음 별도 버전으로 공개 채널의 실제 자동 업데이트를 검증한다. 첫 버전을 배포했다는 사실만으로 자동 업데이트 성공을 선언하지 않는다. + +## 5. 필수 검증 매트릭스 + +| 상황 | 통과 조건 | +| --- | --- | +| beta.9 → beta.10, beta → stable, stable → beta | 제품 SemVer와 monotonic desktopVersion 적용; stable이 beta를 받지 않음 | +| 같은/낮은 desktopVersion, 잘못된 OS/arch | 다운로드/설치 후보에서 제외 | +| 손상 archive, 다른 키, cache 변조, metadata/bundle 버전 불일치 | 설치 거부, 현재 앱과 데이터 보존 | +| 정상 idle 재시작 | draft 저장, admission 차단, 소유 server 종료, 새 버전 server 정상 기동 | +| 실행/승인대기/queued/goal continuation/PTY 및 상태 timeout | 적용 보류, 실행 중단 없음 | +| drain 직후 새로운 작업·다른 웹클라이언트 요청 | 작업 시작과 설치가 동시에 성공하지 않음 | +| 앱 중복 시작, update 중 deep link, QA profile | 소유 instance만 적용, 전달 보존, QA가 생산 설치를 바꾸지 않음 | +| 다운로드/검증/설치 각 단계 종료 또는 전원 중단 | partial 상태를 완료로 취급하지 않음; 선택 plugin의 복구 경계를 실제 입증 | +| 429/offline/timeout/디스크 부족/권한 부족 | 무한 재시도·무한 재시작 없음, 명확한 상태와 수동 복구 | +| 정상 업데이트 전후 | 사용자 인증·설정·프로젝트·transcript·draft 보존, runtime manifest 및 `/health` 정상 | +| updater 없는 기존 beta.9 | 최초 DMG 설치 필요 안내, 자동 업데이트 가능하다는 잘못된 안내 없음 | + +## 6. 승인·실행 경계와 참고 + +이 문서는 계획이다. updater 설치·의존성 변경·서명키 생성·GitHub secret 변경·commit/push·릴리스 게시를 실행하지 않았다. 저장소에 있던 채팅 UI 변경과 실행 중 preview는 본 계획에서 변경하지 않는다. + +실행 시작 전에 확정할 운영 항목: updater 개인키 보관·백업 담당, 기존 로컬 Developer ID/공증 경로 사용 여부와 CI secret 준비 상태. 민감한 값을 채팅이나 문서로 수집하지 않는다. 준비되지 않으면 로컬 fixture 구현은 가능하지만 공개 배포는 차단한다. + +참고: +- https://v2.tauri.app/plugin/updater/ +- https://docs.rs/tauri-plugin-updater/2.11.0/tauri_plugin_updater/struct.Update.html +- `src-tauri/tauri.conf.json`, `src-tauri/scripts/tauri.mjs` +- `.github/workflows/release.yml` +- `scripts/release/LOCAL-RELEASE.md` diff --git a/package-lock.json b/package-lock.json index f0d2ea72..06cebff5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", + "semver": "^7.8.5", "tailwind-merge": "^3.3.1", "ws": "^8.14.2", "zod": "^4.4.3", @@ -91,6 +92,7 @@ "react-scan": "^0.5.7", "release-it": "^20.2.1", "tailwindcss": "^4.3.3", + "tar": "^7.5.22", "tsc-alias": "^1.8.16", "tsx": "^4.23.1", "typescript": "^5.9.3", @@ -245,6 +247,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -279,6 +291,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -648,19 +670,6 @@ "node": ">=v18" } }, - "node_modules/@commitlint/is-ignored/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@commitlint/lint": { "version": "20.5.0", "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", @@ -838,19 +847,6 @@ } } }, - "node_modules/@conventional-changelog/git-client/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@dabh/diagnostics": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", @@ -2315,6 +2311,19 @@ } } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -3926,18 +3935,6 @@ "node": ">= 14" } }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@puppeteer/browsers/node_modules/socks-proxy-agent": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", @@ -4406,19 +4403,6 @@ "release-it": "^18.0.0 || ^19.0.0 || ^20.0.0" } }, - "node_modules/@release-it/conventional-changelog/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -6252,19 +6236,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", @@ -8337,19 +8308,6 @@ "dev": true, "license": "MIT" }, - "node_modules/conf/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/conf/node_modules/type-fest": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", @@ -8484,19 +8442,6 @@ "node": ">=18" } }, - "node_modules/conventional-changelog-writer/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/conventional-commits-filter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", @@ -9909,19 +9854,6 @@ } } }, - "node_modules/eslint-plugin-import-x/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -10033,6 +9965,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-tailwindcss": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-4.4.0.tgz", @@ -12038,19 +11980,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -14774,6 +14703,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -15046,18 +14998,6 @@ "node": ">=10" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -15077,6 +15017,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -15125,19 +15075,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -17627,13 +17564,15 @@ "license": "Apache-2.0" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -18528,6 +18467,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", @@ -18556,6 +18512,26 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", diff --git a/package.json b/package.json index af1622fa..b55d6462 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "tauri": "node src-tauri/scripts/tauri.mjs", "desktop:sign:macos": "node scripts/release/finalize-macos-app.mjs", "desktop:dmg:macos": "node scripts/release/make-macos-dmg.mjs", + "desktop:artifacts:macos": "node scripts/release/make-macos-updater.mjs", "build": "npm run build:client && npm run build:server && npm run build:core", "build:client": "vite build", "build:core": "node scripts/build-rust-core.mjs --release", @@ -162,6 +163,7 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", + "semver": "^7.8.5", "tailwind-merge": "^3.3.1", "ws": "^8.14.2", "zod": "^4.4.3", @@ -206,6 +208,7 @@ "react-scan": "^0.5.7", "release-it": "^20.2.1", "tailwindcss": "^4.3.3", + "tar": "^7.5.22", "tsc-alias": "^1.8.16", "tsx": "^4.23.1", "typescript": "^5.9.3", diff --git a/scripts/release/LOCAL-RELEASE.md b/scripts/release/LOCAL-RELEASE.md index d1d9085a..758aa168 100644 --- a/scripts/release/LOCAL-RELEASE.md +++ b/scripts/release/LOCAL-RELEASE.md @@ -9,8 +9,9 @@ this local route. The tool never creates a release, uploads/replaces/deletes an asset, changes a tag, signs an artifact, reads a signing private key, or exports credentials. -It needs the existing authenticated `gh` session and macOS arm64 verification -tools. A publish request can cause GitHub to create the draft's still-absent +It needs the existing authenticated `gh` session, official Minisign **0.12**, +the trusted updater public-key file, and macOS arm64 verification tools. +A publish request can cause GitHub to create the draft's still-absent tag at its pinned target commit. Existing tags must already resolve to that same commit, including annotated tags. @@ -25,6 +26,38 @@ exported throughout that build. No credential or PKCS#12 export is needed. The existing signed-build instructions remain in `docs/DESKTOP-TAURI-VERIFICATION.md`. +The first updater-enabled build requires one manual DMG installation: +beta.9 has no updater. Its `desktopVersion` must exceed both `0.2.3` and every +previously published desktop version across beta/stable. Product version is +display/tag identity; it is not the install-order counter. Missing historical +tag/commit/package mappings block publication, rather than lowering the floor. +Real signed/notarized A-to-B acceptance, authorization approve/cancel behavior, +recovery, lifecycle and data-survival gates remain required before publication. + +The macOS bundle minimum is **13.0**. This is a loader requirement, not merely +an `Info.plist` declaration: the verifier runs `xcrun vtool -show-build` on +the desktop and server executables, Bun/Rust payload runtimes, and bounded +native runtime modules discovered in the app inventory. Every `LC_BUILD_VERSION` +`platform MACOS` `minos` stamp must be present, well-formed, supported, and no +newer than the pinned `minimumSystemVersion`. A declaration of 11.0 therefore +cannot pass when Bun (or another bundled Mach-O) requires 13.0. macOS 13 +execution itself remains a separate acceptance gate. + +After final app/DMG acceptance, create the updater archive with +`make-macos-updater.mjs`. It verifies a private quarantined app copy, packs the +unchanged final app, invokes the official Tauri signer, verifies a private +snapshot with `minisign -V -H`, and compares the extracted archive with the +DMG app (all member bytes, modes and symlink targets). It never signs/staples +the app itself. Do not recompress or modify an archive after signing. + +Keep the updater private key and its backup under the approved key-custody +procedure. Supply `TAURI_SIGNING_PRIVATE_KEY_PATH` or +`TAURI_SIGNING_PRIVATE_KEY` only through the signer's supported environment; +encrypted keys also need `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Never put key +contents/passwords on argv, in release assets or in logs. The verifier accepts +only the public-key file. No key generation, credential export, or production +signing is implied by these instructions. + For the final SDK 0.16.4 candidate, `scripts/release/MACOS-ACCEPTANCE.md` provides the pinned source snapshot, isolated build paths, bounded local notarization, quarantined copy verification, and separate packaged smokes. @@ -50,13 +83,26 @@ VERSION="$(node -p "require('./package.json').version")" TAG="v$VERSION" TEAM_ID=5987KT43TJ DMG=/absolute/path/to/accepted/macos.dmg +APP="/absolute/path/to/accepted/Gajae Code App.app" SERVER=/absolute/path/to/accepted/server.tar.gz NOTES=/absolute/path/to/reviewed-release-notes.md +UPDATER_PUBLIC_KEY=/absolute/path/to/trusted/updater-public.key +UPDATER_DIR=/absolute/path/to/new/updater-assets +minisign -v # must report minisign 0.12 +node scripts/release/make-macos-updater.mjs \ + --app "$APP" --dmg "$DMG" --output "$UPDATER_DIR" \ + --commit "$RELEASE_COMMIT" --team-id "$TEAM_ID" \ + --updater-public-key-file "$UPDATER_PUBLIC_KEY" --notes-file "$NOTES" \ + --pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +DMG="$UPDATER_DIR/gajae-app-desktop-$VERSION-macos-arm64.dmg" +UPDATER="$UPDATER_DIR/gajae-app-desktop-$VERSION-macos-arm64.app.tar.gz" +MANIFEST="$UPDATER_DIR/desktop-update.json" test "$(basename "$DMG")" = "gajae-app-desktop-$VERSION-macos-arm64.dmg" test "$(basename "$SERVER")" = "gajae-app-server-$VERSION-linux-x64-node22.tar.gz" test -f "$DMG.sha256" test -f "$SERVER.sha256" DMG_SHA="$(shasum -a 256 "$DMG" | awk '{print $1}')" +UPDATER_SHA="$(shasum -a 256 "$UPDATER" | awk '{print $1}')" SERVER_SHA="$(shasum -a 256 "$SERVER" | awk '{print $1}')" ``` @@ -78,7 +124,8 @@ asset upload with `--clobber` to get past that failure. prerelease_args=() if [[ "$VERSION" == *-* ]]; then prerelease_args+=(--prerelease); fi gh release create "$TAG" \ - "$DMG" "$DMG.sha256" "$SERVER" "$SERVER.sha256" \ + "$DMG" "$DMG.sha256" "$UPDATER" "$UPDATER.sig" "$UPDATER.sha256" \ + "$MANIFEST" "$SERVER" "$SERVER.sha256" \ --repo "$REPO" --target "$RELEASE_COMMIT" --draft \ --title "Gajae Code App $TAG" --notes-file "$NOTES" \ "${prerelease_args[@]}" @@ -89,8 +136,9 @@ DRAFT_ID="$(gh release view "$TAG" --repo "$REPO" --json databaseId --jq .databa verify_args=( --repo "$REPO" --draft-id "$DRAFT_ID" --tag "$TAG" --commit "$RELEASE_COMMIT" - --team-id "$TEAM_ID" + --team-id "$TEAM_ID" --updater-public-key-file "$UPDATER_PUBLIC_KEY" --asset "$(basename "$DMG")=$DMG_SHA" + --asset "$(basename "$UPDATER")=$UPDATER_SHA" --asset "$(basename "$SERVER")=$SERVER_SHA" ) node scripts/release/local-release.mjs "${verify_args[@]}" @@ -102,11 +150,17 @@ performs no GitHub write. Missing arguments exit 2; any validation failure exits 1 and leaves the draft and its assets intact. An already-public release is refused before downloads or publication. -For additional Linux desktop payloads, include each artifact and sidecar in +There are six mandatory macOS assets and two mandatory server assets. The +manifest and updater signature are typed bounded sidecars, not extra +checksum-bearing payloads. `--mode ci` requires exactly those eight assets. + +For additional explicitly pinned payloads, including Linux desktop builds, +include each artifact and checksum sidecar in the initial draft creation and append one `--asset "BASENAME=SHA256"` entry per payload to `verify_args`. Unknown or missing assets block publication rather -than being ignored or removed. The canonical Mac DMG and Linux server archive -remain mandatory. Optional payloads receive hash/sidecar validation here; +than being ignored or removed. The canonical Mac DMG, signed updater archive, +manifest/signature sidecars and Linux server archive remain mandatory. +Optional payloads receive hash/sidecar validation here; their platform/installer acceptance remains with their packaging owner. ## Explicit publication, after reviewing the verification result @@ -133,6 +187,7 @@ Each invocation requires: - An exact asset set matching the caller's independent hashes and sidecars, including uploaded state, IDs, lengths and any supplied GitHub digests. Downloads use asset IDs and exclusively created temporary files. + Each download is streaming-capped to its inspected byte count. - The Linux archive's root package name/version and the copied Mac payload's package name/version. `CFBundleIdentifier` and desktop version are checked independently against product identity and the pinned source commit. @@ -140,6 +195,13 @@ Each invocation requires: runtime, valid DMG/app staples, Gatekeeper acceptance, and arm64 desktop and sidecar binaries. The app is checked both on the read-only mount and after copying to a quarantined writable location outside a checkout. + The updater-extracted app receives the same checks, including the pinned + minimum macOS version and exact DMG/app inventory equivalence. +- Strict manifest version/channel/repository/target/commit/URL binding, + signature-sidecar agreement and real Minisign verification over the same + immutable archive snapshot that is extracted. +- Complete bounded published-history discovery and a strictly advancing + desktop version, checked again before publication. - Unchanged draft metadata/assets and tag after downloads and verification, immediately before the optional publication request. @@ -153,7 +215,11 @@ Errors after requesting publication report `status: "publication-outcome-unknown and exit 1; they do not claim that the release stayed unpublished. Command waits are bounded: 2 minutes for metadata/local verification commands, -10 minutes per download, at most 16 payloads plus their checksum sidecars. All +10 minutes per download, and 30 seconds per history request within a five-minute +history pass. Local releases allow at most 16 explicitly pinned payloads plus +their checksum sidecars and the two updater metadata sidecars. The updater +archive/DMG cap is 250 MiB; expanded updater tar data is capped at 1 GiB. +All temporary downloads and copies are removed normally. If image detachment cannot be confirmed, the tool retains and reports its temporary directory; inspect/detach that mount before deleting it. It never recursively removes a @@ -165,7 +231,15 @@ of the already-published beta.8 DMG (asset ID `542909888`). Its recorded SHA-256 matched; the real macOS checker passed signatures, expected team, staples, Gatekeeper, mounted/quarantined-copy verification, package/desktop versions and arm64 binaries. The temporary image/copy were removed after detachment. -This historical fixture validates the tooling, not the upcoming candidate. +The six-asset updater builder was also exercised against this same cached, +independently pinned image using a disposable updater key. Real Developer ID, +staple and Gatekeeper checks passed for the mounted, quarantined and +updater-extracted apps; complete archive/DMG inventories matched. That +historical package declares 11.0 while its Bun Mach-O is stamped 13.0, so the +new deployment-floor guard correctly rejects it; the earlier run demonstrates +signing/inventory tooling only and is not current release acceptance. The +original image remained unchanged and temporary copies/mounts were cleaned up. +No new signing, release acceptance, or installed A-to-B behavior is claimed. No new candidate was built, draft created, release published or signing credential exported while implementing this route. The parent must run it diff --git a/scripts/release/SIGNING-READINESS.md b/scripts/release/SIGNING-READINESS.md index 1d74b4ce..192873e4 100644 --- a/scripts/release/SIGNING-READINESS.md +++ b/scripts/release/SIGNING-READINESS.md @@ -16,7 +16,9 @@ exit 1 means blocked; exit 2 means invalid CLI syntax. Each external command has a 30-second timeout. Raw command errors, credentials and notarization history entries are suppressed. `--mode ci` inspects environment-variable presence only; `--mode github` requests only secret names and update times. -Neither can certify credentials or a built artifact. +These checker modes cover the Apple-signing prerequisites below. The workflow +separately checks updater key/password/public-key configuration. Neither check +certifies credentials, updater key custody, or a built artifact. The workflow now requires signing inputs before installing/building the macOS payload and independently rejects an unsigned desktop at publication. @@ -27,7 +29,7 @@ an ad-hoc asset and replacing it later. ## GitHub-hosted signing requirements -The macOS job uses the `release` environment. Its exact required secret names +The macOS job uses the `release` environment. Its Apple-signing secret names are below; repository secrets are also visible to the job, with environment secrets taking precedence. The checker does not inspect organization grants. This repository is user-owned, so that limitation does not affect its result. @@ -40,6 +42,17 @@ This repository is user-owned, so that limitation does not affect its result. | `APPLE_TEAM_ID` | Team owning the signing identity and notarization account | | `APPLE_APP_PASSWORD` | App-specific notarization password, not the Apple account password | +The updater lane additionally requires `TAURI_SIGNING_PRIVATE_KEY` and +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets, plus the +`GAJAE_UPDATER_PUBLIC_KEY` repository/environment variable containing the +matching Tauri base64 public key. The workflow requires nonempty values and +official Minisign 0.12. The public key is not a credential; private keys and +passwords must never appear on argv, in artifacts or logs. Provisioning, +backup/recovery and production-versus-QA key/feed binding remain separately +approved readiness work, not consequences of an Apple certificate check. +Updater private credentials are scoped to the readiness and final archive +signing steps, not dependency installation, app builds, or runtime smokes. + `DISCORD_WEBHOOK_URL` is optional for announcements. `GITHUB_TOKEN` is supplied by Actions for publication; it is not a signing secret to provision. This task does not export or upload any private key or credential. Existing @@ -104,6 +117,11 @@ with `--publish`. This path requires no hosted signing secrets and preserves the workflow's unsigned-publication guard. Neither route was dispatched or published while implementing these checks. +Hosted dispatch is also draft-only by default. Its explicit `publish` input +authorizes publication only after the shared verifier has rechecked all eight +assets, signatures, exact commit, complete desktop-version history and mutable +draft/tag inputs. Announcements run only after successful explicit publication. + ## Bounded validation after integration 1. Run the parent integration's `npm run verify` on supported Node, Rust and @@ -116,6 +134,8 @@ published while implementing these checks. for a bounded submission wait. If it remains in progress, record the submission ID and use `notarytool info` on that ID; do not submit duplicates. Never re-sign a stapled app; regenerate the DMG checksum after stapling. + Then follow `LOCAL-RELEASE.md` to build/sign the unchanged final app archive + and verify DMG/updater equivalence. Never use a pre-staple updater archive. 3. Require DMG and app staples, Gatekeeper acceptance, and deep/strict signatures both on the mounted image and on a quarantined copy on a writable volume. Run both packaged-server smokes below against that copy, diff --git a/scripts/release/check-signing-readiness.test.mjs b/scripts/release/check-signing-readiness.test.mjs index f741664a..a8f66f80 100644 --- a/scripts/release/check-signing-readiness.test.mjs +++ b/scripts/release/check-signing-readiness.test.mjs @@ -206,8 +206,14 @@ test('failed authentication, command timeout, and unexpected notarization respon test('workflow requires all signing inputs before expensive macOS work and cannot fall back to ad-hoc', () => { const desktop = workflow.slice(workflow.indexOf(' desktop-macos:'), workflow.indexOf(' ubuntu-24-compatibility:')); assert.match(desktop, /environment: release/); + assert.doesNotMatch(desktop.slice(0, desktop.indexOf('\n steps:')), /TAURI_SIGNING_PRIVATE_KEY/); + for (const name of ['Require release signing credentials', 'Build canonical macOS updater assets']) { + for (const secret of ['TAURI_SIGNING_PRIVATE_KEY', 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD']) { + assert.ok(workflowStep(name).includes(`${secret}: ` + '${{ secrets.' + secret + ' }}')); + } + } assert.deepEqual([...desktop.matchAll(/\$\{\{ secrets\.(APPLE_[A-Z0-9_]+) }}/g)].map(match => match[1]), REQUIRED_SIGNING_SECRETS); - assert.match(workflowStep('Require release signing credentials'), /run: node scripts\/release\/check-signing-readiness.mjs --mode ci/); + assert.match(workflowStep('Require release signing credentials'), /node scripts\/release\/check-signing-readiness\.mjs --mode ci/); const preflight = desktop.indexOf('- name: Require release signing credentials'); assert.ok(preflight < desktop.indexOf('- name: Set up Rust')); assert.ok(preflight < desktop.indexOf('- name: Install dependencies')); @@ -221,8 +227,12 @@ test('workflow requires all signing inputs before expensive macOS work and canno test('the actual publication guard rejects missing/false/malformed signing output and accepts only true', () => { const body = stepShell('Require signed desktop before publication'); assert.match(workflowStep('Require signed desktop before publication'), /DESKTOP_SIGNED: \$\{\{ needs\.desktop-macos\.outputs\.signed }}/); - const publish = workflow.slice(workflow.indexOf(' publish:')); - assert.ok(publish.indexOf('- name: Require signed desktop before publication') < publish.indexOf('- name: Download canonical server release assets')); + const publishStart = workflow.lastIndexOf('\n publish:'); + assert.notEqual(publishStart, -1); + const publish = workflow.slice(publishStart); + const guardIndex = publish.indexOf('- name: Require signed desktop before publication'); + const serverDownloadIndex = publish.indexOf('- name: Download canonical server release assets'); + assert.ok(guardIndex >= 0 && serverDownloadIndex >= 0 && guardIndex < serverDownloadIndex); for (const signed of ['', 'false', 'TRUE', ' true ', 'true\n', 'true']) { const result = spawnSync('bash', ['-c', body], { env: { ...process.env, DESKTOP_SIGNED: signed }, encoding: 'utf8' }); assert.equal(result.status, signed === 'true' ? 0 : 1, JSON.stringify(signed)); diff --git a/scripts/release/ci-release.mjs b/scripts/release/ci-release.mjs new file mode 100644 index 00000000..70913373 --- /dev/null +++ b/scripts/release/ci-release.mjs @@ -0,0 +1,699 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { constants, realpathSync } from 'node:fs'; +import { mkdtemp, open, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import semver from 'semver'; + +import { PACKAGE_NAME, REPOSITORY_SLUG } from '../../shared/productIdentity.js'; + +import { + MACOS_UPDATE_TARGET, + assetNames, + buildDesktopUpdateManifest, + strictVersion, + UPDATER_ASSET_LIMITS, + validateDesktopUpdateManifest, + validateDesktopVersionFloor, +} from './updater-artifacts.mjs'; +import { collectPublishedDesktopHistory, resolveReleaseTag } from './updater-history.mjs'; +import { assertChecksum, processLocalRelease as sharedProcessLocalRelease } from './local-release.mjs'; +import { releaseCommand } from './local-release-command.mjs'; +import { readUpdaterSidecar, verifyUpdaterSignature } from './updater-signature.mjs'; + +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 30_000; +const TRANSFER_TIMEOUT_MS = 10 * 60_000; +const OVERALL_TIMEOUT_MS = 30 * 60_000; +const API_OUTPUT_LIMIT = 8 * 1024 * 1024; +const COMMIT = /^[a-f0-9]{40}$/; +const TEAM_ID = /^[A-Z0-9]{10}$/; +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$/; +const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; +const PATH_CONTROL = /[\u0000-\u001f\u007f]/u; + +function demand(condition, message) { + if (!condition) throw new Error(message); +} + +function isRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function boundedText(value, label, maxBytes) { + demand(typeof value === 'string' && value.length > 0 + && Buffer.byteLength(value, 'utf8') <= maxBytes && !CONTROL.test(value), + `${label} is missing or oversized.`); + return value; +} + +function positiveId(value, label) { + demand(Number.isSafeInteger(value) && value > 0, `${label} must be a positive numeric ID.`); + return value; +} + +function safePath(value, label) { + demand(typeof value === 'string' && value.length > 0 && !PATH_CONTROL.test(value), + `${label} is missing or malformed.`); + return resolve(value); +} + +function productVersionFromTag(tag) { + demand(typeof tag === 'string' && tag.startsWith('v') && tag.length > 1, + 'Explicit canonical release tag is required.'); + const productVersion = strictVersion(tag.slice(1), 'Product version'); + const prerelease = semver.prerelease(productVersion); + demand(prerelease === null || prerelease[0] === 'beta', + 'Only beta and stable product channels are supported.'); + demand(`v${productVersion}` === tag, 'Release tag must exactly match the product version.'); + return productVersion; +} + +function validateOptions({ + repo, + tag, + commit, + teamId, + assetsDirectory, + publicKeyFile, + publish = false, + checkoutRoot = process.cwd(), +} = {}) { + demand(repo === REPOSITORY_SLUG, `Release repository is restricted to ${REPOSITORY_SLUG}.`); + const productVersion = productVersionFromTag(tag); + demand(COMMIT.test(commit ?? ''), 'Explicit full lowercase 40-character commit is required.'); + demand(TEAM_ID.test(teamId ?? ''), 'Explicit 10-character team ID is required.'); + demand(typeof publish === 'boolean', 'Publish must be boolean.'); + const assetsDir = safePath(assetsDirectory, 'Assets directory'); + const keyPath = safePath(publicKeyFile, 'Updater public-key file'); + const checkoutDir = safePath(checkoutRoot, 'Checkout directory'); + demand(keyPath !== assetsDir && !keyPath.startsWith(`${assetsDir}/`), + 'Updater public-key file must be outside the assets directory.'); + const names = assetNames({ productVersion, tag }); + return { + repo, + tag, + commit, + teamId, + assetsDirectory: assetsDir, + publicKeyFile: keyPath, + checkoutRoot: checkoutDir, + publish, + productVersion, + names, + }; +} + +async function openRegularFile(path, label) { + let file; + try { + file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + } catch { + throw new Error(`${label} must be a readable regular file.`); + } + try { + const metadata = await file.stat(); + demand(metadata.isFile(), `${label} must be a regular file.`); + return { file, size: metadata.size }; + } catch (error) { + await file.close().catch(() => {}); + throw error; + } +} + +async function hashFile(path, maxBytes, label) { + const { file, size } = await openRegularFile(path, label); + const hash = createHash('sha256'); + let bytes = 0; + try { + demand(size > 0 && size <= maxBytes, `${label} is empty or oversized.`); + for await (const chunk of file.createReadStream({ autoClose: false })) { + bytes += chunk.length; + demand(bytes <= maxBytes, `${label} exceeded its size limit.`); + hash.update(chunk); + } + } finally { + await file.close(); + } + demand(bytes === size && bytes > 0, `${label} changed while reading.`); + return { size: bytes, hash: hash.digest('hex') }; +} + +function parseJsonText(text, label) { + try { + return JSON.parse(text); + } catch { + throw new Error(`${label} must contain valid JSON.`); + } +} + +function fileLimit(name, names) { + if (name === names.macos.dmg) return UPDATER_ASSET_LIMITS.maxDmgBytes; + if (name === names.macos.archive) return UPDATER_ASSET_LIMITS.maxArchiveBytes; + if (name === names.server.archive) return UPDATER_ASSET_LIMITS.maxPayloadBytes; + if (name.endsWith('.sha256')) return UPDATER_ASSET_LIMITS.maxChecksumBytes; + if (name.endsWith('.sig')) return UPDATER_ASSET_LIMITS.maxSignatureBytes; + if (name === names.macos.manifest) return UPDATER_ASSET_LIMITS.maxManifestBytes; + throw new Error(`Unrecognized release asset: ${name}`); +} + +async function inspectAssetDirectory({ assetsDirectory, names }) { + let entries; + try { + entries = await readdir(assetsDirectory, { withFileTypes: true }); + } catch { + throw new Error('Assets directory must be readable.'); + } + const expected = new Set(names.ciAssets); + demand(entries.length === expected.size, 'Assets directory must contain exactly eight release assets.'); + const paths = new Map(); + for (const entry of entries) { + demand(entry.isFile() && SAFE_NAME.test(entry.name) && expected.has(entry.name), + 'Assets directory contains an unexpected or non-regular file.'); + demand(!paths.has(entry.name), `Duplicate release asset name: ${entry.name}`); + paths.set(entry.name, join(assetsDirectory, entry.name)); + } + for (const name of expected) demand(paths.has(name), `Missing release asset: ${name}`); + + const metadata = new Map(); + for (const name of names.ciAssets) { + const path = paths.get(name); + const identity = await hashFile(path, fileLimit(name, names), `Release asset ${name}`); + metadata.set(name, { path, ...identity }); + } + for (const [name, checksumName] of [ + [names.macos.dmg, names.macos.dmgChecksum], + [names.macos.archive, names.macos.archiveChecksum], + [names.server.archive, names.server.checksum], + ]) { + const checksum = await readUpdaterSidecar(metadata.get(checksumName).path, + UPDATER_ASSET_LIMITS.maxChecksumBytes); + assertChecksum(checksum, name, metadata.get(name).hash); + } + return metadata; +} + +function assertAssetMetadataUnchanged(expected, actual, names) { + for (const name of names.ciAssets) { + const before = expected.get(name); + const after = actual.get(name); + demand(after?.size === before?.size && after?.hash === before?.hash, + `Release asset ${name} changed during verification.`); + } +} + +async function readCheckoutSource(checkoutRoot) { + const root = safePath(checkoutRoot, 'Checkout directory'); + const packageJson = parseJsonText(await readUpdaterSidecar(join(root, 'package.json'), + UPDATER_ASSET_LIMITS.maxManifestBytes), 'Checkout package.json'); + const tauriConfig = parseJsonText(await readUpdaterSidecar(join(root, 'src-tauri/tauri.conf.json'), + UPDATER_ASSET_LIMITS.maxManifestBytes), 'Checkout Tauri configuration'); + demand(isRecord(packageJson) && packageJson.name === PACKAGE_NAME, + 'Checkout package.json has an unexpected package name.'); + const productVersion = strictVersion(packageJson.version, 'Checkout product version'); + const desktopVersion = strictVersion(packageJson.desktopVersion, 'Checkout desktopVersion'); + const minimumSystemVersion = tauriConfig?.bundle?.macOS?.minimumSystemVersion; + demand(typeof minimumSystemVersion === 'string' && minimumSystemVersion.length > 0, + 'Checkout must declare a macOS minimumSystemVersion.'); + return { root, productVersion, desktopVersion, minimumSystemVersion }; +} + +function parseApiResult(result, label) { + demand(isRecord(result) && typeof result.stdout === 'string', + `${label} command returned an invalid result.`); + demand(result.stderr === undefined || typeof result.stderr === 'string', + `${label} command returned an invalid diagnostic stream.`); + const outputBytes = Buffer.byteLength(result.stdout, 'utf8') + + Buffer.byteLength(result.stderr ?? '', 'utf8'); + demand(outputBytes <= API_OUTPUT_LIMIT, `${label} response exceeded the output limit.`); + return parseJsonText(result.stdout, `${label} response`); +} + +function attachDraftId(error, draftId) { + if (draftId !== undefined && error && typeof error === 'object') { + Object.defineProperty(error, 'draftId', { value: draftId, enumerable: true, configurable: true }); + } + return error; +} + +function outcomeError(status, message) { + return Object.assign(new Error(message), { outcomeStatus: status }); +} + +function requireApiPath(path) { + demand(typeof path === 'string' && path.length > 0 && !PATH_CONTROL.test(path), + 'GitHub API path is malformed.'); + return path; +} + +function encodedAssetUrl(repo, draftId, name) { + return `https://uploads.github.com/repos/${repo}/releases/${draftId}/assets?name=${encodeURIComponent(name)}`; +} + +function prereleaseFor(productVersion) { + return semver.prerelease(productVersion) !== null; +} + +async function copyRegularFile(sourcePath, destinationPath, maxBytes, expected, label) { + const { file, size } = await openRegularFile(sourcePath, label); + if (size !== expected.size || size <= 0 || size > maxBytes) { + await file.close().catch(() => {}); + throw new Error(`${label} changed after verification.`); + } + let output; + try { + output = await open(destinationPath, constants.O_WRONLY | constants.O_CREAT + | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + } catch (error) { + await file.close().catch(() => {}); + throw error; + } + const hash = createHash('sha256'); + let bytes = 0; + try { + for await (const chunk of file.createReadStream({ autoClose: false })) { + bytes += chunk.length; + demand(bytes <= maxBytes, `${label} exceeded its size limit.`); + hash.update(chunk); + await output.writeFile(chunk); + } + await output.sync(); + } finally { + await file.close(); + await output.close(); + } + demand(bytes === expected.size && hash.digest('hex') === expected.hash, + `${label} changed while staging.`); +} + +async function stageWithGeneratedManifest({ + sourceMetadata, + originalManifest, + draftBody, + source, + options, +}) { + const staging = await mkdtemp(join(tmpdir(), 'gajae-ci-release-')); + try { + const metadata = new Map(); + for (const name of options.names.ciAssets) { + if (name === options.names.macos.manifest) continue; + const expected = sourceMetadata.get(name); + const path = join(staging, name); + await copyRegularFile(expected.path, path, fileLimit(name, options.names), + expected, `Release asset ${name}`); + metadata.set(name, { path, size: expected.size, hash: expected.hash }); + } + const originalIdentity = sourceMetadata.get(options.names.macos.manifest); + const currentIdentity = await hashFile(originalIdentity.path, + UPDATER_ASSET_LIMITS.maxManifestBytes, 'desktop-update.json'); + demand(currentIdentity.size === originalIdentity.size + && currentIdentity.hash === originalIdentity.hash, + 'desktop-update.json changed after verification.'); + const generated = buildDesktopUpdateManifest({ + productVersion: source.productVersion, + desktopVersion: source.desktopVersion, + notes: draftBody, + pubDate: originalManifest.pub_date, + minimumSystemVersion: source.minimumSystemVersion, + commit: options.commit, + signature: originalManifest.platforms?.[MACOS_UPDATE_TARGET]?.signature, + tag: options.tag, + }); + demand(generated.platforms[MACOS_UPDATE_TARGET].url + === originalManifest.platforms?.[MACOS_UPDATE_TARGET]?.url + && generated.platforms[MACOS_UPDATE_TARGET].signature + === originalManifest.platforms?.[MACOS_UPDATE_TARGET]?.signature, + 'Generated notes replacement changed updater archive identity or signature.'); + const generatedText = `${JSON.stringify(generated, null, 2)}\n`; + boundedText(generatedText, 'Generated desktop-update.json', UPDATER_ASSET_LIMITS.maxManifestBytes); + const manifestPath = join(staging, options.names.macos.manifest); + await writeFile(manifestPath, generatedText, { flag: 'wx', mode: 0o600 }); + const manifestIdentity = await hashFile(manifestPath, UPDATER_ASSET_LIMITS.maxManifestBytes, + 'Generated desktop-update.json'); + metadata.set(options.names.macos.manifest, { path: manifestPath, ...manifestIdentity }); + return { staging, metadata }; + } catch (error) { + await rm(staging, { recursive: true, force: true }).catch(() => {}); + throw error; + } +} + +/** + * Validate, stage and upload one complete CI release, then delegate all + * remote-release verification to processLocalRelease. + * + * The draft is intentionally never deleted or retried. Once GitHub returns a + * numeric draft ID, every later failure includes it so an operator can inspect + * the preserved draft. + */ +export async function processCiRelease({ + repo, + tag, + commit, + teamId, + assetsDirectory, + publicKeyFile, + publish = false, + checkoutRoot = process.cwd(), +} = {}, { + run = releaseCommand, + verifySignature = verifyUpdaterSignature, + collectHistory = collectPublishedDesktopHistory, + processLocalRelease = sharedProcessLocalRelease, + now = Date.now, + platform = process.platform, + arch = process.arch, +} = {}) { + const options = validateOptions({ + repo, tag, commit, teamId, assetsDirectory, publicKeyFile, publish, checkoutRoot, + }); + demand(platform === 'darwin' && arch === 'arm64', + 'CI release requires macOS arm64.'); + demand(typeof run === 'function' && typeof verifySignature === 'function' + && typeof collectHistory === 'function' && typeof processLocalRelease === 'function', + 'CI release dependencies are invalid.'); + demand(typeof now === 'function', 'Clock dependency is invalid.'); + const startedAt = now(); + demand(Number.isFinite(startedAt), 'Clock returned an invalid value.'); + const overallDeadline = startedAt + OVERALL_TIMEOUT_MS; + demand(Number.isFinite(overallDeadline), 'Overall CI release deadline is invalid.'); + const boundedRun = async (program, args, extra = {}) => { + const current = now(); + demand(Number.isFinite(current) && current < overallDeadline, + 'CI release overall deadline expired.'); + const requestedTimeout = Number.isSafeInteger(extra.timeout) && extra.timeout > 0 + ? extra.timeout + : REQUEST_TIMEOUT_MS; + const timeout = Math.max(1, Math.min(requestedTimeout, Math.floor(overallDeadline - current))); + const requestDeadline = current + timeout; + const commandOptions = { ...extra, timeout }; + if (commandOptions.maxOutputBytes === undefined) commandOptions.maxOutputBytes = API_OUTPUT_LIMIT; + let result; + try { + result = await run(program, args, commandOptions); + } catch { + const failed = now(); + if (Number.isFinite(failed) && failed >= overallDeadline) { + throw new Error('CI release overall deadline expired.'); + } + if (Number.isFinite(failed) && failed >= requestDeadline) { + throw new Error('CI release request deadline expired.'); + } + throw new Error('CI release command failed; raw command output suppressed.'); + } + // Keep confirmed write responses even at the deadline. The command timeout + // bounds execution; the next command checks the remaining overall budget. + return result; + }; + const call = async (program, args, label, extra = {}) => { + try { + return await boundedRun(program, args, extra); + } catch (error) { + if (error.message.endsWith('deadline expired.')) throw error; + throw new Error(`${label} failed; raw command output suppressed.`); + } + }; + const api = async (path, args = [], label = 'GitHub API request') => { + const result = await call('gh', ['api', '--hostname', 'github.com', requireApiPath(`repos/${repo}/${path}`), ...args], label); + return parseApiResult(result, label); + }; + + const source = await readCheckoutSource(options.checkoutRoot); + demand(source.productVersion === options.productVersion, + 'Checkout product version does not match the explicit release tag.'); + let checkoutHead; + try { + checkoutHead = await boundedRun('git', ['-C', source.root, 'rev-parse', 'HEAD']); + } catch { + throw new Error('Checkout HEAD could not be verified.'); + } + demand(isRecord(checkoutHead) && typeof checkoutHead.stdout === 'string' + && checkoutHead.stdout.trim() === options.commit, + 'Checkout HEAD does not match the explicit release commit.'); + const sourceMetadata = await inspectAssetDirectory(options); + const publicKey = await readUpdaterSidecar(options.publicKeyFile, UPDATER_ASSET_LIMITS.maxSignatureBytes); + const manifestText = await readUpdaterSidecar(sourceMetadata.get(options.names.macos.manifest).path, + UPDATER_ASSET_LIMITS.maxManifestBytes); + const manifest = parseJsonText(manifestText, 'desktop-update.json'); + const signatureText = await readUpdaterSidecar( + sourceMetadata.get(options.names.macos.archiveSignature).path, + UPDATER_ASSET_LIMITS.maxSignatureBytes, + ); + const signature = signatureText.trim(); + boundedText(signature, 'Updater signature', UPDATER_ASSET_LIMITS.maxSignatureBytes); + validateDesktopUpdateManifest(manifest, { + productVersion: source.productVersion, + desktopVersion: source.desktopVersion, + tag: options.tag, + commit: options.commit, + minimumSystemVersion: source.minimumSystemVersion, + expectedSignature: signature, + }); + demand(signature === manifest.platforms[MACOS_UPDATE_TARGET].signature, + 'Updater signature sidecar does not match desktop-update.json.'); + const archive = sourceMetadata.get(options.names.macos.archive); + const signatureRoot = await mkdtemp(join(tmpdir(), 'gajae-ci-signature-')); + try { + await verifySignature({ + archivePath: archive.path, + signature, + publicKey, + root: signatureRoot, + expectedSha256: archive.hash, + }, { run: boundedRun }); + } finally { + await rm(signatureRoot, { recursive: true, force: true }).catch(() => {}); + } + const history = await collectHistory({ repo }, { run: boundedRun, now }); + validateDesktopVersionFloor({ + candidateDesktopVersion: source.desktopVersion, + priorPublished: history.priorPublished, + historyComplete: history.historyComplete, + }); + assertAssetMetadataUnchanged(sourceMetadata, await inspectAssetDirectory(options), options.names); + + let draftId; + let staging; + try { + let existingPage = 1; + const existingIds = new Set(); + const pageFingerprints = new Set(); + for (;;) { + const releases = await api(`releases?per_page=${PAGE_SIZE}&page=${existingPage}`, [], + 'Existing release lookup'); + demand(Array.isArray(releases) && releases.length <= PAGE_SIZE, + 'Existing release lookup response must be a bounded array.'); + const pageFingerprint = JSON.stringify(releases.map(release => [ + release?.id, + release?.tag_name, + ])); + demand(!pageFingerprints.has(pageFingerprint), + 'Existing release lookup returned a repeated page.'); + pageFingerprints.add(pageFingerprint); + for (const release of releases) { + demand(isRecord(release) && Number.isSafeInteger(release.id) && release.id > 0 + && !existingIds.has(release.id) + && typeof release.tag_name === 'string' + && release.tag_name.length > 0 && release.tag_name.length <= 256 + && !PATH_CONTROL.test(release.tag_name), 'Existing release lookup contains a malformed record.'); + existingIds.add(release.id); + if (release.tag_name === options.tag) { + throw new Error('A release with the explicit tag already exists.'); + } + } + if (releases.length < PAGE_SIZE) break; + existingPage += 1; + } + + await resolveReleaseTag({ + tag: options.tag, + expectedCommit: options.commit, + allowAbsent: true, + }, api); + + let created; + try { + created = await api('releases', [ + '--method', 'POST', + '--field', `tag_name=${options.tag}`, + '--field', `target_commitish=${options.commit}`, + '--field', 'draft=true', + '--field', `prerelease=${prereleaseFor(options.productVersion)}`, + '--field', 'generate_release_notes=true', + ], 'Draft creation'); + } catch { + throw outcomeError('draft-creation-outcome-unknown', + 'Draft creation outcome is unknown; inspect GitHub before any retry.'); + } + if (!isRecord(created) || !Number.isSafeInteger(created.id) || created.id <= 0) { + throw outcomeError('draft-creation-outcome-unknown', + 'Draft creation returned no trustworthy draft ID; inspect GitHub before any retry.'); + } + draftId = created.id; + if (created.draft !== true || created.tag_name !== options.tag + || created.target_commitish !== options.commit || !Array.isArray(created.assets) + || created.assets.length !== 0) { + throw outcomeError('draft-creation-outcome-unknown', + 'Draft creation response does not prove an empty draft; inspect GitHub before any retry.'); + } + const draftBody = boundedText(created.body, 'Generated draft notes', UPDATER_ASSET_LIMITS.maxManifestBytes); + const staged = await stageWithGeneratedManifest({ + sourceMetadata, + originalManifest: manifest, + draftBody, + source, + options, + }); + staging = staged.staging; + const uploadedIds = new Set(); + for (const name of options.names.ciAssets) { + const expected = staged.metadata.get(name); + const response = await call('gh', [ + 'api', + '--hostname', 'uploads.github.com', + encodedAssetUrl(options.repo, draftId, name), + '--method', 'POST', + '--input', expected.path, + '--header', 'Content-Type: application/octet-stream', + ], `Upload ${name}`, { timeout: TRANSFER_TIMEOUT_MS }); + const uploaded = parseApiResult(response, `Upload ${name}`); + demand(isRecord(uploaded), `Upload ${name} response must be an object.`); + positiveId(uploaded.id, `Uploaded ${name} ID`); + demand(!uploadedIds.has(uploaded.id), `Duplicate uploaded asset ID: ${uploaded.id}`); + uploadedIds.add(uploaded.id); + demand(uploaded.name === name && uploaded.state === 'uploaded' + && Number.isSafeInteger(uploaded.size) && uploaded.size === expected.size + && typeof uploaded.digest === 'string' && uploaded.digest === `sha256:${expected.hash}`, + `Upload ${name} response does not match the staged asset.`); + } + const pins = new Map([ + [options.names.macos.dmg, sourceMetadata.get(options.names.macos.dmg).hash], + [options.names.macos.archive, sourceMetadata.get(options.names.macos.archive).hash], + [options.names.server.archive, sourceMetadata.get(options.names.server.archive).hash], + ]); + demand(await readUpdaterSidecar(options.publicKeyFile, UPDATER_ASSET_LIMITS.maxSignatureBytes) + === publicKey, + 'Updater public-key file changed during verification.'); + const verification = await processLocalRelease({ + repo: options.repo, + tag: options.tag, + commit: options.commit, + draftId, + teamId: options.teamId, + publish: options.publish, + version: options.productVersion, + pins, + dmgName: options.names.macos.dmg, + serverName: options.names.server.archive, + names: options.names, + mode: 'ci', + publicKeyFile: options.publicKeyFile, + }, { run: boundedRun }); + const expectedStatus = options.publish ? 'published' : 'verified-draft'; + const receiptMatches = isRecord(verification) && verification.status === expectedStatus + && verification.repo === options.repo && verification.tag === options.tag + && verification.commit === options.commit && verification.draftId === draftId; + if (!receiptMatches) { + if (options.publish || verification?.status === 'published' + || verification?.status === 'publication-outcome-unknown') { + throw outcomeError('publication-outcome-unknown', + 'Shared publication receipt does not match; inspect the release before any retry.'); + } + throw new Error('Shared release verifier receipt does not match the exact draft.'); + } + return { + status: verification.status, + repo: options.repo, + tag: options.tag, + commit: options.commit, + draftId, + uploadedCount: uploadedIds.size, + }; + } catch (error) { + if (error && typeof error === 'object' + && error.publicationMayHaveOccurred === true && error.outcomeStatus === undefined) { + error.outcomeStatus = 'publication-outcome-unknown'; + } + throw attachDraftId(error, draftId); + } finally { + if (staging !== undefined) await rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +const usage = `Usage: node scripts/release/ci-release.mjs + --repo ${REPOSITORY_SLUG} --tag vVERSION --commit FULL_SHA --team-id TEAMID1234 + --assets-dir DIRECTORY --updater-public-key-file PUBLIC_KEY_FILE [--publish] + +Creates one empty generated-notes draft, uploads exactly eight immutable assets +once, and delegates final verification/publication to local-release. Draft-only +is the default. No upload retry, replacement, deletion or rollback is performed. +`; + +async function main() { + let values; + try { + ({ values } = parseArgs({ options: { + ...Object.fromEntries(['repo', 'tag', 'commit', 'team-id', 'assets-dir', 'updater-public-key-file'] + .map(name => [name, { type: 'string' }])), + publish: { type: 'boolean' }, help: { type: 'boolean' }, + } })); + if (values.help) { + process.stdout.write(usage); + return; + } + demand(values.repo === REPOSITORY_SLUG && values.tag && values.commit + && values['team-id'] && values['assets-dir'] && values['updater-public-key-file'], + 'All explicit release arguments are required.'); + } catch { + process.stderr.write(usage); + process.exitCode = 2; + return; + } + try { + const result = await processCiRelease({ + repo: values.repo, + tag: values.tag, + commit: values.commit, + teamId: values['team-id'], + assetsDirectory: values['assets-dir'], + publicKeyFile: values['updater-public-key-file'], + publish: values.publish === true, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + const publicationUnknown = error?.publicationMayHaveOccurred === true + || error?.outcomeStatus === 'publication-outcome-unknown'; + const result = { + status: publicationUnknown + ? 'publication-outcome-unknown' + : error?.outcomeStatus + ?? (error?.draftId === undefined ? 'blocked' : 'draft-preserved'), + error: publicationUnknown + ? 'Publication outcome is unknown; inspect the release before any retry.' + : error?.outcomeStatus === 'draft-creation-outcome-unknown' + ? 'Draft creation outcome is unknown; inspect GitHub before any retry.' + : 'CI release failed; inspect the reported draft before any retry.', + ...(error?.draftId === undefined ? {} : { draftId: error.draftId }), + }; + process.stderr.write(`${JSON.stringify(result)}\n`); + process.exitCode = 1; + } +} + +function isDirectInvocation() { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} + +if (isDirectInvocation()) await main(); diff --git a/scripts/release/ci-release.test.mjs b/scripts/release/ci-release.test.mjs new file mode 100644 index 00000000..680c4313 --- /dev/null +++ b/scripts/release/ci-release.test.mjs @@ -0,0 +1,532 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { PACKAGE_NAME, REPOSITORY_SLUG } from '../../shared/productIdentity.js'; + +import { + assetNames, + buildDesktopUpdateManifest, +} from './updater-artifacts.mjs'; +import { processCiRelease } from './ci-release.mjs'; + +const productVersion = '2.0.0-beta.10'; +const tag = `v${productVersion}`; +const commit = 'a'.repeat(40); +const teamId = 'AB12345678'; +const signature = Buffer.from('official updater signature fixture').toString('base64'); +const sha256 = value => createHash('sha256').update(value).digest('hex'); + +test('CI CLI executes through a symlinked checkout and imports inertly without argv1', async t => { + const root = await mkdtemp(join(tmpdir(), 'gajae-ci-entry-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const checkout = join(root, 'checkout'); + await symlink(fileURLToPath(new URL('../../', import.meta.url)), checkout, 'junction'); + const invoked = spawnSync(process.execPath, [join(checkout, 'scripts/release/ci-release.mjs')], { encoding: 'utf8' }); + assert.equal(invoked.status, 2); + assert.match(invoked.stderr, /Usage:/); + const imported = spawnSync(process.execPath, ['--input-type=module', '-e', + `delete process.argv[1]; await import(${JSON.stringify(new URL('./ci-release.mjs', import.meta.url).href)}); console.log('imported');`], + { encoding: 'utf8' }); + assert.equal(imported.status, 0, imported.stderr); + assert.equal(imported.stdout.trim(), 'imported'); +}); + +async function fixture(t, { + mutateSource, + mutateManifest, + extraAsset, + omitAsset, +} = {}) { + const root = await mkdtemp(join(tmpdir(), 'gajae-ci-release-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'package.json'), JSON.stringify({ + name: PACKAGE_NAME, + version: productVersion, + desktopVersion: '0.2.4', + })); + await mkdir(join(root, 'src-tauri')); + await writeFile(join(root, 'src-tauri/tauri.conf.json'), JSON.stringify({ + bundle: { macOS: { minimumSystemVersion: '13.0' } }, + })); + if (mutateSource) await mutateSource(root); + + const assetsDirectory = join(root, 'assets'); + await mkdir(assetsDirectory); + const names = assetNames({ productVersion, tag }); + const bodies = new Map([ + [names.macos.dmg, Buffer.from('final stapled DMG bytes')], + [names.macos.archive, Buffer.from('signed updater archive bytes')], + [names.server.archive, Buffer.from('server archive bytes')], + [names.macos.archiveSignature, Buffer.from(`${signature}\n`)], + ]); + const manifest = structuredClone(buildDesktopUpdateManifest({ + productVersion, + desktopVersion: '0.2.4', + notes: 'Initial reviewed notes', + pubDate: '2026-09-06T00:00:00Z', + minimumSystemVersion: '13.0', + commit, + signature, + })); + if (mutateManifest) mutateManifest(manifest); + bodies.set(names.macos.manifest, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)); + for (const [name, payloadName] of [ + [names.macos.dmgChecksum, names.macos.dmg], + [names.macos.archiveChecksum, names.macos.archive], + [names.server.checksum, names.server.archive], + ]) { + bodies.set(name, Buffer.from(`${sha256(bodies.get(payloadName))} ${payloadName}\n`)); + } + if (omitAsset) bodies.delete(omitAsset(names)); + if (extraAsset) bodies.set(extraAsset, Buffer.from('unexpected')); + for (const [name, body] of bodies) await writeFile(join(assetsDirectory, name), body); + const publicKeyFile = join(root, 'updater.pub'); + await writeFile(publicKeyFile, Buffer.from('official public key fixture').toString('base64')); + return { root, assetsDirectory, publicKeyFile, names, bodies }; +} + +function makeRunner(state) { + return async (program, args, options = {}) => { + state.calls.push({ program, args: [...args], options: { ...options } }); + if (program === 'git') { + assert.deepEqual(args.slice(-2), ['rev-parse', 'HEAD']); + return { stdout: `${state.checkoutHead ?? commit}\n`, stderr: '' }; + } + assert.equal(program, 'gh'); + assert.equal(args[0], 'api'); + assert.equal(Number.isSafeInteger(options.timeout), true); + assert.equal(args.includes('--clobber'), false); + const endpoint = args[3] ?? ''; + if (endpoint.startsWith(`repos/${REPOSITORY_SLUG}/git/matching-refs/tags/`)) { + const encodedTag = endpoint.slice(`repos/${REPOSITORY_SLUG}/git/matching-refs/tags/`.length); + const releaseTag = decodeURIComponent(encodedTag); + const object = state.tagRefs?.get(releaseTag); + return { + stdout: JSON.stringify(object === undefined + ? [[]] + : [[{ ref: `refs/tags/${releaseTag}`, object }]]), + stderr: '', + }; + } + if (endpoint.startsWith(`repos/${REPOSITORY_SLUG}/releases?`)) { + const page = Number(new URLSearchParams(endpoint.slice(endpoint.indexOf('?') + 1)).get('page')); + return { + stdout: JSON.stringify(state.existingPages?.[page - 1] ?? (page === 1 ? state.existing ?? [] : [])), + stderr: '', + }; + } + if (endpoint === `repos/${REPOSITORY_SLUG}/releases`) { + state.createCount += 1; + if (state.createError) throw new Error('transport fixture failure'); + return { + stdout: JSON.stringify(state.created ?? { + id: 700, + tag_name: tag, + target_commitish: commit, + draft: true, + assets: [], + body: 'Generated notes from GitHub', + }), + stderr: '', + }; + } + if (args[3]?.startsWith('https://uploads.github.com/')) { + state.uploadCount += 1; + if (state.uploadErrorAt === state.uploadCount) throw new Error('upload transport fixture failure'); + if (state.mutateOriginalBeforeUpload && state.uploadCount === 1) { + await state.mutateOriginalBeforeUpload(); + } + const name = new URL(args[3]).searchParams.get('name'); + const input = args[args.indexOf('--input') + 1]; + const bytes = await readFile(input); + state.uploadedBodies?.set(name, bytes); + const id = state.uploadIds?.[state.uploadCount - 1] ?? 1000 + state.uploadCount; + return { + stdout: JSON.stringify({ + id, + name, + size: bytes.length, + state: 'uploaded', + digest: `sha256:${state.uploadDigests?.[state.uploadCount - 1] ?? sha256(bytes)}`, + }), + stderr: '', + }; + } + assert.fail(`Unexpected gh endpoint ${endpoint}`); + }; +} + +function dependencies(fixtureState, overrides = {}) { + return { + run: makeRunner(fixtureState), + platform: 'darwin', + arch: 'arm64', + verifySignature: async ({ expectedSha256, publicKey, root }, { run }) => { + assert.equal(expectedSha256, sha256(fixtureState.bodies.get(fixtureState.names.macos.archive))); + assert.equal(publicKey, Buffer.from('official public key fixture').toString('base64')); + assert.ok(root); + assert.equal(typeof run, 'function'); + }, + collectHistory: async () => { + return { priorPublished: [], historyComplete: true }; + }, + processLocalRelease: async options => { + fixtureState.verifyOptions = options; + return { status: options.publish ? 'published' : 'verified-draft', + repo: options.repo, tag: options.tag, commit: options.commit, draftId: options.draftId }; + }, + ...overrides, + }; +} + +function input(state, overrides = {}) { + return { + repo: REPOSITORY_SLUG, + tag, + commit, + teamId, + assetsDirectory: state.assetsDirectory, + publicKeyFile: state.publicKeyFile, + checkoutRoot: state.root, + ...overrides, + }; +} + +test('creates one empty draft, replaces only manifest notes, uploads exactly eight assets once, and stays draft-only by default', async t => { + const state = await fixture(t); + const events = []; + const runState = { + names: state.names, + bodies: state.bodies, + calls: [], + uploadedBodies: new Map(), + createCount: 0, + uploadCount: 0, + existing: [], + mutateOriginalBeforeUpload: async () => { + await writeFile(join(state.assetsDirectory, state.names.macos.archive), 'mutated original archive'); + }, + }; + const deps = dependencies(runState, { + processLocalRelease: async options => { + events.push('verify-release'); + runState.verifyOptions = options; + return { status: 'verified-draft', repo: options.repo, tag: options.tag, + commit: options.commit, draftId: options.draftId }; + }, + }); + const result = await processCiRelease(input(state), deps); + assert.deepEqual(result, { + status: 'verified-draft', + repo: REPOSITORY_SLUG, + tag, + commit, + draftId: 700, + uploadedCount: 8, + }); + assert.equal(runState.createCount, 1); + assert.equal(runState.uploadCount, 8); + assert.deepEqual(events, ['verify-release']); + assert.equal(runState.verifyOptions.publish, false); + assert.deepEqual([...runState.verifyOptions.pins.keys()], [ + state.names.macos.dmg, + state.names.macos.archive, + state.names.server.archive, + ]); + assert.equal(runState.calls.some(call => call.args.includes('--method') && call.args.includes('PATCH')), false); + assert.equal(runState.calls.filter(call => call.args[3]?.startsWith('https://uploads.github.com/')).length, 8); + const tagIndex = runState.calls.findIndex(call => call.args[3]?.includes('/git/matching-refs/tags/')); + const createIndex = runState.calls.findIndex(call => call.args[3] === `repos/${REPOSITORY_SLUG}/releases` + && call.args.includes('POST')); + assert.ok(tagIndex >= 0 && tagIndex < createIndex); + for (const call of runState.calls.filter(item => item.args[3]?.startsWith('https://uploads.github.com/'))) { + assert.equal(call.args[call.args.indexOf('--input') + 1].startsWith(state.assetsDirectory), false); + } + const manifest = JSON.parse(runState.uploadedBodies.get(state.names.macos.manifest).toString('utf8')); + assert.equal(manifest.notes, 'Generated notes from GitHub'); + assert.equal(manifest.platforms['darwin-aarch64'].signature, signature); + assert.deepEqual(await readFile(join(state.assetsDirectory, state.names.macos.archive)), + Buffer.from('mutated original archive')); + assert.deepEqual(runState.uploadedBodies.get(state.names.macos.archive), + state.bodies.get(state.names.macos.archive)); +}); + +test('explicit publish passes publish authorization only to the shared verifier', async t => { + const state = await fixture(t); + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0, existing: [] }; + const deps = dependencies(runState); + const result = await processCiRelease(input(state, { publish: true }), deps); + assert.equal(result.status, 'published'); + assert.equal(runState.verifyOptions.publish, true); + assert.equal(runState.calls.some(call => call.args.includes('--method') && call.args.includes('PATCH')), false); +}); + +test('accepts a matching existing tag but rejects mismatched checkout or tag commits before writing', async t => { + const state = await fixture(t); + const matching = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0, + tagRefs: new Map([[tag, { type: 'commit', sha: commit }]]) }; + assert.equal((await processCiRelease(input(state), dependencies(matching))).status, 'verified-draft'); + for (const mismatch of [ + { checkoutHead: 'b'.repeat(40) }, + { tagRefs: new Map([[tag, { type: 'commit', sha: 'b'.repeat(40) }]]) }, + ]) { + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0, ...mismatch }; + await assert.rejects(processCiRelease(input(state), dependencies(runState)), /commit|HEAD/i); + assert.equal(runState.createCount, 0); + assert.equal(runState.uploadCount, 0); + } +}); + +test('rejects unsupported platform or architecture before any GitHub operation', async t => { + const state = await fixture(t); + for (const overrides of [{ platform: 'linux' }, { arch: 'x64' }]) { + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0 }; + await assert.rejects(processCiRelease(input(state), dependencies(runState, overrides)), /macOS arm64/i); + assert.equal(runState.calls.length, 0); + } +}); + +test('rejects extras, partial assets, missing key/signature/hash, and metadata drift before any GitHub write', async t => { + const cases = [ + { fixture: { extraAsset: 'unexpected.txt' }, expected: /exactly eight|unexpected/i }, + { fixture: { omitAsset: names => names.server.checksum }, expected: /exactly eight|Missing/i }, + { fixture: { mutateManifest: manifest => { manifest.build.commit = 'b'.repeat(40); } }, expected: /commit|match/i }, + { fixture: { mutateSource: async root => { + const packagePath = join(root, 'package.json'); + const packageJson = JSON.parse(await readFile(packagePath, 'utf8')); + packageJson.version = '2.0.0-beta.9'; + await writeFile(packagePath, JSON.stringify(packageJson)); + } }, expected: /product version|tag/i }, + ]; + for (const { fixture: fixtureOptions, expected } of cases) { + const state = await fixture(t, fixtureOptions); + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0 }; + await assert.rejects(processCiRelease(input(state), dependencies(runState)), expected); + assert.equal(runState.createCount, 0); + assert.equal(runState.uploadCount, 0); + } + const state = await fixture(t); + await rm(state.publicKeyFile); + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0 }; + await assert.rejects(processCiRelease(input(state), dependencies(runState)), { code: 'ENOENT' }); + assert.equal(runState.createCount, 0); +}); + +test('requires complete history floor proof and refuses existing public or draft tags', async t => { + const state = await fixture(t); + const noProof = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0 }; + await assert.rejects(processCiRelease(input(state), dependencies(noProof, { + collectHistory: async () => ({ priorPublished: [], historyComplete: false }), + })), /complete/i); + assert.equal(noProof.createCount, 0); + + for (const existing of [ + [{ id: 1, tag_name: tag, draft: false }], + [{ id: 2, tag_name: tag, draft: true }], + ]) { + const current = await fixture(t); + const runState = { + names: current.names, + bodies: current.bodies, + calls: [], + createCount: 0, + uploadCount: 0, + existing, + }; + await assert.rejects(processCiRelease(input(current), dependencies(runState)), /already exists/i); + assert.equal(runState.createCount, 0); + assert.equal(runState.uploadCount, 0); + } +}); + +test('fails immediately on repeated existing-release IDs or pages', async t => { + const state = await fixture(t); + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + tag_name: `v1.0.0-beta.${index + 1}`, + })); + const runState = { + names: state.names, + bodies: state.bodies, + calls: [], + createCount: 0, + uploadCount: 0, + existingPages: [firstPage, firstPage], + }; + await assert.rejects(processCiRelease(input(state), dependencies(runState)), /duplicate|repeated/i); + assert.equal(runState.createCount, 0); + assert.equal(runState.uploadCount, 0); +}); + +test('preserves numeric draft ID on create/upload uncertainty and never retries', async t => { + const state = await fixture(t); + const createFailure = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0, createError: true }; + const createDeps = dependencies(createFailure); + await assert.rejects(processCiRelease(input(state), createDeps), /Draft creation|failed/i); + assert.equal(createFailure.createCount, 1); + assert.equal(createFailure.uploadCount, 0); + + const missingId = await fixture(t); + const missingIdState = { + names: missingId.names, + bodies: missingId.bodies, + calls: [], + createCount: 0, + uploadCount: 0, + existing: [], + created: { + tag_name: tag, + target_commitish: commit, + draft: true, + assets: [], + body: 'Generated notes from GitHub', + }, + }; + await assert.rejects(processCiRelease(input(missingId), dependencies(missingIdState)), error => { + assert.equal(error.outcomeStatus, 'draft-creation-outcome-unknown'); + assert.equal(error.draftId, undefined); + return true; + }); + assert.equal(missingIdState.createCount, 1); + assert.equal(missingIdState.uploadCount, 0); + + const interrupted = await fixture(t); + const uploadFailure = { + names: interrupted.names, + bodies: interrupted.bodies, + calls: [], + createCount: 0, + uploadCount: 0, + existing: [], + uploadErrorAt: 2, + }; + await assert.rejects(processCiRelease(input(interrupted), dependencies(uploadFailure)), error => { + assert.equal(error.draftId, 700); + return true; + }); + assert.equal(uploadFailure.uploadCount, 2); + assert.equal(uploadFailure.calls.filter(call => call.args[3]?.startsWith('https://uploads.github.com/')).length, 2); +}); + +test('propagates uncertain publication and rejects shared verifier status mismatches', async t => { + const state = await fixture(t); + const uncertain = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0, existing: [] }; + await assert.rejects(processCiRelease(input(state, { publish: true }), dependencies(uncertain, { + processLocalRelease: async () => { + throw Object.assign(new Error('publication reply lost'), { publicationMayHaveOccurred: true }); + }, + })), error => { + assert.equal(error.publicationMayHaveOccurred, true); + assert.equal(error.outcomeStatus, 'publication-outcome-unknown'); + assert.equal(error.draftId, 700); + return true; + }); + assert.equal(uncertain.uploadCount, 8); + + const mismatch = await fixture(t); + const mismatchState = { names: mismatch.names, bodies: mismatch.bodies, calls: [], createCount: 0, uploadCount: 0, existing: [] }; + await assert.rejects(processCiRelease(input(mismatch, { publish: true }), dependencies(mismatchState, { + processLocalRelease: async () => ({ status: 'verified-draft', repo: REPOSITORY_SLUG, tag, commit, draftId: 700 }), + })), error => { + assert.equal(error.outcomeStatus, 'publication-outcome-unknown'); + return true; + }); + assert.equal(mismatchState.uploadCount, 8); + + const unexpectedPublication = await fixture(t); + const unexpectedState = { + names: unexpectedPublication.names, + bodies: unexpectedPublication.bodies, + calls: [], + createCount: 0, + uploadCount: 0, + existing: [], + }; + await assert.rejects(processCiRelease(input(unexpectedPublication), dependencies(unexpectedState, { + processLocalRelease: async () => ({ status: 'published' }), + })), error => { + assert.equal(error.outcomeStatus, 'publication-outcome-unknown'); + return true; + }); +}); + +test('mismatched publication receipt identities never imply the draft stayed unpublished', async t => { + const state = await fixture(t); + for (const mismatch of [ + { draftId: 701 }, { repo: 'other/repository' }, { commit: 'b'.repeat(40) }, { tag: 'v2.0.0-beta.9' }, + ]) { + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0 }; + await assert.rejects(processCiRelease(input(state, { publish: true }), dependencies(runState, { + processLocalRelease: async () => ({ status: 'published', repo: REPOSITORY_SLUG, tag, commit, draftId: 700, ...mismatch }), + })), error => { + assert.equal(error.outcomeStatus, 'publication-outcome-unknown'); + assert.equal(error.draftId, 700); + return true; + }); + assert.equal(runState.createCount, 1); + assert.equal(runState.uploadCount, 8); + } +}); + +test('retains confirmed draft and publication responses when the deadline expires at completion', async t => { + for (const finalPublication of [false, true]) { + const state = await fixture(t); + const runState = { names: state.names, bodies: state.bodies, calls: [], createCount: 0, uploadCount: 0 }; + const fixtureRun = makeRunner(runState); + let clock = 0; + let publicationCalls = 0; + const deps = dependencies(runState, { + now: () => clock, + run: async (program, args, options) => { + if (args.includes('PATCH')) { + publicationCalls += 1; + clock = Number.MAX_SAFE_INTEGER; + return { stdout: JSON.stringify({ id: 700, draft: false }), stderr: '' }; + } + const result = await fixtureRun(program, args, options); + if (!finalPublication && runState.createCount === 1) clock = Number.MAX_SAFE_INTEGER; + return result; + }, + processLocalRelease: async (options, { run }) => { + const response = await run('gh', ['api', '--hostname', 'github.com', + `repos/${REPOSITORY_SLUG}/releases/700`, '--method', 'PATCH'], { timeout: 1 }); + assert.deepEqual(JSON.parse(response.stdout), { id: 700, draft: false }); + return { status: 'published', repo: options.repo, tag: options.tag, + commit: options.commit, draftId: options.draftId }; + }, + }); + if (finalPublication) { + assert.equal((await processCiRelease(input(state, { publish: true }), deps)).status, 'published'); + assert.equal(publicationCalls, 1); + } else { + await assert.rejects(processCiRelease(input(state), deps), error => { + assert.equal(error.draftId, 700); + assert.match(error.message, /deadline/); + assert.equal(error.outcomeStatus, undefined); + return true; + }); + assert.equal(runState.uploadCount, 0); + } + } +}); + +test('rejects duplicate or inconsistent GitHub upload identities', async t => { + const state = await fixture(t); + const runState = { + names: state.names, + bodies: state.bodies, + calls: [], + createCount: 0, + uploadCount: 0, + existing: [], + uploadIds: [77, 77], + }; + await assert.rejects(processCiRelease(input(state), dependencies(runState)), /Duplicate uploaded asset ID/i); + assert.equal(runState.uploadCount, 2); +}); diff --git a/scripts/release/local-release-command.mjs b/scripts/release/local-release-command.mjs index 1a300845..d2701190 100644 --- a/scripts/release/local-release-command.mjs +++ b/scripts/release/local-release-command.mjs @@ -1,29 +1,59 @@ import { spawn } from 'node:child_process'; -import { closeSync, openSync } from 'node:fs'; +import { closeSync, openSync, writeSync } from 'node:fs'; // No shell, inherited stdin, credential arguments, or raw command-error output. // Downloads use exclusive creation in a fresh temporary directory. -export async function releaseCommand(program, args, { output, timeout = 120_000 } = {}) { +export async function releaseCommand(program, args, { output, timeout = 120_000, maxOutputBytes = 2 * 1024 ** 3 } = {}) { + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) { + throw new Error('Command file output limit must be a positive safe integer.'); + } const fd = output ? openSync(output, 'wx', 0o600) : undefined; try { return await new Promise((resolve, reject) => { const child = spawn(program, args, { - stdio: ['ignore', fd ?? 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GH_PROMPT_DISABLED: '1' }, }); let stdout = ''; let stderr = ''; + let fileBytes = 0; + let textBytes = 0; let failure; const timer = setTimeout(() => { failure = 'timed out'; child.kill('SIGKILL'); }, timeout); const collect = (stream, target) => { - stream?.setEncoding('utf8'); + const fileOutput = target === 'stdout' && fd !== undefined; + if (!fileOutput) stream?.setEncoding('utf8'); stream?.on('data', chunk => { + if (failure) return; + if (fileOutput) { + fileBytes += chunk.length; + if (fileBytes > maxOutputBytes) { + failure = 'exceeded the file output limit'; + child.kill('SIGKILL'); + return; + } + try { + // Synchronous bounded chunks keep disk writes backpressured and + // complete before the child-close handler closes this descriptor. + let offset = 0; + while (offset < chunk.length) { + const written = writeSync(fd, chunk, offset, chunk.length - offset); + if (written === 0) throw new Error('Incomplete output write.'); + offset += written; + } + } catch { + failure = 'could not write output'; + child.kill('SIGKILL'); + } + return; + } + textBytes += Buffer.byteLength(chunk); if (target === 'stdout') stdout += chunk; else stderr += chunk; - if (stdout.length + stderr.length > 8 * 1024 * 1024) { + if (textBytes > 8 * 1024 * 1024) { failure = 'exceeded the output limit'; child.kill('SIGKILL'); } diff --git a/scripts/release/local-release-macos.mjs b/scripts/release/local-release-macos.mjs index 5cba6ac7..d6444661 100644 --- a/scripts/release/local-release-macos.mjs +++ b/scripts/release/local-release-macos.mjs @@ -1,14 +1,176 @@ -import { lstat, mkdir, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import semver from 'semver'; import { DESKTOP_APP_ID, PACKAGE_NAME, PRODUCT_NAME, PRODUCT_TOKEN } from '../../shared/productIdentity.js'; import { releaseCommand } from './local-release-command.mjs'; +import { + compareAppInventories, + cleanupUpdaterExtraction, + extractUpdaterArchive, + inventoryApp, +} from './updater-archive.mjs'; function requireValue(condition, message) { if (!condition) throw new Error(message); } +const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; +const O_NONBLOCK = constants.O_NONBLOCK ?? 0; +const MAX_PACKAGE_BYTES = 64 * 1024; +const MAX_VTOOL_OUTPUT_BYTES = 64 * 1024; +const MACOS_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/; +const REQUIRED_MACHO_PATHS = Object.freeze([ + `Contents/MacOS/${PRODUCT_TOKEN}-desktop`, + `Contents/MacOS/${PRODUCT_TOKEN}-server`, + 'Contents/Resources/resources/server-payload/dist-native/bun', + 'Contents/Resources/resources/server-payload/dist-native/gajae-core', +]); + +function strictMacosVersion(value, label) { + requireValue(typeof value === 'string' && MACOS_VERSION.test(value) + && value.split('.').every(part => Number(part) <= 999), `${label} must be a bounded macOS version.`); + const normalized = value.split('.').length === 2 ? `${value}.0` : value; + const parsed = semver.parse(normalized); + requireValue(parsed?.version === normalized, `${label} is malformed.`); + return { text: value, parsed }; +} + +function nativeMachOPaths(app, inventory) { + requireValue(inventory !== null && typeof inventory === 'object' && !Array.isArray(inventory) + && inventory.root === `${PRODUCT_NAME}.app` && Array.isArray(inventory.entries), + `A canonical ${PRODUCT_NAME}.app inventory is required for Mach-O deployment verification.`); + const entries = inventory.entries; + const files = new Map(entries.filter(entry => entry?.type === 'file').map(entry => [entry.path, entry])); + const required = REQUIRED_MACHO_PATHS.map(path => `${PRODUCT_NAME}.app/${path}`); + for (const path of required) { + requireValue(files.has(path), `Required bundled Mach-O is missing from the app inventory: ${path}.`); + } + const paths = new Set(required); + for (const entry of entries) { + const path = entry.path; + if (/\.(?:node|dylib|so)$/iu.test(path) + || path.endsWith('/@vscode/ripgrep/bin/rg') + || path.endsWith('/node-pty/build/Release/spawn-helper')) { + requireValue(entry.type === 'file' || entry.type === 'symlink', + `Native runtime module is not a regular file or internal symlink: ${path}.`); + paths.add(path); + } + } + return [...paths].sort().map(path => ({ + path, + absolute: join(app, path.slice(`${PRODUCT_NAME}.app/`.length)), + })); +} + +/** + * Parse one `xcrun vtool -show-build` result. vtool is the authority for + * Mach-O load commands; this parser only validates its bounded textual output + * and never interprets binary bytes itself. + */ +export function parseVtoolBuildMinimums(output, label = 'Mach-O') { + requireValue(typeof output === 'string' && Buffer.byteLength(output, 'utf8') > 0 + && Buffer.byteLength(output, 'utf8') <= MAX_VTOOL_OUTPUT_BYTES, + `${label} vtool output is missing or oversized.`); + const lines = output.split(/\r?\n/); + const commands = []; + let current; + for (const line of lines) { + if (/^\s*cmd(?:\s|$)/.test(line)) { + const command = /^\s*cmd\s+([A-Za-z0-9_]+)\s*$/.exec(line); + requireValue(command, `${label} has a malformed load-command line.`); + if (current) commands.push(current); + current = { name: command[1], platform: undefined, minos: undefined }; + continue; + } + if (!current) continue; + const platform = /^\s*platform\s+([A-Za-z0-9_]+)\s*$/.exec(line); + if (platform) { + requireValue(current.platform === undefined, `${label} has duplicate vtool platform evidence.`); + current.platform = platform[1]; + continue; + } + const minos = /^\s*minos\s+([0-9]+(?:\.[0-9]+){1,2})\s*$/.exec(line); + if (minos) { + requireValue(current.minos === undefined, `${label} has duplicate vtool minimum evidence.`); + current.minos = minos[1]; + } + } + if (current) commands.push(current); + requireValue(commands.length > 0, `${label} has no LC_BUILD_VERSION evidence.`); + requireValue(commands.every(command => command.name === 'LC_BUILD_VERSION'), + `${label} contains an unsupported load command; only LC_BUILD_VERSION is accepted.`); + const minimums = []; + for (const stamp of commands) { + requireValue(stamp.platform === 'MACOS' && stamp.minos !== undefined, + `${label} has missing or unsupported LC_BUILD_VERSION evidence.`); + strictMacosVersion(stamp.minos, `${label} minos`); + minimums.push(stamp.minos); + } + return Object.freeze(minimums); +} + +/** + * Require every bundled Mach-O's deployment stamp to be no newer than the + * declared minimum system version. The inventory supplies a bounded list of + * required executables/modules; vtool supplies the actual loader evidence. + */ +export async function verifyMacosDeploymentFloor({ + app, + minimumSystemVersion, + inventory, +}, { run = releaseCommand } = {}) { + const declared = strictMacosVersion(minimumSystemVersion, 'minimumSystemVersion'); + const paths = nativeMachOPaths(app, inventory); + const stamps = []; + for (const item of paths) { + const result = await run('xcrun', ['vtool', '-show-build', item.absolute], { + maxOutputBytes: MAX_VTOOL_OUTPUT_BYTES, + }); + requireValue(result !== null && typeof result === 'object' && !Array.isArray(result) + && typeof result.stdout === 'string' && typeof result.stderr === 'string', + `${item.path} vtool result has an invalid shape.`); + requireValue(result.stderr === '', `${item.path} vtool wrote diagnostics to stderr.`); + const output = result.stdout; + const minimums = parseVtoolBuildMinimums(output, item.path); + for (const minimum of minimums) { + const parsed = strictMacosVersion(minimum, `${item.path} minos`); + requireValue(semver.lte(parsed.parsed, declared.parsed), + `Bundled Mach-O ${item.path} requires macOS ${minimum} , above declared minimum ${minimumSystemVersion}.`); + stamps.push({ path: item.path, minimumSystemVersion: minimum }); + } + } + return Object.freeze({ + declaredMinimumSystemVersion: declared.text, + maximumStampedMinimumSystemVersion: stamps.reduce((max, item) => ( + semver.gt(strictMacosVersion(item.minimumSystemVersion, 'Mach-O minimum').parsed, strictMacosVersion(max, 'Mach-O maximum').parsed) + ? item.minimumSystemVersion : max + ), '0.0'), + stamps: Object.freeze(stamps), + }); +} + +async function readRegularJson(path) { + const fd = await open(path, constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + try { + const stat = await fd.stat(); + requireValue(stat.isFile() && stat.size <= MAX_PACKAGE_BYTES, `App metadata must be a bounded regular file: ${path}.`); + const chunks = []; + let bytes = 0; + for await (const chunk of fd.createReadStream({ autoClose: false })) { + bytes += chunk.length; + requireValue(bytes <= MAX_PACKAGE_BYTES, `App metadata exceeds its size limit: ${path}.`); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); + } finally { + await fd.close().catch(() => {}); + } +} + export function assertDeveloperSignature(output, teamId, { hardened = false } = {}) { requireValue(!/^Signature=adhoc$/m.test(output) && /^Authority=Developer ID Application: .+$/m.test(output) @@ -20,22 +182,83 @@ export function assertNotarizedAssessment(output) { requireValue(/: accepted\s*$/m.test(output) && /^source=Notarized Developer ID\s*$/m.test(output), 'Gatekeeper did not accept Notarized Developer ID.'); } -export async function verifyMacosRelease({ dmg, root, teamId, version, desktopVersion }, { run = releaseCommand } = {}) { +/** + * Read-only checks for one finalized app bundle. The caller is responsible + * for making any disposable copy or applying quarantine before invoking this + * helper; it never signs, staples, mutates, or copies the app. + */ +export async function verifyMacosApp({ + app, + teamId, + version, + desktopVersion, + minimumSystemVersion, + inventory, +}, { run = releaseCommand } = {}) { + requireValue(typeof app === 'string' && app.length > 0, 'A macOS app path is required.'); + requireValue(basename(app) === `${PRODUCT_NAME}.app`, `App path must end in ${PRODUCT_NAME}.app.`); + strictMacosVersion(minimumSystemVersion, 'minimumSystemVersion'); const combined = async (program, args) => { const result = await run(program, args); return `${result.stdout}\n${result.stderr}`; }; + const appStat = await lstat(app); + requireValue(appStat.isDirectory() && !appStat.isSymbolicLink(), 'The app must be a directory, not a symlink.'); + await run('codesign', ['--verify', '--deep', '--strict', app]); + assertDeveloperSignature(await combined('codesign', ['--display', '--verbose=4', app]), teamId, { hardened: true }); + await run('xcrun', ['stapler', 'validate', app]); + assertNotarizedAssessment(await combined('spctl', ['--assess', '--type', 'exec', '--verbose=2', app])); + + const plist = join(app, 'Contents/Info.plist'); + for (const [key, expected] of [ + ['CFBundleIdentifier', DESKTOP_APP_ID], + ['CFBundleShortVersionString', desktopVersion], + ['LSMinimumSystemVersion', minimumSystemVersion], + ]) { + const result = await run('/usr/libexec/PlistBuddy', ['-c', `Print ${key}`, plist]); + requireValue(result.stdout.trim() === expected, `${app} ${key} does not match the pinned source commit.`); + } + const payload = JSON.parse(await readRegularJson(join(app, 'Contents/Resources/resources/server-payload/package.json'))); + requireValue(payload.name === PACKAGE_NAME && payload.version === version, `${app} payload version/name does not match the release tag.`); + for (const executable of [`${PRODUCT_TOKEN}-desktop`, `${PRODUCT_TOKEN}-server`]) { + await run('lipo', [join(app, 'Contents/MacOS', executable), '-verify_arch', 'arm64']); + } + const appInventory = inventory ?? await inventoryApp(app); + const deployment = await verifyMacosDeploymentFloor({ + app, + minimumSystemVersion, + inventory: appInventory, + }, { run }); + return Object.freeze({ app, inventory: appInventory, deployment }); +} + +export async function verifyMacosRelease({ + dmg, + root, + teamId, + version, + desktopVersion, + updaterArchivePath, + minimumSystemVersion, +}, { run = releaseCommand } = {}) { + requireValue(typeof updaterArchivePath === 'string' && updaterArchivePath.length > 0, + 'A verified updaterArchivePath is required for macOS release verification.'); + strictMacosVersion(minimumSystemVersion, 'minimumSystemVersion'); await run('hdiutil', ['verify', dmg]); await run('codesign', ['--verify', '--strict', dmg]); - assertDeveloperSignature(await combined('codesign', ['--display', '--verbose=4', dmg]), teamId); + const dmgSignature = await run('codesign', ['--display', '--verbose=4', dmg]); + assertDeveloperSignature(`${dmgSignature.stdout}\n${dmgSignature.stderr}`, teamId); await run('xcrun', ['stapler', 'validate', dmg]); - assertNotarizedAssessment(await combined('spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', dmg])); + const dmgAssessment = await run('spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', dmg]); + assertNotarizedAssessment(`${dmgAssessment.stdout}\n${dmgAssessment.stderr}`); const mount = join(root, 'mount'); const copyRoot = join(root, 'copy'); - await mkdir(mount); - await mkdir(copyRoot); + await mkdir(mount, { mode: 0o700 }); + await mkdir(copyRoot, { mode: 0o700 }); let verificationError; + let verificationResult; + let extracted; try { await run('hdiutil', ['attach', dmg, '-nobrowse', '-readonly', '-mountpoint', mount]); const mountedApp = join(mount, `${PRODUCT_NAME}.app`); @@ -44,22 +267,20 @@ export async function verifyMacosRelease({ dmg, root, teamId, version, desktopVe await run('ditto', [mountedApp, copiedApp]); // Only the disposable copy receives quarantine; release files stay intact. await run('xattr', ['-w', 'com.apple.quarantine', '0081;00000000;GajaeLocalRelease;', copiedApp]); - for (const app of [mountedApp, copiedApp]) { - await run('codesign', ['--verify', '--deep', '--strict', app]); - assertDeveloperSignature(await combined('codesign', ['--display', '--verbose=4', app]), teamId, { hardened: true }); - await run('xcrun', ['stapler', 'validate', app]); - assertNotarizedAssessment(await combined('spctl', ['--assess', '--type', 'exec', '--verbose=2', app])); - } - const plist = join(copiedApp, 'Contents/Info.plist'); - for (const [key, expected] of [['CFBundleIdentifier', DESKTOP_APP_ID], ['CFBundleShortVersionString', desktopVersion]]) { - const result = await run('/usr/libexec/PlistBuddy', ['-c', `Print ${key}`, plist]); - requireValue(result.stdout.trim() === expected, `Copied app ${key} does not match the pinned source commit.`); - } - const payload = JSON.parse(await readFile(join(copiedApp, 'Contents/Resources/resources/server-payload/package.json'), 'utf8')); - requireValue(payload.name === PACKAGE_NAME && payload.version === version, 'Copied app payload version/name does not match the release tag.'); - for (const executable of [`${PRODUCT_TOKEN}-desktop`, `${PRODUCT_TOKEN}-server`]) { - await run('lipo', [join(copiedApp, 'Contents/MacOS', executable), '-verify_arch', 'arm64']); + // The inventory includes the payload's runtime manifest and every file it + // names, so this equality binds runtime content without a second parser or + // a mutable-manifest shortcut. + const copiedInventory = await inventoryApp(copiedApp); + extracted = await extractUpdaterArchive({ archivePath: updaterArchivePath, root }); + compareAppInventories(copiedInventory, extracted.inventory); + for (const app of [mountedApp, copiedApp, extracted.appPath]) { + const appInventory = app === copiedApp ? copiedInventory + : app === extracted.appPath ? extracted.inventory : undefined; + await verifyMacosApp({ + app, teamId, version, desktopVersion, minimumSystemVersion, inventory: appInventory, + }, { run }); } + verificationResult = { copiedApp, extractedApp: extracted.appPath, inventory: copiedInventory, archive: extracted.archive }; } catch (error) { verificationError = error; } @@ -69,5 +290,7 @@ export async function verifyMacosRelease({ dmg, root, teamId, version, desktopVe // Never recursively remove a directory that might still be a mount. throw Object.assign(new Error(`Could not confirm image detachment; temporary directory retained: ${root}`), { preserveDirectory: true }); } + if (extracted) await cleanupUpdaterExtraction(extracted); if (verificationError) throw verificationError; + return verificationResult; } diff --git a/scripts/release/local-release-macos.test.mjs b/scripts/release/local-release-macos.test.mjs index 6283a54d..6a86ecbc 100644 --- a/scripts/release/local-release-macos.test.mjs +++ b/scripts/release/local-release-macos.test.mjs @@ -1,33 +1,88 @@ import assert from 'node:assert/strict'; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { assertDeveloperSignature, assertNotarizedAssessment, verifyMacosRelease } from './local-release-macos.mjs'; +import { + assertDeveloperSignature, + assertNotarizedAssessment, + parseVtoolBuildMinimums, + verifyMacosApp, + verifyMacosDeploymentFloor, + verifyMacosRelease, +} from './local-release-macos.mjs'; +import { createUpdaterArchive, inventoryApp } from './updater-archive.mjs'; const teamId = 'AB12345678'; const signature = `Authority=Developer ID Application: Fixture (${teamId})\nTeamIdentifier=${teamId}\nCodeDirectory v=20500 size=400 flags=0x10000(runtime) hashes=12\n`; async function fixture(t) { - const root = await mkdtemp(join(tmpdir(), 'gajae-macos-validation-test-')); + const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-macos-validation-test-'))); t.after(() => rm(root, { recursive: true, force: true })); const dmg = join(root, 'original.dmg'); await writeFile(dmg, 'original immutable image fixture'); - const input = { dmg, root, version: '2.0.0-beta.99', desktopVersion: '0.2.2', teamId }; + const input = { + dmg, + root, + version: '2.0.0-beta.99', + desktopVersion: '0.2.2', + minimumSystemVersion: '13.0', + updaterArchivePath: join(root, 'verified.app.tar.gz'), + teamId, + }; const state = { input, calls: [], packageVersion: input.version, desktopVersion: input.desktopVersion }; state.run = async (program, args) => { state.calls.push({ program, args }); if (state.fail?.(program, args)) throw new Error('Simulated acceptance failure'); if (program === 'hdiutil' && args[0] === 'attach') { - const payload = join(root, 'mount/Gajae Code App.app/Contents/Resources/resources/server-payload'); + const app = join(root, 'mount/Gajae Code App.app'); + const payload = join(app, 'Contents/Resources/resources/server-payload'); await mkdir(payload, { recursive: true }); await writeFile(join(payload, 'package.json'), JSON.stringify({ name: 'gajae-app', version: state.packageVersion })); + await mkdir(join(app, 'Contents/MacOS'), { recursive: true }); + await mkdir(join(payload, 'dist-native'), { recursive: true }); + await writeFile(join(payload, 'dist-native/libfixture.dylib'), 'Mach-O dylib fixture'); + await writeFile(join(payload, 'dist-native/libfixture.so'), 'Mach-O shared-object fixture'); + await mkdir(join(payload, 'node_modules/test-addon'), { recursive: true }); + await writeFile(join(payload, 'node_modules/test-addon/addon.node'), 'Mach-O native module fixture'); + for (const path of [ + 'Contents/MacOS/gajae-app-desktop', + 'Contents/MacOS/gajae-app-server', + 'Contents/Resources/resources/server-payload/dist-native/bun', + 'Contents/Resources/resources/server-payload/dist-native/gajae-core', + ]) { + const file = join(app, path); + await mkdir(join(file, '..'), { recursive: true }); + await writeFile(file, 'Mach-O fixture'); + } + } + if (program === 'ditto') { + await cp(args[0], args[1], { recursive: true }); + if (!state.archiveReady && !state.skipArchive) { + await createUpdaterArchive({ appPath: args[1], archivePath: input.updaterArchivePath }); + state.archiveReady = true; + } } - if (program === 'ditto') await cp(args[0], args[1], { recursive: true }); if (program === 'codesign' && args[0] === '--display') return { stdout: '', stderr: state.signature ?? signature }; if (program === 'spctl') return { stdout: '', stderr: `${args.at(-1)}: accepted\nsource=Notarized Developer ID\n` }; - if (program === '/usr/libexec/PlistBuddy') return { stdout: args[1].includes('Identifier') ? 'app.gajae.desktop\n' : `${state.desktopVersion}\n`, stderr: '' }; + if (program === 'xcrun' && args[0] === 'vtool') { + if (state.vtoolOutput !== undefined) return { stdout: state.vtoolOutput, stderr: '' }; + const minimum = state.vtoolMinimumByPath?.get(args.at(-1)) ?? state.vtoolMinimum ?? '13.0'; + return { + stdout: `${args.at(-1)}:\nLoad command 1\n cmd LC_BUILD_VERSION\n platform MACOS\n minos ${minimum}\n sdk 26.5\n`, + stderr: '', + }; + } + if (program === '/usr/libexec/PlistBuddy') { + const command = args[1]; + const output = command.includes('CFBundleIdentifier') + ? 'app.gajae.desktop\n' + : command.includes('LSMinimumSystemVersion') + ? `${state.appMinimumSystemVersion ?? input.minimumSystemVersion}\n` + : `${state.desktopVersion}\n`; + return { stdout: output, stderr: '' }; + } return { stdout: '', stderr: '' }; }; state.execute = () => verifyMacosRelease(input, { run: state.run }); @@ -45,10 +100,108 @@ test('only an explicit Developer ID team and hardened app signature are accepted assert.throws(() => assertNotarizedAssessment('app: rejected\nsource=Notarized Developer ID\n'), /Gatekeeper/); }); -test('DMG, mounted app and quarantined copy all undergo signature/staple/Gatekeeper validation', async t => { +test('verifyMacosApp performs read-only finalized-app checks without copying or quarantine', async t => { + const state = await fixture(t); + const app = join(state.input.root, 'mount/Gajae Code App.app'); + await state.run('hdiutil', ['attach', state.input.dmg, '-nobrowse', '-readonly', '-mountpoint', join(state.input.root, 'mount')]); + await verifyMacosApp({ + app, + teamId, + version: state.input.version, + desktopVersion: state.input.desktopVersion, + minimumSystemVersion: state.input.minimumSystemVersion, + }, { run: state.run }); + assert.ok(state.calls.some(call => call.program === 'codesign' && call.args.at(-1) === app)); + assert.ok(state.calls.some(call => call.program === 'xcrun' && call.args.at(-1) === app)); + assert.ok(state.calls.some(call => call.program === 'spctl' && call.args.at(-1) === app)); + assert.ok(!state.calls.some(call => call.program === 'ditto' || call.program === 'xattr')); +}); + +test('deployment floor rejects metadata 11 when Bun is stamped for macOS 13', async t => { const state = await fixture(t); - await state.execute(); - const targets = [state.input.dmg, join(state.input.root, 'mount/Gajae Code App.app'), join(state.input.root, 'copy/Gajae Code App.app')]; + const app = join(state.input.root, 'mount/Gajae Code App.app'); + await state.run('hdiutil', ['attach', state.input.dmg, '-nobrowse', '-readonly', '-mountpoint', join(state.input.root, 'mount')]); + const inventory = await inventoryApp(app); + state.appMinimumSystemVersion = '11.0'; + state.vtoolMinimum = '11.0'; + state.vtoolMinimumByPath = new Map([[ + join(app, 'Contents/Resources/resources/server-payload/dist-native/bun'), + '13.0', + ]]); + await assert.rejects(() => verifyMacosApp({ + app, teamId, version: state.input.version, desktopVersion: state.input.desktopVersion, + minimumSystemVersion: '11.0', inventory, + }, { run: state.run }), /Bun|requires macOS 13\.0|above declared/); +}); + +test('deployment floor accepts matching macOS 13 evidence and rejects malformed vtool output', async t => { + const state = await fixture(t); + const app = join(state.input.root, 'mount/Gajae Code App.app'); + await state.run('hdiutil', ['attach', state.input.dmg, '-nobrowse', '-readonly', '-mountpoint', join(state.input.root, 'mount')]); + const inventory = await inventoryApp(app); + const valid = await verifyMacosDeploymentFloor({ + app, minimumSystemVersion: '13.0', inventory, + }, { run: state.run }); + assert.equal(valid.maximumStampedMinimumSystemVersion, '13.0'); + state.vtoolOutput = 'not vtool output'; + await assert.rejects(() => verifyMacosDeploymentFloor({ + app, minimumSystemVersion: '13.0', inventory, + }, { run: state.run }), /LC_BUILD_VERSION|vtool output/); +}); + +test('vtool parser rejects unsupported or malformed deployment stamps', () => { + assert.deepEqual(parseVtoolBuildMinimums( + 'x:\nLoad command 1\n cmd LC_BUILD_VERSION\n platform MACOS\n minos 13.0\n', + ), ['13.0']); + assert.throws(() => parseVtoolBuildMinimums( + 'x:\nLoad command 1\n cmd LC_BUILD_VERSION\n platform IOS\n minos 13.0\n', + ), /unsupported/); + assert.throws(() => parseVtoolBuildMinimums( + 'x:\nLoad command 1\n cmd LC_BUILD_VERSION\n platform MACOS\n', + ), /missing|LC_BUILD_VERSION/); + assert.throws(() => parseVtoolBuildMinimums( + 'x:\nLoad command 1\n cmd LC_BUILD_VERSION\n platform MACOS\n minos 13.0\n' + + 'Load command 2\n cmd LC_VERSION_MIN_MACOSX\n version 11.0\n', + ), /unsupported|LC_VERSION_MIN_MACOSX/); +}); + +test('deployment floor includes dylib and shared-object runtime modules', async t => { + const state = await fixture(t); + const app = join(state.input.root, 'mount/Gajae Code App.app'); + await state.run('hdiutil', ['attach', state.input.dmg, '-nobrowse', '-readonly', '-mountpoint', join(state.input.root, 'mount')]); + const inventory = await inventoryApp(app); + const dylib = join(app, 'Contents/Resources/resources/server-payload/dist-native/libfixture.dylib'); + state.vtoolMinimumByPath = new Map([[dylib, '14.0']]); + await assert.rejects(() => verifyMacosDeploymentFloor({ + app, minimumSystemVersion: '13.0', inventory, + }, { run: state.run }), /libfixture\.dylib|requires macOS 14\.0/); +}); + +test('deployment floor rejects vtool diagnostics and malformed command results', async t => { + const state = await fixture(t); + const app = join(state.input.root, 'mount/Gajae Code App.app'); + await state.run('hdiutil', ['attach', state.input.dmg, '-nobrowse', '-readonly', '-mountpoint', join(state.input.root, 'mount')]); + const inventory = await inventoryApp(app); + state.vtoolStderr = 'warning'; + await assert.rejects(() => verifyMacosDeploymentFloor({ + app, minimumSystemVersion: '13.0', inventory, + }, { run: async (program, args, options) => { + const result = await state.run(program, args, options); + if (program === 'xcrun' && args[0] === 'vtool') result.stderr = state.vtoolStderr; + return result; + } }), /stderr|diagnostics/); + await assert.rejects(() => verifyMacosDeploymentFloor({ + app, minimumSystemVersion: '13.0', inventory, + }, { run: async (program, args) => { + if (program === 'xcrun' && args[0] === 'vtool') return { stdout: 13, stderr: '' }; + return state.run(program, args); + } }), /invalid shape/); +}); + +test('DMG, mounted app, quarantined copy and extracted updater app all undergo validation', async t => { + const state = await fixture(t); + const result = await state.execute(); + const targets = [state.input.dmg, join(state.input.root, 'mount/Gajae Code App.app'), join(state.input.root, 'copy/Gajae Code App.app'), result.extractedApp]; for (const target of targets) { assert.ok(state.calls.some(call => call.program === 'codesign' && call.args[0] === '--verify' && call.args.at(-1) === target)); assert.ok(state.calls.some(call => call.program === 'xcrun' && call.args[0] === 'stapler' && call.args[1] === 'validate' && call.args.at(-1) === target)); @@ -57,7 +210,8 @@ test('DMG, mounted app and quarantined copy all undergo signature/staple/Gatekee const quarantine = state.calls.filter(call => call.program === 'xattr'); assert.equal(quarantine.length, 1); assert.equal(quarantine[0].args.at(-1), targets[2]); - assert.equal(state.calls.filter(call => call.program === 'lipo').length, 2); + assert.equal(state.calls.filter(call => call.program === 'lipo').length, 6); + assert.equal(result.archive.inventory.entries.length, result.inventory.entries.length); assert.deepEqual(state.calls.at(-1), { program: 'hdiutil', args: ['detach', join(state.input.root, 'mount')] }); assert.equal(await readFile(state.input.dmg, 'utf8'), 'original immutable image fixture'); assert.ok(!state.calls.some(call => call.args.includes('--sign') || call.args.includes('staple') || call.args.includes('submit'))); @@ -70,6 +224,14 @@ test('a bad DMG signature blocks before mounting', async t => { assert.ok(!state.calls.some(call => call.program === 'hdiutil' && call.args[0] === 'attach')); }); +test('a missing updater archive is a hard failure rather than DMG-only verification', async t => { + const state = await fixture(t); + state.skipArchive = true; + await assert.rejects(state.execute(), /archive|ENOENT|regular file/i); + assert.equal(state.calls.at(-1).args[0], 'detach'); + assert.ok(!state.calls.some(call => call.program === 'codesign' && call.args.at(-1).includes('.updater-extract-'))); +}); + test('app payload and desktop versions are independently checked against the pinned source', async t => { for (const field of ['packageVersion', 'desktopVersion']) { const state = await fixture(t); @@ -79,6 +241,13 @@ test('app payload and desktop versions are independently checked against the pin } }); +test('the pinned minimum system version is required on both DMG and updater apps', async t => { + const state = await fixture(t); + state.appMinimumSystemVersion = '14.0'; + await assert.rejects(state.execute(), /minimumSystemVersion|LSMinimumSystemVersion/); + assert.equal(state.calls.at(-1).args[0], 'detach'); +}); + test('copy-only signature rejection, absent staples and architecture errors all detach the image', async t => { for (const fail of [ (program, args) => program === 'codesign' && args[0] === '--verify' && args.at(-1).includes('/copy/'), diff --git a/scripts/release/local-release.mjs b/scripts/release/local-release.mjs index 4ba3b029..1c49cf97 100644 --- a/scripts/release/local-release.mjs +++ b/scripts/release/local-release.mjs @@ -1,60 +1,61 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; import { createReadStream, realpathSync } from 'node:fs'; -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; -import { ARTIFACT_PREFIX, PACKAGE_NAME, SERVER_PACKAGE_NAME } from '../../shared/productIdentity.js'; +import { ARTIFACT_PREFIX, PACKAGE_NAME, REPOSITORY_SLUG, SERVER_PACKAGE_NAME } from '../../shared/productIdentity.js'; import { releaseCommand } from './local-release-command.mjs'; import { verifyMacosRelease } from './local-release-macos.mjs'; import { assertOutOfTree } from './out-of-tree.mjs'; +import { assetNames, UPDATER_ASSET_LIMITS, validateDesktopUpdateManifest, validateDesktopVersionFloor, validateReleaseAssets } from './updater-artifacts.mjs'; +import { collectPublishedDesktopHistory, resolveReleaseTag } from './updater-history.mjs'; +import { readUpdaterSidecar, verifyUpdaterSignature } from './updater-signature.mjs'; const demand = (condition, message) => { if (!condition) throw new Error(message); }; const positiveId = value => /^[1-9][0-9]*$/.test(String(value)) && Number.isSafeInteger(Number(value)); const sha256Pattern = /^[a-f0-9]{64}$/; export function releaseOptions(values) { - demand(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(values.repo ?? ''), 'Explicit --repo OWNER/REPO is required.'); + demand(values.repo === REPOSITORY_SLUG, 'Explicit canonical --repo is required.'); demand(/^v\d+\.\d+\.\d+(?:-[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*)?$/.test(values.tag ?? ''), 'Explicit version --tag is required.'); demand(/^[a-f0-9]{40}$/.test(values.commit ?? ''), 'Explicit full lowercase 40-character --commit is required.'); demand(positiveId(values['draft-id']), 'Explicit numeric --draft-id is required.'); demand(/^[A-Z0-9]{10}$/.test(values['team-id'] ?? ''), 'Explicit 10-character --team-id is required.'); + demand(typeof values['updater-public-key-file'] === 'string' && values['updater-public-key-file'].length > 0, + 'Explicit --updater-public-key-file is required; never supply a private key.'); + const mode = values.mode ?? 'local'; + demand(mode === 'local' || mode === 'ci', 'Release mode must be local or ci.'); const version = values.tag.slice(1); + const names = assetNames({ productVersion: version, tag: values.tag }); const pins = new Map(); for (const entry of values.asset ?? []) { const [name, hash, extra] = entry.split('='); demand(!extra && /^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$/.test(name) - && name.startsWith(ARTIFACT_PREFIX) && name.includes(`-${version}-`) && !name.endsWith('.sha256') + && name.startsWith(ARTIFACT_PREFIX) && name.includes(`-${version}-`) && !name.endsWith('.sha256') && !name.endsWith('.sig') && sha256Pattern.test(hash ?? '') && !pins.has(name), 'Each --asset must pin a unique versioned payload basename to a lowercase SHA-256.'); pins.set(name, hash); } - const dmgName = `${ARTIFACT_PREFIX}desktop-${version}-macos-arm64.dmg`; - const serverName = `${ARTIFACT_PREFIX}server-${version}-linux-x64-node22.tar.gz`; - demand(pins.has(dmgName) && pins.has(serverName) && pins.size <= 16, 'Pin the canonical macOS DMG and Linux server archive (at most 16 payloads).'); + const dmgName = names.macos.dmg; + const serverName = names.server.archive; + demand(names.canonicalPayloads.every(name => pins.has(name)) && pins.size <= 16, + 'Pin the canonical DMG, updater archive and server archive (at most 16 payloads).'); + demand(mode !== 'ci' || pins.size === names.canonicalPayloads.length, 'CI permits only the three canonical payload pins.'); return { repo: values.repo, tag: values.tag, commit: values.commit, draftId: Number(values['draft-id']), - teamId: values['team-id'], publish: values.publish === true, version, pins, dmgName, serverName }; + teamId: values['team-id'], publish: values.publish === true, version, pins, dmgName, serverName, + names, mode, publicKeyFile: values['updater-public-key-file'] }; } export function validateDraft(release, options) { demand(release.id === options.draftId && release.draft === true && release.published_at === null, 'Expected the exact existing unpublished draft ID; published releases are never edited.'); demand(release.tag_name === options.tag && release.target_commitish === options.commit, 'Draft tag/target must match the exact supplied tag and full commit, not a branch.'); demand(release.prerelease === options.version.includes('-'), 'Draft prerelease status does not match the version tag.'); - const expected = [...options.pins.keys()].flatMap(name => [name, `${name}.sha256`]).sort(); - demand(Array.isArray(release.assets) - && JSON.stringify(release.assets.map(asset => asset.name).sort()) === JSON.stringify(expected), 'Draft assets differ from the explicitly pinned payloads and their checksum files. No assets will be replaced or removed.'); - const ids = new Set(); - for (const asset of release.assets) { - demand(positiveId(asset.id) && !ids.has(asset.id) && asset.state === 'uploaded' - && Number.isSafeInteger(asset.size) && asset.size > 0 && asset.size <= 2 * 1024 ** 3, 'Draft has invalid, duplicate, incomplete or oversized assets.'); - ids.add(asset.id); - if (asset.name.endsWith('.sha256')) demand(asset.size <= 1024, 'Checksum sidecar is too large.'); - if (asset.name === options.dmgName) demand(asset.size <= 250 * 1024 ** 2, 'DMG exceeds the release size limit.'); - demand(asset.digest == null || /^sha256:[a-f0-9]{64}$/.test(asset.digest), 'Unexpected GitHub asset digest format.'); - } + validateReleaseAssets({ assets: release.assets, productVersion: options.version, + tag: options.tag, mode: options.mode, pins: options.pins }); } export function releaseSnapshot(release) { @@ -75,30 +76,12 @@ async function fileHash(path) { return hash.digest('hex'); } -// Resolves lightweight and annotated tags. A draft may not have created its -// tag yet, but its target_commitish must already be the exact commit. -export async function inspectReleaseTag(options, api) { - const pages = await api(`git/matching-refs/tags/${options.tag}`, ['--paginate', '--slurp']); - demand(Array.isArray(pages) && pages.every(Array.isArray), 'Unexpected tag reference response.'); - const refs = pages.flat().filter(ref => ref.ref === `refs/tags/${options.tag}`); - demand(refs.length <= 1, 'Ambiguous release tag.'); - if (refs.length === 0) return 'absent'; - const initial = refs[0].object?.sha; - let object = refs[0].object; - const seen = new Set(); - while (object?.type === 'tag') { - demand(/^[a-f0-9]{40}$/.test(object.sha) && !seen.has(object.sha) && seen.size < 10, 'Invalid or cyclic annotated release tag.'); - seen.add(object.sha); - object = (await api(`git/tags/${object.sha}`)).object; - } - demand(object?.type === 'commit' && object.sha === options.commit, 'Existing remote tag does not resolve to the supplied commit.'); - return initial; -} - export async function processLocalRelease(options, { - run = releaseCommand, verifyMac = verifyMacosRelease, platform = process.platform, arch = process.arch, + run = releaseCommand, verifyMac = verifyMacosRelease, verifySignature = verifyUpdaterSignature, + collectHistory = collectPublishedDesktopHistory, platform = process.platform, arch = process.arch, } = {}) { demand(platform === 'darwin' && arch === 'arm64', 'Local signed release verification requires macOS arm64.'); + const publicKey = await readUpdaterSidecar(options.publicKeyFile, UPDATER_ASSET_LIMITS.maxSignatureBytes); const endpoint = path => `repos/${options.repo}/${path}`; const api = async (path, args = []) => JSON.parse((await run('gh', ['api', '--hostname', 'github.com', endpoint(path), ...args])).stdout); const readDraft = async () => { @@ -112,13 +95,22 @@ export async function processLocalRelease(options, { }; const before = await readDraft(); const snapshot = releaseSnapshot(before); - const tagSnapshot = await inspectReleaseTag(options, api); + const readTag = () => resolveReleaseTag({ tag: options.tag, expectedCommit: options.commit, allowAbsent: true }, api); + const tagSnapshot = JSON.stringify(await readTag()); demand((await api(`git/commits/${options.commit}`)).sha === options.commit, 'The supplied commit is not a remote Git commit.'); const source = JSON.parse((await run('gh', ['api', '--hostname', 'github.com', endpoint(`contents/package.json?ref=${options.commit}`), '--header', 'Accept: application/vnd.github.raw+json'])).stdout); demand(source.name === PACKAGE_NAME && source.version === options.version && typeof source.desktopVersion === 'string', 'Pinned commit package/version does not match the release tag.'); - - const root = await mkdtemp(join(tmpdir(), 'gajae-local-release-')); + const config = JSON.parse((await run('gh', ['api', '--hostname', 'github.com', + endpoint(`contents/src-tauri/tauri.conf.json?ref=${options.commit}`), '--header', 'Accept: application/vnd.github.raw+json'])).stdout); + const minimumSystemVersion = config.bundle?.macOS?.minimumSystemVersion; + demand(typeof minimumSystemVersion === 'string', 'Pinned commit must declare the minimum macOS version.'); + const history = await collectHistory({ repo: options.repo }, { run }); + const floor = validateDesktopVersionFloor({ candidateDesktopVersion: source.desktopVersion, + priorPublished: history.priorPublished, historyComplete: history.historyComplete }); + const historySnapshot = JSON.stringify([...history.priorPublished].sort((a, b) => a.id - b.id)); + + const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-local-release-'))); let preserveDirectory = false; let publicationRequested = false; try { @@ -128,7 +120,7 @@ export async function processLocalRelease(options, { const output = join(root, asset.name); // Asset IDs bind downloads to the inspected objects, not mutable names. await run('gh', ['api', '--hostname', 'github.com', endpoint(`releases/assets/${asset.id}`), - '--header', 'Accept: application/octet-stream'], { output, timeout: 600_000 }); + '--header', 'Accept: application/octet-stream'], { output, timeout: 600_000, maxOutputBytes: asset.size }); demand((await stat(output)).size === asset.size, 'Downloaded asset size differs from draft metadata.'); const hash = await fileHash(output); if (asset.digest) demand(asset.digest === `sha256:${hash}`, 'Downloaded asset differs from its GitHub digest.'); @@ -136,6 +128,14 @@ export async function processLocalRelease(options, { hashes[asset.name] = hash; } for (const [name, hash] of options.pins) assertChecksum(await readFile(join(root, `${name}.sha256`), 'utf8'), name, hash); + const signature = (await readUpdaterSidecar(join(root, options.names.macos.archiveSignature), UPDATER_ASSET_LIMITS.maxSignatureBytes)).trim(); + const manifest = JSON.parse(await readUpdaterSidecar(join(root, options.names.macos.manifest), UPDATER_ASSET_LIMITS.maxManifestBytes)); + validateDesktopUpdateManifest(manifest, { productVersion: options.version, desktopVersion: source.desktopVersion, + tag: options.tag, commit: options.commit, minimumSystemVersion, expectedSignature: signature }); + const verified = await verifySignature({ + archivePath: join(root, options.names.macos.archive), signature, publicKey, root, + expectedSha256: options.pins.get(options.names.macos.archive), + }, { run }); const archive = join(root, options.serverName); const members = (await run('tar', ['-tzf', archive])).stdout.split('\n').filter(name => name === 'package.json' || name === './package.json'); @@ -143,12 +143,18 @@ export async function processLocalRelease(options, { const server = JSON.parse((await run('tar', ['-xOzf', archive, '--', members[0]])).stdout); demand(server.name === SERVER_PACKAGE_NAME && server.version === options.version, 'Server archive package/version does not match the release tag.'); await verifyMac({ dmg: join(root, options.dmgName), root, teamId: options.teamId, - version: options.version, desktopVersion: source.desktopVersion }, { run }); + version: options.version, desktopVersion: source.desktopVersion, minimumSystemVersion, + updaterArchivePath: verified.archivePath }, { run }); // Downloads/signature checks take time. Re-read every mutable release // input and the tag immediately before the sole optional write. + const freshHistory = await collectHistory({ repo: options.repo }, { run }); + validateDesktopVersionFloor({ candidateDesktopVersion: source.desktopVersion, + priorPublished: freshHistory.priorPublished, historyComplete: freshHistory.historyComplete }); + demand(JSON.stringify([...freshHistory.priorPublished].sort((a, b) => a.id - b.id)) === historySnapshot, + 'Published desktop-version history changed during verification; publication refused.'); demand(releaseSnapshot(await readDraft()) === snapshot, 'Draft metadata or assets changed during verification; publication refused.'); - demand(await inspectReleaseTag(options, api) === tagSnapshot, 'Tag changed during verification; publication refused.'); + demand(JSON.stringify(await readTag()) === tagSnapshot, 'Tag changed during verification; publication refused.'); if (options.publish) { let published; try { @@ -163,7 +169,7 @@ export async function processLocalRelease(options, { 'Publication response is unexpected; inspect the exact release ID and assets. No automatic rollback is performed.'); } return { status: options.publish ? 'published' : 'verified-draft', repo: options.repo, draftId: options.draftId, - tag: options.tag, commit: options.commit, teamId: options.teamId, hashes, + tag: options.tag, commit: options.commit, teamId: options.teamId, hashes, desktopVersionFloor: floor.floor, limits: ['Independent hashes bind the operator-selected builds to this release; this is not a reproducible-build attestation.', 'Runtime/GUI/Linux acceptance remains a separate prerequisite. Additional payloads receive hash validation only.', 'Keep a single publisher: the final recheck and publication request are separate operations, not an atomic guarantee.'] }; @@ -179,8 +185,9 @@ export async function processLocalRelease(options, { } const usage = `Usage: node scripts/release/local-release.mjs --repo OWNER/REPO --draft-id ID - --tag vVERSION --commit FULL_SHA --team-id TEAMID1234 + --tag vVERSION --commit FULL_SHA --team-id TEAMID1234 --updater-public-key-file PUBLIC_KEY_FILE --asset PAYLOAD_BASENAME=SHA256 --asset OTHER_PAYLOAD_BASENAME=SHA256 [--publish] + [--mode local|ci] Default: verify an existing draft without changing it. --publish explicitly repeats all checks then publishes that exact draft ID. Never uploads, overwrites, @@ -192,7 +199,7 @@ async function main() { let options; try { const { values } = parseArgs({ options: { - ...Object.fromEntries(['repo', 'draft-id', 'tag', 'commit', 'team-id'].map(name => [name, { type: 'string' }])), + ...Object.fromEntries(['repo', 'draft-id', 'tag', 'commit', 'team-id', 'updater-public-key-file', 'mode'].map(name => [name, { type: 'string' }])), asset: { type: 'string', multiple: true }, publish: { type: 'boolean' }, help: { type: 'boolean' }, } }); if (values.help) { process.stdout.write(usage); return; } diff --git a/scripts/release/local-release.test.mjs b/scripts/release/local-release.test.mjs index 304b199b..5cb03727 100644 --- a/scripts/release/local-release.test.mjs +++ b/scripts/release/local-release.test.mjs @@ -8,15 +8,20 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { releaseCommand } from './local-release-command.mjs'; -import { assertChecksum, inspectReleaseTag, processLocalRelease, releaseOptions } from './local-release.mjs'; +import { assertChecksum, processLocalRelease, releaseOptions } from './local-release.mjs'; +import { assetNames, buildDesktopUpdateManifest } from './updater-artifacts.mjs'; +import { resolveReleaseTag } from './updater-history.mjs'; const version = '2.0.0-beta.99'; const commit = 'a'.repeat(40); const teamId = 'AB12345678'; const dmgName = `gajae-app-desktop-${version}-macos-arm64.dmg`; const serverName = `gajae-app-server-${version}-linux-x64-node22.tar.gz`; +const names = assetNames({ productVersion: version }); +const publicKey = Buffer.from('injected verifier public-key fixture').toString('base64'); +const signature = 'A'.repeat(88); const sha = bytes => createHash('sha256').update(bytes).digest('hex'); -const packageFor = value => ({ name: 'gajae-app', version: value, desktopVersion: '0.2.2' }); +const packageFor = value => ({ name: 'gajae-app', version: value, desktopVersion: '0.2.4' }); async function fixture(t, { serverVersion = version, serverPackage = 'gajae-app-server' } = {}) { const directory = await mkdtemp(join(tmpdir(), 'gajae-release-test-')); @@ -24,22 +29,32 @@ async function fixture(t, { serverVersion = version, serverPackage = 'gajae-app- await writeFile(join(directory, 'package.json'), JSON.stringify({ name: serverPackage, version: serverVersion })); const archive = join(directory, serverName); await releaseCommand('tar', ['-czf', archive, '-C', directory, 'package.json']); - const files = new Map([[dmgName, Buffer.from('signed image fixture')], [serverName, await readFile(archive)]]); - const values = { repo: 'owner/repo', tag: `v${version}`, commit, 'draft-id': '123', 'team-id': teamId, + const files = new Map([[dmgName, Buffer.from('signed image fixture')], [serverName, await readFile(archive)], + [names.macos.archive, Buffer.from('injected verifier archive fixture')]]); + const publicKeyFile = join(directory, 'updater.pub'); + await writeFile(publicKeyFile, publicKey); + const values = { repo: 'devswha/gajae-code-app', tag: `v${version}`, commit, 'draft-id': '123', 'team-id': teamId, + 'updater-public-key-file': publicKeyFile, asset: [...files].map(([name, body]) => `${name}=${sha(body)}`) }; for (const [name, body] of [...files]) files.set(`${name}.sha256`, Buffer.from(`${sha(body)} ${name}\n`)); + files.set(names.macos.archiveSignature, Buffer.from(signature)); + files.set(names.macos.manifest, Buffer.from(JSON.stringify(buildDesktopUpdateManifest({ + productVersion: version, desktopVersion: '0.2.4', commit, signature, + notes: 'Reviewed notes', pubDate: '2026-09-05T00:00:00Z', minimumSystemVersion: '11.0', + })))); const state = { release: { id: 123, tag_name: values.tag, target_commitish: commit, draft: true, published_at: null, prerelease: true, name: 'Reviewed release', body: 'Reviewed notes', assets: [...files].map(([name, body], index) => ({ id: index + 1, name, label: '', size: body.length, state: 'uploaded', digest: `sha256:${sha(body)}`, updated_at: '2026-09-05T00:00:00Z' })) }, - files, values, calls: [], mutations: [], macChecked: 0, tag: [], source: packageFor(version), reads: 0, + files, values, calls: [], mutations: [], macChecked: 0, signatureChecked: 0, tag: [], source: packageFor(version), reads: 0, + history: { priorPublished: [], historyComplete: true }, historyReads: 0, }; state.run = async (program, args, options = {}) => { state.calls.push({ program, args, options }); if (program !== 'gh') return releaseCommand(program, args, options); assert.deepEqual(args.slice(0, 3), ['api', '--hostname', 'github.com']); - const path = args[3].replace('repos/owner/repo/', ''); + const path = args[3].replace('repos/devswha/gajae-code-app/', ''); const json = value => ({ stdout: JSON.stringify(value), stderr: '' }); if (args.includes('PATCH')) { state.mutations.push({ path, args }); @@ -58,23 +73,46 @@ async function fixture(t, { serverVersion = version, serverPackage = 'gajae-app- if (path === `git/matching-refs/tags/v${version}`) return json([state.tag]); if (path === `git/commits/${commit}`) return json({ sha: commit }); if (path === `contents/package.json?ref=${commit}`) return json(state.source); + if (path === `contents/src-tauri/tauri.conf.json?ref=${commit}`) return json({ bundle: { macOS: { minimumSystemVersion: '11.0' } } }); if (path.startsWith('releases/assets/')) { const asset = state.release.assets.find(item => item.id === Number(path.split('/').at(-1))); assert.ok(asset, 'Downloads must refer to known numeric asset IDs.'); + assert.equal(options.maxOutputBytes, asset.size, 'Every download must be streaming-bounded to the inspected size.'); await writeFile(options.output, state.files.get(asset.name), { flag: 'wx' }); return { stdout: '', stderr: '' }; } assert.fail(`Unexpected API call ${path}`); }; + state.verifySignature = async input => { + assert.equal(input.publicKey, publicKey); + assert.equal(input.signature, signature); + assert.equal(input.expectedSha256, sha(state.files.get(names.macos.archive))); + state.signatureChecked++; + if (state.signatureError) throw new Error(state.signatureError); + const bytes = await readFile(input.archivePath); + state.verifiedArchive = join(input.root, 'verified-updater.archive'); + await writeFile(state.verifiedArchive, bytes, { flag: 'wx' }); + return { archivePath: state.verifiedArchive, sha256: sha(bytes), size: bytes.length }; + }; + state.collectHistory = async () => { + state.historyReads++; + if (state.historyReads === 2 && state.beforeHistoryRecheck) state.beforeHistoryRecheck(); + return structuredClone(state.history); + }; state.verifyMac = async input => { assert.equal(input.teamId, teamId); assert.equal(input.version, version); - assert.equal(input.desktopVersion, '0.2.2'); + assert.equal(input.desktopVersion, '0.2.4'); + assert.equal(input.minimumSystemVersion, '11.0'); + assert.equal(state.signatureChecked, 1); + assert.equal(input.updaterArchivePath, state.verifiedArchive); + assert.notEqual(input.updaterArchivePath, join(input.root, names.macos.archive)); state.macChecked++; if (state.macError) throw new Error(state.macError); }; state.execute = overrides => processLocalRelease(releaseOptions({ ...state.values, ...overrides }), { - run: state.run, verifyMac: state.verifyMac, platform: 'darwin', arch: 'arm64', + run: state.run, verifyMac: state.verifyMac, verifySignature: state.verifySignature, + collectHistory: state.collectHistory, platform: 'darwin', arch: 'arm64', }); return state; } @@ -85,6 +123,8 @@ test('default path verifies the downloaded bytes and versions without any releas assert.equal(result.status, 'verified-draft'); assert.equal(state.macChecked, 1); assert.equal(state.reads, 2); + assert.equal(state.historyReads, 2); + assert.equal(state.signatureChecked, 1); assert.deepEqual(state.mutations, []); assert.equal(result.hashes[dmgName], sha(state.files.get(dmgName))); const output = state.calls.find(call => call.options.output).options.output; @@ -98,10 +138,10 @@ test('explicit publish performs exactly one draft=false PATCH by ID, after all v assert.equal(result.status, 'published'); assert.equal(state.macChecked, 1); assert.deepEqual(state.mutations, [{ path: 'releases/123', args: ['api', '--hostname', 'github.com', - 'repos/owner/repo/releases/123', '--method', 'PATCH', '--field', 'draft=false'] }]); + 'repos/devswha/gajae-code-app/releases/123', '--method', 'PATCH', '--field', 'draft=false'] }]); assert.equal(state.release.name, 'Reviewed release'); assert.equal(state.release.body, 'Reviewed notes'); - assert.equal(state.release.assets.length, 4); + assert.equal(state.release.assets.length, 8); }); test('ambiguous refs, omitted pins, duplicate names and unsafe filenames cannot enter publication', async t => { @@ -137,9 +177,9 @@ test('public releases, wrong IDs/tags/commits and partial assets are refused bef test('unreviewed additional assets remain untouched and block publication', async t => { const state = await fixture(t); state.release.assets.push({ ...state.release.assets[0], id: 90, name: `gajae-app-desktop-${version}-linux-x64.AppImage` }); - await assert.rejects(state.execute({ publish: true }), /assets differ/); + await assert.rejects(state.execute({ publish: true }), /exact expected|Unlisted/); assert.deepEqual(state.mutations, []); - assert.equal(state.release.assets.length, 5); + assert.equal(state.release.assets.length, 9); }); test('additional Linux payloads can be explicitly pinned with their own checksum sidecar', async t => { @@ -153,7 +193,7 @@ test('additional Linux payloads can be explicitly pinned with their own checksum state: 'uploaded', digest: `sha256:${sha(data)}`, updated_at: '2026-09-05T00:00:00Z' }); } const result = await state.execute(); - assert.equal(Object.keys(result.hashes).length, 6); + assert.equal(Object.keys(result.hashes).length, 10); assert.deepEqual(state.mutations, []); }); @@ -162,7 +202,7 @@ test('an attacker updating both the uploaded payload and its GitHub digest canno const replacement = Buffer.from('replacement image'); state.files.set(dmgName, replacement); Object.assign(state.release.assets[0], { digest: `sha256:${sha(replacement)}`, size: replacement.length }); - await assert.rejects(state.execute({ publish: true }), /independently supplied SHA-256/); + await assert.rejects(state.execute({ publish: true }), /independent pin/); assert.deepEqual(state.mutations, []); }); @@ -202,6 +242,67 @@ test('macOS signature/acceptance failure never calls the publication API', async assert.deepEqual(state.mutations, []); }); +test('updater cryptographic failure blocks app verification and publication', async t => { + const state = await fixture(t); + state.signatureError = 'Minisign rejected the archive'; + await assert.rejects(state.execute({ publish: true }), /Minisign/); + assert.equal(state.signatureChecked, 1); + assert.equal(state.macChecked, 0); + assert.deepEqual(state.mutations, []); +}); + +test('manifest identity mismatches fail even when their uploaded digest is updated', async t => { + for (const change of [ + manifest => { manifest.version = '0.2.5'; }, + manifest => { manifest.productVersion = '2.0.0-beta.98'; }, + manifest => { manifest.minimumSystemVersion = '12.0'; }, + manifest => { manifest.build.commit = 'b'.repeat(40); }, + manifest => { manifest.platforms['darwin-aarch64'].signature = 'B'.repeat(88); }, + manifest => { manifest.platforms['darwin-aarch64'].url = 'https://example.com/replacement.tar.gz'; }, + ]) { + const state = await fixture(t); + const manifest = JSON.parse(state.files.get(names.macos.manifest)); + change(manifest); + const bytes = Buffer.from(JSON.stringify(manifest)); + state.files.set(names.macos.manifest, bytes); + Object.assign(state.release.assets.find(asset => asset.name === names.macos.manifest), + { size: bytes.length, digest: `sha256:${sha(bytes)}` }); + await assert.rejects(state.execute({ publish: true })); + assert.equal(state.signatureChecked, 0); + assert.equal(state.macChecked, 0); + assert.deepEqual(state.mutations, []); + } +}); + +test('missing history proof and nonadvancing desktop versions block all downloads', async t => { + for (const change of [ + state => { state.history.historyComplete = false; }, + state => { state.source.desktopVersion = '0.2.3'; }, + state => { state.history.priorPublished.push({ + id: 1, tag: 'v2.0.0-beta.98', productVersion: '2.0.0-beta.98', + desktopVersion: '0.2.4', commit: 'b'.repeat(40), publishedAt: '2026-09-04T00:00:00Z', + }); }, + ]) { + const state = await fixture(t); + change(state); + await assert.rejects(state.execute({ publish: true })); + assert.ok(!state.calls.some(call => call.options.output)); + assert.deepEqual(state.mutations, []); + } +}); + +test('new published history during verification prevents the final publication write', async t => { + const state = await fixture(t); + state.beforeHistoryRecheck = () => state.history.priorPublished.push({ + id: 1, tag: 'v2.0.0-beta.98', productVersion: '2.0.0-beta.98', + desktopVersion: '0.2.3', commit: 'b'.repeat(40), publishedAt: '2026-09-04T00:00:00Z', + }); + await assert.rejects(state.execute({ publish: true }), /history changed/); + assert.equal(state.macChecked, 1); + assert.equal(state.historyReads, 2); + assert.deepEqual(state.mutations, []); +}); + test('asset replacement, edited notes or newly public release during checks prevent publication', async t => { for (const change of [ release => { release.assets[0].id = 999; }, release => { release.body = 'Unreviewed notes'; }, @@ -228,15 +329,15 @@ test('an existing tag must resolve to the exact commit, and a tag created during test('annotated tags are peeled and cyclic or noncommit targets fail closed', async t => { const state = await fixture(t); - const options = releaseOptions(state.values); + const options = { tag: state.values.tag, expectedCommit: commit }; const annotation = 'b'.repeat(40); const ref = { ref: `refs/tags/v${version}`, object: { type: 'tag', sha: annotation } }; - assert.equal(await inspectReleaseTag(options, async path => path.startsWith('git/matching-refs/') - ? [[ref]] : { object: { type: 'commit', sha: commit } }), annotation); - await assert.rejects(inspectReleaseTag(options, async path => path.startsWith('git/matching-refs/') + assert.deepEqual(await resolveReleaseTag(options, async path => path.startsWith('git/matching-refs/') + ? [[ref]] : { object: { type: 'commit', sha: commit } }), { commit, referenceSha: annotation }); + await assert.rejects(resolveReleaseTag(options, async path => path.startsWith('git/matching-refs/') ? [[ref]] : ref), /cyclic/); - await assert.rejects(inspectReleaseTag(options, async path => path.startsWith('git/matching-refs/') - ? [[ref]] : { object: { type: 'tree', sha: commit } }), /does not resolve/); + await assert.rejects(resolveReleaseTag(options, async path => path.startsWith('git/matching-refs/') + ? [[ref]] : { object: { type: 'tree', sha: commit } }), /commit|resolve/); }); test('an uncertain publication failure is never retried or rolled back', async t => { @@ -286,3 +387,18 @@ test('command transport never clobbers an existing file and redacts child errors await assert.rejects(releaseCommand(process.execPath, ['-e', 'console.error("DO-NOT-PRINT");process.exit(1)']), error => !error.message.includes('DO-NOT-PRINT')); await assert.rejects(releaseCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { timeout: 20 }), /timed out/); }); + +test('command downloads enforce the byte limit while streaming, before writing oversized chunks', async t => { + const directory = await mkdtemp(join(tmpdir(), 'gajae-release-command-limit-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const exact = join(directory, 'exact'); + await releaseCommand(process.execPath, ['-e', 'process.stdout.write(Buffer.alloc(1024, 255))'], { + output: exact, maxOutputBytes: 1024, + }); + assert.deepEqual(await readFile(exact), Buffer.alloc(1024, 255)); + const oversized = join(directory, 'oversized'); + await assert.rejects(releaseCommand(process.execPath, ['-e', 'process.stdout.write(Buffer.alloc(1025))'], { + output: oversized, maxOutputBytes: 1024, + }), /file output limit/); + assert.ok((await readFile(oversized)).length <= 1024); +}); diff --git a/scripts/release/make-macos-updater.mjs b/scripts/release/make-macos-updater.mjs new file mode 100644 index 00000000..707169be --- /dev/null +++ b/scripts/release/make-macos-updater.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { constants, createReadStream, realpathSync } from 'node:fs'; +import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { PRODUCT_NAME } from '../../shared/productIdentity.js'; + +import { releaseCommand } from './local-release-command.mjs'; +import { verifyMacosApp, verifyMacosRelease } from './local-release-macos.mjs'; +import { assertOutOfTree } from './out-of-tree.mjs'; +import { compareAppInventories, createUpdaterArchive, inventoryApp } from './updater-archive.mjs'; +import { assetNames, buildDesktopUpdateManifest, UPDATER_ASSET_LIMITS } from './updater-artifacts.mjs'; +import { readUpdaterSidecar, verifyUpdaterSignature } from './updater-signature.mjs'; + +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const signer = fileURLToPath(new URL('../../node_modules/@tauri-apps/cli/tauri.js', import.meta.url)); +const demand = (condition, message) => { if (!condition) throw new Error(message); }; + +async function fileHash(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +/** Build six macOS assets from one already signed, notarized, stapled app. */ +export async function makeMacosUpdater({ + app, dmg, outputDirectory, productVersion, desktopVersion, commit, + minimumSystemVersion, teamId, publicKeyFile, notes, pubDate, +}, { run = releaseCommand, verifyApp = verifyMacosApp, verifyMac = verifyMacosRelease } = {}) { + const names = assetNames({ productVersion }); + demand(/^[a-f0-9]{40}$/.test(commit ?? ''), 'The exact build commit is required.'); + demand(/^[A-Z0-9]{10}$/.test(teamId ?? ''), 'An explicit Developer ID team is required.'); + demand(typeof outputDirectory === 'string' && outputDirectory.length > 0, 'A fresh output directory is required.'); + const requestedOutput = resolve(outputDirectory); + const output = join(await realpath(dirname(requestedOutput)), basename(requestedOutput)); + const sourceApp = await realpath(app); + demand(output !== sourceApp && !output.startsWith(`${sourceApp}/`), 'Output must not modify the finalized source app.'); + const appIdentity = { teamId, version: productVersion, desktopVersion, minimumSystemVersion }; + const publicKey = await readUpdaterSidecar(publicKeyFile, UPDATER_ASSET_LIMITS.maxSignatureBytes); + const sourceInventory = await inventoryApp(app); + const dmgStat = await stat(dmg); + demand(dmgStat.isFile() && dmgStat.size > 0 && dmgStat.size <= UPDATER_ASSET_LIMITS.maxDmgBytes, + 'The finalized DMG must be a bounded nonempty file.'); + const work = await realpath(await mkdtemp(join(tmpdir(), 'gajae-updater-build-'))); + let preserveDirectory = false; + let outputCreated = false; + let complete = false; + try { + await assertOutOfTree(work, 'Updater artifact verification'); + const copyRoot = join(work, 'app-copy'); + await mkdir(copyRoot, { mode: 0o700 }); + const checkedApp = join(copyRoot, `${PRODUCT_NAME}.app`); + await run('ditto', [app, checkedApp]); + await run('xattr', ['-w', 'com.apple.quarantine', '0081;00000000;GajaeLocalRelease;', checkedApp]); + compareAppInventories(sourceInventory, await inventoryApp(checkedApp)); + await verifyApp({ app: checkedApp, ...appIdentity }, { run }); + + const assets = join(work, 'assets'); + await mkdir(assets, { mode: 0o700 }); + const archive = join(assets, names.macos.archive); + await createUpdaterArchive({ appPath: app, archivePath: archive }); + compareAppInventories(sourceInventory, await inventoryApp(app)); + // The official signer obtains its key/password only through its supported + // environment. No private key or password is placed on argv or reported. + await run(process.execPath, [signer, 'signer', 'sign', archive]); + const signatureText = await readUpdaterSidecar(`${archive}.sig`, UPDATER_ASSET_LIMITS.maxSignatureBytes); + const signature = signatureText.trim(); + const archiveHash = await fileHash(archive); + const verified = await verifyUpdaterSignature({ archivePath: archive, signature, publicKey, + root: work, expectedSha256: archiveHash }, { run }); + const stagedDmg = join(assets, names.macos.dmg); + await copyFile(dmg, stagedDmg, constants.COPYFILE_EXCL); + demand((await stat(stagedDmg)).size === dmgStat.size, 'The finalized DMG changed while copying.'); + const dmgHash = await fileHash(stagedDmg); + await verifyMac({ dmg: stagedDmg, root: work, ...appIdentity, updaterArchivePath: verified.archivePath }, { run }); + const manifest = buildDesktopUpdateManifest({ productVersion, desktopVersion, commit, + minimumSystemVersion, notes, pubDate, signature }); + const expectedHashes = { [names.macos.archive]: archiveHash, [names.macos.dmg]: dmgHash, + [names.macos.archiveSignature]: createHash('sha256').update(signatureText).digest('hex') }; + const sidecars = { + [names.macos.manifest]: `${JSON.stringify(manifest, null, 2)}\n`, + [names.macos.archiveChecksum]: `${archiveHash} ${names.macos.archive}\n`, + [names.macos.dmgChecksum]: `${dmgHash} ${names.macos.dmg}\n`, + }; + for (const [name, text] of Object.entries(sidecars)) { + await writeFile(join(assets, name), text, { flag: 'wx', mode: 0o600 }); + expectedHashes[name] = createHash('sha256').update(text).digest('hex'); + } + // Recheck exact signed bytes after every verifier. Never recompress or + // modify the archive after signing, even to normalize its metadata. + demand(await fileHash(archive) === archiveHash, 'Signed archive changed during final verification.'); + demand(await fileHash(stagedDmg) === dmgHash, 'Verified DMG changed during final verification.'); + await mkdir(output, { mode: 0o700 }); + outputCreated = true; + for (const name of Object.values(names.macos)) { + await copyFile(join(assets, name), join(output, name), constants.COPYFILE_EXCL); + demand(await fileHash(join(output, name)) === expectedHashes[name], 'Final artifact copy differs from its verified bytes.'); + } + complete = true; + return { outputDirectory: output, assets: Object.values(names.macos), productVersion, desktopVersion, + commit, hashes: { [names.macos.archive]: archiveHash, [names.macos.dmg]: dmgHash } }; + } catch (error) { + preserveDirectory = error.preserveDirectory === true; + throw error; + } finally { + if (outputCreated && !complete) await rm(output, { recursive: true, force: true }); + if (!preserveDirectory) await rm(work, { recursive: true, force: true }); + } +} + +const usage = `Usage: node scripts/release/make-macos-updater.mjs + --app FINAL_APP --dmg FINAL_DMG --output NEW_DIRECTORY --commit FULL_SHA + --team-id TEAMID1234 --updater-public-key-file PUBLIC_KEY_FILE --notes-file NOTES_FILE + --pub-date ISO_UTC_TIMESTAMP + +Requires macOS arm64, official Minisign0.12, and the official Tauri signer's +TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH environment. +Encrypted keys also require TAURI_SIGNING_PRIVATE_KEY_PASSWORD. Never pass secrets +on argv. Product/desktop versions and OS floor come from the checked-out commit. +Does not sign/notarize the app, install it, or upload/publish anything. +`; + +async function main() { + let values; + try { + ({ values } = parseArgs({ options: { + ...Object.fromEntries(['app', 'dmg', 'output', 'commit', 'team-id', 'updater-public-key-file', 'notes-file', 'pub-date'] + .map(name => [name, { type: 'string' }])), help: { type: 'boolean' }, + } })); + if (values.help) { process.stdout.write(usage); return; } + demand(['app', 'dmg', 'output', 'commit', 'team-id', 'updater-public-key-file', 'notes-file', 'pub-date'] + .every(name => typeof values[name] === 'string' && values[name].length > 0), 'Missing arguments.'); + } catch { + process.stderr.write(usage); + process.exitCode = 2; + return; + } + try { + demand(process.platform === 'darwin' && process.arch === 'arm64', 'Updater artifact creation requires macOS arm64.'); + demand(process.env.TAURI_SIGNING_PRIVATE_KEY?.trim() || process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.trim(), + 'Configure the official updater signing key through its environment.'); + demand((await releaseCommand('minisign', ['-v'])).stdout.trim() === 'minisign 0.12', 'Official Minisign0.12 is required.'); + const head = (await releaseCommand('git', ['-C', repository, 'rev-parse', 'HEAD'])).stdout.trim(); + demand(head === values.commit, 'The checkout must match the exact supplied build commit.'); + const source = JSON.parse(await readFile(join(repository, 'package.json'), 'utf8')); + const config = JSON.parse(await readFile(join(repository, 'src-tauri/tauri.conf.json'), 'utf8')); + const result = await makeMacosUpdater({ + app: values.app, dmg: values.dmg, outputDirectory: values.output, commit: values.commit, + teamId: values['team-id'], publicKeyFile: values['updater-public-key-file'], + productVersion: source.version, desktopVersion: source.desktopVersion, + minimumSystemVersion: config.bundle?.macOS?.minimumSystemVersion, + notes: await readUpdaterSidecar(values['notes-file'], UPDATER_ASSET_LIMITS.maxManifestBytes), pubDate: values['pub-date'], + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + process.stderr.write(`${JSON.stringify({ status: 'blocked', error: error.message })}\n`); + process.exitCode = 1; + } +} + +function isDirectInvocation() { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} + +if (isDirectInvocation()) await main(); diff --git a/scripts/release/make-macos-updater.test.mjs b/scripts/release/make-macos-updater.test.mjs new file mode 100644 index 00000000..bcd0fd8a --- /dev/null +++ b/scripts/release/make-macos-updater.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { PRODUCT_NAME } from '../../shared/productIdentity.js'; + +import { makeMacosUpdater } from './make-macos-updater.mjs'; +import { inventoryApp, compareAppInventories } from './updater-archive.mjs'; +import { assetNames, validateDesktopUpdateManifest } from './updater-artifacts.mjs'; + +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const signature = Buffer.from('injected official-signer signature fixture').toString('base64'); +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'gajae-updater-builder-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const app = join(root, `${PRODUCT_NAME}.app`); + await mkdir(join(app, 'Contents'), { recursive: true }); + await writeFile(join(app, 'Contents', 'payload'), 'final app bytes'); + const dmg = join(root, 'final.dmg'); + await writeFile(dmg, 'final DMG fixture'); + const publicKeyFile = join(root, 'public.key'); + await writeFile(publicKeyFile, Buffer.from('injected public key fixture').toString('base64')); + const input = { app, dmg, publicKeyFile, outputDirectory: join(root, 'output'), + productVersion: '2.0.0-beta.10', desktopVersion: '0.2.4', commit: 'a'.repeat(40), + minimumSystemVersion: '11.0', teamId: 'AB12345678', notes: 'Reviewed fixture notes', pubDate: '2026-09-06T00:00:00Z' }; + const state = { input, events: [], original: await inventoryApp(app) }; + state.dependencies = { + run: async (program, args) => { + if (program === 'ditto') { + await cp(args[0], args[1], { recursive: true }); + state.events.push('copy'); + } else if (program === 'xattr') { + assert.equal(args[1], 'com.apple.quarantine'); + assert.notEqual(args.at(-1), app); + state.events.push('quarantine'); + } else if (program === process.execPath) { + assert.equal(state.events.at(-1), 'verify-app'); + assert.deepEqual(args.slice(1, 3), ['signer', 'sign']); + assert.equal(args.length, 4, 'No private key/password may appear on argv.'); + state.signedArchive = args[3]; + state.signedBytes = await readFile(state.signedArchive); + await writeFile(`${state.signedArchive}.sig`, `${signature}\n`, { flag: 'wx' }); + state.events.push('sign'); + } else if (program === 'minisign') { + assert.deepEqual(args.slice(0, 3), ['-V', '-H', '-m']); + assert.notEqual(args[3], state.signedArchive); + assert.deepEqual(await readFile(args[3]), state.signedBytes); + state.events.push('verify-signature'); + if (state.signatureFailure) throw new Error('cryptographic verifier rejected archive'); + } else assert.fail(`Unexpected command ${program}`); + return { stdout: '', stderr: '' }; + }, + verifyApp: async ({ app: copy }) => { + assert.deepEqual(state.events, ['copy', 'quarantine']); + compareAppInventories(state.original, await inventoryApp(copy)); + state.events.push('verify-app'); + }, + verifyMac: async ({ dmg: copiedDmg, updaterArchivePath }) => { + assert.equal(state.events.at(-1), 'verify-signature'); + assert.notEqual(updaterArchivePath, state.signedArchive); + assert.deepEqual(await readFile(updaterArchivePath), state.signedBytes); + assert.deepEqual(await readFile(copiedDmg), await readFile(dmg)); + state.events.push('verify-equivalence'); + if (state.mutateArchive) await writeFile(state.signedArchive, 'changed after signature'); + if (state.mutateDmg) await writeFile(copiedDmg, 'changed after assessment'); + }, + }; + return state; +} + +test('builder stages exactly six assets only after signed snapshot and full equivalence verification', async t => { + const state = await fixture(t); + const result = await makeMacosUpdater(state.input, state.dependencies); + const names = assetNames({ productVersion: state.input.productVersion }); + assert.deepEqual((await readdir(result.outputDirectory)).sort(), Object.values(names.macos).sort()); + assert.deepEqual(state.events, ['copy', 'quarantine', 'verify-app', 'sign', 'verify-signature', 'verify-equivalence']); + const archive = await readFile(join(result.outputDirectory, names.macos.archive)); + assert.deepEqual(archive, state.signedBytes); + assert.equal(result.hashes[names.macos.archive], hash(archive)); + assert.equal(await readFile(join(result.outputDirectory, names.macos.archiveChecksum), 'utf8'), `${hash(archive)} ${names.macos.archive}\n`); + validateDesktopUpdateManifest(JSON.parse(await readFile(join(result.outputDirectory, names.macos.manifest), 'utf8')), + { productVersion: state.input.productVersion, desktopVersion: state.input.desktopVersion, expectedSignature: signature }); + compareAppInventories(state.original, await inventoryApp(state.input.app)); +}); + +test('signature failure or post-verification payload mutation never exposes final assets', async t => { + for (const flag of ['signatureFailure', 'mutateArchive', 'mutateDmg']) { + const state = await fixture(t); + state[flag] = true; + await assert.rejects(makeMacosUpdater(state.input, state.dependencies), /rejected|changed/); + await assert.rejects(readdir(state.input.outputDirectory), { code: 'ENOENT' }); + compareAppInventories(state.original, await inventoryApp(state.input.app)); + } +}); + +test('existing output and source-app output paths are never overwritten', async t => { + const state = await fixture(t); + await mkdir(state.input.outputDirectory); + await writeFile(join(state.input.outputDirectory, 'user-file'), 'preserve'); + await assert.rejects(makeMacosUpdater(state.input, state.dependencies), { code: 'EEXIST' }); + assert.equal(await readFile(join(state.input.outputDirectory, 'user-file'), 'utf8'), 'preserve'); + await assert.rejects(makeMacosUpdater({ ...state.input, outputDirectory: join(state.input.app, 'output') }, state.dependencies), /source app/); + compareAppInventories(state.original, await inventoryApp(state.input.app)); +}); + +test('artifact builder CLI rejects incomplete or secret-bearing arguments without echoing them', () => { + const script = new URL('./make-macos-updater.mjs', import.meta.url); + for (const args of [[], ['--private-key', 'DO-NOT-PRINT']]) { + const result = spawnSync(process.execPath, [script.pathname, ...args], { encoding: 'utf8' }); + assert.equal(result.status, 2); + assert.ok(!`${result.stdout}${result.stderr}`.includes('DO-NOT-PRINT')); + } +}); diff --git a/scripts/release/updater-archive.mjs b/scripts/release/updater-archive.mjs new file mode 100644 index 00000000..f66afe9a --- /dev/null +++ b/scripts/release/updater-archive.mjs @@ -0,0 +1,882 @@ +import { createHash } from 'node:crypto'; +import { constants, createWriteStream } from 'node:fs'; +import { + chmod, + lchmod, + lstat, + mkdir, + mkdtemp, + open, + readdir, + readlink, + realpath, + rm, + symlink, +} from 'node:fs/promises'; +import { basename, dirname, posix, relative, resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { Transform } from 'node:stream'; +import { createGunzip } from 'node:zlib'; + +import { create as createTar, extract as extractTar, Parser } from 'tar'; + +import { PRODUCT_NAME } from '../../shared/productIdentity.js'; + +import { UPDATER_ASSET_LIMITS } from './updater-artifacts.mjs'; + +const APP_ROOT = `${PRODUCT_NAME}.app`; +const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; +const O_NONBLOCK = constants.O_NONBLOCK ?? 0; +const ARCHIVE_FILE_TYPE = 'file'; +const DIRECTORY_FILE_TYPE = 'directory'; +const SYMLINK_FILE_TYPE = 'symlink'; + +/** + * Limits specific to the single macOS updater archive contract. The two byte + * limits are inherited from the shared release contract; the structural limits + * prevent a small archive from creating an unreasonable metadata tree. + */ +export const UPDATER_ARCHIVE_LIMITS = Object.freeze({ + maxArchiveBytes: UPDATER_ASSET_LIMITS.maxArchiveBytes, + maxExpandedBytes: UPDATER_ASSET_LIMITS.maxExpandedBytes, + maxEntries: 100_000, + maxDepth: 128, + maxSymlinkDereferences: 64, + maxMetadataBytes: UPDATER_ASSET_LIMITS.maxManifestBytes, + maxPathBytes: 4096, + maxSymlinkTargetBytes: 4096, +}); + +function demand(condition, message) { + if (!condition) throw new Error(message); +} + +function asLimits(options = {}) { + const requested = options.limits ?? options; + const number = (name, fallback, maximum, { integer = true } = {}) => { + const value = requested[name] ?? fallback; + demand(typeof value === 'number' && Number.isFinite(value) && value > 0 + && (!integer || Number.isSafeInteger(value)) && value <= maximum, + `${name} must be a positive value no greater than the contract limit.`); + return value; + }; + return Object.freeze({ + maxArchiveBytes: number('maxArchiveBytes', UPDATER_ARCHIVE_LIMITS.maxArchiveBytes, UPDATER_ARCHIVE_LIMITS.maxArchiveBytes), + maxExpandedBytes: number('maxExpandedBytes', UPDATER_ARCHIVE_LIMITS.maxExpandedBytes, UPDATER_ARCHIVE_LIMITS.maxExpandedBytes), + maxEntries: number('maxEntries', UPDATER_ARCHIVE_LIMITS.maxEntries, UPDATER_ARCHIVE_LIMITS.maxEntries), + maxDepth: number('maxDepth', UPDATER_ARCHIVE_LIMITS.maxDepth, UPDATER_ARCHIVE_LIMITS.maxDepth), + maxSymlinkDereferences: number('maxSymlinkDereferences', UPDATER_ARCHIVE_LIMITS.maxSymlinkDereferences, UPDATER_ARCHIVE_LIMITS.maxSymlinkDereferences), + maxMetadataBytes: number('maxMetadataBytes', UPDATER_ARCHIVE_LIMITS.maxMetadataBytes, UPDATER_ARCHIVE_LIMITS.maxMetadataBytes), + maxPathBytes: number('maxPathBytes', UPDATER_ARCHIVE_LIMITS.maxPathBytes, UPDATER_ARCHIVE_LIMITS.maxPathBytes), + maxSymlinkTargetBytes: number('maxSymlinkTargetBytes', UPDATER_ARCHIVE_LIMITS.maxSymlinkTargetBytes, UPDATER_ARCHIVE_LIMITS.maxSymlinkTargetBytes), + }); +} + +function canonicalAlias(value) { + return value.normalize('NFC').toLowerCase(); +} + +function safePathText(value, label, maxBytes) { + demand(typeof value === 'string' && value.length > 0, `${label} must be a nonempty string.`); + demand(!value.includes('\u0000'), `${label} contains a NUL byte.`); + demand(Buffer.byteLength(value, 'utf8') <= maxBytes, `${label} exceeds its path length limit.`); + // The archive is a macOS/POSIX contract. Backslashes are rejected rather + // than becoming platform-dependent separators when a fixture is inspected + // on another host. + demand(!value.includes('\\'), `${label} contains a non-canonical backslash.`); + return value; +} + +function canonicalMemberPath(value, { directory = false, limits = UPDATER_ARCHIVE_LIMITS } = {}) { + safePathText(value, 'Archive member path', limits.maxPathBytes); + const hasTrailingSlash = value.endsWith('/'); + if (directory) { + demand(!value.endsWith('//'), 'Directory member path has repeated trailing separators.'); + } else { + demand(!hasTrailingSlash, 'Non-directory member path must not end with a separator.'); + } + const stripped = hasTrailingSlash ? value.slice(0, -1) : value; + demand(stripped.length > 0 && !stripped.startsWith('/') && !stripped.includes('//'), + 'Archive member path must be relative and canonical.'); + const parts = stripped.split('/'); + demand(parts.every(part => part.length > 0 && part !== '.' && part !== '..'), + 'Archive member path contains an empty, dot or dot-dot component.'); + demand(parts[0] === APP_ROOT, `Archive member must be rooted at ${APP_ROOT}.`); + demand(parts.length <= limits.maxDepth, 'Archive member path is too deep.'); + for (const part of parts) { + demand(!part.startsWith('._'), 'AppleDouble metadata is not part of the updater archive.'); + } + return stripped; +} + +function canonicalLinkTarget(value, limits) { + safePathText(value, 'Symbolic-link target', limits.maxSymlinkTargetBytes); + demand(!value.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(value), + 'Symbolic-link target must be relative to the app.'); + demand(!value.includes('//'), 'Symbolic-link target has repeated separators.'); + return value; +} + +function resolveLinkTarget(memberPath, target) { + const resolved = posix.normalize(posix.join(posix.dirname(memberPath), target)); + demand(resolved === APP_ROOT || resolved.startsWith(`${APP_ROOT}/`), + `Symbolic link ${memberPath} escapes the app root.`); + return resolved; +} + +/** + * Resolve a link target against the complete member map. Unlike a lexical + * `join()/normalize()` check, this walks each path component and expands + * internal symlinks before consuming the following component. That permits + * framework layouts such as `Versions/Current/Foo` where `Current -> A` and + * only `Versions/A/Foo` is an archive member. + */ +function resolveMappedLinkTarget(memberPath, target, byPath, limits, label) { + const components = String(target).split('/'); + const stack = posix.dirname(memberPath).split('/'); + demand(stack[0] === APP_ROOT, `${label} symbolic link ${memberPath} has no canonical app parent.`); + const seenLinks = new Set(); + let dereferences = 0; + while (components.length > 0) { + const component = components.shift(); + if (component === '' || component === '.') continue; + if (component === '..') { + demand(stack.length > 1, `${label} symbolic link ${memberPath} escapes the app root.`); + stack.pop(); + continue; + } + demand(component !== '/', !component.includes('/'), `${label} symbolic link ${memberPath} has a non-canonical component.`); + stack.push(component); + demand(stack.length <= limits.maxDepth, `${label} symbolic link ${memberPath} targets an excessively deep path.`); + const currentPath = stack.join('/'); + const current = byPath.get(currentPath); + demand(current, `${label} symbolic link ${memberPath} targets a missing member: ${currentPath}.`); + if (current.type !== SYMLINK_FILE_TYPE) continue; + demand(++dereferences <= limits.maxSymlinkDereferences, + `${label} symbolic link ${memberPath} exceeds the symlink dereference limit.`); + demand(!seenLinks.has(currentPath), `${label} symbolic links contain a cycle at ${currentPath}.`); + seenLinks.add(currentPath); + stack.pop(); + components.unshift(...current.target.split('/')); + } + const resolved = stack.join('/'); + const final = byPath.get(resolved); + demand(final, `${label} symbolic link ${memberPath} targets a missing member: ${resolved}.`); + return resolved; +} + +function relativeDepth(memberPath) { + return memberPath.split('/').length; +} + +function fileMode(stat) { + return stat.mode & 0o7777; +} + +function entryMode(entry, label) { + demand(Number.isSafeInteger(entry.mode) && entry.mode >= 0 && entry.mode <= 0o7777, + `${label} has an invalid mode.`); + return entry.mode; +} + +function freezeRecord(record) { + return Object.freeze(record); +} + +function sortRecords(entries) { + return [...entries].sort((left, right) => ( + left.path < right.path ? -1 : left.path > right.path ? 1 : 0 + )); +} + +function recordsFrom(value) { + if (Array.isArray(value)) return value; + if (value && Array.isArray(value.entries)) return value.entries; + if (value && value.inventory && Array.isArray(value.inventory.entries)) return value.inventory.entries; + throw new Error('An app inventory entries array is required.'); +} + +function inventoryRoot(value) { + if (Array.isArray(value)) return APP_ROOT; + if (value?.inventory && !Array.isArray(value.entries)) return value.inventory.root; + return value?.root; +} + +/** + * Validate the complete member map. This is intentionally run only after all + * entries have been read, so a symlink that appears before a child entry still + * blocks that child (and a link appearing later blocks earlier-looking paths). + */ +function validateMemberMap(entries, limits, label = 'Archive') { + demand(Array.isArray(entries) && entries.length > 0, `${label} must contain at least one member.`); + demand(entries.length <= limits.maxEntries, `${label} contains too many entries.`); + const byPath = new Map(); + const byAlias = new Map(); + let fileBytes = 0; + for (const entry of entries) { + demand(entry && typeof entry === 'object', `${label} entries must be objects.`); + const path = canonicalMemberPath(entry.path, { + directory: entry.type === DIRECTORY_FILE_TYPE, + limits, + }); + demand(relativeDepth(path) <= limits.maxDepth, `${label} member path is too deep.`); + demand(!byPath.has(path), `${label} contains a duplicate member: ${path}`); + const alias = canonicalAlias(path); + demand(!byAlias.has(alias), `${label} contains a case or Unicode alias: ${path}`); + byPath.set(path, entry); + byAlias.set(alias, path); + if (entry.type === ARCHIVE_FILE_TYPE) { + demand(Number.isSafeInteger(entry.size) && entry.size >= 0, `${label} file ${path} has an invalid size.`); + demand(entry.size <= limits.maxExpandedBytes, `${label} file ${path} exceeds the expanded byte limit.`); + demand(typeof entry.sha256 === 'string' && /^[a-f0-9]{64}$/.test(entry.sha256), + `${label} file ${path} has no canonical SHA-256.`); + fileBytes += entry.size; + demand(fileBytes <= limits.maxExpandedBytes, `${label} files exceed the expanded byte limit.`); + } else if (entry.type === DIRECTORY_FILE_TYPE) { + demand(entry.size === undefined || entry.size === 0, `${label} directory ${path} has file data.`); + } else if (entry.type === SYMLINK_FILE_TYPE) { + demand(entry.size === undefined || entry.size === 0, `${label} symbolic link ${path} has file data.`); + canonicalLinkTarget(entry.target, limits); + } else { + throw new Error(`${label} contains an unsupported member type at ${path}.`); + } + entryMode(entry, `${label} member ${path}`); + } + + const root = byPath.get(APP_ROOT); + demand(root?.type === DIRECTORY_FILE_TYPE, `${label} must contain exactly one ${APP_ROOT} directory root.`); + + for (const entry of entries) { + if (entry.path === APP_ROOT) continue; + let parent = posix.dirname(entry.path); + while (parent && parent !== '.') { + const parentEntry = byPath.get(parent); + demand(parentEntry, `${label} member ${entry.path} has no explicit parent directory: ${parent}.`); + demand(parentEntry.type === DIRECTORY_FILE_TYPE, + `${label} member ${entry.path} is beneath a non-directory ancestor ${parent}.`); + parent = parent === APP_ROOT ? '' : posix.dirname(parent); + } + } + + for (const entry of entries) { + if (entry.type !== SYMLINK_FILE_TYPE) continue; + // Validate the target lexically first (absolute/dot-dot escape checks), + // then resolve it through every map symlink component. Only member + // *targets* may traverse a link; the archive's own member-parent check + // above still forbids writing beneath any symlink ancestor. + resolveLinkTarget(entry.path, entry.target); + resolveMappedLinkTarget(entry.path, entry.target, byPath, limits, label); + } + + return Object.freeze({ + entries: Object.freeze(sortRecords(entries).map(entry => freezeRecord({ ...entry }))), + totalFileBytes: fileBytes, + }); +} + +async function assertRegularArchive(archivePath, maxBytes) { + demand(typeof archivePath === 'string' && archivePath.length > 0, 'Updater archive path is required.'); + // O_NONBLOCK is ignored for regular files but prevents a path swapped to a + // FIFO from blocking before fstat can reject it. + const fd = await open(resolve(archivePath), constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + try { + const stat = await fd.stat(); + demand(stat.isFile() && stat.size > 0, 'Updater archive must be a nonempty regular file.'); + demand(stat.size <= maxBytes, 'Updater archive exceeds the compressed byte limit.'); + return { fd, size: stat.size }; + } catch (error) { + await fd.close().catch(() => {}); + throw error; + } +} + +class ByteLimitTransform extends Transform { + #limit; + #label; + bytes = 0; + + constructor(limit, label, hash = false) { + super(); + this.#limit = limit; + this.#label = label; + this.hash = hash ? createHash('sha256') : null; + } + + _transform(chunk, encoding, callback) { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); + this.bytes += data.length; + if (this.bytes > this.#limit) { + callback(new Error(`${this.#label} exceeded ${this.#limit} bytes.`)); + return; + } + this.hash?.update(data); + callback(null, data); + } + + digest() { + return this.hash?.digest('hex'); + } +} + +function parserRecord(entry, limits) { + const type = entry.type === 'File' ? ARCHIVE_FILE_TYPE + : entry.type === 'Directory' ? DIRECTORY_FILE_TYPE + : entry.type === 'SymbolicLink' ? SYMLINK_FILE_TYPE + : undefined; + demand(type, `Archive contains unsupported entry type ${entry.type}.`); + const path = canonicalMemberPath(entry.path, { directory: type === DIRECTORY_FILE_TYPE, limits }); + const mode = entryMode(entry, `Archive member ${path}`); + if (type === ARCHIVE_FILE_TYPE) { + demand(Number.isSafeInteger(entry.size) && entry.size >= 0, `Archive file ${path} has an invalid size.`); + return { path, type, mode, size: entry.size }; + } + if (type === SYMLINK_FILE_TYPE) { + demand(entry.size === 0, `Archive symbolic link ${path} has file data.`); + return { path, type, mode, target: canonicalLinkTarget(entry.linkpath, limits) }; + } + demand(entry.size === 0, `Archive directory ${path} has file data.`); + return { path, type, mode }; +} + +async function parseArchive(archivePath, limits) { + const archive = await assertRegularArchive(archivePath, limits.maxArchiveBytes); + const records = []; + let failure; + let stopped = false; + let count = 0; + let declaredFileBytes = 0; + const stop = error => { + if (stopped) return; + stopped = true; + failure = error instanceof Error ? error : new Error(String(error)); + parser.abort(failure); + }; + const parser = new Parser({ + strict: true, + preservePaths: true, + maxMetaEntrySize: limits.maxMetadataBytes, + onReadEntry(entry) { + if (stopped) return; + count += 1; + if (count > limits.maxEntries) { + stop(new Error('Archive contains too many entries.')); + return; + } + let record; + try { + record = parserRecord(entry, limits); + if (record.type === ARCHIVE_FILE_TYPE) { + declaredFileBytes += record.size; + if (declaredFileBytes > limits.maxExpandedBytes) { + stop(new Error('Archive files exceed the expanded byte limit.')); + return; + } + const hash = createHash('sha256'); + let bytes = 0; + entry.on('data', chunk => { + if (stopped) return; + bytes += chunk.length; + hash.update(chunk); + if (bytes > limits.maxExpandedBytes) { + stop(new Error('Archive file exceeded the expanded byte limit.')); + } + }); + entry.on('end', () => { + if (stopped) return; + if (bytes !== record.size) { + stop(new Error(`Archive file ${record.path} ended at ${bytes} bytes; expected ${record.size}.`)); + } else if (!record.sha256) { + record.sha256 = hash.digest('hex'); + } + }); + + } + } catch (error) { + stop(error); + return; + } + if (stopped) return; + if (record) records.push(record); + entry.on('error', error => stop(error)); + // Parser entries start paused. Resuming here is required for the parser + // to reach the next header and for file bytes to be hashed. + entry.resume(); + }, + }); + parser.on('meta', value => { + if (stopped) return; + count += 1; + if (count > limits.maxEntries) { + stop(new Error('Archive contains too many entries.')); + return; + } + const size = Buffer.byteLength(String(value ?? ''), 'utf8'); + if (size > limits.maxMetadataBytes) { + stop(new Error('Archive metadata exceeds its size limit.')); + } + // PAX/GNU metadata is bounded here and its effective path, mode, size and + // link target are validated on the following ReadEntry. The selected + // updater accepts maintained tar PAX path metadata (including paths over + // the classic ustar 255-byte limit), so metadata itself is not rejected. + }); + parser.on('ignoredEntry', entry => { + if (stopped) return; + count += 1; + if (count > limits.maxEntries) { + stop(new Error('Archive contains too many entries.')); + } else if (entry?.meta && entry.size > limits.maxMetadataBytes) { + stop(new Error('Archive metadata exceeds its size limit.')); + } else { + stop(new Error(`Archive contains an unsupported or ignored entry: ${entry?.path ?? ''}.`)); + } + }); + + const compressed = new ByteLimitTransform(limits.maxArchiveBytes, 'Compressed archive'); + const decompressed = new ByteLimitTransform(limits.maxExpandedBytes, 'Decompressed archive'); + const gunzip = createGunzip(); + try { + await pipeline(archive.fd.createReadStream({ autoClose: false }), compressed, gunzip, decompressed, parser); + const finalStat = await archive.fd.stat(); + demand(finalStat.size === archive.size, 'Updater archive changed while it was being inspected.'); + } catch (error) { + failure ??= error; + } finally { + await archive.fd.close().catch(() => {}); + } + if (failure) throw failure; + const normalized = validateMemberMap(records, limits, 'Updater archive'); + return Object.freeze({ + archivePath: resolve(archivePath), + compressedBytes: compressed.bytes, + expandedBytes: decompressed.bytes, + inventory: Object.freeze({ + root: APP_ROOT, + entries: normalized.entries, + totalFileBytes: normalized.totalFileBytes, + }), + }); +} + +async function assertSafeDirectoryPath(directory, label) { + const absolute = resolve(directory); + const stat = await lstat(absolute); + demand(stat.isDirectory() && !stat.isSymbolicLink(), `${label} must be a directory, not a symlink.`); + const actual = await realpath(absolute); + demand(actual === absolute, `${label} must not traverse a symbolic-link directory.`); + if (typeof process.getuid === 'function') demand(stat.uid === process.getuid(), `${label} must be owned by the current user.`); + demand((stat.mode & 0o077) === 0, `${label} must be owner-only private.`); + return absolute; +} + +async function createOutput(archivePath) { + const absolute = resolve(archivePath); + const parent = dirname(absolute); + const parentStat = await lstat(parent); + demand(parentStat.isDirectory() && !parentStat.isSymbolicLink(), 'Updater archive destination parent must be a real directory.'); + demand(await realpath(parent) === parent, 'Updater archive destination parent must not traverse a symlink.'); + const fd = await open(absolute, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, 0o600); + return { fd, archivePath: absolute }; +} + +async function hashFile(filePath, expected, limits, total) { + // Keep the bounded-read operation nonblocking even if an app member is + // replaced with a FIFO between lstat and open. + const fd = await open(filePath, constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + try { + const start = await fd.stat(); + demand(start.isFile() && start.size === expected.size, `App file ${expected.path} changed while it was being read.`); + let bytes = 0; + const hash = createHash('sha256'); + for await (const chunk of fd.createReadStream({ autoClose: false })) { + bytes += chunk.length; + total.value += chunk.length; + demand(bytes <= limits.maxExpandedBytes && total.value <= limits.maxExpandedBytes, + 'App files exceed the expanded byte limit.'); + hash.update(chunk); + } + demand(bytes === start.size, `App file ${expected.path} ended at an unexpected size.`); + const end = await fd.stat(); + demand(end.isFile() && end.size === start.size, `App file ${expected.path} changed while it was being read.`); + return { size: bytes, sha256: hash.digest('hex') }; + } finally { + await fd.close().catch(() => {}); + } +} + +/** + * Build a deterministic inventory of a final `.app` directory. File hashes and + * byte lengths stand in for file contents; timestamps, owners and inode data + * are deliberately absent. Symlinks are read without following them. + */ +export async function inventoryApp(appPath, options = {}) { + const limits = asLimits(options); + demand(typeof appPath === 'string' && appPath.length > 0, 'App path is required.'); + const absoluteRoot = resolve(appPath); + demand(basename(absoluteRoot) === APP_ROOT, `App path must end in ${APP_ROOT}.`); + const rootStat = await lstat(absoluteRoot); + demand(rootStat.isDirectory() && !rootStat.isSymbolicLink(), 'App root must be a real directory.'); + const entries = []; + const total = { value: 0 }; + + async function visit(absolute, memberPath) { + demand(entries.length < limits.maxEntries, 'App contains too many entries.'); + const stat = await lstat(absolute); + if (stat.isDirectory()) { + entries.push({ path: memberPath, type: DIRECTORY_FILE_TYPE, mode: fileMode(stat) }); + const names = await readdir(absolute); + names.sort(); + for (const name of names) { + demand(name !== '' && name !== '.' && name !== '..' && !name.includes('\u0000'), + 'App contains an invalid member name.'); + const childPath = `${memberPath}/${name}`; + canonicalMemberPath(childPath, { directory: false, limits }); + await visit(resolve(absolute, name), childPath); + } + return; + } + if (stat.isSymbolicLink()) { + const target = canonicalLinkTarget(await readlink(absolute), limits); + entries.push({ path: memberPath, type: SYMLINK_FILE_TYPE, mode: fileMode(stat), target }); + return; + } + demand(stat.isFile(), `App contains a special file at ${memberPath}.`); + demand(stat.nlink === undefined || stat.nlink <= 1, `App contains a hard-linked file at ${memberPath}.`); + demand(stat.size <= limits.maxExpandedBytes, `App file ${memberPath} exceeds the expanded byte limit.`); + const expected = { path: memberPath, size: stat.size }; + const content = await hashFile(absolute, expected, limits, total); + entries.push({ path: memberPath, type: ARCHIVE_FILE_TYPE, mode: fileMode(stat), ...content }); + } + + await visit(absoluteRoot, APP_ROOT); + const normalized = validateMemberMap(entries, limits, 'App inventory'); + return Object.freeze({ + root: APP_ROOT, + entries: normalized.entries, + totalFileBytes: normalized.totalFileBytes, + }); +} + +/** + * Compare two complete inventories. A successful comparison returns `true`; a + * mismatch identifies the first path/field and never falls back to comparing + * only a checksum or the runtime manifest. + */ +export function compareAppInventories(expected, actual) { + demand(inventoryRoot(expected) === APP_ROOT && inventoryRoot(actual) === APP_ROOT, + `App inventories must use the canonical ${APP_ROOT} root.`); + const left = sortRecords(recordsFrom(expected)); + const right = sortRecords(recordsFrom(actual)); + demand(left.length === right.length, `App inventories differ in entry count (${left.length} !== ${right.length}).`); + for (let index = 0; index < left.length; index += 1) { + const a = left[index]; + const b = right[index]; + demand(a.path === b.path, `App inventories differ at member ${a.path ?? b.path}.`); + demand(a.type === b.type, `App member ${a.path} type differs.`); + demand(a.mode === b.mode, `App member ${a.path} mode differs.`); + if (a.type === ARCHIVE_FILE_TYPE) { + demand(a.size === b.size && a.sha256 === b.sha256, `App member ${a.path} bytes differ.`); + } else if (a.type === SYMLINK_FILE_TYPE) { + demand(a.target === b.target, `App member ${a.path} symbolic-link target differs.`); + } + } + return true; +} + +/** + * Safely inspect a `.app.tar.gz` using tar's maintained parser and an explicit + * gzip stream. `expandedBytes` counts the entire decompressed tar stream, not + * merely the sum of declared file sizes, so headers/padding cannot bypass the + * absolute one-gigabyte cap. + */ +export async function inspectUpdaterArchive({ archivePath, ...options } = {}) { + const limits = asLimits(options); + return parseArchive(archivePath, limits); +} + +/** + * Create a deterministic single-root archive from an already-final app. The + * source is never signed, stapled, or otherwise mutated. Existing output is + * refused, and the generated bytes are inspected again before being returned. + */ +export async function createUpdaterArchive({ appPath, archivePath, ...options } = {}) { + const limits = asLimits(options); + demand(typeof appPath === 'string' && typeof archivePath === 'string', 'App and archive paths are required.'); + const sourceInventory = await inventoryApp(appPath, limits); + const sourceRoot = resolve(appPath); + const destinationPath = resolve(archivePath); + demand(destinationPath !== sourceRoot && !destinationPath.startsWith(`${sourceRoot}/`), + 'Updater archive output must not be inside the source app.'); + const output = await createOutput(archivePath); + let outputClosed = false; + try { + const sourceByPath = new Map(sourceInventory.entries.map(entry => [entry.path, entry])); + const paths = sourceInventory.entries.map(entry => entry.path); + const pack = createTar({ + cwd: dirname(sourceRoot), + gzip: { portable: true, level: 6 }, + noMtime: true, + // `portable:true` intentionally changes modes to a 0644/0755-style + // default. The archive contract preserves the final app's modes, so + // metadata is removed in the callback below without changing the mode + // field. Maintained tar PAX path metadata remains enabled for long + // bundled paths. + portable: false, + follow: false, + noDirRecurse: true, + jobs: 1, + strict: true, + preservePaths: false, + filter: (path) => !basename(path).startsWith('._'), + onWriteEntry: entry => { + const stat = Object.assign(Object.create(Object.getPrototypeOf(entry.stat)), entry.stat, { + mode: entry.stat.mode, + size: entry.stat.size, + uid: undefined, + gid: undefined, + uname: undefined, + gname: undefined, + atime: undefined, + ctime: undefined, + dev: undefined, + ino: undefined, + nlink: undefined, + }); + entry.stat = stat; + entry.noMtime = true; + const expected = sourceByPath.get(entry.path.replace(/\/$/, '')); + demand(expected, `Unexpected source member while packing: ${entry.path}.`); + demand(entry.type === (expected.type === ARCHIVE_FILE_TYPE ? 'File' + : expected.type === DIRECTORY_FILE_TYPE ? 'Directory' : 'SymbolicLink'), + `Source member ${entry.path} changed type while packing.`); + }, + }, paths); + const limited = new ByteLimitTransform(limits.maxArchiveBytes, 'Compressed archive', true); + // Keep descriptor ownership here. A FileHandle-owned stream retains a + // reference after finish when autoClose:false, deadlocking handle.close(). + const stream = createWriteStream(output.archivePath, { fd: output.fd.fd, autoClose: false }); + try { + await pipeline(pack, limited, stream); + await output.fd.sync(); + } finally { + await output.fd.close().catch(() => {}); + outputClosed = true; + } + const inspected = await inspectUpdaterArchive({ archivePath: output.archivePath, ...limits }); + const finalSourceInventory = await inventoryApp(appPath, limits); + compareAppInventories(sourceInventory, finalSourceInventory); + compareAppInventories(finalSourceInventory, inspected.inventory); + return Object.freeze({ + archivePath: output.archivePath, + sha256: limited.digest(), + size: limited.bytes, + compressedBytes: inspected.compressedBytes, + expandedBytes: inspected.expandedBytes, + inventory: finalSourceInventory, + }); + } catch (error) { + if (!outputClosed) await output.fd.close().catch(() => {}); + await rm(output.archivePath, { force: true }).catch(() => {}); + throw error; + } +} + +async function applyAndVerifyModes(appPath, expectedEntries, actualEntries) { + const expectedByPath = new Map(expectedEntries.map(entry => [entry.path, entry])); + // Verify and set children before parents, so even an archive containing a + // non-searchable directory can be checked while its parent remains usable. + const paths = [...actualEntries].sort((left, right) => right.path.split('/').length - left.path.split('/').length); + for (const actual of paths) { + const expected = expectedByPath.get(actual.path); + demand(expected, `Extracted app contains an unexpected member ${actual.path}.`); + const fullPath = resolve(appPath, relative(APP_ROOT, actual.path)); + if (actual.type === SYMLINK_FILE_TYPE) { + const stat = await lstat(fullPath); + demand(stat.isSymbolicLink() && fileMode(stat) === expected.mode, + `Extracted symbolic link ${actual.path} mode differs.`); + continue; + } + await chmod(fullPath, expected.mode); + const stat = await lstat(fullPath); + demand((stat.isDirectory() ? DIRECTORY_FILE_TYPE : stat.isFile() ? ARCHIVE_FILE_TYPE : undefined) === expected.type + && fileMode(stat) === expected.mode, + `Extracted member ${actual.path} mode or type differs.`); + } +} + +async function assertRealLinkParent(appRoot, linkPath) { + const root = await realpath(appRoot); + const parent = dirname(linkPath); + const parentRelative = relative(root, parent); + demand(parentRelative === '' || (!parentRelative.startsWith('..') && !parentRelative.startsWith('/')), + 'Symbolic-link parent escaped the extracted app root.'); + let current = root; + for (const component of parentRelative.split(/[\\/]/u).filter(Boolean)) { + current = resolve(current, component); + const stat = await lstat(current); + demand(stat.isDirectory() && !stat.isSymbolicLink(), + `Symbolic-link parent contains a non-directory or symbolic link: ${current}.`); + demand(await realpath(current) === current, + `Symbolic-link parent traverses a symbolic-link directory: ${current}.`); + } +} + +async function createValidatedSymlinks(appRoot, entries, limits) { + for (const entry of entries.filter(item => item.type === SYMLINK_FILE_TYPE).sort((a, b) => ( + a.path < b.path ? -1 : a.path > b.path ? 1 : 0 + ))) { + const linkPath = resolve(appRoot, relative(APP_ROOT, entry.path)); + await assertRealLinkParent(appRoot, linkPath); + try { + await lstat(linkPath); + throw new Error(`Extraction would overwrite an existing member at ${entry.path}.`); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + await symlink(canonicalLinkTarget(entry.target, limits), linkPath); + const created = await lstat(linkPath); + demand(created.isSymbolicLink(), `Extracted symbolic link ${entry.path} was not created as a link.`); + const createdMode = fileMode(created); + if (createdMode !== entry.mode) { + demand(typeof lchmod === 'function', + `Cannot restore symbolic-link mode for ${entry.path}: lchmod is unavailable.`); + try { + await lchmod(linkPath, entry.mode); + } catch (error) { + throw new Error(`Cannot restore symbolic-link mode for ${entry.path}.`, { cause: error }); + } + const restored = await lstat(linkPath); + demand(restored.isSymbolicLink() && fileMode(restored) === entry.mode, + `Symbolic-link mode restoration did not settle for ${entry.path}.`); + } + } +} + +/** + * Extract a previously inspected archive into a fresh owner-only directory. + * The caller owns the returned directory and should remove it after release + * verification. On any failure the generated directory is removed before the + * error is rethrown. + */ +export async function extractUpdaterArchive({ archivePath, root, destination, ...options } = {}) { + const limits = asLimits(options); + demand(typeof root === 'string' && root.length > 0, 'A private extraction parent is required.'); + const parent = await assertSafeDirectoryPath(root, 'Extraction parent'); + const inspected = await parseArchive(archivePath, limits); + const extractionRoot = destination === undefined + ? await mkdtemp(`${parent}/.updater-extract-`) + : resolve(destination); + let created = false; + try { + if (destination !== undefined) { + demand(dirname(extractionRoot) === parent, + 'A requested extraction directory must be a fresh child of the private extraction parent.'); + await mkdir(extractionRoot, { mode: 0o700 }); + } + created = true; + await chmod(extractionRoot, 0o700); + await assertSafeDirectoryPath(extractionRoot, 'Extraction directory'); + const expectedByPath = new Map(inspected.inventory.entries.map(entry => [entry.path, entry])); + const unpack = extractTar({ + // tar's synchronous unpacker completes filesystem writes before each + // parser write returns. This prevents an extraction error from racing + // cleanup of the private directory. + sync: true, + cwd: extractionRoot, + strict: true, + preservePaths: false, + noMtime: true, + preserveOwner: false, + chmod: true, + processUmask: 0, + keep: true, + unlink: false, + maxDepth: limits.maxDepth, + maxMetaEntrySize: limits.maxMetadataBytes, + filter: (path, entry) => { + const canonical = canonicalMemberPath(path, { directory: entry.type === 'Directory', limits }); + const expected = expectedByPath.get(canonical); + demand(expected, `Archive changed while extracting at ${canonical}.`); + demand((entry.type === 'File' ? ARCHIVE_FILE_TYPE : entry.type === 'Directory' ? DIRECTORY_FILE_TYPE + : entry.type === 'SymbolicLink' ? SYMLINK_FILE_TYPE : undefined) === expected.type, + `Archive member ${canonical} type changed while extracting.`); + demand(entry.mode === expected.mode, `Archive member ${canonical} mode changed while extracting.`); + if (expected.type === SYMLINK_FILE_TYPE) { + demand(canonicalLinkTarget(entry.linkpath, limits) === expected.target, + `Archive symbolic-link target changed while extracting at ${canonical}.`); + } + if (expected.type === ARCHIVE_FILE_TYPE) demand(entry.size === expected.size, + `Archive member ${canonical} size changed while extracting.`); + // tar's async symlink path check cannot understand valid links through + // another internal link (eg Framework/Foo -> Versions/Current/Foo). + // All links were validated from the complete map above; create them + // only after tar has synchronously extracted regular entries. + return expected.type !== SYMLINK_FILE_TYPE; + }, + }); + const archive = await assertRegularArchive(archivePath, limits.maxArchiveBytes); + try { + await pipeline( + archive.fd.createReadStream({ autoClose: false }), + new ByteLimitTransform(limits.maxArchiveBytes, 'Compressed archive'), + createGunzip(), + new ByteLimitTransform(limits.maxExpandedBytes, 'Decompressed archive'), + unpack, + ); + const finalStat = await archive.fd.stat(); + demand(finalStat.size === archive.size, 'Updater archive changed while it was being extracted.'); + } finally { + await archive.fd.close().catch(() => {}); + } + const extractedApp = resolve(extractionRoot, APP_ROOT); + await createValidatedSymlinks(extractedApp, inspected.inventory.entries, limits); + const extractedInventory = await inventoryApp(extractedApp, limits); + const expectedShape = inspected.inventory.entries.map(entry => ({ ...entry })); + const actualShape = extractedInventory.entries.map(entry => ({ ...entry })); + for (const entry of actualShape) delete entry.mode; + for (const entry of expectedShape) delete entry.mode; + compareAppInventories(expectedShape, actualShape); + for (const entry of inspected.inventory.entries) { + if (entry.type !== SYMLINK_FILE_TYPE) continue; + const full = resolve(extractedApp, relative(APP_ROOT, entry.path)); + const resolvedTarget = await realpath(full); + const escaped = relative(extractedApp, resolvedTarget); + demand(escaped === '' || (!escaped.startsWith('..') && !escaped.startsWith('/')), + `Extracted symbolic link ${entry.path} resolves outside the app root.`); + } + await applyAndVerifyModes(extractedApp, inspected.inventory.entries, extractedInventory.entries); + const finalEntries = extractedInventory.entries.map(entry => ({ + ...entry, + mode: expectedByPath.get(entry.path).mode, + })); + compareAppInventories(inspected.inventory.entries, finalEntries); + return Object.freeze({ + directory: extractionRoot, + extractionRoot, + appPath: extractedApp, + inventory: Object.freeze({ root: APP_ROOT, entries: Object.freeze(finalEntries), totalFileBytes: inspected.inventory.totalFileBytes }), + archive: inspected, + }); + } catch (error) { + if (created) { + try { + await rm(extractionRoot, { recursive: true, force: true }); + } catch (cleanupError) { + throw Object.assign(new Error(`Extraction failed and its temporary directory could not be removed: ${extractionRoot}`, { cause: error }), { + preserveDirectory: true, + cleanupError, + }); + } + } + throw error; + } +} + +/** Remove a directory returned by extractUpdaterArchive. */ +export async function cleanupUpdaterExtraction(extraction) { + const directory = extraction?.directory ?? extraction?.extractionRoot; + demand(typeof directory === 'string' && basename(directory).startsWith('.updater-extract-'), + 'A directory returned by extractUpdaterArchive is required.'); + await rm(directory, { recursive: true, force: true }); +} + +export { APP_ROOT as UPDATER_APP_ROOT }; diff --git a/scripts/release/updater-archive.test.mjs b/scripts/release/updater-archive.test.mjs new file mode 100644 index 00000000..54a473bd --- /dev/null +++ b/scripts/release/updater-archive.test.mjs @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict'; +import { execFile as execFileCallback } from 'node:child_process'; +import { createWriteStream } from 'node:fs'; +import { chmod, link, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { finished } from 'node:stream/promises'; +import { createGzip } from 'node:zlib'; +import { test } from 'node:test'; +import { promisify } from 'node:util'; + +import { Header } from 'tar'; + +import { PRODUCT_NAME } from '../../shared/productIdentity.js'; + +import { + cleanupUpdaterExtraction, + compareAppInventories, + createUpdaterArchive, + extractUpdaterArchive, + inspectUpdaterArchive, + inventoryApp, +} from './updater-archive.mjs'; + +const appRoot = `${PRODUCT_NAME}.app`; +const execFile = promisify(execFileCallback); +let rawArchiveId = 0; + +async function fixture(t) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-updater-archive-test-'))); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +async function makeRawArchive(root, entries, name) { + const archiveName = name ?? `fixture-${++rawArchiveId}.app.tar.gz`; + const archivePath = join(root, archiveName); + const output = createWriteStream(archivePath, { flags: 'wx', mode: 0o600 }); + const gzip = createGzip({ level: 9 }); + gzip.pipe(output); + for (const item of entries) { + const body = Buffer.isBuffer(item.body) ? item.body : Buffer.from(item.body ?? ''); + const type = item.type ?? 'File'; + const header = new Header({ + path: item.path, + type, + mode: item.mode ?? (type === 'Directory' ? 0o755 : type === 'SymbolicLink' ? 0o777 : 0o644), + size: type === 'File' || type === 'ExtendedHeader' || type === 'GlobalExtendedHeader' ? body.length : 0, + linkpath: item.linkpath, + }); + const block = Buffer.alloc(512); + header.encode(block); + assert.equal(new Header(block).cksumValid, true, `Fixture header checksum must be valid for ${item.path}.`); + gzip.write(block); + if (body.length > 0) gzip.write(body); + const padding = (512 - (body.length % 512)) % 512; + if (padding > 0) gzip.write(Buffer.alloc(padding)); + } + gzip.end(Buffer.alloc(1024)); + await finished(output); + return archivePath; +} + +async function makeApp(root, { fileBody = 'payload', mode = 0o640, linkTarget = 'A' } = {}) { + const app = join(root, appRoot); + await mkdir(join(app, 'Contents/Resources/resources/server-payload'), { recursive: true, mode: 0o755 }); + await mkdir(join(app, 'Versions/A'), { recursive: true, mode: 0o755 }); + await writeFile(join(app, 'Contents/Resources/resources/server-payload/package.json'), fileBody, { mode }); + await writeFile(join(app, 'Versions/A/Framework'), 'framework bytes', { mode: 0o600 }); + await writeFile(join(app, 'Versions/A/Foo'), 'framework target', { mode: 0o644 }); + await symlink(linkTarget, join(app, 'Versions/Current')); + await symlink('Current/Foo', join(app, 'Versions/Framework')); + await symlink('../Versions/Current/Foo', join(app, 'Contents/Framework')); + return app; +} + +function rootDirectory() { + return { path: appRoot, type: 'Directory', mode: 0o755 }; +} + +function rootFile(path, body = 'x') { + return { path: `${appRoot}/${path}`, type: 'File', body }; +} + +function frameworkFixtureEntries() { + const base = `${appRoot}/Contents/Frameworks/Example.framework`; + return [ + rootDirectory(), + { path: `${appRoot}/Contents`, type: 'Directory', mode: 0o755 }, + { path: `${appRoot}/Contents/Frameworks`, type: 'Directory', mode: 0o755 }, + { path: base, type: 'Directory', mode: 0o755 }, + { path: `${base}/Versions`, type: 'Directory', mode: 0o755 }, + { path: `${base}/Versions/A`, type: 'Directory', mode: 0o755 }, + rootFile('Contents/Frameworks/Example.framework/Versions/A/Foo', 'framework target'), + { path: `${base}/Versions/Current`, type: 'SymbolicLink', linkpath: 'A', mode: 0o777 }, + { path: `${base}/Foo`, type: 'SymbolicLink', linkpath: 'Versions/Current/Foo', mode: 0o777 }, + { path: `${base}/Resources`, type: 'Directory', mode: 0o755 }, + { path: `${base}/Resources/Foo`, type: 'SymbolicLink', linkpath: '../Versions/Current/Foo', mode: 0o777 }, + ]; +} + +test('inventory and archive round-trip preserve bytes, modes, symlinks and unusual names', async t => { + const sourceRoot = await fixture(t); + const app = await makeApp(sourceRoot, { fileBody: 'newline\nname', mode: 0o640 }); + await writeFile(join(app, 'Contents', 'name with\nnewline'), 'unusual', { mode: 0o600 }); + await symlink('Current', join(app, 'Versions/Previous')); + const before = await inventoryApp(app); + const archivePath = join(sourceRoot, 'round-trip.app.tar.gz'); + const created = await createUpdaterArchive({ appPath: app, archivePath }); + const inspected = await inspectUpdaterArchive({ archivePath }); + assert.equal(created.sha256, (await import('node:crypto')).createHash('sha256').update(await readFile(archivePath)).digest('hex')); + compareAppInventories(before, inspected.inventory); + const extractionParent = await fixture(t); + const extracted = await extractUpdaterArchive({ archivePath, root: extractionParent }); + compareAppInventories(before, extracted.inventory); + assert.equal((await readFile(join(app, 'Contents/Resources/resources/server-payload/package.json'), 'utf8')), 'newline\nname'); + await cleanupUpdaterExtraction(extracted); +}); + +test('zero-length files finalize their hash once and survive the full archive roundtrip', async t => { + const root = await fixture(t); + const app = await makeApp(root, { fileBody: '' }); + await writeFile(join(app, 'empty'), ''); + const before = await inventoryApp(app); + const archivePath = join(root, 'empty-members.app.tar.gz'); + await createUpdaterArchive({ appPath: app, archivePath }); + const inspected = await inspectUpdaterArchive({ archivePath }); + compareAppInventories(before, inspected.inventory); + const empty = inspected.inventory.entries.find(entry => entry.path === `${appRoot}/empty`); + assert.equal(empty.size, 0); + assert.equal(empty.sha256, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + const extracted = await extractUpdaterArchive({ archivePath, root: await fixture(t) }); + compareAppInventories(before, extracted.inventory); + await cleanupUpdaterExtraction(extracted); +}); + +test('framework-style links resolve through map symlinks and dot-dot components', async t => { + const root = await fixture(t); + const archivePath = await makeRawArchive(root, frameworkFixtureEntries()); + const inspected = await inspectUpdaterArchive({ archivePath }); + assert.ok(inspected.inventory.entries.some(entry => ( + entry.type === 'symlink' && entry.target === 'Versions/Current/Foo' + ))); + const extractionParent = await fixture(t); + const extracted = await extractUpdaterArchive({ archivePath, root: extractionParent }); + compareAppInventories(inspected.inventory, extracted.inventory); + await cleanupUpdaterExtraction(extracted); +}); + +test('inventory is independent of timestamps and rejects hard-linked files', async t => { + const root = await fixture(t); + const app = await makeApp(root); + const first = await inventoryApp(app); + await writeFile(join(app, 'Contents/Resources/resources/server-payload/package.json'), 'payload', { mode: 0o640 }); + const second = await inventoryApp(app); + compareAppInventories(first, second); + + await link(join(app, 'Contents/Resources/resources/server-payload/package.json'), join(app, 'Contents/hard-link')); + await assert.rejects(() => inventoryApp(app), /hard-linked/); +}); + +test('traversal, absolute paths and noncanonical roots are rejected from actual tar streams', async t => { + const root = await fixture(t); + for (const badPath of [ + '../outside', + `${appRoot}/../outside`, + '/tmp/outside', + `${appRoot}//file`, + `${appRoot}/./file`, + `${appRoot}/file//`, + `Other.app/file`, + ]) { + const invalidEntry = badPath.endsWith('/') + ? { path: badPath, type: 'Directory', mode: 0o755 } + : { path: badPath, type: 'File', body: 'x', mode: 0o644 }; + const archive = await makeRawArchive(root, [rootDirectory(), invalidEntry]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: archive }), /root|canonical|dot|relative|separator|member/); + } +}); + +test('duplicate, case-alias and multiple-root entries are rejected', async t => { + const root = await fixture(t); + const cases = [ + [rootDirectory(), rootFile('same'), rootFile('same')], + [rootDirectory(), rootFile('same'), rootFile('SAME')], + [rootDirectory(), rootFile('file'), { path: 'Other.app', type: 'Directory', mode: 0o755 }], + ]; + for (const entries of cases) { + const archive = await makeRawArchive(root, entries); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: archive }), /duplicate|alias|root|member/); + } +}); + +test('full-map symlink ancestor checks catch links before or after their children', async t => { + const root = await fixture(t); + for (const entries of [ + [rootDirectory(), { path: `${appRoot}/Contents`, type: 'SymbolicLink', linkpath: '.' }, rootFile('Contents/escaped')], + [rootDirectory(), rootFile('Contents/escaped'), { path: `${appRoot}/Contents`, type: 'SymbolicLink', linkpath: '.' }], + ]) { + const archive = await makeRawArchive(root, entries); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: archive }), /ancestor|symbolic|directory/); + } + + const escaping = await makeRawArchive(root, [ + rootDirectory(), + { path: `${appRoot}/link`, type: 'SymbolicLink', linkpath: '../../outside' }, + ]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: escaping }), /escapes|relative|root/); + + const cycle = await makeRawArchive(root, [ + rootDirectory(), + { path: `${appRoot}/one`, type: 'SymbolicLink', linkpath: 'two' }, + { path: `${appRoot}/two`, type: 'SymbolicLink', linkpath: 'one' }, + ]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: cycle }), /cycle|symbolic/); +}); + +test('special files, hard links and AppleDouble are rejected while unsafe PAX paths fail closed', async t => { + const root = await fixture(t); + for (const type of ['Link', 'CharacterDevice', 'BlockDevice', 'FIFO']) { + const archive = await makeRawArchive(root, [rootDirectory(), { path: `${appRoot}/bad`, type, linkpath: type === 'Link' ? 'target' : undefined }]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: archive }), /unsupported|ignored|special|entry/); + } + const appleDouble = await makeRawArchive(root, [rootDirectory(), rootFile('._metadata')]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: appleDouble }), /AppleDouble|canonical/); + const pax = await makeRawArchive(root, [ + { path: 'PaxHeaders.0', type: 'ExtendedHeader', body: '16 path=ignored\n' }, + rootDirectory(), + ]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: pax }), /root|member|canonical/); +}); + +test('compressed and absolute decompressed stream caps are enforced independently', async t => { + const root = await fixture(t); + const archive = await makeRawArchive(root, [rootDirectory(), rootFile('large', 'x'.repeat(16 * 1024))]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: archive, maxExpandedBytes: 1024 }), /expanded|Decompressed/); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: archive, maxArchiveBytes: 16 }), /compressed|Compressed/); + const entryLimited = await makeRawArchive(root, [rootDirectory(), rootFile('one', 'x')]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: entryLimited, maxEntries: 1 }), /entries/); +}); + +test('entry and metadata limits abort parsing at the boundary', async t => { + const root = await fixture(t); + const manyEntries = await makeRawArchive(root, [ + rootDirectory(), + rootFile('one'), + rootFile('two'), + rootFile('three'), + ]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: manyEntries, maxEntries: 2 }), /entries/); + const metadata = await makeRawArchive(root, [ + rootDirectory(), + { path: 'PaxHeaders.0', type: 'ExtendedHeader', body: '10 path=x\n' }, + ]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: metadata, maxEntries: 1 }), /entries/); + const oversizedMetadata = await makeRawArchive(root, [ + rootDirectory(), + { path: 'PaxHeaders.1', type: 'ExtendedHeader', body: 'x'.repeat(128) }, + ]); + await assert.rejects(() => inspectUpdaterArchive({ archivePath: oversizedMetadata, maxMetadataBytes: 32 }), /metadata/); +}); + +test('a FIFO archive path is rejected without waiting for a writer', async t => { + const root = await fixture(t); + const fifo = join(root, 'archive.app.tar.gz'); + await execFile('mkfifo', [fifo]); + const moduleUrl = new URL('./updater-archive.mjs', import.meta.url).href; + const script = `import(${JSON.stringify(moduleUrl)}).then(({ inspectUpdaterArchive }) => inspectUpdaterArchive({ archivePath: ${JSON.stringify(fifo)} })).catch(error => { console.error(error.message); process.exitCode = 1; });`; + await assert.rejects( + execFile(process.execPath, ['--input-type=module', '--eval', script], { timeout: 2000, maxBuffer: 16 * 1024 }), + error => error.killed !== true && /regular file|nonempty|FIFO/.test(error.stderr ?? ''), + ); +}); + +test('inventory comparison catches byte, mode and link-target mismatches', async t => { + const firstRoot = await fixture(t); + const secondRoot = await fixture(t); + const first = await makeApp(firstRoot, { fileBody: 'one' }); + const second = await makeApp(secondRoot, { fileBody: 'two' }); + const firstInventory = await inventoryApp(first); + let secondInventory = await inventoryApp(second); + assert.throws(() => compareAppInventories(firstInventory, secondInventory), /bytes/); + const secondPayload = join(second, 'Contents/Resources/resources/server-payload/package.json'); + await writeFile(secondPayload, 'one'); + await chmod(secondPayload, 0o600); + secondInventory = await inventoryApp(second); + assert.throws(() => compareAppInventories(firstInventory, secondInventory), /mode/); + await chmod(secondPayload, 0o640); + await rm(join(second, 'Versions/Current')); + await symlink('A/.', join(second, 'Versions/Current')); + secondInventory = await inventoryApp(second); + assert.throws(() => compareAppInventories(firstInventory, secondInventory), /target/); +}); + +test('maintained tar PAX metadata preserves long member names and exact inventory', async t => { + const root = await fixture(t); + const app = await makeApp(root); + const longName = 'n'.repeat(101); + await writeFile(join(app, longName), 'long'); + const before = await inventoryApp(app); + const archivePath = join(root, 'long.app.tar.gz'); + await createUpdaterArchive({ appPath: app, archivePath }); + const inspected = await inspectUpdaterArchive({ archivePath }); + compareAppInventories(before, inspected.inventory); + assert.ok(inspected.inventory.entries.some(entry => entry.path.endsWith(`/${longName}`))); +}); + +test('maintained tar PAX/prefix encoding accepts a 300-byte bundled path', async t => { + const root = await fixture(t); + const app = await makeApp(root); + const segments = ['a', 'b', 'c', 'd'].map(value => value.repeat(70)); + const nested = join(app, ...segments); + await mkdir(nested, { recursive: true, mode: 0o755 }); + await writeFile(join(nested, 'runtime'), 'deep path'); + const archivePath = join(root, 'deep.app.tar.gz'); + const before = await inventoryApp(app); + await createUpdaterArchive({ appPath: app, archivePath }); + const inspected = await inspectUpdaterArchive({ archivePath }); + compareAppInventories(before, inspected.inventory); + assert.ok(inspected.inventory.entries.some(entry => entry.path.endsWith('/runtime'))); + const extracted = await extractUpdaterArchive({ archivePath, root: await fixture(t) }); + compareAppInventories(before, extracted.inventory); + await cleanupUpdaterExtraction(extracted); +}); + +test('extraction requires a fresh private parent and cleans failed output', async t => { + const root = await fixture(t); + const app = await makeApp(root); + const archive = join(root, 'safe.app.tar.gz'); + await createUpdaterArchive({ appPath: app, archivePath: archive }); + const notPrivate = await fixture(t); + await chmod(notPrivate, 0o755); + await assert.rejects(() => extractUpdaterArchive({ archivePath: archive, root: notPrivate }), /private|owner-only/); + await assert.rejects(() => extractUpdaterArchive({ archivePath: archive, root, destination: join(root, 'nested', 'not-fresh') }), /fresh child|ENOENT|directory/); +}); diff --git a/scripts/release/updater-artifacts.mjs b/scripts/release/updater-artifacts.mjs new file mode 100644 index 00000000..ead9e017 --- /dev/null +++ b/scripts/release/updater-artifacts.mjs @@ -0,0 +1,619 @@ +import semver from 'semver'; + +import { + ARTIFACT_PREFIX, + REPOSITORY_SLUG, + REPOSITORY_URL, +} from '../../shared/productIdentity.js'; + +const SAFE_ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const COMMIT = /^[a-f0-9]{40}$/; +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const UTC_DATE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/; +const MACOS_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/; + +/** The only updater platform represented by this contract. */ +export const MACOS_UPDATE_TARGET = 'darwin-aarch64'; +/** The Rust target encoded in a macOS updater build. */ +export const MACOS_RUST_TARGET = 'aarch64-apple-darwin'; +/** The desktop version shipped before the updater contract was introduced. */ +export const DESKTOP_VERSION_BASELINE = '0.2.3'; +/** Maximum number of payloads a local release may pin. */ +export const MAX_RELEASE_PAYLOADS = 16; +/** Shared byte limits for release payloads and all updater sidecars. */ +export const UPDATER_ASSET_LIMITS = Object.freeze({ + maxPayloadBytes: 2 * 1024 ** 3, + maxArchiveBytes: 250 * 1024 ** 2, + maxExpandedBytes: 1 * 1024 ** 3, + maxDmgBytes: 250 * 1024 ** 2, + maxChecksumBytes: 1024, + maxSignatureBytes: 16 * 1024, + maxManifestBytes: 64 * 1024, +}); +/** Maximum bytes for any payload asset. */ +export const MAX_PAYLOAD_BYTES = UPDATER_ASSET_LIMITS.maxPayloadBytes; +/** Maximum bytes for a signed updater archive. */ +export const MAX_ARCHIVE_BYTES = UPDATER_ASSET_LIMITS.maxArchiveBytes; +/** Maximum expanded bytes permitted for the signed updater archive. */ +export const MAX_EXPANDED_BYTES = UPDATER_ASSET_LIMITS.maxExpandedBytes; +/** Existing release limit for the macOS DMG. */ +export const MAX_DMG_BYTES = UPDATER_ASSET_LIMITS.maxDmgBytes; +/** Maximum bytes for a checksum sidecar. */ +export const MAX_CHECKSUM_BYTES = UPDATER_ASSET_LIMITS.maxChecksumBytes; +/** Maximum bytes for an updater signature sidecar or manifest signature field. */ +export const MAX_SIGNATURE_BYTES = UPDATER_ASSET_LIMITS.maxSignatureBytes; +/** Maximum bytes for the desktop-update.json asset. */ +export const MAX_MANIFEST_BYTES = UPDATER_ASSET_LIMITS.maxManifestBytes; + +function demand(condition, message) { + if (!condition) throw new Error(message); +} + +function isRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys(value, expected, label) { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + demand(JSON.stringify(actual) === JSON.stringify(wanted), `${label} contains unexpected or missing fields.`); +} + +export function strictVersion(value, label) { + demand(typeof value === 'string' && value.length <= 128 && semver.valid(value) === value, + `${label} must be strict SemVer without a leading v.`); + return value; +} + +function canonicalTag(productVersion, tag) { + strictVersion(productVersion, 'Product version'); + const expected = `v${productVersion}`; + if (tag !== undefined) demand(typeof tag === 'string' && tag === expected, 'Release tag must exactly match the product version.'); + return expected; +} + +function productChannel(productVersion) { + const prerelease = semver.prerelease(strictVersion(productVersion, 'Product version')); + if (prerelease === null) return 'stable'; + demand(prerelease[0] === 'beta', 'Only beta and stable product channels are supported.'); + return 'beta'; +} + +function boundedText(value, label, maxBytes, { empty = false, controls = true } = {}) { + demand(typeof value === 'string' && (empty || value.length > 0) + && Buffer.byteLength(value, 'utf8') <= maxBytes, `${label} is missing or oversized.`); + if (controls) demand(!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value), `${label} contains control characters.`); + return value; +} + +export function validUtcDate(value, label) { + demand(typeof value === 'string' && value.length <= 32, `${label} must be a bounded UTC timestamp.`); + const match = UTC_DATE.exec(value); + demand(match, `${label} must be an ISO-8601 UTC timestamp.`); + const [, year, month, day, hour, minute, second] = match.map(Number); + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + date.setUTCHours(hour, minute, second, 0); + const timestamp = date.getTime(); + demand(Number.isFinite(timestamp) + && date.getUTCFullYear() === year + && date.getUTCMonth() === month - 1 + && date.getUTCDate() === day + && date.getUTCHours() === hour + && date.getUTCMinutes() === minute + && date.getUTCSeconds() === second, `${label} is not a real UTC timestamp.`); + return value; +} + +function validMacosVersion(value) { + demand(typeof value === 'string' && MACOS_VERSION.test(value), 'minimumSystemVersion must be major.minor[.patch].'); + for (const component of value.split('.')) { + demand(Number(component) <= 999, 'minimumSystemVersion contains an oversized component.'); + } + return value; +} + +function validCommit(value, label = 'Build commit') { + demand(typeof value === 'string' && COMMIT.test(value), `${label} must be a lowercase full commit SHA.`); + return value; +} + +function validSignature(value, label = 'Updater signature') { + boundedText(value, label, MAX_SIGNATURE_BYTES); + demand(value.length >= 8 && value.length % 4 === 0 && BASE64.test(value), `${label} must be base64.`); + return value; +} + +function canonicalArchiveUrl(tag, archive) { + return `${REPOSITORY_URL}/releases/download/${tag}/${archive}`; +} + +/** + * Return every release filename owned by the updater contract. + * + * `productVersion` is the package/GitHub version without its leading `v`. + * The returned `ciAssets` is the exact eight-member CI set. `optional` names + * describe the canonical optional Linux payloads. Local publishers may also + * explicitly pin other existing versioned payload/checksum pairs. + */ +export function assetNames({ productVersion, tag } = {}) { + const releaseTag = canonicalTag(productVersion, tag); + const desktopStem = `${ARTIFACT_PREFIX}desktop-${productVersion}-macos-arm64`; + const macos = { + dmg: `${desktopStem}.dmg`, + dmgChecksum: `${desktopStem}.dmg.sha256`, + archive: `${desktopStem}.app.tar.gz`, + archiveSignature: `${desktopStem}.app.tar.gz.sig`, + archiveChecksum: `${desktopStem}.app.tar.gz.sha256`, + manifest: 'desktop-update.json', + }; + const server = { + archive: `${ARTIFACT_PREFIX}server-${productVersion}-linux-x64-node22.tar.gz`, + checksum: `${ARTIFACT_PREFIX}server-${productVersion}-linux-x64-node22.tar.gz.sha256`, + }; + const optional = { + linuxDeb: `${ARTIFACT_PREFIX}desktop-${productVersion}-linux-x64.deb`, + linuxDebChecksum: `${ARTIFACT_PREFIX}desktop-${productVersion}-linux-x64.deb.sha256`, + linuxAppImage: `${ARTIFACT_PREFIX}desktop-${productVersion}-linux-x64.AppImage`, + linuxAppImageChecksum: `${ARTIFACT_PREFIX}desktop-${productVersion}-linux-x64.AppImage.sha256`, + }; + const ciAssets = [ + macos.dmg, + macos.dmgChecksum, + macos.archive, + macos.archiveSignature, + macos.archiveChecksum, + macos.manifest, + server.archive, + server.checksum, + ]; + return Object.freeze({ + productVersion: strictVersion(productVersion, 'Product version'), + tag: releaseTag, + macos: Object.freeze(macos), + server: Object.freeze(server), + optional: Object.freeze(optional), + ciAssets: Object.freeze(ciAssets), + canonicalPayloads: Object.freeze([macos.dmg, macos.archive, server.archive]), + optionalPayloads: Object.freeze([optional.linuxDeb, optional.linuxAppImage]), + }); +} + +function payloadDefinitions(names, pins = new Map()) { + const definitions = new Map([ + [names.macos.dmg, { sidecar: names.macos.dmgChecksum, maxBytes: MAX_DMG_BYTES }], + [names.macos.archive, { sidecar: names.macos.archiveChecksum, maxBytes: MAX_ARCHIVE_BYTES }], + [names.server.archive, { sidecar: names.server.checksum, maxBytes: MAX_PAYLOAD_BYTES }], + [names.optional.linuxDeb, { sidecar: names.optional.linuxDebChecksum, maxBytes: MAX_PAYLOAD_BYTES }], + [names.optional.linuxAppImage, { sidecar: names.optional.linuxAppImageChecksum, maxBytes: MAX_PAYLOAD_BYTES }], + ]); + for (const name of pins.keys()) { + demand(SAFE_ASSET_NAME.test(name) && name.startsWith(ARTIFACT_PREFIX) + && name.includes(`-${names.productVersion}-`) && !name.endsWith('.sha256') && !name.endsWith('.sig'), + 'Pins must name safe versioned payloads, not manifest, signature or checksum sidecars.'); + if (!definitions.has(name)) definitions.set(name, { sidecar: `${name}.sha256`, maxBytes: MAX_PAYLOAD_BYTES }); + } + return definitions; +} + +function normalizePins(pins) { + if (pins === undefined || pins === null) return new Map(); + let entries; + if (pins instanceof Map) { + entries = [...pins.entries()]; + } else if (Array.isArray(pins)) { + entries = pins.map((entry, index) => { + demand(Array.isArray(entry) && entry.length === 2, `Pin ${index + 1} must be a [name, sha256] pair.`); + return entry; + }); + } else { + demand(isRecord(pins), 'Pins must be a Map, object, or [name, sha256] list.'); + entries = Object.entries(pins); + } + const normalized = new Map(); + for (const [name, hash] of entries) { + demand(typeof name === 'string' && !normalized.has(name), 'Payload pins must have unique names.'); + demand(typeof hash === 'string' && SHA256.test(hash), `Pin for ${name} must be a lowercase SHA-256.`); + normalized.set(name, hash); + } + return normalized; +} + +function expectedAssetNames(names, mode, pins) { + demand(mode === 'ci' || mode === 'local', 'Asset validation mode must be ci or local.'); + if (mode === 'ci') { + for (const name of pins.keys()) { + demand(names.canonicalPayloads.includes(name), 'CI pins may only name canonical payloads.'); + } + return new Set(names.ciAssets); + } + demand(pins.size <= MAX_RELEASE_PAYLOADS, `Local release may pin at most ${MAX_RELEASE_PAYLOADS} payloads.`); + const definitions = payloadDefinitions(names, pins); + for (const name of names.canonicalPayloads) { + demand(pins.has(name), `Local release must explicitly pin canonical payload ${name}.`); + } + const expected = new Set(names.ciAssets); + for (const name of pins.keys()) { + expected.add(name); + expected.add(definitions.get(name).sidecar); + } + return expected; +} + +function validateAssetMetadata(asset, expected, definitions, pins, signatureName, manifestName) { + demand(isRecord(asset), 'Release assets must be metadata objects.'); + const { name } = asset; + demand(typeof name === 'string' && SAFE_ASSET_NAME.test(name), 'Release asset has an unsafe basename.'); + demand(expected.has(name), `Unlisted release asset is not allowed: ${name}`); + demand(Number.isSafeInteger(asset.id) && asset.id > 0, `Asset ${name} must have a positive numeric ID.`); + demand(Number.isSafeInteger(asset.size) && asset.size > 0 && asset.size <= MAX_PAYLOAD_BYTES, + `Asset ${name} has an invalid or oversized byte count.`); + if ('state' in asset) demand(asset.state === 'uploaded', `Asset ${name} is not fully uploaded.`); + else throw new Error(`Asset ${name} is missing its upload state.`); + if ('digest' in asset && asset.digest !== null) { + demand(typeof asset.digest === 'string' && /^sha256:[a-f0-9]{64}$/.test(asset.digest), + `Asset ${name} has an invalid GitHub digest.`); + } + if ('updated_at' in asset && asset.updated_at !== null) validUtcDate(asset.updated_at, `${name} updated_at`); + if ('label' in asset && asset.label !== null) boundedText(asset.label, `${name} label`, 256, { empty: true }); + + const payload = definitions.get(name); + if (payload) { + demand(asset.size <= payload.maxBytes, `Payload ${name} exceeds its release size limit.`); + const pinned = pins.get(name); + if (pinned && asset.digest !== null && asset.digest !== undefined) { + demand(asset.digest === `sha256:${pinned}`, `Asset ${name} disagrees with its independent pin.`); + } + } else if (name.endsWith('.sha256')) { + demand(asset.size <= MAX_CHECKSUM_BYTES, `Checksum sidecar ${name} is oversized.`); + } else if (name.endsWith('.sig')) { + demand(name === signatureName, + `Signature sidecar ${name} is not attached to the canonical updater archive.`); + demand(asset.size <= MAX_SIGNATURE_BYTES, `Signature sidecar ${name} is oversized.`); + } else if (name === manifestName) { + demand(asset.size <= MAX_MANIFEST_BYTES, 'desktop-update.json is oversized.'); + } + return { + id: asset.id, + name, + size: asset.size, + state: asset.state, + digest: asset.digest ?? null, + ...(asset.updated_at === undefined ? {} : { updated_at: asset.updated_at }), + }; +} + +/** + * Validate a GitHub-release asset list against the exact CI or local allowlist. + * + * `assets` contains GitHub metadata objects with numeric `id`, `name`, positive + * `size`, `state: "uploaded"` and optional `digest`/`updated_at`/`label`. + * CI accepts exactly eight names. Local requires independent SHA-256 `pins` + * for the three canonical payloads and accepts only explicitly pinned extra + * versioned payload/checksum pairs, including Linux, never extra signatures. + * + * This checks metadata and independent hash declarations only. It does not + * verify archive bytes, checksums, or updater signatures. + */ +export function validateReleaseAssets({ + assets, + productVersion, + tag, + mode = 'ci', + pins, + manifest, + desktopVersion, + commit, + minimumSystemVersion, + channel, + expectedSignature, +} = {}) { + const names = assetNames({ productVersion, tag }); + const normalizedPins = normalizePins(pins); + const expected = expectedAssetNames(names, mode, normalizedPins); + demand(Array.isArray(assets) && assets.length === expected.size, 'Release asset list does not have the exact expected cardinality.'); + const definitions = payloadDefinitions(names, normalizedPins); + const seenNames = new Set(); + const seenIds = new Set(); + const normalized = []; + for (const asset of assets) { + const item = validateAssetMetadata(asset, expected, definitions, normalizedPins, + names.macos.archiveSignature, names.macos.manifest); + demand(!seenNames.has(item.name), `Duplicate release asset name: ${item.name}`); + demand(!seenIds.has(item.id), `Duplicate release asset ID: ${item.id}`); + seenNames.add(item.name); + seenIds.add(item.id); + normalized.push(item); + } + for (const name of expected) demand(seenNames.has(name), `Missing release asset: ${name}`); + if (manifest !== undefined) { + validateDesktopUpdateManifest(manifest, { + productVersion, + desktopVersion, + tag: names.tag, + commit, + minimumSystemVersion, + channel, + expectedSignature, + }); + } + return Object.freeze({ + mode, + productVersion: names.productVersion, + tag: names.tag, + names, + expectedNames: Object.freeze([...expected].sort()), + payloadNames: Object.freeze([...definitions.keys()].filter(name => normalizedPins.has(name) || names.canonicalPayloads.includes(name))), + pins: new Map(normalizedPins), + assets: Object.freeze(normalized.sort((a, b) => a.name.localeCompare(b.name))), + manifestValidated: manifest !== undefined, + }); +} + +/** + * Build the canonical desktop-update.json object for one release. + * + * The archive URL, repository, channel, platform and target are derived from + * shared product identity and the version/tag. The returned object is already + * passed through strict metadata validation; this still makes no cryptographic + * claim about the supplied signature. + */ +export function buildDesktopUpdateManifest({ + productVersion, + desktopVersion, + notes, + pubDate, + minimumSystemVersion, + commit, + signature, + tag, + channel, + target = MACOS_RUST_TARGET, +} = {}) { + const names = assetNames({ productVersion, tag }); + const derivedChannel = productChannel(productVersion); + if (channel !== undefined) demand(channel === derivedChannel, 'Manifest channel does not match productVersion.'); + demand(target === MACOS_RUST_TARGET, 'Manifest build target is not the canonical macOS arm64 target.'); + const manifest = { + version: desktopVersion, + notes, + pub_date: pubDate, + platforms: { + [MACOS_UPDATE_TARGET]: { + url: canonicalArchiveUrl(names.tag, names.macos.archive), + signature, + }, + }, + productVersion, + channel: derivedChannel, + minimumSystemVersion, + repository: REPOSITORY_SLUG, + build: { + commit, + target: MACOS_RUST_TARGET, + }, + }; + return validateDesktopUpdateManifest(manifest, { + productVersion, + desktopVersion, + tag: names.tag, + commit, + minimumSystemVersion, + channel: derivedChannel, + expectedSignature: signature, + target, + }); +} + +/** + * Strictly validate the bounded desktop-update.json object. + * + * The signature is checked only for bounded base64 syntax. Cryptographic + * verification of the `.app.tar.gz` bytes is intentionally owned by the + * subsequent archive-signing/verifier slice. + */ +export function validateDesktopUpdateManifest(manifest, { + productVersion, + desktopVersion, + tag, + commit, + minimumSystemVersion, + channel, + expectedSignature, + target = MACOS_RUST_TARGET, +} = {}) { + demand(isRecord(manifest), 'desktop-update.json must be a plain object.'); + exactKeys(manifest, [ + 'version', + 'notes', + 'pub_date', + 'platforms', + 'productVersion', + 'channel', + 'minimumSystemVersion', + 'repository', + 'build', + ], 'desktop-update.json'); + let encoded; + try { + encoded = JSON.stringify(manifest); + } catch { + throw new Error('desktop-update.json must be JSON-serializable.'); + } + demand(Buffer.byteLength(encoded, 'utf8') <= MAX_MANIFEST_BYTES, 'desktop-update.json exceeds its size limit.'); + + const manifestProductVersion = strictVersion(manifest.productVersion, 'Manifest productVersion'); + const manifestTag = canonicalTag(manifestProductVersion, tag); + if (productVersion !== undefined) { + demand(strictVersion(productVersion, 'Expected productVersion') === manifestProductVersion, + 'Manifest productVersion does not match the release.'); + } + const manifestDesktopVersion = strictVersion(manifest.version, 'Manifest version'); + if (desktopVersion !== undefined) { + demand(strictVersion(desktopVersion, 'Expected desktopVersion') === manifestDesktopVersion, + 'Manifest version does not match the desktop build.'); + } + validUtcDate(manifest.pub_date, 'Manifest pub_date'); + boundedText(manifest.notes, 'Manifest notes', MAX_MANIFEST_BYTES); + + const manifestChannel = productChannel(manifestProductVersion); + demand(manifest.channel === manifestChannel, 'Manifest channel does not match productVersion.'); + if (channel !== undefined) demand(channel === manifestChannel, 'Manifest channel does not match the expected channel.'); + validMacosVersion(manifest.minimumSystemVersion); + if (minimumSystemVersion !== undefined) { + demand(validMacosVersion(minimumSystemVersion) === manifest.minimumSystemVersion, + 'Manifest minimumSystemVersion does not match the release.'); + } + demand(manifest.repository === REPOSITORY_SLUG, 'Manifest repository is not the canonical repository.'); + + demand(isRecord(manifest.platforms), 'Manifest platforms must be an object.'); + exactKeys(manifest.platforms, [MACOS_UPDATE_TARGET], 'Manifest platforms'); + const platform = manifest.platforms[MACOS_UPDATE_TARGET]; + demand(isRecord(platform), 'Manifest darwin-aarch64 platform must be an object.'); + exactKeys(platform, ['url', 'signature'], 'Manifest darwin-aarch64 platform'); + const names = assetNames({ productVersion: manifestProductVersion, tag: manifestTag }); + const expectedUrl = canonicalArchiveUrl(manifestTag, names.macos.archive); + demand(typeof platform.url === 'string' && platform.url === expectedUrl, 'Manifest updater URL is not the canonical GitHub download URL.'); + let parsedUrl; + try { + parsedUrl = new URL(platform.url); + } catch { + throw new Error('Manifest updater URL is malformed.'); + } + demand(parsedUrl.protocol === 'https:' && parsedUrl.username === '' && parsedUrl.password === '' + && parsedUrl.search === '' && parsedUrl.hash === '' && parsedUrl.href === expectedUrl, + 'Manifest updater URL must be credential-free HTTPS without query or fragment data.'); + validSignature(platform.signature); + if (expectedSignature !== undefined) { + demand(validSignature(expectedSignature, 'Expected updater signature') === platform.signature, + 'Manifest updater signature does not match the staged signature.'); + } + + demand(isRecord(manifest.build), 'Manifest build must be an object.'); + exactKeys(manifest.build, ['commit', 'target'], 'Manifest build'); + validCommit(manifest.build.commit); + if (commit !== undefined) demand(validCommit(commit, 'Expected build commit') === manifest.build.commit, + 'Manifest build commit does not match the release.'); + demand(manifest.build.target === target && target === MACOS_RUST_TARGET, + 'Manifest build target is not the canonical macOS arm64 target.'); + return Object.freeze({ + ...manifest, + platforms: Object.freeze({ + [MACOS_UPDATE_TARGET]: Object.freeze({ ...platform }), + }), + build: Object.freeze({ ...manifest.build }), + }); +} + +/** + * Prove that a candidate desktopVersion clears the complete published history. + * + * `priorPublished` must be an explicit, complete mapping (including + * pre-updater releases): each record has a stable numeric `id`, canonical + * product `tag`/`productVersion`, mapped `desktopVersion`, full source + * `commit`, and UTC `publishedAt`. `historyComplete` must be the literal + * boolean true; a partial or unknown history is rejected. + */ +export function validateDesktopVersionFloor({ + candidateDesktopVersion, + priorPublished, + historyComplete, + baseline = DESKTOP_VERSION_BASELINE, +} = {}) { + demand(historyComplete === true, 'Complete published desktop-version history is required.'); + demand(strictVersion(baseline, 'Desktop version baseline') === DESKTOP_VERSION_BASELINE, + `Desktop version baseline is fixed at ${DESKTOP_VERSION_BASELINE}.`); + demand(Array.isArray(priorPublished), 'Published desktop-version history must be an array.'); + const candidate = strictVersion(candidateDesktopVersion, 'Candidate desktopVersion'); + let floor = semver.parse(DESKTOP_VERSION_BASELINE); + const ids = new Set(); + const tags = new Set(); + for (const release of priorPublished) { + demand(isRecord(release), 'Published history entries must be plain objects.'); + exactKeys(release, ['id', 'tag', 'productVersion', 'desktopVersion', 'commit', 'publishedAt'], 'Published history entry'); + demand(Number.isSafeInteger(release.id) && release.id > 0, 'Published history IDs must be positive numeric IDs.'); + demand(!ids.has(release.id), `Duplicate published history ID: ${release.id}`); + ids.add(release.id); + strictVersion(release.productVersion, 'Published productVersion'); + productChannel(release.productVersion); + const releaseTag = canonicalTag(release.productVersion, release.tag); + demand(!tags.has(releaseTag), `Duplicate published history tag: ${releaseTag}`); + tags.add(releaseTag); + const desktopVersion = strictVersion(release.desktopVersion, 'Published desktopVersion'); + validCommit(release.commit, 'Published source commit'); + validUtcDate(release.publishedAt, 'Published history timestamp'); + if (semver.gt(desktopVersion, floor)) floor = semver.parse(desktopVersion); + } + demand(semver.gt(candidate, floor), `Candidate desktopVersion ${candidate} must be greater than historical floor ${floor.version}.`); + return Object.freeze({ + baseline: DESKTOP_VERSION_BASELINE, + floor: floor.version, + candidateDesktopVersion: candidate, + historyCount: priorPublished.length, + historyComplete: true, + }); +} + +/** + * Compare a validated candidate manifest using true SemVer and channel policy. + * + * Stable installations accept only stable candidates. Beta installations may + * adopt beta or stable. Product SemVer identifies the release and channel; + * desktopVersion is the sole install ordering authority. + */ +export function compareDesktopUpdate({ + currentProductVersion, + currentDesktopVersion, + currentChannel, + candidateManifest, + candidateProductVersion, + candidateTag, + candidateCommit, + candidateMinimumSystemVersion, + expectedSignature, +} = {}) { + const currentProduct = strictVersion(currentProductVersion, 'Current productVersion'); + const currentDesktop = strictVersion(currentDesktopVersion, 'Current desktopVersion'); + const inferredCurrentChannel = productChannel(currentProduct); + demand(currentChannel === undefined || currentChannel === inferredCurrentChannel, + 'Current channel does not match current productVersion.'); + const candidateProduct = candidateProductVersion ?? candidateManifest?.productVersion; + const candidate = validateDesktopUpdateManifest(candidateManifest, { + productVersion: candidateProduct, + tag: candidateTag, + commit: candidateCommit, + minimumSystemVersion: candidateMinimumSystemVersion, + expectedSignature, + }); + const productRelation = semver.compare(candidate.productVersion, currentProduct); + const desktopRelation = semver.compare(candidate.version, currentDesktop); + const relation = desktopRelation > 0 ? 'newer' : desktopRelation < 0 ? 'older' : 'equal'; + let reason = 'eligible'; + let eligible = true; + if (inferredCurrentChannel === 'stable' && candidate.channel === 'beta') { + eligible = false; + reason = 'stable-channel-excludes-beta'; + } else if (desktopRelation <= 0) { + eligible = false; + reason = relation === 'equal' ? 'desktop-version-equal' : 'desktop-version-older'; + } + return Object.freeze({ + eligible, + reason, + relation, + productRelation: productRelation > 0 ? 'newer' : productRelation < 0 ? 'older' : 'equal', + current: Object.freeze({ + productVersion: currentProduct, + desktopVersion: currentDesktop, + channel: inferredCurrentChannel, + }), + candidate: Object.freeze({ + productVersion: candidate.productVersion, + desktopVersion: candidate.version, + channel: candidate.channel, + }), + }); +} diff --git a/scripts/release/updater-artifacts.test.mjs b/scripts/release/updater-artifacts.test.mjs new file mode 100644 index 00000000..a34281cc --- /dev/null +++ b/scripts/release/updater-artifacts.test.mjs @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +import { + DESKTOP_VERSION_BASELINE, + MACOS_RUST_TARGET, + MACOS_UPDATE_TARGET, + UPDATER_ASSET_LIMITS, + MAX_CHECKSUM_BYTES, + MAX_MANIFEST_BYTES, + MAX_PAYLOAD_BYTES, + MAX_RELEASE_PAYLOADS, + MAX_SIGNATURE_BYTES, + assetNames, + buildDesktopUpdateManifest, + compareDesktopUpdate, + validateDesktopUpdateManifest, + validateDesktopVersionFloor, + validateReleaseAssets, +} from './updater-artifacts.mjs'; + +const productVersion = '2.0.0-beta.10'; +const desktopVersion = '0.2.4'; +const commit = 'a'.repeat(40); +const signature = 'A'.repeat(88); +const pubDate = '2026-09-06T00:00:00Z'; +const sharedManifestFixture = JSON.parse(readFileSync( + new URL('../../shared/fixtures/desktop-update-manifest.json', import.meta.url), + 'utf8', +)); + +function manifestFor(overrides = {}) { + return buildDesktopUpdateManifest({ + productVersion, + desktopVersion, + notes: 'A signed release fixture.', + pubDate, + minimumSystemVersion: '13.0', + commit, + signature, + ...overrides, + }); +} + +function assetsFor(names, overrides = {}) { + return names.map((name, index) => ({ + id: index + 1, + name, + size: name.endsWith('.sha256') ? 80 : name.endsWith('.sig') ? 100 : name === 'desktop-update.json' ? 500 : 1024, + state: 'uploaded', + digest: null, + updated_at: pubDate, + ...overrides, + })); +} + +function localPins(names, optional = []) { + return new Map([ + [names.macos.dmg, '1'.repeat(64)], + [names.macos.archive, '2'.repeat(64)], + [names.server.archive, '3'.repeat(64)], + ...optional.map((name, index) => [name, `${index + 4}`.repeat(64)]), + ]); +} + +test('assetNames derives the exact versioned CI set and known optional Linux names', () => { + const names = assetNames({ productVersion }); + assert.equal(names.tag, `v${productVersion}`); + assert.deepEqual(names.macos, { + dmg: 'gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg', + dmgChecksum: 'gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg.sha256', + archive: 'gajae-app-desktop-2.0.0-beta.10-macos-arm64.app.tar.gz', + archiveSignature: 'gajae-app-desktop-2.0.0-beta.10-macos-arm64.app.tar.gz.sig', + archiveChecksum: 'gajae-app-desktop-2.0.0-beta.10-macos-arm64.app.tar.gz.sha256', + manifest: 'desktop-update.json', + }); + assert.deepEqual(names.ciAssets, [ + names.macos.dmg, + names.macos.dmgChecksum, + names.macos.archive, + names.macos.archiveSignature, + names.macos.archiveChecksum, + names.macos.manifest, + names.server.archive, + names.server.checksum, + ]); + assert.equal(new Set(names.ciAssets).size, 8); + assert.match(names.optional.linuxAppImage, /-linux-x64\.AppImage$/); + assert.throws(() => assetNames({ productVersion: 'v2.0.0' }), /strict SemVer/); + assert.throws(() => assetNames({ productVersion, tag: 'latest' }), /Release tag/); +}); + +test('CI allowlist requires exactly eight uploaded assets and rejects extras, duplicates, and bad bounds', () => { + const names = assetNames({ productVersion }); + const manifest = manifestFor(); + const assets = assetsFor(names.ciAssets); + const result = validateReleaseAssets({ + assets, + productVersion, + desktopVersion, + commit, + manifest, + expectedSignature: signature, + }); + assert.equal(result.mode, 'ci'); + assert.deepEqual(result.expectedNames, [...names.ciAssets].sort()); + assert.deepEqual(result.payloadNames, [names.macos.dmg, names.macos.archive, names.server.archive]); + + for (const change of [ + list => list.slice(1), + list => [...list, { ...list[0], id: 99, name: 'gajae-app-desktop-2.0.0-beta.10-linux-x64.zip' }], + list => list.map((asset, index) => index === 1 ? { ...asset, id: list[0].id } : asset), + list => list.map((asset, index) => index === 1 ? { ...asset, name: list[0].name } : asset), + list => list.map(asset => asset.name.endsWith('.sha256') ? { ...asset, size: MAX_CHECKSUM_BYTES + 1 } : asset), + list => list.map(asset => asset.name.endsWith('.sig') ? { ...asset, size: MAX_SIGNATURE_BYTES + 1 } : asset), + list => list.map(asset => asset.name === 'desktop-update.json' ? { ...asset, size: MAX_MANIFEST_BYTES + 1 } : asset), + list => list.map(asset => asset.name === names.macos.dmg ? { ...asset, size: MAX_PAYLOAD_BYTES + 1 } : asset), + list => list.map(asset => asset.name === names.macos.archiveSignature ? { ...asset, name: `${names.macos.dmg}.sig` } : asset), + list => list.map(asset => asset.name === names.server.archive ? { ...asset, digest: 'sha256:bad' } : asset), + list => list.map(asset => asset.name === names.server.archive ? { ...asset, state: 'starter' } : asset), + ]) { + assert.throws(() => validateReleaseAssets({ + assets: change(assets), + productVersion, + mode: 'ci', + })); + } +}); + +test('only the updater archive uses the compressed archive bound; server payload keeps the generic bound', () => { + const names = assetNames({ productVersion }); + const sizeAboveUpdaterBound = UPDATER_ASSET_LIMITS.maxArchiveBytes + 1; + const assets = assetsFor(names.ciAssets); + assert.doesNotThrow(() => validateReleaseAssets({ + assets: assets.map(asset => asset.name === names.macos.archive + ? { ...asset, size: UPDATER_ASSET_LIMITS.maxArchiveBytes } + : asset), + productVersion, + })); + const serverAboveUpdaterBound = assets.map(asset => asset.name === names.server.archive + ? { ...asset, size: sizeAboveUpdaterBound } + : asset); + assert.doesNotThrow(() => validateReleaseAssets({ + assets: serverAboveUpdaterBound, + productVersion, + })); + + const updaterAboveBound = assets.map(asset => asset.name === names.macos.archive + ? { ...asset, size: sizeAboveUpdaterBound } + : asset); + assert.throws(() => validateReleaseAssets({ + assets: updaterAboveBound, + productVersion, + }), /release size limit/); +}); + +test('local allowlist accepts only independently pinned canonical and explicitly pinned Linux pairs', () => { + const names = assetNames({ productVersion }); + const optional = [names.optional.linuxDeb, names.optional.linuxAppImage]; + const pins = localPins(names, optional); + const expected = [ + ...names.ciAssets, + names.optional.linuxDeb, + names.optional.linuxDebChecksum, + names.optional.linuxAppImage, + names.optional.linuxAppImageChecksum, + ]; + const result = validateReleaseAssets({ + assets: assetsFor(expected), + productVersion, + mode: 'local', + pins, + manifest: manifestFor(), + desktopVersion, + commit, + expectedSignature: signature, + }); + assert.equal(result.mode, 'local'); + assert.equal(result.pins.size, 5); + assert.equal(result.expectedNames.length, 12); + + assert.throws(() => validateReleaseAssets({ + assets: assetsFor([...names.ciAssets, names.optional.linuxDeb, names.optional.linuxDebChecksum]), + productVersion, + mode: 'local', + pins: localPins(names), + }), /pinned|exact expected/i); + assert.throws(() => validateReleaseAssets({ + assets: assetsFor([...names.ciAssets, `${names.optional.linuxAppImage}.sig`]), + productVersion, + mode: 'local', + pins: localPins(names, [names.optional.linuxAppImage]), + }), /exact expected|Unlisted/); + assert.throws(() => validateReleaseAssets({ + assets: assetsFor(names.ciAssets), + productVersion, + mode: 'local', + pins: new Map([[names.macos.dmg, '1'.repeat(64)]]), + }), /canonical payload/); + assert.throws(() => validateReleaseAssets({ + assets: assetsFor(names.ciAssets), + productVersion, + mode: 'local', + pins: new Map([[names.macos.dmg, '1'.repeat(64)], [names.macos.archive, '2'.repeat(64)], + [names.server.archive, '3'.repeat(64)], [`${names.macos.dmg}.sha256`, '4'.repeat(64)]]), + }), /sidecars/); +}); + +test('local releases preserve explicitly pinned additional payload pairs within the payload limit', () => { + const names = assetNames({ productVersion }); + const pins = localPins(names); + const expected = [...names.ciAssets]; + for (let index = 0; index < MAX_RELEASE_PAYLOADS - names.canonicalPayloads.length; index += 1) { + const name = `gajae-app-extra-${productVersion}-reviewed-${index}.zip`; + pins.set(name, '4'.repeat(64)); + expected.push(name, `${name}.sha256`); + } + const assets = assetsFor(expected); + assert.doesNotThrow(() => validateReleaseAssets({ assets, productVersion, mode: 'local', pins })); + assert.throws(() => validateReleaseAssets({ assets, productVersion, mode: 'ci', pins }), /canonical payloads/); + for (const name of ['desktop-update.json', `${names.macos.archive}.sig`, '../outside.zip', + 'gajae-app-extra-1.0.0-wrong-version.zip']) { + const invalid = new Map(localPins(names)); + invalid.set(name, '5'.repeat(64)); + assert.throws(() => validateReleaseAssets({ assets, productVersion, mode: 'local', pins: invalid }), /safe versioned payloads/); + } + pins.set(`gajae-app-extra-${productVersion}-overflow.zip`, '5'.repeat(64)); + assert.throws(() => validateReleaseAssets({ assets, productVersion, mode: 'local', pins }), /at most 16/); +}); + +test('manifest builder and validator bind product, channel, target, commit, URL, and bounded fields', () => { + const manifest = manifestFor(); + assert.equal(manifest.version, desktopVersion); + assert.equal(manifest.productVersion, productVersion); + assert.equal(manifest.channel, 'beta'); + assert.equal(manifest.repository, 'devswha/gajae-code-app'); + assert.equal(Object.keys(manifest.platforms).length, 1); + assert.equal(manifest.platforms[MACOS_UPDATE_TARGET].url, + `https://github.com/devswha/gajae-code-app/releases/download/v${productVersion}/gajae-app-desktop-${productVersion}-macos-arm64.app.tar.gz`); + assert.equal(manifest.build.target, MACOS_RUST_TARGET); + assert.deepEqual(validateDesktopUpdateManifest(manifest, { + productVersion, + desktopVersion, + commit, + expectedSignature: signature, + }), manifest); + + const mutate = (change) => { + const copy = JSON.parse(JSON.stringify(manifest)); + change(copy); + return copy; + }; + for (const [change, expected] of [ + [copy => { copy.platforms[MACOS_UPDATE_TARGET].url = 'https://evil.example/app.tar.gz'; }, /canonical GitHub/], + [copy => { copy.platforms[MACOS_UPDATE_TARGET].url += '?token=secret'; }, /canonical GitHub|credential-free/], + [copy => { copy.platforms[MACOS_UPDATE_TARGET].url = copy.platforms[MACOS_UPDATE_TARGET].url.replace('https://', 'https://user:secret@'); }, /canonical GitHub|credential-free/], + [copy => { copy.platforms[MACOS_UPDATE_TARGET].url += '#fragment'; }, /canonical GitHub|credential-free/], + [copy => { copy.platforms['linux-x64'] = copy.platforms[MACOS_UPDATE_TARGET]; }, /unexpected or missing/], + [copy => { copy.channel = 'stable'; }, /channel/], + [copy => { copy.productVersion = '2.0.0-alpha.1'; }, /Only beta/], + [copy => { copy.build.commit = 'B'.repeat(40); }, /lowercase/], + [copy => { copy.build.target = 'x86_64-apple-darwin'; }, /canonical macOS/], + [copy => { copy.platforms[MACOS_UPDATE_TARGET].signature = 'not-base64'; }, /base64/], + [copy => { copy.notes = 'x'.repeat(MAX_MANIFEST_BYTES); }, /size limit|oversized/], + [copy => { copy.pub_date = '2026-02-31T00:00:00Z'; }, /real UTC/], + [copy => { copy.extra = true; }, /unexpected or missing/], + ]) assert.throws(() => validateDesktopUpdateManifest(mutate(change)), expected); + assert.throws(() => buildDesktopUpdateManifest({ + productVersion, + desktopVersion, + notes: 'missing commit', + pubDate, + minimumSystemVersion: '13.0', + signature, + }), /full commit/); +}); + +test('shared native fixture is the producer output and its signature is syntax-only', () => { + const built = manifestFor(); + assert.deepEqual(sharedManifestFixture, built); + assert.deepEqual(validateDesktopUpdateManifest(sharedManifestFixture, { + productVersion, + desktopVersion, + commit, + expectedSignature: signature, + }), built); + assert.equal(sharedManifestFixture.platforms[MACOS_UPDATE_TARGET].signature, signature); + assert.equal( + sharedManifestFixture.platforms[MACOS_UPDATE_TARGET].signature, + 'A'.repeat(88), + 'fixture signature is bounded base64 syntax only, not cryptographic acceptance', + ); +}); + +test('strict producer SemVer rejects normalized build metadata and adversarial manifest bounds', () => { + assert.throws(() => manifestFor({ + productVersion: '2.0.0-beta.10+build.1', + }), /strict SemVer/); + for (const change of [ + copy => { copy.minimumSystemVersion = '13'; }, + copy => { copy.minimumSystemVersion = '013.0'; }, + copy => { copy.minimumSystemVersion = '13.00'; }, + copy => { copy.minimumSystemVersion = '13.0.0.1'; }, + copy => { copy.minimumSystemVersion = '1000.0'; }, + copy => { copy.minimumSystemVersion = '13.1000'; }, + copy => { copy.notes = 'ok\u000b'; }, + copy => { copy.platforms[MACOS_UPDATE_TARGET].signature = 'A'.repeat(MAX_SIGNATURE_BYTES + 1); }, + copy => { copy.pub_date = '2026-02-29T00:00:00Z'; }, + copy => { copy.pub_date = '2024-02-30T00:00:00Z'; }, + ]) { + const copy = JSON.parse(JSON.stringify(sharedManifestFixture)); + change(copy); + assert.throws(() => validateDesktopUpdateManifest(copy), /minimumSystemVersion|control|size|base64|real UTC/); + } +}); + +test('historical floor requires an explicit complete mapping and compares prereleases with SemVer', () => { + const history = [ + { + id: 1, + tag: 'v2.0.0-beta.9', + productVersion: '2.0.0-beta.9', + desktopVersion: DESKTOP_VERSION_BASELINE, + commit: 'b'.repeat(40), + publishedAt: '2026-08-01T00:00:00Z', + }, + { + id: 2, + tag: 'v2.0.0-beta.10', + productVersion: '2.0.0-beta.10', + desktopVersion: '0.2.4-beta.9', + commit: 'c'.repeat(40), + publishedAt: '2026-09-01T00:00:00Z', + }, + ]; + const result = validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.4-beta.10', + priorPublished: history, + historyComplete: true, + }); + assert.equal(result.floor, '0.2.4-beta.9'); + assert.equal(result.candidateDesktopVersion, '0.2.4-beta.10'); + assert.equal(result.historyCount, 2); + + assert.throws(() => validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.4', + priorPublished: history, + historyComplete: false, + }), /complete/i); + assert.throws(() => validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.4-beta.10', + priorPublished: [{ ...history[0], desktopVersion: undefined }], + historyComplete: true, + }), /strict SemVer/); + assert.throws(() => validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.4-beta.9', + priorPublished: history, + historyComplete: true, + }), /greater than historical floor/); + assert.throws(() => validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.4', + priorPublished: [{ ...history[0], id: 1 }, { ...history[1], id: 1 }], + historyComplete: true, + }), /Duplicate published history ID/); + const repeatedAndBackfilled = validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.5', + priorPublished: [ + ...history, + { + id: 3, + tag: 'v2.0.0-beta.11', + productVersion: '2.0.0-beta.11', + desktopVersion: '0.2.4-beta.9', + commit: 'd'.repeat(40), + publishedAt: '2026-07-01T00:00:00Z', + }, + { + id: 4, + tag: 'v1.9.9-beta.1', + productVersion: '1.9.9-beta.1', + desktopVersion: '0.2.3', + commit: 'e'.repeat(40), + publishedAt: '2026-10-01T00:00:00Z', + }, + ], + historyComplete: true, + }); + assert.equal(repeatedAndBackfilled.floor, '0.2.4-beta.9'); + assert.equal(repeatedAndBackfilled.candidateDesktopVersion, '0.2.5'); + assert.throws(() => validateDesktopVersionFloor({ + candidateDesktopVersion: '0.2.4', + priorPublished: history, + historyComplete: true, + baseline: '0.2.2', + }), /baseline/); +}); + +test('update comparison applies true desktop SemVer and beta/stable channel policy', () => { + const stableManifest = buildDesktopUpdateManifest({ + productVersion: '2.0.0', + desktopVersion: '0.2.5', + notes: 'Stable release.', + pubDate, + minimumSystemVersion: '13.0', + commit, + signature, + }); + const betaCurrent = compareDesktopUpdate({ + currentProductVersion: '2.0.0-beta.9', + currentDesktopVersion: '0.2.4-beta.9', + candidateManifest: stableManifest, + }); + assert.equal(betaCurrent.eligible, true); + assert.equal(betaCurrent.relation, 'newer'); + assert.equal(betaCurrent.productRelation, 'newer'); + + const lowerProduct = compareDesktopUpdate({ + currentProductVersion: '2.0.0-beta.10', + currentDesktopVersion: '0.2.4', + candidateManifest: manifestFor({ + productVersion: '1.9.9-beta.1', + desktopVersion: '0.2.5', + }), + }); + assert.equal(lowerProduct.eligible, true); + assert.equal(lowerProduct.reason, 'eligible'); + assert.equal(lowerProduct.productRelation, 'older'); + + const betaManifest = manifestFor({ + productVersion: '2.0.1-beta.1', + desktopVersion: '0.2.6', + }); + const stableCurrent = compareDesktopUpdate({ + currentProductVersion: '2.0.0', + currentDesktopVersion: '0.2.5', + candidateManifest: betaManifest, + }); + assert.equal(stableCurrent.eligible, false); + assert.equal(stableCurrent.reason, 'stable-channel-excludes-beta'); + + const higherProductEqualDesktop = compareDesktopUpdate({ + currentProductVersion: '1.9.9', + currentDesktopVersion: '0.2.5', + candidateManifest: stableManifest, + }); + assert.equal(higherProductEqualDesktop.eligible, false); + assert.equal(higherProductEqualDesktop.reason, 'desktop-version-equal'); + + const same = compareDesktopUpdate({ + currentProductVersion: '2.0.0-beta.9', + currentDesktopVersion: desktopVersion, + candidateManifest: manifestFor(), + }); + assert.equal(same.eligible, false); + assert.equal(same.reason, 'desktop-version-equal'); + + const olderManifest = manifestFor({ + productVersion: '2.0.0-beta.11', + desktopVersion: '0.2.3', + }); + const older = compareDesktopUpdate({ + currentProductVersion: '2.0.0-beta.10', + currentDesktopVersion: desktopVersion, + candidateManifest: olderManifest, + }); + assert.equal(older.eligible, false); + assert.equal(older.reason, 'desktop-version-older'); + assert.throws(() => compareDesktopUpdate({ + currentProductVersion: '2.0.0', + currentDesktopVersion: '0.2.5', + currentChannel: 'beta', + candidateManifest: stableManifest, + }), /Current channel/); +}); diff --git a/scripts/release/updater-history.mjs b/scripts/release/updater-history.mjs new file mode 100644 index 00000000..b9fe103b --- /dev/null +++ b/scripts/release/updater-history.mjs @@ -0,0 +1,259 @@ +import semver from 'semver'; + +import { PACKAGE_NAME, REPOSITORY_SLUG } from '../../shared/productIdentity.js'; + +import { releaseCommand } from './local-release-command.mjs'; +import { strictVersion, validUtcDate } from './updater-artifacts.mjs'; + +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 30_000; +const OVERALL_TIMEOUT_MS = 5 * 60_000; +const MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const COMMIT = /^[a-f0-9]{40}$/; +const TAG_SHA = COMMIT; +const CONTROL = /[\u0000-\u001f\u007f]/u; + +function demand(condition, message) { + if (!condition) throw new Error(message); +} + +function isRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function canonicalProductTag(tag) { + demand(typeof tag === 'string' && tag.length > 1 && tag.startsWith('v') && !CONTROL.test(tag), + 'Published release tag is missing or malformed.'); + const productVersion = strictVersion(tag.slice(1), 'Published productVersion'); + const prerelease = semver.prerelease(productVersion); + demand(prerelease === null || prerelease[0] === 'beta', + 'Only beta and stable product release tags are supported.'); + demand(`v${productVersion}` === tag, 'Published release tag is not canonical.'); + return productVersion; +} + +function encodeTag(tag) { + demand(typeof tag === 'string' && tag.length > 0 && tag.length <= 256 && !CONTROL.test(tag), + 'Release tag is missing or malformed.'); + try { + return encodeURIComponent(tag); + } catch { + throw new Error('Release tag cannot be URL-encoded.'); + } +} + +function matchingRefPages(value) { + demand(Array.isArray(value) && value.every(Array.isArray), 'Unexpected tag reference response.'); + const refs = value.flat(); + const seenRefs = new Set(); + for (const ref of refs) { + demand(isRecord(ref), 'Tag reference records must be objects.'); + demand(typeof ref.ref === 'string' && ref.ref.length <= 512 && !CONTROL.test(ref.ref) + && ref.ref.startsWith('refs/tags/'), 'Tag reference is malformed.'); + demand(!seenRefs.has(ref.ref), `Duplicate tag reference: ${ref.ref}`); + seenRefs.add(ref.ref); + demand(isRecord(ref.object), 'Tag reference object is missing.'); + demand(ref.object.type === 'commit' || ref.object.type === 'tag', + 'Tag reference object has an unexpected type.'); + demand(TAG_SHA.test(ref.object.sha ?? ''), 'Tag reference SHA must be a lowercase full commit or tag SHA.'); + } + return refs; +} + +function tagObject(response, label) { + demand(isRecord(response) && isRecord(response.object), `${label} response is malformed.`); + const object = response.object; + demand(object.type === 'commit' || object.type === 'tag', + `${label} must resolve through commit or tag objects.`); + demand(TAG_SHA.test(object.sha ?? ''), `${label} SHA must be a lowercase full SHA.`); + return object; +} + +/** + * Resolve one exact lightweight or annotated tag to its full commit. + * + * `api` is a read-only GitHub API function whose result is already parsed + * JSON. Annotated tags are dereferenced through at most ten tag objects. + * When supplied, expectedCommit must be a lowercase full SHA and the resolved + * commit must match it; omitting it resolves the tag without trusting the + * release listing's (possibly branch-named) target_commitish. + */ +export async function resolveReleaseTag({ tag, expectedCommit, allowAbsent = false } = {}, api) { + demand(typeof api === 'function', 'A GitHub API function is required.'); + if (expectedCommit !== undefined) { + demand(COMMIT.test(expectedCommit), 'Expected commit must be a lowercase full commit SHA.'); + } + demand(typeof allowAbsent === 'boolean', 'allowAbsent must be boolean.'); + + const pages = await api(`git/matching-refs/tags/${encodeTag(tag)}`, ['--paginate', '--slurp']); + const refs = matchingRefPages(pages).filter(ref => ref.ref === `refs/tags/${tag}`); + demand(refs.length <= 1, 'Ambiguous release tag reference.'); + if (refs.length === 0) { + if (allowAbsent === true) return null; + throw new Error('Release tag is missing.'); + } + + const initial = refs[0].object; + const referenceSha = initial.sha; + let object = initial; + const seen = new Set(); + let depth = 0; + while (object.type === 'tag') { + demand(depth < 10, 'Annotated release tag exceeds the dereference depth limit.'); + demand(!seen.has(object.sha), 'Annotated release tag is cyclic.'); + seen.add(object.sha); + object = tagObject(await api(`git/tags/${object.sha}`), 'Annotated release tag'); + depth += 1; + } + demand(object.type === 'commit' + && (expectedCommit === undefined || object.sha === expectedCommit), + expectedCommit === undefined + ? 'Release tag does not resolve to a full commit.' + : 'Release tag does not resolve to the expected commit.'); + return { commit: object.sha, referenceSha }; +} + +function checkClock(now) { + const value = now(); + demand(typeof value === 'number' && Number.isFinite(value), 'Clock returned an invalid value.'); + return value; +} + +function parseCommandResult(result) { + demand(isRecord(result) && typeof result.stdout === 'string', 'GitHub API command returned an invalid result.'); + demand(result.stderr === undefined || typeof result.stderr === 'string', + 'GitHub API command returned an invalid diagnostic stream.'); + const outputBytes = Buffer.byteLength(result.stdout, 'utf8') + + Buffer.byteLength(result.stderr ?? '', 'utf8'); + demand(outputBytes <= MAX_OUTPUT_BYTES, + 'GitHub API response exceeded the output limit.'); + try { + return JSON.parse(result.stdout); + } catch { + throw new Error('GitHub API response was not valid JSON.'); + } +} + +function validatePublishedRecord(release, ids, tags) { + demand(isRecord(release), 'Release page contains a malformed record.'); + // Only an explicitly true draft is ignored. Any other value is treated as + // a published record and must satisfy the complete mapping contract. + if (release.draft === true) return null; + demand(release.draft === false, 'Release draft flag is malformed.'); + demand(Number.isSafeInteger(release.id) && release.id > 0, + 'Published release IDs must be positive safe integers.'); + demand(!ids.has(release.id), `Duplicate published release ID: ${release.id}`); + ids.add(release.id); + + const tag = release.tag_name; + const productVersion = canonicalProductTag(tag); + demand(!tags.has(tag), `Duplicate published release tag: ${tag}`); + tags.add(tag); + if (release.prerelease !== undefined) { + demand(typeof release.prerelease === 'boolean' + && release.prerelease === (semver.prerelease(productVersion) !== null), + 'Published release prerelease status does not match its canonical tag.'); + } + const publishedAt = validUtcDate(release.published_at, 'Published release timestamp'); + demand(typeof release.target_commitish === 'string' && release.target_commitish.length > 0 + && release.target_commitish.length <= 256 && !CONTROL.test(release.target_commitish), + 'Published release target is malformed.'); + return { + id: release.id, + tag, + productVersion, + publishedAt, + }; +} + +function validatePinnedPackage(packageJson, productVersion) { + demand(isRecord(packageJson), 'Pinned package.json response must be an object.'); + demand(packageJson.name === PACKAGE_NAME, 'Pinned package.json has an unexpected package name.'); + const packageVersion = strictVersion(packageJson.version, 'Pinned package version'); + demand(packageVersion === productVersion, 'Pinned package version does not match its release tag.'); + return strictVersion(packageJson.desktopVersion, 'Pinned desktopVersion'); +} + +/** + * Collect and map every published desktop release in the product repository. + * + * The release list is exhausted before any completeness result is returned. + * Each published tag is resolved to a full commit and its package.json is + * read at that commit; no baseline or chronology shortcut is used. + */ +export async function collectPublishedDesktopHistory({ repo } = {}, { + run = releaseCommand, + now = Date.now, +} = {}) { + demand(repo === REPOSITORY_SLUG, `Release history is restricted to ${REPOSITORY_SLUG}.`); + demand(typeof run === 'function', 'A release command function is required.'); + demand(typeof now === 'function', 'A clock function is required.'); + + const startedAt = checkClock(now); + const overallDeadline = startedAt + OVERALL_TIMEOUT_MS; + demand(Number.isFinite(overallDeadline), 'Overall history deadline is invalid.'); + const endpoint = path => `repos/${repo}/${path}`; + + const api = async (path, args = []) => { + const requestStartedAt = checkClock(now); + if (requestStartedAt >= overallDeadline) throw new Error('Release history overall deadline expired.'); + const requestDeadline = Math.min(overallDeadline, requestStartedAt + REQUEST_TIMEOUT_MS); + const timeout = Math.max(1, Math.floor(requestDeadline - requestStartedAt)); + let result; + try { + result = await run('gh', ['api', '--hostname', 'github.com', endpoint(path), ...args], { + timeout, + maxOutputBytes: MAX_OUTPUT_BYTES, + }); + } catch { + const failedAt = checkClock(now); + if (failedAt >= overallDeadline) throw new Error('Release history overall deadline expired.'); + if (failedAt >= requestDeadline) throw new Error('GitHub API request deadline expired.'); + throw new Error('GitHub API request failed.'); + } + const requestFinishedAt = checkClock(now); + if (requestFinishedAt >= overallDeadline) throw new Error('Release history overall deadline expired.'); + if (requestFinishedAt >= requestDeadline) throw new Error('GitHub API request deadline expired.'); + return parseCommandResult(result); + }; + + const records = []; + const ids = new Set(); + const tags = new Set(); + for (let page = 1; ; page += 1) { + const releases = await api(`releases?per_page=${PAGE_SIZE}&page=${page}`); + demand(Array.isArray(releases), 'Release history page must be an array.'); + demand(releases.length <= PAGE_SIZE, 'Release history page exceeds the requested page size.'); + for (const release of releases) { + const record = validatePublishedRecord(release, ids, tags); + if (record !== null) records.push(record); + } + if (releases.length < PAGE_SIZE) break; + } + + const priorPublished = []; + for (const release of records) { + const resolved = await resolveReleaseTag({ + tag: release.tag, + }, api); + demand(isRecord(resolved) && COMMIT.test(resolved.commit), + 'Published release tag mapping is missing or invalid.'); + const packageJson = await api( + `contents/package.json?ref=${encodeURIComponent(resolved.commit)}`, + ['--header', 'Accept: application/vnd.github.raw+json'], + ); + const desktopVersion = validatePinnedPackage(packageJson, release.productVersion); + priorPublished.push({ + id: release.id, + tag: release.tag, + productVersion: release.productVersion, + desktopVersion, + commit: resolved.commit, + publishedAt: release.publishedAt, + }); + } + demand(checkClock(now) < overallDeadline, 'Release history overall deadline expired.'); + return { priorPublished, historyComplete: true }; +} diff --git a/scripts/release/updater-history.test.mjs b/scripts/release/updater-history.test.mjs new file mode 100644 index 00000000..ae0762df --- /dev/null +++ b/scripts/release/updater-history.test.mjs @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { PACKAGE_NAME, REPOSITORY_SLUG } from '../../shared/productIdentity.js'; + +import { collectPublishedDesktopHistory, resolveReleaseTag } from './updater-history.mjs'; + +const commitFor = value => Number(value).toString(16).padStart(40, '0'); +const timestampFor = value => new Date(Date.UTC(2026, 0, 1, Number(value) % 24, Number(value) % 60)).toISOString(); + +function releaseFor(value, overrides = {}) { + const index = Number(value); + const productVersion = overrides.productVersion ?? `2.0.0-beta.${index}`; + return { + id: overrides.id ?? index, + tag_name: overrides.tag_name ?? `v${productVersion}`, + target_commitish: overrides.target_commitish ?? commitFor(index), + draft: overrides.draft ?? false, + published_at: overrides.published_at ?? timestampFor(index), + ...overrides, + }; +} + +function fixtureRunner({ pages, tagRefs = new Map(), annotated = new Map(), packages = new Map() }) { + const calls = []; + const run = async (command, args, options) => { + calls.push({ command, args: [...args], options: { ...options } }); + assert.equal(command, 'gh'); + assert.equal(args[0], 'api'); + assert.equal(Number.isFinite(options.timeout), true); + assert.equal(args.includes('--method'), false); + const endpoint = `repos/${REPOSITORY_SLUG}/`; + assert.equal(args[3].startsWith(endpoint), true); + const path = args[3].slice(endpoint.length); + let response; + if (path.startsWith('releases?')) { + const page = Number(new URLSearchParams(path.slice(path.indexOf('?') + 1)).get('page')); + response = pages[page - 1] ?? []; + } else if (path.startsWith('git/matching-refs/tags/')) { + const tag = decodeURIComponent(path.slice('git/matching-refs/tags/'.length)); + const ref = tagRefs.get(tag); + response = ref === undefined ? [[]] : [[{ ref: `refs/tags/${tag}`, object: ref }]]; + } else if (path.startsWith('git/tags/')) { + const sha = path.slice('git/tags/'.length); + response = { object: annotated.get(sha) }; + } else if (path.startsWith('contents/package.json?ref=')) { + const sha = decodeURIComponent(path.slice('contents/package.json?ref='.length)); + response = packages.get(sha); + } else { + throw new Error(`Unexpected fixture endpoint: ${path}`); + } + return { stdout: JSON.stringify(response) }; + }; + return { run, calls }; +} + +function packagesFor(releases, desktopVersions = new Map()) { + return new Map(releases.map(release => [ + release.target_commitish, + { + name: PACKAGE_NAME, + version: release.tag_name.slice(1), + desktopVersion: desktopVersions.get(release.id) ?? '0.2.3', + }, + ])); +} + +function refsFor(releases) { + return new Map(releases.map(release => [ + release.tag_name, + { type: 'commit', sha: release.target_commitish }, + ])); +} + +test('collects every published page across the 100-release boundary and performs read-only pinned mappings', async () => { + const releases = Array.from({ length: 101 }, (_, index) => releaseFor(index + 1)); + const { run, calls } = fixtureRunner({ + pages: [releases.slice(0, 100), releases.slice(100)], + tagRefs: refsFor(releases), + packages: packagesFor(releases, new Map([ + [1, '0.2.5'], + [2, '0.2.4'], + [3, '0.2.5'], + ])), + }); + const result = await collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run, now: () => 0 }); + assert.equal(result.historyComplete, true); + assert.equal(result.priorPublished.length, 101); + assert.deepEqual(result.priorPublished.slice(0, 3).map(({ desktopVersion }) => desktopVersion), + ['0.2.5', '0.2.4', '0.2.5']); + assert.deepEqual(Object.keys(result.priorPublished[0]).sort(), + ['commit', 'desktopVersion', 'id', 'productVersion', 'publishedAt', 'tag']); + assert.equal(calls.filter(call => call.args[3].includes('/releases?')).length, 2); + assert.equal(calls.some(call => call.args.includes('--method')), false); +}); + +test('combines stable and beta channels while preserving historical desktop-version order', async () => { + const releases = [ + releaseFor(1, { productVersion: '2.0.0-beta.1' }), + releaseFor(2, { productVersion: '2.0.0', target_commitish: 'main' }), + releaseFor(3, { productVersion: '2.0.0-beta.2' }), + ]; + const stableCommit = commitFor(22); + const tagRefs = refsFor(releases); + tagRefs.set(releases[1].tag_name, { type: 'commit', sha: stableCommit }); + const packages = packagesFor(releases, new Map([[1, '0.2.3'], [2, '0.2.1'], [3, '0.2.4']])); + packages.delete('main'); + packages.set(stableCommit, { + name: PACKAGE_NAME, + version: releases[1].tag_name.slice(1), + desktopVersion: '0.2.1', + }); + const { run } = fixtureRunner({ + pages: [releases], + tagRefs, + packages, + }); + const result = await collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run, now: () => 0 }); + assert.deepEqual(result.priorPublished.map(({ productVersion, desktopVersion }) => [productVersion, desktopVersion]), [ + ['2.0.0-beta.1', '0.2.3'], + ['2.0.0', '0.2.1'], + ['2.0.0-beta.2', '0.2.4'], + ]); +}); + +test('ignores only actual drafts and rejects malformed or repeated published records', async () => { + const published = releaseFor(1); + const draft = releaseFor(2, { draft: true, id: undefined, tag_name: undefined, target_commitish: undefined }); + const fixture = fixtureRunner({ + pages: [[draft, published]], + tagRefs: refsFor([published]), + packages: packagesFor([published]), + }); + const result = await collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: fixture.run, now: () => 0 }); + assert.equal(result.priorPublished.length, 1); + + for (const page of [ + [releaseFor(1), releaseFor(1, { tag_name: 'v2.0.0-beta.2', target_commitish: commitFor(2) })], + [releaseFor(1, { id: 0 })], + [releaseFor(1, { tag_name: '2.0.0-beta.1' })], + [releaseFor(1, { published_at: null })], + ]) { + const bad = fixtureRunner({ pages: [page] }); + await assert.rejects(collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: bad.run, now: () => 0 })); + } +}); + +test('fails closed for missing tag, commit, package, and product mappings', async () => { + const release = releaseFor(1); + const missingTag = fixtureRunner({ + pages: [[release]], + packages: packagesFor([release]), + }); + await assert.rejects(collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: missingTag.run, now: () => 0 }), /tag/i); + + const wrongPackage = fixtureRunner({ + pages: [[release]], + tagRefs: refsFor([release]), + packages: new Map([[release.target_commitish, { + name: PACKAGE_NAME, + version: '2.0.0-beta.2', + desktopVersion: '0.2.3', + }]]), + }); + await assert.rejects(collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: wrongPackage.run, now: () => 0 }), /match/i); + + const missingCommit = fixtureRunner({ + pages: [[release]], + tagRefs: new Map([[release.tag_name, { type: 'tag', sha: commitFor(2) }]]), + }); + await assert.rejects(collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: missingCommit.run, now: () => 0 }), /malformed|response|tag/i); + + const wrongName = fixtureRunner({ + pages: [[release]], + tagRefs: refsFor([release]), + packages: new Map([[release.target_commitish, { + name: 'other-package', + version: release.tag_name.slice(1), + desktopVersion: '0.2.3', + }]]), + }); + await assert.rejects(collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: wrongName.run, now: () => 0 }), /package name/i); +}); + +test('resolveReleaseTag handles URL encoding, lightweight and absent tags', async () => { + const expectedCommit = commitFor(1); + const calls = []; + const api = async (path, args) => { + calls.push({ path, args }); + return [[{ + ref: 'refs/tags/v2.0.0-beta/1', + object: { type: 'commit', sha: expectedCommit }, + }]]; + }; + assert.deepEqual(await resolveReleaseTag({ + tag: 'v2.0.0-beta/1', + expectedCommit, + }, api), { commit: expectedCommit, referenceSha: expectedCommit }); + assert.match(calls[0].path, /%2F/); + assert.deepEqual(await resolveReleaseTag({ + tag: 'v2.0.0-beta/2', + expectedCommit, + allowAbsent: true, + }, async () => [[]]), null); + await assert.rejects(resolveReleaseTag({ + tag: 'v2.0.0-beta/2', + expectedCommit, + }, async () => [[]]), /missing/i); +}); + +test('resolveReleaseTag resolves annotated tags and rejects cycles and excessive depth', async () => { + const expectedCommit = commitFor(99); + const initialSha = commitFor(100); + const chain = new Map(); + for (let index = 100; index < 110; index += 1) { + chain.set(commitFor(index), index === 109 + ? { type: 'commit', sha: expectedCommit } + : { type: 'tag', sha: commitFor(index + 1) }); + } + const api = async path => path.startsWith('git/matching-refs/') + ? [[{ ref: 'refs/tags/v2.0.0-beta.1', object: { type: 'tag', sha: initialSha } }]] + : { object: chain.get(path.slice('git/tags/'.length)) }; + assert.deepEqual(await resolveReleaseTag({ + tag: 'v2.0.0-beta.1', + expectedCommit, + }, api), { commit: expectedCommit, referenceSha: initialSha }); + + const cycleApi = async path => path.startsWith('git/matching-refs/') + ? [[{ ref: 'refs/tags/v2.0.0-beta.1', object: { type: 'tag', sha: initialSha } }]] + : { object: { type: 'tag', sha: initialSha } }; + await assert.rejects(resolveReleaseTag({ + tag: 'v2.0.0-beta.1', + expectedCommit, + }, cycleApi), /cyclic/i); + + const tooDeep = new Map(); + for (let index = 100; index < 111; index += 1) { + tooDeep.set(commitFor(index), { type: 'tag', sha: commitFor(index + 1) }); + } + const depthApi = async path => path.startsWith('git/matching-refs/') + ? [[{ ref: 'refs/tags/v2.0.0-beta.1', object: { type: 'tag', sha: initialSha } }]] + : { object: tooDeep.get(path.slice('git/tags/'.length)) }; + await assert.rejects(resolveReleaseTag({ + tag: 'v2.0.0-beta.1', + expectedCommit, + }, depthApi), /depth/i); +}); + +test('enforces per-request and overall deadlines before claiming complete history', async () => { + let perRequestTime = 0; + const perRequest = fixtureRunner({ pages: [[]] }); + const perRequestClock = () => { + if (perRequestTime === 0) { + perRequestTime += 1; + return 0; + } + perRequestTime += 30_001; + return perRequestTime; + }; + await assert.rejects( + collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { run: perRequest.run, now: perRequestClock }), + /deadline/i, + ); + + const page = Array.from({ length: 100 }, (_, index) => releaseFor(index + 1, { draft: true })); + const overall = fixtureRunner({ pages: [page, []] }); + let overallCalls = 0; + await assert.rejects( + collectPublishedDesktopHistory({ repo: REPOSITORY_SLUG }, { + run: overall.run, + now: () => overallCalls++ === 3 ? 300_000 : 0, + }), + /overall|deadline/i, + ); +}); diff --git a/scripts/release/updater-signature.mjs b/scripts/release/updater-signature.mjs new file mode 100644 index 00000000..9cd69936 --- /dev/null +++ b/scripts/release/updater-signature.mjs @@ -0,0 +1,105 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { releaseCommand } from './local-release-command.mjs'; +import { UPDATER_ASSET_LIMITS } from './updater-artifacts.mjs'; + +export async function readUpdaterSidecar(path, limit) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > UPDATER_ASSET_LIMITS.maxManifestBytes) { + throw new Error('Updater sidecar limit must fit the bounded metadata budget.'); + } + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const metadata = await file.stat(); + if (!metadata.isFile() || metadata.size < 1 || metadata.size > limit) { + throw new Error('Expected a nonempty bounded regular sidecar file.'); + } + const chunks = []; + let size = 0; + for await (const chunk of file.createReadStream({ autoClose: false })) { + size += chunk.length; + if (size > limit) throw new Error('Sidecar exceeded its streaming size limit.'); + chunks.push(chunk); + } + if (size === 0) throw new Error('Sidecar became empty while reading.'); + return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); + } finally { + await file.close(); + } +} + +function decodeTauriText(value, label) { + if (typeof value !== 'string' || Buffer.byteLength(value) > UPDATER_ASSET_LIMITS.maxSignatureBytes) { + throw new Error(`${label} must be bounded Tauri base64 text.`); + } + const encoded = value.trim(); + const decoded = Buffer.from(encoded, 'base64'); + if (!encoded || decoded.toString('base64') !== encoded) { + throw new Error(`${label} is not canonical base64.`); + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(decoded); + } catch { + throw new Error(`${label} must contain UTF-8 Minisign text.`); + } +} + +async function snapshotArchive(source, destination) { + const input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + let output; + try { + const metadata = await input.stat(); + if (!metadata.isFile() || metadata.size < 1 || metadata.size > UPDATER_ASSET_LIMITS.maxArchiveBytes) { + throw new Error('Updater archive must be a nonempty regular file within the archive size limit.'); + } + output = await open(destination, 'wx', 0o600); + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of input.createReadStream({ autoClose: false })) { + size += chunk.length; + if (size > UPDATER_ASSET_LIMITS.maxArchiveBytes) throw new Error('Updater archive exceeded the streaming size limit.'); + hash.update(chunk); + await output.writeFile(chunk); + } + if (size === 0) throw new Error('Updater archive became empty while snapshotting.'); + await output.sync(); + return { sha256: hash.digest('hex'), size }; + } finally { + await output?.close(); + await input.close(); + } +} + +/** + * Verify a private snapshot with the official Minisign CLI, not a format-only + * check. Consumers must inspect/extract the returned archivePath, never the + * mutable source. The caller owns root and its eventual cleanup. No app is + * installed, no private signing key is accepted, and no signing occurs here. + */ +export async function verifyUpdaterSignature({ archivePath, signature, publicKey, root, expectedSha256 }, { + run = releaseCommand, + minisign = 'minisign', +} = {}) { + if (!/^[a-f0-9]{64}$/.test(expectedSha256 ?? '')) throw new Error('An independently pinned archive SHA-256 is required.'); + const decodedKey = decodeTauriText(publicKey, 'Updater public key'); + const decodedSignature = decodeTauriText(signature, 'Updater signature'); + const work = await mkdtemp(join(root, 'updater-signature-')); + try { + const verifiedArchive = join(work, 'verified.app.tar.gz'); + const keyPath = join(work, 'updater.pub'); + const signaturePath = join(work, 'updater.minisig'); + const identity = await snapshotArchive(archivePath, verifiedArchive); + if (identity.sha256 !== expectedSha256) throw new Error('Updater archive does not match its independently pinned SHA-256.'); + await writeFile(keyPath, decodedKey, { flag: 'wx', mode: 0o600 }); + await writeFile(signaturePath, decodedSignature, { flag: 'wx', mode: 0o600 }); + // -H forbids legacy unprehashed signatures. Missing CLI or any verification + // failure throws through releaseCommand; there is no cryptographic fallback. + await run(minisign, ['-V', '-H', '-m', verifiedArchive, '-p', keyPath, '-x', signaturePath]); + return { archivePath: verifiedArchive, ...identity }; + } catch (error) { + await rm(work, { recursive: true, force: true }); + throw error; + } +} diff --git a/scripts/release/updater-signature.test.mjs b/scripts/release/updater-signature.test.mjs new file mode 100644 index 00000000..4b46e876 --- /dev/null +++ b/scripts/release/updater-signature.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, open, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { UPDATER_ASSET_LIMITS } from './updater-artifacts.mjs'; +import { readUpdaterSidecar, verifyUpdaterSignature } from './updater-signature.mjs'; + +const encoded = text => Buffer.from(text).toString('base64'); +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'gajae-updater-signature-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const archivePath = join(root, 'source.app.tar.gz'); + const bytes = Buffer.alloc(150_000, 0x61); + await writeFile(archivePath, bytes); + return { root, archivePath, bytes, expectedSha256: createHash('sha256').update(bytes).digest('hex'), + publicKey: encoded('public-key syntax fixture; CLI validates crypto'), signature: encoded('signature syntax fixture; CLI validates crypto') }; +} + +test('verification binds the hash and returned private snapshot, not a mutable source path', async t => { + const input = await fixture(t); + let invocation; + const result = await verifyUpdaterSignature(input, { run: async (program, args) => { + invocation = { program, args }; + assert.equal(program, 'minisign'); + assert.deepEqual(args.slice(0, 3), ['-V', '-H', '-m']); + assert.notEqual(args[3], input.archivePath); + assert.equal((await stat(args[3])).mode & 0o777, 0o600); + assert.equal((await stat(args[5])).mode & 0o777, 0o600); + assert.equal((await stat(args[7])).mode & 0o777, 0o600); + assert.equal(await readFile(args[5], 'utf8'), Buffer.from(input.publicKey, 'base64').toString()); + assert.equal(await readFile(args[7], 'utf8'), Buffer.from(input.signature, 'base64').toString()); + await writeFile(input.archivePath, 'source changed after snapshot'); + assert.deepEqual(await readFile(args[3]), input.bytes); + return { stdout: '', stderr: '' }; + } }); + assert.equal(result.archivePath, invocation.args[3]); + assert.equal(result.sha256, input.expectedSha256); + assert.equal(result.size, input.bytes.length); + assert.deepEqual(await readFile(result.archivePath), input.bytes); +}); + +test('hash disagreement and failed verifier remove only their private work directory', async t => { + const input = await fixture(t); + let calls = 0; + const run = async () => { calls++; throw new Error('verifier rejected the signature'); }; + await assert.rejects(verifyUpdaterSignature({ ...input, expectedSha256: '0'.repeat(64) }, { run }), /pinned SHA-256/); + assert.equal(calls, 0); + await assert.rejects(verifyUpdaterSignature(input, { run }), /verifier rejected/); + assert.equal(calls, 1); + assert.deepEqual(await readdir(input.root), ['source.app.tar.gz']); + assert.deepEqual(await readFile(input.archivePath), input.bytes); +}); + +test('malformed, oversized and invalid UTF-8 sidecars are rejected before invoking a verifier', async t => { + const input = await fixture(t); + const run = async () => assert.fail('invalid sidecar reached the verifier'); + for (const value of ['%%%=', '', 'Zg', 'Zm9v!', 'A'.repeat(UPDATER_ASSET_LIMITS.maxSignatureBytes + 1), '/w==', null]) { + for (const field of ['publicKey', 'signature']) { + await assert.rejects(verifyUpdaterSignature({ ...input, [field]: value }, { run }), /base64|UTF-8/); + } + } + await assert.rejects(verifyUpdaterSignature({ ...input, expectedSha256: undefined }, { run }), /pinned archive SHA-256/); + assert.deepEqual(await readdir(input.root), ['source.app.tar.gz']); +}); + +test('symlink, empty and oversized source archives never reach cryptographic verification', async t => { + const input = await fixture(t); + const link = join(input.root, 'link.app.tar.gz'); + await symlink(input.archivePath, link); + const run = async () => assert.fail('invalid archive reached the verifier'); + await assert.rejects(verifyUpdaterSignature({ ...input, archivePath: link }, { run })); + await writeFile(input.archivePath, ''); + await assert.rejects(verifyUpdaterSignature(input, { run }), /nonempty regular file/); + const handle = await open(input.archivePath, 'w'); + try { await handle.truncate(UPDATER_ASSET_LIMITS.maxArchiveBytes + 1); } finally { await handle.close(); } + await assert.rejects(verifyUpdaterSignature(input, { run }), /archive size limit/); + assert.ok(!(await readdir(input.root)).some(name => name.startsWith('updater-signature-'))); +}); + +test('sidecar reads enforce exact byte bounds, regular files and fatal UTF-8', async t => { + const { root } = await fixture(t); + const sidecar = join(root, 'sidecar'); + await writeFile(sidecar, '한'); + assert.equal(await readUpdaterSidecar(sidecar, 3), '한'); + await assert.rejects(readUpdaterSidecar(sidecar, 2), /bounded regular/); + await writeFile(sidecar, Buffer.from([0xff])); + await assert.rejects(readUpdaterSidecar(sidecar, 3), /encoded data/); + await writeFile(sidecar, ''); + await assert.rejects(readUpdaterSidecar(sidecar, 3), /nonempty/); + const link = join(root, 'sidecar-link'); + await symlink(sidecar, link); + await assert.rejects(readUpdaterSidecar(link, 3)); + await assert.rejects(readUpdaterSidecar(root, 3), /regular/); + await assert.rejects(readUpdaterSidecar(sidecar, Infinity), /budget/); +}); + +test('FIFO inputs fail without blocking a filesystem worker', { skip: process.platform === 'win32' }, async t => { + const input = await fixture(t); + const fifo = join(input.root, 'fifo'); + assert.equal(spawnSync('mkfifo', [fifo], { timeout: 1000 }).status, 0); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import { readUpdaterSidecar, verifyUpdaterSignature } from ${JSON.stringify(new URL('./updater-signature.mjs', import.meta.url).href)}; + await assert.rejects(readUpdaterSidecar(process.env.FIFO, 64), /regular/); + await assert.rejects(verifyUpdaterSignature({ + archivePath: process.env.FIFO, root: process.env.ROOT, + publicKey: 'Zm9v', signature: 'Zm9v', expectedSha256: '0'.repeat(64) + }), /regular/); + `], { env: { ...process.env, FIFO: fifo, ROOT: input.root }, encoding: 'utf8', timeout: 2000 }); + assert.equal(child.status, 0, child.error?.message ?? child.stderr); +}); diff --git a/shared/fixtures/desktop-update-manifest.json b/shared/fixtures/desktop-update-manifest.json new file mode 100644 index 00000000..5f2a1517 --- /dev/null +++ b/shared/fixtures/desktop-update-manifest.json @@ -0,0 +1,19 @@ +{ + "version": "0.2.4", + "notes": "A signed release fixture.", + "pub_date": "2026-09-06T00:00:00Z", + "platforms": { + "darwin-aarch64": { + "url": "https://github.com/devswha/gajae-code-app/releases/download/v2.0.0-beta.10/gajae-app-desktop-2.0.0-beta.10-macos-arm64.app.tar.gz", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + "productVersion": "2.0.0-beta.10", + "channel": "beta", + "minimumSystemVersion": "13.0", + "repository": "devswha/gajae-code-app", + "build": { + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target": "aarch64-apple-darwin" + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13393b48..ed3503d0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -324,6 +324,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -431,6 +448,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -827,6 +853,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -995,14 +1031,27 @@ dependencies = [ name = "gajae-app-desktop" version = "0.2.3" dependencies = [ + "base64 0.22.1", + "flate2", "fs2", + "futures-util", "getrandom 0.2.17", + "libc", + "minisign-verify", + "plist", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "semver", "serde", "serde_json", + "sha2", + "tar", "tauri", "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-shell", + "tauri-plugin-updater", "tauri-runtime", "tauri-runtime-wry", "tokio", @@ -1135,8 +1184,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1147,10 +1198,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1422,6 +1487,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1728,6 +1809,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -1743,6 +1830,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -1812,6 +1905,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2084,6 +2183,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2174,6 +2285,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2528,6 +2653,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -2543,6 +2724,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.7.3" @@ -2554,7 +2741,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -2568,6 +2755,17 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2606,6 +2804,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -2624,6 +2828,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2693,16 +2906,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2712,6 +2930,21 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] @@ -2745,6 +2978,54 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3016,7 +3297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3227,6 +3508,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3331,6 +3618,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3507,6 +3805,37 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cd78a6cbd1255e989e96eedec004e9e8949e6c6359b41f861279aba64ea306" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.59.0", +] + [[package]] name = "tauri-runtime" version = "2.7.0" @@ -3607,6 +3936,19 @@ dependencies = [ "toml 0.9.5", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -3737,6 +4079,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4018,6 +4370,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" @@ -4210,6 +4568,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -4266,6 +4634,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4818,6 +5195,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -4837,3 +5224,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f6c8b4d3..115110b0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,16 +9,41 @@ rust-version = "1.85" build = "build.rs" [build-dependencies] +rustls-webpki = { version = "=0.103.15", default-features = false, features = ["std"] } +rustls-pki-types = "=1.15.1" +base64 = "=0.22.1" +libc = "=0.2.186" +semver = "=1.0.26" +sha2 = "=0.10.9" +serde_json = "=1.0.140" tauri-build = { version = "=2.3.0", features = [] } [dependencies] tauri = { version = "=2.6.0", features = [] } getrandom = "0.2" fs2 = "=0.4.3" +libc = "=0.2.186" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "=0.10.9" tauri-plugin-shell = "=2.3.0" tauri-plugin-deep-link = "=2.3.0" tokio = { version = "1", features = ["sync", "time"] } tauri-runtime = "=2.7.0" tauri-runtime-wry = "=2.7.0" + +[target.'cfg(target_os = "macos")'.dependencies] +rustls-webpki = { version = "=0.103.15", default-features = false, features = ["std"] } +rustls-pki-types = "=1.15.1" +# Pin the updater release selected for the existing Tauri 2.6/runtime +# 2.7 graph. Keep the native reqwest client on the same minor so its +# no-redirect/HTTPS/timeout policy uses the API selected by the plugin. +tauri-plugin-updater = { version = "=2.6.0", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "=0.12.28", default-features = false, features = ["rustls-tls"] } +base64 = "=0.22.1" +futures-util = "=0.3.32" +minisign-verify = "=0.2.5" +semver = "=1.0.26" +tar = "=0.4.46" +flate2 = "=1.1.9" +plist = "=1.7.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0f5b39cc..c06bc729 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,34 +1,122 @@ +#[path = "update_build_binding.rs"] +mod update_build_binding; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sha2::{Digest, Sha256}; use std::{env, fs, path::PathBuf}; fn main() { println!("cargo:rerun-if-changed=../package.json"); + println!("cargo:rerun-if-changed=../server/gjc-runtime-manifest.json"); + println!("cargo:rerun-if-changed=update_build_binding.rs"); + for name in update_build_binding::INPUT_ENV_NAMES { + println!("cargo:rerun-if-env-changed={name}"); + } let package_json = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing manifest directory")) .join("../package.json"); - let package = fs::read_to_string(&package_json) + let package_text = fs::read_to_string(&package_json) .unwrap_or_else(|error| panic!("failed to read {}: {error}", package_json.display())); - let desktop_version = json_string_field(&package, "desktopVersion").unwrap_or_else(|| { - panic!( - "{} must contain a desktopVersion string", - package_json.display() - ) - }); + let package_value: serde_json::Value = + serde_json::from_str(&package_text).unwrap_or_else(|error| { + panic!("failed to parse {}: {error}", package_json.display()); + }); + let package = update_build_binding::PackageMetadata::from_json(&package_value) + .unwrap_or_else(|error| panic!("invalid {}: {error}", package_json.display())); assert_eq!( - desktop_version, + package.desktop_version, env::var("CARGO_PKG_VERSION").expect("missing Cargo package version"), "src-tauri/Cargo.toml package.version must match package.json desktopVersion" ); - tauri_build::build() -} + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let temp_root = (target_os == "macos") + .then(|| fs::canonicalize(env::temp_dir()).ok()) + .flatten(); + let inputs = update_build_binding::BuildInputs::from_env( + target_os, + env::var("PROFILE").is_ok_and(|profile| profile == "debug"), + temp_root, + ) + .unwrap_or_else(|error| panic!("invalid updater build inputs: {error}")); + let binding = update_build_binding::validate(&package, &inputs) + .unwrap_or_else(|error| panic!("invalid updater build binding: {error}")); + let qa_ca = match binding.qa_root.as_deref() { + Some(root) => { + println!( + "cargo:rerun-if-changed={}", + root.join("updater-ca.pem").display() + ); + STANDARD.encode( + update_build_binding::read_qa_certificate(root) + .expect("QA updater HTTPS certificate is required"), + ) + } + None => String::new(), + }; + println!("cargo:rustc-env=GJC_UPDATE_QA_CA_CERT={qa_ca}"); -fn json_string_field<'a>(json: &'a str, key: &str) -> Option<&'a str> { - let key = format!("\"{key}\""); - let (_, value) = json.split_once(&key)?; - let value = value.trim_start().strip_prefix(':')?.trim_start(); - let value = value.strip_prefix('"')?; - let end = value.find('"')?; - Some(&value[..end]) + println!( + "cargo:rustc-env=GJC_EXPECTED_PAYLOAD_VERSION={}", + package.product_version + ); + println!("cargo:rustc-env=GJC_UPDATE_MODE={}", binding.mode.as_str()); + println!( + "cargo:rustc-env=GJC_UPDATE_FEED_ORIGIN={}", + binding.feed_origin_value() + ); + println!( + "cargo:rustc-env=GJC_UPDATE_PUBKEY={}", + binding.pubkey_value() + ); + println!( + "cargo:rustc-env=GJC_UPDATE_QA_ROOT={}", + binding.qa_root_value() + ); + println!( + "cargo:rustc-env=GJC_UPDATE_KEY_FINGERPRINT={}", + binding.key_fingerprint_value() + ); + println!( + "cargo:rustc-env=GJC_UPDATE_REPOSITORY={}", + binding.repository + ); + println!( + "cargo:rustc-env=GJC_UPDATE_ARTIFACT_PREFIX={}", + binding.artifact_prefix + ); + println!("cargo:rustc-env=GJC_UPDATE_PACKAGE_NAME={}", package.name); + let product_name = package_value["build"]["productName"] + .as_str() + .expect("package.json build.productName is required"); + assert!(!product_name.chars().any(char::is_control)); + println!("cargo:rustc-env=GJC_UPDATE_PRODUCT_NAME={product_name}"); + let identifier = package_value["build"]["appId"] + .as_str() + .expect("package build.appId is required"); + assert!(!identifier.chars().any(char::is_control)); + println!("cargo:rustc-env=GJC_UPDATE_BUNDLE_IDENTIFIER={identifier}"); + println!( + "cargo:rustc-env=GJC_EXPECTED_PAYLOAD_PACKAGE_NAME={}", + package.name + ); + let runtime_manifest = fs::read( + package_json + .parent() + .unwrap() + .join("server/gjc-runtime-manifest.json"), + ) + .expect("source runtime manifest is required for independent payload binding"); + assert!( + !runtime_manifest.is_empty() && runtime_manifest.len() <= 64 * 1024, + "source runtime manifest is empty or oversized" + ); + println!( + "cargo:rustc-env=GJC_EXPECTED_RUNTIME_MANIFEST_SHA256={:x}", + Sha256::digest(&runtime_manifest) + ); + + tauri_build::build() } diff --git a/src-tauri/examples/instance_probe.rs b/src-tauri/examples/instance_probe.rs new file mode 100644 index 00000000..cca684f5 --- /dev/null +++ b/src-tauri/examples/instance_probe.rs @@ -0,0 +1,179 @@ +//! Disposable cross-process probe for the macOS instance flock. +//! +//! Protocol: +//! instance_probe hold +//! stdout: `acquired\n`; stdin command: `release\n`; stdout: `released\n`. +//! instance_probe try [timeout-ms] +//! stdout: `acquired\n` or `contended\n`; exit 0 or 2 respectively. +//! +//! The root must already be a private, real directory below the system +//! temporary directory. This probe never launches the app/server and never +//! removes the lock path. +#[cfg(target_os = "macos")] +#[path = "../src/macos_instance.rs"] +mod macos_instance; + +#[cfg(target_os = "macos")] +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +#[cfg(not(target_os = "macos"))] +use std::process::ExitCode; +#[cfg(target_os = "macos")] +use std::{ + env, fs, + io::{self, BufRead, Write}, + path::{Path, PathBuf}, + process::ExitCode, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "macos")] +const DEFAULT_TRY_TIMEOUT: Duration = Duration::from_millis(250); +#[cfg(target_os = "macos")] +const MAX_TRY_TIMEOUT: Duration = Duration::from_secs(5); + +#[cfg(target_os = "macos")] +fn effective_uid() -> u32 { + unsafe { libc::geteuid() } +} + +#[cfg(target_os = "macos")] +fn private_temp_root(raw: &str) -> Result { + // Strip lexical trailing separators and `/.` before lstat; otherwise + // macOS follows a final symlink when a directory path ends in `/`. + let candidate: PathBuf = Path::new(raw).components().collect(); + if !candidate.is_absolute() { + return Err("root must be an absolute private temporary directory".into()); + } + let metadata = fs::symlink_metadata(&candidate) + .map_err(|error| format!("could not inspect probe root: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("root must be a real directory, not a symlink".into()); + } + #[cfg(unix)] + { + if metadata.uid() != effective_uid() { + return Err("root must be owned by the current user".into()); + } + if metadata.permissions().mode() & 0o7777 != 0o700 { + return Err("root permissions must be exactly 0700".into()); + } + } + let temp = fs::canonicalize(env::temp_dir()) + .map_err(|error| format!("could not locate system temporary directory: {error}"))?; + let root = candidate + .canonicalize() + .map_err(|error| format!("could not canonicalize probe root: {error}"))?; + if root == temp || !root.starts_with(&temp) { + return Err("root must be below the system temporary directory".into()); + } + Ok(root) +} + +#[cfg(target_os = "macos")] +fn acquire_error(error: macos_instance::LockError) -> ExitCode { + if error.is_contended() { + println!("contended"); + ExitCode::from(2) + } else { + eprintln!("error: {error}"); + ExitCode::from(1) + } +} + +#[cfg(target_os = "macos")] +fn hold(root: &Path) -> ExitCode { + let lock = match macos_instance::acquire(&root.join("desktop.lock")) { + Ok(lock) => lock, + Err(error) => return acquire_error(error), + }; + println!("acquired"); + io::stdout().flush().expect("flush acquisition marker"); + let mut command = String::new(); + let read = io::stdin().lock().read_line(&mut command); + match read { + Ok(_) if command.trim_end_matches(&['\r', '\n'][..]) == "release" => { + println!("released"); + io::stdout().flush().expect("flush release marker"); + drop(lock); + ExitCode::SUCCESS + } + Ok(_) => { + eprintln!("error: expected the release command"); + drop(lock); + ExitCode::from(1) + } + Err(error) => { + eprintln!("error: could not read release command: {error}"); + drop(lock); + ExitCode::from(1) + } + } +} + +#[cfg(target_os = "macos")] +fn try_acquire(root: &Path, timeout: Duration) -> ExitCode { + match macos_instance::acquire_until(&root.join("desktop.lock"), Instant::now() + timeout) { + Ok(lock) => { + println!("acquired"); + io::stdout().flush().expect("flush acquisition marker"); + drop(lock); + ExitCode::SUCCESS + } + Err(error) => acquire_error(error), + } +} + +#[cfg(target_os = "macos")] +fn usage() -> ! { + eprintln!( + "usage: instance_probe hold | instance_probe try [timeout-ms]" + ); + std::process::exit(1); +} + +#[cfg(target_os = "macos")] +fn run() -> Result { + let mut args = env::args().skip(1); + let command = args.next().unwrap_or_else(|| usage()); + let raw_root = args.next().unwrap_or_else(|| usage()); + let root = private_temp_root(&raw_root)?; + let exit = match command.as_str() { + "hold" if args.next().is_none() => hold(&root), + "try" => { + let timeout = match args.next() { + None => DEFAULT_TRY_TIMEOUT, + Some(raw) if args.next().is_none() => { + let millis = raw + .parse::() + .map_err(|_| "timeout-ms must be an integer".to_owned())?; + let timeout = Duration::from_millis(millis); + if timeout.is_zero() || timeout > MAX_TRY_TIMEOUT { + return Err("timeout-ms must be between 1 and 5000".into()); + } + timeout + } + Some(_) => usage(), + }; + try_acquire(&root, timeout) + } + _ => usage(), + }; + Ok(exit) +} + +#[cfg(target_os = "macos")] +fn main() -> ExitCode { + match run() { + Ok(exit) => exit, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::from(1) + } + } +} + +#[cfg(not(target_os = "macos"))] +fn main() -> ExitCode { + eprintln!("instance_probe is supported only on macOS."); + ExitCode::from(1) +} diff --git a/src-tauri/examples/tauri.conf.json b/src-tauri/examples/tauri.conf.json new file mode 100644 index 00000000..69b9ea2f --- /dev/null +++ b/src-tauri/examples/tauri.conf.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Gajae Updater Probe", + "identifier": "app.gajae.updater.probe", + "build": { + "frontendDist": "../recovery" + }, + "app": { + "windows": [] + }, + "bundle": { + "active": false, + "icon": ["../icons/32x32.png"], + "macOS": { + "minimumSystemVersion": "13.0" + } + } +} diff --git a/src-tauri/examples/updater_probe.rs b/src-tauri/examples/updater_probe.rs new file mode 100644 index 00000000..1cf275b8 --- /dev/null +++ b/src-tauri/examples/updater_probe.rs @@ -0,0 +1,2163 @@ +//! Disposable macOS-only updater probe. +//! +//! This binary deliberately registers the official updater plugin only in this +//! example. It never uses the production application state, server, feed, or +//! keys. The parent process must create the fixture root and schema-2 marker +//! first. +//! The root marker is schema-versioned and must explicitly identify either a +//! `marker_only` observation fixture rooted at `A.app` or a `signed_bundle` +//! fixture rooted at the canonical `Gajae Code App.app`. +//! +//! The install scenario exercises the plugin's real `check` and +//! `Update::install` APIs. The native HTTPS client streams the official archive +//! URL through a hard cap, then the downloaded bytes are verified with the +//! maintained `minisign-verify` crate using the signature returned by the +//! official check; that exact buffer is passed to `install`. The plugin's +//! unbounded `download` API is intentionally not called. Native manifest and +//! archive requests and the plugin check use fixture-root `qa-ca.pem`; TLS +//! validation remains enabled and no invalid-certificate bypass is exposed. + +#[cfg(target_os = "macos")] +#[path = "../src/updater_transport.rs"] +// The probe uses bounded body helpers, not preparation redirect metadata. +#[allow(dead_code)] +mod updater_transport; + +#[cfg(target_os = "macos")] +mod macos_probe { + use std::{ + env, + fs::{self, File, FileType}, + io::{self, Read, Write}, + net::IpAddr, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + panic::{self, AssertUnwindSafe}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicI32, Ordering}, + mpsc, Arc, + }, + thread, + time::{Duration, Instant}, + }; + + use base64::{engine::general_purpose::STANDARD, Engine}; + use reqwest::{redirect::Policy, Url}; + use serde::Deserialize; + use serde_json::{json, Value}; + use tauri::{AppHandle, Context, RunEvent, Wry}; + use tauri_plugin_updater::{Update, UpdaterExt}; + + use super::updater_transport::{self, TransportError}; + + const PLUGIN_VERSION: &str = "2.6.0"; + const PROBE_IDENTIFIER: &str = "app.gajae.updater.probe"; + const PROBE_PRODUCT_NAME: &str = "Gajae Updater Probe"; + const ROOT_MARKER_NAME: &str = ".gajae-updater-probe-root"; + const ROOT_MARKER_PURPOSE: &str = "gajae-updater-probe"; + const ROOT_MARKER_FIXTURE: &str = "disposable-a-to-b"; + const ROOT_PREFIX: &str = "gajae-updater-probe-"; + // The marker-only fixture is intentionally a tiny disposable shell app. + // Signed fixtures use the same updater target name as release archives. + const MARKER_APP_DIR_NAME: &str = "A.app"; + const SIGNED_APP_DIR_NAME: &str = "Gajae Code App.app"; + const SIGNED_EXECUTABLE_BASENAME: &str = "gajae-app-desktop"; + const ARCHIVE_NAME: &str = "B.app.tar.gz"; + const MANIFEST_NAME: &str = "update.json"; + // This file stores the base64 public-key value expected by Tauri's + // updater config, not private key material. The decoded text is the + // standard two-line minisign public-key file. + const PUBLIC_KEY_NAME: &str = "qa-public.key"; + const CA_CERTIFICATE_NAME: &str = "qa-ca.pem"; + const APP_MARKER_RELATIVE: &str = "Contents/Resources/gajae-updater-probe.txt"; + const APP_MARKER_A: &[u8] = b"gajae-updater-probe/A\n"; + const APP_MARKER_B: &[u8] = b"gajae-updater-probe/B\n"; + const MAX_ROOT_MARKER_BYTES: u64 = 16 * 1024; + const MAX_APP_MARKER_BYTES: u64 = 1024; + const MAX_MANIFEST_BYTES: u64 = 64 * 1024; + const MAX_PUBLIC_KEY_BYTES: u64 = 16 * 1024; + const MAX_CA_CERTIFICATE_BYTES: u64 = 64 * 1024; + const MAX_ARCHIVE_BYTES: u64 = 250 * 1024 * 1024; + const MAX_NATIVE_MANIFEST_BYTES: u64 = 64 * 1024; + const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); + const TOTAL_TIMEOUT: Duration = Duration::from_secs(5); + const ARCHIVE_TIMEOUT: Duration = Duration::from_secs(60); + const INSTALL_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(100); + const INSTALL_HEARTBEAT_DEADLINE: Duration = Duration::from_secs(60); + + #[derive(Clone, Copy, Debug)] + enum Scenario { + Check, + Reconstruct, + Install, + } + + impl Scenario { + fn parse(value: &str) -> Result { + match value { + "check" => Ok(Self::Check), + "reconstruct" => Ok(Self::Reconstruct), + "install" => Ok(Self::Install), + _ => Err(ProbeError::new( + "invalid_arguments", + "--scenario must be check, reconstruct, or install", + )), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Check => "check", + Self::Reconstruct => "reconstruct", + Self::Install => "install", + } + } + + fn requires_install(self) -> bool { + matches!(self, Self::Install) + } + } + + #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "snake_case")] + enum ProofKind { + MarkerOnly, + SignedBundle, + } + + impl ProofKind { + fn as_str(self) -> &'static str { + match self { + Self::MarkerOnly => "marker_only", + Self::SignedBundle => "signed_bundle", + } + } + + fn requires_marker(self) -> bool { + matches!(self, Self::MarkerOnly) + } + } + + #[derive(Clone, Debug)] + struct Options { + root: PathBuf, + scenario: Scenario, + endpoint: Url, + expected_version: Option, + } + + #[derive(Debug)] + struct ProbeError { + code: &'static str, + message: String, + details: Value, + } + + impl ProbeError { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: Value::Null, + } + } + + fn with_details(mut self, details: Value) -> Self { + self.details = details; + self + } + + fn with_code(mut self, code: &'static str) -> Self { + self.code = code; + self + } + } + + #[derive(Clone, Debug)] + struct Fixture { + root: PathBuf, + proof_kind: ProofKind, + executable_basename: String, + current_desktop_version: String, + app_dir: PathBuf, + app_executable: PathBuf, + app_marker: Option, + archive_size: u64, + manifest: PathBuf, + public_key: PathBuf, + ca_certificate: PathBuf, + } + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct RootMarker { + schema: u32, + purpose: String, + root: String, + fixture: String, + proof_kind: ProofKind, + app: String, + archive: String, + manifest: String, + public_key: String, + executable_basename: String, + current_desktop_version: String, + } + + #[derive(Debug)] + struct InstallHeartbeat { + attempts: u64, + responsive: u64, + timeouts: u64, + max_latency_ms: u128, + elapsed_ms: u128, + stopped_reason: &'static str, + } + + struct InstallHeartbeatHandle { + stop: Arc, + join: thread::JoinHandle, + } + + impl InstallHeartbeat { + fn as_json(&self) -> Value { + json!({ + "attempts": self.attempts, + "responsive": self.responsive, + "timeouts": self.timeouts, + "max_latency_ms": self.max_latency_ms, + "elapsed_ms": self.elapsed_ms, + "observation_deadline_ms": INSTALL_HEARTBEAT_DEADLINE.as_millis(), + "stopped_reason": self.stopped_reason, + "first_timeout_stops_observation": true, + }) + } + } + + pub fn run() { + if !cfg!(target_arch = "aarch64") { + print_standalone_error(ProbeError::new( + "unsupported_arch", + "updater_probe is restricted to macOS arm64", + )); + } + let options = match parse_args(env::args_os().skip(1)) { + Ok(Some(options)) => options, + Ok(None) => return, + Err(error) => print_standalone_error(error), + }; + + let fixture = match load_fixture(&options.root) { + Ok(fixture) => fixture, + Err(error) => print_standalone_error(error), + }; + + if let Err(error) = validate_endpoint(&options.endpoint) { + print_fixture_error(error, &fixture); + } + + let key_config = match read_bounded_text(&fixture.public_key, MAX_PUBLIC_KEY_BYTES) { + Ok(key) => key.trim().to_owned(), + Err(error) => print_fixture_error(error.with_code("invalid_fixture"), &fixture), + }; + if key_config.is_empty() { + print_fixture_error( + ProbeError::new("invalid_fixture", "the QA public key is empty"), + &fixture, + ); + } + let key_text = match decode_public_key_config(&key_config) { + Ok(key_text) => key_text, + Err(error) => print_fixture_error(error, &fixture), + }; + if key_text.to_ascii_lowercase().contains("private") + || key_text.to_ascii_lowercase().contains("secret key") + { + print_fixture_error( + ProbeError::new( + "production_key_refused", + "the fixture key looks like private or secret key material", + ), + &fixture, + ); + } + if let Err(error) = minisign_verify::PublicKey::decode(&key_text) { + print_fixture_error( + ProbeError::new( + "invalid_fixture_key", + format!("QA public key text is not valid Minisign data: {error}"), + ), + &fixture, + ); + } + if let Err(error) = validate_manifest_fixture(&fixture, &options) { + print_fixture_error(error, &fixture); + } + + let mut context = probe_context(); + if let Err(error) = configure_probe_context( + &mut context, + &fixture.current_desktop_version, + &key_config, + &options.endpoint, + ) { + print_fixture_error(error, &fixture); + } + + let updater_plugin = tauri_plugin_updater::Builder::new() + .pubkey(key_config) + .build(); + let install_active = Arc::new(AtomicBool::new(false)); + let outcome_code = Arc::new(AtomicI32::new(2)); + let worker_outcome_code = Arc::clone(&outcome_code); + let worker_active = Arc::clone(&install_active); + let worker_options = options.clone(); + let worker_fixture = fixture.clone(); + + let app = match tauri::Builder::default() + // This registration is intentionally scoped to the standalone + // probe; production main.rs remains updater-free in P0. + .plugin(updater_plugin) + .setup(move |app| { + let app_handle = app.handle().clone(); + let run_options = worker_options.clone(); + let run_fixture = worker_fixture.clone(); + let active = Arc::clone(&worker_active); + thread::spawn(move || { + let result = execute(&app_handle, &run_options, &run_fixture, active.as_ref()); + let exit_code = match result { + Ok(value) => { + print_outcome(value); + 0 + } + Err(error) => { + print_outcome(error_outcome( + error, + Some(&run_options), + Some(&run_fixture), + )); + 1 + } + }; + worker_outcome_code.store(exit_code, Ordering::Release); + app_handle.exit(exit_code); + }); + Ok(()) + }) + .build(context) + { + Ok(app) => app, + Err(error) => print_fixture_error( + ProbeError::new( + "app_build_failed", + format!("failed to initialize the probe app: {error}"), + ), + &fixture, + ), + }; + + let exit_code = app.run_return(move |_, event| { + // A user quit request must not tear down the event thread while + // Update::install may be synchronously waiting for its official + // macOS authorization closure to run here. Apple events that + // bypass this preventable event remain a manual fault-injection + // branch and are reported by the resulting installer outcome. + if let RunEvent::ExitRequested { api, .. } = event { + if install_active.load(Ordering::Acquire) { + api.prevent_exit(); + } + } + }); + let outcome_code = outcome_code.load(Ordering::Acquire); + if exit_code != 0 || outcome_code != 0 { + std::process::exit(if exit_code != 0 { + exit_code + } else { + outcome_code + }); + } + } + + fn parse_args(args: I) -> Result, ProbeError> + where + I: IntoIterator, + { + let mut root = None; + let mut scenario = Scenario::Check; + let mut endpoint = None; + let mut expected_version = None; + let mut args = args.into_iter(); + + while let Some(argument) = args.next() { + let argument = argument.to_str().ok_or_else(|| { + ProbeError::new("invalid_arguments", "arguments must be valid UTF-8") + })?; + match argument { + "--help" | "-h" => { + print_usage(); + return Ok(None); + } + "--root" => { + root = Some(PathBuf::from(next_value(&mut args, "--root")?)); + } + "--scenario" => { + scenario = Scenario::parse(&next_value(&mut args, "--scenario")?)?; + } + "--endpoint" => { + let value = next_value(&mut args, "--endpoint")?; + endpoint = Some(value.parse::().map_err(|error| { + ProbeError::new( + "invalid_endpoint", + format!("--endpoint is not a valid URL: {error}"), + ) + })?); + } + "--expected-version" => { + let value = next_value(&mut args, "--expected-version")?; + if value.trim().is_empty() { + return Err(ProbeError::new( + "invalid_arguments", + "--expected-version cannot be empty", + )); + } + expected_version = Some(value); + } + _ if argument.starts_with('-') => { + return Err(ProbeError::new( + "invalid_arguments", + format!("unknown option `{argument}`"), + )); + } + _ => { + return Err(ProbeError::new( + "invalid_arguments", + format!("unexpected positional argument `{argument}`"), + )); + } + } + } + + let root = root.ok_or_else(|| { + ProbeError::new( + "invalid_arguments", + "--root is required; the probe never creates fixture roots", + ) + })?; + let endpoint = endpoint.ok_or_else(|| { + ProbeError::new( + "invalid_arguments", + "--endpoint is required and must be the local HTTPS manifest URL", + ) + })?; + if scenario.requires_install() && expected_version.is_none() { + return Err(ProbeError::new( + "invalid_arguments", + "--expected-version is required for --scenario install", + )); + } + + Ok(Some(Options { + root, + scenario, + endpoint, + expected_version, + })) + } + + fn next_value(args: &mut I, option: &str) -> Result + where + I: Iterator, + { + args.next() + .ok_or_else(|| ProbeError::new("invalid_arguments", format!("{option} needs a value"))) + .and_then(|value| { + value.to_str().map(str::to_owned).ok_or_else(|| { + ProbeError::new("invalid_arguments", format!("{option} must be UTF-8")) + }) + }) + } + + fn load_fixture(root_arg: &Path) -> Result { + let root_argument_metadata = fs::symlink_metadata(root_arg).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("cannot inspect --root before canonicalization: {error}"), + ) + })?; + if root_argument_metadata.file_type().is_symlink() { + return Err(ProbeError::new( + "fixture_symlink_refused", + "--root must not be a symlink", + )); + } + if !root_argument_metadata.is_dir() { + return Err(ProbeError::new( + "invalid_fixture", + "--root must be a directory", + )); + } + let root = fs::canonicalize(root_arg).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("cannot canonicalize --root: {error}"), + ) + })?; + let root_metadata = fs::symlink_metadata(&root).map_err(|error| { + ProbeError::new("invalid_fixture", format!("cannot inspect --root: {error}")) + })?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(ProbeError::new( + "invalid_fixture", + "--root must be a real directory, not a symlink", + )); + } + validate_fixture_root(&root)?; + + let root_marker_path = root.join(ROOT_MARKER_NAME); + ensure_regular(&root_marker_path, "root marker")?; + let marker_text = read_bounded_text(&root_marker_path, MAX_ROOT_MARKER_BYTES)?; + let marker: RootMarker = serde_json::from_str(&marker_text).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("root marker is not valid probe JSON: {error}"), + ) + })?; + if marker.schema != 2 + || marker.purpose != ROOT_MARKER_PURPOSE + || marker.fixture != ROOT_MARKER_FIXTURE + || marker.archive != ARCHIVE_NAME + || marker.manifest != MANIFEST_NAME + || marker.public_key != PUBLIC_KEY_NAME + { + return Err(ProbeError::new( + "invalid_fixture", + "root marker does not match the schema-2 disposable A-to-B probe contract", + )); + } + let expected_app_dir = expected_app_dir_name(marker.proof_kind); + validate_app_root_name(marker.proof_kind, &marker.app)?; + if marker.proof_kind == ProofKind::SignedBundle + && marker.executable_basename != SIGNED_EXECUTABLE_BASENAME + { + return Err(ProbeError::new( + "invalid_fixture", + format!("signed_bundle executable_basename must be {SIGNED_EXECUTABLE_BASENAME}"), + )); + } + validate_executable_basename(&marker.executable_basename)?; + validate_desktop_version(&marker.current_desktop_version)?; + let marker_root = PathBuf::from(&marker.root); + if !marker_root.is_absolute() || marker_root.as_path() != root { + return Err(ProbeError::new( + "fixture_root_binding_mismatch", + "root marker is not bound to this canonical fixture directory", + )); + } + + let app_dir = root.join(expected_app_dir); + let app_executable = app_dir + .join("Contents") + .join("MacOS") + .join(&marker.executable_basename); + let app_marker_path = app_dir.join(APP_MARKER_RELATIVE); + let archive = root.join(ARCHIVE_NAME); + let manifest = root.join(MANIFEST_NAME); + let public_key = root.join(PUBLIC_KEY_NAME); + let ca_certificate = root.join(CA_CERTIFICATE_NAME); + let app_contents = app_dir.join("Contents"); + let app_macos = app_contents.join("MacOS"); + ensure_directory(&app_dir, expected_app_dir)?; + ensure_directory(&app_contents, &format!("{expected_app_dir}/Contents"))?; + ensure_directory(&app_macos, &format!("{expected_app_dir}/Contents/MacOS"))?; + ensure_regular(&app_executable, "fixture app executable")?; + let app_marker = if marker.proof_kind.requires_marker() { + ensure_regular(&app_marker_path, "marker-only A.app fixture marker")?; + Some(app_marker_path) + } else { + None + }; + ensure_regular(&archive, "B.app archive")?; + ensure_regular(&manifest, "update manifest")?; + ensure_regular(&public_key, "QA public key")?; + ensure_regular(&ca_certificate, "QA CA certificate")?; + let mut fixture_paths = vec![ + &app_dir, + &app_executable, + &archive, + &manifest, + &public_key, + &ca_certificate, + ]; + if let Some(app_marker) = app_marker.as_ref() { + fixture_paths.push(app_marker); + } + for path in fixture_paths { + let canonical = fs::canonicalize(path).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!( + "cannot canonicalize fixture path {}: {error}", + path.display() + ), + ) + })?; + if !canonical.starts_with(&root) { + return Err(ProbeError::new( + "fixture_path_escape", + "fixture path escapes the bound root", + )); + } + } + if let Some(app_marker) = app_marker.as_ref() { + let marker_bytes = read_bounded_bytes(app_marker, MAX_APP_MARKER_BYTES)?; + if marker_bytes != APP_MARKER_A { + return Err(ProbeError::new( + "invalid_fixture", + "marker-only fixture marker must identify version A", + )); + } + } + + let archive_size = open_bounded_regular(&archive).and_then(|file| { + file.metadata() + .map(|metadata| metadata.len()) + .map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!( + "cannot fstat fixture archive {}: {error}", + archive.display() + ), + ) + }) + })?; + if archive_size == 0 || archive_size > MAX_ARCHIVE_BYTES { + return Err(ProbeError::new( + "invalid_fixture", + format!("B.app archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"), + )); + } + + Ok(Fixture { + root, + proof_kind: marker.proof_kind, + executable_basename: marker.executable_basename, + current_desktop_version: marker.current_desktop_version, + app_dir, + app_executable, + app_marker, + archive_size, + manifest, + public_key, + ca_certificate, + }) + } + + fn validate_executable_basename(name: &str) -> Result<(), ProbeError> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name.chars().any(char::is_whitespace) + { + return Err(ProbeError::new( + "invalid_fixture", + "executable_basename must be one non-empty path component without whitespace", + )); + } + Ok(()) + } + + fn expected_app_dir_name(proof_kind: ProofKind) -> &'static str { + match proof_kind { + ProofKind::MarkerOnly => MARKER_APP_DIR_NAME, + ProofKind::SignedBundle => SIGNED_APP_DIR_NAME, + } + } + + fn validate_app_root_name( + proof_kind: ProofKind, + app_root_name: &str, + ) -> Result<(), ProbeError> { + let expected = expected_app_dir_name(proof_kind); + if app_root_name != expected { + return Err(ProbeError::new( + "invalid_fixture", + format!( + "root marker app must be {expected} for proof_kind {}", + proof_kind.as_str() + ), + )); + } + Ok(()) + } + + fn set_context_version(context: &mut Context, version: &str) -> Result<(), ProbeError> { + context.package_info_mut().version = version.parse().map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("current_desktop_version is not accepted by Tauri: {error}"), + ) + })?; + Ok(()) + } + + // Expand the embedded Info.plist once, with a non-production identity. + fn probe_context() -> Context { + tauri::generate_context!("examples/tauri.conf.json") + } + + fn configure_probe_context( + context: &mut Context, + version: &str, + key_config: &str, + endpoint: &Url, + ) -> Result<(), ProbeError> { + set_context_version(context, version)?; + let config = context.config_mut(); + // The pinned runtime's automatic WindowConfig conversion can drop + // data_store_identifier. Avoid WebView creation entirely: this probe + // exercises only the event loop and official updater installer. + config.app.windows.clear(); + config.identifier = PROBE_IDENTIFIER.to_string(); + config.product_name = Some(PROBE_PRODUCT_NAME.to_string()); + config.plugins.0.insert( + "updater".to_string(), + json!({ + "pubkey": key_config, + "endpoints": [endpoint.as_str()], + "dangerousInsecureTransportProtocol": false + }), + ); + Ok(()) + } + + fn validate_desktop_version(version: &str) -> Result<(), ProbeError> { + semver::Version::parse(version) + .map(|_| ()) + .map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("current_desktop_version must be a semantic version: {error}"), + ) + }) + } + + fn validate_fixture_root(root: &Path) -> Result<(), ProbeError> { + let name = root + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + ProbeError::new("invalid_fixture_root", "fixture root has no UTF-8 name") + })?; + if !name.starts_with(ROOT_PREFIX) { + return Err(ProbeError::new( + "production_root_refused", + "fixture root must use the gajae-updater-probe- prefix", + )); + } + + let temp_dir = fs::canonicalize(env::temp_dir()).map_err(|error| { + ProbeError::new( + "invalid_fixture_root", + format!("cannot canonicalize the system temporary directory: {error}"), + ) + })?; + if root.parent() != Some(temp_dir.as_path()) { + return Err(ProbeError::new( + "production_root_refused", + "fixture root must be a direct child of the system temporary directory", + )); + } + + if let Some(home) = env::var_os("HOME") { + if let Ok(home) = fs::canonicalize(home) { + if root.starts_with(home) { + return Err(ProbeError::new( + "production_root_refused", + "fixture root must not be under the production home directory", + )); + } + } + } + for production_root in [ + Path::new("/Applications"), + Path::new("/System/Applications"), + ] { + if root.starts_with(production_root) { + return Err(ProbeError::new( + "production_root_refused", + "fixture root must not be an application installation path", + )); + } + } + let metadata = fs::symlink_metadata(root).map_err(|error| { + ProbeError::new( + "invalid_fixture_root", + format!("cannot inspect fixture root ownership and mode: {error}"), + ) + })?; + validate_fixture_owner_mode(metadata.uid(), metadata.mode() & 0o7777)?; + Ok(()) + } + + fn validate_fixture_owner_mode(uid: u32, mode: u32) -> Result<(), ProbeError> { + let effective_uid = unsafe { libc::geteuid() as u32 }; + if uid != effective_uid { + return Err(ProbeError::new( + "fixture_owner_refused", + "fixture root must be owned by the current effective user", + )); + } + if mode != 0o700 && mode != 0o500 { + return Err(ProbeError::new( + "fixture_mode_refused", + "fixture root permissions must be exactly 0700 or 0500", + )); + } + Ok(()) + } + + fn validate_install_volume(root: &Path, app_dir: &Path) -> Result<(), ProbeError> { + validate_fixture_root(root)?; + let root = ensure_path_type(root, "fixture root")?; + let app = ensure_path_type(app_dir, "fixture app")?; + let temporary = fs::metadata(env::temp_dir()).map_err(|error| { + ProbeError::new( + "invalid_fixture_root", + format!("cannot inspect installer temporary volume: {error}"), + ) + })?; + if !root.is_dir() + || !app.is_dir() + || !temporary.is_dir() + || root.dev() != temporary.dev() + || app.dev() != temporary.dev() + { + return Err(ProbeError::new( + "installation_volume_refused", + "fixture root, app and installer temporary directory must share one volume", + )); + } + Ok(()) + } + + fn ensure_directory(path: &Path, description: &str) -> Result<(), ProbeError> { + let metadata = ensure_path_type(path, description)?; + if !metadata.is_dir() { + return Err(ProbeError::new( + "invalid_fixture", + format!("{description} must be a directory"), + )); + } + Ok(()) + } + + fn ensure_regular(path: &Path, description: &str) -> Result<(), ProbeError> { + let metadata = ensure_path_type(path, description)?; + if !metadata.is_file() { + return Err(ProbeError::new( + "invalid_fixture", + format!("{description} must be a regular file"), + )); + } + Ok(()) + } + + fn ensure_path_type(path: &Path, description: &str) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("cannot inspect {description} {}: {error}", path.display()), + ) + })?; + let file_type: FileType = metadata.file_type(); + if file_type.is_symlink() { + return Err(ProbeError::new( + "fixture_symlink_refused", + format!("{description} must not be a symlink"), + )); + } + Ok(metadata) + } + + fn read_bounded_text(path: &Path, max_bytes: u64) -> Result { + String::from_utf8(read_bounded_bytes(path, max_bytes)?).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("{} is not valid UTF-8 text: {error}", path.display()), + ) + }) + } + + fn validate_endpoint(endpoint: &Url) -> Result<(), ProbeError> { + if endpoint.scheme() != "https" { + return Err(ProbeError::new( + "production_feed_refused", + "the probe accepts HTTPS endpoints only", + )); + } + if endpoint.port().is_none() { + return Err(ProbeError::new( + "production_feed_refused", + "the probe requires an explicit local HTTPS port", + )); + } + if endpoint.username() != "" || endpoint.password().is_some() { + return Err(ProbeError::new( + "production_feed_refused", + "endpoint credentials are never accepted", + )); + } + if endpoint.query().is_some() || endpoint.fragment().is_some() { + return Err(ProbeError::new( + "production_feed_refused", + "endpoint query strings and fragments are not accepted", + )); + } + if !is_loopback_host(endpoint.host_str()) { + return Err(ProbeError::new( + "production_feed_refused", + "the probe endpoint host must be localhost or a loopback IP", + )); + } + Ok(()) + } + + fn is_loopback_host(host: Option<&str>) -> bool { + let Some(host) = host else { + return false; + }; + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .map(|address| address.is_loopback()) + .unwrap_or(false) + } + + fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme().eq_ignore_ascii_case(right.scheme()) + && left + .host_str() + .zip(right.host_str()) + .map(|(left, right)| left.eq_ignore_ascii_case(right)) + .unwrap_or(false) + && left.port() == right.port() + } + + fn is_fixed_archive_url(endpoint: &Url, archive_url: &Url) -> bool { + same_origin(endpoint, archive_url) + && archive_url.path() == format!("/{ARCHIVE_NAME}") + && archive_url.query().is_none() + && archive_url.fragment().is_none() + } + + fn validate_manifest_fixture(fixture: &Fixture, options: &Options) -> Result<(), ProbeError> { + let text = read_bounded_text(&fixture.manifest, MAX_MANIFEST_BYTES)?; + let value: Value = serde_json::from_str(&text).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("update manifest is not valid JSON: {error}"), + ) + })?; + let version = value + .get("version") + .or_else(|| value.get("name")) + .and_then(Value::as_str) + .ok_or_else(|| { + ProbeError::new( + "invalid_fixture", + "update manifest needs a version or name field", + ) + })?; + if let Some(expected) = options.expected_version.as_deref() { + if version.trim_start_matches('v') != expected.trim_start_matches('v') { + return Err(ProbeError::new( + "fixture_identity_mismatch", + "local update manifest version does not match --expected-version", + )); + } + } + let platform = value + .get("platforms") + .and_then(|platforms| platforms.get("darwin-aarch64")); + let (url_value, signature_value) = if let Some(platform) = platform { + ( + platform.get("url").and_then(Value::as_str), + platform.get("signature").and_then(Value::as_str), + ) + } else { + ( + value.get("url").and_then(Value::as_str), + value.get("signature").and_then(Value::as_str), + ) + }; + let archive_url = url_value + .ok_or_else(|| ProbeError::new("invalid_fixture", "manifest has no archive URL"))? + .parse::() + .map_err(|error| ProbeError::new("invalid_fixture", error.to_string()))?; + validate_endpoint(&archive_url)?; + if !is_fixed_archive_url(&options.endpoint, &archive_url) { + return Err(ProbeError::new( + "production_feed_refused", + "manifest archive URL must be the local fixture archive on the endpoint origin", + )); + } + if signature_value + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + return Err(ProbeError::new( + "invalid_fixture", + "manifest archive signature is empty", + )); + } + Ok(()) + } + + fn execute( + app: &AppHandle, + options: &Options, + fixture: &Fixture, + install_active: &AtomicBool, + ) -> Result { + let local_manifest = read_bounded_bytes(&fixture.manifest, MAX_MANIFEST_BYTES)?; + let key_config = read_bounded_text(&fixture.public_key, MAX_PUBLIC_KEY_BYTES)?; + let key_text = decode_public_key_config(key_config.trim())?; + let ca_pem = read_bounded_bytes(&fixture.ca_certificate, MAX_CA_CERTIFICATE_BYTES)?; + let ca_certificate = reqwest::Certificate::from_pem(&ca_pem).map_err(|error| { + ProbeError::new( + "invalid_fixture_ca", + format!("QA CA certificate is not valid PEM: {error}"), + ) + })?; + + let main_thread_before = main_thread_ping(app); + if !main_thread_before { + return Err(ProbeError::new( + "main_thread_unavailable", + "the Tauri event thread did not acknowledge the preflight ping", + )); + } + + let extract_path = tauri_plugin_updater::extract_path_from_executable( + &fixture.app_executable, + ) + .map_err(|error| { + ProbeError::new( + "fixture_target_rejected", + format!("official updater could not derive the fixture app path: {error}"), + ) + })?; + let canonical_app = fs::canonicalize(&fixture.app_dir).map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("cannot canonicalize the fixture app: {error}"), + ) + })?; + if extract_path != canonical_app { + return Err(ProbeError::new( + "fixture_target_rejected", + "official updater extract path is not exactly the bound fixture app", + )); + } + + let preflight_started = Instant::now(); + let native_client = updater_transport::build_client( + Some(ca_certificate.clone()), + CONNECT_TIMEOUT, + TOTAL_TIMEOUT, + ) + .map_err(|error| { + ProbeError::new( + "native_check_configuration_rejected", + format!("native manifest client failed to build: {error}"), + ) + })?; + let native_manifest = tauri::async_runtime::block_on(updater_transport::fetch_manifest( + &native_client, + &options.endpoint, + MAX_NATIVE_MANIFEST_BYTES, + )) + .map_err(|error| { + map_transport_error( + error, + "native HTTPS manifest prefetch", + "native_check_failed", + "native_manifest_size_limit", + ) + })?; + let native_manifest_matches_local = native_manifest + .as_deref() + .map(|manifest| manifest == local_manifest.as_slice()); + if native_manifest_matches_local == Some(false) { + return Err(ProbeError::new( + "native_manifest_mismatch", + "the native HTTPS prefetch differs from the bound local update manifest", + ) + .with_details(json!({ + "native_manifest_bytes": native_manifest.as_ref().map(|manifest| manifest.len()), + "local_manifest_bytes": local_manifest.len(), + "same_manifest": false, + }))); + } + let remaining = TOTAL_TIMEOUT + .checked_sub(preflight_started.elapsed()) + .ok_or_else(|| { + ProbeError::new( + "preflight_budget_exhausted", + "native manifest prefetch consumed the combined five-second preflight budget", + ) + })?; + if remaining.is_zero() { + return Err(ProbeError::new( + "preflight_budget_exhausted", + "no time remained for official updater check reconstruction", + )); + } + let plugin_ca_certificate = ca_certificate.clone(); + let updater = app + .updater_builder() + .endpoints(vec![options.endpoint.clone()]) + .map_err(|error| { + ProbeError::new( + "check_configuration_rejected", + format!("official updater rejected the selected endpoint: {error}"), + ) + })? + .pubkey(key_config.trim().to_owned()) + .executable_path(&fixture.app_executable) + .timeout(remaining) + .configure_client(move |client| { + client + .add_root_certificate(plugin_ca_certificate.clone()) + .https_only(true) + .redirect(Policy::none()) + .connect_timeout(CONNECT_TIMEOUT.min(remaining)) + .timeout(remaining) + }) + .build() + .map_err(|error| { + ProbeError::new( + "check_configuration_rejected", + format!("official updater builder failed: {error}"), + ) + })?; + + let checked = tauri::async_runtime::block_on(updater.check()).map_err(|error| { + ProbeError::new( + "check_failed", + format!("official updater check failed: {error}"), + ) + })?; + let Some(update): Option = checked else { + if options.expected_version.is_some() { + return Err(ProbeError::new( + "selected_update_missing", + "the official check returned no update for --expected-version", + )); + } + return Ok(json!({ + "schema": 1, + "ok": true, + "app_integrity_proven": false, + "scenario": options.scenario.as_str(), + "plugin": {"name": "tauri-plugin-updater", "version": PLUGIN_VERSION}, + "current_version": fixture.current_desktop_version, + "current_version_source": "root_marker.current_desktop_version", + "target": "darwin-aarch64", + "fixture": fixture_observation(fixture), + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "fixture_archive_bytes": fixture.archive_size, + "native_prefetch": { + "performed": true, + "manifest_bytes": native_manifest.as_ref().map(|manifest| manifest.len()), + "manifest_cap_bytes": MAX_NATIVE_MANIFEST_BYTES, + "same_local_manifest": native_manifest_matches_local, + "trust_anchor": CA_CERTIFICATE_NAME, + "valid_certificates": true, + "redirect_policy": "none", + }, + "check": { + "performed": true, + "update": null, + "endpoint_https": true, + "endpoint_loopback": true, + "https_only": true, + "redirect_policy": "none", + "valid_certificates": true, + "connect_timeout_ms": CONNECT_TIMEOUT.min(remaining).as_millis(), + "total_timeout_ms": remaining.as_millis(), + "native_total_timeout_ms": TOTAL_TIMEOUT.as_millis(), + "dangerous_insecure_transport_protocol": false, + "combined_preflight_budget_ms": TOTAL_TIMEOUT.as_millis(), + "preflight_elapsed_ms": preflight_started.elapsed().as_millis(), + "plugin_metadata_allocation": "timeout_bounded_not_byte_bounded", + }, + "reconstruction": { + "performed": matches!(options.scenario, Scenario::Reconstruct), + "uses_supported_check": true, + "private_update_state_serialized": false, + }, + "native_archive": { + "performed": false, + "reason": "check-only scenario does not fetch or install the archive", + }, + "install": {"attempted": false}, + "main_thread": {"preflight_ping": main_thread_before}, + })); + }; + + if let Some(expected) = options.expected_version.as_deref() { + if update.version.trim_start_matches('v') != expected.trim_start_matches('v') { + return Err(ProbeError::new( + "selected_version_mismatch", + format!( + "official check selected {} but --expected-version is {expected}", + update.version + ), + )); + } + } + if update.signature.trim().is_empty() { + return Err(ProbeError::new( + "selected_signature_missing", + "official check returned an update without a signature", + )); + } + validate_endpoint(&update.download_url).map_err(|error| { + ProbeError::new( + "production_feed_refused", + format!( + "official update archive URL was rejected: {}", + error.message + ), + ) + })?; + if !is_fixed_archive_url(&options.endpoint, &update.download_url) { + return Err(ProbeError::new( + "selected_archive_mismatch", + "official update archive URL is not the local fixture archive on the endpoint origin", + )); + } + + let common = json!({ + "schema": 1, + "plugin": {"name": "tauri-plugin-updater", "version": PLUGIN_VERSION}, + "current_version": fixture.current_desktop_version, + "current_version_source": "root_marker.current_desktop_version", + "target": "darwin-aarch64", + "fixture": fixture_observation(fixture), + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "fixture_archive_bytes": fixture.archive_size, + "native_prefetch": { + "performed": true, + "manifest_bytes": native_manifest.as_ref().map(|manifest| manifest.len()), + "manifest_cap_bytes": MAX_NATIVE_MANIFEST_BYTES, + "same_local_manifest": native_manifest_matches_local, + "trust_anchor": CA_CERTIFICATE_NAME, + "valid_certificates": true, + "redirect_policy": "none", + }, + "check": { + "performed": true, + "selected_version": update.version, + "selected_archive_origin": update.download_url.origin().ascii_serialization(), + "signature_present": true, + "endpoint_https": true, + "endpoint_loopback": true, + "https_only": true, + "redirect_policy": "none", + "valid_certificates": true, + "connect_timeout_ms": CONNECT_TIMEOUT.min(remaining).as_millis(), + "total_timeout_ms": remaining.as_millis(), + "native_total_timeout_ms": TOTAL_TIMEOUT.as_millis(), + "dangerous_insecure_transport_protocol": false, + "combined_preflight_budget_ms": TOTAL_TIMEOUT.as_millis(), + "preflight_elapsed_ms": preflight_started.elapsed().as_millis(), + "plugin_metadata_allocation": "timeout_bounded_not_byte_bounded", + }, + "reconstruction": { + "performed": matches!(options.scenario, Scenario::Reconstruct), + "uses_supported_check": true, + "private_update_state_serialized": false, + }, + "main_thread": { + "preflight_ping": main_thread_before, + "install_thread": "std::thread", + }, + "selected_target_version": update.version, + }); + + if !options.scenario.requires_install() { + return Ok(merge_json( + common, + json!({ + "ok": true, + "scenario": options.scenario.as_str(), + "app_integrity_proven": false, + "signature_verification": { + "performed": false, + "reason": "check-only scenario does not download or install", + }, + "install": {"attempted": false}, + }), + )); + } + + let native_archive_client = + updater_transport::build_client(Some(ca_certificate), CONNECT_TIMEOUT, ARCHIVE_TIMEOUT) + .map_err(|error| { + ProbeError::new( + "native_archive_configuration_rejected", + format!("native archive client failed to build: {error}"), + ) + })?; + let native_archive = tauri::async_runtime::block_on(updater_transport::fetch_bounded( + &native_archive_client, + &update.download_url, + MAX_ARCHIVE_BYTES, + )) + .map_err(|error| { + map_transport_error( + error, + "native archive download", + "native_archive_download_failed", + "native_archive_size_limit", + ) + })?; + let native_downloaded_bytes = native_archive.len(); + + let public_key = minisign_verify::PublicKey::decode(&key_text).map_err(|error| { + ProbeError::new( + "invalid_fixture_key", + format!("QA public key is not a Minisign public key: {error}"), + ) + })?; + let signature_text = STANDARD + .decode(update.signature.as_bytes()) + .map_err(|error| { + ProbeError::new( + "signature_rejected", + format!("official check returned a non-base64 signature: {error}"), + ) + }) + .and_then(|bytes| { + String::from_utf8(bytes).map_err(|error| { + ProbeError::new( + "signature_rejected", + format!("official check returned a non-UTF-8 signature: {error}"), + ) + }) + })?; + let signature = minisign_verify::Signature::decode(&signature_text).map_err(|error| { + ProbeError::new( + "signature_rejected", + format!("official check returned an invalid signature: {error}"), + ) + })?; + public_key + .verify(&native_archive, &signature, false) + .map_err(|error| { + ProbeError::new( + "signature_rejected", + format!("the native downloaded archive failed Minisign verification: {error}"), + ) + .with_details(json!({ + "native_downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "archive_timeout_ms": ARCHIVE_TIMEOUT.as_millis(), + "signature_verified": false, + "install_attempted": false, + })) + })?; + if native_archive.is_empty() { + return Err(ProbeError::new( + "invalid_fixture", + "the native downloaded archive is empty", + ) + .with_details(json!({ + "signature_verified": true, + "native_downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "install_attempted": false, + }))); + } + + validate_install_volume(&fixture.root, &fixture.app_dir)?; + install_active.store(true, Ordering::Release); + let install_started = Instant::now(); + let heartbeat = spawn_install_heartbeat(app.clone()); + let install_result = + panic::catch_unwind(AssertUnwindSafe(|| update.install(&native_archive))); + install_active.store(false, Ordering::Release); + let install_elapsed_ms = install_started.elapsed().as_millis(); + let install_heartbeat = finish_install_heartbeat(heartbeat); + let main_thread_after = main_thread_ping(app); + match install_result { + Ok(Ok(())) => { + if fixture.proof_kind.requires_marker() { + let observation = fixture_observation(fixture); + let marker_after = observation + .get("marker") + .and_then(|marker| marker.get("value")) + .and_then(Value::as_str); + if marker_after != Some("B") { + return Err(ProbeError::new( + "post_install_observation_failed", + "marker-only fixture did not expose its expected B observation", + ) + .with_details(json!({ + "signature_verified": true, + "native_downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "same_bytes": true, + "same_buffer_as_signature_verified": true, + "buffer_passed_unchanged_to_install": true, + "install_attempted": true, + "install_result": "success", + "selected_target_version": update.version, + "install_elapsed_ms": install_elapsed_ms, + "main_thread_post_install_ping": main_thread_after, + "main_thread_heartbeat": install_heartbeat.as_json(), + "app_integrity_proven": false, + }))); + } + } + Ok(merge_json( + common, + json!({ + "ok": true, + "scenario": "install", + "app_integrity_proven": false, + "native_archive": { + "performed": true, + "downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "timeout_ms": ARCHIVE_TIMEOUT.as_millis(), + "same_buffer_passed_to_install": true, + "redirect_policy": "none", + "https_only": true, + "valid_certificates": true, + }, + "signature_verification": { + "performed": true, + "verified_by": "minisign-verify::PublicKey::verify", + "native_downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "same_bytes": true, + "same_buffer_as_signature_verified": true, + "buffer_passed_unchanged_to_install": true, + "app_integrity_proven": false, + }, + "install": { + "attempted": true, + "result": "success", + "selected_target_version": update.version, + "target": fixture + .app_dir + .file_name() + .and_then(|name| name.to_str()), + "observed_after": fixture_observation(fixture), + "elapsed_ms": install_elapsed_ms, + "main_thread_post_install_ping": main_thread_after, + "main_thread_heartbeat": install_heartbeat.as_json(), + }, + }), + )) + } + Ok(Err(error)) => { + let message = error.to_string(); + let code = + if message.contains("Authentication failed") || message.contains("cancelled") { + "authorization_cancelled_or_failed" + } else { + "install_failed" + }; + Err( + ProbeError::new(code, format!("official updater install failed: {message}")) + .with_details(json!({ + "signature_verified": true, + "native_downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "same_bytes": true, + "same_buffer_as_signature_verified": true, + "buffer_passed_unchanged_to_install": true, + "selected_target_version": update.version, + "native_archive": { + "performed": true, + "downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "timeout_ms": ARCHIVE_TIMEOUT.as_millis(), + "same_buffer_passed_to_install": true, + }, + "install_attempted": true, + "install_elapsed_ms": install_elapsed_ms, + "main_thread_post_install_ping": main_thread_after, + "main_thread_heartbeat": install_heartbeat.as_json(), + "app_integrity_proven": false, + "observed_after": fixture_observation(fixture), + "recovery": "integrity_unproven_manual_classification_required", + "retry_allowed": false, + })), + ) + } + Err(_) => { + let details = json!({ + "signature_verified": true, + "native_downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "same_bytes": true, + "same_buffer_as_signature_verified": true, + "buffer_passed_unchanged_to_install": true, + "selected_target_version": update.version, + "native_archive": { + "performed": true, + "downloaded_bytes": native_downloaded_bytes, + "archive_cap_bytes": MAX_ARCHIVE_BYTES, + "timeout_ms": ARCHIVE_TIMEOUT.as_millis(), + "same_buffer_passed_to_install": true, + }, + "install_attempted": true, + "install_elapsed_ms": install_elapsed_ms, + "main_thread_post_install_ping": main_thread_after, + "main_thread_heartbeat": install_heartbeat.as_json(), + "app_integrity_proven": false, + "observed_after": fixture_observation(fixture), + "recovery": "integrity_unproven_manual_classification_required", + "retry_allowed": false, + }); + Err(ProbeError::new( + "install_panicked", + "official updater install panicked; heartbeat observation was bounded", + ) + .with_details(details)) + } + } + } + + fn map_transport_error( + error: TransportError, + operation: &'static str, + error_code: &'static str, + size_error_code: &'static str, + ) -> ProbeError { + match error { + TransportError::Request(error) => { + ProbeError::new(error_code, format!("{operation} failed: {error}")) + } + TransportError::Status(status) => { + ProbeError::new(error_code, format!("{operation} returned status {status}")) + } + TransportError::Stream(error) => { + ProbeError::new(error_code, format!("{operation} stream failed: {error}")) + } + TransportError::UrlCredentials => ProbeError::new( + "production_feed_refused", + updater_transport::URL_CREDENTIALS_MESSAGE, + ), + TransportError::ContentLengthExceeded { max_bytes } => ProbeError::new( + size_error_code, + format!("{operation} Content-Length exceeded the {max_bytes}-byte cap"), + ), + TransportError::BodyExceeded { max_bytes } => ProbeError::new( + size_error_code, + format!("{operation} exceeded the {max_bytes}-byte cap"), + ), + TransportError::SizeOverflow => ProbeError::new( + size_error_code, + format!("{operation} size overflowed the probe limit"), + ), + TransportError::InvalidHeader(name) => ProbeError::new( + error_code, + format!("{operation} returned an invalid {name} header"), + ), + } + } + + fn read_bounded_bytes(path: &Path, max_bytes: u64) -> Result, ProbeError> { + let file = open_bounded_regular(path)?; + let mut bytes = Vec::new(); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!("cannot read {}: {error}", path.display()), + ) + })?; + if bytes.len() as u64 > max_bytes { + return Err(ProbeError::new( + "fixture_size_limit", + format!( + "{} is larger than the {max_bytes}-byte probe limit", + path.display() + ), + )); + } + Ok(bytes) + } + + fn open_bounded_regular(path: &Path) -> Result { + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!( + "cannot open {} without following links: {error}", + path.display() + ), + ) + })?; + let metadata = file.metadata().map_err(|error| { + ProbeError::new( + "invalid_fixture", + format!( + "cannot inspect opened {} before reading: {error}", + path.display() + ), + ) + })?; + if !metadata.is_file() { + return Err(ProbeError::new( + "invalid_fixture", + format!("{} is not a regular file", path.display()), + )); + } + Ok(file) + } + + fn decode_public_key_config(key_config: &str) -> Result { + let bytes = STANDARD.decode(key_config.as_bytes()).map_err(|error| { + ProbeError::new( + "invalid_fixture_key", + format!("QA public key is not base64 Tauri public-key text: {error}"), + ) + })?; + String::from_utf8(bytes).map_err(|error| { + ProbeError::new( + "invalid_fixture_key", + format!("QA public-key text is not UTF-8: {error}"), + ) + }) + } + + fn main_thread_ping(app: &AppHandle) -> bool { + let (sender, receiver) = mpsc::sync_channel(1); + if app + .run_on_main_thread(move || { + let _ = sender.send(()); + }) + .is_err() + { + return false; + } + receiver + .recv_timeout(Duration::from_secs(2)) + .map(|_| true) + .unwrap_or(false) + } + + fn spawn_install_heartbeat(app: AppHandle) -> InstallHeartbeatHandle { + let stop = Arc::new(AtomicBool::new(false)); + let observer_stop = Arc::clone(&stop); + let join = thread::spawn(move || { + let started = Instant::now(); + let deadline = started + .checked_add(INSTALL_HEARTBEAT_DEADLINE) + .unwrap_or(started); + let mut attempts = 0; + let mut responsive = 0; + let mut timeouts = 0; + let mut max_latency_ms = 0; + let mut stopped_reason = "observation_deadline"; + + loop { + if observer_stop.load(Ordering::Acquire) { + stopped_reason = "install_finished"; + break; + } + if Instant::now() >= deadline { + break; + } + if deadline + .saturating_duration_since(Instant::now()) + .lt(&Duration::from_secs(2)) + { + break; + } + + let ping_started = Instant::now(); + let ping_responsive = main_thread_ping(&app); + let latency_ms = ping_started.elapsed().as_millis(); + attempts += 1; + max_latency_ms = max_latency_ms.max(latency_ms); + if ping_responsive { + responsive += 1; + } else { + timeouts += 1; + stopped_reason = "first_timeout"; + break; + } + + if observer_stop.load(Ordering::Acquire) { + stopped_reason = "install_finished"; + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + thread::sleep(INSTALL_HEARTBEAT_INTERVAL.min(remaining)); + } + + InstallHeartbeat { + attempts, + responsive, + timeouts, + max_latency_ms, + elapsed_ms: started.elapsed().as_millis(), + stopped_reason, + } + }); + InstallHeartbeatHandle { stop, join } + } + + fn finish_install_heartbeat(handle: InstallHeartbeatHandle) -> InstallHeartbeat { + handle.stop.store(true, Ordering::Release); + match handle.join.join() { + Ok(summary) => summary, + Err(_) => InstallHeartbeat { + attempts: 0, + responsive: 0, + timeouts: 0, + max_latency_ms: 0, + elapsed_ms: 0, + stopped_reason: "observer_panicked", + }, + } + } + + fn merge_json(base: Value, extra: Value) -> Value { + let mut base = match base { + Value::Object(object) => object, + _ => serde_json::Map::new(), + }; + if let Value::Object(extra) = extra { + base.extend(extra); + } + Value::Object(base) + } + + fn print_outcome(value: Value) { + let line = serde_json::to_string(&value).unwrap_or_else(|_| { + "{\"schema\":1,\"ok\":false,\"error\":{\"code\":\"serialization_failed\"}}".to_string() + }); + println!("{line}"); + let _ = io::stdout().flush(); + } + + fn fixture_observation(fixture: &Fixture) -> Value { + let marker = match fixture.app_marker.as_ref() { + Some(path) => match read_bounded_bytes(path, MAX_APP_MARKER_BYTES) { + Ok(bytes) if bytes == APP_MARKER_A => { + json!({"present": true, "value": "A"}) + } + Ok(bytes) if bytes == APP_MARKER_B => { + json!({"present": true, "value": "B"}) + } + Ok(_) => json!({"present": true, "value": "other"}), + Err(error) => json!({ + "present": true, + "value": null, + "read_error": error.code, + }), + }, + None => json!({"present": false, "value": null, "scope": "not_applicable"}), + }; + json!({ + "root_basename": fixture.root.file_name().and_then(|name| name.to_str()), + "proof_kind": fixture.proof_kind.as_str(), + "target": fixture + .app_dir + .file_name() + .and_then(|name| name.to_str()), + "executable_basename": fixture.executable_basename, + "declared_current_desktop_version": fixture.current_desktop_version, + "observed_current_desktop_version": fixture.current_desktop_version, + "marker": marker, + "app_integrity_proven": false, + }) + } + + fn error_outcome( + error: ProbeError, + options: Option<&Options>, + fixture: Option<&Fixture>, + ) -> Value { + let mut outcome = json!({ + "schema": 1, + "ok": false, + "scenario": options.map(|options| options.scenario.as_str()), + "plugin": { + "name": "tauri-plugin-updater", + "version": PLUGIN_VERSION, + }, + "app_integrity_proven": false, + "error": { + "code": error.code, + "message": error.message, + "details": error.details, + }, + }); + if let Some(fixture) = fixture { + if let Value::Object(outcome) = &mut outcome { + outcome.insert("fixture".to_string(), fixture_observation(fixture)); + } + } + outcome + } + + fn print_fixture_error(error: ProbeError, fixture: &Fixture) -> ! { + print_outcome(error_outcome(error, None, Some(fixture))); + std::process::exit(1); + } + + fn print_standalone_error(error: ProbeError) -> ! { + print_outcome(error_outcome(error, None, None)); + std::process::exit(1); + } + + #[cfg(test)] + mod tests { + use super::*; + use std::{ + ffi::CString, + os::unix::{ffi::OsStrExt, fs::PermissionsExt}, + }; + + #[test] + fn install_volume_precheck_rejects_a_different_device() { + let temporary = fs::canonicalize(env::temp_dir()).unwrap(); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = temporary.join(format!( + "{ROOT_PREFIX}volume-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let app = root.join("A.app"); + fs::create_dir(&app).unwrap(); + validate_install_volume(&root, &app).unwrap(); + assert_ne!( + fs::metadata("/dev").unwrap().dev(), + fs::metadata(&root).unwrap().dev() + ); + assert_eq!( + validate_install_volume(&root, Path::new("/dev")) + .unwrap_err() + .code, + "installation_volume_refused" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bounded_fixture_reads_enforce_byte_limits_and_utf8() { + let mut random = [0u8; 16]; + getrandom::getrandom(&mut random).unwrap(); + let path = env::temp_dir().join(format!( + "gajae-updater-probe-read-{:032x}", + u128::from_ne_bytes(random) + )); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let _cleanup = Cleanup(path.clone()); + assert!(read_bounded_bytes(&path, 0).unwrap().is_empty()); + file.write_all(b"abcd").unwrap(); + assert_eq!(read_bounded_text(&path, 4).unwrap(), "abcd"); + assert_eq!( + read_bounded_bytes(&path, 3).unwrap_err().code, + "fixture_size_limit" + ); + file.write_all(&[0xff]).unwrap(); + assert_eq!( + read_bounded_text(&path, 4).unwrap_err().code, + "fixture_size_limit" + ); + assert_eq!( + read_bounded_text(&path, 5).unwrap_err().code, + "invalid_fixture" + ); + } + + #[test] + fn probe_rejects_remote_insecure_and_credential_bearing_feeds() { + for endpoint in [ + "http://127.0.0.1:3001/update.json", + "https://example.com:443/update.json", + "https://user:password@127.0.0.1:3001/update.json", + "https://127.0.0.1:3001/update.json?token=secret", + ] { + assert_eq!( + validate_endpoint(&Url::parse(endpoint).unwrap()) + .unwrap_err() + .code, + "production_feed_refused" + ); + } + validate_endpoint(&Url::parse("https://127.0.0.1:3001/update.json").unwrap()).unwrap(); + } + + #[test] + fn schema_requires_explicit_proof_kind_and_bundle_metadata() { + let mut marker = json!({ + "schema": 2, + "purpose": ROOT_MARKER_PURPOSE, + "root": "/tmp/gajae-updater-probe-test", + "fixture": ROOT_MARKER_FIXTURE, + "app": MARKER_APP_DIR_NAME, + "archive": ARCHIVE_NAME, + "manifest": MANIFEST_NAME, + "public_key": PUBLIC_KEY_NAME, + "proof_kind": "marker_only", + "executable_basename": "A", + "current_desktop_version": "0.2.3" + }); + assert!(serde_json::from_value::(marker.clone()).is_ok()); + marker.as_object_mut().unwrap().remove("proof_kind"); + assert!(serde_json::from_value::(marker.clone()).is_err()); + marker["proof_kind"] = json!("legacy"); + assert!(serde_json::from_value::(marker).is_err()); + let mut marker_with_extra = json!({ + "schema": 2, + "purpose": ROOT_MARKER_PURPOSE, + "root": "/tmp/gajae-updater-probe-test", + "fixture": ROOT_MARKER_FIXTURE, + "proof_kind": "marker_only", + "app": MARKER_APP_DIR_NAME, + "archive": ARCHIVE_NAME, + "manifest": MANIFEST_NAME, + "public_key": PUBLIC_KEY_NAME, + "executable_basename": "A", + "current_desktop_version": "0.2.3", + "legacy": true + }); + assert!(serde_json::from_value::(marker_with_extra.clone()).is_err()); + marker_with_extra.as_object_mut().unwrap().remove("legacy"); + assert!(serde_json::from_value::(marker_with_extra).is_ok()); + assert_eq!( + expected_app_dir_name(ProofKind::MarkerOnly), + MARKER_APP_DIR_NAME + ); + assert_eq!( + expected_app_dir_name(ProofKind::SignedBundle), + SIGNED_APP_DIR_NAME + ); + assert!(validate_app_root_name(ProofKind::SignedBundle, MARKER_APP_DIR_NAME).is_err()); + validate_app_root_name(ProofKind::SignedBundle, SIGNED_APP_DIR_NAME).unwrap(); + assert_eq!(SIGNED_EXECUTABLE_BASENAME, "gajae-app-desktop"); + } + + #[test] + fn metadata_bounds_reject_paths_and_invalid_versions() { + assert_eq!(MAX_MANIFEST_BYTES, 64 * 1024); + assert_eq!(MAX_NATIVE_MANIFEST_BYTES, 64 * 1024); + assert_eq!(MAX_ARCHIVE_BYTES, 250 * 1024 * 1024); + for name in ["", ".", "..", "A/B", "A\\B", "A B"] { + assert!(validate_executable_basename(name).is_err()); + } + validate_executable_basename("GajaeCode").unwrap(); + for version in [ + "", + "0.2", + "0.02.3", + "0.2.3 bad", + "0.2.3-", + "v0.2.3", + " 0.2.3", + ] { + assert!(validate_desktop_version(version).is_err()); + } + validate_desktop_version("0.2.2-beta.8").unwrap(); + validate_desktop_version("0.2.3").unwrap(); + } + + #[test] + fn heartbeat_summary_exposes_bounded_aggregate_contract() { + let summary = InstallHeartbeat { + attempts: 3, + responsive: 2, + timeouts: 1, + max_latency_ms: 2_000, + elapsed_ms: 2_101, + stopped_reason: "first_timeout", + }; + let value = summary.as_json(); + assert_eq!(value["attempts"], 3); + assert_eq!(value["responsive"], 2); + assert_eq!(value["timeouts"], 1); + assert_eq!(value["max_latency_ms"], 2_000); + assert_eq!(value["first_timeout_stops_observation"], true); + assert_eq!(value["stopped_reason"], "first_timeout"); + assert_eq!(value["observation_deadline_ms"], 60_000); + } + + #[test] + fn fixture_root_and_signed_observation_stay_isolated() { + let temporary = fs::canonicalize(env::temp_dir()).unwrap(); + let valid_root = + temporary.join(format!("{ROOT_PREFIX}metadata-{}", std::process::id())); + fs::create_dir(&valid_root).unwrap(); + fs::set_permissions(&valid_root, fs::Permissions::from_mode(0o700)).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(valid_root.clone()); + validate_fixture_root(&valid_root).unwrap(); + assert_eq!( + validate_fixture_root(&temporary.join("not-a-probe-root")) + .unwrap_err() + .code, + "production_root_refused" + ); + let fixture = Fixture { + root: valid_root, + proof_kind: ProofKind::SignedBundle, + executable_basename: SIGNED_EXECUTABLE_BASENAME.to_string(), + current_desktop_version: "0.2.2".to_string(), + app_dir: PathBuf::new(), + app_executable: PathBuf::new(), + app_marker: None, + archive_size: 0, + manifest: PathBuf::new(), + public_key: PathBuf::new(), + ca_certificate: PathBuf::new(), + }; + let observed = fixture_observation(&fixture); + assert_eq!(observed["proof_kind"], "signed_bundle"); + assert_eq!(observed["declared_current_desktop_version"], "0.2.2"); + assert_eq!(observed["marker"]["present"], false); + assert_eq!(observed["app_integrity_proven"], false); + } + + #[test] + fn fixture_root_requires_current_owner_and_private_directory_mode() { + let current_uid = unsafe { libc::geteuid() as u32 }; + validate_fixture_owner_mode(current_uid, 0o700).unwrap(); + validate_fixture_owner_mode(current_uid, 0o500).unwrap(); + assert_eq!( + validate_fixture_owner_mode(current_uid, 0o755) + .unwrap_err() + .code, + "fixture_mode_refused" + ); + let other_uid = if current_uid == 0 { 1 } else { 0 }; + assert_eq!( + validate_fixture_owner_mode(other_uid, 0o700) + .unwrap_err() + .code, + "fixture_owner_refused" + ); + } + + #[test] + fn bounded_reader_rejects_symlink_without_following_it() { + let target = env::temp_dir().join(format!( + "gajae-updater-probe-symlink-target-{}", + std::process::id() + )); + let link = env::temp_dir().join(format!( + "gajae-updater-probe-symlink-{}", + std::process::id() + )); + let _ = fs::remove_file(&target); + let _ = fs::remove_file(&link); + fs::write(&target, b"target").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert_eq!( + read_bounded_bytes(&link, 1024).unwrap_err().code, + "invalid_fixture" + ); + let _ = fs::remove_file(&target); + let _ = fs::remove_file(&link); + } + + #[test] + fn bounded_reader_rejects_fifo_without_blocking() { + let fifo = + env::temp_dir().join(format!("gajae-updater-probe-fifo-{}", std::process::id())); + let _ = fs::remove_file(&fifo); + let c_path = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0); + assert_eq!( + read_bounded_bytes(&fifo, 1024).unwrap_err().code, + "invalid_fixture" + ); + let _ = fs::remove_file(&fifo); + } + + #[test] + fn fixture_versions_follow_semver_including_build_metadata() { + assert!(validate_desktop_version("0.2.2+build-with-hyphen").is_ok()); + assert!(validate_desktop_version("0.2.2-beta.1+build.01").is_ok()); + for version in ["0.2.2-01", "0.2.2+bad!", " 0.2.2", "00.2.2"] { + assert!(validate_desktop_version(version).is_err(), "{version}"); + } + } + + #[test] + fn probe_context_has_no_configured_windows_before_construction() { + let mut context = probe_context(); + assert_eq!(context.config().identifier, PROBE_IDENTIFIER); + context.config_mut().app.windows.push(Default::default()); + let endpoint = Url::parse("https://127.0.0.1:3001/update.json").unwrap(); + configure_probe_context(&mut context, "0.2.2", "cHVibGljLWtleQ==", &endpoint).unwrap(); + assert!( + context.config().app.windows.is_empty(), + "updater probe must not construct any WebView" + ); + assert_eq!(context.config().identifier, PROBE_IDENTIFIER); + } + } + + fn print_usage() { + println!( + "Usage: updater_probe --root ROOT --endpoint https://127.0.0.1:PORT/update.json [--scenario check|reconstruct|install] [--expected-version VERSION]\n\nROOT must be a parent-created $TMPDIR/gajae-updater-probe-* directory containing a schema-2 .gajae-updater-probe-root marker, qa-ca.pem, and fixture archive. proof_kind=marker_only requires the fixed root/A.app test fixture and observes its explicit test marker; proof_kind=signed_bundle requires the fixed root/Gajae Code App.app and declared gajae-app-desktop executable, without requiring or adding a marker. install performs native HTTPS manifest prefetch -> official check -> capped native archive download -> direct Minisign verification -> the unchanged buffer passed to official Update::install. This probe creates no WebViews and never proves complete app integrity." + ); + } +} + +#[cfg(target_os = "macos")] +fn main() { + macos_probe::run(); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + println!( + "{{\"schema\":1,\"ok\":false,\"app_integrity_proven\":false,\"error\":{{\"code\":\"unsupported_os\",\"message\":\"updater_probe is macOS-only\"}}}}" + ); + std::process::exit(2); +} diff --git a/src-tauri/src/expected_payload.rs b/src-tauri/src/expected_payload.rs new file mode 100644 index 00000000..5384c783 --- /dev/null +++ b/src-tauri/src/expected_payload.rs @@ -0,0 +1,700 @@ +//! Read-only payload identity checks, independent of the supervised process. +//! +//! `build.rs` must bind package name/product version and the SHA-256 of the source +//! `server/gjc-runtime-manifest.json` into the executable. Never derive these +//! expectations from the installed payload, a ready frame, health, or runtime +//! environment variables. Missing build inputs fail closed, even with updates +//! disabled. This module does not enable updates or access update state. +//! +//! Integration: verify the resolved packaged payload before spawning. Preserve +//! the supervisor's existing independently versioned ready/health checks and +//! the worker's own runtime file verification. Success here is not a signature, +//! installation, or writer-exit proof. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + fs::{self, File}, + io::Read, + path::Path, +}; + +use serde::{de, Deserialize, Deserializer}; +use sha2::{Digest, Sha256}; + +const PACKAGE_LIMIT: usize = 256 * 1024; +const MANIFEST_LIMIT: usize = 64 * 1024; +const SOURCE_MANIFEST: &str = "server/gjc-runtime-manifest.json"; +const WORKER_MANIFEST: &str = "dist-server/server/gjc-runtime-manifest.json"; + +#[derive(Debug)] +pub(crate) struct ExpectedPayload { + package_name: String, + product_version: String, + runtime_manifest_sha256: String, +} + +impl ExpectedPayload { + pub(crate) fn compiled() -> Result { + Self::from_build_values( + option_env!("GJC_EXPECTED_PAYLOAD_PACKAGE_NAME"), + option_env!("GJC_EXPECTED_PAYLOAD_VERSION"), + option_env!("GJC_EXPECTED_RUNTIME_MANIFEST_SHA256"), + ) + } + + fn from_build_values( + package_name: Option<&str>, + product_version: Option<&str>, + runtime_manifest_sha256: Option<&str>, + ) -> Result { + let required = |value: Option<&str>, field: &str| { + value + .filter(|value| valid_identity_text(value)) + .map(str::to_owned) + .ok_or_else(|| format!("missing or malformed compiled payload {field}")) + }; + let digest = required(runtime_manifest_sha256, "runtime manifest digest")?; + if !valid_sha256(&digest) { + return Err("malformed compiled payload runtime manifest digest".to_owned()); + } + Ok(Self { + package_name: required(package_name, "package name")?, + product_version: required(product_version, "product version")?, + runtime_manifest_sha256: digest, + }) + } + + /// Check both the packaging copy and the JSON imported by the Bun worker. + /// TypeScript reformats the latter, so raw byte equality between the two is + /// not required. Every field is represented in the strict manifest schema. + pub(crate) fn verify_payload(&self, root: &Path) -> Result<(), String> { + let package = read_payload_file(root, "package.json", PACKAGE_LIMIT)?; + self.verify_package(&package)?; + let source = read_payload_file(root, SOURCE_MANIFEST, MANIFEST_LIMIT)?; + let worker = read_payload_file(root, WORKER_MANIFEST, MANIFEST_LIMIT)?; + self.verify_manifests(&source, &worker) + } + + fn verify_package(&self, bytes: &[u8]) -> Result<(), String> { + check_limit(bytes, PACKAGE_LIMIT, "package metadata")?; + let package: PackageIdentity = serde_json::from_slice(bytes) + .map_err(|_| "malformed payload package metadata".to_owned())?; + if package.name != self.package_name || package.version != self.product_version { + return Err("payload package identity does not match the native build".to_owned()); + } + Ok(()) + } + + fn verify_manifests(&self, source: &[u8], worker: &[u8]) -> Result<(), String> { + check_limit(source, MANIFEST_LIMIT, "source runtime manifest")?; + check_limit(worker, MANIFEST_LIMIT, "worker runtime manifest")?; + if format!("{:x}", Sha256::digest(source)) != self.runtime_manifest_sha256 { + return Err( + "payload runtime manifest digest does not match the native build".to_owned(), + ); + } + let source = parse_manifest(source)?; + let worker = parse_manifest(worker)?; + if source != worker { + return Err( + "payload worker runtime manifest differs from the verified manifest".to_owned(), + ); + } + Ok(()) + } +} + +// Package.json legitimately carries unrelated fields (dependencies, etc.). +// Required identity fields are typed and duplicates rejected by the derived +// deserializer, without denying those existing extra fields. +#[derive(Deserialize)] +struct PackageIdentity { + name: String, + version: String, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RuntimeManifest { + schema_version: u32, + gjc_sdk: String, + bun: String, + natives: String, + #[serde(deserialize_with = "unique_platforms")] + platforms: BTreeMap, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct RuntimePlatform { + files: Vec, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct RuntimeFile { + package: String, + path: String, + sha256: String, +} + +// A plain BTreeMap accepts duplicate platform keys by overwriting the earlier +// value. Reject them so semantic equality cannot hide ambiguous worker JSON. +fn unique_platforms<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct PlatformsVisitor; + impl<'de> de::Visitor<'de> for PlatformsVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("runtime platforms with unique names") + } + + fn visit_map(self, mut map: M) -> Result + where + M: de::MapAccess<'de>, + { + let mut platforms = BTreeMap::new(); + while let Some((key, value)) = map.next_entry::()? { + if platforms.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate runtime platform")); + } + } + Ok(platforms) + } + } + deserializer.deserialize_map(PlatformsVisitor) +} + +fn parse_manifest(bytes: &[u8]) -> Result { + let failure = || "malformed payload runtime manifest".to_owned(); + let manifest: RuntimeManifest = serde_json::from_slice(bytes).map_err(|_| failure())?; + if manifest.schema_version != 1 + || !valid_identity_text(&manifest.gjc_sdk) + || !valid_identity_text(&manifest.bun) + || !valid_identity_text(&manifest.natives) + || manifest.platforms.is_empty() + { + return Err(failure()); + } + for (platform, closure) in &manifest.platforms { + if !valid_identity_text(platform) + || !platform + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(failure()); + } + let platform_package = format!("@gajae-code/natives-{platform}"); + let mut seen = BTreeSet::new(); + for file in &closure.files { + if (file.package != "@gajae-code/natives" && file.package != platform_package) + || !valid_native_path(&file.path) + || !valid_sha256(&file.sha256) + || !seen.insert((&file.package, &file.path)) + { + return Err(failure()); + } + } + } + // fill:runtime-manifest permits empty closures for foreign platforms. The + // actual desktop target must still have a populated closure. + let platform = match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "darwin-arm64", + ("linux", "x86_64") => "linux-x64", + _ => return Err("unsupported desktop runtime manifest target".to_owned()), + }; + if manifest + .platforms + .get(platform) + .is_none_or(|closure| closure.files.is_empty()) + { + return Err("payload runtime manifest lacks the desktop target closure".to_owned()); + } + Ok(manifest) +} + +fn valid_identity_text(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.trim() == value + && !value.chars().any(char::is_control) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn valid_native_path(value: &str) -> bool { + value.starts_with("native/") + && !value.contains(['\\', '\0']) + && !value.contains("..") + && value + .split('/') + .all(|part| !part.is_empty() && part != "." && !part.chars().any(char::is_control)) +} + +fn check_limit(bytes: &[u8], limit: usize, label: &str) -> Result<(), String> { + if bytes.len() > limit { + return Err(format!("payload {label} exceeds its size limit")); + } + Ok(()) +} + +fn read_payload_file(root: &Path, relative: &str, limit: usize) -> Result, String> { + let failure = || format!("payload {relative} is missing or is not a regular file"); + if !fs::symlink_metadata(root) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + return Err("payload root is not a directory".to_owned()); + } + // All callers use fixed relative paths, never inputs from a manifest. + let mut path = root.to_path_buf(); + let mut components = relative.split('/').peekable(); + while let Some(component) = components.next() { + path.push(component); + let metadata = fs::symlink_metadata(&path).map_err(|_| failure())?; + if components.peek().is_some() { + if !metadata.is_dir() { + return Err(failure()); + } + } else if !metadata.is_file() { + return Err(failure()); + } + } + let file = File::open(path).map_err(|_| failure())?; + let metadata = file.metadata().map_err(|_| failure())?; + if !metadata.is_file() { + return Err(failure()); + } + if metadata.len() > limit as u64 { + return Err(format!("payload {relative} exceeds its size limit")); + } + let mut bytes = Vec::new(); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| format!("could not read payload {relative}"))?; + check_limit(&bytes, limit, relative)?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + use std::{ + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + }; + + const PACKAGE_NAME: &str = "gajae-app"; + const VERSION: &str = "2.0.0-beta.9"; + const OTHER_VERSION: &str = "2.0.0-beta.10"; + + fn manifest() -> Value { + let file = |package| { + json!({ + "package": package, + "path": "native/index.js", + "sha256": "0".repeat(64), + }) + }; + json!({ + "schemaVersion": 1, + "gjcSdk": "0.16.4", + "bun": "1.4.0", + "natives": "0.16.4", + "platforms": { + "darwin-arm64": {"files": [file("@gajae-code/natives")]}, + "linux-x64": {"files": [file("@gajae-code/natives")]}, + }, + }) + } + + fn expected(source: &[u8]) -> ExpectedPayload { + ExpectedPayload::from_build_values( + Some(PACKAGE_NAME), + Some(VERSION), + Some(&format!("{:x}", Sha256::digest(source))), + ) + .unwrap() + } + + fn bytes(value: &Value) -> Vec { + serde_json::to_vec(value).unwrap() + } + + #[test] + fn compiled_expectations_are_complete() { + // This also detects missing build.rs integration; no payload fallback. + ExpectedPayload::compiled().unwrap(); + } + + #[test] + fn missing_or_malformed_build_values_fail_closed() { + let digest = "a".repeat(64); + let valid = [Some(PACKAGE_NAME), Some(VERSION), Some(digest.as_str())]; + for index in 0..valid.len() { + for bad in [ + None, + Some(""), + Some(" "), + Some("untrusted\nvalue"), + Some(" padded"), + ] { + let mut inputs = valid; + inputs[index] = bad; + assert!( + ExpectedPayload::from_build_values(inputs[0], inputs[1], inputs[2]).is_err() + ); + } + } + for bad in [ + "a".repeat(63), + "a".repeat(65), + "A".repeat(64), + "g".repeat(64), + ] { + assert!(ExpectedPayload::from_build_values(valid[0], valid[1], Some(&bad)).is_err()); + } + } + + #[test] + fn package_requires_expected_name_and_product_version_but_allows_existing_fields() { + let expected = expected(&bytes(&manifest())); + let package = json!({"name": PACKAGE_NAME, "version": VERSION, + "desktopVersion": "0.2.3", "dependencies": {}, "type": "module"}); + assert!(expected.verify_package(&bytes(&package)).is_ok()); + for field in ["name", "version"] { + for bad in [ + Value::Null, + json!(false), + json!(1), + json!([]), + json!({}), + json!(""), + json!("wrong"), + ] { + let mut value = package.clone(); + value[field] = bad; + assert!(expected.verify_package(&bytes(&value)).is_err()); + } + let mut value = package.clone(); + value.as_object_mut().unwrap().remove(field); + assert!(expected.verify_package(&bytes(&value)).is_err()); + let duplicate = format!( + "{{\"{field}\":{},{}", + package[field], + &package.to_string()[1..] + ); + assert!(expected.verify_package(duplicate.as_bytes()).is_err()); + } + for bad in [b"null".as_slice(), b"[]", b"{", b"\xff"] { + assert!(expected.verify_package(bad).is_err()); + } + } + + #[test] + fn manifest_copies_may_differ_in_formatting_but_not_content() { + let manifest = manifest(); + let source = serde_json::to_vec_pretty(&manifest).unwrap(); + let worker = bytes(&manifest); + assert_ne!(source, worker); + let expected = expected(&source); + assert!(expected.verify_manifests(&source, &worker).is_ok()); + // The source copy's digest remains exact, not canonicalized at runtime. + assert!(expected.verify_manifests(&worker, &worker).is_err()); + let mut changed = manifest.clone(); + changed["bun"] = json!("1.4.1"); + assert!(expected + .verify_manifests(&source, &bytes(&changed)) + .is_err()); + assert!(expected + .verify_manifests(&bytes(&changed), &bytes(&changed)) + .is_err()); + changed = manifest; + changed["platforms"]["darwin-arm64"]["files"][0]["sha256"] = json!("f".repeat(64)); + assert!(expected + .verify_manifests(&source, &bytes(&changed)) + .is_err()); + } + + fn reject_even_with_matching_digest(value: &Value) { + let source = bytes(value); + assert!(expected(&source) + .verify_manifests(&source, &source) + .is_err()); + } + + #[test] + fn manifest_requires_supported_schema_and_typed_nonempty_fields() { + for field in ["schemaVersion", "gjcSdk", "bun", "natives", "platforms"] { + let mut value = manifest(); + value.as_object_mut().unwrap().remove(field); + reject_even_with_matching_digest(&value); + for bad in [Value::Null, json!([]), json!(false)] { + let mut value = manifest(); + value[field] = bad; + reject_even_with_matching_digest(&value); + } + } + for (field, bad) in [ + ("schemaVersion", json!(2)), + ("schemaVersion", json!("1")), + ("schemaVersion", json!(1.0)), + ("gjcSdk", json!("")), + ("bun", json!(" ")), + ("natives", json!(3)), + ("platforms", json!({})), + ("unknown", json!(true)), + ] { + let mut value = manifest(); + value[field] = bad; + reject_even_with_matching_digest(&value); + } + } + + #[test] + fn manifest_rejects_bad_closures_files_hashes_and_paths() { + for platform in ["darwin-arm64", "linux-x64"] { + for bad in [ + Value::Null, + json!({}), + json!({"files": null}), + json!({"files": {}}), + json!({"files": [null]}), + ] { + let mut value = manifest(); + value["platforms"][platform] = bad; + reject_even_with_matching_digest(&value); + } + for field in ["package", "path", "sha256"] { + let mut value = manifest(); + value["platforms"][platform]["files"][0] + .as_object_mut() + .unwrap() + .remove(field); + reject_even_with_matching_digest(&value); + } + for (field, bad) in [ + ("package", json!("other")), + ("path", json!("native/../elsewhere")), + ("path", json!("/native/index.js")), + ("path", json!("native/./index.js")), + ("path", json!("native//index.js")), + ("path", json!("native\\index.js")), + ("path", json!("native/")), + ("path", json!("native/a\u{0}b")), + ("sha256", json!("0".repeat(63))), + ("sha256", json!("F".repeat(64))), + ("sha256", json!(false)), + ("extra", json!(true)), + ] { + let mut value = manifest(); + value["platforms"][platform]["files"][0][field] = bad; + reject_even_with_matching_digest(&value); + } + let mut value = manifest(); + let file = value["platforms"][platform]["files"][0].clone(); + value["platforms"][platform]["files"] + .as_array_mut() + .unwrap() + .push(file); + reject_even_with_matching_digest(&value); + } + let mut value = manifest(); + value["platforms"]["darwin-arm64"]["files"] = json!([]); + value["platforms"]["linux-x64"]["files"] = json!([]); + reject_even_with_matching_digest(&value); + } + + #[test] + fn duplicate_manifest_fields_or_platforms_are_rejected() { + let original = manifest().to_string(); + for source in [ + original.replacen("\"bun\":", "\"bun\":\"wrong\",\"bun\":", 1), + original.replacen( + "\"darwin-arm64\":", + "\"darwin-arm64\":{\"files\":[]},\"darwin-arm64\":", + 1, + ), + original.replacen("\"files\":", "\"files\":[],\"files\":", 1), + original.replacen("\"path\":", "\"path\":\"native/other\",\"path\":", 1), + "null".into(), + "[]".into(), + "{".into(), + ] { + assert!(expected(source.as_bytes()) + .verify_manifests(source.as_bytes(), source.as_bytes()) + .is_err()); + } + } + + #[test] + fn all_input_sizes_are_bounded_including_valid_json_with_trailing_whitespace() { + let source = bytes(&manifest()); + let expected = expected(&source); + let mut oversized = bytes(&json!({"name": PACKAGE_NAME, "version": VERSION})); + oversized.resize(PACKAGE_LIMIT + 1, b' '); + assert!(expected + .verify_package(&oversized) + .unwrap_err() + .contains("size limit")); + oversized = source.clone(); + oversized.resize(MANIFEST_LIMIT + 1, b' '); + assert!(expected + .verify_manifests(&oversized, &source) + .unwrap_err() + .contains("size limit")); + assert!(expected + .verify_manifests(&source, &oversized) + .unwrap_err() + .contains("size limit")); + } + + struct Fixture { + root: PathBuf, + source: Vec, + } + + impl Fixture { + fn new() -> Self { + static SEQUENCE: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "gajae-expected-payload-test-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + // Exclusive creation: never reuse or clean another test's directory. + fs::create_dir(&root).unwrap(); + let fixture = Self { + root, + source: serde_json::to_vec_pretty(&manifest()).unwrap(), + }; + fs::create_dir(fixture.root.join("server")).unwrap(); + fs::create_dir_all(fixture.root.join("dist-server/server")).unwrap(); + fixture.write( + "package.json", + &bytes(&json!({"name": PACKAGE_NAME, "version": VERSION})), + ); + fixture.write(SOURCE_MANIFEST, &fixture.source); + fixture.write(WORKER_MANIFEST, &bytes(&manifest())); + fixture + } + + fn write(&self, relative: &str, content: &[u8]) { + fs::write(self.root.join(relative), content).unwrap(); + } + + fn verify(&self) -> Result<(), String> { + expected(&self.source).verify_payload(&self.root) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } + } + + #[test] + fn packaged_layout_is_accepted_without_update_configuration_or_writes() { + let fixture = Fixture::new(); + assert!(fixture.verify().is_ok()); + assert_eq!( + fs::read(fixture.root.join(SOURCE_MANIFEST)).unwrap(), + fixture.source + ); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 3); + } + + #[test] + fn missing_files_and_nonregular_files_fail_closed() { + for relative in ["package.json", SOURCE_MANIFEST, WORKER_MANIFEST] { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join(relative)).unwrap(); + assert!(fixture.verify().is_err()); + fs::create_dir(fixture.root.join(relative)).unwrap(); + assert!(fixture.verify().is_err()); + } + let fixture = Fixture::new(); + assert!(expected(&fixture.source) + .verify_payload(&fixture.root.join("missing")) + .is_err()); + assert!(expected(&fixture.source) + .verify_payload(&fixture.root.join("package.json")) + .is_err()); + } + + #[test] + fn wrong_payload_metadata_is_refused_before_startup() { + for relative in ["package.json", SOURCE_MANIFEST, WORKER_MANIFEST] { + let fixture = Fixture::new(); + fixture.write(relative, b"{}"); + assert!(fixture.verify().is_err()); + } + let fixture = Fixture::new(); + fixture.write( + "package.json", + &bytes(&json!({"name": PACKAGE_NAME, "version": OTHER_VERSION})), + ); + assert!(fixture.verify().is_err()); + } + + #[test] + fn oversized_files_are_refused() { + for (relative, limit) in [ + ("package.json", PACKAGE_LIMIT), + (SOURCE_MANIFEST, MANIFEST_LIMIT), + (WORKER_MANIFEST, MANIFEST_LIMIT), + ] { + let fixture = Fixture::new(); + fixture.write(relative, &vec![b' '; limit + 1]); + assert!(fixture.verify().unwrap_err().contains("size limit")); + } + } + + #[cfg(unix)] + #[test] + fn symlinked_files_or_manifest_directories_are_refused() { + use std::os::unix::fs::symlink; + for relative in ["package.json", SOURCE_MANIFEST, WORKER_MANIFEST] { + let fixture = Fixture::new(); + let file = fixture.root.join(relative); + let moved = file.with_extension("saved"); + fs::rename(&file, &moved).unwrap(); + symlink(moved.file_name().unwrap(), &file).unwrap(); + assert!(fixture.verify().is_err()); + } + let fixture = Fixture::new(); + fs::rename( + fixture.root.join("server"), + fixture.root.join("saved-server"), + ) + .unwrap(); + symlink("saved-server", fixture.root.join("server")).unwrap(); + assert!(fixture.verify().is_err()); + } + + #[test] + fn errors_do_not_echo_payload_controlled_values() { + let expected = expected(&bytes(&manifest())); + let sentinel = "sentinel-secret-file-path-and-token"; + let value = json!({"name": PACKAGE_NAME, "version": sentinel}); + assert!(!expected + .verify_package(&bytes(&value)) + .unwrap_err() + .contains(sentinel)); + assert!(!expected + .verify_package(sentinel.as_bytes()) + .unwrap_err() + .contains(sentinel)); + } +} diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs index 26f86584..911bcf6f 100644 --- a/src-tauri/src/lifecycle.rs +++ b/src-tauri/src/lifecycle.rs @@ -6,6 +6,12 @@ use std::{ use tauri::{AppHandle, Manager, Window}; use tokio::sync::Notify; +#[derive(Debug, PartialEq, Eq)] +pub enum StartError { + Admission(String), + Spawn(String), +} + pub struct SidecarLifecycle { pid: std::sync::Mutex>, shutting_down: AtomicBool, @@ -29,13 +35,17 @@ impl SidecarLifecycle { /// A repeated Retry must not replace the server whose exit we still await. pub fn start( &self, + admit: impl FnOnce() -> Result<(), String>, spawn: impl FnOnce() -> Result<(u32, T), String>, - ) -> Result, String> { + ) -> Result, StartError> { let mut pid = self.pid.lock().expect("sidecar lifecycle lock poisoned"); if pid.is_some() || self.is_shutting_down() { return Ok(None); } - let (started_pid, child) = spawn()?; + // Admission runs while this PID/shutdown lock is held and directly + // before spawn, so Retry and Quit cannot race a pre-server refusal. + admit().map_err(StartError::Admission)?; + let (started_pid, child) = spawn().map_err(StartError::Spawn)?; *pid = Some(started_pid); Ok(Some(child)) } @@ -256,7 +266,7 @@ mod tests { #[test] fn shutdown_is_started_once() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((42, ()))).unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); assert_eq!(lifecycle.begin_shutdown(), None); } @@ -268,7 +278,10 @@ mod tests { assert_eq!(lifecycle.begin_shutdown(), None); assert!(lifecycle.shutdown_complete()); assert_eq!( - lifecycle.start::<()>(|| panic!("closing during startup must prevent a late spawn")), + lifecycle.start::<()>( + || panic!("closing during startup must prevent admission"), + || panic!("closing during startup must prevent a late spawn"), + ), Ok(None) ); } @@ -276,7 +289,7 @@ mod tests { #[test] fn repeated_close_cannot_release_the_shutdown_fence_early() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((42, ()))).unwrap(); assert!(!lifecycle.shutdown_complete()); assert_eq!(lifecycle.begin_shutdown(), Some(42)); assert!(!lifecycle.shutdown_complete()); @@ -291,7 +304,10 @@ mod tests { "the final app.exit() must proceed" ); assert_eq!( - lifecycle.start::<()>(|| panic!("a completed shutdown must still reject Retry")), + lifecycle.start::<()>( + || panic!("a completed shutdown must still reject admission"), + || panic!("a completed shutdown must still reject Retry"), + ), Ok(None) ); } @@ -299,7 +315,7 @@ mod tests { #[test] fn unexpected_server_exit_does_not_count_as_a_requested_shutdown() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((42, ()))).unwrap(); lifecycle.exited(42); assert!(!lifecycle.shutdown_complete()); assert_eq!(lifecycle.begin_shutdown(), None); @@ -309,7 +325,7 @@ mod tests { #[test] fn exit_before_waiting_completes_shutdown_immediately() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((42, ()))).unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); lifecycle.exited(42); tauri::async_runtime::block_on(async { @@ -324,16 +340,19 @@ mod tests { fn retry_does_not_spawn_another_server_until_the_previous_one_exits() { let lifecycle = SidecarLifecycle::default(); assert_eq!( - lifecycle.start(|| Ok((42, "first"))).unwrap(), + lifecycle.start(|| Ok(()), || Ok((42, "first"))).unwrap(), Some("first") ); assert_eq!( - lifecycle.start::<()>(|| panic!("the previous server is still tracked")), + lifecycle.start::<()>( + || panic!("the previous server is still tracked"), + || panic!("the previous server is still tracked"), + ), Ok(None) ); lifecycle.exited(42); assert_eq!( - lifecycle.start(|| Ok((43, "retry"))).unwrap(), + lifecycle.start(|| Ok(()), || Ok((43, "retry"))).unwrap(), Some("retry") ); lifecycle.exited(42); @@ -344,17 +363,74 @@ mod tests { fn failed_spawn_can_retry_but_shutdown_cannot_spawn() { let lifecycle = SidecarLifecycle::default(); assert!(lifecycle - .start::<()>(|| Err("spawn failed".to_owned())) + .start::<()>(|| Ok(()), || Err("spawn failed".to_owned())) .is_err()); - assert_eq!(lifecycle.start(|| Ok((42, ()))).unwrap(), Some(())); + assert_eq!( + lifecycle.start(|| Ok(()), || Ok((42, ()))).unwrap(), + Some(()) + ); assert_eq!(lifecycle.begin_shutdown(), Some(42)); lifecycle.exited(42); assert_eq!( - lifecycle.start::<()>(|| panic!("Quit already began")), + lifecycle.start::<()>( + || panic!("Quit already began"), + || panic!("Quit already began"), + ), Ok(None) ); } + #[test] + fn denied_admission_never_invokes_spawn() { + let lifecycle = SidecarLifecycle::default(); + let spawned = std::sync::atomic::AtomicBool::new(false); + assert_eq!( + lifecycle.start::<()>( + || Err("pending update attempt".to_owned()), + || { + spawned.store(true, Ordering::SeqCst); + Ok((42, ())) + }, + ), + Err(StartError::Admission("pending update attempt".to_owned())) + ); + assert!(!spawned.load(Ordering::SeqCst)); + assert!(!lifecycle.has_sidecar()); + } + + #[cfg(target_os = "macos")] + #[test] + fn repeated_retry_cannot_bypass_the_same_update_attempt_record() { + let root = std::fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "gajae-lifecycle-update-attempt-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + assert!(crate::updater_attempt::check(&root).is_ok()); + std::fs::write(root.join("desktop-update-attempt.json"), b"pending").unwrap(); + let lifecycle = SidecarLifecycle::default(); + for _ in 0..2 { + let result = lifecycle.start::<()>( + || crate::updater_attempt::check(&root), + || panic!("a present update attempt must deny every retry"), + ); + match result { + Err(StartError::Admission(reason)) => { + assert!(reason.contains("desktop-update-attempt.json")) + } + result => panic!("expected record admission refusal, got {result:?}"), + } + assert!(!lifecycle.has_sidecar()); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn quit_cannot_miss_a_spawn_whose_pid_is_not_yet_published() { use std::sync::{Arc, Barrier}; @@ -365,10 +441,13 @@ mod tests { let spawn_barrier = Arc::clone(&spawning); let starting_lifecycle = &lifecycle; let start = threads.spawn(move || { - starting_lifecycle.start(|| { - spawn_barrier.wait(); - Ok((42, ())) - }) + starting_lifecycle.start( + || Ok(()), + || { + spawn_barrier.wait(); + Ok((42, ())) + }, + ) }); spawning.wait(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); @@ -379,7 +458,7 @@ mod tests { #[test] fn shutdown_waits_for_the_tracked_server_to_exit() { let lifecycle = SidecarLifecycle::default(); - lifecycle.start(|| Ok((42, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((42, ()))).unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(42)); tauri::async_runtime::block_on(async { let mut waiting = Box::pin(lifecycle.wait_for_exit()); diff --git a/src-tauri/src/macos_instance.rs b/src-tauri/src/macos_instance.rs new file mode 100644 index 00000000..65aeeff1 --- /dev/null +++ b/src-tauri/src/macos_instance.rs @@ -0,0 +1,294 @@ +//! Bounded macOS single-instance ownership. +//! +//! The returned guard owns the open file descriptor and its advisory lock. It +//! deliberately does not remove the lock path or unlock before the guard is +//! dropped, so a successor started during shutdown can wait for ownership. +use std::{ + fmt, + fs::{self, File, OpenOptions}, + io, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::Path, + thread, + time::{Duration, Instant}, +}; + +use fs2::FileExt; + +pub(crate) const HANDOFF_TIMEOUT: Duration = Duration::from_secs(5); +const RETRY_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Debug)] +pub(crate) enum LockError { + Contended, + Open(io::Error), + Lock(io::Error), +} + +impl LockError { + pub(crate) fn is_contended(&self) -> bool { + matches!(self, Self::Contended) + } +} + +impl fmt::Display for LockError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Contended => { + formatter.write_str("desktop instance lock is held by another process") + } + Self::Open(error) => write!(formatter, "could not open desktop instance lock: {error}"), + Self::Lock(error) => write!( + formatter, + "could not acquire desktop instance lock: {error}" + ), + } + } +} + +impl std::error::Error for LockError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Contended => None, + Self::Open(error) | Self::Lock(error) => Some(error), + } + } +} + +#[derive(Debug)] +pub(crate) struct InstanceLock { + // Keeping this descriptor in the guard keeps the flock held for the whole + // process lifetime. There is intentionally no explicit unlock or unlink. + _file: File, +} + +pub(crate) fn acquire(path: &Path) -> Result { + acquire_until(path, Instant::now() + HANDOFF_TIMEOUT) +} + +/// Acquire a lock with an injectable absolute deadline. Production callers use +/// [`acquire`]; tests and the disposable probe use short bounded deadlines. +pub(crate) fn acquire_until(path: &Path, deadline: Instant) -> Result { + let file = open_lock_file(path)?; + loop { + // Check immediately before every non-blocking flock attempt. A + // deadline expiring during the retry sleep must never turn into a + // late ownership success. + if Instant::now() >= deadline { + return Err(LockError::Contended); + } + match file.try_lock_exclusive() { + Ok(()) => return Ok(InstanceLock { _file: file }), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(LockError::Contended); + } + thread::sleep(remaining.min(RETRY_INTERVAL)); + } + Err(error) => return Err(LockError::Lock(error)), + } + } +} + +fn open_lock_file(path: &Path) -> Result { + // std also sets close-on-exec, so child servers cannot retain this lock. + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(LockError::Open)?; + + let descriptor = file.metadata().map_err(LockError::Open)?; + if !descriptor.file_type().is_file() { + return Err(LockError::Open(io::Error::new( + io::ErrorKind::PermissionDenied, + "desktop instance lock descriptor must be a regular file", + ))); + } + #[cfg(unix)] + { + let path_metadata = fs::symlink_metadata(path).map_err(LockError::Open)?; + if !path_metadata.file_type().is_file() + || descriptor.dev() != path_metadata.dev() + || descriptor.ino() != path_metadata.ino() + { + return Err(LockError::Open(io::Error::new( + io::ErrorKind::PermissionDenied, + "desktop instance lock path changed while opening", + ))); + } + } + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + sync::{mpsc, Arc, Barrier}, + thread, + }; + + struct TempDirectory(std::path::PathBuf); + + impl TempDirectory { + fn new() -> Self { + let mut entropy = [0; 16]; + getrandom::getrandom(&mut entropy).unwrap(); + let id = u128::from_ne_bytes(entropy); + let path = std::env::temp_dir() + .join(format!("gajae-macos-instance-{}-{id}", std::process::id())); + fs::create_dir(&path).expect("create temporary lock directory"); + Self(path) + } + + fn lock_path(&self) -> std::path::PathBuf { + self.0.join("desktop.lock") + } + } + + impl Drop for TempDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn held_lock_blocks_until_deadline_then_release_admits_a_new_owner() { + let directory = TempDirectory::new(); + let path = directory.lock_path(); + let first = acquire(&path).expect("first owner acquires"); + let error = acquire_until(&path, Instant::now() + Duration::from_millis(60)) + .expect_err("held lock must not be reported as acquired"); + assert!(error.is_contended()); + assert!(path.is_file()); + drop(first); + let second = acquire_until(&path, Instant::now() + Duration::from_millis(60)) + .expect("released lock admits a successor"); + drop(second); + assert!(path.is_file()); + } + + #[test] + fn two_contenders_never_own_the_lock_simultaneously() { + let directory = TempDirectory::new(); + let path = directory.lock_path(); + let first = acquire(&path).expect("first owner acquires"); + let start = Arc::new(Barrier::new(3)); + let (result_tx, result_rx) = mpsc::channel(); + let mut releases = Vec::new(); + let mut workers = Vec::new(); + for id in 0..2 { + let start = Arc::clone(&start); + let path = path.clone(); + let result_tx = result_tx.clone(); + let (release_tx, release_rx) = mpsc::channel(); + releases.push(release_tx); + workers.push(thread::spawn(move || { + start.wait(); + match acquire_until(&path, Instant::now() + Duration::from_millis(180)) { + Ok(lock) => { + result_tx.send((id, true)).unwrap(); + release_rx + .recv_timeout(Duration::from_secs(1)) + .expect("owner release signal"); + drop(lock); + } + Err(error) if error.is_contended() => { + result_tx.send((id, false)).unwrap(); + } + Err(error) => panic!("unexpected lock error: {error}"), + } + })); + } + drop(result_tx); + start.wait(); + drop(first); + let results = [result_rx.recv().unwrap(), result_rx.recv().unwrap()]; + assert_eq!(results.iter().filter(|(_, acquired)| *acquired).count(), 1); + assert_eq!(results.iter().filter(|(_, acquired)| !*acquired).count(), 1); + let owner = results + .iter() + .find_map(|(id, acquired)| acquired.then_some(*id)) + .unwrap(); + releases[owner].send(()).unwrap(); + for worker in workers { + worker.join().unwrap(); + } + } + + #[test] + fn child_exec_cannot_inherit_the_instance_lock() { + use std::os::fd::AsRawFd; + + let directory = TempDirectory::new(); + let owner = acquire(&directory.lock_path()).unwrap(); + let flags = unsafe { libc::fcntl(owner._file.as_raw_fd(), libc::F_GETFD) }; + assert!(flags >= 0); + assert_ne!(flags & libc::FD_CLOEXEC, 0); + } + + #[test] + fn open_errors_are_not_misreported_as_contention() { + let directory = TempDirectory::new(); + let path = directory.0.join("not-a-lock"); + fs::create_dir(&path).unwrap(); + let error = acquire_until(&path, Instant::now() + Duration::from_millis(60)) + .expect_err("directory cannot become an instance lock"); + assert!(matches!(error, LockError::Open(_))); + assert!(!error.is_contended()); + } + + #[test] + fn expired_deadline_does_not_attempt_lock_ownership() { + let directory = TempDirectory::new(); + let path = directory.lock_path(); + let owner = acquire(&path).expect("first owner acquires"); + let started = Instant::now(); + let error = + acquire_until(&path, started).expect_err("expired deadline must refuse ownership"); + assert!(error.is_contended()); + assert!( + started.elapsed() < Duration::from_millis(250), + "expired acquisition must not wait for the owner" + ); + drop(owner); + } + + #[cfg(unix)] + #[test] + fn symlink_and_fifo_lock_paths_are_rejected_without_blocking() { + use std::os::unix::fs::symlink; + + let directory = TempDirectory::new(); + let target = directory.0.join("target"); + fs::write(&target, b"not the lock").unwrap(); + let path = directory.lock_path(); + symlink(&target, &path).unwrap(); + let error = acquire_until(&path, Instant::now() + Duration::from_millis(60)) + .expect_err("symlink lock path must be refused"); + assert!(matches!(error, LockError::Open(_))); + assert!(!error.is_contended()); + + fs::remove_file(&path).unwrap(); + assert!(std::process::Command::new("/usr/bin/mkfifo") + .arg(&path) + .status() + .expect("run mkfifo") + .success()); + let started = Instant::now(); + let error = acquire_until(&path, Instant::now() + Duration::from_millis(60)) + .expect_err("FIFO lock path must be refused"); + assert!(matches!(error, LockError::Open(_))); + assert!(!error.is_contended()); + assert!( + started.elapsed() < Duration::from_millis(250), + "FIFO refusal must not block" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a3e780a5..c9c92079 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,33 +1,54 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] use std::fs::OpenOptions; -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] use fs2::FileExt; use tauri::Manager; mod desktop_origin; +mod expected_payload; #[cfg(target_os = "linux")] mod instance; mod lifecycle; +#[cfg(any(target_os = "macos", test))] +mod macos_instance; mod navigation; #[cfg(any(target_os = "macos", test))] mod qa_profile; mod supervisor; - -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "macos")] +mod updater; +#[cfg(target_os = "macos")] +mod updater_archive; +#[cfg(target_os = "macos")] +mod updater_attempt; +#[cfg(target_os = "macos")] +mod updater_binding; +#[cfg(target_os = "macos")] +mod updater_discovery; +#[cfg(target_os = "macos")] +mod updater_manifest; +#[cfg(target_os = "macos")] +mod updater_signature; +#[cfg(target_os = "macos")] +mod updater_store; +#[cfg(target_os = "macos")] +mod updater_transport; + +#[cfg(target_os = "windows")] struct SingleInstanceLock { _file: std::fs::File, } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] fn acquire_single_instance_lock() -> Result { let lock_path = std::env::temp_dir().join("gajae-app-desktop.lock"); acquire_single_instance_lock_at(&lock_path) } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] fn acquire_single_instance_lock_at( lock_path: &std::path::Path, ) -> Result { @@ -42,6 +63,18 @@ fn acquire_single_instance_lock_at( .map_err(|_| "Gajae Code App is already running.".to_owned())?; Ok(SingleInstanceLock { _file: file }) } + +#[cfg(target_os = "macos")] +fn acquire_single_instance_lock() -> Result { + let lock_path = std::env::temp_dir().join("gajae-app-desktop.lock"); + macos_instance::acquire(&lock_path).map_err(|error| { + if error.is_contended() { + "Gajae Code App is already running.".to_owned() + } else { + error.to_string() + } + }) +} fn is_gajae_deep_link(url: &tauri::Url) -> bool { url.scheme() == "gajae-app" } @@ -277,26 +310,34 @@ fn main() { .setup(move |app| { // A held lock means another instance is running. Setup errors // abort inside did_finish_launching (panic_cannot_unwind -> - // SIGABRT -> crash-reporter dialog), so exit cleanly instead; + // SIGABRT -> crash-reporter dialog), so report the bounded + // ownership failure and exit with a nonzero status instead; // macOS LaunchServices focuses the running instance on reopen. + // A failed bounded handoff is still a failed launch: never report + // success when this process did not acquire ownership. #[cfg(not(target_os = "linux"))] let lock_result = { #[cfg(target_os = "macos")] - if qa_profile.is_some() { - // QaProfile already owns its lock, before window creation. - Ok(None) - } else { + { + if qa_profile.is_some() { + // QaProfile already owns its lock, before window + // creation. + Ok(None) + } else { + acquire_single_instance_lock().map(Some) + } + } + #[cfg(target_os = "windows")] + { acquire_single_instance_lock().map(Some) } - #[cfg(not(target_os = "macos"))] - acquire_single_instance_lock().map(Some) }; #[cfg(not(target_os = "linux"))] let lock = match lock_result { Ok(lock) => lock, Err(message) => { eprintln!("{message}"); - std::process::exit(0); + std::process::exit(1); } }; #[cfg(not(target_os = "linux"))] @@ -311,6 +352,8 @@ fn main() { app.manage(lifecycle::SidecarLifecycle::default()); app.manage(supervisor::RecoveryScreen::default()); #[cfg(target_os = "macos")] + app.manage(updater::Preparation::default()); + #[cfg(target_os = "macos")] if let Some(profile) = app.try_state::() { profile.create_windows(app, &qa_windows)?; } @@ -377,6 +420,8 @@ fn main() { app.run( |app: &tauri::AppHandle, event: tauri::RunEvent| match event { tauri::RunEvent::ExitRequested { api, .. } => { + #[cfg(target_os = "macos")] + updater::unhealthy(app); // graceful_quit finishes with app.exit(), which requests exit // again on Linux. Let that request through only after the // sidecar is gone; otherwise closing can never release the @@ -390,6 +435,8 @@ fn main() { } } tauri::RunEvent::Exit => { + #[cfg(target_os = "macos")] + updater::unhealthy(app); // macOS Quit Apple events (Cmd-Q, AppleScript quit) bypass a // preventable ExitRequested in this Tauri version; guarantee // the sidecar's graceful shutdown on every exit path. @@ -511,7 +558,7 @@ mod tests { ); } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] #[test] fn single_instance_lock_is_released_for_a_fresh_launch() { let unique = std::time::SystemTime::now() diff --git a/src-tauri/src/qa_profile.rs b/src-tauri/src/qa_profile.rs index b3abdcb5..41470442 100644 --- a/src-tauri/src/qa_profile.rs +++ b/src-tauri/src/qa_profile.rs @@ -5,8 +5,10 @@ use std::{ fs, io::Write, path::{Path, PathBuf}, + time::Instant, }; +use crate::macos_instance; use serde::{Deserialize, Serialize}; use tauri::utils::config::{Config, WindowConfig}; @@ -26,7 +28,7 @@ pub(crate) struct QaProfile { webkit_store: [u8; 16], // Own the profile before changing directories or constructing any webview. // Tauri creates configured windows before invoking the app setup callback. - _lock: fs::File, + _lock: macos_instance::InstanceLock, } pub(crate) fn requested_root( @@ -96,6 +98,10 @@ fn private_directory(path: &Path) -> Result<(), String> { impl QaProfile { pub(crate) fn open(path: &Path) -> Result { + Self::open_until(path, Instant::now() + macos_instance::HANDOFF_TIMEOUT) + } + + pub(crate) fn open_until(path: &Path, deadline: Instant) -> Result { // A trailing slash or `/.` makes lstat follow the final symlink on // macOS. Remove those lexical suffixes before inspecting the root. let path: PathBuf = path.components().collect(); @@ -144,16 +150,13 @@ impl QaProfile { if fs::symlink_metadata(&lock_path).is_ok_and(|metadata| !metadata.file_type().is_file()) { return Err("QA instance lock must be a regular non-symlink file.".into()); } - let mut options = fs::OpenOptions::new(); - options.read(true).write(true).create(true).truncate(false); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let lock = options.open(lock_path).map_err(|error| error.to_string())?; - fs2::FileExt::try_lock_exclusive(&lock) - .map_err(|_| "This desktop QA profile is already in use.".to_owned())?; + let lock = macos_instance::acquire_until(&lock_path, deadline).map_err(|error| { + if error.is_contended() { + "This desktop QA profile is already in use.".to_owned() + } else { + error.to_string() + } + })?; private_directory(&root)?; let manifest = if let Some(manifest) = manifest { manifest @@ -212,6 +215,10 @@ impl QaProfile { self.root.join("home") } + pub(crate) fn root(&self) -> &Path { + &self.root + } + pub(crate) fn configure(&self, config: &mut Config) -> Vec { let mut windows = Vec::new(); for window in &mut config.app.windows { @@ -429,7 +436,11 @@ mod tests { // A contender must not initialize missing paths before discovering the // owner. This directory is only a fixture, with no sidecar running. fs::remove_dir(profile.home().join(".cache")).unwrap(); - assert!(QaProfile::open(&root.0).is_err()); + assert!(QaProfile::open_until( + &root.0, + Instant::now() + std::time::Duration::from_millis(60) + ) + .is_err()); assert!(!profile.home().join(".cache").exists()); assert_eq!(fs::read(root.0.join(MANIFEST)).unwrap(), manifest); let store = profile.webkit_store; diff --git a/src-tauri/src/supervisor.rs b/src-tauri/src/supervisor.rs index 394f89c3..f06c8b09 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -4,7 +4,7 @@ use std::{ env, io::{Read, Write}, net::TcpStream, - path::PathBuf, + path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -27,6 +27,7 @@ const FAILED_KILL_TIMEOUT: Duration = Duration::from_secs(5); const SESSION_STOP_GRACE: Duration = Duration::from_secs(30); const HEALTH_TIMEOUT: Duration = Duration::from_secs(2); const HEALTH_RESPONSE_LIMIT: usize = 16 * 1024; +const EXPECTED_PAYLOAD_VERSION: &str = env!("GJC_EXPECTED_PAYLOAD_VERSION"); #[derive(Default)] pub(crate) struct RecoveryScreen(std::sync::Mutex>); @@ -49,6 +50,7 @@ impl ReadyFrame { && self.host == "127.0.0.1" && self.port != 0 && self.protocol_version == PROTOCOL_VERSION + && self.version == EXPECTED_PAYLOAD_VERSION } } @@ -124,7 +126,7 @@ fn endpoint(port: u16) -> String { format!("http://127.0.0.1:{port}") } -fn health_check(port: u16, expected_version: &str) -> Result<(), String> { +fn health_check(port: u16) -> Result<(), String> { let deadline = Instant::now() + HEALTH_TIMEOUT; let mut stream = TcpStream::connect_timeout( &format!("127.0.0.1:{port}") @@ -154,7 +156,7 @@ fn health_check(port: u16, expected_version: &str) -> Result<(), String> { if health.status != "ok" || health.product != "gajae-app" || health.protocol_version != PROTOCOL_VERSION - || health.version != expected_version + || health.version != EXPECTED_PAYLOAD_VERSION { return Err("health endpoint identity did not match the supervised server".to_owned()); } @@ -201,6 +203,8 @@ fn recovery_script(message: &str, retry_enabled: bool) -> String { } fn reset_desktop_readiness(app: &AppHandle) { + #[cfg(target_os = "macos")] + crate::updater::unhealthy(app); app.state::().clear(); crate::reset_deep_link_readiness(app); } @@ -238,6 +242,28 @@ fn is_recovery_origin(url: &tauri::Url) -> bool { || (url.scheme() == "http" && url.host_str() == Some("tauri.localhost")) } +pub(crate) fn desktop_data_root(app: &AppHandle) -> Result { + #[cfg(target_os = "macos")] + if let Some(profile) = app.try_state::() { + return Ok(profile.home().join(".gajae-app")); + } + app.path() + .app_local_data_dir() + .map_err(|error| error.to_string()) +} + +fn update_attempt_admission(desktop_data_root: &Path) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + crate::updater_attempt::check(desktop_data_root) + } + #[cfg(not(target_os = "macos"))] + { + let _ = desktop_data_root; + Ok(()) + } +} + /// Called by the main page-load hook after the bundled document finishes. /// Keep the message until the next accepted start, including cleanup updates /// that can arrive while navigation back from a dead HTTP origin is pending. @@ -398,24 +424,29 @@ pub fn start(app: AppHandle) { if lifecycle.is_shutting_down() || lifecycle.has_sidecar() { return; } - let origin_directory = app.path().app_local_data_dir(); - #[cfg(target_os = "macos")] - let origin_directory = - if let Some(profile) = app.try_state::() { - Ok(profile.home().join(".gajae-app")) - } else { - origin_directory - }; - let desktop_origin = match origin_directory - .map_err(|error| error.to_string()) - .and_then(crate::desktop_origin::DesktopOrigin::load) - { - Ok(origin) => origin, + let desktop_data_root = match desktop_data_root(&app) { + Ok(root) => root, Err(error) => { - show_error(&window, &error, true); + show_error(&window, &error, !cfg!(target_os = "macos")); return; } }; + // Classify an already-invalid update-attempt path before origin + // loading can turn it into an ordinary retryable origin error. The + // lifecycle admission below repeats this check under its PID/shutdown + // lock so neither startup nor Retry can skip admission. + if let Err(error) = update_attempt_admission(&desktop_data_root) { + show_error(&window, &error, false); + return; + } + let desktop_origin = + match crate::desktop_origin::DesktopOrigin::load(desktop_data_root.clone()) { + Ok(origin) => origin, + Err(error) => { + show_error(&window, &error, true); + return; + } + }; let payload = match payload_root(&app) { Ok(payload) => payload, Err(error) => { @@ -423,6 +454,12 @@ pub fn start(app: AppHandle) { return; } }; + if let Err(error) = crate::expected_payload::ExpectedPayload::compiled() + .and_then(|expected| expected.verify_payload(&payload)) + { + show_error(&window, &error, true); + return; + } let api_key = match random_secret() { Ok(value) => value, Err(error) => { @@ -440,48 +477,56 @@ pub fn start(app: AppHandle) { let home = env::var("HOME").unwrap_or_default(); let path = env::var("PATH").unwrap_or_default(); let entrypoint = payload.join("dist-server/server/index.js"); - let command = lifecycle.start(|| { - reset_desktop_readiness(&app); - *app.state::() - .0 - .lock() - .expect("recovery screen lock poisoned") = None; - let command = app - .shell() - .sidecar("gajae-app-server") - .map_err(|error| format!("could not prepare server sidecar: {error}"))? - .arg(entrypoint.to_string_lossy().as_ref()); - #[cfg(target_os = "macos")] - let command = if let Some(profile) = app.try_state::() { - if payload.join(".env").exists() { - return Err( - "QA refuses a server payload containing an environment file.".into(), - ); - } - command - .env_clear() - .envs(profile.environment()) - .current_dir(profile.home()) - } else { - command.env("HOME", &home).env("PATH", &path) - }; - #[cfg(not(target_os = "macos"))] - let command = command.env("HOME", &home).env("PATH", &path); - let (events, child) = command - .env("HOST", "127.0.0.1") - .env("SERVER_PORT", desktop_origin.requested_port().to_string()) - .env("NODE_ENV", "production") - .env("GJC_DESKTOP", "1") - .env("GJC_DESKTOP_API_KEY", api_key) - .env("GJC_DESKTOP_BOOTSTRAP_NONCE", &nonce) - .spawn() - .map_err(|error| format!("could not start server sidecar: {error}"))?; - Ok((child.pid(), (events, child))) - }); + let command = lifecycle.start( + || update_attempt_admission(&desktop_data_root), + || { + reset_desktop_readiness(&app); + *app.state::() + .0 + .lock() + .expect("recovery screen lock poisoned") = None; + let command = app + .shell() + .sidecar("gajae-app-server") + .map_err(|error| format!("could not prepare server sidecar: {error}"))? + .arg(entrypoint.to_string_lossy().as_ref()); + #[cfg(target_os = "macos")] + let command = if let Some(profile) = app.try_state::() + { + if payload.join(".env").exists() { + return Err( + "QA refuses a server payload containing an environment file.".into(), + ); + } + command + .env_clear() + .envs(profile.environment()) + .current_dir(profile.home()) + } else { + command.env("HOME", &home).env("PATH", &path) + }; + #[cfg(not(target_os = "macos"))] + let command = command.env("HOME", &home).env("PATH", &path); + let (events, child) = command + .env("HOST", "127.0.0.1") + .env("SERVER_PORT", desktop_origin.requested_port().to_string()) + .env("NODE_ENV", "production") + .env("GJC_DESKTOP", "1") + .env("GJC_DESKTOP_API_KEY", api_key) + .env("GJC_DESKTOP_BOOTSTRAP_NONCE", &nonce) + .spawn() + .map_err(|error| format!("could not start server sidecar: {error}"))?; + Ok((child.pid(), (events, child))) + }, + ); let (mut events, child) = match command { Ok(Some(child)) => child, Ok(None) => return, - Err(error) => { + Err(crate::lifecycle::StartError::Admission(error)) => { + show_error(&window, &error, false); + return; + } + Err(crate::lifecycle::StartError::Spawn(error)) => { show_error(&window, &error, true); return; } @@ -572,7 +617,7 @@ pub fn start(app: AppHandle) { if !ready_frame.matches_sidecar(sidecar_pid) { continue; } - match health_check(ready_frame.port, &ready_frame.version) { + match health_check(ready_frame.port) { Ok(()) => { // Quit can arrive during the health request. if lifecycle.is_shutting_down() { @@ -591,6 +636,8 @@ pub fn start(app: AppHandle) { return; } ready = true; + #[cfg(target_os = "macos")] + crate::updater::after_healthy(&app); break; } Err(error) => { @@ -644,7 +691,7 @@ mod tests { #[test] fn health_check_accepts_only_the_expected_server_identity() { - for version in ["expected", "wrong"] { + for version in [EXPECTED_PAYLOAD_VERSION, "wrong"] { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let responder = std::thread::spawn(move || { @@ -665,13 +712,84 @@ mod tests { .unwrap(); }); assert_eq!( - health_check(port, "expected").is_ok(), - version == "expected" + health_check(port).is_ok(), + version == EXPECTED_PAYLOAD_VERSION ); responder.join().unwrap(); } } + #[test] + fn ready_frame_and_health_agreeing_on_wrong_product_version_are_refused() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let child_version = "9.9.9"; + let responder = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = [0u8; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + let body = format!( + r#"{{"status":"ok","product":"gajae-app","protocolVersion":1,"version":"{child_version}"}}"# + ); + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + let ready = ReadyFrame { + kind: READY_KIND.to_owned(), + pid: 1, + host: "127.0.0.1".to_owned(), + port, + protocol_version: PROTOCOL_VERSION, + version: child_version.to_owned(), + }; + + assert!(!ready.matches_sidecar(1)); + assert!(health_check(port).is_err()); + responder.join().unwrap(); + } + + #[test] + fn matching_ready_frame_and_health_pass_compiled_product_version() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let responder = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = [0u8; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + let body = format!( + r#"{{"status":"ok","product":"gajae-app","protocolVersion":1,"version":"{EXPECTED_PAYLOAD_VERSION}"}}"# + ); + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + let ready = ReadyFrame { + kind: READY_KIND.to_owned(), + pid: 1, + host: "127.0.0.1".to_owned(), + port, + protocol_version: PROTOCOL_VERSION, + version: EXPECTED_PAYLOAD_VERSION.to_owned(), + }; + + assert!(ready.matches_sidecar(1)); + assert!(health_check(port).is_ok()); + responder.join().unwrap(); + } + #[test] fn trickling_health_response_cannot_extend_the_deadline() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -825,7 +943,7 @@ mod tests { for send_exit in [true, false] { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, send_exit); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((child.pid, ()))).unwrap(); let mut stopping = Box::pin(stop_failed_sidecar( &lifecycle, child.pid, @@ -839,7 +957,10 @@ mod tests { .is_err()); assert!(lifecycle.has_sidecar()); assert_eq!( - lifecycle.start::<()>(|| panic!("cleanup still owns the child")), + lifecycle.start::<()>( + || panic!("cleanup still owns the child"), + || panic!("cleanup still owns the child"), + ), Ok(None) ); time::timeout(Duration::from_secs(3), stopping) @@ -849,7 +970,10 @@ mod tests { assert_eq!(child.status().signal(), Some(9)); assert!(!crate::lifecycle::process_alive(child.pid)); let (mut retry, mut retry_events) = TestChild::spawn(false, true); - assert_eq!(lifecycle.start(|| Ok((retry.pid, ()))).unwrap(), Some(())); + assert_eq!( + lifecycle.start(|| Ok(()), || Ok((retry.pid, ()))).unwrap(), + Some(()) + ); lifecycle.exited(child.pid); assert!( lifecycle.has_sidecar(), @@ -875,7 +999,7 @@ mod tests { tauri::async_runtime::block_on(async { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, true); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((child.pid, ()))).unwrap(); assert_eq!(lifecycle.begin_shutdown(), Some(child.pid)); assert_eq!(lifecycle.begin_shutdown(), None); assert!(!lifecycle.shutdown_complete()); @@ -892,7 +1016,10 @@ mod tests { assert_eq!(child.status().signal(), Some(9)); assert!(lifecycle.shutdown_complete()); assert_eq!( - lifecycle.start::<()>(|| panic!("Quit already began")), + lifecycle.start::<()>( + || panic!("Quit already began"), + || panic!("Quit already began"), + ), Ok(None) ); }); @@ -903,7 +1030,7 @@ mod tests { tauri::async_runtime::block_on(async { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, false); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((child.pid, ()))).unwrap(); let error = time::timeout( Duration::from_secs(2), stop_failed_sidecar( @@ -922,7 +1049,10 @@ mod tests { assert!(lifecycle.has_sidecar()); assert!(crate::lifecycle::process_alive(child.pid)); assert_eq!( - lifecycle.start::<()>(|| panic!("exit remains unconfirmed")), + lifecycle.start::<()>( + || panic!("exit remains unconfirmed"), + || panic!("exit remains unconfirmed"), + ), Ok(None) ); child.input.write_all(b"exit\n").unwrap(); @@ -940,7 +1070,7 @@ mod tests { tauri::async_runtime::block_on(async { let lifecycle = SidecarLifecycle::default(); let (mut child, mut events) = TestChild::spawn(true, true); - lifecycle.start(|| Ok((child.pid, ()))).unwrap(); + lifecycle.start(|| Ok(()), || Ok((child.pid, ()))).unwrap(); let result = stop_failed_sidecar( &lifecycle, child.pid, @@ -965,7 +1095,10 @@ mod tests { #[test] fn ready_frame_requires_loopback_contract() { - let ready: ReadyFrame = serde_json::from_str(r#"{"kind":"gajae-desktop-ready","pid":1,"host":"127.0.0.1","port":1234,"protocolVersion":1,"version":"0.2.0"}"#).unwrap(); + let ready: ReadyFrame = serde_json::from_str(&format!( + r#"{{"kind":"gajae-desktop-ready","pid":1,"host":"127.0.0.1","port":1234,"protocolVersion":1,"version":"{EXPECTED_PAYLOAD_VERSION}"}}"# + )) + .unwrap(); assert!(ready.matches_sidecar(1)); assert!(!ready.matches_sidecar(2)); for (field, value) in [ @@ -974,8 +1107,9 @@ mod tests { ("host", serde_json::json!("example.com")), ("port", serde_json::json!(0)), ("protocolVersion", serde_json::json!(2)), + ("version", serde_json::json!("9.9.9")), ] { - let mut frame = serde_json::json!({"kind":READY_KIND,"pid":1,"host":"127.0.0.1","port":1234,"protocolVersion":1,"version":"0.2.0"}); + let mut frame = serde_json::json!({"kind":READY_KIND,"pid":1,"host":"127.0.0.1","port":1234,"protocolVersion":1,"version":EXPECTED_PAYLOAD_VERSION}); frame[field] = value; assert!(!serde_json::from_value::(frame) .unwrap() diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 00000000..9f51efa3 --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,1055 @@ +//! Preparation-only updater owner. Installation, restart, attempt resolution and +//! browser authority remain deliberately unavailable until their safety gates +//! are proven. No official plugin install/download API is called here. +use std::{ + future::Future, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use semver::Version; +use serde::Serialize; +use tauri::{AppHandle, Manager}; +use tokio::sync::Notify; + +use crate::{ + updater_archive::{inspect_archive, ArchiveIdentity}, + updater_binding::{Binding, Mode}, + updater_discovery::{ + self, DiscoveryCompleteness, DiscoveryCursor, DiscoveryError, DiscoveryPolicy, + SelectedRelease, + }, + updater_manifest::{parse_manifest, Channel, Manifest, ProductIdentity}, + updater_signature::{digest, verify_archive}, + updater_store::{PreparedRecord, Store}, + updater_transport::{build_client, HttpsClient}, +}; + +const INTERVAL_SECONDS: u64 = 6 * 60 * 60; +const CONTINUATION_DELAY: Duration = Duration::from_secs(60); + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Disabled, + Idle, + Checking, + Downloading, + Verifying, + Ready, + Deferred, + Error, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub phase: Phase, + pub automatic: bool, + pub product_version: &'static str, + pub desktop_version: &'static str, + pub target_product_version: Option, + pub target_desktop_version: Option, + pub discovery_incomplete: bool, + pub reason: Option<&'static str>, + /// A staged archive is NOT installation permission or installation proof. + pub installation_available: bool, +} + +impl Default for Snapshot { + fn default() -> Self { + Self { + phase: Phase::Disabled, + automatic: false, + product_version: env!("GJC_EXPECTED_PAYLOAD_VERSION"), + desktop_version: env!("CARGO_PKG_VERSION"), + target_product_version: None, + target_desktop_version: None, + discovery_incomplete: true, + reason: None, + installation_available: false, + } + } +} + +struct Control { + snapshot: Snapshot, + snapshot_generation: u64, + in_flight: bool, + requested: bool, + restart_requested: bool, + next_due: Instant, + not_before: Instant, + failures: usize, + store: Option>, + verified: Option, +} + +impl Default for Control { + fn default() -> Self { + Self { + snapshot: Snapshot::default(), + snapshot_generation: 0, + in_flight: false, + requested: false, + restart_requested: false, + next_due: Instant::now(), + not_before: Instant::now(), + failures: 0, + store: None, + verified: None, + } + } +} + +#[derive(Default)] +struct Coordinator { + control: Mutex, + changed: Notify, + started: AtomicBool, + // Event-thread cancellation must never wait for cache I/O or fsync. + healthy: AtomicBool, + generation: AtomicU64, +} + +#[derive(Default)] +pub(crate) struct Preparation(Arc); + +impl Coordinator { + /// The only snapshot publication boundary. Raw state may have been written + /// by a worker racing nonblocking invalidation; its epoch cannot be exposed + /// as Ready after that epoch has retired. + #[allow(dead_code)] + fn snapshot(&self) -> Snapshot { + let control = self.control.lock().expect("update owner lock poisoned"); + let mut snapshot = control.snapshot.clone(); + if snapshot.phase != Phase::Disabled && !self.valid(control.snapshot_generation) { + snapshot.phase = Phase::Deferred; + snapshot.reason = Some("preparation_cancelled"); + } + snapshot + } + + fn healthy_start(&self) -> bool { + let mut control = self.control.lock().expect("update owner lock poisoned"); + let was_healthy = self.healthy.swap(true, Ordering::AcqRel); + if !was_healthy { + self.generation.fetch_add(1, Ordering::AcqRel); + } + control.next_due = Instant::now(); + if self.started.load(Ordering::Acquire) { + // Retirement must consume this notification or keep its owner alive. + control.restart_requested = true; + self.changed.notify_one(); + return false; + } + self.generation.fetch_add(1, Ordering::AcqRel); + control.restart_requested = false; + self.started.store(true, Ordering::Release); + self.changed.notify_one(); + true + } + + fn valid(&self, generation: u64) -> bool { + self.healthy.load(Ordering::Acquire) + && self.generation.load(Ordering::Acquire) == generation + } + + fn invalidate(&self) { + self.healthy.store(false, Ordering::Release); + self.generation.fetch_add(1, Ordering::AcqRel); + self.changed.notify_one(); + if let Ok(mut control) = self.control.try_lock() { + control.snapshot.phase = Phase::Deferred; + control.snapshot.reason = Some("server_not_ready"); + } + } + + fn accept_cached(&self, generation: u64, target: VerifiedTarget) -> Result<(), PrepareError> { + let mut control = self.control.lock().expect("update owner lock poisoned"); + if !self.valid(generation) { + return Err(PrepareError::Cancelled); + } + set_target(&mut control.snapshot, &target.manifest); + control.snapshot.phase = Phase::Ready; + control.snapshot_generation = generation; + control.verified = Some(target); + Ok(()) + } + + fn retire(&self, result: Result<(), PrepareError>) -> bool { + let mut control = self.control.lock().expect("update owner lock poisoned"); + control.in_flight = false; + control.store = None; + control.verified = None; + if let Err(error) = result { + control.snapshot.phase = Phase::Error; + control.snapshot.reason = Some(error.code()); + control.snapshot.automatic = false; + control.snapshot_generation = self.generation.load(Ordering::Acquire); + eprintln!("desktop updater preparation unavailable: {}", error.code()); + } + let restart = control.restart_requested && self.healthy.load(Ordering::Acquire); + control.restart_requested = false; + if !restart { + control.requested = false; + } + // Claiming and retirement use the same mutex. A concurrent healthy + // callback either keeps this owner alive or claims after it retires. + self.started.store(restart, Ordering::Release); + restart + } + + fn phase(&self, generation: u64, phase: Phase) -> Result<(), PrepareError> { + let mut control = self.control.lock().expect("update owner lock poisoned"); + if !self.valid(generation) { + return Err(PrepareError::Cancelled); + } + control.snapshot.phase = phase; + control.snapshot_generation = generation; + control.snapshot.reason = None; + Ok(()) + } + + /// Used only by a future authenticated native bridge. No remote Tauri grant + /// or backend/browser route is installed by this preparation slice. + #[allow(dead_code)] + fn set_automatic(&self, automatic: bool) -> Result<(), &'static str> { + let mut control = self.control.lock().map_err(|_| "updater_unavailable")?; + let store = control.store.as_ref().ok_or("updater_inactive")?.clone(); + // Serialize the durable preference acknowledgement with ready publication. + // Even a disk failure cancels this generation in memory, without claiming + // the opt-out was persisted. Automatic checking cannot continue silently. + control.snapshot_generation = self + .generation + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + control.requested = false; + control.snapshot.automatic = false; + if store.set_automatic(automatic).is_err() { + control.snapshot.phase = Phase::Error; + control.snapshot.reason = Some("preferences_not_persisted"); + self.changed.notify_one(); + return Err("preferences_not_persisted"); + } + control.snapshot.automatic = automatic; + control.snapshot.phase = Phase::Idle; + control.requested = automatic; + control.next_due = Instant::now(); + self.changed.notify_one(); + Ok(()) + } + + #[allow(dead_code)] + fn manual_check(&self) -> Result<(), &'static str> { + let mut control = self.control.lock().map_err(|_| "updater_unavailable")?; + if control.store.is_none() + || !self.healthy.load(Ordering::Acquire) + || !self.started.load(Ordering::Acquire) + { + return Err("updater_inactive"); + } + // Coalesce repeated requests; manual checking never modifies consent. + if !control.in_flight { + control.requested = true; + control.restart_requested = true; + self.changed.notify_one(); + } + Ok(()) + } +} + +pub(crate) fn unhealthy(app: &AppHandle) { + let Some(preparation) = app.try_state::() else { + return; + }; + preparation.0.invalidate(); +} + +/// This hook runs only AFTER independent payload health and navigation succeed. +/// It never delays sidecar startup and never grants apply/restart authority. +pub(crate) fn after_healthy(app: &AppHandle) { + if app + .state::() + .is_shutting_down() + { + return; + } + let binding = Binding::compiled(); + let profile = app.try_state::(); + if !cfg!(target_arch = "aarch64") + || !binding.admits_profile(profile.as_ref().map(|p| p.root()), !cfg!(debug_assertions)) + { + return; + } + let owner = app.state::().0.clone(); + if owner.healthy_start() { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + let result = run(&app, owner.clone(), binding.clone()).await; + if !owner.retire(result) { + break; + } + } + }); + } +} + +#[derive(Debug)] +enum PrepareError { + Cancelled, + Binding, + Cache, + Signature, + Archive, + Policy, + Worker, + Discovery(DiscoveryError), +} +impl PrepareError { + fn code(&self) -> &'static str { + match self { + Self::Cancelled => "preparation_cancelled", + Self::Binding => "binding_mismatch", + Self::Cache => "cache_invalid", + Self::Signature => "signature_rejected", + Self::Archive => "archive_rejected", + Self::Policy => "candidate_ineligible", + Self::Worker => "verification_worker_failed", + Self::Discovery(_) => "discovery_failed", + } + } +} + +struct Runtime { + store: Arc, + client: HttpsClient, + policy: DiscoveryPolicy, + binding: Binding, + os: String, +} + +#[derive(Clone)] +struct VerifiedTarget { + manifest: Manifest, + release_id: u64, + manifest_asset_id: u64, + archive_asset_id: u64, +} +impl VerifiedTarget { + fn matches(&self, selected: &SelectedRelease) -> bool { + self.manifest == selected.manifest + && self.release_id == selected.release.id + && self.manifest_asset_id == selected.manifest_asset.id + && self.archive_asset_id == selected.archive_asset.id + } +} + +fn initialize(app: &AppHandle, binding: Binding) -> Result { + let profile = app.try_state::(); + let root = crate::supervisor::desktop_data_root(app).map_err(|_| PrepareError::Binding)?; + let executable = std::env::current_exe().map_err(|_| PrepareError::Binding)?; + binding + .validate_runtime( + profile.as_ref().map(|p| p.root()), + &executable, + &root, + !cfg!(debug_assertions), + ) + .map_err(|_| PrepareError::Binding)?; + let os = std::process::Command::new("/usr/bin/sw_vers") + .arg("-productVersion") + .output() + .map_err(|_| PrepareError::Binding)?; + if !os.status.success() || os.stdout.len() > 128 { + return Err(PrepareError::Binding); + } + let os = String::from_utf8(os.stdout) + .map_err(|_| PrepareError::Binding)? + .trim() + .to_owned(); + let version = Version::parse(env!("CARGO_PKG_VERSION")).map_err(|_| PrepareError::Policy)?; + let channel = installed_channel()?; + let policy = match binding.mode { + Mode::Production => DiscoveryPolicy::production(&identity(), version, channel, &os), + Mode::Qa => DiscoveryPolicy::qa( + &identity(), + version, + channel, + &os, + binding + .feed_origin + .parse() + .map_err(|_| PrepareError::Binding)?, + ), + Mode::Disabled => return Err(PrepareError::Binding), + } + .map_err(PrepareError::Discovery)?; + let store = Arc::new(Store::open(&root).map_err(|_| PrepareError::Cache)?); + // The fixture CA is compiled into QA only; never disable TLS validation or + // accept a runtime/browser-supplied trust root. + let certificate = if binding.mode == Mode::Qa { + let pem = STANDARD + .decode(env!("GJC_UPDATE_QA_CA_CERT")) + .map_err(|_| PrepareError::Binding)?; + Some(reqwest::Certificate::from_pem(&pem).map_err(|_| PrepareError::Binding)?) + } else { + None + }; + let client = build_client( + certificate, + Duration::from_secs(5), + Duration::from_secs(10 * 60), + ) + .map_err(|_| PrepareError::Binding)?; + Ok(Runtime { + store, + client, + policy, + binding, + os, + }) +} + +async fn run( + app: &AppHandle, + owner: Arc, + binding: Binding, +) -> Result<(), PrepareError> { + let runtime = initialize(app, binding)?; + let preferences = runtime + .store + .preferences() + .map_err(|_| PrepareError::Cache)?; + { + let mut control = owner.control.lock().expect("update owner lock poisoned"); + control.store = Some(runtime.store.clone()); + control.snapshot.automatic = preferences.automatic; + control.snapshot.phase = Phase::Idle; + control.snapshot_generation = owner.generation.load(Ordering::Acquire); + } + // Every restart re-verifies cache bytes, even when automatic checking is off. + // This is preparation metadata only: no startup gate or attempt is cleared. + let store = runtime.store.clone(); + let key = runtime.binding.public_key.clone(); + let os = runtime.os.clone(); + let cache_generation = owner.generation.load(Ordering::Acquire); + let cached = tauri::async_runtime::spawn_blocking(move || validate_cache(&store, &key, &os)) + .await + .map_err(|_| PrepareError::Worker)??; + if let Some(target) = cached { + // A late read may be useful on a later check, but cannot overwrite a + // cancellation/opt-out/health transition from a retired generation. + let _ = owner.accept_cached(cache_generation, target); + } + let mut cursor = DiscoveryCursor::default(); + loop { + let generation = { + // This waiter must be dropped before preparation registers its own + // cancellation waiter; Notify::notify_one cannot wake two owners. + let notified = owner.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + let decision = { + let mut control = owner.control.lock().expect("update owner lock poisoned"); + // This live consumer has observed the healthy/manual wake. + control.restart_requested = false; + let now = Instant::now(); + if owner.healthy.load(Ordering::Acquire) + && !control.in_flight + && now >= control.not_before + && (control.requested + || (control.snapshot.automatic && now >= control.next_due)) + { + control.in_flight = true; + control.requested = false; + Some(owner.generation.load(Ordering::Acquire)) + } else { + None + } + }; + let Some(generation) = decision else { + // A bounded heartbeat coalesces OS wake/suspend without an event + // listener storm. Check requests and health changes wake immediately. + let _ = tokio::time::timeout(Duration::from_secs(30), notified).await; + if app + .state::() + .is_shutting_down() + { + return Ok(()); + } + continue; + }; + generation + }; + let result = prepare(&owner, generation, &runtime, &mut cursor).await; + let mut control = owner.control.lock().expect("update owner lock poisoned"); + control.in_flight = false; + if !owner.valid(generation) { + control.snapshot.phase = Phase::Deferred; + control.snapshot.reason = Some("preparation_cancelled"); + cursor = DiscoveryCursor::default(); + continue; + } + match result { + Ok((incomplete, delay)) => { + control.failures = 0; + control.snapshot.discovery_incomplete = incomplete; + if control.snapshot.phase != Phase::Ready { + control.snapshot.phase = if control.verified.is_some() { + Phase::Ready + } else { + Phase::Idle + }; + } + let minimum = delay; + let delay = delay.unwrap_or_else(|| { + if incomplete { + CONTINUATION_DELAY + } else { + interval_delay() + } + }); + control.next_due = Instant::now() + delay; + control.not_before = Instant::now() + minimum.unwrap_or(Duration::ZERO); + } + Err(PrepareError::Cancelled) => { + cursor = DiscoveryCursor::default(); + } + Err(error) => { + let delay = match &error { + PrepareError::Discovery(DiscoveryError::RetryAfter(delay)) => *delay, + _ => retry_delay(control.failures), + }; + control.failures = control.failures.saturating_add(1); + control.next_due = Instant::now() + delay; + control.not_before = match &error { + PrepareError::Discovery(DiscoveryError::RetryAfter(_)) => control.next_due, + _ => Instant::now(), + }; + control.snapshot.phase = Phase::Deferred; + control.snapshot.reason = Some(error.code()); + } + } + } +} + +async fn cancellable( + owner: &Coordinator, + generation: u64, + future: impl Future>, +) -> Result { + let changed = owner.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if !owner.valid(generation) { + return Err(PrepareError::Cancelled); + } + match futures_util::future::select(Box::pin(future), changed).await { + futures_util::future::Either::Left((result, _)) => result.map_err(PrepareError::Discovery), + futures_util::future::Either::Right(_) => Err(PrepareError::Cancelled), + } +} + +async fn prepare( + owner: &Coordinator, + generation: u64, + runtime: &Runtime, + cursor: &mut DiscoveryCursor, +) -> Result<(bool, Option), PrepareError> { + owner.phase(generation, Phase::Checking)?; + let result = cancellable( + owner, + generation, + updater_discovery::discover_burst(&runtime.client, &runtime.policy, cursor), + ) + .await?; + let incomplete = !matches!( + result.completeness, + DiscoveryCompleteness::CompleteObservedScan + ); + let Some(selected) = result.selected else { + return Ok((incomplete, result.retry_after)); + }; + { + let mut control = owner.control.lock().expect("update owner lock poisoned"); + if !owner.valid(generation) { + return Err(PrepareError::Cancelled); + } + if control + .verified + .as_ref() + .is_some_and(|verified| verified.matches(&selected)) + { + control.snapshot.phase = Phase::Ready; + return Ok((incomplete, result.retry_after)); + } + } + owner.phase(generation, Phase::Downloading)?; + let bytes = cancellable( + owner, + generation, + updater_discovery::fetch_archive( + &runtime.client, + &runtime.policy, + &selected, + Duration::from_secs(10 * 60), + ), + ) + .await?; + owner.phase(generation, Phase::Verifying)?; + let key = runtime.binding.public_key.clone(); + let manifest = selected.manifest.clone(); + let target = VerifiedTarget { + manifest: manifest.clone(), + release_id: selected.release.id, + manifest_asset_id: selected.manifest_asset.id, + archive_asset_id: selected.archive_asset.id, + }; + let store = runtime.store.clone(); + // Never drop this blocking worker on cancellation. It has no installer + // authority; await completion, then discard this generation's private files. + let staged = tauri::async_runtime::spawn_blocking(move || { + let record = record_for(&selected, &bytes, &key)?; + store + .stage(&record, &bytes) + .map_err(|_| PrepareError::Cache) + }) + .await + .map_err(|_| PrepareError::Worker)??; + let mut control = owner.control.lock().expect("update owner lock poisoned"); + if !owner.valid(generation) { + runtime.store.discard(staged); + return Err(PrepareError::Cancelled); + } + runtime + .store + .commit(staged) + .map_err(|_| PrepareError::Cache)?; + // A non-mutating preparation commit already in flight may finish during + // Quit. It never owns install authority and must not restore Ready state. + if !owner.valid(generation) { + return Err(PrepareError::Cancelled); + } + set_target(&mut control.snapshot, &manifest); + control.snapshot.phase = Phase::Ready; + control.snapshot_generation = generation; + control.snapshot.discovery_incomplete = incomplete; + control.verified = Some(target); + Ok((incomplete, result.retry_after)) +} + +fn identity() -> ProductIdentity<'static> { + ProductIdentity { + repository: env!("GJC_UPDATE_REPOSITORY"), + artifact_prefix: env!("GJC_UPDATE_ARTIFACT_PREFIX"), + } +} + +fn archive_identity(manifest: &Manifest) -> ArchiveIdentity { + ArchiveIdentity { + product_name: env!("GJC_UPDATE_PRODUCT_NAME").into(), + executable: env!("CARGO_PKG_NAME").into(), + bundle_identifier: env!("GJC_UPDATE_BUNDLE_IDENTIFIER").into(), + package_name: env!("GJC_UPDATE_PACKAGE_NAME").into(), + desktop_version: manifest.version.to_string(), + product_version: manifest.product_version.to_string(), + minimum_system_version: manifest.minimum_system_version.clone(), + } +} + +fn record_for( + selected: &SelectedRelease, + bytes: &[u8], + key: &str, +) -> Result { + let sha = verify_archive(bytes, key, &selected.manifest.signature) + .map_err(|_| PrepareError::Signature)?; + let inventory = inspect_archive(bytes, &archive_identity(&selected.manifest)) + .map_err(|_| PrepareError::Archive)?; + Ok(PreparedRecord { + schema: 1, + release_id: selected.release.id, + manifest_asset_id: selected.manifest_asset.id, + archive_asset_id: selected.archive_asset.id, + archive_size: bytes.len() as u64, + archive_sha256: sha, + manifest: String::from_utf8(selected.manifest_bytes.clone()) + .map_err(|_| PrepareError::Policy)?, + inventory: serde_json::to_value(inventory).map_err(|_| PrepareError::Archive)?, + }) +} + +fn validate_cache( + store: &Store, + key: &str, + os: &str, +) -> Result, PrepareError> { + let Some((record, bytes)) = store.load().map_err(|_| PrepareError::Cache)? else { + return Ok(None); + }; + let manifest = + parse_manifest(record.manifest.as_bytes(), &identity()).map_err(|_| PrepareError::Cache)?; + if !eligible_cached(&manifest, os)? { + return Ok(None); + } + if digest(&bytes) != record.archive_sha256 { + return Err(PrepareError::Cache); + } + verify_archive(&bytes, key, &manifest.signature).map_err(|_| PrepareError::Signature)?; + let inventory = + inspect_archive(&bytes, &archive_identity(&manifest)).map_err(|_| PrepareError::Archive)?; + if serde_json::to_value(inventory).map_err(|_| PrepareError::Archive)? != record.inventory { + return Err(PrepareError::Cache); + } + Ok(Some(VerifiedTarget { + manifest, + release_id: record.release_id, + manifest_asset_id: record.manifest_asset_id, + archive_asset_id: record.archive_asset_id, + })) +} + +fn installed_channel() -> Result { + let version = + Version::parse(env!("GJC_EXPECTED_PAYLOAD_VERSION")).map_err(|_| PrepareError::Policy)?; + match version.pre.as_str().split('.').next() { + Some("") => Ok(Channel::Stable), + Some("beta") => Ok(Channel::Beta), + _ => Err(PrepareError::Policy), + } +} + +fn eligible_cached(manifest: &Manifest, os: &str) -> Result { + let current = Version::parse(env!("CARGO_PKG_VERSION")).map_err(|_| PrepareError::Policy)?; + let floor = Version::new(0, 2, 3); + let parse_os = |value: &str| -> Result<[u16; 3], PrepareError> { + let values: Vec<_> = value.split('.').collect(); + if !(2..=3).contains(&values.len()) { + return Err(PrepareError::Policy); + } + let mut result = [0; 3]; + for (i, value) in values.iter().enumerate() { + result[i] = value.parse().map_err(|_| PrepareError::Policy)?; + } + Ok(result) + }; + Ok(manifest.version.cmp_precedence(¤t).is_gt() + && manifest.version.cmp_precedence(&floor).is_gt() + && (installed_channel()? != Channel::Stable || manifest.channel == Channel::Stable) + && parse_os(&manifest.minimum_system_version)? <= parse_os(os)?) +} + +fn set_target(snapshot: &mut Snapshot, manifest: &Manifest) { + snapshot.target_product_version = Some(manifest.product_version.to_string()); + snapshot.target_desktop_version = Some(manifest.version.to_string()); + snapshot.reason = Some("installation_safety_gate_pending"); +} + +fn retry_delay(failures: usize) -> Duration { + Duration::from_secs(match failures { + 0 => 60, + 1 => 300, + 2 => 1800, + _ => INTERVAL_SECONDS, + }) +} +fn interval_delay() -> Duration { + let mut random = [0; 2]; + if getrandom::getrandom(&mut random).is_err() { + return Duration::from_secs(INTERVAL_SECONDS); + } + Duration::from_secs(INTERVAL_SECONDS - 600 + u16::from_ne_bytes(random) as u64 % 1201) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf}; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let mut bytes = [0; 8]; + getrandom::getrandom(&mut bytes).unwrap(); + let path = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!("gajae-preparation-{:x}", u64::from_ne_bytes(bytes))); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn manual_check_does_not_grant_consent_and_busy_requests_coalesce() { + let temp = Temp::new(); + let owner = Coordinator::default(); + { + let mut control = owner.control.lock().unwrap(); + owner.healthy.store(true, Ordering::Release); + owner.started.store(true, Ordering::Release); + control.store = Some(Arc::new(Store::open(&temp.0).unwrap())); + } + owner.set_automatic(false).unwrap(); + owner.manual_check().unwrap(); + let mut control = owner.control.lock().unwrap(); + assert!(control.requested); + assert!(!control.snapshot.automatic); + assert!( + !control + .store + .as_ref() + .unwrap() + .preferences() + .unwrap() + .automatic + ); + control.in_flight = true; + control.requested = false; + drop(control); + owner.manual_check().unwrap(); + assert!(!owner.control.lock().unwrap().requested); + } + + #[test] + fn opt_out_invalidates_the_active_generation_before_acknowledgement() { + let temp = Temp::new(); + let owner = Coordinator::default(); + { + let mut control = owner.control.lock().unwrap(); + owner.healthy.store(true, Ordering::Release); + control.store = Some(Arc::new(Store::open(&temp.0).unwrap())); + } + let generation = owner.generation.load(Ordering::Acquire); + assert!(owner.valid(generation)); + owner.set_automatic(false).unwrap(); + assert!(!owner.valid(generation)); + assert!(owner.phase(generation, Phase::Ready).is_err()); + assert!( + !owner + .control + .lock() + .unwrap() + .snapshot + .installation_available + ); + } + + #[test] + fn timers_are_bounded_and_cached_manifest_obeys_version_and_os_policy() { + assert_eq!(retry_delay(0).as_secs(), 60); + assert_eq!(retry_delay(1).as_secs(), 300); + assert_eq!(retry_delay(2).as_secs(), 1800); + for _ in 0..50 { + assert!((21000..=22200).contains(&interval_delay().as_secs())); + } + let mut manifest = parse_manifest( + include_bytes!("../../shared/fixtures/desktop-update-manifest.json"), + &identity(), + ) + .unwrap(); + assert!(eligible_cached(&manifest, "26.0").unwrap()); + assert!(!eligible_cached(&manifest, "12.0").unwrap()); + manifest.version = Version::new(0, 2, 3); + assert!(!eligible_cached(&manifest, "26.0").unwrap()); + } + + #[test] + fn fabricated_cached_signature_never_becomes_ready() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + let record = PreparedRecord { + schema: 1, + release_id: 1, + manifest_asset_id: 2, + archive_asset_id: 3, + archive_size: 4, + archive_sha256: digest(b"test"), + manifest: String::from_utf8( + include_bytes!("../../shared/fixtures/desktop-update-manifest.json").to_vec(), + ) + .unwrap(), + inventory: serde_json::json!({"fabricated":true}), + }; + store + .commit(store.stage(&record, b"test").unwrap()) + .unwrap(); + assert!(matches!( + validate_cache(&store, "ZmFrZQ==", "26.0"), + Err(PrepareError::Signature) + )); + } + + #[test] + fn opt_out_wakes_and_cancels_a_pending_network_operation() { + let temp = Temp::new(); + let owner = Arc::new(Coordinator::default()); + { + let mut control = owner.control.lock().unwrap(); + owner.healthy.store(true, Ordering::Release); + control.store = Some(Arc::new(Store::open(&temp.0).unwrap())); + } + let generation = owner.generation.load(Ordering::Acquire); + tauri::async_runtime::block_on(async { + let (started, ready) = tokio::sync::oneshot::channel(); + let background = owner.clone(); + let task = tauri::async_runtime::spawn(async move { + let _ = started.send(()); + cancellable( + &background, + generation, + std::future::pending::>(), + ) + .await + }); + ready.await.unwrap(); + owner.set_automatic(false).unwrap(); + let outcome = tokio::time::timeout(Duration::from_millis(500), task) + .await + .unwrap() + .unwrap(); + assert!(matches!(outcome, Err(PrepareError::Cancelled))); + }); + } + + #[test] + fn failed_preference_write_cancels_memory_state_without_claiming_durable_success() { + let temp = Temp::new(); + let owner = Coordinator::default(); + let store = Arc::new(Store::open(&temp.0).unwrap()); + store.set_automatic(true).unwrap(); + { + let mut control = owner.control.lock().unwrap(); + owner.healthy.store(true, Ordering::Release); + control.snapshot.automatic = true; + control.store = Some(store); + } + let path = temp.0.join("desktop-update-cache/preferences.json"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).unwrap(); + assert_eq!(owner.set_automatic(false), Err("preferences_not_persisted")); + let control = owner.control.lock().unwrap(); + assert!(!control.snapshot.automatic); + assert_eq!(control.snapshot.phase, Phase::Error); + assert_eq!(control.snapshot.reason, Some("preferences_not_persisted")); + } + + #[test] + fn lifecycle_cancellation_never_waits_for_the_persistence_mutex() { + let owner = Coordinator::default(); + owner.healthy.store(true, Ordering::Release); + let generation = owner.generation.load(Ordering::Acquire); + // Model a stalled fsync with its generation lock held on this thread. + // invalidate() must return without trying to acquire that mutex. + let _stalled_writer = owner.control.lock().unwrap(); + let before = Instant::now(); + owner.invalidate(); + assert!(before.elapsed() < Duration::from_millis(100)); + assert!(!owner.valid(generation)); + } + + #[test] + fn late_cache_validation_cannot_restore_ready_after_cancellation() { + let owner = Coordinator::default(); + owner.healthy.store(true, Ordering::Release); + let generation = owner.generation.load(Ordering::Acquire); + let manifest = parse_manifest( + include_bytes!("../../shared/fixtures/desktop-update-manifest.json"), + &identity(), + ) + .unwrap(); + owner.invalidate(); + assert!(matches!( + owner.accept_cached( + generation, + VerifiedTarget { + manifest, + release_id: 1, + manifest_asset_id: 2, + archive_asset_id: 3 + } + ), + Err(PrepareError::Cancelled) + )); + let control = owner.control.lock().unwrap(); + assert_eq!(control.snapshot.phase, Phase::Deferred); + assert!(control.verified.is_none()); + } + + #[test] + fn retired_failed_owner_releases_restart_claim_and_refuses_unconsumed_requests() { + let temp = Temp::new(); + let owner = Coordinator::default(); + owner.started.store(true, Ordering::Release); + owner.healthy.store(true, Ordering::Release); + { + let mut control = owner.control.lock().unwrap(); + control.store = Some(Arc::new(Store::open(&temp.0).unwrap())); + control.in_flight = true; + } + owner.retire(Err(PrepareError::Cache)); + assert!(!owner.started.load(Ordering::Acquire)); + assert_eq!(owner.manual_check(), Err("updater_inactive")); + assert!(owner.control.lock().unwrap().store.is_none()); + // Same claim used by a new after_healthy callback following repair/Retry. + assert!(!owner.started.swap(true, Ordering::AcqRel)); + } + + #[test] + fn epoch_aware_snapshot_hides_ready_written_after_nonblocking_invalidation() { + let owner = Coordinator::default(); + owner.healthy_start(); + let generation = owner.generation.load(Ordering::Acquire); + { + let mut control = owner.control.lock().unwrap(); + assert!(owner.valid(generation)); + // Exact check/write interleaving: invalidation cannot take the lock, + // then the old worker writes Ready after its previous valid check. + owner.invalidate(); + control.snapshot.phase = Phase::Ready; + control.snapshot_generation = generation; + } + assert_eq!(owner.snapshot().phase, Phase::Deferred); + assert_eq!(owner.snapshot().reason, Some("preparation_cancelled")); + } + + #[test] + fn healthy_callback_during_retirement_is_consumed_by_exactly_one_owner() { + let owner = Coordinator::default(); + assert!(owner.healthy_start()); + // Old run has returned but has not retired. The new healthy callback + // must not spawn concurrently or disappear when old retirement finishes. + assert!(!owner.healthy_start()); + assert!(owner.retire(Err(PrepareError::Binding))); + assert!(owner.started.load(Ordering::Acquire)); + // No further callback: the failed replacement now retires normally. + assert!(!owner.retire(Err(PrepareError::Binding))); + assert!(owner.healthy_start()); + } + + #[test] + fn manual_intent_survives_retirement_while_auto_is_off() { + let temp = Temp::new(); + let owner = Coordinator::default(); + owner.healthy_start(); + { + let mut control = owner.control.lock().unwrap(); + control.store = Some(Arc::new(Store::open(&temp.0).unwrap())); + } + owner.set_automatic(false).unwrap(); + owner.manual_check().unwrap(); + assert!(owner.retire(Err(PrepareError::Cache))); + let control = owner.control.lock().unwrap(); + assert!(control.requested); + assert!(!control.snapshot.automatic); + } +} diff --git a/src-tauri/src/updater_archive.rs b/src-tauri/src/updater_archive.rs new file mode 100644 index 00000000..bd97d1cc --- /dev/null +++ b/src-tauri/src/updater_archive.rs @@ -0,0 +1,2153 @@ +//! Read-only inspection of the final `.app.tar.gz` release artifact. +//! +//! Call only after the parent verifies the updater signature on the same bytes. +//! Inventory/hash/metadata checks are NOT Apple code-signature, notarization, +//! installation or runtime-health proof. Nothing here extracts or opens a path. +//! +//! The producer is `scripts/release/updater-archive.mjs`: explicit directories, +//! preserved modes, one gzip member, and local PAX path/linkpath metadata. We +//! deliberately reject global/GNU/sparse extensions and unequal PAX/header sizes +//! to avoid interpreting different bytes from the selected official installer. + +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + fmt, + io::{self, Cursor, Read, Seek, SeekFrom}, +}; + +use flate2::bufread::GzDecoder; +use serde::{ + de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}, + Deserialize, Serialize, +}; +use sha2::{Digest, Sha256}; + +pub const MAX_COMPRESSED_BYTES: usize = 250 * 1024 * 1024; +pub const MAX_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_ENTRIES: usize = 100_000; // Includes extension headers. +const MAX_PATH_BYTES: usize = 4096; +const MAX_DEPTH: usize = 128; +const MAX_METADATA_BYTES: usize = 64 * 1024; +const MAX_INVENTORY_METADATA_BYTES: usize = 32 * 1024 * 1024; +const MAX_LINK_DEREFERENCES: usize = 64; +const PAYLOAD: &str = "Contents/Resources/resources/server-payload"; + +/// Trusted build identity plus the validated candidate's versions/OS floor. +/// `package_name` is the desktop payload's package.json name (`gajae-app`), +/// not the separately distributed server archive's package name. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ArchiveIdentity { + pub product_name: String, + pub executable: String, + pub bundle_identifier: String, + pub package_name: String, + pub desktop_version: String, + pub product_version: String, + pub minimum_system_version: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ArchiveInventory { + pub identity: ArchiveIdentity, + pub root: String, + pub compressed_bytes: u64, + /// Entire decompressed stream, including headers, PAX, padding and EOF. + pub expanded_bytes: u64, + pub total_file_bytes: u64, + pub archive_sha256: String, + /// Domain-separated, length-framed hash of sorted paths/types/modes/bytes/links. + pub inventory_sha256: String, + /// Byte hash of the in-archive manifest, not a signature or current-build hash. + pub runtime_manifest_sha256: String, + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ArchiveEntry { + pub path: String, + pub mode: u32, + pub kind: ArchiveEntryKind, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ArchiveEntryKind { + File { size: u64, sha256: String }, + Directory, + Symlink { target: String }, +} + +#[derive(Clone, Copy)] +struct Limits { + compressed: usize, + expanded: u64, + entries: usize, + metadata: usize, + inventory_metadata: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + compressed: MAX_COMPRESSED_BYTES, + expanded: MAX_EXPANDED_BYTES, + entries: MAX_ENTRIES, + metadata: MAX_METADATA_BYTES, + inventory_metadata: MAX_INVENTORY_METADATA_BYTES, + } + } +} + +pub fn inspect_archive( + bytes: &[u8], + expected: &ArchiveIdentity, +) -> Result { + inspect_with_limits(bytes, expected, Limits::default()) +} + +fn require(condition: bool, error: &'static str) -> Result<(), String> { + if condition { + Ok(()) + } else { + Err(error.to_owned()) + } +} + +fn inspect_with_limits( + bytes: &[u8], + expected: &ArchiveIdentity, + limits: Limits, +) -> Result { + require(!bytes.is_empty(), "Archive is empty")?; + require( + bytes.len() <= limits.compressed, + "Compressed archive exceeds limit", + )?; + validate_identity(expected)?; + let root = format!("{}.app", expected.product_name); + let plist_path = format!("{root}/Contents/Info.plist"); + let executable_path = format!("{root}/Contents/MacOS/{}", expected.executable); + let package_path = format!("{root}/{PAYLOAD}/package.json"); + let runtime_path = format!("{root}/{PAYLOAD}/server/gjc-runtime-manifest.json"); + let mut captured = BTreeMap::new(); + + // The bufread decoder leaves the compressed tail visible; the read decoder + // may consume it. Drain through EOF below to check CRC/ISIZE, then reject + // concatenated members or garbage. The producer writes exactly one member. + let decoder = GzDecoder::new(bytes); + require(decoder.header().is_some(), "Invalid gzip header")?; + require( + bytes.len() - decoder.get_ref().len() <= limits.metadata, + "Gzip metadata exceeds limit", + )?; + let reader = LimitedReader { + inner: decoder, + count: 0, + limit: limits.expanded, + }; + let mut archive = tar::Archive::new(reader); + let mut entries = BTreeMap::new(); + let mut aliases = BTreeSet::new(); + let mut pending_pax = None; + let mut header_count = 0; + let mut metadata_bytes = 0; + let mut file_bytes = 0_u64; + let mut expected_end = 0_u64; + + // raw(true) is essential: automatic PAX/GNU consumption allocates extension + // bodies before the caller sees their size. tar still checks each checksum. + for entry in archive + .entries() + .map_err(|_| "Invalid tar stream")? + .raw(true) + { + let mut entry = entry.map_err(|_| "Invalid tar entry")?; + header_count += 1; + require(header_count <= limits.entries, "Too many archive entries")?; + let header = entry.header().clone(); + require( + header.as_ustar().is_some(), + "Archive requires a POSIX ustar header", + )?; + let size = entry.size(); + require( + size <= limits.expanded, + "Declared entry size exceeds expanded limit", + )?; + require( + entry.raw_header_position() == expected_end, + "Unexpected tar entry boundary", + )?; + expected_end = entry + .raw_file_position() + .checked_add(size.checked_add(511).ok_or("Entry size overflow")? & !511) + .ok_or("Archive size overflow")?; + require( + expected_end <= limits.expanded, + "Tar stream exceeds expanded limit", + )?; + let entry_type = header.entry_type().as_byte(); + if entry_type == b'x' { + require(pending_pax.is_none(), "Stacked PAX headers are ambiguous")?; + require( + size > 0 && size <= limits.metadata as u64, + "PAX metadata exceeds limit", + )?; + charge_metadata(&mut metadata_bytes, size as usize, limits)?; + let mut data = Vec::with_capacity(size as usize); + entry + .read_to_end(&mut data) + .map_err(|_| "Truncated PAX metadata")?; + require(data.len() as u64 == size, "Truncated PAX metadata")?; + pending_pax = Some(parse_pax(&data)?); + continue; + } + require( + matches!(entry_type, 0 | b'0' | b'2' | b'5'), + "Unsupported tar entry type", + )?; + let pax = pending_pax.take().unwrap_or_default(); + if let Some(pax_size) = pax.get("size") { + require( + parse_decimal(pax_size)? == size, + "PAX/header size disagreement", + )?; + } + let header_path = header.path_bytes(); + let header_path = std::str::from_utf8(&header_path).map_err(|_| "Non-UTF8 tar path")?; + let path = member_path( + pax.get("path").map(String::as_str).unwrap_or(header_path), + entry_type == b'5', + &root, + )?; + let mode = header.mode().map_err(|_| "Invalid archive mode")?; + // Preserve ordinary modes verbatim, never bless setuid/setgid/sticky + // entries as an installer permission request. + require(mode <= 0o777, "Special permission bits are not permitted")?; + charge_metadata(&mut metadata_bytes, path.len(), limits)?; + require(!entries.contains_key(&path), "Duplicate archive path")?; + require( + aliases.insert(path.to_ascii_lowercase()), + "Case-alias archive paths", + )?; + let raw_link = header.link_name_bytes(); + let raw_link = raw_link.as_deref().unwrap_or_default(); + let raw_link = std::str::from_utf8(raw_link).map_err(|_| "Non-UTF8 link target")?; + let target = pax.get("linkpath").map(String::as_str).unwrap_or(raw_link); + let kind = match entry_type { + b'5' => { + require( + size == 0 && target.is_empty(), + "Directory has data or link metadata", + )?; + ArchiveEntryKind::Directory + } + b'2' => { + require(size == 0, "Symlink has file data")?; + validate_link_text(target)?; + charge_metadata(&mut metadata_bytes, target.len(), limits)?; + ArchiveEntryKind::Symlink { + target: target.to_owned(), + } + } + _ => { + require(target.is_empty(), "Regular file has link metadata")?; + file_bytes = file_bytes.checked_add(size).ok_or("File size overflow")?; + require( + file_bytes <= limits.expanded, + "File bytes exceed expanded limit", + )?; + let metadata = path == plist_path || path == package_path || path == runtime_path; + require( + !metadata || size <= limits.metadata as u64, + "Identity metadata exceeds limit", + )?; + let capture = metadata || path == executable_path; + let (sha256, prefix) = hash_file(&mut entry, size, capture, limits.metadata)?; + if capture { + captured.insert(path.clone(), prefix); + } + ArchiveEntryKind::File { size, sha256 } + } + }; + entries.insert(path.clone(), ArchiveEntry { path, mode, kind }); + } + require(pending_pax.is_none(), "PAX header has no following member")?; + let mut reader = archive.into_inner(); + // tar stops on the FIRST zero header (or plain EOF); insist on both EOF + // blocks and zero-only, block-aligned trailing padding. Never ignore a + // second hidden tar archive after its first end marker. + require(reader.count == expected_end + 512, "Missing tar end marker")?; + let mut tail = 0_u64; + let mut buffer = [0_u8; 8192]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|_| "Invalid gzip trailer or expanded limit")?; + if read == 0 { + break; + } + require( + buffer[..read].iter().all(|byte| *byte == 0), + "Nonzero trailing tar data", + )?; + tail += read as u64; + } + require( + tail >= 512 && tail % 512 == 0, + "Truncated or misaligned tar end blocks", + )?; + let expanded_bytes = reader.count; + require( + reader.inner.into_inner().is_empty(), + "Trailing compressed data or multiple gzip members", + )?; + validate_tree(&entries, &root)?; + let metadata = |path: &str| { + captured + .get(path) + .ok_or_else(|| "Required identity member is missing or not a file".to_owned()) + }; + validate_plist(metadata(&plist_path)?, expected)?; + validate_package(metadata(&package_path)?, expected)?; + let executable = entries + .get(&executable_path) + .ok_or("Main executable is missing")?; + let ArchiveEntryKind::File { size, .. } = executable.kind else { + return Err("Main executable is not a regular file".to_owned()); + }; + require( + executable.mode & 0o111 != 0, + "Main executable has no execute permission", + )?; + validate_macho( + metadata(&executable_path)?, + size, + &expected.minimum_system_version, + )?; + validate_runtime_manifest(metadata(&runtime_path)?, &entries, &root)?; + let runtime_manifest_sha256 = hash_bytes(metadata(&runtime_path)?); + let entries: Vec<_> = entries.into_values().collect(); + Ok(ArchiveInventory { + identity: expected.clone(), + root, + compressed_bytes: bytes.len() as u64, + expanded_bytes, + total_file_bytes: file_bytes, + archive_sha256: hash_bytes(bytes), + inventory_sha256: inventory_hash(&entries), + runtime_manifest_sha256, + entries, + }) +} + +struct LimitedReader { + inner: R, + count: u64, + limit: u64, +} + +impl Read for LimitedReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() { + return Ok(0); + } + // Probe one byte at the boundary so exact-limit valid EOF succeeds. + let length = output + .len() + .min((self.limit - self.count).saturating_add(1) as usize); + let read = self.inner.read(&mut output[..length])?; + if read as u64 > self.limit - self.count { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Expanded archive exceeds limit", + )); + } + self.count += read as u64; + Ok(read) + } +} + +fn charge_metadata(total: &mut usize, bytes: usize, limits: Limits) -> Result<(), String> { + *total = total.checked_add(bytes).ok_or("Metadata size overflow")?; + require( + *total <= limits.inventory_metadata, + "Aggregate archive metadata exceeds limit", + ) +} + +fn parse_decimal(text: &str) -> Result { + require( + !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()), + "Invalid PAX integer", + )?; + require( + text.len() == 1 || !text.starts_with('0'), + "Noncanonical PAX integer", + )?; + text.parse().map_err(|_| "PAX integer overflow".to_owned()) +} + +fn parse_pax(bytes: &[u8]) -> Result, String> { + // PaxExtensions stops at an empty line and tolerates a missing final LF. + // Reject those before invoking the maintained key/value/length parser. + require( + bytes.ends_with(b"\n") + && !bytes.starts_with(b"\n") + && !bytes.windows(2).any(|w| w == b"\n\n"), + "Malformed PAX lines", + )?; + let mut result = BTreeMap::new(); + for record in tar::PaxExtensions::new(bytes) { + let record = record.map_err(|_| "Malformed PAX record")?; + let key = record.key().map_err(|_| "Invalid PAX key")?; + let value = record.value().map_err(|_| "Invalid PAX value")?; + // These are the only semantics needed by the final-app producer. + // In particular never silently ignore sparse/xattr/ACL/type/mode keys. + require( + matches!( + key, + "path" + | "linkpath" + | "size" + | "uid" + | "gid" + | "uname" + | "gname" + | "mtime" + | "atime" + | "ctime" + ), + "Unsupported PAX key", + )?; + require( + !value.bytes().any(|b| b < 0x20 || b == 0x7f), + "Control character in PAX value", + )?; + if matches!(key, "size" | "uid" | "gid") { + parse_decimal(value)?; + } + if matches!(key, "mtime" | "atime" | "ctime") { + require( + !value.is_empty() + && value.len() <= 32 + && value.parse::().is_ok_and(|v| v.is_finite()), + "Invalid PAX timestamp", + )?; + } + require( + result.insert(key.to_owned(), value.to_owned()).is_none(), + "Duplicate PAX key", + )?; + } + require(!result.is_empty(), "Empty PAX metadata")?; + Ok(result) +} + +fn path_text(text: &str) -> Result<(), String> { + // Deliberately narrower than the JS producer: no Unicode path acceptance + // without canonical decomposition + filesystem case-folding. This avoids + // blessing NFC/NFD aliases with an incomplete Rust lowercase approximation. + require( + !text.is_empty() && text.len() <= MAX_PATH_BYTES, + "Path length exceeds limit", + )?; + require( + text.bytes() + .all(|b| (0x20..0x7f).contains(&b) && b != b'\\' && b != b':'), + "Unsupported path character (ASCII paths only)", + ) +} + +fn relative_components(text: &str) -> Result<(), String> { + path_text(text)?; + let mut depth = 0; + for component in text.split('/') { + depth += 1; + require( + !component.is_empty() + && component != "." + && component != ".." + && !component.starts_with("._"), + "Noncanonical path component", + )?; + require( + component.len() <= 255, + "Path component exceeds macOS filename limit", + )?; + } + require(depth <= MAX_DEPTH, "Archive path exceeds depth limit") +} + +fn member_path(text: &str, directory: bool, root: &str) -> Result { + path_text(text)?; + let text = if directory { + text.strip_suffix('/').unwrap_or(text) + } else { + text + }; + relative_components(text)?; + require( + text == root + || text + .strip_prefix(root) + .is_some_and(|rest| rest.starts_with('/')), + "Archive has a foreign or ambiguous root", + )?; + Ok(text.to_owned()) +} + +fn validate_link_text(target: &str) -> Result<(), String> { + path_text(target)?; + require( + !target.starts_with('/') && !target.contains("//"), + "Noncanonical or absolute link target", + )?; + require( + target.split('/').count() <= MAX_DEPTH, + "Link target exceeds depth limit", + ) +} + +fn validate_identity(identity: &ArchiveIdentity) -> Result<(), String> { + for component in [ + &identity.product_name, + &identity.executable, + &identity.package_name, + &identity.bundle_identifier, + ] { + relative_components(component)?; + require( + !component.contains('/'), + "Identity must contain single path components", + )?; + } + for version in [&identity.desktop_version, &identity.product_version] { + require(version.len() <= 128, "Identity version exceeds limit")?; + let version = semver::Version::parse(version).map_err(|_| "Invalid identity version")?; + require( + version.build.is_empty(), + "Identity version contains build metadata", + )?; + } + macos_version(&identity.minimum_system_version)?; + Ok(()) +} + +fn validate_tree(entries: &BTreeMap, root: &str) -> Result<(), String> { + require( + entries + .get(root) + .is_some_and(|e| matches!(e.kind, ArchiveEntryKind::Directory)), + "Explicit app directory root is required", + )?; + for entry in entries.values() { + if entry.path != root { + let parent = entry + .path + .rsplit_once('/') + .ok_or("Missing parent directory")? + .0; + require( + entries + .get(parent) + .is_some_and(|e| matches!(e.kind, ArchiveEntryKind::Directory)), + "Member beneath absent, file or symlink parent", + )?; + } + if let ArchiveEntryKind::Symlink { target } = &entry.kind { + resolve_link(&entry.path, target, entries, root)?; + } + } + Ok(()) +} + +fn resolve_link<'a>( + path: &'a str, + target: &'a str, + entries: &'a BTreeMap, + root: &str, +) -> Result { + let mut stack: Vec<&str> = path + .rsplit_once('/') + .ok_or("Symlink cannot be root")? + .0 + .split('/') + .collect(); + let mut pending: VecDeque<&str> = target.split('/').collect(); + let mut dereferences = 0; + while let Some(component) = pending.pop_front() { + // POSIX requires a directory even for `file/..` and `file/.`. + let parent = stack.join("/"); + require( + entries + .get(&parent) + .is_some_and(|e| matches!(e.kind, ArchiveEntryKind::Directory)), + "Link traverses a non-directory", + )?; + match component { + "" | "." => continue, + ".." => { + require(stack.len() > 1, "Link escapes app root")?; + stack.pop(); + } + component => { + stack.push(component); + require( + stack.len() <= MAX_DEPTH, + "Resolved link exceeds depth limit", + )?; + let resolved = stack.join("/"); + let entry = entries + .get(&resolved) + .ok_or("Link targets a missing or case-aliased member")?; + if let ArchiveEntryKind::Symlink { target } = &entry.kind { + dereferences += 1; + require( + dereferences <= MAX_LINK_DEREFERENCES, + "Link cycle or dereference limit", + )?; + stack.pop(); + for part in target.split('/').rev() { + pending.push_front(part); + } + } + } + } + } + let resolved = stack.join("/"); + require( + stack.first().copied() == Some(root) && entries.contains_key(&resolved), + "Link escapes or has no target", + )?; + Ok(resolved) +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn hash_file( + reader: &mut impl Read, + size: u64, + capture: bool, + cap: usize, +) -> Result<(String, Vec), String> { + let mut hash = Sha256::new(); + let mut prefix = Vec::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut count = 0_u64; + loop { + let read = reader + .read(&mut buffer) + .map_err(|_| "Truncated file or expanded limit")?; + if read == 0 { + break; + } + count += read as u64; + require(count <= size, "File exceeds declared size")?; + hash.update(&buffer[..read]); + if capture { + prefix.extend_from_slice(&buffer[..read.min(cap - prefix.len())]); + } + } + require(count == size, "Truncated archive file")?; + Ok((format!("{:x}", hash.finalize()), prefix)) +} + +fn inventory_hash(entries: &[ArchiveEntry]) -> String { + fn field(hash: &mut Sha256, bytes: &[u8]) { + hash.update((bytes.len() as u64).to_be_bytes()); + hash.update(bytes); + } + let mut hash = Sha256::new(); + hash.update(b"gajae-updater-inventory-v1\0"); + hash.update((entries.len() as u64).to_be_bytes()); + for entry in entries { + field(&mut hash, entry.path.as_bytes()); + hash.update(entry.mode.to_be_bytes()); + match &entry.kind { + ArchiveEntryKind::Directory => hash.update([0]), + ArchiveEntryKind::File { size, sha256 } => { + hash.update([1]); + hash.update(size.to_be_bytes()); + field(&mut hash, sha256.as_bytes()); + } + ArchiveEntryKind::Symlink { target } => { + hash.update([2]); + field(&mut hash, target.as_bytes()); + } + } + } + format!("{:x}", hash.finalize()) +} + +// Bounded serde visitor shared by plist and JSON. Avoid Value::from_reader: +// duplicate dictionary keys are otherwise overwritten, and nested/referenced +// binary plists can amplify a tiny encoded document into a huge object tree. +#[derive(Debug)] +enum MetadataValue { + String(String), + Integer(u64), + Map(BTreeMap), + Array(Vec), + Other, +} +struct Document(MetadataValue); +struct MetadataBudget { + nodes: usize, + bytes: usize, +} +struct MetadataSeed<'a> { + depth: usize, + budget: &'a mut MetadataBudget, +} + +impl<'de> Deserialize<'de> for Document { + fn deserialize>(deserializer: D) -> Result { + let mut budget = MetadataBudget { + nodes: 16_384, + bytes: 4 * MAX_METADATA_BYTES, + }; + MetadataSeed { + depth: 0, + budget: &mut budget, + } + .deserialize(deserializer) + .map(Self) + } +} + +impl<'de> DeserializeSeed<'de> for MetadataSeed<'_> { + type Value = MetadataValue; + fn deserialize>( + self, + deserializer: D, + ) -> Result { + if self.depth > 32 || self.budget.nodes == 0 { + return Err(de::Error::custom("Metadata depth/node limit")); + } + self.budget.nodes -= 1; + deserializer.deserialize_any(self) + } +} + +impl MetadataSeed<'_> { + fn charge(&mut self, length: usize) -> Result<(), E> { + self.budget.bytes = self + .budget + .bytes + .checked_sub(length) + .ok_or_else(|| E::custom("Decoded metadata size limit"))?; + Ok(()) + } +} + +impl<'de> Visitor<'de> for MetadataSeed<'_> { + type Value = MetadataValue; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("bounded, duplicate-free metadata") + } + fn visit_str(mut self, value: &str) -> Result { + self.charge(value.len())?; + Ok(MetadataValue::String(value.to_owned())) + } + fn visit_string(mut self, value: String) -> Result { + self.charge(value.len())?; + Ok(MetadataValue::String(value)) + } + fn visit_u64(self, value: u64) -> Result { + Ok(MetadataValue::Integer(value)) + } + fn visit_i64(self, value: i64) -> Result { + Ok(u64::try_from(value) + .map(MetadataValue::Integer) + .unwrap_or(MetadataValue::Other)) + } + fn visit_f64(self, _: f64) -> Result { + Ok(MetadataValue::Other) + } + fn visit_bool(self, _: bool) -> Result { + Ok(MetadataValue::Other) + } + fn visit_unit(self) -> Result { + Ok(MetadataValue::Other) + } + fn visit_bytes(mut self, value: &[u8]) -> Result { + self.charge(value.len())?; + Ok(MetadataValue::Other) + } + fn visit_byte_buf(self, value: Vec) -> Result { + self.visit_bytes(&value) + } + fn visit_map>(mut self, mut map: A) -> Result { + let mut result = BTreeMap::new(); + while let Some(key) = map.next_key::()? { + self.charge(key.len())?; + if result.contains_key(&key) { + return Err(de::Error::custom("Duplicate metadata key")); + } + let value = map.next_value_seed(MetadataSeed { + depth: self.depth + 1, + budget: self.budget, + })?; + result.insert(key, value); + } + Ok(MetadataValue::Map(result)) + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut result = Vec::new(); + while let Some(value) = sequence.next_element_seed(MetadataSeed { + depth: self.depth + 1, + budget: self.budget, + })? { + result.push(value); + } + Ok(MetadataValue::Array(result)) + } +} + +impl MetadataValue { + fn map(&self) -> Result<&BTreeMap, String> { + if let Self::Map(value) = self { + Ok(value) + } else { + Err("Identity metadata must be an object".to_owned()) + } + } + fn field(&self, key: &str) -> Result<&Self, String> { + self.map()? + .get(key) + .ok_or_else(|| format!("Missing identity metadata field: {key}")) + } + fn string(&self) -> Result<&str, String> { + if let Self::String(value) = self { + Ok(value) + } else { + Err("Identity metadata must be a string".to_owned()) + } + } + fn equals(&self, key: &str, expected: &str) -> Result<(), String> { + if self.field(key)?.string()? == expected { + Ok(()) + } else { + Err(format!("Archive identity mismatch: {key}")) + } + } +} + +fn validate_plist(bytes: &[u8], identity: &ArchiveIdentity) -> Result<(), String> { + let Document(value) = if bytes.starts_with(b"bplist00") { + plist::from_reader(Cursor::new(bytes)) + } else { + // plist 1.7's serde entry point returns after the root value, without + // consuming the XML footer. Limit read-ahead to one byte so we can + // require the producer's closing plist tag, not accept a second root, + // hidden duplicate dictionary or a truncated XML document. Binary + // plist parsing instead validates its trailer at the end of the input. + let mut cursor = Cursor::new(bytes); + let result = plist::from_reader(ExactXmlReader(&mut cursor)); + if result.is_ok() { + let tail = &bytes[cursor.position() as usize..]; + require( + tail.trim_ascii() == b"", + "Invalid or ambiguous Info.plist XML footer", + )?; + } + result + } + .map_err(|_| "Invalid, duplicate or excessive Info.plist metadata")?; + for (key, expected) in [ + ("CFBundleName", identity.product_name.as_str()), + ("CFBundleDisplayName", identity.product_name.as_str()), + ("CFBundleExecutable", identity.executable.as_str()), + ("CFBundleIdentifier", identity.bundle_identifier.as_str()), + ("CFBundlePackageType", "APPL"), + ( + "CFBundleShortVersionString", + identity.desktop_version.as_str(), + ), + ("CFBundleVersion", identity.desktop_version.as_str()), + ( + "LSMinimumSystemVersion", + identity.minimum_system_version.as_str(), + ), + ] { + value.equals(key, expected)?; + } + if let Some(floors) = value.map()?.get("LSMinimumSystemVersionByArchitecture") { + floors.equals("arm64", &identity.minimum_system_version)?; + } + Ok(()) +} + +struct ExactXmlReader(R); + +impl Read for ExactXmlReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + let length = output.len().min(1); + self.0.read(&mut output[..length]) + } +} + +impl Seek for ExactXmlReader { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.0.seek(position) + } +} + +fn json_metadata(bytes: &[u8]) -> Result { + let Document(value) = serde_json::from_slice(bytes) + .map_err(|_| "Invalid, duplicate or excessive JSON metadata")?; + Ok(value) +} + +fn validate_package(bytes: &[u8], identity: &ArchiveIdentity) -> Result<(), String> { + let value = json_metadata(bytes)?; + value.equals("name", &identity.package_name)?; + value.equals("version", &identity.product_version)?; + value.equals("desktopVersion", &identity.desktop_version)?; + value.equals("productName", &identity.product_name)?; + value + .field("build")? + .equals("appId", &identity.bundle_identifier)?; + value + .field("build")? + .equals("productName", &identity.product_name) +} + +fn validate_runtime_manifest( + bytes: &[u8], + entries: &BTreeMap, + root: &str, +) -> Result<(), String> { + let value = json_metadata(bytes)?; + require( + matches!(value.field("schemaVersion")?, MetadataValue::Integer(1)), + "Unsupported runtime manifest schema", + )?; + for key in ["gjcSdk", "bun", "natives"] { + let version = value.field(key)?.string()?; + require( + version.len() <= 128 && semver::Version::parse(version).is_ok(), + "Invalid runtime manifest version", + )?; + } + let MetadataValue::Array(files) = value + .field("platforms")? + .field("darwin-arm64")? + .field("files")? + else { + return Err("Runtime manifest files must be an array".to_owned()); + }; + require(!files.is_empty(), "Runtime manifest closure is empty")?; + let mut seen = BTreeSet::new(); + for file in files { + let package = file.field("package")?.string()?; + relative_components(package)?; + let parts: Vec<_> = package.split('/').collect(); + require( + (parts.len() == 1 && !package.starts_with('@')) + || (parts.len() == 2 && parts[0].starts_with('@') && parts[0].len() > 1), + "Invalid runtime package name", + )?; + let relative = file.field("path")?.string()?; + relative_components(relative)?; + let digest = file.field("sha256")?.string()?; + require( + digest.len() == 64 + && digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "Invalid runtime manifest hash", + )?; + let path = format!("{root}/{PAYLOAD}/node_modules/{package}/{relative}"); + require( + seen.insert(path.clone()), + "Duplicate runtime manifest member", + )?; + let entry = entries + .get(&path) + .ok_or("Runtime manifest member is missing")?; + require( + matches!(&entry.kind, ArchiveEntryKind::File { sha256, .. } if sha256 == digest), + "Runtime manifest member hash/type mismatch", + )?; + } + Ok(()) +} + +fn macos_version(text: &str) -> Result { + let parts: Vec<_> = text.split('.').collect(); + require( + parts.len() == 2 || parts.len() == 3, + "Invalid macOS version", + )?; + let mut values = [0_u32; 3]; + for (index, part) in parts.iter().enumerate() { + let value = parse_decimal(part)?; + require( + value <= if index == 0 { 65535 } else { 255 }, + "macOS version component overflow", + )?; + values[index] = value as u32; + } + Ok((values[0] << 16) | (values[1] << 8) | values[2]) +} + +fn u32_at(bytes: &[u8], offset: usize) -> Result { + let slice = bytes + .get(offset..offset + 4) + .ok_or("Truncated Mach-O header")?; + Ok(u32::from_le_bytes( + slice.try_into().map_err(|_| "Invalid Mach-O word")?, + )) +} + +fn u64_at(bytes: &[u8], offset: usize) -> Result { + let slice = bytes + .get(offset..offset + 8) + .ok_or("Truncated Mach-O command")?; + Ok(u64::from_le_bytes( + slice.try_into().map_err(|_| "Invalid Mach-O word")?, + )) +} + +fn validate_macho(bytes: &[u8], size: u64, floor: &str) -> Result<(), String> { + // Layout/constants follow Apple's SDK mach-o/loader.h and mach/machine.h. + // Thin little-endian MH_MAGIC_64 / CPU_TYPE_ARM64 / ARM64_ALL / + // MH_EXECUTE. No fat/universal, Intel, ARM64E-only or dylib substitution. + require( + u32_at(bytes, 0)? == 0xfeed_facf + && u32_at(bytes, 4)? == 0x0100_000c + && u32_at(bytes, 8)? == 0 + && u32_at(bytes, 12)? == 2, + "Main executable is not a thin arm64 Mach-O executable", + )?; + let count = u32_at(bytes, 16)? as usize; + let length = u32_at(bytes, 20)? as usize; + require( + count > 0 + && count <= 4096 + && length <= MAX_METADATA_BYTES - 32 + && length + 32 <= bytes.len(), + "Invalid or oversized Mach-O load commands", + )?; + let end = 32 + length; + let mut offset = 32; + let mut minimum = None; + let mut main = None; + let mut executable_ranges = Vec::new(); + for _ in 0..count { + require(offset + 8 <= end, "Truncated Mach-O load command")?; + let command = u32_at(bytes, offset)?; + let length = u32_at(bytes, offset + 4)? as usize; + require( + length >= 8 && length % 8 == 0 && length <= end - offset, + "Invalid Mach-O command length", + )?; + match command { + 0x32 => { + // LC_BUILD_VERSION + require( + length >= 24 && u32_at(bytes, offset + 8)? == 1, + "Mach-O is not a macOS build", + )?; + require( + u32_at(bytes, offset + 20)? as u64 * 8 + 24 == length as u64, + "Invalid Mach-O build tools", + )?; + require( + minimum.replace(u32_at(bytes, offset + 12)?).is_none(), + "Ambiguous Mach-O OS floor", + )?; + } + 0x24 => { + // LC_VERSION_MIN_MACOSX + require(length == 16, "Invalid Mach-O OS floor command")?; + require( + minimum.replace(u32_at(bytes, offset + 8)?).is_none(), + "Ambiguous Mach-O OS floor", + )?; + } + 0x19 => { + // LC_SEGMENT_64 + require( + length >= 72 && u32_at(bytes, offset + 64)? as u64 * 80 + 72 == length as u64, + "Invalid Mach-O segment", + )?; + let start = u64_at(bytes, offset + 40)?; + let length = u64_at(bytes, offset + 48)?; + require( + start <= size && length <= size - start, + "Mach-O segment outside executable", + )?; + if u32_at(bytes, offset + 60)? & 4 != 0 && length > 0 { + executable_ranges.push((start, start + length)); + } + } + 0x8000_0028 => { + // LC_MAIN + require( + length == 24 && main.replace(u64_at(bytes, offset + 8)?).is_none(), + "Invalid or duplicate Mach-O entry point", + )?; + } + 0x1d => { + // LC_CODE_SIGNATURE: bounds only, NEVER signature proof. + require(length == 16, "Invalid Mach-O code signature command")?; + let start = u32_at(bytes, offset + 8)? as u64; + let length = u32_at(bytes, offset + 12)? as u64; + require( + start <= size && length <= size - start, + "Mach-O signature data outside executable", + )?; + } + _ => {} + } + offset += length; + } + require(offset == end, "Mach-O command count/size disagreement")?; + require( + minimum.is_some_and(|version| version > 0 && version <= macos_version(floor).unwrap_or(0)), + "Mach-O deployment floor exceeds manifest or is missing", + )?; + require( + main.is_some_and(|entry| { + executable_ranges + .iter() + .any(|(start, end)| entry >= *start && entry < *end) + }), + "Mach-O entry point is outside executable segments", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{write::GzEncoder, Compression}; + use std::io::Write; + + const ROOT: &str = "Gajae Code App.app"; + const PLIST: &str = "Gajae Code App.app/Contents/Info.plist"; + const EXECUTABLE: &str = "Gajae Code App.app/Contents/MacOS/gajae-app-desktop"; + const PACKAGE: &str = + "Gajae Code App.app/Contents/Resources/resources/server-payload/package.json"; + const RUNTIME: &str = "Gajae Code App.app/Contents/Resources/resources/server-payload/server/gjc-runtime-manifest.json"; + const NATIVE: &str = "Gajae Code App.app/Contents/Resources/resources/server-payload/node_modules/@gajae-code/natives/native/index.js"; + + #[derive(Clone)] + struct Fixture { + path: String, + kind: u8, + mode: u32, + data: Vec, + target: String, + pax: Vec<(String, Vec)>, + } + + impl Fixture { + fn file(path: &str, data: &[u8]) -> Self { + Self { + path: path.into(), + kind: b'0', + mode: 0o644, + data: data.into(), + target: String::new(), + pax: Vec::new(), + } + } + fn directory(path: &str) -> Self { + Self { + kind: b'5', + mode: 0o755, + ..Self::file(path, b"") + } + } + fn link(path: &str, target: &str) -> Self { + Self { + kind: b'2', + mode: 0o777, + target: target.into(), + ..Self::file(path, b"") + } + } + fn header(&self) -> tar::Header { + let mut header = tar::Header::new_ustar(); + header.set_mode(self.mode); + header.set_size(self.data.len() as u64); + header.set_entry_type(tar::EntryType::new(self.kind)); + if self.path.len() <= 100 || header.set_path(&self.path).is_err() { + // Deliberately bypass builder traversal protection for attacks. + header.as_mut_bytes()[..100].fill(0); + let bytes = self.path.as_bytes(); + header.as_mut_bytes()[..bytes.len().min(100)] + .copy_from_slice(&bytes[..bytes.len().min(100)]); + } + if !self.target.is_empty() { + let bytes = self.target.as_bytes(); + header.as_mut_bytes()[157..257].fill(0); + header.as_mut_bytes()[157..157 + bytes.len().min(100)] + .copy_from_slice(&bytes[..bytes.len().min(100)]); + } + header.set_cksum(); + header + } + } + + fn identity() -> ArchiveIdentity { + ArchiveIdentity { + product_name: "Gajae Code App".into(), + executable: "gajae-app-desktop".into(), + bundle_identifier: "app.gajae.desktop".into(), + package_name: "gajae-app".into(), + desktop_version: "0.2.4".into(), + product_version: "2.0.0-beta.10".into(), + minimum_system_version: "13.0".into(), + } + } + + fn plist_value() -> plist::Value { + let identity = identity(); + let fields = [ + ("CFBundleName", identity.product_name.clone()), + ("CFBundleDisplayName", identity.product_name), + ("CFBundleExecutable", identity.executable), + ("CFBundleIdentifier", identity.bundle_identifier), + ("CFBundlePackageType", "APPL".into()), + ( + "CFBundleShortVersionString", + identity.desktop_version.clone(), + ), + ("CFBundleVersion", identity.desktop_version), + ("LSMinimumSystemVersion", identity.minimum_system_version), + ]; + plist::Value::Dictionary( + fields + .into_iter() + .map(|(key, value)| (key.to_owned(), plist::Value::String(value))) + .collect(), + ) + } + + fn plist_bytes(value: &plist::Value, binary: bool) -> Vec { + let mut bytes = Vec::new(); + if binary { + value.to_writer_binary(&mut bytes).unwrap(); + } else { + value.to_writer_xml(&mut bytes).unwrap(); + } + bytes + } + + fn word(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + fn wide(bytes: &mut [u8], offset: usize, value: u64) { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + + /// Structural Mach-O fixture only: not signed, loadable or executable code. + fn macho() -> Vec { + let mut bytes = vec![0; 256]; + for (offset, value) in [ + (0, 0xfeed_facf), + (4, 0x0100_000c), + (12, 2), + (16, 3), + (20, 120), + (32, 0x19), + (36, 72), + (92, 5), + (104, 0x32), + (108, 24), + (112, 1), + (116, 13 << 16), + (128, 0x8000_0028), + (132, 24), + ] { + word(&mut bytes, offset, value); + } + wide(&mut bytes, 80, 256); // segment filesize + wide(&mut bytes, 136, 160); // LC_MAIN entryoff + bytes + } + + fn fixture() -> Vec { + let id = identity(); + let package = serde_json::to_vec(&serde_json::json!({ + "name": id.package_name, "version": id.product_version, + "desktopVersion": id.desktop_version, "productName": id.product_name, + "build": { "appId": id.bundle_identifier, "productName": "Gajae Code App" }, + "scripts": {}, "dependencies": {} + })) + .unwrap(); + let native = b"export const fixture = true;\n"; + let runtime = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 1, "gjcSdk": "0.16.4", "bun": "1.4.0", "natives": "0.16.4", + "platforms": { "darwin-arm64": { "files": [{ + "package": "@gajae-code/natives", "path": "native/index.js", "sha256": hash_bytes(native) + }] } } + })).unwrap(); + let mut files = vec![ + Fixture::file(PLIST, &plist_bytes(&plist_value(), false)), + Fixture { + mode: 0o755, + ..Fixture::file(EXECUTABLE, &macho()) + }, + Fixture::file(PACKAGE, &package), + Fixture::file(RUNTIME, &runtime), + Fixture::file(NATIVE, native), + ]; + let mut parents = BTreeSet::new(); + for file in &files { + let mut parent = file.path.as_str(); + while let Some((prefix, _)) = parent.rsplit_once('/') { + parents.insert(prefix.to_owned()); + parent = prefix; + } + } + files.extend(parents.iter().map(|p| Fixture::directory(p))); + files.sort_by(|left, right| left.path.cmp(&right.path)); + files + } + + fn tar_bytes(fixtures: &[Fixture]) -> Vec { + let mut tar = tar::Builder::new(Vec::new()); + for file in fixtures { + if !file.pax.is_empty() { + tar.append_pax_extensions(file.pax.iter().map(|(k, v)| (k.as_str(), v.as_slice()))) + .unwrap(); + } + tar.append(&file.header(), file.data.as_slice()).unwrap(); + } + tar.into_inner().unwrap() + } + fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + fn inspect(fixtures: &[Fixture]) -> Result { + inspect_archive(&gzip(&tar_bytes(fixtures)), &identity()) + } + fn get<'a>(fixtures: &'a mut [Fixture], path: &str) -> &'a mut Fixture { + fixtures.iter_mut().find(|f| f.path == path).unwrap() + } + fn rejected(fixtures: &[Fixture], reason: &str) { + let error = inspect(fixtures).unwrap_err(); + assert!(error.contains(reason), "expected {reason:?}, got {error:?}"); + } + + #[test] + fn accepts_complete_inventory_and_roundtrips_owned_types() { + let fixtures = fixture(); + let raw = tar_bytes(&fixtures); + let bytes = gzip(&raw); + let inventory = inspect_archive(&bytes, &identity()).unwrap(); + assert_eq!(inventory.root, ROOT); + assert_eq!(inventory.compressed_bytes, bytes.len() as u64); + assert_eq!(inventory.expanded_bytes, raw.len() as u64); + assert_eq!( + inventory.total_file_bytes, + fixtures.iter().map(|f| f.data.len() as u64).sum::() + ); + assert_eq!(inventory.archive_sha256, hash_bytes(&bytes)); + assert_eq!(inventory.entries.len(), fixtures.len()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&inventory).unwrap()) + .unwrap(), + inventory + ); + } + + #[test] + fn accepts_binary_plist_and_nested_standard_plist_values() { + let mut value = plist_value(); + value.as_dictionary_mut().unwrap().insert( + "CFBundleURLTypes".into(), + plist::Value::Array(vec![plist::Value::Dictionary( + [ + ( + "CFBundleURLSchemes".to_owned(), + plist::Value::Array(vec![plist::Value::String("gajae-app".into())]), + ), + ("Enabled".to_owned(), plist::Value::Boolean(true)), + ] + .into_iter() + .collect(), + )]), + ); + for binary in [true, false] { + let mut fixtures = fixture(); + get(&mut fixtures, PLIST).data = plist_bytes(&value, binary); + inspect(&fixtures).unwrap(); + } + } + + #[test] + fn inventory_hash_commits_to_every_file_mode_link_and_path_not_order() { + let mut fixtures = fixture(); + fixtures.push(Fixture::file(&format!("{ROOT}/extra"), b"A")); + fixtures.push(Fixture::file(&format!("{ROOT}/other"), b"B")); + fixtures.push(Fixture::link(&format!("{ROOT}/link"), "extra")); + let original = inspect(&fixtures).unwrap(); + fixtures.reverse(); + let reordered = inspect(&fixtures).unwrap(); + assert_eq!(original.entries, reordered.entries); + assert_eq!(original.inventory_sha256, reordered.inventory_sha256); + assert_ne!(original.archive_sha256, reordered.archive_sha256); + for change in 0..5 { + let mut changed = fixtures.clone(); + let entry = get(&mut changed, &format!("{ROOT}/extra")); + match change { + 0 => entry.data[0] ^= 1, + 1 => entry.mode = 0o600, + 2 => get(&mut changed, ROOT).mode = 0o700, + 3 => get(&mut changed, &format!("{ROOT}/link")).target = "other".into(), + _ => get(&mut changed, &format!("{ROOT}/other")).path = format!("{ROOT}/renamed"), + } + assert_ne!( + original.inventory_sha256, + inspect(&changed).unwrap().inventory_sha256 + ); + } + } + + #[test] + fn compressed_expanded_count_and_aggregate_metadata_limits_are_hard() { + let fixtures = fixture(); + let raw = tar_bytes(&fixtures); + let bytes = gzip(&raw); + for limits in [ + Limits { + compressed: bytes.len() - 1, + ..Limits::default() + }, + Limits { + expanded: raw.len() as u64 - 1, + ..Limits::default() + }, + Limits { + entries: fixtures.len() - 1, + ..Limits::default() + }, + Limits { + inventory_metadata: 20, + ..Limits::default() + }, + Limits { + metadata: 64, + ..Limits::default() + }, + ] { + assert!(inspect_with_limits(&bytes, &identity(), limits).is_err()); + } + let exact = Limits { + compressed: bytes.len(), + expanded: raw.len() as u64, + entries: fixtures.len(), + ..Limits::default() + }; + inspect_with_limits(&bytes, &identity(), exact).unwrap(); + } + + #[test] + fn counts_zero_padding_and_pax_headers_against_limits() { + let fixtures = fixture(); + let mut raw = tar_bytes(&fixtures); + let cap = raw.len() as u64 + 512; + raw.extend_from_slice(&[0; 1024]); + assert!(inspect_with_limits( + &gzip(&raw), + &identity(), + Limits { + expanded: cap, + ..Limits::default() + } + ) + .is_err()); + let mut fixtures = fixtures; + fixtures[0] + .pax + .push(("path".into(), ROOT.as_bytes().into())); + assert!(inspect_with_limits( + &gzip(&tar_bytes(&fixtures)), + &identity(), + Limits { + entries: fixtures.len(), + ..Limits::default() + } + ) + .is_err()); + } + + #[test] + fn refuses_huge_declared_sizes_before_body_allocation() { + for kind in [b'0', b'x'] { + let fixture = Fixture { + kind, + ..Fixture::file(ROOT, b"") + }; + let mut header = fixture.header(); + header.set_size(MAX_EXPANDED_BYTES + 1); + header.set_cksum(); + assert!(inspect_archive(&gzip(header.as_bytes()), &identity()) + .unwrap_err() + .contains("size exceeds")); + } + let mut header = Fixture { + kind: b'x', + ..Fixture::file("PaxHeader/root", b"") + } + .header(); + header.set_size(MAX_METADATA_BYTES as u64 + 1); + header.set_cksum(); + assert!(inspect_archive(&gzip(header.as_bytes()), &identity()) + .unwrap_err() + .contains("PAX metadata exceeds")); + } + + #[test] + fn rejects_every_truncation_of_a_small_valid_gzip() { + let bytes = gzip(&tar_bytes(&fixture())); + for cut in 0..bytes.len() { + assert!( + inspect_archive(&bytes[..cut], &identity()).is_err(), + "accepted truncation {cut}" + ); + } + } + + #[test] + fn rejects_crc_isize_and_hidden_compressed_members() { + let bytes = gzip(&tar_bytes(&fixture())); + for position in [bytes.len() - 8, bytes.len() - 4] { + let mut bad = bytes.clone(); + bad[position] ^= 1; + assert!(inspect_archive(&bad, &identity()).is_err()); + } + for suffix in [vec![0], b"garbage".to_vec(), gzip(b""), bytes.clone()] { + let mut bad = bytes.clone(); + bad.extend_from_slice(&suffix); + assert!(inspect_archive(&bad, &identity()).is_err()); + } + assert!(inspect_archive(b"not gzip", &identity()).is_err()); + } + + #[test] + fn rejects_excessive_gzip_metadata_and_invalid_gzip_flags() { + let raw = tar_bytes(&fixture()); + let mut writer = flate2::GzBuilder::new() + .comment(vec![b'x'; 1024]) + .write(Vec::new(), Compression::fast()); + writer.write_all(&raw).unwrap(); + let bytes = writer.finish().unwrap(); + assert!(inspect_with_limits( + &bytes, + &identity(), + Limits { + metadata: 1024, + ..Limits::default() + } + ) + .unwrap_err() + .contains("Gzip metadata")); + let mut bytes = gzip(&raw); + bytes[3] |= 0x80; + assert!(inspect_archive(&bytes, &identity()).is_err()); + } + + #[test] + fn rejects_tar_trailing_data_missing_markers_and_bad_checksum() { + let raw = tar_bytes(&fixture()); + for removed in [1, 512, 1024] { + assert!(inspect_archive(&gzip(&raw[..raw.len() - removed]), &identity()).is_err()); + } + let mut hidden = raw.clone(); + hidden.extend_from_slice(&raw); + assert!(inspect_archive(&gzip(&hidden), &identity()) + .unwrap_err() + .contains("trailing tar")); + let mut aligned = raw.clone(); + aligned.push(0); + assert!(inspect_archive(&gzip(&aligned), &identity()).is_err()); + let mut bad = raw; + bad[0] ^= 1; + assert!(inspect_archive(&gzip(&bad), &identity()).is_err()); + } + + #[test] + fn rejects_foreign_root_traversal_separator_control_and_unicode_paths() { + let paths = [ + "/Gajae Code App.app/bad", + "../Gajae Code App.app/bad", + "./Gajae Code App.app/bad", + "Other.app/bad", + "gajae code app.app/bad", + "Gajae Code App.app2/bad", + "Gajae Code App.app/../bad", + "Gajae Code App.app/./bad", + "Gajae Code App.app//bad", + "Gajae Code App.app/evil\\path", + "Gajae Code App.app/evil:path", + "Gajae Code App.app/._metadata", + "Gajae Code App.app/a\nb", + "Gajae Code App.app/a\tb", + "Gajae Code App.app/café", + "Gajae Code App.app/cafe\u{301}", + "Gajae Code App.app/한글", + "Gajae Code App.app/bad/", + ]; + for path in paths { + let mut fixtures = fixture(); + fixtures.push(Fixture::file(path, b"x")); + assert!(inspect(&fixtures).is_err(), "accepted {path:?}"); + } + } + + #[test] + fn rejects_missing_parents_duplicate_paths_and_case_aliases() { + for path in [ + ROOT.to_owned(), + format!("{ROOT}/Contents/info.plist"), + format!("{ROOT}/Contents/Info.plist"), + format!("{ROOT}/Missing/file"), + ] { + let mut fixtures = fixture(); + fixtures.push(Fixture::file(&path, b"x")); + assert!(inspect(&fixtures).is_err()); + } + let mut fixtures = fixture(); + fixtures.push(Fixture::directory(&format!("{ROOT}/"))); + rejected(&fixtures, "Duplicate"); + let mut fixtures = fixture(); + fixtures.retain(|f| f.path != ROOT); + rejected(&fixtures, "root"); + let mut fixtures = fixture(); + fixtures.retain(|f| f.path != format!("{ROOT}/Contents")); + rejected(&fixtures, "parent"); + } + + #[test] + fn rejects_special_files_modes_and_nonfile_data() { + for kind in [ + b'1', b'3', b'4', b'6', b'7', b'S', b'g', b'L', b'K', b'D', b'V', + ] { + let mut fixtures = fixture(); + fixtures.push(Fixture { + kind, + ..Fixture::file(&format!("{ROOT}/special"), b"") + }); + rejected(&fixtures, "Unsupported"); + } + for mode in [0o1000, 0o2000, 0o4000, 0o100755] { + let mut fixtures = fixture(); + get(&mut fixtures, EXECUTABLE).mode = mode; + rejected(&fixtures, "permission"); + } + for kind in [b'2', b'5'] { + let mut fixtures = fixture(); + fixtures.push(Fixture { + kind, + ..Fixture::file(&format!("{ROOT}/data"), b"not empty") + }); + assert!(inspect(&fixtures).is_err()); + } + let mut fixtures = fixture(); + get(&mut fixtures, PLIST).target = "Contents".into(); + rejected(&fixtures, "link metadata"); + } + + #[test] + fn accepts_local_pax_long_paths_and_links_like_release_packer() { + let mut fixtures = fixture(); + let mut parent = ROOT.to_owned(); + for _ in 0..4 { + parent.push('/'); + parent.push_str(&"long".repeat(15)); + fixtures.push(Fixture::directory(&parent)); + } + let long_path = format!("{parent}/data"); + fixtures.push(Fixture { + pax: vec![ + ("path".into(), long_path.as_bytes().into()), + ("size".into(), b"1".to_vec()), + ], + ..Fixture::file(&format!("{ROOT}/placeholder"), b"x") + }); + let target = long_path.strip_prefix(&format!("{ROOT}/")).unwrap(); + fixtures.push(Fixture { + pax: vec![("linkpath".into(), target.as_bytes().into())], + ..Fixture::link(&format!("{ROOT}/link"), "placeholder") + }); + // Long directory paths also need PAX (tar::Builder would use GNU L). + for file in &mut fixtures { + if file.kind == b'5' && file.path.len() > 250 { + file.pax + .push(("path".into(), file.path.as_bytes().to_vec())); + file.path = format!("{ROOT}/directory-placeholder"); + } + } + let inventory = inspect(&fixtures).unwrap(); + assert!(inventory.entries.iter().any(|e| e.path == long_path)); + } + + #[test] + fn rejects_pax_ambiguity_sparse_xattr_traversal_and_size_disagreement() { + let variants: Vec)>> = vec![ + vec![("path".into(), b"../outside".to_vec())], + vec![("path".into(), b"/absolute".to_vec())], + vec![("path".into(), format!("{ROOT}/a\0hidden").into_bytes())], + vec![ + ("path".into(), format!("{ROOT}/a").into_bytes()), + ("path".into(), format!("{ROOT}/b").into_bytes()), + ], + vec![("size".into(), b"2".to_vec())], + vec![("size".into(), b"01".to_vec())], + vec![("size".into(), b"18446744073709551616".to_vec())], + vec![("GNU.sparse.map".into(), b"0,1".to_vec())], + vec![("SCHILY.xattr.user.foo".into(), b"x".to_vec())], + vec![("SCHILY.acl.access".into(), b"x".to_vec())], + vec![("mode".into(), b"493".to_vec())], + vec![("type".into(), b"2".to_vec())], + vec![("mtime".into(), b"NaN".to_vec())], + ]; + for pax in variants { + let mut fixtures = fixture(); + fixtures.push(Fixture { + pax, + ..Fixture::file(&format!("{ROOT}/extra"), b"x") + }); + assert!(inspect(&fixtures).is_err()); + } + } + + #[test] + fn rejects_malformed_and_dangling_pax_records() { + for data in [ + b"12 path=bad\n".as_slice(), + b"11 path=xx", + b"\n11 path=xx\n", + b"11 path=xx\n\nignored", + b"11 path=xx\n9 size=1\n", + ] { + let mut fixtures = fixture(); + fixtures.insert( + 0, + Fixture { + kind: b'x', + ..Fixture::file("PaxHeader/x", data) + }, + ); + assert!(inspect(&fixtures).is_err()); + } + let mut tar = tar::Builder::new(Vec::new()); + tar.append_pax_extensions([("path", ROOT.as_bytes())]) + .unwrap(); + assert!(inspect_archive(&gzip(&tar.into_inner().unwrap()), &identity()).is_err()); + let mut fixtures = fixture(); + fixtures.insert( + 0, + Fixture { + kind: b'x', + data: b"9 size=0\n".to_vec(), + ..Fixture::file("PaxHeader/x", b"") + }, + ); + fixtures[1].pax = vec![("path".into(), ROOT.as_bytes().into())]; + rejected(&fixtures, "Stacked PAX"); + } + + #[test] + fn accepts_framework_link_chains_with_complete_parent_inventory() { + let mut fixtures = fixture(); + for path in ["Framework", "Framework/Versions", "Framework/Versions/A"] { + fixtures.push(Fixture::directory(&format!("{ROOT}/{path}"))); + } + fixtures.push(Fixture::file( + &format!("{ROOT}/Framework/Versions/A/Foo"), + b"framework", + )); + fixtures.push(Fixture::link( + &format!("{ROOT}/Framework/Versions/Current"), + "A", + )); + fixtures.push(Fixture::link( + &format!("{ROOT}/Framework/Foo"), + "Versions/Current/Foo", + )); + fixtures.push(Fixture::link( + &format!("{ROOT}/again"), + "Framework/../Framework/Foo", + )); + inspect(&fixtures).unwrap(); + } + + #[test] + fn rejects_escaping_dangling_cycles_case_links_and_file_dotdot() { + for target in [ + "/tmp/escape", + "../escape", + "../../escape", + "C:/escape", + "Contents\\Info.plist", + "Contents//Info.plist", + "missing", + "link", + "contents/Info.plist", + "Contents/Info.plist/..", + "Contents/Info.plist/", + "Contents/Info.plist/.", + ] { + let mut fixtures = fixture(); + fixtures.push(Fixture::link(&format!("{ROOT}/link"), target)); + assert!(inspect(&fixtures).is_err(), "accepted {target:?}"); + } + let mut fixtures = fixture(); + fixtures.push(Fixture::link(&format!("{ROOT}/a"), "b")); + fixtures.push(Fixture::link(&format!("{ROOT}/b"), "a")); + rejected(&fixtures, "cycle"); + let mut fixtures = fixture(); + fixtures.push(Fixture::link(&format!("{ROOT}/Contents/back"), "..")); + fixtures.push(Fixture::link( + &format!("{ROOT}/danger"), + "Contents/back/../outside", + )); + rejected(&fixtures, "escapes"); + } + + #[test] + fn rejects_children_beneath_links_regardless_of_order() { + for reverse in [true, false] { + let mut fixtures = fixture(); + fixtures.push(Fixture::link(&format!("{ROOT}/linked"), "Contents")); + fixtures.push(Fixture::file(&format!("{ROOT}/linked/injected"), b"x")); + if reverse { + fixtures.reverse(); + } + rejected(&fixtures, "parent"); + } + } + + #[test] + fn rejects_overlong_paths_components_and_depth() { + for path in [ + format!("{ROOT}/{}", "x".repeat(256)), + format!("{ROOT}/{}", "x/".repeat(128)), + format!("{ROOT}/{}", "x".repeat(MAX_PATH_BYTES)), + ] { + let mut fixtures = fixture(); + fixtures.push(Fixture { + pax: vec![("path".into(), path.into_bytes())], + ..Fixture::file(&format!("{ROOT}/short"), b"") + }); + assert!(inspect(&fixtures).is_err()); + } + } + + #[test] + fn identity_fields_must_match_archive_and_be_trusted_components() { + let fixtures = fixture(); + let bytes = gzip(&tar_bytes(&fixtures)); + for field in 0..7 { + let mut id = identity(); + match field { + 0 => id.product_name = "Other".into(), + 1 => id.executable = "other".into(), + 2 => id.bundle_identifier = "other.app".into(), + 3 => id.package_name = "gajae-app-server".into(), + 4 => id.desktop_version = "0.2.5".into(), + 5 => id.product_version = "2.0.0-beta.11".into(), + _ => id.minimum_system_version = "14.0".into(), + } + assert!(inspect_archive(&bytes, &id).is_err()); + } + let mut id = identity(); + id.executable = "../../other".into(); + assert!(inspect_archive(&bytes, &id).is_err()); + let mut id = identity(); + id.product_version = "2.0.0+unbound".into(); + assert!(inspect_archive(&bytes, &id).is_err()); + } + + #[test] + fn rejects_missing_and_symlinked_identity_members() { + for path in [PLIST, PACKAGE, RUNTIME, EXECUTABLE] { + let mut fixtures = fixture(); + fixtures.retain(|f| f.path != path); + assert!(inspect(&fixtures).is_err()); + let mut fixtures = fixture(); + let original = get(&mut fixtures, path).clone(); + let alternative = format!("{ROOT}/alternative"); + fixtures.push(Fixture { + path: alternative, + ..original + }); + let parent_depth = path.split('/').count() - 2; + *get(&mut fixtures, path) = + Fixture::link(path, &format!("{}alternative", "../".repeat(parent_depth))); + assert!(inspect(&fixtures).is_err()); + } + } + + #[test] + fn rejects_plist_mismatch_wrong_type_duplicates_and_deep_nesting() { + for key in [ + "CFBundleName", + "CFBundleDisplayName", + "CFBundleExecutable", + "CFBundleIdentifier", + "CFBundlePackageType", + "CFBundleShortVersionString", + "CFBundleVersion", + "LSMinimumSystemVersion", + ] { + let mut value = plist_value(); + value + .as_dictionary_mut() + .unwrap() + .insert(key.into(), plist::Value::String("wrong".into())); + let mut fixtures = fixture(); + get(&mut fixtures, PLIST).data = plist_bytes(&value, false); + assert!(inspect(&fixtures).is_err()); + } + let mut fixtures = fixture(); + let xml = String::from_utf8(get(&mut fixtures, PLIST).data.clone()).unwrap(); + get(&mut fixtures, PLIST).data = xml + .replace( + "", + "CFBundleNameGajae Code App", + ) + .into_bytes(); + rejected(&fixtures, "Info.plist"); + let mut value = plist_value(); + value + .as_dictionary_mut() + .unwrap() + .insert("CFBundleVersion".into(), plist::Value::Integer(24.into())); + let mut fixtures = fixture(); + get(&mut fixtures, PLIST).data = plist_bytes(&value, true); + rejected(&fixtures, "string"); + let mut nested = plist::Value::String("x".into()); + for _ in 0..40 { + nested = plist::Value::Array(vec![nested]); + } + let mut value = plist_value(); + value + .as_dictionary_mut() + .unwrap() + .insert("Extra".into(), nested); + let mut fixtures = fixture(); + get(&mut fixtures, PLIST).data = plist_bytes(&value, true); + rejected(&fixtures, "Info.plist"); + } + + #[test] + fn rejects_plist_reference_amplification_under_small_encoded_size() { + // The binary writer deduplicates strings, so the encoded input is small + // while materializing every repeated reference would exceed the budget. + let mut value = plist_value(); + value.as_dictionary_mut().unwrap().insert( + "Amplification".into(), + plist::Value::Array(vec![plist::Value::String("x".repeat(4096)); 100]), + ); + let bytes = plist_bytes(&value, true); + assert!(bytes.len() < MAX_METADATA_BYTES); + let mut fixtures = fixture(); + get(&mut fixtures, PLIST).data = bytes; + rejected(&fixtures, "Info.plist"); + } + + #[test] + fn rejects_package_identity_disagreement_duplicate_keys_and_trailing_json() { + for key in ["name", "version", "desktopVersion", "productName"] { + let mut fixtures = fixture(); + let file = get(&mut fixtures, PACKAGE); + let mut value: serde_json::Value = serde_json::from_slice(&file.data).unwrap(); + value[key] = "wrong".into(); + file.data = serde_json::to_vec(&value).unwrap(); + rejected(&fixtures, "identity mismatch"); + } + let mut fixtures = fixture(); + let file = get(&mut fixtures, PACKAGE); + let mut text = String::from_utf8(file.data.clone()).unwrap(); + text.insert_str(1, "\"name\":\"gajae-app\","); + file.data = text.into_bytes(); + rejected(&fixtures, "JSON metadata"); + let mut fixtures = fixture(); + get(&mut fixtures, PACKAGE).data.extend_from_slice(b"{}"); + rejected(&fixtures, "JSON metadata"); + } + + #[test] + fn runtime_manifest_closure_requires_exact_existing_regular_file_hashes() { + for mutation in 0..9 { + let mut fixtures = fixture(); + let file = get(&mut fixtures, RUNTIME); + let mut value: serde_json::Value = serde_json::from_slice(&file.data).unwrap(); + let record = &mut value["platforms"]["darwin-arm64"]["files"][0]; + match mutation { + 0 => record["sha256"] = "0".repeat(64).into(), + 1 => record["path"] = "../../escape".into(), + 2 => record["package"] = "../../escape".into(), + 3 => record["path"] = "native/missing.js".into(), + 4 => record["sha256"] = "A".repeat(64).into(), + 5 => { + let duplicate = record.clone(); + value["platforms"]["darwin-arm64"]["files"] + .as_array_mut() + .unwrap() + .push(duplicate); + } + 6 => value["schemaVersion"] = 2.into(), + 7 => value["platforms"]["darwin-arm64"]["files"] = serde_json::json!([]), + _ => value["platforms"] = serde_json::json!({ "linux-x64": {} }), + } + file.data = serde_json::to_vec(&value).unwrap(); + assert!(inspect(&fixtures).is_err()); + } + let mut fixtures = fixture(); + get(&mut fixtures, NATIVE).data.push(0); + rejected(&fixtures, "hash/type mismatch"); + } + + #[test] + fn rejects_macho_wrong_arch_type_load_command_floor_and_segment_bounds() { + for (offset, value) in [ + (0, 0xcafe_babe), + (4, 0x0100_0007), + (8, 2), + (12, 6), + (16, 0), + (16, 4097), + (16, 2), + (20, 119), + (20, 65536), + (36, 7), + (36, 0), + (36, 65528), + (96, 100), + (112, 2), + (116, 14 << 16), + (124, 1), + (136, 256), + (92, 1), + ] { + let mut fixtures = fixture(); + word(&mut get(&mut fixtures, EXECUTABLE).data, offset, value); + assert!(inspect(&fixtures).is_err(), "accepted {offset}={value}"); + } + let mut fixtures = fixture(); + wide(&mut get(&mut fixtures, EXECUTABLE).data, 80, u64::MAX); + rejected(&fixtures, "segment outside"); + let mut fixtures = fixture(); + get(&mut fixtures, EXECUTABLE).data.truncate(31); + rejected(&fixtures, "load commands"); + let mut fixtures = fixture(); + get(&mut fixtures, EXECUTABLE).mode = 0o644; + rejected(&fixtures, "execute permission"); + } + + #[test] + fn accepts_older_macho_floor_but_not_an_ambiguous_floor() { + let mut fixtures = fixture(); + word(&mut get(&mut fixtures, EXECUTABLE).data, 116, 11 << 16); + inspect(&fixtures).unwrap(); + let mut bytes = macho(); + word(&mut bytes, 16, 4); + word(&mut bytes, 20, 136); + word(&mut bytes, 152, 0x24); + word(&mut bytes, 156, 16); + word(&mut bytes, 160, 13 << 16); + assert!(validate_macho(&bytes, 256, "13.0") + .unwrap_err() + .contains("Ambiguous")); + } + + #[test] + fn macho_code_signature_command_is_only_bounds_checked() { + let mut bytes = macho(); + word(&mut bytes, 16, 4); + word(&mut bytes, 20, 136); + word(&mut bytes, 152, 0x1d); + word(&mut bytes, 156, 16); + word(&mut bytes, 160, 240); + word(&mut bytes, 164, 16); + validate_macho(&bytes, 256, "13.0").unwrap(); + word(&mut bytes, 164, 17); + assert!(validate_macho(&bytes, 256, "13.0") + .unwrap_err() + .contains("signature data outside")); + } + + #[test] + fn rejects_truncated_or_multiple_xml_plist_roots() { + let original = plist_bytes(&plist_value(), false); + let text = std::str::from_utf8(&original).unwrap(); + for text in [ + text.replace("", ""), + text.replace( + "", + "CFBundleNameOther", + ), + format!("{text}"), + format!("{text}{text}"), + ] { + assert!(validate_plist(text.as_bytes(), &identity()).is_err()); + } + } + + /// Optional, explicitly invoked compatibility check. Reads an EXISTING + /// artifact only; no signing, download, filesystem extraction or execution. + #[test] + #[ignore = "requires GJC_ARCHIVE_FIXTURE and GJC_ARCHIVE_FIXTURE_IDENTITY"] + fn existing_release_archive_read_only() { + let path = std::env::var_os("GJC_ARCHIVE_FIXTURE").expect("existing archive path"); + let identity: ArchiveIdentity = serde_json::from_str( + &std::env::var("GJC_ARCHIVE_FIXTURE_IDENTITY").expect("expected identity JSON"), + ) + .unwrap(); + let file = std::fs::File::open(path).unwrap(); + assert!(file.metadata().unwrap().is_file()); + let mut bytes = Vec::new(); + file.take(MAX_COMPRESSED_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .unwrap(); + let inventory = inspect_archive(&bytes, &identity).unwrap(); + if let Some(path) = std::env::var_os("GJC_ARCHIVE_FIXTURE_INVENTORY") { + let file = std::fs::File::open(path).unwrap(); + let mut bytes = Vec::new(); + file.take(MAX_INVENTORY_METADATA_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .unwrap(); + assert!(bytes.len() <= MAX_INVENTORY_METADATA_BYTES); + let producer: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(producer["root"], inventory.root); + assert_eq!(producer["totalFileBytes"], inventory.total_file_bytes); + let expected_entries = producer["entries"].as_array().unwrap(); + assert_eq!(expected_entries.len(), inventory.entries.len()); + for (expected, actual) in expected_entries.iter().zip(&inventory.entries) { + let mut value = serde_json::to_value(&actual.kind).unwrap(); + value["path"] = actual.path.clone().into(); + value["mode"] = actual.mode.into(); + assert_eq!(*expected, value, "producer/native inventory disagreement"); + } + println!( + "all {} producer inventory entries match bytes, modes and links", + expected_entries.len() + ); + } + println!( + "existing archive: compressed={} expanded={} entries={} sha256={} inventory_sha256={}", + inventory.compressed_bytes, + inventory.expanded_bytes, + inventory.entries.len(), + inventory.archive_sha256, + inventory.inventory_sha256 + ); + } +} diff --git a/src-tauri/src/updater_attempt.rs b/src-tauri/src/updater_attempt.rs new file mode 100644 index 00000000..03546081 --- /dev/null +++ b/src-tauri/src/updater_attempt.rs @@ -0,0 +1,218 @@ +//! Presence-only admission guard for an interrupted desktop update attempt. +//! +//! A future writer must durably publish the record before beginning install, +//! and must not clear it without proving the complete operation integrity and +//! completion. This reader intentionally never reads, creates, mutates, or +//! removes the record. +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +const ATTEMPT_RECORD: &str = "desktop-update-attempt.json"; + +/// Admit a startup only when the update-attempt record is validated absent. +/// Any present directory entry, regardless of its contents or type, blocks. +pub(crate) fn check(desktop_data_root: &Path) -> Result<(), String> { + let root = normalize_absolute(desktop_data_root)?; + if !validate_real_directory_ancestors(&root)? { + return Ok(()); + } + let record = root.join(ATTEMPT_RECORD); + match fs::symlink_metadata(&record) { + Ok(_) => Err(format!( + "Desktop update attempt state is present at {}; startup is blocked.", + record.display() + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "Could not validate desktop update attempt state at {}: {error}", + record.display() + )), + } +} + +fn normalize_absolute(path: &Path) -> Result { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("Desktop data root contains an unsafe parent component.".to_owned()); + } + let normalized: PathBuf = path.components().collect(); + if !normalized.is_absolute() { + return Err("Desktop data root must be an absolute directory.".to_owned()); + } + Ok(normalized) +} + +/// Walk only real directory ancestors. A missing tail is a validated absence, +/// but a symlink, non-directory, permission error, or other unknown result is +/// unsafe and refuses startup rather than masquerading as ENOENT. +fn validate_real_directory_ancestors(root: &Path) -> Result { + let mut current = PathBuf::new(); + for component in root.components() { + match component { + Component::Prefix(_) | Component::RootDir => { + current.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + return Err("Desktop data root contains an unsafe parent component.".to_owned()) + } + Component::Normal(name) => { + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "Desktop data root contains a symlink ancestor: {}", + current.display() + )) + } + Ok(metadata) if !metadata.is_dir() => { + return Err(format!( + "Desktop data root ancestor is not a directory: {}", + current.display() + )) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "Could not validate desktop data root ancestor {}: {error}", + current.display() + )) + } + } + } + } + } + Ok(true) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::{ + ffi::OsStr, + fs, + os::unix::{ + ffi::OsStrExt, + fs::{symlink, PermissionsExt}, + }, + process::Command, + time::Duration, + }; + + struct Temp(PathBuf); + + impl Temp { + fn new() -> Self { + let mut entropy = [0; 16]; + getrandom::getrandom(&mut entropy).unwrap(); + let id = u128::from_ne_bytes(entropy); + let temp = fs::canonicalize(std::env::temp_dir()).unwrap(); + let path = temp.join(format!("gajae-updater-attempt-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn missing_root_and_record_are_admitted_without_creation() { + let temp = Temp::new(); + let missing = temp.0.join("missing").join(".gajae-app"); + assert!(check(&missing).is_ok()); + assert!(!missing.exists()); + + let root = temp.0.join("existing"); + fs::create_dir(&root).unwrap(); + assert!(check(&root).is_ok()); + assert!(!root.join(ATTEMPT_RECORD).exists()); + } + + #[test] + fn any_present_record_blocks_without_reading_or_mutating_it() { + let temp = Temp::new(); + let root = temp.0.join("root"); + fs::create_dir(&root).unwrap(); + let record = root.join(ATTEMPT_RECORD); + let matching_version = format!( + "{{\"state\":\"relaunch\",\"target_desktop_version\":\"{}\",\"target_payload_version\":\"{}\"}}", + env!("CARGO_PKG_VERSION"), + env!("GJC_EXPECTED_PAYLOAD_VERSION") + ); + for body in [ + b"".as_slice(), + b"not json".as_slice(), + matching_version.as_bytes(), + ] { + fs::write(&record, body).unwrap(); + let before = fs::read(&record).unwrap(); + assert!(check(&root).is_err()); + assert_eq!(fs::read(&record).unwrap(), before); + } + } + + #[test] + fn inaccessible_record_parent_never_authorizes_startup() { + let temp = Temp::new(); + let root = temp.0.join("restricted"); + fs::create_dir(&root).unwrap(); + fs::write(root.join(ATTEMPT_RECORD), b"pending").unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o0)).unwrap(); + let result = check(&root); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let error = result.expect_err("inaccessible or present records must block"); + if unsafe { libc::geteuid() } != 0 { + assert!(error.starts_with("Could not validate desktop update attempt state")); + } + } + + #[test] + fn symlink_dangling_parent_and_fifo_are_refused() { + let temp = Temp::new(); + let root = temp.0.join("root"); + fs::create_dir(&root).unwrap(); + let target = root.join("target"); + fs::write(&target, b"record").unwrap(); + let record = root.join(ATTEMPT_RECORD); + symlink(&target, &record).unwrap(); + assert!(check(&root).is_err()); + fs::remove_file(&record).unwrap(); + + let link = temp.0.join("dangling"); + symlink(temp.0.join("does-not-exist"), &link).unwrap(); + assert!(check(&link.join("root")).is_err()); + + assert!(Command::new("/usr/bin/mkfifo") + .arg(&record) + .status() + .unwrap() + .success()); + let started = std::time::Instant::now(); + assert!(check(&root).is_err()); + assert!(started.elapsed() < Duration::from_millis(250)); + } + + #[test] + fn unsafe_type_and_invalid_io_roots_are_refused() { + let temp = Temp::new(); + let root_file = temp.0.join("root-file"); + fs::write(&root_file, b"not a directory").unwrap(); + assert!(check(&root_file).is_err()); + + let invalid = Path::new("relative").join("desktop"); + assert!(check(&invalid).is_err()); + // Absolute paths containing NUL produce an unknown metadata I/O + // result; they must not be treated as a missing record. + let invalid_component = OsStr::from_bytes(b"invalid\0desktop-data-root"); + assert!(check(&temp.0.join(invalid_component)).is_err()); + } +} diff --git a/src-tauri/src/updater_binding.rs b/src-tauri/src/updater_binding.rs new file mode 100644 index 00000000..36127e48 --- /dev/null +++ b/src-tauri/src/updater_binding.rs @@ -0,0 +1,187 @@ +//! Runtime admission for a compiled updater build. Disabled builds perform no +//! updater filesystem/network I/O, and ordinary QA can never activate production. +use std::{ + fs, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Disabled, + Production, + Qa, +} + +#[derive(Clone, Debug)] +pub struct Binding { + pub mode: Mode, + pub feed_origin: String, + pub public_key: String, + pub qa_root: Option, +} + +impl Binding { + pub fn compiled() -> Self { + Self { + mode: match env!("GJC_UPDATE_MODE") { + "production" => Mode::Production, + "qa" => Mode::Qa, + _ => Mode::Disabled, + }, + feed_origin: env!("GJC_UPDATE_FEED_ORIGIN").into(), + public_key: env!("GJC_UPDATE_PUBKEY").into(), + qa_root: (!env!("GJC_UPDATE_QA_ROOT").is_empty()) + .then(|| PathBuf::from(env!("GJC_UPDATE_QA_ROOT"))), + } + } + + /// The outer mode/profile checks precede even resolving an executable or + /// opening a state directory. Inputs are native-owned, never browser values. + pub fn admits_profile(&self, qa_profile: Option<&Path>, release_arm64: bool) -> bool { + match self.mode { + Mode::Disabled => false, + Mode::Production => release_arm64 && qa_profile.is_none() && self.qa_root.is_none(), + Mode::Qa => qa_profile.is_some() && qa_profile == self.qa_root.as_deref(), + } + } + + pub fn validate_runtime( + &self, + qa_profile: Option<&Path>, + executable: &Path, + data_root: &Path, + release_arm64: bool, + ) -> Result<(), String> { + if !self.admits_profile(qa_profile, release_arm64) { + return Err("Updater build/profile binding is inactive.".into()); + } + if self.mode == Mode::Production { + if self.feed_origin != "https://api.github.com" || self.public_key.is_empty() { + return Err("Invalid production updater binding.".into()); + } + return Ok(()); + } + let root = self + .qa_root + .as_deref() + .ok_or("Missing compiled updater QA root.")?; + let metadata = fs::symlink_metadata(root).map_err(|_| "Updater QA root is unavailable.")?; + let canonical = root + .canonicalize() + .map_err(|_| "Updater QA root is unavailable.")?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o7777 != 0o700 + || canonical != root + || root.parent() + != Some( + fs::canonicalize(std::env::temp_dir()) + .map_err(|_| "Updater QA temporary root is unavailable.")? + .as_path(), + ) + { + return Err("Updater QA root no longer matches its private build binding.".into()); + } + // Even a matching --qa-profile cannot enable a production/foreign app. + let executable = executable + .canonicalize() + .map_err(|_| "Updater QA app is unavailable.")?; + let data_root = data_root + .canonicalize() + .map_err(|_| "Updater QA data is unavailable.")?; + if !executable.starts_with(root) + || data_root != root.join("home/.gajae-app") + || executable.parent().and_then(Path::file_name) != Some(std::ffi::OsStr::new("MacOS")) + || executable + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + != Some(std::ffi::OsStr::new("Contents")) + || executable + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .and_then(Path::file_name) + != Some(std::ffi::OsStr::new(&format!( + "{}.app", + env!("GJC_UPDATE_PRODUCT_NAME") + ))) + { + return Err( + "Updater QA executable/data are outside the compiled isolated profile.".into(), + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn disabled_development_and_unbound_qa_are_inert_before_io() { + let absent = Path::new("/does-not-exist/updater-tests"); + let mut binding = Binding { + mode: Mode::Disabled, + feed_origin: String::new(), + public_key: String::new(), + qa_root: None, + }; + assert!(!binding.admits_profile(None, true)); + assert!(binding + .validate_runtime(None, absent, absent, true) + .is_err()); + binding.mode = Mode::Production; + assert!(!binding.admits_profile(None, false)); + assert!(!binding.admits_profile(Some(absent), true)); + binding.mode = Mode::Qa; + assert!(!binding.admits_profile(Some(absent), true)); + assert!(!absent.exists()); + } + + #[test] + fn matching_qa_profile_still_rejects_a_foreign_executable_or_data_root() { + let mut random = [0; 8]; + getrandom::getrandom(&mut random).unwrap(); + let root = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "gajae-binding-runtime-{:x}", + u64::from_ne_bytes(random) + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let data = root.join("home/.gajae-app"); + fs::create_dir_all(&data).unwrap(); + let binary = root.join(format!( + "{}.app/Contents/MacOS/app", + env!("GJC_UPDATE_PRODUCT_NAME") + )); + fs::create_dir_all(binary.parent().unwrap()).unwrap(); + fs::write(&binary, b"fixture").unwrap(); + let binding = Binding { + mode: Mode::Qa, + feed_origin: "https://127.0.0.1:44321".into(), + public_key: "test".into(), + qa_root: Some(root.clone()), + }; + assert!(binding + .validate_runtime(Some(&root), &binary, &data, true) + .is_ok()); + assert!(binding + .validate_runtime(Some(&root), Path::new("/bin/sh"), &data, true) + .is_err()); + assert!(binding + .validate_runtime(Some(&root), &binary, &root, true) + .is_err()); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(binding + .validate_runtime(Some(&root), &binary, &data, true) + .is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/updater_discovery.rs b/src-tauri/src/updater_discovery.rs new file mode 100644 index 00000000..c0bcb5b9 --- /dev/null +++ b/src-tauri/src/updater_discovery.rs @@ -0,0 +1,1877 @@ +//! Native preparation only: no persistence, signature verification or installer. +//! The caller owns build/QA-root admission, consent, generation and cancellation. +//! `CompleteObservedScan` means a bounded traversal was subsequently re-observed +//! unchanged, NOT an atomic GitHub snapshot or a promise of globally latest data. + +use std::{ + collections::HashSet, + future::Future, + pin::Pin, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use reqwest::Url; +use semver::Version; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::time::Instant; + +use crate::updater_manifest::{parse_manifest, Channel, Manifest, ProductIdentity}; +use crate::updater_transport::{fetch_response, Accept, BoundedResponse, HttpsClient}; + +pub const PAGE_SIZE: usize = 30; +pub const MAX_PAGES_PER_BURST: usize = 3; +pub const DISCOVERY_BURST: Duration = Duration::from_secs(30); +pub const MAX_ARCHIVE_BYTES: u64 = 250 * 1024 * 1024; +const MAX_MANIFEST_BYTES: u64 = 64 * 1024; +const MAX_PAGE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_REQUESTS: usize = 128; +const MAX_REDIRECTS: usize = 4; +// A memory ceiling, not a successful end-of-history condition. Unlike the burst +// cap this is exceptionally terminal; reaching it always reports an error. +const MAX_SCAN_PAGES: usize = 4096; +const MAX_RETRY_AFTER: Duration = Duration::from_secs(24 * 60 * 60); +const MAX_DOWNLOAD_TIME: Duration = Duration::from_secs(10 * 60); + +/// Error text carries no response body, URL, Location or transient query token. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiscoveryError { + InvalidPolicy, + UnauthorizedUrl, + InvalidRelease, + InvalidManifest, + IdentityChanged, + ConflictingVersion, + ScanChanged, + ScanLimit, + Network, + HttpStatus(u16), + SizeMismatch, + RedirectLimit, + InvalidRetryAfter, + RetryAfter(Duration), + Deadline, + RequestBudget, + PageBudget, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiscoveryPolicy { + repository: String, + artifact_prefix: String, + current_desktop: Version, + channel: Channel, + macos_version: [u16; 3], + qa_origin: Option, +} + +impl DiscoveryPolicy { + /// Inputs must come from native compiled identity and native OS inspection, + /// never remote UI/IPC. The caller must admit production mode before I/O. + pub fn production( + identity: &ProductIdentity<'_>, + current_desktop: Version, + channel: Channel, + macos_version: &str, + ) -> Result { + let parts: Vec<_> = identity.repository.split('/').collect(); + if parts.len() != 2 + || parts.iter().any(|part| !safe_name(part)) + || !safe_name(identity.artifact_prefix) + || !current_desktop.build.is_empty() + { + return Err(DiscoveryError::InvalidPolicy); + } + let macos_version = os_version(macos_version)?; + if macos_version < [13, 0, 0] { + return Err(DiscoveryError::InvalidPolicy); + } + Ok(Self { + repository: identity.repository.to_owned(), + artifact_prefix: identity.artifact_prefix.to_owned(), + current_desktop, + channel, + macos_version, + qa_origin: None, + }) + } + + /// A local HTTPS fixture origin supplied by the admitted native QA caller. + /// It must equal the compiled binding; a production/disabled build cannot + /// opt into QA. The caller still verifies its compiled QA-root/profile/key + /// binding before constructing its CA-trusting HttpsClient or calling us. + /// Canonical URLs in release records and manifests are NEVER rewritten. + pub fn qa( + identity: &ProductIdentity<'_>, + current_desktop: Version, + channel: Channel, + macos_version: &str, + local_https_origin: Url, + ) -> Result { + let mut policy = Self::production(identity, current_desktop, channel, macos_version)?; + validate_qa_binding( + &local_https_origin, + option_env!("GJC_UPDATE_MODE"), + option_env!("GJC_UPDATE_FEED_ORIGIN"), + )?; + policy.qa_origin = Some(local_https_origin); + Ok(policy) + } + + fn identity(&self) -> ProductIdentity<'_> { + ProductIdentity { + repository: &self.repository, + artifact_prefix: &self.artifact_prefix, + } + } + + fn api(&self, suffix: &str) -> Url { + Url::parse(&format!( + "https://api.github.com/repos/{}/releases{}", + self.repository, suffix + )) + .expect("validated repository and internally generated suffix") + } + + fn download(&self, tag: &str, name: &str) -> Url { + Url::parse(&format!( + "https://github.com/{}/releases/download/{tag}/{name}", + self.repository + )) + .expect("validated tag and artifact name") + } + + fn wire_url(&self, canonical: &Url) -> Url { + match &self.qa_origin { + None => canonical.clone(), + Some(origin) => { + let mut url = origin.clone(); + url.set_path(canonical.path()); + url.set_query(canonical.query()); + url + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReleaseIdentity { + pub id: u64, + pub tag_name: String, + pub api_url: Url, + pub html_url: Url, + pub prerelease: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetIdentity { + pub id: u64, + pub name: String, + pub size: u64, + pub api_url: Url, + pub download_url: Url, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SelectedRelease { + pub release: ReleaseIdentity, + pub manifest_asset: AssetIdentity, + pub archive_asset: AssetIdentity, + pub manifest_bytes: Vec, + pub manifest: Manifest, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IncompleteReason { + PageBudget, + TimeBudget, + RequestBudget, + RetryAfter, + ScanChanged, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiscoveryCompleteness { + Incomplete(IncompleteReason), + CompleteObservedScan, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiscoveryResult { + pub selected: Option, + pub completeness: DiscoveryCompleteness, + pub pages_observed: usize, + /// Scheduler delay, not a sleep and not permission to create a new attempt. + pub retry_after: Option, +} + +/// Ephemeral continuation. Do not serialize it or infer authority from a cache. +/// Reusing it with a different policy starts a fresh scan. A complete cursor +/// starts a new scan on the next call; the caller controls normal scheduling. +#[derive(Default)] +pub struct DiscoveryCursor { + policy: Option, + stamps: Vec<[u8; 32]>, + seen_ids: HashSet, + seen_tags: HashSet, + seen_assets: HashSet, + pending: Option, + verify_next: Option, + complete: bool, + best: Option, + not_before: Option, +} + +struct PendingPage { + stamp: [u8; 32], + releases: Vec, + next: usize, +} + +impl DiscoveryCursor { + fn reset_scan(&mut self) { + let not_before = self.not_before; + let policy = self.policy.take(); + *self = Self { + policy, + not_before, + ..Self::default() + }; + } + + fn result( + &self, + completeness: DiscoveryCompleteness, + retry_after: Option, + ) -> DiscoveryResult { + DiscoveryResult { + selected: self.best.clone(), + completeness, + pages_observed: self.stamps.len(), + retry_after, + } + } +} + +/// At most three listing-page requests (including revalidation), 30 entries +/// each, 128 total native requests and 30s wall time. Continuations retain +/// progress within a page so slow manifests cannot impose a 90-release cutoff. +pub async fn discover_burst( + client: &HttpsClient, + policy: &DiscoveryPolicy, + cursor: &mut DiscoveryCursor, +) -> Result { + discover_with(client, policy, cursor, DISCOVERY_BURST).await +} + +/// Fetch only the selected, freshly re-bound asset ID. Returns the exact bounded +/// bytes; the caller MUST verify Minisign and signed archive identity next. +/// No plugin download API is involved. Duration is capped at ten minutes, and +/// can be shortened by the caller's own remaining preparation budget. +pub async fn fetch_archive( + client: &HttpsClient, + policy: &DiscoveryPolicy, + selected: &SelectedRelease, + timeout: Duration, +) -> Result, DiscoveryError> { + fetch_archive_with(client, policy, selected, timeout).await +} + +type FetchFuture<'a> = + Pin> + Send + 'a>>; + +trait Transport { + fn fetch<'a>(&'a self, url: &'a Url, accept: Accept, limit: u64) -> FetchFuture<'a>; +} + +impl Transport for HttpsClient { + fn fetch<'a>(&'a self, url: &'a Url, accept: Accept, limit: u64) -> FetchFuture<'a> { + Box::pin(async move { + fetch_response(self, url, accept, limit) + .await + .map_err(|_| DiscoveryError::Network) + }) + } +} + +struct Budget { + deadline: Instant, + requests: usize, + pages: usize, +} + +impl Budget { + fn new(duration: Duration) -> Self { + Self { + deadline: Instant::now() + duration, + requests: 0, + pages: 0, + } + } + + async fn fetch( + &mut self, + transport: &impl Transport, + url: &Url, + accept: Accept, + limit: u64, + ) -> Result { + if Instant::now() >= self.deadline { + return Err(DiscoveryError::Deadline); + } + if self.requests == MAX_REQUESTS { + return Err(DiscoveryError::RequestBudget); + } + self.requests += 1; + let response = tokio::time::timeout_at(self.deadline, transport.fetch(url, accept, limit)) + .await + .map_err(|_| DiscoveryError::Deadline)??; + // Keep injection tests honest and defend against future transport edits. + if response.body.len() as u64 > limit { + return Err(DiscoveryError::SizeMismatch); + } + if let Some(header) = &response.retry_after { + return Err(DiscoveryError::RetryAfter(retry_after( + header, + SystemTime::now(), + )?)); + } + if matches!(response.status.as_u16(), 403 | 429 | 503) { + return Err(DiscoveryError::RetryAfter(Duration::from_secs(60))); + } + Ok(response) + } +} + +async fn discover_with( + transport: &impl Transport, + policy: &DiscoveryPolicy, + cursor: &mut DiscoveryCursor, + duration: Duration, +) -> Result { + if cursor.policy.as_ref() != Some(policy) || cursor.complete { + cursor.reset_scan(); + cursor.policy = Some(policy.clone()); + } + if let Some(delay) = cursor + .not_before + .and_then(|until| until.checked_duration_since(Instant::now())) + { + return Ok(cursor.result( + DiscoveryCompleteness::Incomplete(IncompleteReason::RetryAfter), + Some(delay), + )); + } + cursor.not_before = None; + let result = scan(transport, policy, cursor, &mut Budget::new(duration)).await; + let reason = match result { + Ok(()) => { + return Ok(cursor.result(DiscoveryCompleteness::CompleteObservedScan, None)); + } + Err(DiscoveryError::PageBudget) => IncompleteReason::PageBudget, + Err(DiscoveryError::Deadline) => IncompleteReason::TimeBudget, + Err(DiscoveryError::RequestBudget) => IncompleteReason::RequestBudget, + Err(DiscoveryError::RetryAfter(delay)) => { + cursor.not_before = Some(Instant::now() + delay); + return Ok(cursor.result( + DiscoveryCompleteness::Incomplete(IncompleteReason::RetryAfter), + Some(delay), + )); + } + Err(DiscoveryError::ScanChanged) => { + cursor.reset_scan(); + IncompleteReason::ScanChanged + } + Err(error) => { + // A malformed eligible record cannot leave an older candidate ready. + cursor.reset_scan(); + return Err(error); + } + }; + Ok(cursor.result(DiscoveryCompleteness::Incomplete(reason), None)) +} + +async fn scan( + transport: &impl Transport, + policy: &DiscoveryPolicy, + cursor: &mut DiscoveryCursor, + budget: &mut Budget, +) -> Result<(), DiscoveryError> { + // Across bursts recheck the head AND the last observed/pending boundary. + // An interior mutation is also caught by the full verification pass below. + let mut checks = Vec::new(); + if let Some(stamp) = cursor.stamps.first() { + checks.push((1, *stamp)); + } + if let Some(pending) = &cursor.pending { + checks.push((cursor.stamps.len() + 1, pending.stamp)); + } else if cursor.stamps.len() > 1 { + checks.push((cursor.stamps.len(), *cursor.stamps.last().unwrap())); + } + for (page, stamp) in checks { + check_page(transport, policy, budget, page, stamp).await?; + } + loop { + if let Some(index) = cursor.verify_next { + if index == cursor.stamps.len() { + cursor.complete = true; + return Ok(()); + } + check_page(transport, policy, budget, index + 1, cursor.stamps[index]).await?; + cursor.verify_next = Some(index + 1); + continue; + } + if cursor.pending.is_none() { + if cursor.stamps.len() == MAX_SCAN_PAGES { + return Err(DiscoveryError::ScanLimit); + } + let (releases, stamp) = + fetch_page(transport, policy, budget, cursor.stamps.len() + 1).await?; + for release in &releases { + if release.id == 0 + || !cursor.seen_ids.insert(release.id) + || !cursor.seen_tags.insert(release.tag_name.clone()) + { + return Err(DiscoveryError::ScanChanged); + } + for asset in &release.assets { + if asset.id == 0 || !cursor.seen_assets.insert(asset.id) { + return Err(DiscoveryError::ScanChanged); + } + } + } + cursor.pending = Some(PendingPage { + stamp, + releases, + next: 0, + }); + } + let pending = cursor.pending.as_mut().unwrap(); + while let Some(release) = pending.releases.get(pending.next) { + if let Some(candidate) = candidate(transport, policy, budget, release).await? { + consider(&mut cursor.best, candidate)?; + } + pending.next += 1; + } + let pending = cursor.pending.take().unwrap(); + cursor.stamps.push(pending.stamp); + if pending.releases.len() < PAGE_SIZE { + cursor.verify_next = Some(0); + } + } +} + +async fn check_page( + transport: &impl Transport, + policy: &DiscoveryPolicy, + budget: &mut Budget, + page: usize, + expected: [u8; 32], +) -> Result<(), DiscoveryError> { + let (_, stamp) = fetch_page(transport, policy, budget, page).await?; + if stamp != expected { + return Err(DiscoveryError::ScanChanged); + } + Ok(()) +} + +async fn fetch_page( + transport: &impl Transport, + policy: &DiscoveryPolicy, + budget: &mut Budget, + page: usize, +) -> Result<(Vec, [u8; 32]), DiscoveryError> { + if budget.pages == MAX_PAGES_PER_BURST { + return Err(DiscoveryError::PageBudget); + } + budget.pages += 1; + let url = policy.wire_url(&policy.api(&format!("?per_page={PAGE_SIZE}&page={page}"))); + // No listing redirects, Link URLs, /latest shortcut or caller-supplied page URL. + let response = budget + .fetch(transport, &url, Accept::GithubJson, MAX_PAGE_BYTES) + .await?; + require_ok(&response)?; + let releases: Vec = + serde_json::from_slice(&response.body).map_err(|_| DiscoveryError::InvalidRelease)?; + if releases.len() > PAGE_SIZE { + return Err(DiscoveryError::InvalidRelease); + } + if releases.iter().any(|release| { + release.tag_name.len() > 129 + || release.assets.len() > 128 + || release.assets.iter().any(|asset| asset.name.len() > 256) + }) { + return Err(DiscoveryError::InvalidRelease); + } + // GitHub's download_count (and unrelated descriptions) can change because + // we fetch a manifest. Compare all parsed release/asset identity fields, + // not volatile/unknown metadata or JSON whitespace/property order. + let identity_bytes = + serde_json::to_vec(&releases).map_err(|_| DiscoveryError::InvalidRelease)?; + Ok((releases, Sha256::digest(&identity_bytes).into())) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ReleaseRecord { + id: u64, + tag_name: String, + draft: bool, + prerelease: bool, + url: String, + html_url: String, + assets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct AssetRecord { + id: u64, + name: String, + size: u64, + url: String, + browser_download_url: String, + state: String, +} + +fn release_assets( + policy: &DiscoveryPolicy, + release: &ReleaseRecord, +) -> Result, DiscoveryError> { + if release.draft { + return Ok(None); + } + if release.assets.len() > 128 || release.tag_name.len() > 129 { + return Err(DiscoveryError::InvalidRelease); + } + let manifest_assets: Vec<_> = release + .assets + .iter() + .filter(|asset| asset.name == "desktop-update.json") + .collect(); + if manifest_assets.is_empty() { + // Pre-updater releases have no desktop manifest, including older tags + // that do not follow the present product version convention. + return Ok(None); + } + if manifest_assets.len() != 1 { + return Err(DiscoveryError::InvalidRelease); + } + let product = release + .tag_name + .strip_prefix('v') + .ok_or(DiscoveryError::InvalidRelease)?; + let version = Version::parse(product).map_err(|_| DiscoveryError::InvalidRelease)?; + if !version.build.is_empty() || version.to_string() != product { + return Err(DiscoveryError::InvalidRelease); + } + let channel = version_channel(&version)?; + if release.prerelease != (channel == Channel::Beta) { + return Err(DiscoveryError::InvalidRelease); + } + if policy.channel == Channel::Stable && channel == Channel::Beta { + return Ok(None); + } + let expected_api = policy.api(&format!("/{}", release.id)); + let expected_html = format!( + "https://github.com/{}/releases/tag/{}", + policy.repository, release.tag_name + ); + if release.id == 0 || release.url != expected_api.as_str() || release.html_url != expected_html + { + return Err(DiscoveryError::InvalidRelease); + } + let mut names = HashSet::new(); + let mut ids = HashSet::new(); + if release + .assets + .iter() + .any(|asset| asset.id == 0 || !names.insert(&asset.name) || !ids.insert(asset.id)) + { + return Err(DiscoveryError::InvalidRelease); + } + let archive_name = format!( + "{}desktop-{product}-macos-arm64.app.tar.gz", + policy.artifact_prefix + ); + let archive = release + .assets + .iter() + .find(|asset| asset.name == archive_name) + .ok_or(DiscoveryError::InvalidRelease)?; + Ok(Some(( + ReleaseIdentity { + id: release.id, + tag_name: release.tag_name.clone(), + api_url: expected_api, + html_url: Url::parse(&expected_html).map_err(|_| DiscoveryError::InvalidRelease)?, + prerelease: release.prerelease, + }, + asset_identity(policy, release, manifest_assets[0], MAX_MANIFEST_BYTES)?, + asset_identity(policy, release, archive, MAX_ARCHIVE_BYTES)?, + ))) +} + +fn asset_identity( + policy: &DiscoveryPolicy, + release: &ReleaseRecord, + asset: &AssetRecord, + max_bytes: u64, +) -> Result { + let api_url = policy.api(&format!("/assets/{}", asset.id)); + let download_url = policy.download(&release.tag_name, &asset.name); + if asset.id == 0 + || asset.size == 0 + || asset.size > max_bytes + || asset.state != "uploaded" + || asset.url != api_url.as_str() + || asset.browser_download_url != download_url.as_str() + { + return Err(DiscoveryError::InvalidRelease); + } + Ok(AssetIdentity { + id: asset.id, + name: asset.name.clone(), + size: asset.size, + api_url, + download_url, + }) +} + +async fn candidate( + transport: &impl Transport, + policy: &DiscoveryPolicy, + budget: &mut Budget, + release: &ReleaseRecord, +) -> Result, DiscoveryError> { + let Some((release, manifest_asset, archive_asset)) = release_assets(policy, release)? else { + return Ok(None); + }; + let bytes = fetch_asset(transport, policy, budget, &manifest_asset).await?; + let manifest = + parse_manifest(&bytes, &policy.identity()).map_err(|_| DiscoveryError::InvalidManifest)?; + let selection = SelectedRelease { + release, + manifest_asset, + archive_asset, + manifest_bytes: bytes, + manifest, + }; + if eligible(policy, &selection)? { + Ok(Some(selection)) + } else { + Ok(None) + } +} + +fn eligible(policy: &DiscoveryPolicy, selection: &SelectedRelease) -> Result { + let manifest = &selection.manifest; + if selection.release.tag_name != format!("v{}", manifest.product_version) + || selection.release.prerelease != (manifest.channel == Channel::Beta) + || manifest.archive_url != selection.archive_asset.download_url + || version_channel(&manifest.product_version)? != manifest.channel + { + return Err(DiscoveryError::InvalidManifest); + } + // Fail inconsistent metadata even when its version would not be adopted. + Ok( + !(policy.channel == Channel::Stable && manifest.channel == Channel::Beta) + && manifest + .version + .cmp_precedence(&policy.current_desktop) + .is_gt() + && manifest + .version + .cmp_precedence(&Version::new(0, 2, 3)) + .is_gt() + && os_version(&manifest.minimum_system_version)? <= policy.macos_version, + ) +} + +fn consider( + best: &mut Option, + candidate: SelectedRelease, +) -> Result<(), DiscoveryError> { + if let Some(previous) = best { + match candidate + .manifest + .version + .cmp_precedence(&previous.manifest.version) + { + std::cmp::Ordering::Less => return Ok(()), + std::cmp::Ordering::Equal if previous == &candidate => return Ok(()), + std::cmp::Ordering::Equal => return Err(DiscoveryError::ConflictingVersion), + std::cmp::Ordering::Greater => {} + } + } + *best = Some(candidate); + Ok(()) +} + +async fn fetch_archive_with( + transport: &impl Transport, + policy: &DiscoveryPolicy, + selected: &SelectedRelease, + timeout: Duration, +) -> Result, DiscoveryError> { + // SelectedRelease is a transparent staging contract, not a bearer token. + // Reparse it and reconstruct all URLs before issuing even one request. + let manifest = parse_manifest(&selected.manifest_bytes, &policy.identity()) + .map_err(|_| DiscoveryError::InvalidManifest)?; + if manifest != selected.manifest || !eligible(policy, selected)? { + return Err(DiscoveryError::IdentityChanged); + } + let expected_api = policy.api(&format!("/{}", selected.release.id)); + if selected.release.id == 0 || selected.release.api_url != expected_api { + return Err(DiscoveryError::IdentityChanged); + } + let mut budget = Budget::new(timeout.min(MAX_DOWNLOAD_TIME)); + let response = budget + .fetch( + transport, + &policy.wire_url(&expected_api), + Accept::GithubJson, + MAX_PAGE_BYTES, + ) + .await?; + require_ok(&response)?; + let release: ReleaseRecord = + serde_json::from_slice(&response.body).map_err(|_| DiscoveryError::InvalidRelease)?; + let Some((identity, manifest_asset, archive_asset)) = release_assets(policy, &release)? else { + return Err(DiscoveryError::IdentityChanged); + }; + if identity != selected.release + || manifest_asset != selected.manifest_asset + || archive_asset != selected.archive_asset + { + return Err(DiscoveryError::IdentityChanged); + } + let bytes = fetch_asset(transport, policy, &mut budget, &manifest_asset).await?; + if bytes != selected.manifest_bytes { + return Err(DiscoveryError::IdentityChanged); + } + fetch_asset(transport, policy, &mut budget, &archive_asset).await +} + +async fn fetch_asset( + transport: &impl Transport, + policy: &DiscoveryPolicy, + budget: &mut Budget, + asset: &AssetIdentity, +) -> Result, DiscoveryError> { + let mut url = policy.wire_url(&asset.api_url); + let mut visited = HashSet::new(); + for redirects in 0..=MAX_REDIRECTS { + // URLs with query tokens are ephemeral, not included in any result, + // state, error or log. Hashing here avoids retaining them for loop checks. + if !visited.insert(<[u8; 32]>::from(Sha256::digest(url.as_str().as_bytes()))) { + return Err(DiscoveryError::RedirectLimit); + } + let response = budget + .fetch(transport, &url, Accept::Archive, asset.size) + .await?; + if response.status.as_u16() == 200 { + if response.location.is_some() || response.body.len() as u64 != asset.size { + return Err(DiscoveryError::SizeMismatch); + } + return Ok(response.body); + } + if !matches!(response.status.as_u16(), 301 | 302 | 303 | 307 | 308) { + return Err(DiscoveryError::HttpStatus(response.status.as_u16())); + } + if redirects == MAX_REDIRECTS { + return Err(DiscoveryError::RedirectLimit); + } + let location = response.location.ok_or(DiscoveryError::UnauthorizedUrl)?; + url = authorize_redirect(policy, asset, &url, &location)?; + } + Err(DiscoveryError::RedirectLimit) +} + +fn authorize_redirect( + policy: &DiscoveryPolicy, + asset: &AssetIdentity, + current: &Url, + location: &str, +) -> Result { + if location.is_empty() + || location.len() > 4096 + || location + .bytes() + .any(|b| b <= 0x20 || b == 0x7f || b == b'\\') + { + return Err(DiscoveryError::UnauthorizedUrl); + } + // Absolute, canonical serialization only. Relative and encoded path tricks + // are unnecessary for GitHub release delivery and are rejected fail-closed. + let next = Url::parse(location).map_err(|_| DiscoveryError::UnauthorizedUrl)?; + if !clean_https(&next) || next.as_str() != location { + return Err(DiscoveryError::UnauthorizedUrl); + } + let start = policy.wire_url(&asset.api_url); + let download = policy.wire_url(&asset.download_url); + if let Some(origin) = &policy.qa_origin { + // QA stays on the exact compile-bound loopback origin and asset path. + // It cannot redirect to production, unrelated fixture paths or tokens. + if (current != &start && current != &download) + || next.origin() != origin.origin() + || next.query().is_some() + || (next != start && next != download) + { + return Err(DiscoveryError::UnauthorizedUrl); + } + } else { + if current != &start && current != &download && !delivery_url(current) { + return Err(DiscoveryError::UnauthorizedUrl); + } + if next == download { + if current != &start { + return Err(DiscoveryError::UnauthorizedUrl); + } + } else if !delivery_url(&next) { + return Err(DiscoveryError::UnauthorizedUrl); + } + } + Ok(next) +} + +fn delivery_url(url: &Url) -> bool { + if !clean_https(url) + || url.port_or_known_default() != Some(443) + || !matches!( + url.host_str(), + Some("release-assets.githubusercontent.com" | "objects.githubusercontent.com") + ) + { + return false; + } + let segments: Vec<_> = url.path().split('/').collect(); + segments.len() == 4 + && segments[0].is_empty() + && matches!( + segments[1], + "github-production-release-asset" | "github-production-release-asset-2e65be" + ) + && !segments[2].is_empty() + && segments[2].bytes().all(|b| b.is_ascii_digit()) + && !segments[3].is_empty() + && segments[3] + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + +fn clean_https(url: &Url) -> bool { + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + && url.host_str().is_some() +} + +fn validate_qa_binding( + origin: &Url, + compiled_mode: Option<&str>, + compiled_origin: Option<&str>, +) -> Result<(), DiscoveryError> { + let normalized = compiled_origin.and_then(|text| Url::parse(text).ok()); + if compiled_mode != Some("qa") + || normalized.as_ref() != Some(origin) + || !clean_https(origin) + || origin.host_str() != Some("127.0.0.1") + || origin.port().is_none() + || origin.path() != "/" + || origin.query().is_some() + { + return Err(DiscoveryError::InvalidPolicy); + } + Ok(()) +} + +fn safe_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value != "." + && value != ".." + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) +} + +fn version_channel(version: &Version) -> Result { + match version.pre.as_str().split('.').next() { + Some("") => Ok(Channel::Stable), + Some("beta") => Ok(Channel::Beta), + _ => Err(DiscoveryError::InvalidManifest), + } +} + +fn os_version(value: &str) -> Result<[u16; 3], DiscoveryError> { + let parts: Vec<_> = value.split('.').collect(); + if !(2..=3).contains(&parts.len()) { + return Err(DiscoveryError::InvalidPolicy); + } + let mut version = [0; 3]; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() + || part.len() > 3 + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|b| b.is_ascii_digit()) + { + return Err(DiscoveryError::InvalidPolicy); + } + version[i] = part.parse().map_err(|_| DiscoveryError::InvalidPolicy)?; + } + Ok(version) +} + +fn require_ok(response: &BoundedResponse) -> Result<(), DiscoveryError> { + if response.status.as_u16() != 200 || response.location.is_some() { + return Err(DiscoveryError::HttpStatus(response.status.as_u16())); + } + Ok(()) +} + +/// Accept bounded delta-seconds or IMF-fixdate. An out-of-policy long delay is +/// rejected, NOT shortened (which would violate the server's requested delay). +/// Invalid/oversized header text is never reflected into the error. +fn retry_after(value: &str, now: SystemTime) -> Result { + if value.is_empty() || value.len() > 128 { + return Err(DiscoveryError::InvalidRetryAfter); + } + let seconds = if value.bytes().all(|b| b.is_ascii_digit()) { + value + .parse::() + .map_err(|_| DiscoveryError::InvalidRetryAfter)? + } else { + let timestamp = http_date(value)?; + timestamp + .duration_since(now) + .unwrap_or_default() + .as_secs() + .saturating_add(1) + }; + if seconds > MAX_RETRY_AFTER.as_secs() { + return Err(DiscoveryError::InvalidRetryAfter); + } + Ok(Duration::from_secs(seconds.max(1))) +} + +fn http_date(value: &str) -> Result { + let b = value.as_bytes(); + if b.len() != 29 + || !value.is_ascii() + || &b[3..5] != b", " + || b[7] != b' ' + || b[11] != b' ' + || b[16] != b' ' + || b[19] != b':' + || b[22] != b':' + || &b[25..] != b" GMT" + { + return Err(DiscoveryError::InvalidRetryAfter); + } + let number = |text: &str| -> Result { + if !text.bytes().all(|b| b.is_ascii_digit()) { + return Err(DiscoveryError::InvalidRetryAfter); + } + text.parse().map_err(|_| DiscoveryError::InvalidRetryAfter) + }; + let day = number(&value[5..7])?; + let year = number(&value[12..16])?; + let hour = number(&value[17..19])?; + let minute = number(&value[20..22])?; + let second = number(&value[23..25])?; + let month = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + .iter() + .position(|name| *name == &value[8..11]) + .ok_or(DiscoveryError::InvalidRetryAfter)?; + let leap = |y: u64| y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let lengths = [ + 31, + if leap(year) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if !(1970..=9999).contains(&year) + || day == 0 + || day > lengths[month] + || hour > 23 + || minute > 59 + || second > 59 + { + return Err(DiscoveryError::InvalidRetryAfter); + } + let days = (1970..year) + .map(|y| if leap(y) { 366 } else { 365 }) + .sum::() + + lengths[..month].iter().sum::() + + day + - 1; + if ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"][(days % 7) as usize] != &value[..3] { + return Err(DiscoveryError::InvalidRetryAfter); + } + Ok(UNIX_EPOCH + Duration::from_secs(days * 86400 + hour * 3600 + minute * 60 + second)) +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::StatusCode; + use serde_json::{json, Value}; + use std::{ + collections::{HashMap, VecDeque}, + sync::Mutex, + }; + + #[derive(Clone)] + struct Reply { + status: u16, + body: Vec, + location: Option, + retry_after: Option, + } + + impl Reply { + fn ok(body: impl Into>) -> Self { + Self { + status: 200, + body: body.into(), + location: None, + retry_after: None, + } + } + + fn redirect(location: &str) -> Self { + Self { + status: 302, + body: vec![], + location: Some(location.into()), + retry_after: None, + } + } + } + + #[derive(Default)] + struct FakeState { + routes: HashMap>, + calls: Vec, + } + + #[derive(Default)] + struct Fake(Mutex); + + impl Fake { + fn set(&self, url: &Url, reply: Reply) { + self.0 + .lock() + .unwrap() + .routes + .insert(url.to_string(), VecDeque::from([reply])); + } + + fn calls(&self) -> Vec { + self.0.lock().unwrap().calls.clone() + } + + fn page(&self, policy: &DiscoveryPolicy, page: usize, values: Value) { + let url = policy.wire_url(&policy.api(&format!("?per_page=30&page={page}"))); + self.set(&url, Reply::ok(serde_json::to_vec(&values).unwrap())); + } + + fn release(&self, policy: &DiscoveryPolicy, value: &Value, bytes: &[u8]) { + let record: ReleaseRecord = serde_json::from_value(value.clone()).unwrap(); + self.set( + &policy.wire_url(&policy.api(&format!("/{}", record.id))), + Reply::ok(serde_json::to_vec(value).unwrap()), + ); + let (_, manifest, _) = release_assets(policy, &record).unwrap().unwrap(); + self.set( + &policy.wire_url(&manifest.api_url), + Reply::ok(bytes.to_vec()), + ); + } + } + + impl Transport for Fake { + fn fetch<'a>(&'a self, url: &'a Url, _: Accept, _: u64) -> FetchFuture<'a> { + Box::pin(async move { + let mut state = self.0.lock().unwrap(); + state.calls.push(url.to_string()); + let replies = state + .routes + .get_mut(url.as_str()) + .expect("missing injected route; no network fallback"); + let reply = if replies.len() > 1 { + replies.pop_front().unwrap() + } else { + replies.front().unwrap().clone() + }; + Ok(BoundedResponse { + status: StatusCode::from_u16(reply.status).unwrap(), + body: reply.body, + location: reply.location, + retry_after: reply.retry_after, + }) + }) + } + } + + fn policy(channel: Channel) -> DiscoveryPolicy { + DiscoveryPolicy::production( + &ProductIdentity { + repository: "devswha/gajae-code-app", + artifact_prefix: "gajae-app-", + }, + Version::new(0, 2, 3), + channel, + "13.6.1", + ) + .unwrap() + } + + fn release( + policy: &DiscoveryPolicy, + id: u64, + product: &str, + desktop: &str, + os: &str, + ) -> (Value, Vec) { + let mut manifest: Value = serde_json::from_str(include_str!( + "../../shared/fixtures/desktop-update-manifest.json" + )) + .unwrap(); + let tag = format!("v{product}"); + let name = format!( + "{}desktop-{product}-macos-arm64.app.tar.gz", + policy.artifact_prefix + ); + let beta = !Version::parse(product).unwrap().pre.is_empty(); + manifest["version"] = json!(desktop); + manifest["productVersion"] = json!(product); + manifest["channel"] = json!(if beta { "beta" } else { "stable" }); + manifest["minimumSystemVersion"] = json!(os); + manifest["platforms"]["darwin-aarch64"]["url"] = + json!(policy.download(&tag, &name).as_str()); + let bytes = serde_json::to_vec(&manifest).unwrap(); + let asset = |asset_id: u64, name: &str, size: u64| { + json!({ + "id": asset_id, "name": name, "size": size, "state": "uploaded", + "url": policy.api(&format!("/assets/{asset_id}")).as_str(), + "browser_download_url": policy.download(&tag, name).as_str(), + }) + }; + ( + json!({ + "id": id, "tag_name": tag, "draft": false, "prerelease": beta, + "url": policy.api(&format!("/{id}")).as_str(), + "html_url": format!("https://github.com/{}/releases/tag/{tag}", policy.repository), + "assets": [asset(id * 10 + 1, "desktop-update.json", bytes.len() as u64), asset(id * 10 + 2, &name, 4)], + }), + bytes, + ) + } + + fn old_page(policy: &DiscoveryPolicy, start: u64, count: usize) -> Value { + Value::Array((start..start + count as u64).map(|id| json!({ + "id": id, "tag_name": format!("legacy-{id}"), "draft": false, "prerelease": false, + "url": policy.api(&format!("/{id}")).as_str(), + "html_url": format!("https://github.com/{}/releases/tag/legacy-{id}", policy.repository), + "assets": [], + })).collect()) + } + + fn burst( + fake: &Fake, + policy: &DiscoveryPolicy, + cursor: &mut DiscoveryCursor, + ) -> DiscoveryResult { + tauri::async_runtime::block_on(discover_with(fake, policy, cursor, DISCOVERY_BURST)) + .unwrap() + } + + fn complete( + fake: &Fake, + policy: &DiscoveryPolicy, + cursor: &mut DiscoveryCursor, + ) -> DiscoveryResult { + for _ in 0..30 { + let before = fake.calls().len(); + let result = burst(fake, policy, cursor); + let calls = fake.calls(); + assert!( + calls[before..] + .iter() + .filter(|url| url.contains("?per_page=")) + .count() + <= 3 + ); + if result.completeness == DiscoveryCompleteness::CompleteObservedScan { + return result; + } + } + panic!("injected finite scan did not complete") + } + + #[test] + fn maximum_desktop_not_product_or_release_order_and_exact_identity() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (newer_product, first_bytes) = release(&policy, 1, "2.0.0-beta.11", "0.2.4", "13.0"); + let (backfill, maximum_bytes) = release(&policy, 2, "2.0.0-beta.10", "0.3.0", "13.0"); + fake.release(&policy, &newer_product, &first_bytes); + fake.release(&policy, &backfill, &maximum_bytes); + fake.page(&policy, 1, json!([newer_product, backfill])); + let result = complete(&fake, &policy, &mut DiscoveryCursor::default()); + let selected = result.selected.unwrap(); + assert_eq!(selected.release.id, 2); + assert_eq!(selected.manifest_asset.id, 21); + assert_eq!(selected.archive_asset.id, 22); + assert_eq!(selected.archive_asset.size, 4); + assert_eq!(selected.release.tag_name, "v2.0.0-beta.10"); + assert_eq!(selected.manifest_bytes, maximum_bytes); + assert_eq!(selected.manifest.version, Version::new(0, 3, 0)); + assert_eq!( + selected.manifest_asset.download_url, + policy.download("v2.0.0-beta.10", "desktop-update.json") + ); + } + + #[test] + fn channel_floor_current_and_os_policies() { + for (channel, product, desktop, os, accepted) in [ + (Channel::Beta, "2.0.0", "0.2.4", "13.0", true), + (Channel::Beta, "2.0.0-beta.10", "0.2.4", "13.0", true), + (Channel::Stable, "2.0.0-beta.10", "0.2.4", "13.0", false), + (Channel::Stable, "2.0.0", "0.2.4", "13.6.1", true), + (Channel::Beta, "2.0.0", "0.2.3", "13.0", false), + (Channel::Beta, "2.0.0", "0.2.2", "13.0", false), + (Channel::Beta, "2.0.0", "0.2.4", "13.6.2", false), + (Channel::Beta, "2.0.0", "0.2.4", "14.0", false), + ] { + let policy = policy(channel); + let fake = Fake::default(); + let (record, bytes) = release(&policy, 1, product, desktop, os); + if !(channel == Channel::Stable && product.contains("beta")) { + fake.release(&policy, &record, &bytes); + } + fake.page(&policy, 1, json!([record])); + let result = complete(&fake, &policy, &mut DiscoveryCursor::default()); + assert_eq!( + result.selected.is_some(), + accepted, + "{channel:?} {product} {desktop} {os}" + ); + } + let mut policy = policy(Channel::Beta); + let fake = Fake::default(); + policy.current_desktop = Version::new(0, 1, 0); + let (record, bytes) = release(&policy, 1, "2.0.0", "0.2.3", "13.0"); + fake.release(&policy, &record, &bytes); + fake.page(&policy, 1, json!([record])); + assert!(complete(&fake, &policy, &mut DiscoveryCursor::default()) + .selected + .is_none()); + policy.current_desktop = Version::new(0, 9, 0); + assert!(complete(&fake, &policy, &mut DiscoveryCursor::default()) + .selected + .is_none()); + } + + #[test] + fn resumes_beyond_ninety_and_never_calls_partial_scan_latest() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + for page in 1..=3 { + fake.page(&policy, page, old_page(&policy, page as u64 * 30, 30)); + } + let (record, bytes) = release(&policy, 999, "2.0.0-beta.10", "0.3.0", "13.0"); + fake.release(&policy, &record, &bytes); + fake.page(&policy, 4, json!([record])); + let mut cursor = DiscoveryCursor::default(); + let first = burst(&fake, &policy, &mut cursor); + assert_eq!(first.pages_observed, 3); + assert_eq!( + first.completeness, + DiscoveryCompleteness::Incomplete(IncompleteReason::PageBudget) + ); + assert!(first.selected.is_none()); + let second = burst(&fake, &policy, &mut cursor); + assert_eq!(second.selected.unwrap().release.id, 999); + assert!(matches!( + second.completeness, + DiscoveryCompleteness::Incomplete(_) + )); + let result = complete(&fake, &policy, &mut cursor); + assert_eq!(result.pages_observed, 4); + assert_eq!(result.selected.unwrap().release.id, 999); + assert!(fake.calls().iter().all(|url| !url.contains("/latest"))); + } + + #[test] + fn changed_head_boundary_or_interior_invalidates_scan_and_candidate() { + for changed_page in [1, 2, 3] { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + for page in 1..=3 { + fake.page(&policy, page, old_page(&policy, page as u64 * 30, 30)); + } + fake.page(&policy, 4, json!([])); + let mut cursor = DiscoveryCursor::default(); + burst(&fake, &policy, &mut cursor); + fake.page(&policy, changed_page, old_page(&policy, 900, 30)); + let mut changed = false; + for _ in 0..10 { + let result = burst(&fake, &policy, &mut cursor); + if result.completeness + == DiscoveryCompleteness::Incomplete(IncompleteReason::ScanChanged) + { + assert_eq!(result.pages_observed, 0); + assert!(result.selected.is_none()); + changed = true; + break; + } + assert_ne!( + result.completeness, + DiscoveryCompleteness::CompleteObservedScan + ); + } + assert!(changed); + } + } + + #[test] + fn identity_stamps_ignore_download_counters_but_detect_asset_changes() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (record, bytes) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + fake.release(&policy, &record, &bytes); + let mut first = old_page(&policy, 2, 29); + first.as_array_mut().unwrap().push(record); + fake.page(&policy, 1, first.clone()); + fake.page(&policy, 2, old_page(&policy, 31, 30)); + fake.page(&policy, 3, old_page(&policy, 61, 30)); + fake.page(&policy, 4, json!([])); + let mut cursor = DiscoveryCursor::default(); + burst(&fake, &policy, &mut cursor); + first[29]["assets"][0]["download_count"] = json!(99999); + first[29]["body"] = json!("An unrelated release description changed."); + fake.page(&policy, 1, first.clone()); + let result = complete(&fake, &policy, &mut cursor); + assert_eq!(result.selected.unwrap().release.id, 1); + + cursor = DiscoveryCursor::default(); + burst(&fake, &policy, &mut cursor); + first[29]["assets"][0]["size"] = json!(bytes.len() + 1); + fake.page(&policy, 1, first); + let result = burst(&fake, &policy, &mut cursor); + assert_eq!( + result.completeness, + DiscoveryCompleteness::Incomplete(IncompleteReason::ScanChanged) + ); + assert!(result.selected.is_none()); + } + + #[test] + fn duplicate_boundary_release_and_policy_change_cannot_certify_completion() { + let mut policy = policy(Channel::Beta); + let fake = Fake::default(); + fake.page(&policy, 1, old_page(&policy, 1, 30)); + fake.page(&policy, 2, old_page(&policy, 30, 1)); + let mut cursor = DiscoveryCursor::default(); + let result = burst(&fake, &policy, &mut cursor); + assert_eq!( + result.completeness, + DiscoveryCompleteness::Incomplete(IncompleteReason::ScanChanged) + ); + assert!(cursor.seen_ids.is_empty()); + fake.page(&policy, 1, old_page(&policy, 1, 0)); + policy.channel = Channel::Stable; + let result = burst(&fake, &policy, &mut cursor); + assert_eq!( + result.completeness, + DiscoveryCompleteness::CompleteObservedScan + ); + assert_eq!(cursor.policy, Some(policy)); + } + + #[test] + fn malformed_eligible_metadata_is_not_silently_skipped() { + let policy = policy(Channel::Beta); + for mutate in 0..9 { + let fake = Fake::default(); + let (mut record, bytes) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + match mutate { + 0 => record["url"] = json!("https://api.github.com/repos/foreign/repo/releases/1"), + 1 => { + record["assets"][0]["browser_download_url"] = + json!("https://example.com/desktop-update.json") + } + 2 => record["assets"][1]["size"] = json!(MAX_ARCHIVE_BYTES + 1), + 3 => record["assets"][1]["name"] = json!("wrong-archive.app.tar.gz"), + 4 => record["prerelease"] = json!(false), + 5 => record["assets"][0]["size"] = json!(0), + 6 => { + record["assets"].as_array_mut().unwrap().pop(); + } + 7 => record["assets"][1]["state"] = json!("new"), + _ => { + fake.set( + &policy.api("/assets/11"), + Reply::ok(vec![b'x'; bytes.len()]), + ); + } + } + fake.page(&policy, 1, json!([record])); + let mut cursor = DiscoveryCursor::default(); + assert!(tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut cursor, + DISCOVERY_BURST + )) + .is_err()); + assert!(cursor.best.is_none()); + } + } + + #[test] + fn manifest_tag_and_archive_crosschecks_fail_even_when_old_or_os_ineligible() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (record, _) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + let (_, mismatched_bytes) = release(&policy, 2, "2.0.0-beta.11", "0.2.2", "99.0"); + let mut record = record; + record["assets"][0]["size"] = json!(mismatched_bytes.len()); + fake.release(&policy, &record, &mismatched_bytes); + fake.page(&policy, 1, json!([record])); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::InvalidManifest) + ); + } + + #[test] + fn equal_desktop_versions_with_distinct_identities_fail_closed() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (one, bytes_one) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + let (two, bytes_two) = release(&policy, 2, "2.0.0-beta.11", "0.2.4", "13.0"); + fake.release(&policy, &one, &bytes_one); + fake.release(&policy, &two, &bytes_two); + fake.page(&policy, 1, json!([one, two])); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::ConflictingVersion) + ); + } + + #[test] + fn retry_after_is_bounded_honored_without_sleep_and_preserves_pending_position() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (record, bytes) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + fake.page(&policy, 1, json!([record])); + let mut reply = Reply::ok(vec![]); + reply.status = 429; + reply.retry_after = Some("120".into()); + fake.set(&policy.api("/assets/11"), reply); + let mut cursor = DiscoveryCursor::default(); + let result = burst(&fake, &policy, &mut cursor); + assert_eq!(result.retry_after, Some(Duration::from_secs(120))); + assert_eq!(cursor.pending.as_ref().unwrap().next, 0); + let calls = fake.calls().len(); + burst(&fake, &policy, &mut cursor); + assert_eq!(fake.calls().len(), calls); + cursor.not_before = None; // advance scheduler in the fixture, never sleep + fake.set(&policy.api("/assets/11"), Reply::ok(bytes)); + let result = complete(&fake, &policy, &mut cursor); + assert!(result.selected.is_some()); + let now = http_date("Mon, 07 Sep 2026 00:00:00 GMT").unwrap(); + assert_eq!( + retry_after("Mon, 07 Sep 2026 00:02:00 GMT", now), + Ok(Duration::from_secs(121)) + ); + assert_eq!(retry_after("0", now), Ok(Duration::from_secs(1))); + for invalid in [ + "86401", + "-1", + "1.5", + "18446744073709551616", + "not-a-date", + "Tue, 07 Sep 2026 00:02:00 GMT", + "Mon, 31 Feb 2026 00:00:00 GMT", + ] { + assert_eq!( + retry_after(invalid, now), + Err(DiscoveryError::InvalidRetryAfter) + ); + } + } + + #[test] + fn interrupted_manifest_page_resumes_without_restarting_completed_assets() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (one, bytes_one) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + let (two, bytes_two) = release(&policy, 2, "2.0.0-beta.11", "0.2.5", "13.0"); + fake.release(&policy, &one, &bytes_one); + fake.release(&policy, &two, &bytes_two); + fake.page(&policy, 1, json!([one, two])); + fake.set( + &policy.api("/assets/21"), + Reply { + status: 503, + body: vec![], + location: None, + retry_after: Some("10".into()), + }, + ); + let mut cursor = DiscoveryCursor::default(); + let result = burst(&fake, &policy, &mut cursor); + assert_eq!(cursor.pending.as_ref().unwrap().next, 1); + assert_eq!(result.selected.unwrap().release.id, 1); + assert_eq!(result.retry_after, Some(Duration::from_secs(10))); + cursor.not_before = None; + fake.set(&policy.api("/assets/21"), Reply::ok(bytes_two)); + let result = complete(&fake, &policy, &mut cursor); + assert_eq!(result.selected.unwrap().release.id, 2); + assert_eq!( + fake.calls() + .iter() + .filter(|url| url.ends_with("/assets/11")) + .count(), + 1 + ); + } + + #[test] + fn pending_transport_and_total_request_count_are_bounded() { + struct Pending; + impl Transport for Pending { + fn fetch<'a>(&'a self, _: &'a Url, _: Accept, _: u64) -> FetchFuture<'a> { + Box::pin(std::future::pending()) + } + } + let policy = policy(Channel::Beta); + let start = std::time::Instant::now(); + let result = tauri::async_runtime::block_on(discover_with( + &Pending, + &policy, + &mut DiscoveryCursor::default(), + Duration::from_millis(5), + )) + .unwrap(); + assert_eq!( + result.completeness, + DiscoveryCompleteness::Incomplete(IncompleteReason::TimeBudget) + ); + assert!(start.elapsed() < Duration::from_secs(2)); + let fake = Fake::default(); + let url = policy.api("/assets/11"); + fake.set(&url, Reply::ok(b"data".to_vec())); + tauri::async_runtime::block_on(async { + let mut budget = Budget::new(DISCOVERY_BURST); + budget.requests = MAX_REQUESTS - 1; + budget.fetch(&fake, &url, Accept::Archive, 4).await.unwrap(); + assert!(matches!( + budget.fetch(&fake, &url, Accept::Archive, 4).await, + Err(DiscoveryError::RequestBudget) + )); + }); + assert_eq!(fake.calls().len(), 1); + } + + #[test] + fn listing_redirects_duplicate_json_keys_and_identity_limits_fail_closed() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let url = policy.api("?per_page=30&page=1"); + fake.set( + &url, + Reply::redirect("https://api.github.com/repos/foreign/repo/releases"), + ); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::HttpStatus(302)) + ); + let json = format!( + "[{{\"id\":1,{}]", + &serde_json::to_string(&old_page(&policy, 1, 1)[0]).unwrap()[1..] + ); + fake.set(&url, Reply::ok(json.into_bytes())); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::InvalidRelease) + ); + let mut oversized = old_page(&policy, 1, 1); + oversized[0]["tag_name"] = json!("v".repeat(130)); + fake.page(&policy, 1, oversized); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::InvalidRelease) + ); + } + + #[test] + fn zero_deadline_and_page_body_caps_are_explicit() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let result = tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + Duration::ZERO, + )) + .unwrap(); + assert_eq!( + result.completeness, + DiscoveryCompleteness::Incomplete(IncompleteReason::TimeBudget) + ); + assert!(fake.calls().is_empty()); + fake.page(&policy, 1, old_page(&policy, 1, 31)); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::InvalidRelease) + ); + fake.set( + &policy.api("?per_page=30&page=1"), + Reply::ok(vec![b' '; MAX_PAGE_BYTES as usize + 1]), + ); + assert_eq!( + tauri::async_runtime::block_on(discover_with( + &fake, + &policy, + &mut DiscoveryCursor::default(), + DISCOVERY_BURST + )), + Err(DiscoveryError::SizeMismatch) + ); + } + + #[test] + fn canonical_redirects_allow_only_bound_asset_then_release_delivery() { + let policy = policy(Channel::Beta); + let (record, _) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + let record: ReleaseRecord = serde_json::from_value(record).unwrap(); + let (_, asset, _) = release_assets(&policy, &record).unwrap().unwrap(); + assert_eq!( + authorize_redirect(&policy, &asset, &asset.api_url, asset.download_url.as_str()), + Ok(asset.download_url.clone()) + ); + for host in [ + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", + ] { + let location = format!("https://{host}/github-production-release-asset-2e65be/123/abc-456?token=TOKEN_SENTINEL"); + let allowed = + authorize_redirect(&policy, &asset, &asset.download_url, &location).unwrap(); + assert_eq!(allowed.query(), Some("token=TOKEN_SENTINEL")); + } + for bad in [ + "http://release-assets.githubusercontent.com/github-production-release-asset/123/abc", + "https://secret@release-assets.githubusercontent.com/github-production-release-asset/123/abc", + "https://release-assets.githubusercontent.com.evil.test/github-production-release-asset/123/abc", + "https://github.com/foreign/repo/releases/download/v2.0.0-beta.10/desktop-update.json", + "https://api.github.com/repos/devswha/gajae-code-app/releases/assets/99", + "https://release-assets.githubusercontent.com:8443/github-production-release-asset/123/abc", + "https://release-assets.githubusercontent.com/arbitrary?token=TOKEN_SENTINEL", + "https://raw.githubusercontent.com/github-production-release-asset/123/abc", + "https://objects.githubusercontent.com/github-production-release-asset/123/abc#fragment", + "https://objects.githubusercontent.com/github-production-release-asset/123/%61bc", + "/relative", " https://objects.githubusercontent.com/github-production-release-asset/123/abc", + ] { + let error = authorize_redirect(&policy, &asset, &asset.api_url, bad).unwrap_err(); + assert_eq!(error, DiscoveryError::UnauthorizedUrl); + assert!(!format!("{error:?}").contains("TOKEN_SENTINEL")); + } + let token_on_canonical = format!("{}?token=TOKEN_SENTINEL", asset.download_url); + assert!(authorize_redirect(&policy, &asset, &asset.api_url, &token_on_canonical).is_err()); + let foreign = Url::parse("https://evil.test/").unwrap(); + assert!( + authorize_redirect(&policy, &asset, &foreign, asset.download_url.as_str()).is_err() + ); + } + + #[test] + fn qa_requires_compile_binding_and_cannot_escape_local_policy() { + let origin = Url::parse("https://127.0.0.1:9443/").unwrap(); + assert!(validate_qa_binding(&origin, Some("qa"), Some("https://127.0.0.1:9443")).is_ok()); + for mode in [None, Some("disabled"), Some("production")] { + assert!(validate_qa_binding(&origin, mode, Some(origin.as_str())).is_err()); + } + assert!(validate_qa_binding(&origin, Some("qa"), Some("https://127.0.0.1:9444/")).is_err()); + for bad in [ + "http://127.0.0.1:9443/", + "https://localhost:9443/", + "https://127.0.0.1:9443/path", + "https://user@127.0.0.1:9443/", + "https://api.github.com/", + ] { + assert!(validate_qa_binding(&Url::parse(bad).unwrap(), Some("qa"), Some(bad)).is_err()); + } + let mut policy = policy(Channel::Beta); + policy.qa_origin = Some(origin); + let (record, bytes) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + let fake = Fake::default(); + fake.release(&policy, &record, &bytes); + fake.page(&policy, 1, json!([record])); + let selected = complete(&fake, &policy, &mut DiscoveryCursor::default()) + .selected + .unwrap(); + assert_eq!(selected.manifest.archive_url.host_str(), Some("github.com")); + assert!(fake + .calls() + .iter() + .all(|url| url.starts_with("https://127.0.0.1:9443/"))); + let asset = &selected.manifest_asset; + let start = policy.wire_url(&asset.api_url); + let download = policy.wire_url(&asset.download_url); + assert!(authorize_redirect(&policy, asset, &start, download.as_str()).is_ok()); + for bad in [ + asset.download_url.to_string(), + "https://127.0.0.1:9443/unrelated".into(), + format!("{download}?token=secret"), + ] { + assert!(authorize_redirect(&policy, asset, &start, &bad).is_err()); + } + } + + #[test] + fn archive_rebinds_release_and_manifest_then_fetches_exact_asset_bytes() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (record, bytes) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + fake.release(&policy, &record, &bytes); + fake.page(&policy, 1, json!([record])); + let selected = complete(&fake, &policy, &mut DiscoveryCursor::default()) + .selected + .unwrap(); + let delivery = "https://release-assets.githubusercontent.com/github-production-release-asset/123/abc?token=TOKEN_SENTINEL"; + fake.set(&selected.archive_asset.api_url, Reply::redirect(delivery)); + fake.set(&Url::parse(delivery).unwrap(), Reply::ok(b"data".to_vec())); + let fetched = tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + DISCOVERY_BURST, + )) + .unwrap(); + assert_eq!(fetched, b"data"); + fake.set(&Url::parse(delivery).unwrap(), Reply::ok(b"data!".to_vec())); + assert_eq!( + tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + DISCOVERY_BURST + )), + Err(DiscoveryError::SizeMismatch) + ); + let mut changed = record.clone(); + changed["assets"][1]["id"] = json!(999); + changed["assets"][1]["url"] = json!(policy.api("/assets/999").as_str()); + fake.set( + &selected.release.api_url, + Reply::ok(serde_json::to_vec(&changed).unwrap()), + ); + assert_eq!( + tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + DISCOVERY_BURST + )), + Err(DiscoveryError::IdentityChanged) + ); + fake.release(&policy, &record, &bytes); + let mut changed_bytes = bytes; + let index = changed_bytes.iter().position(|byte| *byte == b'A').unwrap(); + changed_bytes[index] = b'B'; + fake.set(&selected.manifest_asset.api_url, Reply::ok(changed_bytes)); + assert_eq!( + tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + DISCOVERY_BURST + )), + Err(DiscoveryError::IdentityChanged) + ); + } + + #[test] + fn archive_never_accepts_forged_release_url_or_unbounded_redirects() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (record, bytes) = release(&policy, 1, "2.0.0-beta.10", "0.2.4", "13.0"); + fake.release(&policy, &record, &bytes); + fake.page(&policy, 1, json!([record])); + let mut selected = complete(&fake, &policy, &mut DiscoveryCursor::default()) + .selected + .unwrap(); + let original = selected.release.api_url.clone(); + selected.release.api_url = Url::parse("https://evil.test/?token=TOKEN_SENTINEL").unwrap(); + let before = fake.calls().len(); + assert_eq!( + tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + DISCOVERY_BURST + )), + Err(DiscoveryError::IdentityChanged) + ); + assert_eq!(before, fake.calls().len()); + selected.release.api_url = original; + let delivery = "https://objects.githubusercontent.com/github-production-release-asset/123/abc?token=TOKEN_SENTINEL"; + fake.set(&selected.archive_asset.api_url, Reply::redirect(delivery)); + fake.set(&Url::parse(delivery).unwrap(), Reply::redirect(delivery)); + let error = tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + DISCOVERY_BURST, + )) + .unwrap_err(); + assert_eq!(error, DiscoveryError::RedirectLimit); + assert!(!format!("{error:?}").contains("TOKEN_SENTINEL")); + } +} diff --git a/src-tauri/src/updater_manifest.rs b/src-tauri/src/updater_manifest.rs new file mode 100644 index 00000000..9d044428 --- /dev/null +++ b/src-tauri/src/updater_manifest.rs @@ -0,0 +1,648 @@ +//! Strict, bounded parsing for the desktop update manifest. +//! +//! This module validates manifest syntax and trusted product identity only. It +//! does not fetch, discover, cache, or cryptographically verify an archive. + +use std::{collections::HashSet, fmt}; + +use reqwest::Url; +use semver::Version; +use serde::de::Error as _; +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Value}; + +const MAX_MANIFEST_BYTES: usize = 64 * 1024; +const MAX_NOTES_BYTES: usize = 64 * 1024; +const MAX_SIGNATURE_BYTES: usize = 16 * 1024; +const MAX_VERSION_LENGTH: usize = 128; +const MACOS_UPDATE_TARGET: &str = "darwin-aarch64"; +const MACOS_RUST_TARGET: &str = "aarch64-apple-darwin"; +const MAX_SEMVER_COMPONENT: u64 = 9_007_199_254_740_991; +const URL_CREDENTIALS_MESSAGE: &str = + "Manifest updater URL must be credential-free HTTPS without query or fragment data."; + +const ROOT_KEYS: &[&str] = &[ + "version", + "notes", + "pub_date", + "platforms", + "productVersion", + "channel", + "minimumSystemVersion", + "repository", + "build", +]; +const PLATFORM_KEYS: &[&str] = &["url", "signature"]; +const BUILD_KEYS: &[&str] = &["commit", "target"]; + +/// Native product identity. These values are compiled-in trust anchors, never +/// accepted from an updater request or another IPC caller. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProductIdentity<'a> { + pub repository: &'a str, + pub artifact_prefix: &'a str, +} + +/// The only channels represented by the desktop updater contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + Stable, + Beta, +} + +/// Owned values from a validated desktop update manifest. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Manifest { + pub version: Version, + pub product_version: Version, + pub channel: Channel, + pub minimum_system_version: String, + pub archive_url: Url, + pub signature: String, + pub commit: String, + pub notes: String, + pub pub_date: String, +} + +/// Parse and validate one bounded `desktop-update.json` document. +/// +/// A successful result proves only that the document satisfies the updater +/// schema and trusted identity policy. The signature remains syntax-only; the +/// archive bytes must be verified by the later archive-signing slice. +pub fn parse_manifest(bytes: &[u8], identity: &ProductIdentity<'_>) -> Result { + if bytes.len() > MAX_MANIFEST_BYTES { + return Err(format!( + "desktop-update.json exceeds its {MAX_MANIFEST_BYTES}-byte limit" + )); + } + + let value = parse_json(bytes)?; + let root = object(&value, "desktop-update.json")?; + exact_keys(root, ROOT_KEYS, "desktop-update.json")?; + + let version_text = string_field(root, "version", "Manifest version")?; + let version = strict_version(version_text, "Manifest version")?; + let product_version_text = string_field(root, "productVersion", "Manifest productVersion")?; + let product_version = strict_version(product_version_text, "Manifest productVersion")?; + let channel = channel_for(&product_version)?; + let declared_channel = string_field(root, "channel", "Manifest channel")?; + if declared_channel != channel.as_str() { + return Err("Manifest channel does not match productVersion.".to_owned()); + } + + let notes = bounded_text( + string_field(root, "notes", "Manifest notes")?, + "Manifest notes", + MAX_NOTES_BYTES, + false, + )?; + let pub_date = string_field(root, "pub_date", "Manifest pub_date")?; + validate_utc_date(pub_date, "Manifest pub_date")?; + let minimum_system_version = string_field( + root, + "minimumSystemVersion", + "Manifest minimumSystemVersion", + )?; + validate_macos_version(minimum_system_version)?; + + let repository = string_field(root, "repository", "Manifest repository")?; + if repository != identity.repository { + return Err("Manifest repository is not the trusted repository.".to_owned()); + } + + let platforms = object_field(root, "platforms", "Manifest platforms")?; + exact_keys(platforms, &[MACOS_UPDATE_TARGET], "Manifest platforms")?; + let platform = object_field(platforms, MACOS_UPDATE_TARGET, "Manifest platform")?; + exact_keys(platform, PLATFORM_KEYS, "Manifest darwin-aarch64 platform")?; + let archive_url_text = string_field(platform, "url", "Manifest updater URL")?; + let archive_url = validate_archive_url(archive_url_text, product_version_text, identity)?; + let signature = bounded_signature(string_field( + platform, + "signature", + "Manifest updater signature", + )?)?; + + let build = object_field(root, "build", "Manifest build")?; + exact_keys(build, BUILD_KEYS, "Manifest build")?; + let commit = string_field(build, "commit", "Build commit")?; + validate_commit(commit)?; + let target = string_field(build, "target", "Manifest build target")?; + if target != MACOS_RUST_TARGET { + return Err("Manifest build target is not the canonical macOS arm64 target.".to_owned()); + } + + Ok(Manifest { + version, + product_version, + channel, + minimum_system_version: minimum_system_version.to_owned(), + archive_url, + signature, + commit: commit.to_owned(), + notes, + pub_date: pub_date.to_owned(), + }) +} + +impl Channel { + fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + } + } +} + +fn channel_for(version: &Version) -> Result { + match version.pre.as_str().split('.').next() { + None | Some("") => Ok(Channel::Stable), + Some("beta") => Ok(Channel::Beta), + Some(_) => Err("Only beta and stable product channels are supported.".to_owned()), + } +} + +fn strict_version(value: &str, label: &str) -> Result { + if value.is_empty() || value.len() > MAX_VERSION_LENGTH { + return Err(format!( + "{label} must be strict SemVer without a leading v." + )); + } + let parsed = Version::parse(value) + .map_err(|_| format!("{label} must be strict SemVer without a leading v."))?; + // npm semver's `valid(value)` returns the normalized `version` string, + // which intentionally omits build metadata. Equality in the producer's + // strictVersion helper therefore rejects build metadata as well as `v`. + if !parsed.build.is_empty() + || parsed.major > MAX_SEMVER_COMPONENT + || parsed.minor > MAX_SEMVER_COMPONENT + || parsed.patch > MAX_SEMVER_COMPONENT + || parsed.to_string() != value + { + return Err(format!( + "{label} must be strict SemVer without a leading v." + )); + } + Ok(parsed) +} + +fn validate_macos_version(value: &str) -> Result<(), String> { + let components: Vec<&str> = value.split('.').collect(); + if !(components.len() == 2 || components.len() == 3) + || components.iter().any(|part| part.is_empty()) + || components.iter().any(|part| { + (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || match part.parse::() { + Ok(number) => number > 999, + Err(_) => true, + } + }) + { + return Err("minimumSystemVersion must be major.minor[.patch].".to_owned()); + } + Ok(()) +} + +fn validate_commit(value: &str) -> Result<(), String> { + if value.len() != 40 + || !value + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return Err("Build commit must be a lowercase full commit SHA.".to_owned()); + } + Ok(()) +} + +fn bounded_signature(value: &str) -> Result { + bounded_text( + value, + "Manifest updater signature", + MAX_SIGNATURE_BYTES, + false, + )?; + if value.len() < 8 + || value.len() % 4 != 0 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')) + { + return Err("Manifest updater signature must be base64.".to_owned()); + } + // Keep the same strict padding grammar as the producer's BASE64 regex. + let valid_padding = match value.as_bytes().iter().position(|byte| *byte == b'=') { + None => true, + Some(index) => { + let padding = value.len() - index; + (padding == 1 || padding == 2) && value[index..].bytes().all(|byte| byte == b'=') + } + }; + if !valid_padding { + return Err("Manifest updater signature must be base64.".to_owned()); + } + Ok(value.to_owned()) +} + +fn bounded_text(value: &str, label: &str, max_bytes: usize, empty: bool) -> Result { + if (!empty && value.is_empty()) || value.len() > max_bytes { + return Err(format!("{label} is missing or oversized.")); + } + if value.bytes().any(|byte| { + matches!( + byte, + 0x00..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f + ) + }) { + return Err(format!("{label} contains control characters.")); + } + Ok(value.to_owned()) +} + +fn validate_utc_date(value: &str, label: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let fractional_len = match bytes.len() { + 20 => 0, + 22 => 1, + 23 => 2, + 24 => 3, + _ => return Err(format!("{label} must be an ISO-8601 UTC timestamp.")), + }; + let z_index = if fractional_len == 0 { + 19 + } else { + 20 + fractional_len + }; + if bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || bytes[z_index] != b'Z' + || (fractional_len > 0 && bytes[19] != b'.') + || bytes[..4].iter().any(|byte| !byte.is_ascii_digit()) + || bytes[5..7].iter().any(|byte| !byte.is_ascii_digit()) + || bytes[8..10].iter().any(|byte| !byte.is_ascii_digit()) + || bytes[11..13].iter().any(|byte| !byte.is_ascii_digit()) + || bytes[14..16].iter().any(|byte| !byte.is_ascii_digit()) + || bytes[17..19].iter().any(|byte| !byte.is_ascii_digit()) + || bytes[20..20 + fractional_len] + .iter() + .any(|byte| !byte.is_ascii_digit()) + { + return Err(format!("{label} must be an ISO-8601 UTC timestamp.")); + } + let year = number(&bytes[0..4]); + let month = number(&bytes[5..7]); + let day = number(&bytes[8..10]); + let hour = number(&bytes[11..13]); + let minute = number(&bytes[14..16]); + let second = number(&bytes[17..19]); + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + }; + if month == 0 || day == 0 || day > days_in_month || hour > 23 || minute > 59 || second > 59 { + return Err(format!("{label} is not a real UTC timestamp.")); + } + Ok(()) +} + +fn number(bytes: &[u8]) -> u32 { + bytes + .iter() + .fold(0, |value, byte| value * 10 + u32::from(byte - b'0')) +} + +fn validate_archive_url( + value: &str, + product_version: &str, + identity: &ProductIdentity<'_>, +) -> Result { + let archive = format!( + "https://github.com/{}/releases/download/v{}/{}desktop-{}-macos-arm64.app.tar.gz", + identity.repository, product_version, identity.artifact_prefix, product_version + ); + if value != archive { + return Err("Manifest updater URL is not the canonical GitHub download URL.".to_owned()); + } + let parsed = Url::parse(value).map_err(|_| "Manifest updater URL is malformed.".to_owned())?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.as_str() != archive + { + return Err(URL_CREDENTIALS_MESSAGE.to_owned()); + } + Ok(parsed) +} + +fn object<'a>(value: &'a Value, label: &str) -> Result<&'a Map, String> { + value + .as_object() + .ok_or_else(|| format!("{label} must be an object.")) +} + +fn object_field<'a>( + values: &'a Map, + key: &str, + label: &str, +) -> Result<&'a Map, String> { + object( + values + .get(key) + .ok_or_else(|| format!("{label} is missing."))?, + label, + ) +} + +fn string_field<'a>( + object: &'a Map, + key: &str, + label: &str, +) -> Result<&'a str, String> { + object + .get(key) + .ok_or_else(|| format!("{label} is missing."))? + .as_str() + .ok_or_else(|| format!("{label} must be a string.")) +} + +fn exact_keys(object: &Map, expected: &[&str], label: &str) -> Result<(), String> { + if object.len() != expected.len() || expected.iter().any(|key| !object.contains_key(*key)) { + return Err(format!("{label} contains unexpected or missing fields.")); + } + Ok(()) +} + +fn parse_json(bytes: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue::deserialize(&mut deserializer) + .map_err(|error| format!("desktop-update.json is invalid JSON: {error}"))? + .0; + deserializer + .end() + .map_err(|error| format!("desktop-update.json has trailing data: {error}"))?; + Ok(value) +} + +struct StrictValue(Value); + +impl<'de> Deserialize<'de> for StrictValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StrictVisitor; + + impl<'de> Visitor<'de> for StrictVisitor { + type Value = StrictValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(StrictValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + Ok(StrictValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(StrictValue(Value::Number(value.into()))) + } + + fn visit_i128(self, value: i128) -> Result + where + E: de::Error, + { + serde_json::Number::from_i128(value) + .map(|number| StrictValue(Value::Number(number))) + .ok_or_else(|| E::custom("JSON number out of range")) + } + + fn visit_u128(self, value: u128) -> Result + where + E: de::Error, + { + serde_json::Number::from_u128(value) + .map(|number| StrictValue(Value::Number(number))) + .ok_or_else(|| E::custom("JSON number out of range")) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + serde_json::Number::from_f64(value) + .map(|number| StrictValue(Value::Number(number))) + .ok_or_else(|| E::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(StrictValue(Value::String(value.to_owned()))) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + Ok(StrictValue(Value::String(value))) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(StrictValue(Value::Null)) + } + + fn visit_seq(self, mut access: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = access.next_element::()? { + values.push(value.0); + } + Ok(StrictValue(Value::Array(values))) + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = Map::new(); + let mut keys = HashSet::new(); + while let Some(key) = access.next_key::()? { + if !keys.insert(key.clone()) { + return Err(A::Error::custom("duplicate JSON object key")); + } + let value = access.next_value::()?; + values.insert(key, value.0); + } + Ok(StrictValue(Value::Object(values))) + } + } + + deserializer.deserialize_any(StrictVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IDENTITY: ProductIdentity<'static> = ProductIdentity { + repository: "devswha/gajae-code-app", + artifact_prefix: "gajae-app-", + }; + + fn fixture() -> Value { + serde_json::from_slice(include_bytes!( + "../../shared/fixtures/desktop-update-manifest.json" + )) + .expect("fixture JSON") + } + + fn bytes(value: &Value) -> Vec { + serde_json::to_vec(value).expect("manifest JSON") + } + + fn assert_rejected(change: impl FnOnce(&mut Value)) { + let mut value = fixture(); + change(&mut value); + assert!(parse_manifest(&bytes(&value), &IDENTITY).is_err()); + } + + #[test] + fn fixture_is_accepted_with_owned_fields() { + let manifest = parse_manifest( + include_bytes!("../../shared/fixtures/desktop-update-manifest.json"), + &IDENTITY, + ) + .unwrap(); + assert_eq!(manifest.version.to_string(), "0.2.4"); + assert_eq!(manifest.product_version.to_string(), "2.0.0-beta.10"); + assert_eq!(manifest.channel, Channel::Beta); + assert_eq!(manifest.minimum_system_version, "13.0"); + assert_eq!(manifest.commit, "a".repeat(40)); + assert_eq!(manifest.signature, "A".repeat(88)); + assert_eq!(manifest.pub_date, "2026-09-06T00:00:00Z"); + } + + #[test] + fn malformed_unknown_and_duplicate_keys_fail_closed() { + assert!(parse_manifest(br"{}", &IDENTITY).is_err()); + assert_rejected(|value| { + value + .as_object_mut() + .unwrap() + .insert("unknown".into(), Value::Null); + }); + let duplicate = br#"{"version":"0.2.4","version":"0.2.4"}"#; + assert!(parse_manifest(duplicate, &IDENTITY).is_err()); + let nested_duplicate = br#"{"version":"0.2.4","notes":"ok","pub_date":"2026-09-06T00:00:00Z","platforms":{"darwin-aarch64":{"url":"x","url":"y","signature":"AAAAAAAA"}},"productVersion":"2.0.0-beta.10","channel":"beta","minimumSystemVersion":"13.0","repository":"devswha/gajae-code-app","build":{"commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","target":"aarch64-apple-darwin"}}"#; + assert!(parse_manifest(nested_duplicate, &IDENTITY).is_err()); + } + + #[test] + fn url_date_version_channel_and_target_policy_fail_closed() { + let wrong_identity = ProductIdentity { + repository: "another-owner/another-repository", + artifact_prefix: IDENTITY.artifact_prefix, + }; + assert!(parse_manifest( + include_bytes!("../../shared/fixtures/desktop-update-manifest.json"), + &wrong_identity, + ) + .is_err()); + let wrong_prefix = ProductIdentity { + repository: IDENTITY.repository, + artifact_prefix: "another-prefix-", + }; + assert!(parse_manifest( + include_bytes!("../../shared/fixtures/desktop-update-manifest.json"), + &wrong_prefix, + ) + .is_err()); + assert_rejected(|value| { + value["platforms"][MACOS_UPDATE_TARGET]["url"] = + Value::String("https://github.com/devswha/gajae-code-app/releases/download/v2.0.0-beta.10/gajae-app-desktop-2.0.0-beta.10-macos-arm64.app.tar.gz?token=secret".into()); + }); + for date in [ + "2026-02-29T00:00:00Z", + "2024-02-30T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-01-01T24:00:00Z", + ] { + assert_rejected(|value| value["pub_date"] = Value::String(date.into())); + } + for version in ["v2.0.0-beta.10", "2.0.0-beta.01", "2.0.0+build.1", "2.0"] { + assert_rejected(|value| value["productVersion"] = Value::String(version.into())); + } + for version in ["v0.2.4", "0.2.4+build.1", "0.2.4-beta.01", "0.2"] { + assert_rejected(|value| value["version"] = Value::String(version.into())); + } + assert_rejected(|value| value["channel"] = Value::String("stable".into())); + assert_rejected(|value| { + value["build"]["target"] = Value::String("aarch64-unknown-linux-gnu".into()) + }); + } + + #[test] + fn field_bounds_and_shapes_are_enforced() { + assert!(parse_manifest(&vec![b' '; MAX_MANIFEST_BYTES + 1], &IDENTITY).is_err()); + assert_rejected(|value| value["notes"] = Value::String("x".repeat(MAX_NOTES_BYTES))); + assert_rejected(|value| value["notes"] = Value::String("ok\u{000b}".into())); + assert_rejected(|value| { + value["platforms"][MACOS_UPDATE_TARGET]["signature"] = + Value::String("A".repeat(MAX_SIGNATURE_BYTES + 1)) + }); + for signature in ["not-base64", "AAAAAAA!", "AAAA===="] { + assert_rejected(|value| { + value["platforms"][MACOS_UPDATE_TARGET]["signature"] = + Value::String(signature.into()) + }); + } + assert_rejected(|value| value["build"]["commit"] = Value::String("A".repeat(40))); + for minimum in [ + "13", "013.0", "13.00", "13.0.0.1", "1000.0", "13.1000", "+13.0", "13.0 ", + ] { + assert_rejected(|value| value["minimumSystemVersion"] = Value::String(minimum.into())); + } + assert_rejected(|value| value["platforms"] = Value::Array(Vec::new())); + } + + #[test] + fn real_leap_day_and_bounded_fractional_utc_timestamp_are_accepted() { + let mut value = fixture(); + value["pub_date"] = Value::String("2024-02-29T23:59:59.123Z".into()); + assert!(parse_manifest(&bytes(&value), &IDENTITY).is_ok()); + } + + #[test] + fn producer_semver_build_metadata_is_rejected_like_js_normalized_valid() { + assert!(strict_version("2.0.0-beta.10+build.1", "version").is_err()); + assert!(strict_version("2.0.0-beta.10", "version").is_ok()); + } +} diff --git a/src-tauri/src/updater_signature.rs b/src-tauri/src/updater_signature.rs new file mode 100644 index 00000000..ffba13ee --- /dev/null +++ b/src-tauri/src/updater_signature.rs @@ -0,0 +1,66 @@ +//! Verify the same immutable archive buffer used for inspection and staging, +//! using the selected official updater's Minisign algorithm/library. +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use minisign_verify::{PublicKey, Signature}; +use sha2::{Digest, Sha256}; + +pub fn verify_archive(bytes: &[u8], public_key: &str, signature: &str) -> Result { + if bytes.is_empty() || bytes.len() > crate::updater_store::MAX_ARCHIVE_BYTES { + return Err("Updater archive is empty or exceeds its limit.".into()); + } + let key = decode(public_key)?; + let signature = decode(signature)?; + let key = PublicKey::decode(&key).map_err(|_| "Invalid updater public key.")?; + let signature = Signature::decode(&signature).map_err(|_| "Invalid updater signature.")?; + key.verify(bytes, &signature, false) + .map_err(|_| "Updater archive signature verification failed.")?; + Ok(digest(bytes)) +} + +pub fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn decode(value: &str) -> Result { + if value.is_empty() || value.len() > 16 * 1024 { + return Err("Updater signature material exceeds its limit.".into()); + } + let bytes = STANDARD + .decode(value) + .map_err(|_| "Invalid updater signature encoding.")?; + String::from_utf8(bytes).map_err(|_| "Invalid updater signature text.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Published minisign-verify 0.2.5 test vector; real cryptographic verification, + // not the manifest schema fixture's intentionally fake signature. + fn vector() -> (String, String) { + let key = "untrusted comment: minisign public key\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3\n"; + let signature = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1633700835\tfile:test\tprehashed\nwLMDjy9FLAuxZ3q4NlEvkgtyhrr0gtTu6KC4KBJdITbbOeAi1zBIYo0v4iTgt8jJpIidRJnp94ABQkJAgAooBQ=="; + (STANDARD.encode(key), STANDARD.encode(signature)) + } + #[test] + fn actual_signature_accepts_exact_bytes_and_rejects_tampering() { + let (key, signature) = vector(); + assert_eq!( + verify_archive(b"test", &key, &signature).unwrap(), + digest(b"test") + ); + assert!(verify_archive(b"changed", &key, &signature).is_err()); + assert!(verify_archive(b"test", &STANDARD.encode("bad key"), &signature).is_err()); + } + #[test] + fn malformed_material_is_bounded_and_errors_do_not_echo_it() { + for value in [ + "secret://credential".into(), + "A".repeat(16385), + "/w==".into(), + ] { + let error = verify_archive(b"test", &value, &value).unwrap_err(); + assert!(!error.contains(&value)); + } + } +} diff --git a/src-tauri/src/updater_store.rs b/src-tauri/src/updater_store.rs new file mode 100644 index 00000000..831b9d74 --- /dev/null +++ b/src-tauri/src/updater_store.rs @@ -0,0 +1,1146 @@ +//! Private preparation cache. Nothing in this module installs an app or owns an +//! install-attempt record. A cache hit is untrusted input and must be verified +//! again by the preparation owner before being shown as ready. +use std::{ + ffi::{CStr, CString}, + fs::{File, Metadata}, + io::{Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::MetadataExt, + }, + path::{Component, Path}, + sync::{Arc, Mutex}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const MAX_ARCHIVE_BYTES: usize = 250 * 1024 * 1024; +const MAX_RECORD_BYTES: usize = 32 * 1024 * 1024; +const MAX_MANIFEST_BYTES: usize = 64 * 1024; +const MAX_PREFERENCES_BYTES: usize = 4096; +const MAX_CACHE_FILES: usize = 8; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Preferences { + pub schema: u8, + pub automatic: bool, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + schema: 1, + automatic: true, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PreparedRecord { + pub schema: u8, + pub release_id: u64, + pub manifest_asset_id: u64, + pub archive_asset_id: u64, + pub archive_size: u64, + pub archive_sha256: String, + /// Only the canonical manifest is persisted, never a signed redirect URL. + pub manifest: String, + /// Full file/mode/hash/link inventory, compared to a fresh inspection on load. + pub inventory: Value, +} + +impl PreparedRecord { + fn validate(&self) -> Result<(), String> { + if self.schema != 1 + || self.release_id == 0 + || self.manifest_asset_id == 0 + || self.archive_asset_id == 0 + || self.archive_size == 0 + || self.archive_size > MAX_ARCHIVE_BYTES as u64 + || self.manifest.is_empty() + || self.manifest.len() > MAX_MANIFEST_BYTES + || self.archive_sha256.len() != 64 + || !self + .archive_sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || !self.inventory.is_object() + { + return Err("Invalid prepared update record.".into()); + } + Ok(()) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Pointer { + schema: u8, + id: String, +} + +/// Anchored directory descriptors prevent path replacement or symlink traversal +/// between validation and I/O. Call only after compiled runtime admission. +pub struct Store { + directory: File, + // Only allocation/publication/retirement, never the large staged writes. + // The parent still owns generation and consent admission. + mutation: Arc>, + #[cfg(test)] + fail_sync: Mutex>, + #[cfg(test)] + stage_pause: Mutex, std::sync::mpsc::Receiver<()>)>>, +} + +#[must_use = "commit or discard the stage; dropping it discards unpublished files"] +pub struct StagedRecord { + pointer: Pointer, + directory: File, + mutation: Arc>, + files: Vec, + keep_files: bool, +} + +struct StagedFile { + name: String, + file: File, + persisted: Option, +} + +impl Drop for StagedRecord { + fn drop(&mut self) { + if self.keep_files { + return; + } + let Ok(_mutation) = self.mutation.lock() else { + // Retain bounded orphans rather than run uncoordinated cleanup. + return; + }; + for staged in &self.files { + // Only names created by this handle, still pointing at its inode. + // An entry replaced after staging is not ours to erase. + if let Ok(current) = open_at_io( + &self.directory, + &staged.name, + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) { + if let (Ok(current), Ok(owned)) = (current.metadata(), staged.file.metadata()) { + if same_inode(¤t, &owned) { + let _ = unlink_owned(&self.directory, &staged.name); + } + } + } + } + let _ = self.directory.sync_all(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SyncPoint { + Stage, + Publication, + Retirement, +} + +impl Store { + pub fn open(data_root: &Path) -> Result { + let parent = open_root(data_root)?; + let name = c_name("desktop-update-cache")?; + let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) }; + if result != 0 + && std::io::Error::last_os_error().kind() != std::io::ErrorKind::AlreadyExists + { + return Err("Could not create private update cache.".into()); + } + let directory = open_at( + &parent, + "desktop-update-cache", + libc::O_RDONLY | libc::O_DIRECTORY, + 0, + )?; + private_metadata( + &directory + .metadata() + .map_err(|_| "Could not inspect update cache.")?, + true, + )?; + parent + .sync_all() + .map_err(|_| "Could not synchronize update cache parent.")?; + Ok(Self { + directory, + mutation: Arc::new(Mutex::new(())), + #[cfg(test)] + fail_sync: Mutex::new(None), + #[cfg(test)] + stage_pause: Mutex::new(None), + }) + } + + pub fn preferences(&self) -> Result { + let Some(bytes) = self.read("preferences.json", MAX_PREFERENCES_BYTES)? else { + return Ok(Preferences::default()); + }; + let preferences: Preferences = + serde_json::from_slice(&bytes).map_err(|_| "Invalid update preferences.")?; + if preferences.schema != 1 { + return Err("Unknown update preference schema.".into()); + } + Ok(preferences) + } + + pub fn set_automatic(&self, automatic: bool) -> Result<(), String> { + let _mutation = self + .mutation + .lock() + .map_err(|_| "Update cache lock failed.")?; + // An explicit preference write cannot silently hide malformed state. + self.preferences()?; + let value = Preferences { + schema: 1, + automatic, + }; + self.atomic_json("preferences.json", &value, MAX_PREFERENCES_BYTES) + } + + pub fn load(&self) -> Result)>, String> { + let Some(pointer) = self.pointer()? else { + return Ok(None); + }; + let record = self + .read(&format!("record-{}.json", pointer.id), MAX_RECORD_BYTES)? + .ok_or("Prepared update metadata is missing.")?; + let record: PreparedRecord = + serde_json::from_slice(&record).map_err(|_| "Invalid prepared update metadata.")?; + record.validate()?; + let archive = self + .read(&format!("archive-{}", pointer.id), MAX_ARCHIVE_BYTES)? + .ok_or("Prepared update archive is missing.")?; + if archive.len() as u64 != record.archive_size { + return Err("Prepared update size changed.".into()); + } + // Digest, real Minisign verification, manifest policy, and full archive + // inspection belong to the caller and are mandatory, even after restart. + Ok(Some((record, archive))) + } + + pub fn stage(&self, record: &PreparedRecord, archive: &[u8]) -> Result { + record.validate()?; + if archive.len() as u64 != record.archive_size { + return Err("Prepared update size does not match.".into()); + } + let encoded = + serde_json::to_vec(record).map_err(|_| "Could not encode prepared update.")?; + if encoded.len() > MAX_RECORD_BYTES { + return Err("Prepared inventory exceeds its limit.".into()); + } + let id = random_id()?; + let mut staged = StagedRecord { + pointer: Pointer { schema: 1, id }, + directory: self + .directory + .try_clone() + .map_err(|_| "Could not retain cache directory.")?, + mutation: self.mutation.clone(), + files: Vec::with_capacity(2), + keep_files: false, + }; + { + let _mutation = self + .mutation + .lock() + .map_err(|_| "Update cache lock failed.")?; + // Reserve the pair with exclusive creates while capacity is locked. + // Leave room for the eventual atomic pointer's temporary file. + self.check_capacity(3)?; + for name in [ + format!("archive-{}", staged.pointer.id), + format!("record-{}.json", staged.pointer.id), + ] { + let file = open_at( + &self.directory, + &name, + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + 0o600, + )?; + staged.files.push(StagedFile { + name, + file, + persisted: None, + }); + } + } + // No store or parent generation lock is held during large I/O. + #[cfg(test)] + if let Some((entered, resume)) = self.stage_pause.lock().unwrap().take() { + entered.send(()).unwrap(); + resume + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + for (file, bytes) in staged.files.iter_mut().zip([archive, encoded.as_slice()]) { + file.persisted = Some(persist_file(&mut file.file, bytes)?); + } + // Persist both directory entries before ready.json can refer to them, + // including when the later pointer-directory fsync is uncertain. + self.sync_directory(SyncPoint::Stage)?; + Ok(staged) + } + + /// Commit only while holding the generation/consent lock. Slow archive I/O + /// happens in stage(), so an opt-out can cancel it without waiting for a + /// large write. No observer sees ready until both staged files are durable. + pub fn commit(&self, mut staged: StagedRecord) -> Result<(), String> { + if !Arc::ptr_eq(&self.mutation, &staged.mutation) { + return Err("Prepared update belongs to a different cache owner.".into()); + } + let _mutation = self + .mutation + .lock() + .map_err(|_| "Update cache lock failed.")?; + // Cheap descriptor/metadata checks only; never re-read the archive while + // the parent holds consent admission. Reload still requires crypto proof. + for file in &staged.files { + let current = open_at( + &self.directory, + &file.name, + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + )?; + let current = current + .metadata() + .map_err(|_| "Could not inspect staged update.")?; + private_metadata(¤t, false)?; + if !file + .persisted + .as_ref() + .is_some_and(|expected| same_snapshot(expected, ¤t)) + { + return Err("Prepared update changed before publication.".into()); + } + } + let old = self.pointer()?; + // Both files reach stable storage before the atomic pointer is published. + // On uncertain pointer fsync failure retain files; a later load decides. + let pointer = Pointer { + schema: staged.pointer.schema, + id: staged.pointer.id.clone(), + }; + self.atomic_json_after_rename("ready.json", &pointer, MAX_PREFERENCES_BYTES, || { + // Rename made the new pointer visible. Even if its directory fsync + // fails, Drop must not erase files that ready.json might reference. + staged.keep_files = true; + })?; + if let Some(old) = old { + if old.id != staged.pointer.id { + self.remove_owned(&format!("archive-{}", old.id))?; + self.remove_owned(&format!("record-{}.json", old.id))?; + self.sync_directory(SyncPoint::Retirement)?; + } + } + Ok(()) + } + + pub fn discard(&self, staged: StagedRecord) { + // Cleanup is anchored to the handle's originating store, not this path. + drop(staged); + } + + #[cfg(test)] + fn publish(&self, record: &PreparedRecord, archive: &[u8]) -> Result<(), String> { + self.commit(self.stage(record, archive)?) + } + + fn pointer(&self) -> Result, String> { + let Some(bytes) = self.read("ready.json", MAX_PREFERENCES_BYTES)? else { + return Ok(None); + }; + let pointer: Pointer = + serde_json::from_slice(&bytes).map_err(|_| "Invalid prepared update pointer.")?; + if pointer.schema != 1 + || pointer.id.len() != 32 + || !pointer + .id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err("Invalid prepared update identifier.".into()); + } + Ok(Some(pointer)) + } + + fn read(&self, name: &str, cap: usize) -> Result>, String> { + self.validate_directory()?; + // O_NONBLOCK prevents a malicious FIFO from hanging before fstat. + let mut file = match open_at_io(&self.directory, name, libc::O_RDONLY | libc::O_NONBLOCK, 0) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("Could not open private update cache file.".into()), + }; + let before = file + .metadata() + .map_err(|_| "Could not inspect update cache file.")?; + private_metadata(&before, false)?; + if before.len() > cap as u64 { + return Err("Update cache file exceeds its limit.".into()); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(cap as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Could not read update cache file.")?; + let after = file + .metadata() + .map_err(|_| "Could not recheck update cache file.")?; + private_metadata(&after, false)?; + if bytes.len() > cap + || bytes.len() as u64 != before.len() + || !same_snapshot(&before, &after) + { + return Err("Update cache file changed while reading.".into()); + } + Ok(Some(bytes)) + } + + fn write_exclusive(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + let mut file = open_at( + &self.directory, + name, + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + 0o600, + )?; + if let Err(error) = persist_file(&mut file, bytes) { + let _ = self.remove_owned(name); + return Err(error); + } + Ok(()) + } + + fn atomic_json(&self, target: &str, value: &impl Serialize, cap: usize) -> Result<(), String> { + self.atomic_json_after_rename(target, value, cap, || {}) + } + + // Caller holds mutation (and parent generation/consent admission for ready). + fn atomic_json_after_rename( + &self, + target: &str, + value: &impl Serialize, + cap: usize, + after_rename: impl FnOnce(), + ) -> Result<(), String> { + let bytes = serde_json::to_vec(value).map_err(|_| "Could not encode updater state.")?; + if bytes.len() > cap { + return Err("Updater state exceeds its limit.".into()); + } + // Refuse malformed/aliased existing files rather than hiding corruption. + let _ = self.read(target, cap)?; + self.check_capacity(1)?; + let temporary = format!("pending-{}", random_id()?); + self.write_exclusive(&temporary, &bytes)?; + let source = c_name(&temporary)?; + let target = c_name(target)?; + if unsafe { + libc::renameat( + self.directory.as_raw_fd(), + source.as_ptr(), + self.directory.as_raw_fd(), + target.as_ptr(), + ) + } != 0 + { + let _ = self.remove_owned(&temporary); + return Err("Could not publish updater state.".into()); + } + after_rename(); + self.sync_directory(SyncPoint::Publication) + } + + fn remove_owned(&self, name: &str) -> Result<(), String> { + unlink_owned(&self.directory, name) + } + + fn sync_directory(&self, _point: SyncPoint) -> Result<(), String> { + #[cfg(test)] + { + let mut failure = self.fail_sync.lock().unwrap(); + if *failure == Some(_point) { + *failure = None; + return Err("Injected cache directory synchronization failure.".into()); + } + } + self.directory + .sync_all() + .map_err(|_| "Could not synchronize update cache state.".into()) + } + + fn validate_directory(&self) -> Result<(), String> { + private_metadata( + &self + .directory + .metadata() + .map_err(|_| "Could not inspect update cache.")?, + true, + ) + } + + fn check_capacity(&self, additional: usize) -> Result<(), String> { + self.validate_directory()?; + // Crash-orphan files are never trusted or recursively erased. Bound + // their accumulation and fail safely until explicit cache maintenance. + // dup/fcntl(F_DUPFD_*) share an open-file-description offset. fdopendir + // consumes that offset, so a later capacity scan could start at EOF. + // Open "." relative to the anchored descriptor for an independent scan. + let fd = unsafe { + libc::openat( + self.directory.as_raw_fd(), + c".".as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err("Could not inspect update cache capacity.".into()); + } + let directory = unsafe { libc::fdopendir(fd) }; + if directory.is_null() { + unsafe { + libc::close(fd); + } + return Err("Could not inspect update cache capacity.".into()); + } + let mut count = 0; + let result = loop { + unsafe { + *libc::__error() = 0; + } + let entry = unsafe { libc::readdir(directory) }; + if entry.is_null() { + break if unsafe { *libc::__error() } == 0 { + Ok(()) + } else { + Err("Could not enumerate update cache.".into()) + }; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name != b"." && name != b".." { + count += 1; + } + if count + additional > MAX_CACHE_FILES { + break Err( + "Update cache contains too many retained files; maintenance is required." + .into(), + ); + } + }; + unsafe { + libc::closedir(directory); + } + result + } +} + +fn unlink_owned(directory: &File, name: &str) -> Result<(), String> { + // Never follows an entry or recursively erases a tree. + let name = c_name(name)?; + if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 + && std::io::Error::last_os_error().kind() != std::io::ErrorKind::NotFound + { + return Err("Could not retire private update cache file.".into()); + } + Ok(()) +} + +fn persist_file(file: &mut File, bytes: &[u8]) -> Result { + private_metadata( + &file + .metadata() + .map_err(|_| "Could not inspect staged file.")?, + false, + )?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| "Could not persist prepared update.")?; + let metadata = file + .metadata() + .map_err(|_| "Could not inspect persisted file.")?; + private_metadata(&metadata, false)?; + if metadata.len() != bytes.len() as u64 { + return Err("Prepared update changed while writing.".into()); + } + Ok(metadata) +} + +fn same_inode(left: &Metadata, right: &Metadata) -> bool { + left.dev() == right.dev() && left.ino() == right.ino() +} + +fn same_snapshot(left: &Metadata, right: &Metadata) -> bool { + same_inode(left, right) + && left.len() == right.len() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +fn private_metadata(metadata: &Metadata, directory: bool) -> Result<(), String> { + let right_type = if directory { + metadata.is_dir() + } else { + metadata.is_file() && metadata.nlink() == 1 + }; + let mode = if directory { 0o700 } else { 0o600 }; + if !right_type + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o7777 != mode + { + return Err("Update cache must be owner-only, regular and unaliased.".into()); + } + Ok(()) +} + +fn open_root(path: &Path) -> Result { + if !path.is_absolute() + || path + .components() + .any(|c| matches!(c, Component::ParentDir | Component::CurDir)) + { + return Err("Updater data root must be an absolute real directory.".into()); + } + let mut current = File::open("/").map_err(|_| "Could not open updater root.")?; + for component in path.components() { + if let Component::Normal(name) = component { + let name = name.to_str().ok_or("Invalid updater root component.")?; + current = open_at(¤t, name, libc::O_RDONLY | libc::O_DIRECTORY, 0)?; + } + } + let metadata = current + .metadata() + .map_err(|_| "Could not inspect updater data root.")?; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o022 != 0 { + return Err("Updater data root must be owned and not group/world writable.".into()); + } + Ok(current) +} + +fn c_name(name: &str) -> Result { + if name.is_empty() || matches!(name, "." | "..") || name.contains('/') { + return Err("Invalid updater cache component.".into()); + } + CString::new(name).map_err(|_| "Invalid updater cache component.".into()) +} + +fn open_at_io(parent: &File, name: &str, flags: i32, mode: libc::mode_t) -> std::io::Result { + let name = c_name(name).map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC, + mode as libc::c_uint, + ) + }; + if fd < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(fd) }) + } +} + +fn open_at(parent: &File, name: &str, flags: i32, mode: libc::mode_t) -> Result { + open_at_io(parent, name, flags, mode) + .map_err(|_| "Could not open real updater directory/file.".into()) +} + +fn random_id() -> Result { + let mut bytes = [0; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| "Could not create updater generation.")?; + Ok(format!("{:032x}", u128::from_be_bytes(bytes))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + os::unix::{ + ffi::OsStrExt, + fs::{symlink, PermissionsExt}, + }, + path::PathBuf, + }; + + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let root = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!("gajae-cache-{}", random_id().unwrap())); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + Self(root) + } + fn cache(&self) -> PathBuf { + self.0.join("desktop-update-cache") + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn record() -> PreparedRecord { + PreparedRecord { + schema: 1, + release_id: 1, + manifest_asset_id: 2, + archive_asset_id: 3, + archive_size: 4, + archive_sha256: "a".repeat(64), + manifest: "{}".into(), + inventory: serde_json::json!({"entries":[]}), + } + } + + #[test] + fn preferences_survive_reopen_and_manual_cache_does_not_change_consent() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + assert!(store.preferences().unwrap().automatic); + store.set_automatic(false).unwrap(); + store.publish(&record(), b"test").unwrap(); + drop(store); + let store = Store::open(&temp.0).unwrap(); + assert!(!store.preferences().unwrap().automatic); + let (cached, bytes) = store.load().unwrap().unwrap(); + assert_eq!(cached, record()); + assert_eq!(bytes, b"test"); + } + + #[test] + fn publication_retires_only_the_previous_owned_pair() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + fs::write(temp.cache().join("user-sentinel"), b"keep").unwrap(); + store.publish(&record(), b"test").unwrap(); + let first = store.pointer().unwrap().unwrap().id; + store.publish(&record(), b"next").unwrap(); + assert!(!temp.cache().join(format!("archive-{first}")).exists()); + assert_eq!( + fs::read(temp.cache().join("user-sentinel")).unwrap(), + b"keep" + ); + assert_eq!(store.load().unwrap().unwrap().1, b"next"); + } + + #[test] + fn symlinks_hardlinks_fifo_world_readable_and_truncated_state_are_rejected() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + let preferences = temp.cache().join("preferences.json"); + let target = temp.0.join("sentinel"); + fs::write(&target, b"keep").unwrap(); + symlink(&target, &preferences).unwrap(); + assert!(store.preferences().is_err()); + assert!(store.set_automatic(false).is_err()); + fs::remove_file(&preferences).unwrap(); + fs::hard_link(&target, &preferences).unwrap(); + assert!(store.preferences().is_err()); + fs::remove_file(&preferences).unwrap(); + let path = CString::new(preferences.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + assert!(store.preferences().is_err()); + fs::remove_file(&preferences).unwrap(); + store.set_automatic(false).unwrap(); + fs::set_permissions(&preferences, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(store.preferences().is_err()); + fs::set_permissions(&preferences, fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(&preferences, b"{").unwrap(); + assert!(store.preferences().is_err()); + assert_eq!(fs::read(target).unwrap(), b"keep"); + } + + #[test] + fn root_alias_and_pointer_path_injection_never_reach_external_files() { + let temp = Temp::new(); + let alias = temp.0.join("alias"); + symlink(&temp.0, &alias).unwrap(); + assert!(Store::open(&alias).is_err()); + let store = Store::open(&temp.0).unwrap(); + store + .atomic_json( + "ready.json", + &serde_json::json!({"schema":1,"id":"../outside"}), + 4096, + ) + .unwrap(); + assert!(store.load().is_err()); + } + + #[test] + fn missing_archive_or_changed_size_cannot_be_loaded() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.publish(&record(), b"test").unwrap(); + let id = store.pointer().unwrap().unwrap().id; + let archive = temp.cache().join(format!("archive-{id}")); + fs::write(&archive, b"changed").unwrap(); + assert!(store.load().is_err()); + fs::remove_file(&archive).unwrap(); + assert!(store.load().is_err()); + } + + fn names(temp: &Temp) -> Vec { + let mut names: Vec<_> = fs::read_dir(temp.cache()) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + names + } + + fn assert_pair(temp: &Temp, id: &str, exists: bool) { + for name in [format!("archive-{id}"), format!("record-{id}.json")] { + assert_eq!(temp.cache().join(name).exists(), exists); + } + } + + #[test] + fn repeated_capacity_scans_see_new_crash_orphans_and_never_erase_them() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + // Exhausting one directory stream must not consume the next scan. + store.check_capacity(3).unwrap(); + for index in 0..6 { + fs::write(temp.cache().join(format!("crash-orphan-{index}")), b"keep").unwrap(); + } + let before = names(&temp); + for _ in 0..4 { + assert!(store.check_capacity(3).is_err()); + assert!(store.stage(&record(), b"test").is_err()); + assert_eq!(names(&temp), before); + } + assert!(store.load().unwrap().is_none()); + } + + #[test] + fn atomic_preferences_also_respect_the_capacity_ceiling() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.set_automatic(false).unwrap(); + for index in 0..MAX_CACHE_FILES - 1 { + fs::write(temp.cache().join(format!("orphan-{index}")), b"keep").unwrap(); + } + let before = names(&temp); + for _ in 0..3 { + assert!(store.set_automatic(true).is_err()); + assert!(!store.preferences().unwrap().automatic); + assert_eq!(names(&temp), before); + } + } + + #[test] + fn repeated_publications_and_preference_writes_remain_bounded() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + for index in 0..32 { + store.set_automatic(index % 2 == 0).unwrap(); + store.publish(&record(), b"test").unwrap(); + assert_eq!(store.load().unwrap().unwrap().1, b"test"); + assert_eq!(store.preferences().unwrap().automatic, index % 2 == 0); + assert_eq!(names(&temp).len(), 4); + for name in names(&temp) { + assert_eq!( + fs::metadata(temp.cache().join(name)).unwrap().mode() & 0o7777, + 0o600 + ); + } + } + } + + #[test] + fn dropped_and_discarded_stages_never_publish_or_accumulate() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + for explicit in [false, true] { + let stage = store.stage(&record(), b"test").unwrap(); + assert!(store.load().unwrap().is_none()); + assert_eq!(names(&temp).len(), 2); + if explicit { + store.discard(stage); + } else { + drop(stage); + } + assert!(names(&temp).is_empty()); + assert!(store.load().unwrap().is_none()); + } + store.publish(&record(), b"prev").unwrap(); + let previous = store.pointer().unwrap().unwrap().id; + let staged = store.stage(&record(), b"next").unwrap(); + store.set_automatic(false).unwrap(); + store.discard(staged); + assert_eq!(store.pointer().unwrap().unwrap().id, previous); + assert_eq!(store.load().unwrap().unwrap().1, b"prev"); + assert!(!store.preferences().unwrap().automatic); + } + + #[test] + fn cross_store_commit_cannot_publish_or_delete_another_stores_pair() { + let source = Temp::new(); + let target = Temp::new(); + let source_store = Store::open(&source.0).unwrap(); + let target_store = Store::open(&target.0).unwrap(); + target_store.publish(&record(), b"prev").unwrap(); + let target_before = names(&target); + let stage = source_store.stage(&record(), b"next").unwrap(); + assert!(target_store.commit(stage).is_err()); + assert!(names(&source).is_empty()); + assert_eq!(names(&target), target_before); + assert_eq!(target_store.load().unwrap().unwrap().1, b"prev"); + } + + #[test] + fn stage_sync_failure_keeps_previous_pointer_and_cleans_unpublished_pair() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.publish(&record(), b"prev").unwrap(); + let before = names(&temp); + *store.fail_sync.lock().unwrap() = Some(SyncPoint::Stage); + assert!(store.stage(&record(), b"next").is_err()); + assert_eq!(names(&temp), before); + assert_eq!(store.load().unwrap().unwrap().1, b"prev"); + } + + #[test] + fn failure_before_pointer_rename_discards_new_stage_only() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.publish(&record(), b"prev").unwrap(); + let old = store.pointer().unwrap().unwrap().id; + let stage = store.stage(&record(), b"next").unwrap(); + let new = stage.pointer.id.clone(); + for index in 0..3 { + fs::write(temp.cache().join(format!("orphan-{index}")), b"keep").unwrap(); + } + assert!(store.commit(stage).is_err()); + assert_eq!(store.pointer().unwrap().unwrap().id, old); + assert_pair(&temp, &old, true); + assert_pair(&temp, &new, false); + assert_eq!(store.load().unwrap().unwrap().1, b"prev"); + } + + #[test] + fn pointer_sync_failure_retains_both_pairs_and_later_publications_stay_bounded() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.publish(&record(), b"prev").unwrap(); + let old = store.pointer().unwrap().unwrap().id; + let stage = store.stage(&record(), b"next").unwrap(); + let new = stage.pointer.id.clone(); + *store.fail_sync.lock().unwrap() = Some(SyncPoint::Publication); + assert!(store.commit(stage).is_err()); + // Visible rename is not an acknowledgement of crash durability. + assert_eq!(store.pointer().unwrap().unwrap().id, new); + assert_pair(&temp, &old, true); + assert_pair(&temp, &new, true); + assert_eq!(store.load().unwrap().unwrap().1, b"next"); + for _ in 0..8 { + store.publish(&record(), b"last").unwrap(); + assert_eq!(store.load().unwrap().unwrap().1, b"last"); + assert_pair(&temp, &old, true); // uncertain/crash orphan is not garbage-collected + assert_eq!(names(&temp).len(), 5); + } + assert_pair(&temp, &new, false); + } + + #[test] + fn retirement_sync_failure_does_not_erase_committed_generation() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.publish(&record(), b"prev").unwrap(); + let staged = store.stage(&record(), b"next").unwrap(); + let next = staged.pointer.id.clone(); + *store.fail_sync.lock().unwrap() = Some(SyncPoint::Retirement); + assert!(store.commit(staged).is_err()); + assert_pair(&temp, &next, true); + assert_eq!(store.load().unwrap().unwrap().1, b"next"); + store.publish(&record(), b"last").unwrap(); + assert_eq!(names(&temp).len(), 3); + } + + #[test] + fn uncertain_preference_sync_reports_failure_without_reverting_visible_opt_out() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + store.set_automatic(true).unwrap(); + *store.fail_sync.lock().unwrap() = Some(SyncPoint::Publication); + assert!(store.set_automatic(false).is_err()); + assert!(!store.preferences().unwrap().automatic); + store.set_automatic(false).unwrap(); + assert_eq!(names(&temp), ["preferences.json"]); + } + + #[test] + fn a_pointer_already_naming_the_stage_never_retires_that_same_pair() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + let stage = store.stage(&record(), b"test").unwrap(); + store + .atomic_json("ready.json", &stage.pointer, MAX_PREFERENCES_BYTES) + .unwrap(); + store.commit(stage).unwrap(); + assert_eq!(store.load().unwrap().unwrap().1, b"test"); + assert_eq!(names(&temp).len(), 3); + } + + #[test] + fn staged_file_aliases_fifos_changes_and_replacements_cannot_publish() { + for kind in ["archive", "record"] { + for tamper in ["bytes", "replace", "symlink", "hardlink", "fifo", "mode"] { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + let stage = store.stage(&record(), b"test").unwrap(); + let name = if kind == "archive" { + format!("archive-{}", stage.pointer.id) + } else { + format!("record-{}.json", stage.pointer.id) + }; + let path = temp.cache().join(name); + let saved = path.with_extension("saved"); + match tamper { + "bytes" => { + fs::write(&path, b"evil").unwrap(); + } + "replace" => { + let bytes = fs::read(&path).unwrap(); + fs::rename(&path, &saved).unwrap(); + fs::write(&path, bytes).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + "symlink" => { + fs::rename(&path, &saved).unwrap(); + symlink(&saved, &path).unwrap(); + } + "hardlink" => { + fs::hard_link(&path, &saved).unwrap(); + } + "fifo" => { + fs::rename(&path, &saved).unwrap(); + let name = CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + } + "mode" => { + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + } + _ => unreachable!(), + } + assert!(store.commit(stage).is_err(), "{kind} {tamper}"); + assert!(store.load().unwrap().is_none()); + if matches!(tamper, "replace" | "symlink" | "fifo") { + // A replaced entry belongs to whoever replaced it; Drop + // cannot erase it just because the name once belonged to us. + assert!(fs::symlink_metadata(path).is_ok()); + } + } + } + } + + #[test] + fn anchored_directory_and_cleanup_do_not_follow_a_replacement_cache_path() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + let saved = temp.0.join("original-cache"); + fs::rename(temp.cache(), &saved).unwrap(); + fs::create_dir(temp.cache()).unwrap(); + fs::set_permissions(temp.cache(), fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(temp.cache().join("sentinel"), b"keep").unwrap(); + let stage = store.stage(&record(), b"test").unwrap(); + assert_eq!(fs::read_dir(&saved).unwrap().count(), 2); + store.discard(stage); + assert_eq!(fs::read_dir(&saved).unwrap().count(), 0); + store.publish(&record(), b"next").unwrap(); + assert_eq!(store.load().unwrap().unwrap().1, b"next"); + assert_eq!(names(&temp), ["sentinel"]); + assert_eq!(fs::read(temp.cache().join("sentinel")).unwrap(), b"keep"); + } + + #[test] + fn changed_cache_permissions_and_malformed_preferences_are_not_hidden() { + let temp = Temp::new(); + let store = Store::open(&temp.0).unwrap(); + fs::set_permissions(temp.cache(), fs::Permissions::from_mode(0o777)).unwrap(); + assert!(store.preferences().is_err()); + assert!(store.stage(&record(), b"test").is_err()); + assert!(store.set_automatic(false).is_err()); + fs::set_permissions(temp.cache(), fs::Permissions::from_mode(0o700)).unwrap(); + store.set_automatic(false).unwrap(); + fs::write(temp.cache().join("preferences.json"), b"{").unwrap(); + assert!(store.set_automatic(true).is_err()); + assert_eq!( + fs::read(temp.cache().join("preferences.json")).unwrap(), + b"{" + ); + } + + #[test] + fn simultaneous_stage_reservations_cannot_overrun_capacity() { + let temp = Temp::new(); + let store = Arc::new(Store::open(&temp.0).unwrap()); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let workers: Vec<_> = (0..8) + .map(|_| { + let store = store.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + store.stage(&record(), b"test").ok() + }) + }) + .collect(); + let mut stages: Vec<_> = workers + .into_iter() + .filter_map(|worker| worker.join().unwrap()) + .collect(); + assert_eq!(stages.len(), 3); + assert_eq!(names(&temp).len(), 6); + store.set_automatic(false).unwrap(); + store.commit(stages.pop().unwrap()).unwrap(); + assert_eq!(names(&temp).len(), MAX_CACHE_FILES); + drop(stages); + assert_eq!(names(&temp).len(), 4); + assert!(!store.preferences().unwrap().automatic); + } + + #[test] + fn opt_out_can_persist_while_stage_is_paused_at_large_write_boundary() { + use std::{sync::mpsc, time::Duration}; + let temp = Temp::new(); + let store = Arc::new(Store::open(&temp.0).unwrap()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + *store.stage_pause.lock().unwrap() = Some((entered_tx, resume_rx)); + let writer_store = store.clone(); + let writer = std::thread::spawn(move || writer_store.stage(&record(), b"test")); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let (ack_tx, ack_rx) = mpsc::channel(); + let preference_store = store.clone(); + let preference = + std::thread::spawn(move || ack_tx.send(preference_store.set_automatic(false)).unwrap()); + let acknowledged = ack_rx.recv_timeout(Duration::from_secs(2)); + // Always unblock and join before asserting, including a regression that + // incorrectly holds the mutation lock across the slow-write phase. + resume_tx.send(()).unwrap(); + let stage = writer.join().unwrap().unwrap(); + preference.join().unwrap(); + assert!(acknowledged.unwrap().is_ok()); + store.discard(stage); // parent rejected this cancelled generation + assert!(!store.preferences().unwrap().automatic); + assert!(store.load().unwrap().is_none()); + assert_eq!(names(&temp), ["preferences.json"]); + } +} diff --git a/src-tauri/src/updater_transport.rs b/src-tauri/src/updater_transport.rs new file mode 100644 index 00000000..70e29990 --- /dev/null +++ b/src-tauri/src/updater_transport.rs @@ -0,0 +1,622 @@ +//! Bounded native HTTPS transport for updater preparation and isolated probes. +//! +//! The caller owns endpoint and fixture policy. This module only configures a +//! strict HTTPS client and streams successful responses into a hard byte cap. + +use std::time::Duration; + +use futures_util::StreamExt; +use reqwest::{ + header::HeaderMap, redirect::Policy, Certificate, Client, Response, StatusCode, Url, +}; + +pub const URL_CREDENTIALS_MESSAGE: &str = "request URL credentials are not allowed"; + +#[derive(Debug)] +pub enum TransportError { + Request(reqwest::Error), + Status(StatusCode), + Stream(reqwest::Error), + UrlCredentials, + ContentLengthExceeded { max_bytes: u64 }, + BodyExceeded { max_bytes: u64 }, + SizeOverflow, + InvalidHeader(&'static str), +} + +impl std::fmt::Display for TransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Request(error) | Self::Stream(error) => { + if error.is_timeout() { + f.write_str("updater request timed out") + } else { + f.write_str("updater request failed") + } + } + Self::Status(status) => write!(f, "updater HTTP status {}", status.as_u16()), + Self::UrlCredentials => f.write_str(URL_CREDENTIALS_MESSAGE), + Self::ContentLengthExceeded { max_bytes } | Self::BodyExceeded { max_bytes } => { + write!(f, "updater response exceeds {max_bytes} bytes") + } + Self::SizeOverflow => f.write_str("updater response size overflow"), + Self::InvalidHeader(name) => write!(f, "invalid updater {name} header"), + } + } +} + +pub struct HttpsClient { + client: Client, +} + +pub enum Accept { + GithubJson, + Archive, +} + +#[derive(Debug)] +pub struct BoundedResponse { + pub status: StatusCode, + pub body: Vec, + pub location: Option, + pub retry_after: Option, +} + +/// Return bounded metadata without following redirects. The preparation owner +/// must authorize each next URL and interpret status/Retry-After itself. +pub async fn fetch_response( + client: &HttpsClient, + url: &Url, + accept: Accept, + max_bytes: u64, +) -> Result { + reject_url_credentials(url)?; + let response = client + .client + .get(url.clone()) + .header( + reqwest::header::USER_AGENT, + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")), + ) + .header( + reqwest::header::ACCEPT, + match accept { + Accept::GithubJson => "application/vnd.github+json", + Accept::Archive => "application/octet-stream", + }, + ) + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|error| TransportError::Request(error.without_url()))?; + let status = response.status(); + let location = bounded_header(response.headers(), "location", 4096)?; + let retry_after = bounded_header(response.headers(), "retry-after", 128)?; + // Error/redirect bodies are not needed to make the policy decision. + // Dropping them also avoids buffering arbitrary error-page content. + let body = if status.is_success() { + read_response_bounded(response, max_bytes).await? + } else { + Vec::new() + }; + Ok(BoundedResponse { + status, + body, + location, + retry_after, + }) +} + +fn bounded_header( + headers: &HeaderMap, + name: &'static str, + max_bytes: usize, +) -> Result, TransportError> { + let mut values = headers.get_all(name).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(TransportError::InvalidHeader(name)); + } + let value = value + .to_str() + .map_err(|_| TransportError::InvalidHeader(name))?; + if value.is_empty() + || value.len() > max_bytes + || value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) + { + return Err(TransportError::InvalidHeader(name)); + } + Ok(Some(value.to_owned())) +} + +pub fn build_client( + extra_root_certificate: Option, + connect_timeout: Duration, + total_timeout: Duration, +) -> Result { + let mut builder = Client::builder() + .https_only(true) + .redirect(Policy::none()) + .connect_timeout(connect_timeout) + .timeout(total_timeout); + if let Some(certificate) = extra_root_certificate { + builder = builder.add_root_certificate(certificate); + } + builder.build().map(|client| HttpsClient { client }) +} + +// Compatibility entrypoints used by the isolated installer probe, not product +// preparation (which consumes bounded status/redirect metadata directly). +#[allow(dead_code)] +pub async fn fetch_manifest( + client: &HttpsClient, + endpoint: &Url, + max_bytes: u64, +) -> Result>, TransportError> { + let response = fetch_response(client, endpoint, Accept::GithubJson, max_bytes).await?; + if response.status == StatusCode::NO_CONTENT { + return Ok(None); + } + if !response.status.is_success() { + return Err(TransportError::Status(response.status)); + } + Ok(Some(response.body)) +} + +#[allow(dead_code)] +pub async fn fetch_bounded( + client: &HttpsClient, + url: &Url, + max_bytes: u64, +) -> Result, TransportError> { + let response = fetch_response(client, url, Accept::Archive, max_bytes).await?; + if !response.status.is_success() { + return Err(TransportError::Status(response.status)); + } + Ok(response.body) +} + +fn reject_url_credentials(url: &Url) -> Result<(), TransportError> { + if !url.username().is_empty() || url.password().is_some() { + return Err(TransportError::UrlCredentials); + } + Ok(()) +} + +async fn read_response_bounded( + response: Response, + max_bytes: u64, +) -> Result, TransportError> { + reject_advertised_length(response.content_length(), max_bytes)?; + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| TransportError::Stream(error.without_url()))?; + append_bounded_chunk(&mut body, &chunk, max_bytes)?; + } + Ok(body) +} + +fn reject_advertised_length( + content_length: Option, + max_bytes: u64, +) -> Result<(), TransportError> { + if content_length.is_some_and(|length| length > max_bytes) { + return Err(TransportError::ContentLengthExceeded { max_bytes }); + } + Ok(()) +} + +fn append_bounded_chunk( + body: &mut Vec, + chunk: &[u8], + max_bytes: u64, +) -> Result<(), TransportError> { + let new_len = body + .len() + .checked_add(chunk.len()) + .ok_or(TransportError::SizeOverflow)?; + if new_len as u64 > max_bytes { + return Err(TransportError::BodyExceeded { max_bytes }); + } + body.extend_from_slice(chunk); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct PrivateHttpsFixture { + root: std::path::PathBuf, + server: Option, + reader: Option>, + } + + impl PrivateHttpsFixture { + fn new() -> Self { + use std::{ + fs, + os::unix::fs::PermissionsExt, + process::{Command, Stdio}, + }; + let output = Command::new("mktemp") + .arg("-d") + .arg(std::env::temp_dir().join("gajae-private-https.XXXXXX")) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .expect("mktemp is required for the private HTTPS fixture"); + assert!( + output.status.success(), + "could not create private HTTPS fixture" + ); + let root = + fs::canonicalize(std::str::from_utf8(&output.stdout).unwrap().trim()).unwrap(); + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + let fixture = Self { + root, + server: None, + reader: None, + }; + fs::write( + fixture.root.join("openssl.cnf"), + "[req]\ndistinguished_name=dn\n[dn]\n", + ) + .unwrap(); + fixture.openssl(&[ + "req", + "-x509", + "-newkey", + "ec", + "-pkeyopt", + "ec_paramgen_curve:P-256", + "-nodes", + "-keyout", + "ca-key.pem", + "-out", + "updater-ca.pem", + "-days", + "1", + "-sha256", + "-subj", + "/CN=Disposable Gajae HTTPS CA", + "-config", + "openssl.cnf", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ]); + fixture.openssl(&[ + "req", + "-new", + "-newkey", + "ec", + "-pkeyopt", + "ec_paramgen_curve:P-256", + "-nodes", + "-keyout", + "server-key.pem", + "-out", + "server.csr", + "-sha256", + "-subj", + "/CN=Disposable Gajae HTTPS Server", + "-config", + "openssl.cnf", + ]); + fs::write( + fixture.root.join("server.ext"), + concat!( + "basicConstraints=critical,CA:FALSE\n", + "keyUsage=critical,digitalSignature\n", + "extendedKeyUsage=serverAuth\n", + "subjectAltName=IP:127.0.0.1\n", + ), + ) + .unwrap(); + fixture.openssl(&[ + "x509", + "-req", + "-in", + "server.csr", + "-CA", + "updater-ca.pem", + "-CAkey", + "ca-key.pem", + "-set_serial", + "2", + "-days", + "1", + "-sha256", + "-out", + "server.pem", + "-extfile", + "server.ext", + ]); + for entry in fs::read_dir(&fixture.root).unwrap() { + fs::set_permissions(entry.unwrap().path(), fs::Permissions::from_mode(0o600)) + .unwrap(); + } + fixture + } + + fn openssl(&self, args: &[&str]) { + use std::process::{Command, Stdio}; + let status = Command::new("openssl") + .current_dir(&self.root) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("openssl is required for the private HTTPS fixture"); + assert!( + status.success(), + "private HTTPS certificate generation failed" + ); + } + + fn start(&mut self) -> (Url, std::sync::mpsc::Receiver) { + use std::{ + io::BufRead, + process::{Command, Stdio}, + sync::mpsc, + }; + // TLS wraps each accepted socket before any HTTP read. Only bounded + // counters/status are emitted; neither key material nor URLs leak. + let script = r#" +import pathlib, socket, ssl, sys +root = pathlib.Path(sys.argv[1]) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.minimum_version = ssl.TLSVersion.TLSv1_2 +context.load_cert_chain(root / 'server.pem', root / 'server-key.pem') +with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + listener.listen(2) + listener.settimeout(10) + print('READY', listener.getsockname()[1], flush=True) + rejected, http = 0, 0 + for _ in range(2): + raw, _ = listener.accept() + raw.settimeout(3) + try: + connection = context.wrap_socket(raw, server_side=True) + except ssl.SSLError: + raw.close() + rejected += 1 + print('TLS_REJECTED', http, flush=True) + continue + with connection: + request = b'' + while b'\r\n\r\n' not in request: + chunk = connection.recv(1024) + if not chunk or len(request) + len(chunk) > 8192: + raise RuntimeError('missing or oversized fixture request') + request += chunk + if not request.startswith(b'GET /fixture HTTP/1.1\r\n'): + raise RuntimeError('unexpected fixture request') + http += 1 + body = b'{"fixture":"private-ca"}' + connection.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: ' + str(len(body)).encode() + b'\r\nConnection: close\r\n\r\n' + body) + print('DONE', rejected, http, flush=True) +"#; + let mut child = Command::new("python3") + .args(["-I", "-u", "-c", script]) + .arg(&self.root) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("python3 is required for the local HTTPS fixture"); + let stdout = child.stdout.take().unwrap(); + self.server = Some(child); + let (send, receive) = mpsc::channel(); + self.reader = Some(std::thread::spawn(move || { + for line in std::io::BufReader::new(stdout).lines() { + let Ok(line) = line else { + break; + }; + if send.send(line).is_err() { + break; + } + } + })); + let ready = receive + .recv_timeout(Duration::from_secs(5)) + .expect("private HTTPS server did not start"); + let port: u16 = ready.strip_prefix("READY ").unwrap().parse().unwrap(); + ( + Url::parse(&format!("https://127.0.0.1:{port}/fixture")).unwrap(), + receive, + ) + } + } + + impl Drop for PrivateHttpsFixture { + fn drop(&mut self) { + if let Some(child) = self.server.as_mut() { + if !matches!(child.try_wait(), Ok(Some(_))) { + let _ = child.kill(); + } + let _ = child.wait(); + } + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + let _ = std::fs::remove_dir_all(&self.root); + } + } + + #[test] + fn private_ca_https_succeeds_but_default_client_rejects_before_http() { + let mut fixture = PrivateHttpsFixture::new(); + let (url, events) = fixture.start(); + let default = build_client(None, Duration::from_secs(2), Duration::from_secs(4)).unwrap(); + let error = tauri::async_runtime::block_on(fetch_bounded(&default, &url, 64)).unwrap_err(); + match error { + TransportError::Request(error) => { + assert!(error.is_connect()); + assert!(!error.is_timeout()); + assert!( + format!("{error:?}").contains("UnknownIssuer"), + "default client must reject the private issuer" + ); + } + _ => panic!("default client must fail TLS, not receive an HTTP response"), + } + // The server observed a failed TLS handshake and zero HTTP requests. + assert_eq!( + events.recv_timeout(Duration::from_secs(5)).unwrap(), + "TLS_REJECTED 0" + ); + let pem = std::fs::read(fixture.root.join("updater-ca.pem")).unwrap(); + let custom = build_client( + Some(Certificate::from_pem(&pem).unwrap()), + Duration::from_secs(2), + Duration::from_secs(4), + ) + .unwrap(); + let body = tauri::async_runtime::block_on(fetch_bounded(&custom, &url, 64)).unwrap(); + assert_eq!(body, br#"{"fixture":"private-ca"}"#); + assert_eq!( + events.recv_timeout(Duration::from_secs(5)).unwrap(), + "DONE 1 1" + ); + assert!(fixture.server.as_mut().unwrap().wait().unwrap().success()); + + // Adding a private root does not relax the existing HTTPS-only policy. + let mut plain = url; + plain.set_scheme("http").unwrap(); + assert!(matches!( + tauri::async_runtime::block_on(fetch_bounded(&custom, &plain, 64)), + Err(TransportError::Request(error)) if error.is_builder() + )); + } + + #[test] + fn response_headers_reject_ambiguous_oversized_and_control_values() { + let mut headers = HeaderMap::new(); + assert_eq!(bounded_header(&headers, "location", 4).unwrap(), None); + headers.insert("location", "abcd".parse().unwrap()); + assert_eq!( + bounded_header(&headers, "location", 4).unwrap().as_deref(), + Some("abcd") + ); + assert!(bounded_header(&headers, "location", 3).is_err()); + headers.append("location", "next".parse().unwrap()); + assert!(matches!( + bounded_header(&headers, "location", 4096), + Err(TransportError::InvalidHeader("location")) + )); + headers.insert("retry-after", "12\t3".parse().unwrap()); + assert!(bounded_header(&headers, "retry-after", 128).is_err()); + headers.insert("retry-after", "120".parse().unwrap()); + assert_eq!( + bounded_header(&headers, "retry-after", 128) + .unwrap() + .as_deref(), + Some("120") + ); + } + + #[test] + fn metadata_requests_refuse_credentials_and_redact_delivery_tokens() { + let client = build_client(None, Duration::from_secs(1), Duration::from_secs(1)).unwrap(); + let credentialed = Url::parse("https://secret@192.0.2.1/").unwrap(); + assert!(matches!( + tauri::async_runtime::block_on(fetch_response( + &client, + &credentialed, + Accept::GithubJson, + 4, + )), + Err(TransportError::UrlCredentials) + )); + let endpoint = + Url::parse("https://127.0.0.1:1/archive?token=DELIVERY_TOKEN_SENTINEL").unwrap(); + let error = + tauri::async_runtime::block_on(fetch_response(&client, &endpoint, Accept::Archive, 4)) + .unwrap_err(); + match error { + TransportError::Request(error) => { + assert!(error.url().is_none()); + assert!(!error.to_string().contains("DELIVERY_TOKEN_SENTINEL")); + } + other => panic!("expected a redacted request error, got {other:?}"), + } + } + + #[test] + fn strict_client_rejects_plain_http_before_connecting() { + let client = build_client(None, Duration::from_secs(1), Duration::from_secs(1)).unwrap(); + let endpoint = Url::parse("http://127.0.0.1:1/").unwrap(); + let error = + tauri::async_runtime::block_on(fetch_bounded(&client, &endpoint, 4)).unwrap_err(); + match error { + TransportError::Request(error) => assert!(error.is_builder()), + error => panic!("expected HTTPS policy refusal, got {error:?}"), + } + } + + #[test] + fn manifest_credentials_are_rejected_before_network_access() { + let client = build_client(None, Duration::from_secs(1), Duration::from_secs(1)).unwrap(); + for credentials in ["qa-user:qa-secret", "qa-user", ":qa-secret", "qa-user:"] { + let endpoint = + Url::parse(&format!("https://{credentials}@192.0.2.1/update.json")).unwrap(); + let error = + tauri::async_runtime::block_on(fetch_manifest(&client, &endpoint, 4)).unwrap_err(); + assert!(matches!(error, TransportError::UrlCredentials)); + assert!(!format!("{error:?}").contains("qa-")); + } + } + + #[test] + fn archive_credentials_are_rejected_before_network_access() { + let client = build_client(None, Duration::from_secs(1), Duration::from_secs(1)).unwrap(); + for credentials in ["qa-user:qa-secret", "qa-user", ":qa-secret", "qa-user:"] { + let archive = + Url::parse(&format!("https://{credentials}@192.0.2.1/B.app.tar.gz")).unwrap(); + let error = + tauri::async_runtime::block_on(fetch_bounded(&client, &archive, 4)).unwrap_err(); + assert!(matches!(error, TransportError::UrlCredentials)); + assert!(!format!("{error:?}").contains("qa-")); + } + } + + #[test] + fn stream_cap_is_enforced_for_absent_lying_and_oversized_lengths() { + reject_advertised_length(None, 4).unwrap(); + reject_advertised_length(Some(4), 4).unwrap(); + assert!(matches!( + reject_advertised_length(Some(5), 4), + Err(TransportError::ContentLengthExceeded { max_bytes: 4 }) + )); + + let mut body = Vec::new(); + append_bounded_chunk(&mut body, b"12", 4).unwrap(); + // A lying Content-Length of one byte cannot disable the cumulative cap + // once the stream has supplied more bytes. + reject_advertised_length(Some(1), 4).unwrap(); + assert!(matches!( + append_bounded_chunk(&mut body, b"345", 4), + Err(TransportError::BodyExceeded { max_bytes: 4 }) + )); + assert_eq!(body, b"12"); + + let mut absent_length_body = Vec::new(); + assert!(matches!( + append_bounded_chunk(&mut absent_length_body, b"12345", 4), + Err(TransportError::BodyExceeded { max_bytes: 4 }) + )); + assert!(absent_length_body.is_empty()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d862acd3..b5c7584a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -55,7 +55,7 @@ "icons/512x512@2x.png" ], "macOS": { - "minimumSystemVersion": "11.0", + "minimumSystemVersion": "13.0", "signingIdentity": "-", "entitlements": "entitlements.plist" } diff --git a/src-tauri/tests/update_build_binding.rs b/src-tauri/tests/update_build_binding.rs new file mode 100644 index 00000000..b5141591 --- /dev/null +++ b/src-tauri/tests/update_build_binding.rs @@ -0,0 +1,456 @@ +#![cfg(target_os = "macos")] + +#[path = "../update_build_binding.rs"] +// Build-script-only entrypoints are intentionally unused by this policy harness. +#[allow(dead_code)] +mod binding; + +use std::{ + fs, + os::unix::fs::{symlink, PermissionsExt}, + path::{Path, PathBuf}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sha2::{Digest, Sha256}; + +use binding::{ + BuildInputs, PackageMetadata, UpdateMode, UPDATE_MODE_DISABLED, UPDATE_MODE_PRODUCTION, + UPDATE_MODE_QA, +}; + +struct TempRoot(PathBuf); + +impl TempRoot { + fn new() -> Self { + let temp = fs::canonicalize(std::env::temp_dir()).unwrap(); + // Wall-clock resolution is not uniqueness under parallel test execution. + let mut entropy = [0; 16]; + getrandom::getrandom(&mut entropy).unwrap(); + let id = u128::from_ne_bytes(entropy); + let path = temp.join(format!("gajae-update-binding-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } + + fn child(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn package() -> PackageMetadata { + PackageMetadata::from_parts( + "gajae-app", + "2.0.0-beta.9", + "0.2.3", + "https://github.com/devswha/gajae-code-app", + "git+https://github.com/devswha/gajae-code-app.git", + ) +} + +fn key_config() -> String { + let mut record = [0u8; 42]; + record[..2].copy_from_slice(b"Ed"); + for (index, byte) in record[2..].iter_mut().enumerate() { + *byte = (index as u8).wrapping_add(1); + } + let text = format!( + "untrusted comment: minisign public key: fixture\n{}\n", + STANDARD.encode(record) + ); + STANDARD.encode(text) +} + +fn expected_fingerprint() -> String { + let mut record = [0u8; 42]; + record[..2].copy_from_slice(b"Ed"); + for (index, byte) in record[2..].iter_mut().enumerate() { + *byte = (index as u8).wrapping_add(1); + } + let digest = Sha256::digest(record); + format!("{digest:x}") +} + +fn key_with_record(record: &[u8]) -> String { + let text = format!( + "untrusted comment: minisign public key: fixture\n{}\n", + STANDARD.encode(record) + ); + STANDARD.encode(text) +} + +fn inputs(temp_root: &Path) -> BuildInputs { + BuildInputs { + target_os: "macos".into(), + debug: false, + feed_origin: None, + mode: None, + pubkey: None, + qa_root: None, + temp_root: Some(temp_root.to_owned()), + } +} + +fn production_inputs(temp_root: &Path) -> BuildInputs { + BuildInputs { + target_os: "macos".into(), + debug: false, + feed_origin: Some("https://api.github.com".into()), + mode: Some(UPDATE_MODE_PRODUCTION.into()), + pubkey: Some(key_config()), + qa_root: None, + temp_root: Some(temp_root.to_owned()), + } +} + +fn qa_inputs(temp_root: &Path, qa_root: PathBuf) -> BuildInputs { + BuildInputs { + target_os: "macos".into(), + debug: false, + feed_origin: Some("https://127.0.0.1:43123".into()), + mode: Some(UPDATE_MODE_QA.into()), + pubkey: Some(key_config()), + qa_root: Some(qa_root), + temp_root: Some(temp_root.to_owned()), + } +} + +#[test] +fn disabled_mode_is_explicitly_empty_and_unknown_or_partial_modes_fail() { + let temp = TempRoot::new(); + let disabled = binding::validate(&package(), &inputs(&temp.0)).unwrap(); + assert_eq!(disabled.mode, UpdateMode::Disabled); + assert_eq!(disabled.feed_origin_value(), ""); + assert_eq!(disabled.pubkey_value(), ""); + assert_eq!(disabled.qa_root_value(), ""); + + for partial in [ + BuildInputs { + feed_origin: Some(String::new()), + ..inputs(&temp.0) + }, + BuildInputs { + pubkey: Some(String::new()), + ..inputs(&temp.0) + }, + BuildInputs { + qa_root: Some(temp.child("root")), + ..inputs(&temp.0) + }, + BuildInputs { + mode: Some(UPDATE_MODE_DISABLED.into()), + feed_origin: Some("https://api.github.com".into()), + ..inputs(&temp.0) + }, + ] { + assert!(binding::validate(&package(), &partial).is_err()); + } + let unknown = BuildInputs { + mode: Some("staging".into()), + ..inputs(&temp.0) + }; + assert!(binding::validate(&package(), &unknown).is_err()); +} + +#[test] +fn nonmac_targets_are_disabled_and_reject_explicit_enablement() { + let temp = TempRoot::new(); + let mut disabled = inputs(&temp.0); + disabled.target_os = "linux".into(); + assert_eq!( + binding::validate(&package(), &disabled).unwrap().mode, + UpdateMode::Disabled + ); + for mode in [UPDATE_MODE_PRODUCTION, UPDATE_MODE_QA] { + let enabled = BuildInputs { + mode: Some(mode.into()), + ..disabled.clone() + }; + assert!(binding::validate(&package(), &enabled).is_err()); + } +} + +#[test] +fn production_requires_exact_origin_key_and_release_build() { + let temp = TempRoot::new(); + let valid = binding::validate(&package(), &production_inputs(&temp.0)).unwrap(); + assert_eq!(valid.mode, UpdateMode::Production); + assert_eq!(valid.repository, "devswha/gajae-code-app"); + assert_eq!(valid.artifact_prefix, "gajae-app-"); + assert_eq!(valid.key_fingerprint_value(), expected_fingerprint()); + + let mut debug = production_inputs(&temp.0); + debug.debug = true; + assert!(binding::validate(&package(), &debug).is_err()); + + for origin in [ + "https://api.github.com/", + "https://api.github.com/repos", + "http://api.github.com", + "https://user:pass@api.github.com", + "https://api.github.com?x=1", + "https://api.github.com#fragment", + ] { + let invalid = BuildInputs { + feed_origin: Some(origin.into()), + ..production_inputs(&temp.0) + }; + assert!(binding::validate(&package(), &invalid).is_err()); + } + let with_qa_root = BuildInputs { + qa_root: Some(temp.child("qa")), + ..production_inputs(&temp.0) + }; + assert!(binding::validate(&package(), &with_qa_root).is_err()); +} + +#[test] +fn public_key_rejects_invalid_private_oversized_and_control_records() { + let temp = TempRoot::new(); + let mut invalid = production_inputs(&temp.0); + let mut wrong_length_record = vec![0u8; 43]; + wrong_length_record[..2].copy_from_slice(b"Ed"); + let mut wrong_algorithm_record = [0u8; 42]; + wrong_algorithm_record[..2].copy_from_slice(b"XX"); + for key in [ + "%%%".to_owned(), + STANDARD.encode("untrusted comment: minisign encrypted secret key: fixture\n"), + key_with_record(&wrong_length_record), + key_with_record(&wrong_algorithm_record), + STANDARD.encode(format!( + "untrusted comment: minisign public key: fixture\0\n{}\n", + STANDARD.encode([b'E', b'd']) + )), + "A".repeat(16 * 1024 + 1), + format!("{}\n", key_config()), + ] { + invalid.pubkey = Some(key); + let error = binding::validate(&package(), &invalid).unwrap_err(); + assert!(!error.contains("fixture")); + } +} + +#[test] +fn qa_requires_exact_local_origin_and_existing_canonical_private_root() { + let temp = TempRoot::new(); + let root = temp.child("qa"); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let valid = binding::validate(&package(), &qa_inputs(&temp.0, root.clone())).unwrap(); + assert_eq!(valid.mode, UpdateMode::Qa); + assert_eq!(valid.qa_root.as_deref(), Some(root.as_path())); + + let missing_root = BuildInputs { + qa_root: None, + ..qa_inputs(&temp.0, root.clone()) + }; + assert!(binding::validate(&package(), &missing_root).is_err()); + assert!(binding::validate(&package(), &qa_inputs(&temp.0, temp.child("missing"))).is_err()); + assert!(binding::validate(&package(), &qa_inputs(&temp.0, temp.0.clone())).is_err()); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(binding::validate(&package(), &qa_inputs(&temp.0, root.clone())).is_err()); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + + let alias = temp.child("alias"); + symlink(&root, &alias).unwrap(); + assert!(binding::validate(&package(), &qa_inputs(&temp.0, alias)).is_err()); + + for origin in [ + "https://127.0.0.1:0", + "https://127.0.0.1:01", + "https://127.0.0.1:65536", + "https://127.0.0.1:43123/", + "https://127.0.0.1:43123/path", + "https://user@127.0.0.1:43123", + "https://127.0.0.2:43123", + "http://127.0.0.1:43123", + "https://127.0.0.1:43123?x=1", + "https://127.0.0.1:43123#x", + ] { + let invalid = BuildInputs { + feed_origin: Some(origin.into()), + ..qa_inputs(&temp.0, root.clone()) + }; + assert!(binding::validate(&package(), &invalid).is_err()); + } +} + +#[test] +fn package_metadata_repository_must_match_and_versions_must_be_semver() { + let temp = TempRoot::new(); + let mut mismatch = package(); + mismatch.repository_url = "git+https://github.com/other/repo.git".into(); + assert!(binding::validate(&mismatch, &inputs(&temp.0)).is_err()); + let mut invalid_version = package(); + invalid_version.product_version = "not-semver".into(); + assert!(binding::validate(&invalid_version, &inputs(&temp.0)).is_err()); + let mut invalid_homepage = package(); + invalid_homepage.homepage = "https://github.com/devswha/gajae-code-app/".into(); + assert!(binding::validate(&invalid_homepage, &inputs(&temp.0)).is_err()); +} + +#[test] +fn qa_certificate_is_public_bounded_private_and_unaliased() { + let temp = qa_certificate_root(); + let path = temp.child("updater-ca.pem"); + assert!(binding::read_qa_certificate(&temp.0).is_err()); + let pem = qa_certificate_fixture(&temp); + assert_eq!( + binding::read_qa_certificate(&temp.0).unwrap(), + pem.as_bytes() + ); + fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).unwrap(); + assert!(binding::read_qa_certificate(&temp.0).is_ok()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(binding::read_qa_certificate(&temp.0).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + for text in [ + "-----BEGIN CERTIFICATE-----\nMAA=\n-----END CERTIFICATE-----\n".into(), + "-----BEGIN PRIVATE KEY-----\nMAA=\n-----END PRIVATE KEY-----".into(), + format!("{pem}{pem}"), + "x".repeat(65537), + ] { + fs::write(&path, text).unwrap(); + assert!(binding::read_qa_certificate(&temp.0).is_err()); + } + fs::remove_file(&path).unwrap(); + let target = temp.child("certificate-original"); + fs::write(&target, &pem).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &path).unwrap(); + assert!(binding::read_qa_certificate(&temp.0).is_err()); + fs::remove_file(&path).unwrap(); + fs::hard_link(&target, &path).unwrap(); + assert!(binding::read_qa_certificate(&temp.0).is_err()); + fs::remove_file(&path).unwrap(); + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + let fifo = CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + assert!(binding::read_qa_certificate(&temp.0).is_err()); +} + +// Only disposable keys in an already-private fixture root. OpenSSL output is +// deliberately suppressed; no private key or certificate contents enter logs. +fn qa_certificate_root() -> TempRoot { + use std::process::{Command, Stdio}; + let output = Command::new("mktemp") + .arg("-d") + .arg(std::env::temp_dir().join("gajae-qa-certificate.XXXXXX")) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .expect("mktemp is required for the isolated CA fixture"); + assert!( + output.status.success(), + "could not create private CA fixture" + ); + let root = fs::canonicalize(std::str::from_utf8(&output.stdout).unwrap().trim()).unwrap(); + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + TempRoot(root) +} + +fn qa_certificate_fixture(temp: &TempRoot) -> String { + use std::process::{Command, Stdio}; + fs::write( + temp.child("openssl.cnf"), + "[req]\ndistinguished_name=dn\n[dn]\n", + ) + .unwrap(); + let status = Command::new("openssl") + .current_dir(&temp.0) + .args([ + "req", + "-x509", + "-newkey", + "ec", + "-pkeyopt", + "ec_paramgen_curve:P-256", + "-nodes", + "-keyout", + "ca-key.pem", + "-out", + "updater-ca.pem", + "-days", + "1", + "-sha256", + "-subj", + "/CN=Disposable Gajae QA Certificate", + "-config", + "openssl.cnf", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("openssl is required for the isolated QA certificate test"); + assert!( + status.success(), + "disposable QA certificate generation failed" + ); + for name in ["ca-key.pem", "updater-ca.pem"] { + fs::set_permissions(temp.child(name), fs::Permissions::from_mode(0o600)).unwrap(); + } + fs::read_to_string(temp.child("updater-ca.pem")).unwrap() +} + +#[test] +fn qa_certificate_rejects_truncated_or_trailing_der_not_just_wrong_pem() { + use rustls_pki_types::{pem::PemObject, CertificateDer}; + let temp = qa_certificate_root(); + let pem = qa_certificate_fixture(&temp); + let certificate = CertificateDer::from_pem_slice(pem.as_bytes()).unwrap(); + let der = certificate.as_ref(); + let mut trailing = der.to_vec(); + trailing.push(0); + for malformed in [ + &der[..1], + &der[..der.len() / 2], + &der[..der.len() - 1], + trailing.as_slice(), + b"\x30\x00", + ] { + let pem = format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + STANDARD.encode(malformed) + ); + fs::write(temp.child("updater-ca.pem"), pem).unwrap(); + assert!(binding::read_qa_certificate(&temp.0).is_err()); + } +} + +#[test] +fn qa_certificate_parsing_does_not_claim_self_signature_verification() { + use rustls_pki_types::{pem::PemObject, CertificateDer}; + let temp = qa_certificate_root(); + let pem = qa_certificate_fixture(&temp); + let mut der = CertificateDer::from_pem_slice(pem.as_bytes()) + .unwrap() + .as_ref() + .to_vec(); + // Change the signature value without changing X.509/DER structure. Trust + // anchor extraction intentionally does not authenticate this self-signature. + *der.last_mut().unwrap() ^= 1; + let pem = format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + STANDARD.encode(der) + ); + fs::write(temp.child("updater-ca.pem"), &pem).unwrap(); + assert_eq!( + binding::read_qa_certificate(&temp.0).unwrap(), + pem.as_bytes() + ); +} diff --git a/src-tauri/update_build_binding.rs b/src-tauri/update_build_binding.rs new file mode 100644 index 00000000..4161aeb6 --- /dev/null +++ b/src-tauri/update_build_binding.rs @@ -0,0 +1,568 @@ +//! Build-time binding for the optional desktop updater. +//! +//! This module is included by `build.rs` and by the focused integration tests. +//! It validates explicit inputs before Cargo emits any runtime constants. No +//! updater client, filesystem writer, installer, or release operation belongs +//! here. +use std::{ + env, fs, + io::Read, + path::{Path, PathBuf}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub const UPDATE_MODE_ENV: &str = "GJC_UPDATE_MODE"; +pub const UPDATE_FEED_ORIGIN_ENV: &str = "GJC_UPDATE_FEED_ORIGIN"; +pub const UPDATE_PUBKEY_ENV: &str = "GJC_UPDATE_PUBKEY"; +pub const UPDATE_QA_ROOT_ENV: &str = "GJC_UPDATE_QA_ROOT"; +pub const INPUT_ENV_NAMES: [&str; 4] = [ + UPDATE_MODE_ENV, + UPDATE_FEED_ORIGIN_ENV, + UPDATE_PUBKEY_ENV, + UPDATE_QA_ROOT_ENV, +]; + +pub const UPDATE_MODE_DISABLED: &str = "disabled"; +pub const UPDATE_MODE_PRODUCTION: &str = "production"; +pub const UPDATE_MODE_QA: &str = "qa"; +const PUBLIC_KEY_CONFIG_LIMIT: usize = 16 * 1024; +const PUBLIC_KEY_RECORD_LENGTH: usize = 42; +const PUBLIC_KEY_COMMENT_PREFIX: &str = "untrusted comment: minisign public key: "; +const PRODUCTION_FEED_ORIGIN: &str = "https://api.github.com"; +const QA_HOST: &str = "127.0.0.1"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageMetadata { + pub name: String, + pub product_version: String, + pub desktop_version: String, + pub homepage: String, + pub repository_url: String, +} + +impl PackageMetadata { + pub fn from_json(package: &Value) -> Result { + let object = package + .as_object() + .ok_or_else(|| "package.json must contain a top-level object".to_owned())?; + let string = |field: &str| { + object + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("package.json must contain a non-empty {field} string")) + }; + let repository = object + .get("repository") + .and_then(Value::as_object) + .ok_or_else(|| "package.json repository must be an object".to_owned())?; + let repository_url = repository + .get("url") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| "package.json repository.url must be a non-empty string".to_owned())?; + Ok(Self { + name: string("name")?, + product_version: string("version")?, + desktop_version: string("desktopVersion")?, + homepage: string("homepage")?, + repository_url, + }) + } + + #[cfg(test)] + pub fn from_parts( + name: impl Into, + product_version: impl Into, + desktop_version: impl Into, + homepage: impl Into, + repository_url: impl Into, + ) -> Self { + Self { + name: name.into(), + product_version: product_version.into(), + desktop_version: desktop_version.into(), + homepage: homepage.into(), + repository_url: repository_url.into(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BuildInputs { + pub target_os: String, + pub debug: bool, + pub feed_origin: Option, + pub mode: Option, + pub pubkey: Option, + pub qa_root: Option, + pub temp_root: Option, +} + +impl BuildInputs { + pub fn from_env( + target_os: impl Into, + debug: bool, + temp_root: Option, + ) -> Result { + Ok(Self { + target_os: target_os.into(), + debug, + feed_origin: env_value(UPDATE_FEED_ORIGIN_ENV)?, + mode: env_value(UPDATE_MODE_ENV)?, + pubkey: env_value(UPDATE_PUBKEY_ENV)?, + qa_root: env_value(UPDATE_QA_ROOT_ENV)?.map(PathBuf::from), + temp_root, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpdateMode { + Disabled, + Production, + Qa, +} + +impl UpdateMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Disabled => UPDATE_MODE_DISABLED, + Self::Production => UPDATE_MODE_PRODUCTION, + Self::Qa => UPDATE_MODE_QA, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuildBinding { + pub mode: UpdateMode, + pub feed_origin: Option, + pub pubkey: Option, + pub qa_root: Option, + pub key_fingerprint: Option, + pub repository: String, + pub artifact_prefix: String, +} + +impl BuildBinding { + pub fn feed_origin_value(&self) -> &str { + self.feed_origin.as_deref().unwrap_or_default() + } + + pub fn pubkey_value(&self) -> &str { + self.pubkey.as_deref().unwrap_or_default() + } + + pub fn qa_root_value(&self) -> &str { + match self.qa_root.as_deref() { + Some(path) => path + .to_str() + .expect("validated QA root must be UTF-8 for compile-time binding"), + None => "", + } + } + + pub fn key_fingerprint_value(&self) -> &str { + self.key_fingerprint.as_deref().unwrap_or_default() + } +} + +/// Validate package identity and explicit update inputs without reading or +/// mutating any update state. `temp_root` in `inputs` is supplied explicitly so +/// tests never need to mutate process-wide environment variables. +pub fn validate(package: &PackageMetadata, inputs: &BuildInputs) -> Result { + let repository = derive_repository(&package.homepage, &package.repository_url)?; + let artifact_prefix = artifact_prefix(&package.name)?; + semver::Version::parse(&package.product_version) + .map_err(|_| "package.json version must be valid SemVer".to_owned())?; + semver::Version::parse(&package.desktop_version) + .map_err(|_| "package.json desktopVersion must be valid SemVer".to_owned())?; + reject_control_fields(inputs)?; + + let mode = match inputs.mode.as_deref() { + None => UpdateMode::Disabled, + Some(UPDATE_MODE_DISABLED) => UpdateMode::Disabled, + Some(UPDATE_MODE_PRODUCTION) => UpdateMode::Production, + Some(UPDATE_MODE_QA) => UpdateMode::Qa, + Some(_) => return Err("GJC_UPDATE_MODE is unknown".to_owned()), + }; + let has_extra_input = + inputs.feed_origin.is_some() || inputs.pubkey.is_some() || inputs.qa_root.is_some(); + if mode == UpdateMode::Disabled { + if has_extra_input { + return Err( + "disabled update mode cannot include feed, public-key, or QA-root input".to_owned(), + ); + } + return Ok(BuildBinding { + mode, + feed_origin: None, + pubkey: None, + qa_root: None, + key_fingerprint: None, + repository, + artifact_prefix, + }); + } + if inputs.target_os != "macos" { + return Err("updater modes other than disabled are supported only on macOS".to_owned()); + } + let feed_origin = inputs + .feed_origin + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "updater feed origin is required".to_owned())?; + let public_key = inputs + .pubkey + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "updater public key is required".to_owned())?; + let (public_key, key_fingerprint) = validate_public_key(public_key)?; + + match mode { + UpdateMode::Production => { + if inputs.debug { + return Err("production updater mode is forbidden in debug builds".to_owned()); + } + if feed_origin != PRODUCTION_FEED_ORIGIN { + return Err( + "production updater feed origin must be exactly https://api.github.com" + .to_owned(), + ); + } + if inputs.qa_root.is_some() { + return Err("production updater mode cannot include a QA root".to_owned()); + } + Ok(BuildBinding { + mode, + feed_origin: Some(feed_origin.to_owned()), + pubkey: Some(public_key), + qa_root: None, + key_fingerprint: Some(key_fingerprint), + repository, + artifact_prefix, + }) + } + UpdateMode::Qa => { + let qa_root = inputs + .qa_root + .as_deref() + .ok_or_else(|| "QA updater mode requires an explicit QA root".to_owned())?; + validate_qa_root(qa_root, inputs.temp_root.as_deref())?; + validate_qa_origin(feed_origin)?; + Ok(BuildBinding { + mode, + feed_origin: Some(feed_origin.to_owned()), + pubkey: Some(public_key), + qa_root: Some(qa_root.to_owned()), + key_fingerprint: Some(key_fingerprint), + repository, + artifact_prefix, + }) + } + UpdateMode::Disabled => unreachable!(), + } +} + +pub fn derive_repository(homepage: &str, repository_url: &str) -> Result { + let homepage_slug = parse_github_slug(homepage, false)?; + let repository_slug = parse_github_slug(repository_url, true)?; + if homepage_slug != repository_slug { + return Err( + "package homepage and repository.url do not identify the same GitHub repository" + .to_owned(), + ); + } + Ok(homepage_slug) +} + +/// Compile a private fixture CA into QA builds; never read runtime trust inputs. +pub fn read_qa_certificate(root: &Path) -> Result, String> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC); + } + let file = options + .open(root.join("updater-ca.pem")) + .map_err(|_| "QA updater requires updater-ca.pem in its compiled root.")?; + let metadata = file + .metadata() + .map_err(|_| "Could not inspect QA updater certificate.")?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 64 * 1024 { + return Err("QA updater certificate must be a bounded regular file.".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.nlink() != 1 + || !matches!(metadata.mode() & 0o7777, 0o400 | 0o600) + { + return Err("QA updater certificate must be owner-only and unaliased.".into()); + } + } + let mut bytes = Vec::new(); + file.take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Could not read QA updater certificate.")?; + if bytes.len() as u64 != metadata.len() || bytes.len() > 64 * 1024 { + return Err("QA updater certificate changed or exceeded its limit.".into()); + } + let text = std::str::from_utf8(&bytes).map_err(|_| "Invalid QA updater certificate PEM.")?; + let body = text + .trim() + .strip_prefix("-----BEGIN CERTIFICATE-----") + .and_then(|text| text.strip_suffix("-----END CERTIFICATE-----")) + .ok_or("QA updater trust input must be one public certificate PEM, never a key.")?; + let encoded: String = body + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect(); + let der = STANDARD + .decode(encoded) + .map_err(|_| "Invalid QA updater certificate PEM.")?; + // Parse the entire X.509 certificate using the same pinned parser as TLS, + // not merely its ASN.1 sequence tag. The caller explicitly trusts this + // private build-time input: parsing does not verify a root self-signature, + // validity period, or CA basic constraints, nor claim to establish trust. + let certificate = rustls_pki_types::CertificateDer::from(der.as_slice()); + webpki::anchor_from_trusted_cert(&certificate) + .map_err(|_| "Invalid QA updater certificate DER.")?; + Ok(bytes) +} + +pub fn artifact_prefix(package_name: &str) -> Result { + if package_name.is_empty() + || package_name.len() > 128 + || !package_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err("package.name must be a safe non-empty package identifier".to_owned()); + } + Ok(format!("{package_name}-")) +} + +fn parse_github_slug(value: &str, repository_url: bool) -> Result { + let mut value = value; + if repository_url { + value = value.strip_prefix("git+").unwrap_or(value); + } + let value = value + .strip_prefix("https://github.com/") + .ok_or_else(|| "package GitHub metadata must use canonical HTTPS".to_owned())?; + if value.is_empty() + || value + .chars() + .any(|character| matches!(character, '?' | '#' | '@')) + { + return Err("package GitHub metadata has an invalid repository path".to_owned()); + } + let value = if repository_url { + value + .strip_suffix(".git") + .ok_or_else(|| "package.repository.url must end in .git".to_owned())? + } else { + value + }; + let mut parts = value.split('/'); + let owner = parts.next().unwrap_or_default(); + let repo = parts.next().unwrap_or_default(); + if parts.next().is_some() + || owner.is_empty() + || repo.is_empty() + || !owner + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || !repo + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err("package GitHub metadata has an invalid owner/repository".to_owned()); + } + Ok(format!("{owner}/{repo}")) +} + +fn validate_public_key(config: &str) -> Result<(String, String), String> { + if config.is_empty() + || config.len() > PUBLIC_KEY_CONFIG_LIMIT + || !config.is_ascii() + || config + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err( + "GJC_UPDATE_PUBKEY must be bounded canonical base64 public-key text".to_owned(), + ); + } + let decoded = STANDARD + .decode(config.as_bytes()) + .map_err(|_| "GJC_UPDATE_PUBKEY is not valid base64 public-key text".to_owned())?; + if STANDARD.encode(&decoded) != config { + return Err("GJC_UPDATE_PUBKEY must use canonical base64".to_owned()); + } + let text = String::from_utf8(decoded) + .map_err(|_| "GJC_UPDATE_PUBKEY must decode to UTF-8 Minisign text".to_owned())?; + if text + .chars() + .any(|character| character.is_control() && character != '\n') + { + return Err("GJC_UPDATE_PUBKEY contains unsupported control input".to_owned()); + } + let mut lines = text.lines(); + let comment = lines.next().unwrap_or_default(); + let record_text = lines.next().unwrap_or_default(); + if !comment.is_ascii() + || !record_text.is_ascii() + || !comment.starts_with(PUBLIC_KEY_COMMENT_PREFIX) + || comment.len() == PUBLIC_KEY_COMMENT_PREFIX.len() + { + return Err("GJC_UPDATE_PUBKEY is not standard Minisign public-key text".to_owned()); + } + if lines.next().is_some() || record_text.is_empty() { + return Err("GJC_UPDATE_PUBKEY must contain exactly one public-key record".to_owned()); + } + let record = STANDARD + .decode(record_text.as_bytes()) + .map_err(|_| "GJC_UPDATE_PUBKEY public record is not canonical base64".to_owned())?; + if record.len() != PUBLIC_KEY_RECORD_LENGTH + || STANDARD.encode(&record) != record_text + || record.get(..2) != Some(b"Ed") + { + return Err( + "GJC_UPDATE_PUBKEY must contain an Ed public record of exactly 42 bytes".to_owned(), + ); + } + let mut hasher = Sha256::new(); + hasher.update(&record); + let digest = hasher.finalize(); + let mut fingerprint = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + write!(&mut fingerprint, "{byte:02x}").expect("writing a digest to String cannot fail"); + } + Ok((config.to_owned(), fingerprint)) +} + +fn validate_qa_origin(origin: &str) -> Result<(), String> { + if origin.is_empty() + || origin + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err("QA updater feed origin contains invalid input".to_owned()); + } + let authority = origin + .strip_prefix("https://") + .ok_or_else(|| "QA updater feed origin must use HTTPS".to_owned())?; + if authority.is_empty() + || authority + .chars() + .any(|character| matches!(character, '/' | '?' | '#' | '@')) + { + return Err("QA updater feed origin must contain only a host and port".to_owned()); + } + let port = authority + .strip_prefix(&format!("{QA_HOST}:")) + .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) + .ok_or_else(|| "QA updater feed origin must use numeric 127.0.0.1 port".to_owned())?; + let number = port + .parse::() + .ok() + .filter(|number| *number != 0) + .ok_or_else(|| "QA updater feed origin port must be nonzero and valid".to_owned())?; + if number.to_string() != port { + return Err("QA updater feed origin port must be canonical decimal".to_owned()); + } + Ok(()) +} + +fn validate_qa_root(path: &Path, temp_root: Option<&Path>) -> Result<(), String> { + if !path.is_absolute() { + return Err("QA root must be an absolute directory".to_owned()); + } + if path.to_str().is_none() { + return Err("QA root must be valid UTF-8".to_owned()); + } + let metadata = fs::symlink_metadata(path) + .map_err(|_| "QA root must be an existing private directory".to_owned())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("QA root must be an existing non-symlink directory".to_owned()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let uid = unsafe { libc::geteuid() }; + if metadata.uid() != uid { + return Err("QA root must be owned by the current user".to_owned()); + } + if metadata.mode() & 0o7777 != 0o700 { + return Err("QA root permissions must be exactly 0700".to_owned()); + } + } + #[cfg(not(unix))] + return Err("QA root ownership cannot be validated on this platform".to_owned()); + + let temp_root = + temp_root.ok_or_else(|| "canonical OS temporary root is unavailable".to_owned())?; + if !temp_root.is_absolute() { + return Err("canonical OS temporary root must be absolute".to_owned()); + } + let temp_metadata = fs::symlink_metadata(temp_root) + .map_err(|_| "canonical OS temporary root is unavailable".to_owned())?; + if temp_metadata.file_type().is_symlink() || !temp_metadata.is_dir() { + return Err("canonical OS temporary root is unavailable".to_owned()); + } + let canonical_temp_root = fs::canonicalize(temp_root) + .map_err(|_| "canonical OS temporary root is unavailable".to_owned())?; + if canonical_temp_root != temp_root { + return Err("canonical OS temporary root must not contain symlink components".to_owned()); + } + let temp_root = canonical_temp_root; + let canonical = fs::canonicalize(path) + .map_err(|_| "QA root must be canonical and accessible".to_owned())?; + if canonical != path || canonical.parent() != Some(temp_root.as_path()) { + return Err("QA root must be a canonical direct child of the OS temporary root".to_owned()); + } + Ok(()) +} + +fn reject_control_fields(inputs: &BuildInputs) -> Result<(), String> { + for value in [ + inputs.mode.as_deref(), + inputs.feed_origin.as_deref(), + inputs.pubkey.as_deref(), + ] + .into_iter() + .flatten() + { + if value.chars().any(char::is_control) { + return Err("updater configuration contains unsupported control input".to_owned()); + } + } + if let Some(path) = inputs.qa_root.as_deref() { + if path.to_string_lossy().chars().any(char::is_control) { + return Err("updater QA root contains unsupported control input".to_owned()); + } + } + Ok(()) +} + +fn env_value(name: &str) -> Result, String> { + match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => { + Err(format!("{name} contains invalid environment text")) + } + } +}