From 6e6a4ba42c5394ecefc711dd268c2eb3788c1f13 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:42 +0800 Subject: [PATCH 01/21] chore: reserve implementation for #170 From 39ae89d09ce436ff19bf2a6b4450b31a744440cc Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:44 +0800 Subject: [PATCH 02/21] chore: reserve implementation for #171 From ea6c0c95f5f9fe0c1955d178adbbeb83bfe75578 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:32:38 +0800 Subject: [PATCH 03/21] fix(release): make mirror promotion monotonic --- .github/workflows/release.yml | 548 ++++- cloudflare/manager-download-router/README.md | 30 +- .../manager-download-router/src/index.js | 38 +- .../test/index.test.js | 40 + docs/release.md | 114 +- package.json | 1 + scripts/check-immutable-releases.mjs | 75 + scripts/check-release-reuse.mjs | 119 ++ scripts/mirror-release.mjs | 1800 +++++++++++++++++ scripts/mirror-release.test.mjs | 1345 ++++++++++++ scripts/release-workflow.test.mjs | 164 ++ scripts/sync-mirror.sh | 245 +-- scripts/validate-release-manifest.mjs | 37 + scripts/verify-release-artifacts.mjs | 63 + scripts/write-release-summary.mjs | 95 +- 15 files changed, 4413 insertions(+), 301 deletions(-) create mode 100644 scripts/check-immutable-releases.mjs create mode 100644 scripts/check-release-reuse.mjs create mode 100755 scripts/mirror-release.mjs create mode 100644 scripts/mirror-release.test.mjs create mode 100644 scripts/release-workflow.test.mjs create mode 100644 scripts/validate-release-manifest.mjs create mode 100644 scripts/verify-release-artifacts.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64e88d4..d4ba4e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,23 @@ name: Release # AC_API_KEY_BASE64 base64 of the AuthKey_XXXX.p8 # TAURI_SIGNING_PRIVATE_KEY updater private key (string) # TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password (empty if none) +# IMMUTABLE_RELEASES_READ_TOKEN fine-grained token with repository +# Administration: read-only +# MANAGER_R2_S3_ENDPOINT / MANAGER_R2_PROMOTION_ACCESS_KEY_ID / +# MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY +# R2 S3-compatible read/write access +# MANAGER_IHEP_S3_ENDPOINT / MANAGER_IHEP_S3_BUCKET / +# MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID / +# MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY +# IHEP S3-compatible read/write access +# Both mirror backends are required for stable publication. They must already +# contain a valid latest.json baseline and support conditional PutObject +# (If-Match / If-None-Match) plus HeadObject user-metadata round trips. +# GitHub Immutable Releases must be enabled before any release starts; the +# workflow verifies the repository setting with IMMUTABLE_RELEASES_READ_TOKEN. +# Every reused asset is also pinned to the API-provided sha256 digest. +# The old MANAGER_*_ACCESS_KEY_ID / SECRET_ACCESS_KEY names must stay deleted; +# otherwise a historical workflow revision could still perform unconditional writes. # # Optional Windows Authenticode (non-blocking until configured; see docs/windows-signing.md): # WINDOWS_CERTIFICATE base64 of OV/EV code-signing .pfx @@ -26,13 +43,99 @@ on: push: tags: ["v*"] workflow_dispatch: + inputs: + target_tag: + description: "Existing vX.Y.Z release to republish through the current protected workflow" + required: false + default: "" + type: string + allow_mirror_downgrade: + description: "Emergency only: allow this tag to replace a newer mirror latest.json" + required: false + default: false + type: boolean + mirror_downgrade_reason: + description: "Required audit reason when emergency mirror downgrade is enabled" + required: false + default: "" + type: string permissions: contents: read +env: + RELEASE_TAG: ${{ inputs.target_tag || github.ref_name }} + +# Serialize every tag in the repository through one release lane. The mirror +# promotion also uses per-object ETag conditions, so an external/manual writer +# cannot bypass monotonicity merely by racing this workflow. +concurrency: + group: release-latest-${{ github.repository }} + cancel-in-progress: false + queue: max + jobs: + prepare: + if: ${{ startsWith(inputs.target_tag || github.ref_name, 'v') }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + outputs: + release_reusable: ${{ steps.lookup.outputs.release_reusable }} + release_asset_digests: ${{ steps.lookup.outputs.release_asset_digests }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 20 + + - name: Require GitHub Immutable Releases + env: + GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} + run: node scripts/check-immutable-releases.mjs + + - name: Check for an existing immutable release + id: lookup + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DISPATCH_REF_NAME: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }} + run: | + set -euo pipefail + if [[ -n "$REQUESTED_TARGET_TAG" && "$DISPATCH_REF_NAME" != "$DEFAULT_BRANCH" ]]; then + echo "::error::target_tag must be dispatched from the default branch ($DEFAULT_BRANCH), not $DISPATCH_REF_NAME" + exit 1 + fi + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG" + exit 1 + fi + lookup_error="$RUNNER_TEMP/release-lookup-error.txt" + release_json="$RUNNER_TEMP/release.json" + if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$lookup_error"; then + REQUESTED_TARGET_TAG="$REQUESTED_TARGET_TAG" \ + node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json" + elif grep -q 'HTTP 404' "$lookup_error"; then + if [[ -n "$REQUESTED_TARGET_TAG" ]]; then + echo "::error::target_tag $REQUESTED_TARGET_TAG must already exist as a GitHub Release" + exit 1 + fi + echo "release_reusable=false" >> "$GITHUB_OUTPUT" + else + cat "$lookup_error" >&2 + echo "::error::could not determine whether release $RELEASE_TAG already exists" + exit 1 + fi + build: - if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + needs: prepare + if: ${{ needs.prepare.result == 'success' && needs.prepare.outputs.release_reusable != 'true' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && !(github.event_name == 'workflow_dispatch' && inputs.target_tag != '') }} # Repo admins should configure the release environment and copy signing # secrets there before moving them off repo-level Actions secrets. environment: release @@ -281,13 +384,75 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: bundle-${{ matrix.target }} + name: bundle-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }} path: dist-artifacts/* if-no-files-found: error + # Signing and notarization are intentionally non-deterministic. Keep every + # successful matrix artifact under an attempt-qualified name, then select the + # earliest successful artifact for each target. A release/stage rerun therefore + # reuses the exact first bytes instead of creating a second immutable payload. + select_artifacts: + needs: [prepare, build] + if: ${{ always() && needs.prepare.result == 'success' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + artifact_names: ${{ steps.select.outputs.artifact_names }} + steps: + - name: Select canonical build artifacts + id: select + if: ${{ needs.prepare.outputs.release_reusable != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + targets=( + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-pc-windows-msvc + aarch64-pc-windows-msvc + ) + names_file="$RUNNER_TEMP/canonical-artifact-names.txt" + : > "$names_file" + for poll in 1 2 3 4 5; do + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \ + --jq '.artifacts[].name' > "$RUNNER_TEMP/run-artifacts.txt" + : > "$names_file" + complete=true + for target in "${targets[@]}"; do + selected="" + for attempt in $(seq 1 "$GITHUB_RUN_ATTEMPT"); do + candidate="bundle-${target}-${GITHUB_RUN_ID}-${attempt}" + if grep -Fxq "$candidate" "$RUNNER_TEMP/run-artifacts.txt"; then + selected="$candidate" + break + fi + done + if [[ -z "$selected" ]]; then + complete=false + break + fi + echo "$selected" >> "$names_file" + done + [[ "$complete" == "true" ]] && break + sleep 5 + done + [[ "$complete" == "true" ]] || { + echo "::error::no complete canonical release artifact set exists for this run" + exit 1 + } + artifact_names="$(jq -Rsc 'split("\n") | map(select(length > 0))' "$names_file")" + echo "artifact_names=$artifact_names" >> "$GITHUB_OUTPUT" + echo "Canonical artifacts: $artifact_names" + release: - if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} - needs: build + if: ${{ always() && needs.prepare.result == 'success' && needs.select_artifacts.result == 'success' && startsWith(inputs.target_tag || github.ref_name, 'v') }} + needs: [prepare, build, select_artifacts] # Repo admins should configure the release environment and copy mirror # secrets there before moving them off repo-level Actions secrets. environment: release @@ -302,14 +467,167 @@ jobs: with: persist-credentials: false + # Historical workflow revisions used the legacy credential names and wrote + # latest.json unconditionally. Keeping those secrets deleted is the storage + # authorization boundary that makes an old run's "Re-run all jobs" fail + # before it can mutate either mirror. + - name: Enforce legacy mirror credential revocation + shell: bash + env: + LEGACY_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }} + LEGACY_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }} + LEGACY_IHEP_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }} + LEGACY_IHEP_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }} + run: | + if [[ -n "$LEGACY_R2_ACCESS_KEY_ID$LEGACY_R2_SECRET_ACCESS_KEY$LEGACY_IHEP_ACCESS_KEY_ID$LEGACY_IHEP_SECRET_ACCESS_KEY" ]]; then + echo "::error::legacy mirror write secrets are still configured; move credentials to the *_PROMOTION_* names and delete all four legacy access-key secrets" + exit 1 + fi + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 20 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - path: dist - merge-multiple: true + # Re-check on every release-job attempt. `prepare` is not rerun by + # "Re-run failed jobs", and publishing a resumed draft while the setting is + # disabled would create a mutable canonical source. + - name: Re-check GitHub Immutable Releases + env: + GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} + run: node scripts/check-immutable-releases.mjs + + # `prepare` may belong to an earlier run attempt when the operator chooses + # "Re-run failed jobs". Refresh the release state inside this job so a + # GitHub Release published by the failed attempt becomes the canonical, + # immutable byte source instead of being uploaded a second time. + - name: Refresh immutable release state + id: live_release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }} + run: | + set -euo pipefail + lookup_error="$RUNNER_TEMP/live-release-lookup-error.txt" + release_json="$RUNNER_TEMP/live-release.json" + if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$lookup_error"; then + REQUESTED_TARGET_TAG="$REQUESTED_TARGET_TAG" \ + node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json" + elif grep -q 'HTTP 404' "$lookup_error"; then + if [[ -n "$REQUESTED_TARGET_TAG" ]]; then + echo "::error::target_tag $REQUESTED_TARGET_TAG must already exist as a GitHub Release" + exit 1 + fi + echo "release_reusable=false" >> "$GITHUB_OUTPUT" + echo "release_asset_digests={}" >> "$GITHUB_OUTPUT" + else + cat "$lookup_error" >&2 + echo "::error::could not refresh release state for $RELEASE_TAG" + exit 1 + fi + + # A target_tag dispatch intentionally checks out the protected default + # branch so historical workflow code cannot run. The updater trust root is + # release data, however, and must come from the target tag: a later key + # rotation must not make valid immutable historical artifacts unverifiable. + - name: Resolve updater trust root for release tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + release_config="$RUNNER_TEMP/release-tauri.conf.json" + gh api --method GET \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/$GITHUB_REPOSITORY/contents/src-tauri/tauri.conf.json" \ + -f ref="$RELEASE_TAG" >"$release_config" + if ! updater_public_key="$( + jq -er ' + .plugins.updater.pubkey + | select(type == "string" and length > 0) + | select((contains("\n") or contains("\r")) | not) + | select(test("^[A-Za-z0-9+/]+={0,2}$")) + ' "$release_config" + )"; then + echo "::error::release tag $RELEASE_TAG has no valid single-line updater public key" + exit 1 + fi + printf 'RELEASE_TAURI_CONFIG=%s\n' "$release_config" >> "$GITHUB_ENV" + printf 'MIRROR_UPDATER_PUBLIC_KEY=%s\n' "$updater_public_key" >> "$GITHUB_ENV" + + - name: Download canonical build artifacts + if: ${{ steps.live_release.outputs.release_reusable != 'true' }} + shell: bash + env: + ARTIFACT_NAMES_JSON: ${{ needs.select_artifacts.outputs.artifact_names }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + rm -rf dist + mkdir -p dist + jq -er '.[]' <<<"$ARTIFACT_NAMES_JSON" | while IFS= read -r artifact; do + gh run download "$GITHUB_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name "$artifact" \ + --dir dist + done + + # A same-tag rerun or emergency downgrade must use the bytes already + # published for that tag. Rebuilding signed/notarized artifacts is + # intentionally non-reproducible and would conflict with immutable keys. + - name: Resolve immutable release artifact source + id: release_source + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ASSET_DIGESTS: ${{ steps.live_release.outputs.release_asset_digests }} + run: | + set -euo pipefail + if [[ "${{ steps.live_release.outputs.release_reusable }}" == "true" ]]; then + published="$RUNNER_TEMP/published-release-artifacts" + rm -rf "$published" + mkdir -p "$published" + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$published" \ + --pattern 'CodexAppManager*' \ + --pattern 'latest.json' + test -f "$published/latest.json" || { + echo "::error::published $RELEASE_TAG has no latest.json" + exit 1 + } + jq -e 'type == "object" and length > 0' <<<"$RELEASE_ASSET_DIGESTS" >/dev/null || { + echo "::error::immutable release digest evidence is missing" + exit 1 + } + while IFS=$'\t' read -r name expected_digest; do + file="$published/$name" + test -f "$file" || { + echo "::error::immutable release asset is missing after download: $name" + exit 1 + } + actual_digest="sha256:$(sha256sum "$file" | cut -d' ' -f1)" + if [[ "$actual_digest" != "$expected_digest" ]]; then + echo "::error::immutable release digest mismatch for $name" + exit 1 + fi + echo "Verified immutable release digest: $name" + done < <(jq -r 'to_entries[] | [.key, .value] | @tsv' <<<"$RELEASE_ASSET_DIGESTS") + cp "$published/latest.json" "$RUNNER_TEMP/published-latest.json" + rm "$published/latest.json" + rm -rf dist + mv "$published" dist + echo "existing=true" >> "$GITHUB_OUTPUT" + echo "source=github-release" >> "$GITHUB_OUTPUT" + echo "Reusing immutable artifacts from existing release $RELEASE_TAG" + else + test -d dist || { + echo "::error::no build artifacts are available for new release $RELEASE_TAG" + exit 1 + } + echo "existing=false" >> "$GITHUB_OUTPUT" + echo "source=canonical-actions-artifacts" >> "$GITHUB_OUTPUT" + fi # Validate the merged *publishable* tree (renamed final names), not the # per-target intermediate build directories from the matrix jobs. @@ -322,12 +640,20 @@ jobs: missing=0 require() { local pattern="$1" + local matches # shellcheck disable=SC2086 - if ! compgen -G "dist/$pattern" > /dev/null; then + matches="$(compgen -G "dist/$pattern" || true)" + if [[ -z "$matches" ]]; then echo "::error::[build] missing final artifact matching: $pattern" missing=1 else - echo "OK $pattern → $(compgen -G "dist/$pattern" | tr '\n' ' ')" + while IFS= read -r file; do + if [[ ! -s "$file" ]]; then + echo "::error::[build] empty final artifact: $file" + missing=1 + fi + done <<<"$matches" + echo "OK $pattern → $(tr '\n' ' ' <<<"$matches")" fi } require "CodexAppManager_aarch64.dmg" @@ -350,29 +676,91 @@ jobs: - name: Generate updater manifest (latest.json) env: ALLOW_PARTIAL_RELEASE: ${{ vars.ALLOW_PARTIAL_RELEASE }} - run: node scripts/gen-updater-manifest.mjs "${{ github.ref_name }}" dist + run: | + node scripts/gen-updater-manifest.mjs "$RELEASE_TAG" dist + cp latest.json "$RUNNER_TEMP/artifact-derived-latest.json" + if [[ "${{ steps.release_source.outputs.existing }}" == "true" ]]; then + cp "$RUNNER_TEMP/published-latest.json" latest.json + fi + if [[ "$RELEASE_TAG" != *-* ]]; then + node scripts/validate-release-manifest.mjs \ + "$RELEASE_TAG" \ + latest.json \ + "$RUNNER_TEMP/artifact-derived-latest.json" + fi + + # A wrong private/public updater-key pairing cannot be repaired after an + # immutable GitHub Release is published. Verify the exact local payloads + # (including manifest/sidecar equality) before either mirror staging or + # draft publication. This also protects prereleases, which skip mirrors. + - name: Verify local updater signatures before immutable publication + run: | + node scripts/verify-release-artifacts.mjs \ + latest.json \ + dist \ + "$RELEASE_TAURI_CONFIG" - # Stable releases mirror in two phases: stage immutable versioned assets - # before GitHub Release publication, then promote latest.json only after + # Stable releases stage immutable versioned assets before GitHub Release + # publication, verify them separately, then promote latest.json only after # the GitHub Release succeeds. Pre-releases still skip the mirror entirely. - name: Stage CDN mirror candidate (R2 + IHEP S3) id: mirror_stage - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ !contains(env.RELEASE_TAG, '-') }} env: MIRROR_PHASE: stage + MIRROR_REQUIRED_BACKENDS: r2,ihep + MIRROR_CANDIDATE_ID: ${{ github.run_id }}-${{ github.run_attempt }} + MIRROR_ALLOW_DOWNGRADE: ${{ inputs.allow_mirror_downgrade && '1' || '0' }} + MIRROR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + MIRROR_DOWNGRADE_REASON: ${{ inputs.mirror_downgrade_reason || '' }} + MIRROR_WORKFLOW_REF_NAME: ${{ github.ref_name }} + MANAGER_R2_S3_ENDPOINT: ${{ secrets.MANAGER_R2_S3_ENDPOINT }} + MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }} + MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }} + MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }} + MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }} + MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }} + MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }} + MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }} + MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }} + MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }} + run: | + [ -f dist/latest.json ] || cp latest.json dist/latest.json + bash scripts/sync-mirror.sh dist + # The GitHub Release uploads the root latest.json exactly once. Recreate + # dist/latest.json later for promotion instead of giving the upload + # action two paths with the same asset basename. + rm -f dist/latest.mirror.json dist/latest.json + + # Complete a direct readback from both object stores and force separate + # readbacks through the Worker's R2 and mainland-China IHEP branches. This + # is a verify-only phase: latest.json cannot be changed here. Both public + # branches must pass before GitHub publication becomes immutable. + - name: Verify staged CDN mirror before immutable publication + id: mirror_verify + if: ${{ !contains(env.RELEASE_TAG, '-') }} + env: + MIRROR_PHASE: verify + MIRROR_REQUIRED_BACKENDS: r2,ihep + MIRROR_CANDIDATE_ID: ${{ github.run_id }}-${{ github.run_attempt }} + MIRROR_ALLOW_DOWNGRADE: ${{ inputs.allow_mirror_downgrade && '1' || '0' }} + MIRROR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + MIRROR_DOWNGRADE_REASON: ${{ inputs.mirror_downgrade_reason || '' }} + MIRROR_WORKFLOW_REF_NAME: ${{ github.ref_name }} MANAGER_R2_S3_ENDPOINT: ${{ secrets.MANAGER_R2_S3_ENDPOINT }} - MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }} - MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }} + MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }} + MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }} + MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }} MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }} MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }} MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }} MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }} - MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }} - MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }} + MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }} + MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }} run: | [ -f dist/latest.json ] || cp latest.json dist/latest.json bash scripts/sync-mirror.sh dist - rm -f dist/latest.mirror.json + rm -f dist/latest.mirror.json dist/latest.json # ── release notes: the hand-written file from the version-bump PR # (docs/releases/.md, see docs/releases/TEMPLATE.md) wins; @@ -380,7 +768,7 @@ jobs: # empty. GitHub's auto "What's Changed" section is appended either way. - name: Prepare release notes run: | - NOTES="docs/releases/${{ github.ref_name }}.md" + NOTES="docs/releases/$RELEASE_TAG.md" if [ -f "$NOTES" ]; then cp "$NOTES" "$RUNNER_TEMP/release-notes.md" else @@ -432,17 +820,21 @@ jobs: exit "$status" - - name: Publish GitHub Release - id: publish_release + # Explicit draft-first publication keeps asset upload ahead of publication + # for both stable and prerelease tags. This is required when GitHub immutable + # releases are enabled: a published release can no longer accept assets. + - name: Upload GitHub Release draft + id: upload_release_draft + if: ${{ steps.release_source.outputs.existing != 'true' }} uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: - tag_name: ${{ github.ref_name }} - draft: false + tag_name: ${{ env.RELEASE_TAG }} + draft: true # Tags carrying a pre-release identifier (e.g. v0.1.2-rc1) publish as a # prerelease, so GitHub's `releases/latest` — and the updater endpoint # pointing at it — keep resolving to the last stable build instead of # serving a release-candidate to real users. - prerelease: ${{ contains(github.ref_name, '-') }} + prerelease: ${{ contains(env.RELEASE_TAG, '-') }} body_path: ${{ runner.temp }}/release-notes.md generate_release_notes: true files: | @@ -451,36 +843,86 @@ jobs: SHA256SUMS release-assets/* + # Omitting `draft` tells action-gh-release v3 to publish the existing draft + # after all uploads succeeded while retaining its stable/prerelease metadata. + - name: Publish GitHub Release + id: publish_release + if: ${{ steps.release_source.outputs.existing != 'true' }} + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 + with: + tag_name: ${{ env.RELEASE_TAG }} + + - name: Verify published immutable Release and asset digests + if: ${{ steps.release_source.outputs.existing != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + release_json="$RUNNER_TEMP/published-release.json" + verification_log="$RUNNER_TEMP/published-release-verification.log" + verification_output="$RUNNER_TEMP/published-release-verification-output.txt" + verified=false + for attempt in 1 2 3 4 5; do + : > "$verification_log" + : > "$verification_output" + if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$verification_log" \ + && REQUESTED_TARGET_TAG="$RELEASE_TAG" GITHUB_OUTPUT="$verification_output" \ + node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json" >>"$verification_log" 2>&1; then + verified=true + break + fi + sleep 3 + done + if [[ "$verified" != "true" ]]; then + cat "$verification_log" >&2 + echo "::error::published $RELEASE_TAG did not become immutable with canonical asset digests" + exit 1 + fi + cat "$verification_log" + + # Attest immediately after GitHub confirms the canonical immutable bytes, + # before mirror promotion can fail the job. Keep the subjects limited to + # updater payloads and latest.json: on a failed-job rerun those are fetched + # from the immutable Release and checked against GitHub's canonical SHA-256 + # digests. SHA256SUMS and SBOMs are intentionally excluded because they are + # regenerated locally and are not necessarily the bytes already published. + # A failed attestation must fail the job so the release stays retryable and + # neither mirror promotion nor winget dispatch can proceed without proof. + - name: Attest build provenance + id: attest + if: ${{ steps.publish_release.outcome == 'success' || steps.release_source.outputs.existing == 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + dist/* + latest.json + - name: Promote CDN mirror latest (R2 + IHEP S3) id: mirror_promote - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ success() && steps.attest.outcome == 'success' && !contains(env.RELEASE_TAG, '-') }} env: MIRROR_PHASE: promote + MIRROR_REQUIRED_BACKENDS: r2,ihep + MIRROR_CANDIDATE_ID: ${{ github.run_id }}-${{ github.run_attempt }} + MIRROR_ALLOW_DOWNGRADE: ${{ inputs.allow_mirror_downgrade && '1' || '0' }} + MIRROR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + MIRROR_DOWNGRADE_REASON: ${{ inputs.mirror_downgrade_reason || '' }} + MIRROR_WORKFLOW_REF_NAME: ${{ github.ref_name }} MANAGER_R2_S3_ENDPOINT: ${{ secrets.MANAGER_R2_S3_ENDPOINT }} - MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }} - MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }} + MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }} + MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }} + MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }} MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }} MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }} MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }} MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }} - MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }} - MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }} + MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }} + MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }} run: | [ -f dist/latest.json ] || cp latest.json dist/latest.json bash scripts/sync-mirror.sh dist - - name: Attest build provenance - id: attest - if: ${{ steps.publish_release.outcome == 'success' }} - continue-on-error: true - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: | - dist/* - latest.json - SHA256SUMS - release-assets/* - # The release above is created with GITHUB_TOKEN, which by design does not # trigger other workflows — so winget.yml's `on: release` never fires. # Dispatch it explicitly (stable releases only). winget.yml no-ops cleanly @@ -488,11 +930,11 @@ jobs: # so a re-run is harmless. - name: Trigger winget submission id: winget - if: ${{ !contains(github.ref_name, '-') }} + if: ${{ success() && steps.attest.outcome == 'success' && !contains(env.RELEASE_TAG, '-') }} env: GH_TOKEN: ${{ github.token }} run: | - if gh workflow run winget.yml -f release-tag="${{ github.ref_name }}" --repo "${{ github.repository }}"; then + if gh workflow run winget.yml -f release-tag="$RELEASE_TAG" --repo "${{ github.repository }}"; then echo "status=dispatched" >> "$GITHUB_OUTPUT" else echo "::warning::winget dispatch failed" @@ -502,10 +944,26 @@ jobs: - name: Write release summary if: always() env: - PUBLISH_OUTCOME: ${{ steps.publish_release.outcome }} + PUBLISH_OUTCOME: ${{ steps.release_source.outputs.existing == 'true' && 'reused' || steps.publish_release.outcome }} MIRROR_STAGE_OUTCOME: ${{ steps.mirror_stage.outcome }} + MIRROR_VERIFY_OUTCOME: ${{ steps.mirror_verify.outcome }} MIRROR_PROMOTE_OUTCOME: ${{ steps.mirror_promote.outcome }} WINGET_OUTCOME: ${{ steps.winget.outputs.status || steps.winget.outcome }} SBOM_OUTCOME: ${{ steps.sbom.outcome }} ATTESTATION_OUTCOME: ${{ steps.attest.outcome }} run: node scripts/write-release-summary.mjs + + - name: Upload release audit records + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-audit-${{ env.RELEASE_TAG }}-${{ github.run_attempt }} + path: | + manifest-summary.json + updater-signature-verification.json + mirror-stage-summary.json + mirror-verification-summary.json + mirror-promotion-summary.json + SHA256SUMS + if-no-files-found: warn + retention-days: 90 diff --git a/cloudflare/manager-download-router/README.md b/cloudflare/manager-download-router/README.md index 920e514..bb08eea 100644 --- a/cloudflare/manager-download-router/README.md +++ b/cloudflare/manager-download-router/README.md @@ -18,8 +18,12 @@ The updater (`src-tauri/tauri.conf.json`) already checks ## Why a separate latest.json on the mirror `latest.json`'s embedded signatures sign the artifact **bytes**, not the URL, so re-hosting the same files keeps them valid — we only rewrite the download URLs to -`…/manager//`. `scripts/sync-mirror.sh` does this on every -(non-pre-)release. +`…/manager//`. `scripts/sync-mirror.sh` stages stable releases, +then verifies both storage endpoints and separately forces this Worker's R2 and +IHEP branches before the GitHub Release becomes immutable. Probe responses identify +their backend with `X-Codex-Mirror-Backend`; an explicit IHEP probe fails instead +of falling back when its Worker secrets are incomplete. The release repeats that +readback immediately before advancing the mirror's `latest.json` pointer. Installers are uploaded under a **per-version** key (`/`) and `latest.json` stays at the fixed root the updater polls. macOS updater tarballs @@ -31,7 +35,7 @@ self-consistent; v0.1.9+ use the versioned layout.) > ⚠️ The mirror's `latest.json` MUST be refreshed on every stable release, or the > first endpoint serves a stale version and **blocks** updates. That's why the -> `release.yml` "Sync to CDN mirror" step exists. +> `release.yml` stage, verify, and promote steps exist. ## Already provisioned (done) - R2 bucket `codex-app-manager` created. @@ -59,17 +63,27 @@ So each release uploads to the mirror (`release.yml` → `scripts/sync-mirror.sh | Secret | Notes | | --- | --- | | `MANAGER_R2_S3_ENDPOINT` | `https://d39dc6c92d1c4cfde580bf13e946b616.r2.cloudflarestorage.com` | -| `MANAGER_R2_ACCESS_KEY_ID` / `MANAGER_R2_SECRET_ACCESS_KEY` | R2 S3 API token (can reuse the mirror's; account-scoped) | -| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` / `_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` | IHEP creds (optional; `_REGION`/`_PREFIX` optional) | +| `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` / `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 S3 API token for the current protected release workflow | +| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` | IHEP endpoint and bucket (`_REGION`/`_PREFIX` optional) | +| `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` / `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP token for the current protected release workflow | -R2 secrets missing → step warns and skips (mirror goes stale). IHEP missing → -CN just falls back to R2 via the worker. +Both backends must already contain the same valid `latest.json` baseline and must +support conditional `PutObject` (`If-Match` / `If-None-Match`) plus custom +user-metadata round trips through `HeadObject`. Delete the legacy access-key secret +names after migration so historical workflow revisions cannot perform their old +unconditional write. + +Deploy this Worker revision before enabling the protected release workflow. The +release gate intentionally rejects an older Worker that cannot force and identify +both public backend branches. ## Deploy / manual sync ```bash wrangler deploy # from this directory # manual re-sync of a tag's assets (download release → rewrite → upload): -# gh release download vX.Y.Z -D dist && cp -f dist/latest.json dist/ && \ +# rm -rf dist && gh release download vX.Y.Z -D dist && \ # MANAGER_R2_S3_ENDPOINT=… MANAGER_R2_ACCESS_KEY_ID=… MANAGER_R2_SECRET_ACCESS_KEY=… \ +# MANAGER_IHEP_S3_ENDPOINT=… MANAGER_IHEP_S3_BUCKET=… \ +# MANAGER_IHEP_S3_ACCESS_KEY_ID=… MANAGER_IHEP_S3_SECRET_ACCESS_KEY=… \ # bash ../../scripts/sync-mirror.sh dist ``` diff --git a/cloudflare/manager-download-router/src/index.js b/cloudflare/manager-download-router/src/index.js index 7856a06..34e1717 100644 --- a/cloudflare/manager-download-router/src/index.js +++ b/cloudflare/manager-download-router/src/index.js @@ -38,6 +38,7 @@ export default { } const country = request.cf?.country || request.headers.get("CF-IPCountry") || ""; + const probeBackend = requestedProbeBackend(url); const secondaryCountryCodes = new Set( (env.SECONDARY_COUNTRY_CODES || DEFAULT_SECONDARY_COUNTRY_CODES) .split(",") @@ -46,7 +47,19 @@ export default { ); // ── Mainland China: presign the IHEP S3 object and redirect ────────────── - if (secondaryCountryCodes.has(country.toUpperCase()) && hasSecondaryS3Config(env)) { + const secondaryConfigured = hasSecondaryS3Config(env); + if (probeBackend === "ihep" && !secondaryConfigured) { + return new Response("Secondary mirror is not configured", { + status: 503, + headers: { "X-Codex-Mirror-Backend": "ihep" }, + }); + } + const useSecondary = + probeBackend === "ihep" || + (probeBackend !== "r2" && + secondaryCountryCodes.has(country.toUpperCase()) && + secondaryConfigured); + if (useSecondary) { const objectKey = objectKeyForKey(key, env.SECONDARY_S3_PREFIX || ""); const signedUrl = await presignS3Url({ // Sign for the actual method: the redirect preserves it, and an @@ -62,7 +75,7 @@ export default { expiresInSeconds: ttlSeconds(env.SECONDARY_S3_SIGNED_URL_TTL_SECONDS), responseHeaders: {}, }); - return redirect(signedUrl); + return redirect(signedUrl, "ihep"); } // ── Everyone else: stream straight from the bound R2 bucket ────────────── @@ -74,6 +87,7 @@ export default { object.writeHttpMetadata(headers); // Content-Type etc. from R2 metadata headers.set("ETag", object.httpEtag); headers.set("Cache-Control", cacheControlForKey(key)); + headers.set("X-Codex-Mirror-Backend", "r2"); if (request.method === "HEAD") { return new Response(null, { headers }); } @@ -97,16 +111,32 @@ function hasSecondaryS3Config(env) { ); } +// Release verification must exercise both geographic branches from a single +// GitHub runner. This is a routing selector, not an authorization mechanism: +// every addressed object is already public through this Worker. Requiring the +// run-specific probe token shape prevents ordinary download links from changing +// backend accidentally while allowing CI to force and identify each branch. +function requestedProbeBackend(url) { + const token = url.searchParams.get("cam_probe") || ""; + const backend = url.searchParams.get("cam_backend") || ""; + if (!/^[0-9a-f]{24}$/.test(token)) return null; + return ["r2", "ihep"].includes(backend) ? backend : null; +} + function ttlSeconds(value) { const parsed = Number.parseInt(value || DEFAULT_SIGNED_URL_TTL_SECONDS, 10); if (!Number.isFinite(parsed)) return DEFAULT_SIGNED_URL_TTL_SECONDS; return Math.min(Math.max(parsed, 1), 604800); } -function redirect(location) { +function redirect(location, backend) { return new Response(null, { status: 302, - headers: { Location: location, "Cache-Control": "private, no-store" }, + headers: { + Location: location, + "Cache-Control": "private, no-store", + "X-Codex-Mirror-Backend": backend, + }, }); } diff --git a/cloudflare/manager-download-router/test/index.test.js b/cloudflare/manager-download-router/test/index.test.js index e49431a..fefd681 100644 --- a/cloudflare/manager-download-router/test/index.test.js +++ b/cloudflare/manager-download-router/test/index.test.js @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import worker from "../src/index.js"; +const PROBE_TOKEN = "0123456789abcdef01234567"; + const latestManifest = JSON.stringify({ version: "0.1.18", platforms: { @@ -140,6 +142,7 @@ describe("manager download router", () => { const jsonRes = await worker.fetch(request("/manager/0.1.18/latest.json"), jsonEnv); expect(jsonRes.status).toBe(200); expect(jsonRes.headers.get("Cache-Control")).toBe("public, max-age=120, s-maxage=120"); + expect(jsonRes.headers.get("X-Codex-Mirror-Backend")).toBe("r2"); const installerEnv = { BUCKET: bucket({ @@ -166,6 +169,7 @@ describe("manager download router", () => { ); expect(res.status).toBe(302); + expect(res.headers.get("X-Codex-Mirror-Backend")).toBe("ihep"); const location = new URL(res.headers.get("Location")); expect(location.origin).toBe("https://s3.example.test"); expect(location.pathname).toBe("/root/mirror-bucket/manager/0.1.18/CodexAppManager_0.1.18_x64-setup.exe"); @@ -195,6 +199,42 @@ describe("manager download router", () => { expect(calls).toEqual(["0.1.18/CodexAppManager_0.1.18_x64-setup.exe"]); }); + it("lets release probes force and identify both geographic backends", async () => { + const calls = []; + const path = "/manager/0.1.18/CodexAppManager_0.1.18_x64-setup.exe"; + const env = { + BUCKET: bucket( + { "0.1.18/CodexAppManager_0.1.18_x64-setup.exe": r2Object("installer") }, + calls, + ), + ...secondaryEnv(), + }; + + const forcedR2 = await worker.fetch( + request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=r2`, {}, { country: "CN" }), + env, + ); + expect(forcedR2.status).toBe(200); + expect(forcedR2.headers.get("X-Codex-Mirror-Backend")).toBe("r2"); + expect(await forcedR2.text()).toBe("installer"); + + const forcedIhep = await worker.fetch( + request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=ihep`, {}, { country: "US" }), + env, + ); + expect(forcedIhep.status).toBe(302); + expect(forcedIhep.headers.get("X-Codex-Mirror-Backend")).toBe("ihep"); + expect(new URL(forcedIhep.headers.get("Location")).origin).toBe("https://s3.example.test"); + expect(calls).toEqual(["0.1.18/CodexAppManager_0.1.18_x64-setup.exe"]); + + const missingSecondary = await worker.fetch( + request(`${path}?cam_probe=${PROBE_TOKEN}&cam_backend=ihep`), + { BUCKET: bucket({}), ...secondaryEnv({ SECONDARY_S3_SECRET_ACCESS_KEY: "" }) }, + ); + expect(missingSecondary.status).toBe(503); + expect(missingSecondary.headers.get("X-Codex-Mirror-Backend")).toBe("ihep"); + }); + it("rejects empty keys, directory keys, and traversal-looking keys", async () => { const calls = []; const env = { BUCKET: bucket({}, calls) }; diff --git a/docs/release.md b/docs/release.md index 5e6a7e3..ff9369a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -67,6 +67,93 @@ PR-time x64 packaged smoke lives in Required CI (`ci.yml`) also runs standalone engine crate tests for `codex-mac-engine` and `codex-win-engine`. +## Mirror promotion safety + +Stable releases use a stage/verify/promote, dual-backend protocol implemented by +[`scripts/mirror-release.mjs`](../scripts/mirror-release.mjs): + +1. Before uploading anything immutable, cryptographically verify every local + updater payload against its manifest signature and the updater public key in + `tauri.conf.json`. The manifest signature must also equal the local `.sig` + sidecar. This gate runs for prereleases as well as stable releases. +2. Upload immutable, versioned artifacts and a run-specific candidate manifest + to both R2 and IHEP. A versioned object is never overwritten. +3. Before publishing the GitHub Release, download the candidate and **every staged + artifact** directly from each S3 endpoint. Verify every file's byte size and + SHA-256, plus each updater bundle's embedded Tauri/minisign signature with the + public key in `tauri.conf.json`. This includes both DMG installers even though + they are not referenced by the updater manifest. Stable promotion requires all + four updater platforms and requires every manifest signature to match its + downloaded `.sig` sidecar; `ALLOW_PARTIAL_RELEASE` cannot weaken the + mirror gate. The same verify-only phase forces separate downloads of the + run-specific candidate and all four updater payloads through the public + Worker's R2 and mainland-China IHEP branches. Each response must identify the + requested backend, catching bad routes, bucket bindings, Worker secondary + credentials, and presigned redirects before immutable publication. +4. Publish the draft GitHub Release and require GitHub to report that it is + immutable with canonical asset digests. +5. Repeat the complete direct-backend and public-route readback immediately before + promotion, then read each backend's current `latest.json` and compare semantic + versions. Newer candidates advance, same-version reruns are + no-write/idempotent, and older tags fail closed. +6. Publish with ETag (`If-Match` / `If-None-Match`) conditions. If the second + backend or final verification fails, restore every backend already changed, + also using ETag conditions so rollback cannot overwrite a concurrent writer. + +The release workflow has one repository-wide `release-latest-*` concurrency lane +with `queue: max`, so every pending tag remains queued instead of a third tag +replacing the second. The object-store conditions remain the independent backstop +for manual/external writes. `mirror-stage-summary.json`, +`mirror-verification-summary.json`, and `mirror-promotion-summary.json` are shown +in the job summary and retained as workflow artifacts for 90 days. If a runner is +hard-killed after only one +`latest.json` write, a fresh run recognizes the exact candidate identity, leaves +that backend untouched, and conditionally advances only the lagging backend. + +Before `prepare` permits any build, and again at the start of every release-job +attempt, the workflow queries the repository Immutable Releases setting with a +dedicated fine-grained, read-only token. A missing token, failed query, or +`enabled: false` response fails closed before draft upload or mirror publication. + +Same-tag reruns reuse the artifacts and `latest.json` attached to a complete, +published GitHub Release only when GitHub reports `immutable: true` and a canonical +`sha256:` digest for every required asset. The workflow re-hashes every downloaded +asset against those API digests, and rejects a mutable release instead of calling +its current bytes canonical. Only a draft is repairable; a published mutable or +incomplete Release fails closed before any asset upload. The release job performs +this lookup live rather than +trusting `prepare` outputs from an earlier attempt, so **Re-run failed jobs** after +a mirror failure reuses the already-published immutable bytes. While a new or +partial release is still being repaired, the workflow selects the earliest +successful, attempt-qualified Actions artifact for each platform so every rerun +stages the same signed/notarized bytes. It never creates a second byte sequence for +an immutable version key. New releases are uploaded as drafts first and published +only after all assets succeed, including prereleases. Immediately after publish, +the workflow requires the Release itself to report `immutable: true` and canonical +digests for every required asset before any mirror pointer can advance. A +historical Actions run still executes its historical workflow revision, so the +release environment intentionally uses new `*_PROMOTION_*` credential names. The +four legacy access-key secrets listed below must be deleted; the current workflow +has a hard gate that rejects them if they are reintroduced. + +### Emergency mirror downgrade + +A normal old-tag rerun can never downgrade the mirror. For an incident rollback: + +1. Open the current `Release` workflow and dispatch it from the repository's + **default branch** (never select the historical tag's workflow revision). +2. Set `target_tag` to the already-published `vX.Y.Z` release to restore. +3. Enable `allow_mirror_downgrade`. +4. Enter a concrete `mirror_downgrade_reason` (at least 10 characters). + +The workflow downloads the target release's original assets instead of rebuilding +them. The target GitHub Release must be immutable and expose canonical SHA-256 +digests; older mutable releases must first be migrated through an explicitly +reviewed process and cannot be used directly. The script rejects the override on +tag-push events or without GitHub actor/run metadata. The triggering actor (plus +the original workflow actor on a rerun), reason, whether the override was actually +used, and the run URL are written to the promotion audit and job summary. + ## Required GitHub Actions secrets | Secret | What | @@ -80,8 +167,24 @@ Required CI (`ci.yml`) also runs standalone engine crate tests for | `AC_API_KEY_BASE64` | base64 of the `AuthKey_XXXX.p8` | | `TAURI_SIGNING_PRIVATE_KEY` | updater private key | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | its password (empty if none) | - -### Optional Windows Authenticode secrets / vars +| `IMMUTABLE_RELEASES_READ_TOKEN` | fine-grained token used only to read this repository's Immutable Releases setting; grant **Administration: read-only** and store it in the `release` environment | +| `MANAGER_R2_S3_ENDPOINT` | R2 S3-compatible endpoint | +| `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` | R2 write/read credential used only by the protected workflow | +| `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 write/read secret used only by the protected workflow | +| `MANAGER_IHEP_S3_ENDPOINT` | IHEP S3-compatible endpoint | +| `MANAGER_IHEP_S3_BUCKET` | IHEP bucket | +| `MANAGER_IHEP_S3_REGION` | IHEP region; defaults to `auto` when empty | +| `MANAGER_IHEP_S3_PREFIX` | optional object-key prefix for IHEP | +| `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` | IHEP write/read credential used only by the protected workflow | +| `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP write/read secret used only by the protected workflow | + +After creating the promotion credentials, delete (and preferably revoke/rotate) +`MANAGER_R2_ACCESS_KEY_ID`, `MANAGER_R2_SECRET_ACCESS_KEY`, +`MANAGER_IHEP_S3_ACCESS_KEY_ID`, and `MANAGER_IHEP_S3_SECRET_ACCESS_KEY` from the +repository and `release` environment. Historical workflow revisions reference +those exact names; leaving any of them available defeats old-run isolation. + +### Optional signing secrets and release variables | Name | What | |---|---| @@ -89,6 +192,7 @@ Required CI (`ci.yml`) also runs standalone engine crate tests for | `WINDOWS_CERTIFICATE_PASSWORD` | password for that .pfx | | `AUTHENTICODE_REQUIRED` (repo **variable**) | `true` → fail release when PE is not `Valid` | | `WINDOWS_TIMESTAMP_URL` (repo **variable**) | optional RFC3161 timestamp URL | +| `MANAGER_R2_BUCKET` (repo **variable**) | R2 bucket; defaults to `codex-app-manager` | Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. @@ -98,3 +202,9 @@ Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. > > Keep **updater signature**, **Authenticode**, and **SmartScreen reputation** > conceptually separate — see [`docs/windows-signing.md`](./windows-signing.md). +> +> Before enabling promotion, seed both S3-compatible endpoints with a valid +> `latest.json` baseline. Both endpoints must support conditional `PutObject` +> requests (`If-Match` / `If-None-Match`) and must preserve custom user metadata +> through `HeadObject`. Promotion fails closed if either baseline is absent or an +> endpoint cannot enforce those requirements. diff --git a/package.json b/package.json index 89e1ada..8cabff7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "check": "tsc --noEmit", "lint": "eslint . --max-warnings=0", "test": "vitest run", + "test:release": "vitest run scripts/mirror-release.test.mjs scripts/release-workflow.test.mjs", "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", diff --git a/scripts/check-immutable-releases.mjs b/scripts/check-immutable-releases.mjs new file mode 100644 index 0000000..3ab5950 --- /dev/null +++ b/scripts/check-immutable-releases.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const API_VERSION = "2026-03-10"; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +export function assertImmutableReleasesEnabled(settings) { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + throw new Error("GitHub Immutable Releases response must be an object"); + } + if (settings.enabled !== true) { + throw new Error( + "GitHub Immutable Releases are disabled; enable them before starting a release", + ); + } + return { enabled: true }; +} + +export function verifyImmutableReleases({ + repository = process.env.GITHUB_REPOSITORY || "", + token = process.env.GH_TOKEN || "", + runner = spawnSync, +} = {}) { + if (!REPOSITORY_PATTERN.test(repository)) { + throw new Error("GITHUB_REPOSITORY must be an owner/repository name"); + } + if (!token.trim()) { + throw new Error( + "IMMUTABLE_RELEASES_READ_TOKEN is missing; release preflight cannot verify repository settings", + ); + } + + const result = runner( + "gh", + [ + "api", + "-H", + `X-GitHub-Api-Version: ${API_VERSION}`, + `repos/${repository}/immutable-releases`, + ], + { + encoding: "utf8", + env: { ...process.env, GH_TOKEN: token }, + }, + ); + if (result.error || result.status !== 0) { + const detail = String(result.error?.message || result.stderr || result.stdout || "unknown error") + .replace(/\s+/g, " ") + .trim() + .slice(0, 500); + throw new Error( + `could not verify GitHub Immutable Releases with the read-only token: ${detail}`, + ); + } + + let settings; + try { + settings = JSON.parse(result.stdout); + } catch (error) { + throw new Error(`GitHub Immutable Releases response was not valid JSON: ${error.message}`); + } + return assertImmutableReleasesEnabled(settings); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + verifyImmutableReleases(); + console.log("GitHub Immutable Releases are enabled"); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/check-release-reuse.mjs b/scripts/check-release-reuse.mjs new file mode 100644 index 0000000..fa84e54 --- /dev/null +++ b/scripts/check-release-reuse.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import { appendFileSync, readFileSync } from "node:fs"; + +const RELEASE_TAG_PATTERN = + /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export function requiredReleaseAssetNames(releaseTag) { + const tag = String(releaseTag); + if (!RELEASE_TAG_PATTERN.test(tag)) { + throw new Error(`release tag must be a semantic vX.Y.Z tag: ${releaseTag}`); + } + const version = tag.slice(1); + return [ + "latest.json", + "CodexAppManager_aarch64.dmg", + "CodexAppManager_x86_64.dmg", + "CodexAppManager_aarch64.app.tar.gz", + "CodexAppManager_aarch64.app.tar.gz.sig", + "CodexAppManager_x86_64.app.tar.gz", + "CodexAppManager_x86_64.app.tar.gz.sig", + `CodexAppManager_${version}_x64-setup.exe`, + `CodexAppManager_${version}_x64-setup.exe.sig`, + `CodexAppManager_${version}_arm64-setup.exe`, + `CodexAppManager_${version}_arm64-setup.exe.sig`, + ]; +} + +export function inspectReleaseForReuse(release, releaseTag) { + if (!release || typeof release !== "object" || Array.isArray(release)) { + throw new Error("GitHub release response must be a JSON object"); + } + const required = requiredReleaseAssetNames(releaseTag); + const assets = Array.isArray(release.assets) ? release.assets : []; + const missing = []; + for (const name of required) { + const matches = assets.filter((asset) => asset?.name === name); + if (matches.length !== 1 || !Number.isFinite(matches[0]?.size) || matches[0].size <= 0) { + missing.push(name); + } + } + + // Drafts are the only repairable state: their assets may still be replaced + // before publication. Never route a published Release back through upload. + if (release.draft !== false) { + return { + digests: {}, + missing, + reason: "draft", + reusable: false, + }; + } + if (release.immutable !== true) { + throw new Error( + `existing release ${releaseTag} is mutable; refusing to treat its current assets as canonical`, + ); + } + if (missing.length > 0) { + throw new Error( + `existing immutable release ${releaseTag} is missing required assets and cannot be repaired: ${missing.join(", ")}`, + ); + } + + const selectedAssets = assets.filter( + (asset) => asset?.name === "latest.json" || asset?.name?.startsWith("CodexAppManager"), + ); + const selectedNames = new Set(); + const digests = {}; + for (const asset of selectedAssets) { + const name = asset.name; + if (selectedNames.has(name)) { + throw new Error(`immutable release has duplicate downloadable asset names: ${name}`); + } + selectedNames.add(name); + if (!Number.isFinite(asset.size) || asset.size <= 0) { + throw new Error(`immutable release downloadable asset is empty: ${name}`); + } + const digest = asset.digest; + if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) { + throw new Error(`immutable release asset has no canonical SHA-256 digest: ${name}`); + } + digests[name] = digest; + } + return { digests, missing: [], reason: null, reusable: true }; +} + +const isCli = process.argv[1]?.endsWith("check-release-reuse.mjs"); +if (isCli) { + const [, , releaseTag, releaseJsonPath] = process.argv; + const requestedTargetTag = process.env.REQUESTED_TARGET_TAG || ""; + const outputPath = process.env.GITHUB_OUTPUT; + try { + if (!releaseTag || !releaseJsonPath || !outputPath) { + throw new Error( + "usage: check-release-reuse.mjs with GITHUB_OUTPUT set", + ); + } + const release = JSON.parse(readFileSync(releaseJsonPath, "utf8")); + const result = inspectReleaseForReuse(release, releaseTag); + if (!result.reusable && requestedTargetTag) { + throw new Error( + `target_tag ${requestedTargetTag} is draft or missing required release assets: ${result.missing.join(", ") || result.reason}`, + ); + } + appendFileSync(outputPath, `release_reusable=${result.reusable ? "true" : "false"}\n`); + if (result.reusable) { + appendFileSync(outputPath, `release_asset_digests=${JSON.stringify(result.digests)}\n`); + console.log(`Existing immutable release ${releaseTag} has canonical digests for all assets`); + } else { + console.log( + `Existing release ${releaseTag} is not reusable yet (${result.reason}; ${result.missing.join(", ")})`, + ); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`::error::${detail}`); + process.exitCode = 1; + } +} diff --git a/scripts/mirror-release.mjs b/scripts/mirror-release.mjs new file mode 100755 index 0000000..c4bda1f --- /dev/null +++ b/scripts/mirror-release.mjs @@ -0,0 +1,1800 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { + createHash, + createPublicKey, + randomUUID, + verify as cryptoVerify, +} from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, ".."); +const DEFAULT_MIRROR_BASE = "https://codexapp.agentsmirror.com/manager"; +const LATEST_KEY = "latest.json"; +const SUMMARY_SCHEMA_VERSION = 1; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +export const REQUIRED_UPDATER_PLATFORMS = [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", + "windows-aarch64", +]; + +let interruptedBy = null; +let rollbackInProgress = false; +const activeChildren = new Set(); + +export class ConditionalWriteError extends Error { + constructor(message) { + super(message); + this.name = "ConditionalWriteError"; + } +} + +export class DowngradeBlockedError extends Error { + constructor(message) { + super(message); + this.name = "DowngradeBlockedError"; + } +} + +class WriteOutcomeUncertainError extends Error { + constructor(message) { + super(message); + this.name = "WriteOutcomeUncertainError"; + } +} + +function errorText(error) { + if (error instanceof Error) return error.message; + return String(error); +} + +function nowIso() { + return new Date().toISOString(); +} + +function safeSummaryError(error) { + return errorText(error).replace(/\s+/g, " ").trim().slice(0, 2_000); +} + +function strictBase64(value, label) { + const normalized = String(value).trim(); + if ( + normalized.length === 0 || + normalized.length % 4 !== 0 || + !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized) + ) { + throw new Error(`${label} is not valid base64`); + } + const decoded = Buffer.from(normalized, "base64"); + if (decoded.toString("base64") !== normalized) { + throw new Error(`${label} is not canonical base64`); + } + return decoded; +} + +export function parseSemver(value) { + const text = String(value).trim().replace(/^v/, ""); + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + text, + ); + if (!match) throw new Error(`invalid semantic version: ${value}`); + const prerelease = match[4] ? match[4].split(".") : []; + for (const identifier of prerelease) { + if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) { + throw new Error(`invalid semantic version prerelease: ${value}`); + } + } + return { + raw: text, + major: BigInt(match[1]), + minor: BigInt(match[2]), + patch: BigInt(match[3]), + prerelease, + }; +} + +export function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + for (const key of ["major", "minor", "patch"]) { + if (a[key] < b[key]) return -1; + if (a[key] > b[key]) return 1; + } + if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0; + if (a.prerelease.length === 0) return 1; + if (b.prerelease.length === 0) return -1; + const count = Math.max(a.prerelease.length, b.prerelease.length); + for (let index = 0; index < count; index += 1) { + const av = a.prerelease[index]; + const bv = b.prerelease[index]; + if (av === undefined) return -1; + if (bv === undefined) return 1; + if (av === bv) continue; + const aNumeric = /^\d+$/.test(av); + const bNumeric = /^\d+$/.test(bv); + if (aNumeric && bNumeric) return BigInt(av) < BigInt(bv) ? -1 : 1; + if (aNumeric !== bNumeric) return aNumeric ? -1 : 1; + return av < bv ? -1 : 1; + } + return 0; +} + +function assertSafeSegment(value, label) { + if (!/^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(value) || value.includes("..")) { + throw new Error(`${label} contains unsafe characters: ${value}`); + } + return value; +} + +function normalizeMirrorBase(value) { + const parsed = new URL(String(value || DEFAULT_MIRROR_BASE).replace(/\/$/, "")); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error("MIRROR_BASE_URL must be an HTTPS origin/path without credentials, query, or fragment"); + } + return parsed.toString().replace(/\/$/, ""); +} + +export function candidateIdFromEnv(env = process.env) { + const explicit = env.MIRROR_CANDIDATE_ID?.trim(); + const fallback = `${env.GITHUB_RUN_ID || "local"}-${env.GITHUB_RUN_ATTEMPT || "1"}`; + return assertSafeSegment(explicit || fallback, "mirror candidate id"); +} + +export function candidateKeyFor(version, candidateId) { + const parsed = parseSemver(version).raw; + assertSafeSegment(parsed, "candidate version"); + assertSafeSegment(candidateId, "mirror candidate id"); + return `candidates/${parsed}/${candidateId}.json`; +} + +function promotionTokenForCandidate(candidateKey) { + return createHash("sha256").update(candidateKey).digest("hex"); +} + +async function readJson(path, label = path) { + let value; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + throw new Error(`cannot read ${label}: ${errorText(error)}`); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must contain a JSON object`); + } + return value; +} + +async function writeJson(path, value) { + await mkdir(dirname(resolve(path)), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function sha256File(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +async function blake2bFile(path) { + const hash = createHash("blake2b512"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest(); +} + +function parseUpdaterPublicKey(publicKeyBase64) { + const text = strictBase64(publicKeyBase64, "Tauri updater public key").toString("utf8"); + const lines = text.trim().split(/\r?\n/); + if (lines.length < 2) throw new Error("Tauri updater public key has invalid minisign encoding"); + const raw = strictBase64(lines[1], "minisign public key"); + if (raw.length !== 42 || !["Ed", "ED"].includes(raw.subarray(0, 2).toString("ascii"))) { + throw new Error("Tauri updater public key has unsupported minisign encoding"); + } + return { keyId: raw.subarray(2, 10), key: raw.subarray(10, 42) }; +} + +function parseUpdaterSignature(signatureBase64) { + const text = strictBase64(signatureBase64, "Tauri updater signature").toString("utf8"); + const lines = text.trim().split(/\r?\n/); + if (lines.length !== 4 || !lines[2].startsWith("trusted comment: ")) { + throw new Error("Tauri updater signature has invalid minisign encoding"); + } + const primary = strictBase64(lines[1], "minisign primary signature"); + const global = strictBase64(lines[3], "minisign global signature"); + if (primary.length !== 74 || global.length !== 64) { + throw new Error("Tauri updater signature has invalid minisign lengths"); + } + const algorithm = primary.subarray(0, 2).toString("ascii"); + if (!['Ed', 'ED'].includes(algorithm)) { + throw new Error(`unsupported minisign signature algorithm: ${algorithm}`); + } + return { + algorithm, + keyId: primary.subarray(2, 10), + signature: primary.subarray(10, 74), + trustedComment: lines[2].slice("trusted comment: ".length), + globalSignature: global, + }; +} + +export async function verifyTauriUpdaterSignature( + artifactPath, + signatureBase64, + publicKeyBase64, +) { + const publicKey = parseUpdaterPublicKey(publicKeyBase64); + const signature = parseUpdaterSignature(signatureBase64); + if (!publicKey.keyId.equals(signature.keyId)) { + throw new Error("Tauri updater signature key id does not match configured public key"); + } + const spki = Buffer.concat([ED25519_SPKI_PREFIX, publicKey.key]); + const key = createPublicKey({ key: spki, format: "der", type: "spki" }); + const payload = + signature.algorithm === "ED" + ? await blake2bFile(artifactPath) + : await readFile(artifactPath); + if (!cryptoVerify(null, payload, key, signature.signature)) { + throw new Error("Tauri updater artifact signature is invalid"); + } + const globalPayload = Buffer.concat([ + signature.signature, + Buffer.from(signature.trustedComment, "utf8"), + ]); + if (!cryptoVerify(null, globalPayload, key, signature.globalSignature)) { + throw new Error("Tauri updater trusted-comment signature is invalid"); + } + return true; +} + +function updaterArtifactsFromManifest(manifest, label) { + assertManifestShape(manifest, label); + const seenNames = new Set(); + return Object.entries(manifest.platforms) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([platform, entry]) => { + const parsed = new URL(entry.url); + const rawName = parsed.pathname.split("/").filter(Boolean).at(-1); + const name = rawName ? decodeURIComponent(rawName) : ""; + assertSafeSegment(name, `${label} platform ${platform} artifact name`); + if (seenNames.has(name)) { + throw new Error(`multiple updater platforms resolve to the same artifact: ${name}`); + } + seenNames.add(name); + return { name, platform, signature: entry.signature, url: entry.url }; + }); +} + +export async function verifyLocalUpdaterArtifacts({ + manifest, + distDir, + publicKey, +}) { + const dist = resolve(distDir); + const reports = []; + for (const artifact of updaterArtifactsFromManifest(manifest, "release latest.json")) { + const artifactPath = join(dist, artifact.name); + const sidecarPath = `${artifactPath}.sig`; + let sidecar; + try { + sidecar = (await readFile(sidecarPath, "utf8")).trim(); + } catch (error) { + throw new Error( + `local updater signature sidecar is missing for ${artifact.platform}: ${errorText(error)}`, + ); + } + if (sidecar !== artifact.signature) { + throw new Error( + `local updater signature sidecar does not match manifest for ${artifact.platform}`, + ); + } + const metadata = await stat(artifactPath).catch((error) => { + throw new Error( + `local updater artifact is missing for ${artifact.platform}: ${errorText(error)}`, + ); + }); + if (!metadata.isFile() || metadata.size <= 0) { + throw new Error(`local updater artifact is empty for ${artifact.platform}`); + } + await verifyTauriUpdaterSignature(artifactPath, artifact.signature, publicKey); + reports.push({ + name: artifact.name, + platform: artifact.platform, + sha256: await sha256File(artifactPath), + size: metadata.size, + }); + } + return { artifactCount: reports.length, artifacts: reports, verified: true }; +} + +function manifestReleaseIdentity(manifest) { + const platforms = Object.fromEntries( + Object.entries(manifest.platforms || {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([platform, entry]) => [ + platform, + { signature: entry?.signature || "", url: entry?.url || "" }, + ]), + ); + return JSON.stringify({ version: manifest.version, platforms }); +} + +function assertManifestShape(manifest, label) { + const version = parseSemver(manifest.version).raw; + if (!manifest.platforms || typeof manifest.platforms !== "object" || Array.isArray(manifest.platforms)) { + throw new Error(`${label} has no platforms object`); + } + const entries = Object.entries(manifest.platforms); + if (entries.length === 0) throw new Error(`${label} has no updater platforms`); + for (const [platform, entry] of entries) { + if (!entry || typeof entry.url !== "string" || typeof entry.signature !== "string") { + throw new Error(`${label} platform ${platform} is missing url/signature`); + } + if (!entry.url.trim()) { + throw new Error(`${label} platform ${platform} has an empty updater URL`); + } + if (!entry.signature.trim()) { + throw new Error(`${label} platform ${platform} has an empty updater signature`); + } + } + return version; +} + +function assertRequiredUpdaterPlatforms(manifest, label) { + const missing = REQUIRED_UPDATER_PLATFORMS.filter( + (platform) => !Object.hasOwn(manifest.platforms, platform), + ); + if (missing.length > 0) { + throw new Error(`${label} is missing required updater platforms: ${missing.join(", ")}`); + } +} + +export function assertCandidateMatchesRelease(candidateManifest, artifactManifest, releaseTag) { + const tag = String(releaseTag); + if (!tag.startsWith("v")) { + throw new Error(`release tag must start with v: ${releaseTag}`); + } + const expectedVersion = tag.slice(1); + if (parseSemver(expectedVersion).raw !== expectedVersion) { + throw new Error(`release tag must contain a canonical semantic version: ${releaseTag}`); + } + + assertManifestShape(candidateManifest, "candidate latest.json"); + assertManifestShape(artifactManifest, "artifact-derived latest.json"); + assertRequiredUpdaterPlatforms(candidateManifest, "candidate latest.json"); + assertRequiredUpdaterPlatforms(artifactManifest, "artifact-derived latest.json"); + if (candidateManifest.version !== expectedVersion) { + throw new Error( + `candidate latest.json version ${candidateManifest.version} does not match release tag ${releaseTag}`, + ); + } + if (artifactManifest.version !== expectedVersion) { + throw new Error( + `artifact-derived latest.json version ${artifactManifest.version} does not match release tag ${releaseTag}`, + ); + } + if (manifestReleaseIdentity(candidateManifest) !== manifestReleaseIdentity(artifactManifest)) { + throw new Error( + "candidate latest.json platforms/signatures do not match the artifact-derived manifest", + ); + } + return { + platformCount: Object.keys(candidateManifest.platforms).length, + version: expectedVersion, + }; +} + +export async function createMirrorManifest(distDir, mirrorBaseValue = DEFAULT_MIRROR_BASE) { + const dist = resolve(distDir); + const sourcePath = join(dist, "latest.json"); + const manifest = await readJson(sourcePath, "release latest.json"); + const version = assertManifestShape(manifest, "release latest.json"); + const mirrorBase = normalizeMirrorBase(mirrorBaseValue); + const rewritten = structuredClone(manifest); + for (const [platform, entry] of Object.entries(rewritten.platforms)) { + const original = new URL(entry.url); + const rawName = original.pathname.split("/").filter(Boolean).at(-1); + if (!rawName) throw new Error(`platform ${platform} URL has no artifact name`); + const name = decodeURIComponent(rawName); + assertSafeSegment(name, `platform ${platform} artifact name`); + entry.url = `${mirrorBase}/${encodeURIComponent(version)}/${encodeURIComponent(name)}`; + } + const outputPath = join(dist, "latest.mirror.json"); + await writeJson(outputPath, rewritten); + return { manifest: rewritten, mirrorBase, outputPath, version }; +} + +async function expectedArtifactsFromManifest(manifest, distDir, mirrorBase) { + const version = assertManifestShape(manifest, "mirror candidate"); + const expectedBase = `${normalizeMirrorBase(mirrorBase)}/${encodeURIComponent(version)}/`; + const updaterByName = new Map(); + for (const [platform, entry] of Object.entries(manifest.platforms).sort(([a], [b]) => a.localeCompare(b))) { + if (!entry.url.startsWith(expectedBase)) { + throw new Error(`platform ${platform} URL is not under ${expectedBase}`); + } + const parsed = new URL(entry.url); + const rawName = parsed.pathname.split("/").filter(Boolean).at(-1); + const name = rawName ? decodeURIComponent(rawName) : ""; + assertSafeSegment(name, `platform ${platform} artifact name`); + if (updaterByName.has(name)) { + throw new Error(`multiple updater platforms resolve to the same artifact: ${name}`); + } + updaterByName.set(name, { platform, signature: entry.signature }); + } + + for (const [name, updater] of updaterByName) { + const sidecarPath = join(resolve(distDir), `${name}.sig`); + let sidecar; + try { + sidecar = (await readFile(sidecarPath, "utf8")).trim(); + } catch (error) { + throw new Error( + `local updater signature sidecar is missing for ${updater.platform}: ${errorText(error)}`, + ); + } + if (sidecar !== updater.signature) { + throw new Error( + `local updater signature sidecar does not match manifest for ${updater.platform}`, + ); + } + } + + const artifacts = []; + const names = (await readdir(distDir)) + .filter((name) => !["latest.json", "latest.mirror.json"].includes(name)) + .sort(); + for (const name of names) { + assertSafeSegment(name, "release artifact name"); + const localPath = join(resolve(distDir), name); + const metadata = await stat(localPath); + if (!metadata.isFile()) continue; + const updater = updaterByName.get(name); + artifacts.push({ + platform: updater?.platform || null, + name, + key: `${version}/${name}`, + localPath, + size: metadata.size, + sha256: await sha256File(localPath), + signature: updater ? updater.signature : null, + }); + updaterByName.delete(name); + } + if (updaterByName.size > 0) { + throw new Error( + `local release artifacts are missing: ${[...updaterByName.keys()].join(", ")}`, + ); + } + return artifacts; +} + +function publicProbeUrl(value, candidateKey, backend) { + const parsed = new URL(value); + if (parsed.protocol !== "https:") { + throw new Error(`public mirror probe requires HTTPS: ${value}`); + } + parsed.searchParams.set( + "cam_probe", + createHash("sha256").update(candidateKey).digest("hex").slice(0, 24), + ); + parsed.searchParams.set("cam_backend", backend); + return parsed.toString(); +} + +async function downloadPublicObject(fetchImpl, url, destination, label, backend) { + await mkdir(dirname(destination), { recursive: true }); + let lastError; + for (let attempt = 1; attempt <= 5; attempt += 1) { + await rm(destination, { force: true }); + try { + let response = await fetchImpl(url, { + headers: { + "Cache-Control": "no-cache", + "User-Agent": "Codex-App-Manager release verifier", + }, + redirect: backend === "ihep" ? "manual" : "follow", + signal: AbortSignal.timeout(300_000), + }); + const routedBackend = response.headers.get("X-Codex-Mirror-Backend"); + if (routedBackend !== backend) { + await response.body?.cancel().catch(() => {}); + throw new Error( + `Worker reported backend ${routedBackend || "missing"}, expected ${backend}`, + ); + } + if (backend === "ihep") { + if (response.status !== 302) { + await response.body?.cancel().catch(() => {}); + throw new Error(`Worker IHEP probe returned HTTP ${response.status}`); + } + const location = response.headers.get("Location"); + const redirected = location ? new URL(location, url) : null; + if (!redirected || redirected.protocol !== "https:") { + throw new Error("Worker IHEP probe did not return a secure redirect"); + } + await response.body?.cancel().catch(() => {}); + response = await fetchImpl(redirected, { + headers: { "User-Agent": "Codex-App-Manager release verifier" }, + redirect: "follow", + signal: AbortSignal.timeout(300_000), + }); + } + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${response.status}`); + } + await pipeline(Readable.fromWeb(response.body), createWriteStream(destination)); + return { path: destination, size: (await stat(destination)).size, url }; + } catch (error) { + await rm(destination, { force: true }); + lastError = error; + if (attempt < 5) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, attempt * 1_000)); + } + } + } + throw new Error(`${label} public ${backend} download failed: ${errorText(lastError)}`); +} + +async function verifyPublicMirrorBackend({ + backend, + candidateKey, + candidateManifest, + candidatePath, + expectedByPlatform, + mirrorBase, + publicKey, + publicDir, + fetchImpl, +}) { + const encodedCandidateKey = candidateKey + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const candidateUrl = publicProbeUrl( + `${normalizeMirrorBase(mirrorBase)}/${encodedCandidateKey}`, + candidateKey, + backend, + ); + const downloadedCandidate = join(publicDir, backend, "candidate.json"); + const candidateResponse = await downloadPublicObject( + fetchImpl, + candidateUrl, + downloadedCandidate, + "mirror candidate", + backend, + ); + const [localCandidateHash, publicCandidateHash] = await Promise.all([ + sha256File(candidatePath), + sha256File(downloadedCandidate), + ]); + if (localCandidateHash !== publicCandidateHash) { + throw new Error(`public mirror ${backend} candidate bytes do not match this release run`); + } + const publicManifest = await readJson( + downloadedCandidate, + `public mirror ${backend} candidate`, + ); + if ( + manifestReleaseIdentity(publicManifest) !== + manifestReleaseIdentity(candidateManifest) + ) { + throw new Error( + `public mirror ${backend} candidate release identity does not match this release run`, + ); + } + + const artifactReports = []; + for (const updater of updaterArtifactsFromManifest(candidateManifest, "mirror candidate")) { + const expected = expectedByPlatform.get(updater.platform); + if (!expected || expected.name !== updater.name) { + throw new Error( + `public mirror ${backend} probe has no bound artifact for ${updater.platform}`, + ); + } + const publicPath = join(publicDir, backend, updater.name); + const response = await downloadPublicObject( + fetchImpl, + publicProbeUrl(updater.url, candidateKey, backend), + publicPath, + `mirror artifact ${updater.name}`, + backend, + ); + if (response.size !== expected.size) { + throw new Error( + `public mirror ${backend} size mismatch for ${updater.name}: ${response.size} != ${expected.size}`, + ); + } + const publicHash = await sha256File(publicPath); + if (publicHash !== expected.sha256) { + throw new Error(`public mirror ${backend} sha256 mismatch for ${updater.name}`); + } + await verifyTauriUpdaterSignature(publicPath, updater.signature, publicKey); + artifactReports.push({ + name: updater.name, + platform: updater.platform, + sha256: publicHash, + size: response.size, + url: updater.url, + }); + await rm(publicPath, { force: true }); + } + return { + artifactCount: artifactReports.length, + artifacts: artifactReports, + backend, + candidateSha256: publicCandidateHash, + candidateSize: candidateResponse.size, + candidateUrl, + verified: true, + }; +} + +export async function verifyPublicMirrorRoute({ + candidateKey, + candidateManifest, + candidatePath, + expectedArtifacts, + mirrorBase, + publicKey, + workDir, + fetchImpl = fetch, +}) { + const expectedByPlatform = new Map( + expectedArtifacts + .filter((artifact) => artifact.platform) + .map((artifact) => [artifact.platform, artifact]), + ); + const backends = {}; + for (const backend of ["r2", "ihep"]) { + backends[backend] = await verifyPublicMirrorBackend({ + backend, + candidateKey, + candidateManifest, + candidatePath, + expectedByPlatform, + mirrorBase, + publicKey, + publicDir: join(workDir, "public-route"), + fetchImpl, + }); + } + return { + artifactCount: updaterArtifactsFromManifest(candidateManifest, "mirror candidate").length, + backendCount: Object.keys(backends).length, + backends, + verified: Object.values(backends).every((report) => report.verified), + }; +} + +function isNotFound(text) { + return /(?:\b404\b|NoSuchKey|Not Found|does not exist)/i.test(text); +} + +function isConditionalFailure(text) { + return /(?:\b409\b|\b412\b|PreconditionFailed|ConditionalRequestConflict|pre-?condition|conditional request conflict)/i.test( + text, + ); +} + +async function runProcess(command, args, options = {}) { + if (interruptedBy && !rollbackInProgress) { + throw new Error(`release interrupted by ${interruptedBy}`); + } + return await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + activeChildren.add(child); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + if (stdout.length < 1_000_000) stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + if (stderr.length < 1_000_000) stderr += chunk.toString(); + }); + child.on("error", (error) => { + activeChildren.delete(child); + rejectPromise(error); + }); + child.on("close", (code, signal) => { + activeChildren.delete(child); + const result = { code: code ?? 1, signal, stderr, stdout }; + if (code === 0 || options.allowFailure) resolvePromise(result); + else rejectPromise(new Error(`${command} failed (${code ?? signal}): ${stderr || stdout}`)); + }); + }); +} + +export class AwsObjectStore { + constructor({ name, endpoint, bucket, region, prefix = "", accessKeyId, secretAccessKey, configPath }) { + this.name = name; + this.endpoint = endpoint; + this.bucket = bucket; + this.region = region || "auto"; + this.prefix = prefix.replace(/^\/+|\/+$/g, ""); + this.configPath = configPath; + this.processEnv = { + ...process.env, + AWS_ACCESS_KEY_ID: accessKeyId, + AWS_SECRET_ACCESS_KEY: secretAccessKey, + AWS_DEFAULT_REGION: this.region, + AWS_EC2_METADATA_DISABLED: "true", + AWS_CONFIG_FILE: configPath, + }; + } + + objectKey(key) { + return this.prefix ? `${this.prefix}/${key}` : key; + } + + s3Uri(key) { + return `s3://${this.bucket}/${this.objectKey(key)}`; + } + + async aws(args, options = {}) { + const { env: envOverride = {}, ...runOptions } = options; + return await runProcess( + "aws", + [...args, "--endpoint-url", this.endpoint], + { + ...runOptions, + env: { ...this.processEnv, ...envOverride }, + }, + ); + } + + async head(key) { + const result = await this.aws( + [ + "s3api", + "head-object", + "--bucket", + this.bucket, + "--key", + this.objectKey(key), + "--output", + "json", + ], + { allowFailure: true }, + ); + if (result.code !== 0) { + const detail = `${result.stderr}\n${result.stdout}`; + if (isNotFound(detail)) return null; + throw new Error(`${this.name}: cannot read object metadata for ${key}: ${detail.trim()}`); + } + const metadata = JSON.parse(result.stdout); + if (!metadata.ETag || !Number.isFinite(metadata.ContentLength)) { + throw new Error(`${this.name}: incomplete object metadata for ${key}`); + } + const objectMetadata = Object.fromEntries( + Object.entries(metadata.Metadata || {}).map(([name, value]) => [ + name.toLowerCase(), + String(value), + ]), + ); + return { etag: metadata.ETag, metadata: objectMetadata, size: metadata.ContentLength }; + } + + async download(key, destination) { + await mkdir(dirname(destination), { recursive: true }); + await this.aws([ + "s3", + "cp", + this.s3Uri(key), + destination, + "--only-show-errors", + ]); + } + + async snapshot(key, destination) { + for (let attempt = 1; attempt <= 3; attempt += 1) { + const before = await this.head(key); + if (!before) return { exists: false, etag: null, size: 0, path: null }; + await this.download(key, destination); + const after = await this.head(key); + const localSize = (await stat(destination)).size; + if (after && before.etag === after.etag && after.size === localSize) { + return { + exists: true, + etag: after.etag, + metadata: after.metadata, + size: localSize, + path: destination, + }; + } + await rm(destination, { force: true }); + if (attempt === 3) { + throw new Error(`${this.name}: ${key} changed repeatedly while being downloaded`); + } + } + throw new Error(`${this.name}: could not snapshot ${key}`); + } + + async putObject( + localPath, + key, + { contentType, ifMatch, ifNoneMatch, metadata, singleAttempt = false } = {}, + ) { + const args = [ + "s3api", + "put-object", + "--bucket", + this.bucket, + "--key", + this.objectKey(key), + "--body", + localPath, + "--content-type", + contentType || "application/octet-stream", + "--output", + "json", + ]; + if (ifMatch) args.push("--if-match", ifMatch); + if (ifNoneMatch) args.push("--if-none-match", ifNoneMatch); + if (metadata && Object.keys(metadata).length > 0) { + args.push( + "--metadata", + Object.entries(metadata) + .map(([name, value]) => `${name}=${value}`) + .join(","), + ); + } + const result = await this.aws(args, { + allowFailure: true, + ...(singleAttempt ? { env: { AWS_MAX_ATTEMPTS: "1" } } : {}), + }); + if (result.code !== 0) { + const detail = `${result.stderr}\n${result.stdout}`.trim(); + if (isConditionalFailure(detail)) { + throw new ConditionalWriteError(`${this.name}: conditional write rejected for ${key}`); + } + throw new Error(`${this.name}: upload failed for ${key}: ${detail}`); + } + let response; + try { + response = JSON.parse(result.stdout); + } catch (error) { + throw new WriteOutcomeUncertainError( + `${this.name}: ${key} upload succeeded but its response was not valid JSON: ${errorText(error)}`, + ); + } + if (typeof response.ETag !== "string" || !response.ETag) { + throw new WriteOutcomeUncertainError( + `${this.name}: ${key} upload response did not include the committed ETag`, + ); + } + return { etag: response.ETag, size: (await stat(localPath)).size }; + } + + async putImmutable(localPath, key, contentType, metadata = {}) { + const existing = await this.head(key); + if (existing) return { status: "existing", ...existing }; + try { + const uploaded = await this.putObject(localPath, key, { + contentType, + ifNoneMatch: "*", + metadata, + }); + return { status: "uploaded", ...uploaded }; + } catch (error) { + if (!(error instanceof ConditionalWriteError)) throw error; + const raced = await this.head(key); + if (!raced) throw error; + return { status: "existing-after-race", ...raced }; + } + } + + async putLatestConditional(localPath, expectedEtag, promotionToken = "") { + return await this.putObject(localPath, LATEST_KEY, { + contentType: "application/json", + ...(expectedEtag ? { ifMatch: expectedEtag } : { ifNoneMatch: "*" }), + ...(promotionToken + ? { metadata: { "cam-promotion-token": promotionToken } } + : {}), + singleAttempt: true, + }); + } +} + +function contentType(name) { + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".tar.gz")) return "application/gzip"; + if (name.endsWith(".dmg")) return "application/x-apple-diskimage"; + return "application/octet-stream"; +} + +function requiredValue(env, name, backendName) { + const value = env[name]?.trim(); + if (!value) throw new Error(`${backendName} backend requires ${name}`); + return value; +} + +export function backendConfigsFromEnv(env, configPath) { + const required = (env.MIRROR_REQUIRED_BACKENDS || "r2,ihep") + .split(",") + .map((name) => name.trim().toLowerCase()) + .filter(Boolean); + const unknown = required.filter((name) => !["r2", "ihep"].includes(name)); + if (unknown.length > 0) throw new Error(`unknown MIRROR_REQUIRED_BACKENDS: ${unknown.join(", ")}`); + if (!required.includes("r2") || !required.includes("ihep")) { + throw new Error("stable mirror publication requires both r2 and ihep backends"); + } + return [ + new AwsObjectStore({ + name: "r2", + endpoint: requiredValue(env, "MANAGER_R2_S3_ENDPOINT", "r2"), + bucket: env.MANAGER_R2_BUCKET?.trim() || "codex-app-manager", + region: "auto", + accessKeyId: requiredValue(env, "MANAGER_R2_ACCESS_KEY_ID", "r2"), + secretAccessKey: requiredValue(env, "MANAGER_R2_SECRET_ACCESS_KEY", "r2"), + configPath, + }), + new AwsObjectStore({ + name: "ihep", + endpoint: requiredValue(env, "MANAGER_IHEP_S3_ENDPOINT", "ihep"), + bucket: requiredValue(env, "MANAGER_IHEP_S3_BUCKET", "ihep"), + region: env.MANAGER_IHEP_S3_REGION?.trim() || "auto", + prefix: env.MANAGER_IHEP_S3_PREFIX?.trim() || "", + accessKeyId: requiredValue(env, "MANAGER_IHEP_S3_ACCESS_KEY_ID", "ihep"), + secretAccessKey: requiredValue(env, "MANAGER_IHEP_S3_SECRET_ACCESS_KEY", "ihep"), + configPath, + }), + ]; +} + +export function downgradeOverrideFromEnv(env = process.env) { + const requested = env.MIRROR_ALLOW_DOWNGRADE === "1" || env.MIRROR_ALLOW_DOWNGRADE === "true"; + const reason = env.MIRROR_DOWNGRADE_REASON?.trim() || ""; + const eventName = env.GITHUB_EVENT_NAME || ""; + const originalActor = env.GITHUB_ACTOR || ""; + const triggeringActor = env.GITHUB_TRIGGERING_ACTOR || ""; + const actor = triggeringActor || originalActor; + const repository = env.GITHUB_REPOSITORY || ""; + const runId = env.GITHUB_RUN_ID || ""; + const workflowRefName = env.MIRROR_WORKFLOW_REF_NAME || ""; + const defaultBranch = env.MIRROR_DEFAULT_BRANCH || ""; + const runUrl = repository && runId ? `https://github.com/${repository}/actions/runs/${runId}` : ""; + if (requested) { + if (eventName !== "workflow_dispatch") { + throw new Error("mirror downgrade override is accepted only from workflow_dispatch"); + } + if (!workflowRefName || !defaultBranch || workflowRefName !== defaultBranch) { + throw new Error("mirror downgrade override must run from the repository default branch"); + } + if (reason.length < 10) { + throw new Error("mirror downgrade override requires an audit reason of at least 10 characters"); + } + if (!actor || !runUrl) { + throw new Error("mirror downgrade override requires GitHub actor and run audit metadata"); + } + } + return { + actor, + defaultBranch, + eventName, + originalActor, + reason, + requested, + runUrl, + used: false, + triggeringActor, + workflowRefName, + }; +} + +async function updaterPublicKeyFromRepo() { + const config = await readJson(join(REPO_ROOT, "src-tauri", "tauri.conf.json"), "tauri.conf.json"); + const pubkey = config.plugins?.updater?.pubkey; + if (typeof pubkey !== "string" || !pubkey.trim()) { + throw new Error("tauri.conf.json has no updater public key"); + } + return pubkey.trim(); +} + +export async function verifyBackendCandidate({ + backend, + candidateKey, + candidatePath, + candidateManifest, + distDir, + mirrorBase, + publicKey, + workDir, + expectedArtifacts: preparedArtifacts, + candidateSha256, + promotionToken, +}) { + const backendDir = join(workDir, backend.name); + await mkdir(backendDir, { recursive: true }); + const downloadedCandidate = join(backendDir, "candidate.json"); + const remoteCandidate = await backend.snapshot(candidateKey, downloadedCandidate); + if (!remoteCandidate.exists) throw new Error(`${backend.name}: candidate is missing at ${candidateKey}`); + if ( + promotionToken && + remoteCandidate.metadata?.["cam-promotion-token"] !== promotionToken + ) { + throw new Error( + `${backend.name}: candidate metadata does not preserve the promotion token`, + ); + } + const [localCandidateHash, remoteCandidateHash] = await Promise.all([ + candidateSha256 ? Promise.resolve(candidateSha256) : sha256File(candidatePath), + sha256File(downloadedCandidate), + ]); + if (localCandidateHash !== remoteCandidateHash) { + throw new Error(`${backend.name}: candidate bytes do not match this release run`); + } + const remoteManifest = await readJson(downloadedCandidate, `${backend.name} candidate`); + const expectedVersion = assertManifestShape(candidateManifest, "local mirror candidate"); + const remoteVersion = assertManifestShape(remoteManifest, `${backend.name} candidate`); + if (remoteVersion !== expectedVersion || manifestReleaseIdentity(remoteManifest) !== manifestReleaseIdentity(candidateManifest)) { + throw new Error(`${backend.name}: candidate release identity does not match version ${expectedVersion}`); + } + + const expectedArtifacts = + preparedArtifacts || + (await expectedArtifactsFromManifest(candidateManifest, distDir, mirrorBase)); + const artifactReports = []; + for (const artifact of expectedArtifacts) { + const remotePath = join(backendDir, artifact.name); + const remote = await backend.snapshot(artifact.key, remotePath); + if (!remote.exists) throw new Error(`${backend.name}: artifact is missing: ${artifact.key}`); + const remoteHash = await sha256File(remotePath); + if (remote.size !== artifact.size) { + throw new Error( + `${backend.name}: size mismatch for ${artifact.name}: ${remote.size} != ${artifact.size}`, + ); + } + if (remoteHash !== artifact.sha256) { + throw new Error(`${backend.name}: sha256 mismatch for ${artifact.name}`); + } + if (artifact.platform) { + if (!artifact.signature) { + throw new Error(`${backend.name}: updater artifact ${artifact.name} has no signature`); + } + await verifyTauriUpdaterSignature(remotePath, artifact.signature, publicKey); + } + artifactReports.push({ + name: artifact.name, + platform: artifact.platform, + sha256: remoteHash, + signatureVerified: artifact.platform ? true : null, + size: remote.size, + }); + await rm(remotePath, { force: true }); + } + return { + artifactCount: artifactReports.length, + artifacts: artifactReports, + candidateEtag: remoteCandidate.etag, + candidateSha256: remoteCandidateHash, + verified: true, + version: remoteVersion, + }; +} + +function initialSummary(phase, version, candidateKey, override) { + return { + schemaVersion: SUMMARY_SCHEMA_VERSION, + phase, + candidateVersion: version, + candidateKey, + startedAt: nowIso(), + finishedAt: null, + outcome: "running", + error: null, + override: { ...override }, + backends: [], + }; +} + +async function finishSummary(path, summary, outcome, error = null) { + summary.finishedAt = nowIso(); + summary.outcome = outcome; + summary.error = error ? safeSummaryError(error) : null; + await writeJson(path, summary); +} + +export async function stageMirrors({ + backends, + candidateKey, + candidatePath, + distDir, + summaryPath, + version, + override, +}) { + const summary = initialSummary("stage", version, candidateKey, override); + summary.backends = backends.map((backend) => ({ + name: backend.name, + candidate: null, + assets: [], + status: "not-started", + })); + const promotionToken = promotionTokenForCandidate(candidateKey); + let activeSummary = null; + try { + const names = (await readdir(distDir)).sort(); + const assets = names.filter((name) => !["latest.json", "latest.mirror.json"].includes(name)); + for (const backend of backends) { + const backendSummary = summary.backends.find((entry) => entry.name === backend.name); + activeSummary = backendSummary; + backendSummary.status = "staging"; + for (const name of assets) { + assertSafeSegment(name, "release artifact name"); + const localPath = join(distDir, name); + const metadata = await stat(localPath); + if (!metadata.isFile()) continue; + const uploaded = await backend.putImmutable( + localPath, + `${version}/${name}`, + contentType(name), + ); + backendSummary.assets.push({ name, status: uploaded.status }); + } + const candidate = await backend.putImmutable( + candidatePath, + candidateKey, + "application/json", + { "cam-promotion-token": promotionToken }, + ); + backendSummary.candidate = { key: candidateKey, status: candidate.status }; + backendSummary.status = "staged"; + } + await finishSummary(summaryPath, summary, "staged"); + return summary; + } catch (error) { + if (activeSummary?.status === "staging") { + activeSummary.status = "failed"; + activeSummary.error = safeSummaryError(error); + } + await finishSummary(summaryPath, summary, "failed", error); + throw error; + } +} + +async function readCurrentState(backend, workDir, candidateManifest) { + const path = join(workDir, `${backend.name}-previous-latest.json`); + const snapshot = await backend.snapshot(LATEST_KEY, path); + if (!snapshot.exists) { + throw new Error( + `${backend.name}: latest.json is absent; seed both backends with the same valid baseline before enabling transactional promotion`, + ); + } + const manifest = await readJson(path, `${backend.name} current latest.json`); + const currentVersion = assertManifestShape(manifest, `${backend.name} current latest.json`); + const candidateVersion = assertManifestShape(candidateManifest, "candidate latest.json"); + const comparison = compareSemver(currentVersion, candidateVersion); + let decision; + if (comparison < 0) decision = "promote-forward"; + else if (comparison > 0) decision = "blocked-downgrade"; + else if (manifestReleaseIdentity(manifest) === manifestReleaseIdentity(candidateManifest)) { + decision = "idempotent"; + } else { + decision = "blocked-same-version-mismatch"; + } + return { + backend, + currentIdentity: manifestReleaseIdentity(manifest), + currentManifest: manifest, + currentVersion, + decision, + previous: snapshot, + }; +} + +async function snapshotMatchesFile(snapshot, expectedPath) { + if (!snapshot.exists) return false; + const [actual, expected] = await Promise.all([ + sha256File(snapshot.path), + sha256File(expectedPath), + ]); + return actual === expected; +} + +async function observeAmbiguousWrite( + state, + candidatePath, + workDir, + promotionToken, +) { + try { + const observed = await state.backend.snapshot( + LATEST_KEY, + join(workDir, `${state.backend.name}-ambiguous-latest.json`), + ); + if ( + observed.metadata?.["cam-promotion-token"] === promotionToken && + (await snapshotMatchesFile(observed, candidatePath)) + ) { + return observed; + } + } catch { + // The original write error remains the primary failure. Rollback reporting + // will make any uncertainty visible in the audit summary. + } + return null; +} + +async function withRollbackIo(callback) { + const previous = rollbackInProgress; + rollbackInProgress = true; + try { + return await callback(); + } finally { + rollbackInProgress = previous; + } +} + +async function rollbackTouched(touched, summaryByName, workDir) { + const failures = []; + rollbackInProgress = true; + try { + for (const item of [...touched].reverse()) { + const backendSummary = summaryByName.get(item.state.backend.name); + try { + const owned = await item.state.backend.snapshot( + LATEST_KEY, + join(workDir, `${item.state.backend.name}-rollback-ownership.json`), + ); + if ( + !owned.exists || + owned.etag !== item.promotedEtag || + owned.metadata?.["cam-promotion-token"] !== item.promotionToken + ) { + throw new ConditionalWriteError( + `${item.state.backend.name}: latest.json is no longer owned by this promotion`, + ); + } + if (item.state.previous.exists) { + const restored = await item.state.backend.putLatestConditional( + item.state.previous.path, + item.promotedEtag, + `rollback-${randomUUID()}`, + ); + const check = await item.state.backend.snapshot( + LATEST_KEY, + join(workDir, `${item.state.backend.name}-rollback-check.json`), + ); + if (!(await snapshotMatchesFile(check, item.state.previous.path))) { + throw new Error("rollback verification did not match previous latest.json"); + } + item.promotedEtag = restored.etag; + } else { + throw new Error("transactional promotion cannot roll back an unseeded backend"); + } + backendSummary.rollback = "restored"; + } catch (error) { + backendSummary.rollback = "failed"; + backendSummary.rollbackError = safeSummaryError(error); + failures.push(`${item.state.backend.name}: ${errorText(error)}`); + } + } + } finally { + rollbackInProgress = false; + } + return failures; +} + +export async function promoteCandidateTransaction({ + backends, + candidateManifest, + candidatePath, + override, + summary, + workDir, + hooks = {}, + promotionToken = randomUUID(), +}) { + const summaryByName = new Map(summary.backends.map((entry) => [entry.name, entry])); + const states = []; + for (const backend of backends) { + try { + states.push(await readCurrentState(backend, workDir, candidateManifest)); + } catch (error) { + const backendSummary = summaryByName.get(backend.name); + if (backendSummary) backendSummary.error = safeSummaryError(error); + throw error; + } + } + await hooks.afterSnapshots?.(states); + + for (const state of states) { + const backendSummary = summaryByName.get(state.backend.name); + backendSummary.currentVersion = state.currentVersion; + backendSummary.decision = state.decision; + } + + const mismatch = states.find((state) => state.decision === "blocked-same-version-mismatch"); + if (mismatch) { + throw new Error( + `${mismatch.backend.name}: current latest has the candidate version but different artifact/signature identity`, + ); + } + const downgradeStates = states.filter((state) => state.decision === "blocked-downgrade"); + if (downgradeStates.length > 0 && !override.requested) { + throw new DowngradeBlockedError( + `candidate ${candidateManifest.version} is older than ${downgradeStates + .map((state) => `${state.backend.name}=${state.currentVersion}`) + .join(", ")}`, + ); + } + if (downgradeStates.length > 0) { + override.used = true; + summary.override.used = true; + for (const state of downgradeStates) { + state.decision = "promote-downgrade-override"; + summaryByName.get(state.backend.name).decision = state.decision; + } + } + + const toPromote = states.filter((state) => state.decision !== "idempotent"); + if (toPromote.length === 0) { + for (const state of states) { + const backendSummary = summaryByName.get(state.backend.name); + try { + const finalSnapshot = await state.backend.snapshot( + LATEST_KEY, + join(workDir, `${state.backend.name}-idempotent-final.json`), + ); + if (!finalSnapshot.exists || finalSnapshot.etag !== state.previous.etag) { + throw new ConditionalWriteError( + `${state.backend.name}: latest.json changed after the idempotent snapshot`, + ); + } + const finalManifest = await readJson( + finalSnapshot.path, + `${state.backend.name} idempotent final latest.json`, + ); + if ( + manifestReleaseIdentity(finalManifest) !== + manifestReleaseIdentity(candidateManifest) + ) { + throw new ConditionalWriteError( + `${state.backend.name}: latest.json no longer exposes the idempotent candidate`, + ); + } + backendSummary.finalVersion = finalManifest.version; + } catch (error) { + backendSummary.error = safeSummaryError(error); + throw error; + } + } + return { outcome: "idempotent", states }; + } + + const touched = []; + let activeState = null; + try { + for (const state of toPromote) { + activeState = state; + const backendSummary = summaryByName.get(state.backend.name); + await hooks.beforeWrite?.(state); + let promoted; + try { + promoted = await state.backend.putLatestConditional( + candidatePath, + state.previous.etag, + promotionToken, + ); + } catch (error) { + if (error instanceof ConditionalWriteError) { + throw error; + } + const ambiguous = await withRollbackIo(() => + observeAmbiguousWrite( + state, + candidatePath, + workDir, + promotionToken, + ), + ); + if (ambiguous) { + touched.push({ + state, + promotedEtag: ambiguous.etag, + promotionToken, + }); + backendSummary.promotion = "write-outcome-ambiguous"; + } else { + backendSummary.promotion = "write-outcome-unresolved"; + backendSummary.rollback = "uncertain"; + throw new WriteOutcomeUncertainError( + `${state.backend.name}: write failed and ownership could not be resolved after ${errorText(error)}`, + ); + } + throw error; + } + const touchedItem = { + state, + promotedEtag: promoted.etag, + promotionToken, + }; + touched.push(touchedItem); + backendSummary.promotion = "written"; + const check = await state.backend.snapshot( + LATEST_KEY, + join(workDir, `${state.backend.name}-promoted-check.json`), + ); + if ( + check.etag !== promoted.etag || + check.metadata?.["cam-promotion-token"] !== promotionToken + ) { + touched.pop(); + backendSummary.promotion = "ownership-lost"; + backendSummary.rollback = "skipped-concurrent-change"; + throw new ConditionalWriteError( + `${state.backend.name}: latest.json changed after the conditional write`, + ); + } + if (!(await snapshotMatchesFile(check, candidatePath))) { + throw new Error(`${state.backend.name}: promoted latest.json failed byte verification`); + } + backendSummary.promotion = "verified"; + await hooks.afterWrite?.(state); + } + + for (const state of states) { + activeState = state; + const finalSnapshot = await state.backend.snapshot( + LATEST_KEY, + join(workDir, `${state.backend.name}-final-latest.json`), + ); + if (!finalSnapshot.exists) throw new Error(`${state.backend.name}: latest.json disappeared`); + const finalManifest = await readJson( + finalSnapshot.path, + `${state.backend.name} final latest.json`, + ); + if (manifestReleaseIdentity(finalManifest) !== manifestReleaseIdentity(candidateManifest)) { + throw new Error(`${state.backend.name}: final latest.json does not expose the candidate release`); + } + summaryByName.get(state.backend.name).finalVersion = finalManifest.version; + } + return { outcome: override.used ? "downgrade-override-promoted" : "promoted", states }; + } catch (error) { + if (activeState) { + summaryByName.get(activeState.backend.name).error = safeSummaryError(error); + } + const rollbackFailures = await rollbackTouched(touched, summaryByName, workDir); + const ownershipUncertain = error instanceof WriteOutcomeUncertainError; + const failures = ownershipUncertain + ? [...rollbackFailures, `${activeState?.backend.name || "backend"}: write ownership unresolved`] + : rollbackFailures; + summary.rollback = { + attempted: touched.length > 0, + complete: failures.length === 0, + failures, + }; + if (failures.length > 0) { + throw new Error( + `${errorText(error)}; rollback incomplete: ${failures.join("; ")}`, + ); + } + if (touched.length > 0) summary.outcome = "rolled-back"; + throw error; + } +} + +function mirrorVerificationRows(backends, includeTransaction = false) { + return backends.map((backend) => ({ + name: backend.name, + candidateVerification: "not-started", + ...(includeTransaction + ? { + currentVersion: null, + decision: null, + promotion: "not-started", + rollback: "not-needed", + } + : {}), + })); +} + +async function verifyMirrorCandidates({ + backends, + candidateKey, + candidateManifest, + candidatePath, + distDir, + mirrorBase, + publicKey, + summary, + tempRoot, + fetchImpl = fetch, +}) { + const [expectedArtifacts, candidateSha256] = await Promise.all([ + expectedArtifactsFromManifest(candidateManifest, distDir, mirrorBase), + sha256File(candidatePath), + ]); + const promotionToken = promotionTokenForCandidate(candidateKey); + + // Both storage origins must finish a complete candidate + artifact readback + // before any mutable latest.json write. Verification is sequential to cap + // runner disk use. + for (const backend of backends) { + const backendSummary = summary.backends.find((entry) => entry.name === backend.name); + backendSummary.candidateVerification = "running"; + try { + backendSummary.candidate = await verifyBackendCandidate({ + backend, + candidateKey, + candidatePath, + candidateManifest, + distDir, + mirrorBase, + publicKey, + workDir: join(tempRoot, "backend"), + expectedArtifacts, + candidateSha256, + promotionToken, + }); + backendSummary.candidateVerification = "passed"; + } catch (error) { + backendSummary.candidateVerification = "failed"; + backendSummary.error = safeSummaryError(error); + throw error; + } + } + + // Direct S3 readback does not prove that the Worker route used by clients is + // healthy or bound to the expected bucket. Fetch the run-specific candidate + // and every updater payload through their real public HTTPS URLs as a separate + // fail-closed gate. + summary.publicRouteVerification = "running"; + try { + summary.publicRoute = await verifyPublicMirrorRoute({ + candidateKey, + candidateManifest, + candidatePath, + expectedArtifacts, + mirrorBase, + publicKey, + workDir: join(tempRoot, "public"), + fetchImpl, + }); + summary.publicRouteVerification = "passed"; + } catch (error) { + summary.publicRouteVerification = "failed"; + summary.publicRouteError = safeSummaryError(error); + throw error; + } + + return { candidateSha256, expectedArtifacts, promotionToken }; +} + +export async function verifyMirrors({ + backends, + candidateKey, + candidateManifest, + candidatePath, + distDir, + mirrorBase, + override, + publicKey, + summaryPath, + tempRoot, + fetchImpl, +}) { + const version = assertManifestShape(candidateManifest, "candidate latest.json"); + assertRequiredUpdaterPlatforms(candidateManifest, "candidate latest.json"); + const summary = initialSummary("verify", version, candidateKey, override); + summary.backends = mirrorVerificationRows(backends); + summary.publicRouteVerification = "not-started"; + try { + await verifyMirrorCandidates({ + backends, + candidateKey, + candidateManifest, + candidatePath, + distDir, + mirrorBase, + publicKey, + summary, + tempRoot, + fetchImpl, + }); + await finishSummary(summaryPath, summary, "verified"); + return summary; + } catch (error) { + await finishSummary(summaryPath, summary, "failed", error); + throw error; + } +} + +export async function promoteMirrors({ + backends, + candidateKey, + candidateManifest, + candidatePath, + distDir, + mirrorBase, + override, + publicKey, + summaryPath, + tempRoot, + hooks, + fetchImpl, +}) { + const version = assertManifestShape(candidateManifest, "candidate latest.json"); + assertRequiredUpdaterPlatforms(candidateManifest, "candidate latest.json"); + const summary = initialSummary("promote", version, candidateKey, override); + summary.backends = mirrorVerificationRows(backends, true); + summary.publicRouteVerification = "not-started"; + try { + const { promotionToken } = await verifyMirrorCandidates({ + backends, + candidateKey, + candidateManifest, + candidatePath, + distDir, + mirrorBase, + publicKey, + summary, + tempRoot: join(tempRoot, "verify"), + fetchImpl, + }); + + const transaction = await promoteCandidateTransaction({ + backends, + candidateManifest, + candidatePath, + override, + summary, + workDir: join(tempRoot, "transaction"), + hooks, + promotionToken, + }); + await finishSummary(summaryPath, summary, transaction.outcome); + return summary; + } catch (error) { + const outcome = + error instanceof DowngradeBlockedError + ? "blocked-downgrade" + : summary.outcome === "rolled-back" + ? "rolled-back" + : "failed"; + await finishSummary(summaryPath, summary, outcome, error); + throw error; + } +} + +async function createAwsConfig(tempRoot) { + const path = join(tempRoot, "aws-config"); + await writeFile(path, "[default]\nregion = auto\ns3 =\n addressing_style = path\n"); + return path; +} + +function installSignalHandlers() { + const handler = (signal) => { + if (interruptedBy) return; + interruptedBy = signal; + for (const child of activeChildren) child.kill("SIGTERM"); + }; + process.on("SIGINT", handler); + process.on("SIGTERM", handler); + return () => { + process.off("SIGINT", handler); + process.off("SIGTERM", handler); + }; +} + +async function main() { + const distArg = process.argv[2]; + if (!distArg) throw new Error("usage: sync-mirror.sh "); + const distDir = resolve(distArg); + const phase = process.env.MIRROR_PHASE || "all"; + if (!["all", "stage", "verify", "promote"].includes(phase)) { + throw new Error( + `unsupported MIRROR_PHASE=${phase} (expected all, stage, verify, or promote)`, + ); + } + const override = downgradeOverrideFromEnv(process.env); + if (override.requested) { + console.log( + `::warning::[mirror] emergency downgrade override requested by ${safeSummaryError( + override.actor, + )}: ${safeSummaryError(override.reason)} (${override.runUrl})`, + ); + } + const candidateId = candidateIdFromEnv(process.env); + const mirror = await createMirrorManifest( + distDir, + process.env.MIRROR_BASE_URL || DEFAULT_MIRROR_BASE, + ); + const candidateKey = candidateKeyFor(mirror.version, candidateId); + const tempRoot = await mkdtemp(join(tmpdir(), "cam-mirror-release-")); + const removeSignalHandlers = installSignalHandlers(); + try { + const configPath = await createAwsConfig(tempRoot); + const backends = backendConfigsFromEnv(process.env, configPath); + if (phase === "all" || phase === "stage") { + const stageSummaryPath = resolve( + process.env.MIRROR_STAGE_SUMMARY_PATH || "mirror-stage-summary.json", + ); + console.log(`::group::[mirror] stage ${mirror.version} candidate=${candidateKey}`); + await stageMirrors({ + backends, + candidateKey, + candidatePath: mirror.outputPath, + distDir, + summaryPath: stageSummaryPath, + version: mirror.version, + override, + }); + console.log("::endgroup::"); + } + if (phase === "verify") { + const verificationSummaryPath = resolve( + process.env.MIRROR_VERIFICATION_SUMMARY_PATH || + "mirror-verification-summary.json", + ); + const publicKey = process.env.MIRROR_UPDATER_PUBLIC_KEY || (await updaterPublicKeyFromRepo()); + console.log(`::group::[mirror] verify ${mirror.version}`); + await verifyMirrors({ + backends, + candidateKey, + candidateManifest: mirror.manifest, + candidatePath: mirror.outputPath, + distDir, + mirrorBase: mirror.mirrorBase, + override, + publicKey, + summaryPath: verificationSummaryPath, + tempRoot, + }); + console.log("::endgroup::"); + } + if (phase === "all" || phase === "promote") { + const promotionSummaryPath = resolve( + process.env.MIRROR_PROMOTION_SUMMARY_PATH || "mirror-promotion-summary.json", + ); + const publicKey = process.env.MIRROR_UPDATER_PUBLIC_KEY || (await updaterPublicKeyFromRepo()); + console.log(`::group::[mirror] verify and promote ${mirror.version}`); + await promoteMirrors({ + backends, + candidateKey, + candidateManifest: mirror.manifest, + candidatePath: mirror.outputPath, + distDir, + mirrorBase: mirror.mirrorBase, + override, + publicKey, + summaryPath: promotionSummaryPath, + tempRoot, + }); + console.log("::endgroup::"); + } + } finally { + removeSignalHandlers(); + await rm(tempRoot, { force: true, recursive: true }); + } + if (interruptedBy) throw new Error(`release interrupted by ${interruptedBy}`); +} + +const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + main().catch((error) => { + console.error(`::error::[mirror] ${safeSummaryError(error)}`); + process.exitCode = interruptedBy === "SIGINT" ? 130 : interruptedBy === "SIGTERM" ? 143 : 1; + }); +} diff --git a/scripts/mirror-release.test.mjs b/scripts/mirror-release.test.mjs new file mode 100644 index 0000000..3039574 --- /dev/null +++ b/scripts/mirror-release.test.mjs @@ -0,0 +1,1345 @@ +import { + createHash, + generateKeyPairSync, + sign as cryptoSign, +} from "node:crypto"; +import { execFile } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + AwsObjectStore, + ConditionalWriteError, + DowngradeBlockedError, + assertCandidateMatchesRelease, + candidateKeyFor, + compareSemver, + createMirrorManifest, + downgradeOverrideFromEnv, + promoteCandidateTransaction, + promoteMirrors, + verifyBackendCandidate, + verifyLocalUpdaterArtifacts, + verifyMirrors, + verifyPublicMirrorRoute, + verifyTauriUpdaterSignature, +} from "./mirror-release.mjs"; +import { + inspectReleaseForReuse, + requiredReleaseAssetNames, +} from "./check-release-reuse.mjs"; + +const roots = []; +const execFileAsync = promisify(execFile); + +async function tempRoot(name) { + const root = await mkdtemp(join(tmpdir(), `cam-${name}-`)); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +function hash(body) { + return createHash("sha256").update(body).digest("hex"); +} + +function manifest(version, signature = "test-signature") { + return { + version, + notes: `release ${version}`, + pub_date: "2026-01-01T00:00:00.000Z", + platforms: { + "windows-x86_64": { + signature, + url: `https://mirror.example/manager/${version}/manager-${version}.exe`, + }, + }, + }; +} + +function completeManifest(version, signature = "test-signature") { + return { + version, + notes: `release ${version}`, + pub_date: "2026-01-01T00:00:00.000Z", + platforms: Object.fromEntries( + [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", + "windows-aarch64", + ].map((platform) => [ + platform, + { + signature, + url: `https://mirror.example/manager/${version}/manager-${platform}-${version}.bin`, + }, + ]), + ), + }; +} + +function completeRelease(releaseTag, overrides = {}) { + const digest = `sha256:${"a".repeat(64)}`; + return { + draft: false, + immutable: true, + assets: requiredReleaseAssetNames(releaseTag).map((name) => ({ + digest, + name, + size: 1, + })), + ...overrides, + }; +} + +async function writeManifest(root, name, value) { + const path = join(root, name); + await mkdir(root, { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); + return path; +} + +function summaryFor(backends, override = overrideOff()) { + return { + backends: backends.map((backend) => ({ + name: backend.name, + candidateVerification: "passed", + promotion: "not-started", + rollback: "not-needed", + })), + outcome: "running", + override: { ...override }, + }; +} + +function overrideOff() { + return { + actor: "", + eventName: "push", + reason: "", + requested: false, + runUrl: "", + used: false, + }; +} + +class MemoryBackend { + constructor(name, objects = {}) { + this.name = name; + this.objects = new Map(); + this.counter = 0; + this.latestPutAttempts = 0; + for (const [key, body] of Object.entries(objects)) this.set(key, body); + } + + set(key, body, metadata = {}) { + const bytes = Buffer.isBuffer(body) ? Buffer.from(body) : Buffer.from(body); + this.counter += 1; + this.objects.set(key, { + body: bytes, + etag: `"${hash(bytes)}-${this.counter}"`, + metadata: { ...metadata }, + }); + } + + body(key) { + return this.objects.get(key)?.body; + } + + async head(key) { + const object = this.objects.get(key); + return object + ? { + etag: object.etag, + metadata: { ...object.metadata }, + size: object.body.length, + } + : null; + } + + async snapshot(key, destination) { + const object = this.objects.get(key); + if (!object) return { exists: false, etag: null, path: null, size: 0 }; + await mkdir(join(destination, ".."), { recursive: true }); + await writeFile(destination, object.body); + return { + exists: true, + etag: object.etag, + metadata: { ...object.metadata }, + path: destination, + size: object.body.length, + }; + } + + async putImmutable(localPath, key, _contentType, metadata = {}) { + const existing = this.objects.get(key); + if (existing) return { status: "existing", etag: existing.etag, size: existing.body.length }; + const body = await readFile(localPath); + this.set(key, body, metadata); + const stored = this.objects.get(key); + return { status: "uploaded", etag: stored.etag, size: stored.body.length }; + } + + async putLatestConditional(localPath, expectedEtag, promotionToken = "") { + this.latestPutAttempts += 1; + const current = this.objects.get("latest.json"); + const matches = expectedEtag ? current?.etag === expectedEtag : !current; + if (!matches) throw new ConditionalWriteError(`${this.name}: stale latest ETag`); + if (this.failLatestPut) throw new Error(`${this.name}: simulated write failure`); + this.set( + "latest.json", + await readFile(localPath), + promotionToken ? { "cam-promotion-token": promotionToken } : {}, + ); + const stored = this.objects.get("latest.json"); + return { etag: stored.etag, size: stored.body.length }; + } + + async deleteLatestConditional(expectedEtag) { + const current = this.objects.get("latest.json"); + if (!current || current.etag !== expectedEtag) { + throw new ConditionalWriteError(`${this.name}: stale delete ETag`); + } + this.objects.delete("latest.json"); + } +} + +function encodedUpdaterSignature(privateKey, keyId, artifact) { + const digest = createHash("blake2b512").update(artifact).digest(); + const signature = cryptoSign(null, digest, privateKey); + const trustedComment = "timestamp:1700000000\tfile:test-artifact"; + const globalSignature = cryptoSign( + null, + Buffer.concat([signature, Buffer.from(trustedComment)]), + privateKey, + ); + const primary = Buffer.concat([Buffer.from("ED"), keyId, signature]); + const text = [ + "untrusted comment: signature from test key", + primary.toString("base64"), + `trusted comment: ${trustedComment}`, + globalSignature.toString("base64"), + ].join("\n"); + return Buffer.from(`${text}\n`).toString("base64"); +} + +function updaterFixture(artifact) { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const publicDer = publicKey.export({ format: "der", type: "spki" }); + const rawPublicKey = publicDer.subarray(publicDer.length - 32); + const keyId = Buffer.from("0102030405060708", "hex"); + const minisignPublic = Buffer.concat([Buffer.from("Ed"), keyId, rawPublicKey]); + const publicText = [ + "untrusted comment: minisign public key: test", + minisignPublic.toString("base64"), + ].join("\n"); + return { + publicKey: Buffer.from(`${publicText}\n`).toString("base64"), + signature: encodedUpdaterSignature(privateKey, keyId, artifact), + }; +} + +function publicRouteFetch(objects, requests = [], { ihepObjects = objects } = {}) { + return async (value) => { + const url = new URL(value); + requests.push(url); + if (url.hostname === "ihep.example") { + const body = ihepObjects.get(url.pathname); + return body === undefined + ? new Response("not found", { status: 404 }) + : new Response(body, { status: 200 }); + } + const backend = url.searchParams.get("cam_backend"); + if (backend === "ihep") { + return new Response(null, { + status: 302, + headers: { + Location: `https://ihep.example${url.pathname}`, + "X-Codex-Mirror-Backend": "ihep", + }, + }); + } + const body = objects.get(url.pathname); + return body === undefined + ? new Response("not found", { status: 404 }) + : new Response(body, { + status: 200, + headers: { "X-Codex-Mirror-Backend": "r2" }, + }); + }; +} + +describe("semantic release ordering", () => { + it("orders stable and prerelease versions without lexical mistakes", () => { + expect(compareSemver("1.10.0", "1.9.9")).toBe(1); + expect(compareSemver("1.0.0-rc.2", "1.0.0-rc.10")).toBe(-1); + expect(compareSemver("1.0.0", "1.0.0-rc.10")).toBe(1); + expect(compareSemver("v2.0.0+build.1", "2.0.0+build.2")).toBe(0); + }); +}); + +describe("release candidate binding", () => { + it("binds the candidate version and complete platform identity to the release tag", () => { + const derived = completeManifest("1.2.3", "artifact-derived-signature"); + const candidate = structuredClone(derived); + + expect(assertCandidateMatchesRelease(candidate, derived, "v1.2.3")).toEqual({ + platformCount: 4, + version: "1.2.3", + }); + + const wrongVersion = structuredClone(candidate); + wrongVersion.version = "9.9.9"; + expect(() => assertCandidateMatchesRelease(wrongVersion, derived, "v1.2.3")).toThrow( + "does not match release tag", + ); + + const missingPlatform = structuredClone(candidate); + delete missingPlatform.platforms["windows-aarch64"]; + expect(() => assertCandidateMatchesRelease(missingPlatform, derived, "v1.2.3")).toThrow( + "missing required updater platforms: windows-aarch64", + ); + + const changedSignature = structuredClone(candidate); + changedSignature.platforms["windows-x86_64"].signature = "different-signature"; + expect(() => assertCandidateMatchesRelease(changedSignature, derived, "v1.2.3")).toThrow( + "platforms/signatures do not match", + ); + }); +}); + +describe("existing GitHub Release reuse", () => { + it("requires GitHub immutability and canonical SHA-256 asset digests", () => { + const releaseTag = "v1.2.3"; + const valid = inspectReleaseForReuse(completeRelease(releaseTag), releaseTag); + expect(valid.reusable).toBe(true); + expect(Object.keys(valid.digests)).toHaveLength(11); + + expect(() => + inspectReleaseForReuse(completeRelease(releaseTag, { immutable: false }), releaseTag), + ).toThrow("is mutable"); + + const mutableAndIncomplete = completeRelease(releaseTag, { immutable: false }); + mutableAndIncomplete.assets.pop(); + expect(() => inspectReleaseForReuse(mutableAndIncomplete, releaseTag)).toThrow("is mutable"); + + const immutableAndIncomplete = completeRelease(releaseTag); + immutableAndIncomplete.assets.pop(); + expect(() => inspectReleaseForReuse(immutableAndIncomplete, releaseTag)).toThrow( + "is missing required assets and cannot be repaired", + ); + + const repairableDraft = completeRelease(releaseTag, { draft: true, immutable: false }); + repairableDraft.assets.pop(); + expect(inspectReleaseForReuse(repairableDraft, releaseTag)).toMatchObject({ + reason: "draft", + reusable: false, + }); + + const missingDigest = completeRelease(releaseTag); + delete missingDigest.assets[0].digest; + expect(() => inspectReleaseForReuse(missingDigest, releaseTag)).toThrow( + "has no canonical SHA-256 digest", + ); + + const unpinnedExtra = completeRelease(releaseTag); + unpinnedExtra.assets.push({ name: "CodexAppManager_extra.zip", size: 10 }); + expect(() => inspectReleaseForReuse(unpinnedExtra, releaseTag)).toThrow( + "has no canonical SHA-256 digest", + ); + }); +}); + +describe("Tauri updater verification", () => { + it("accepts the configured minisign format and rejects changed bytes", async () => { + const root = await tempRoot("updater-signature"); + const artifact = Buffer.from("signed updater bytes"); + const fixture = updaterFixture(artifact); + const path = join(root, "artifact.bin"); + await writeFile(path, artifact); + + await expect( + verifyTauriUpdaterSignature(path, fixture.signature, fixture.publicKey), + ).resolves.toBe(true); + + await writeFile(path, Buffer.from("tampered updater bytes")); + await expect( + verifyTauriUpdaterSignature(path, fixture.signature, fixture.publicKey), + ).rejects.toThrow("artifact signature is invalid"); + }); + + it("verifies local manifest payloads and sidecars before publication", async () => { + const root = await tempRoot("local-updater-signatures"); + const artifact = Buffer.from("locally signed updater bytes"); + const fixture = updaterFixture(artifact); + const candidate = manifest("1.2.3", fixture.signature); + const artifactName = "manager-1.2.3.exe"; + await writeFile(join(root, artifactName), artifact); + await writeFile(join(root, `${artifactName}.sig`), `${fixture.signature}\n`); + + await expect( + verifyLocalUpdaterArtifacts({ + manifest: candidate, + distDir: root, + publicKey: fixture.publicKey, + }), + ).resolves.toMatchObject({ artifactCount: 1, verified: true }); + + const wrongKey = updaterFixture(Buffer.from("different release")).publicKey; + await expect( + verifyLocalUpdaterArtifacts({ + manifest: candidate, + distDir: root, + publicKey: wrongKey, + }), + ).rejects.toThrow("artifact signature is invalid"); + }); +}); + +describe("public mirror route verification", () => { + it("downloads the candidate and every updater payload through public URLs", async () => { + const root = await tempRoot("public-route"); + const artifact = Buffer.from("public updater payload"); + const fixture = updaterFixture(artifact); + const candidate = completeManifest("1.2.3", fixture.signature); + const candidateKey = candidateKeyFor("1.2.3", "public-run"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const objects = new Map([ + [`/manager/${candidateKey}`, await readFile(candidatePath)], + ]); + const expectedArtifacts = Object.entries(candidate.platforms).map( + ([platform, entry]) => { + const name = new URL(entry.url).pathname.split("/").at(-1); + objects.set(`/manager/1.2.3/${name}`, artifact); + return { + key: `1.2.3/${name}`, + localPath: join(root, name), + name, + platform, + sha256: hash(artifact), + signature: fixture.signature, + size: artifact.length, + }; + }, + ); + const requests = []; + + const report = await verifyPublicMirrorRoute({ + candidateKey, + candidateManifest: candidate, + candidatePath, + expectedArtifacts, + mirrorBase: "https://mirror.example/manager", + publicKey: fixture.publicKey, + workDir: join(root, "verify"), + fetchImpl: publicRouteFetch(objects, requests), + }); + + expect(report).toMatchObject({ artifactCount: 4, backendCount: 2, verified: true }); + expect(report.backends.r2).toMatchObject({ artifactCount: 4, verified: true }); + expect(report.backends.ihep).toMatchObject({ artifactCount: 4, verified: true }); + expect(requests).toHaveLength(15); + const workerRequests = requests.filter((url) => url.hostname === "mirror.example"); + expect(workerRequests).toHaveLength(10); + expect(workerRequests.every((url) => url.searchParams.has("cam_probe"))).toBe(true); + expect(new Set(workerRequests.map((url) => url.searchParams.get("cam_backend")))).toEqual( + new Set(["r2", "ihep"]), + ); + + const corruptedName = expectedArtifacts[0].name; + const corruptedIhep = new Map(objects); + corruptedIhep.set(`/manager/1.2.3/${corruptedName}`, Buffer.from("corrupt")); + await expect( + verifyPublicMirrorRoute({ + candidateKey, + candidateManifest: candidate, + candidatePath, + expectedArtifacts, + mirrorBase: "https://mirror.example/manager", + publicKey: fixture.publicKey, + workDir: join(root, "corrupt"), + fetchImpl: publicRouteFetch(objects, [], { ihepObjects: corruptedIhep }), + }), + ).rejects.toThrow(`public mirror ihep size mismatch for ${corruptedName}`); + }); +}); + +describe("AWS conditional writes", () => { + function objectStore() { + return new AwsObjectStore({ + name: "r2", + endpoint: "https://r2.example.invalid", + bucket: "manager", + region: "auto", + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + configPath: "/tmp/test-aws-config", + }); + } + + it("uses one attempt and trusts the committing PutObject response ETag", async () => { + const root = await tempRoot("conditional-put"); + const candidatePath = await writeManifest(root, "candidate.json", manifest("1.2.3")); + const backend = objectStore(); + let call; + backend.aws = async (args, options) => { + call = { args, options }; + return { code: 0, stderr: "", stdout: '{"ETag":"\\"committed-etag\\""}' }; + }; + backend.head = async () => { + throw new Error("post-write HEAD must not determine write ownership"); + }; + + const result = await backend.putLatestConditional( + candidatePath, + '"previous-etag"', + "promotion-token", + ); + + expect(result.etag).toBe('"committed-etag"'); + expect(call.options.env).toEqual({ AWS_MAX_ATTEMPTS: "1" }); + expect(call.args).toEqual( + expect.arrayContaining([ + "--if-match", + '"previous-etag"', + "--metadata", + "cam-promotion-token=promotion-token", + ]), + ); + }); + + it("reports a final 412 as a conditional conflict without claiming ownership", async () => { + const root = await tempRoot("conditional-conflict"); + const candidatePath = await writeManifest(root, "candidate.json", manifest("1.2.3")); + const backend = objectStore(); + backend.aws = async () => ({ + code: 1, + stderr: "PreconditionFailed (412)", + stdout: "", + }); + + await expect( + backend.putLatestConditional(candidatePath, '"stale-etag"', "promotion-token"), + ).rejects.toBeInstanceOf(ConditionalWriteError); + }); +}); + +describe("backend candidate verification", () => { + it("downloads candidate and artifact and verifies version, size, hash, and signature", async () => { + const root = await tempRoot("candidate-verify"); + const dist = join(root, "dist"); + await mkdir(dist, { recursive: true }); + const artifact = Buffer.from("release artifact from object storage"); + const fixture = updaterFixture(artifact); + const candidate = manifest("1.2.3", fixture.signature); + const artifactName = "manager-1.2.3.exe"; + const dmgName = "CodexAppManager_aarch64.dmg"; + const signatureName = `${artifactName}.sig`; + const dmg = Buffer.from("macOS installer bytes"); + const signatureSidecar = Buffer.from(`${fixture.signature}\n`); + await writeFile(join(dist, artifactName), artifact); + await writeFile(join(dist, dmgName), dmg); + await writeFile(join(dist, signatureName), signatureSidecar); + const candidatePath = await writeManifest(dist, "latest.mirror.json", candidate); + const key = candidateKeyFor("1.2.3", "run-1"); + const backend = new MemoryBackend("r2", { + [key]: await readFile(candidatePath), + [`1.2.3/${artifactName}`]: artifact, + [`1.2.3/${dmgName}`]: dmg, + [`1.2.3/${signatureName}`]: signatureSidecar, + }); + + const report = await verifyBackendCandidate({ + backend, + candidateKey: key, + candidatePath, + candidateManifest: candidate, + distDir: dist, + mirrorBase: "https://mirror.example/manager", + publicKey: fixture.publicKey, + workDir: join(root, "verify"), + }); + + expect(report.verified).toBe(true); + expect(report.artifactCount).toBe(3); + expect(report.artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: artifactName, + sha256: hash(artifact), + signatureVerified: true, + size: artifact.length, + }), + expect.objectContaining({ + name: dmgName, + sha256: hash(dmg), + signatureVerified: null, + size: dmg.length, + }), + expect.objectContaining({ + name: signatureName, + sha256: hash(signatureSidecar), + signatureVerified: null, + size: signatureSidecar.length, + }), + ]), + ); + }); + + it("rejects an empty platform signature instead of silently skipping verification", async () => { + const root = await tempRoot("candidate-empty-signature"); + const dist = join(root, "dist"); + await mkdir(dist, { recursive: true }); + const candidate = manifest("1.2.3", ""); + const candidatePath = await writeManifest(dist, "latest.mirror.json", candidate); + const key = candidateKeyFor("1.2.3", "run-empty-signature"); + const backend = new MemoryBackend("r2", { + [key]: await readFile(candidatePath), + }); + + await expect( + verifyBackendCandidate({ + backend, + candidateKey: key, + candidatePath, + candidateManifest: candidate, + distDir: dist, + mirrorBase: "https://mirror.example/manager", + publicKey: "deliberately-invalid-public-key", + workDir: join(root, "verify"), + }), + ).rejects.toThrow("empty updater signature"); + }); + + it("requires each manifest signature to match its local sidecar", async () => { + const root = await tempRoot("candidate-sidecar-mismatch"); + const dist = join(root, "dist"); + await mkdir(dist, { recursive: true }); + const artifact = Buffer.from("release artifact from object storage"); + const fixture = updaterFixture(artifact); + const candidate = manifest("1.2.3", fixture.signature); + const artifactName = "manager-1.2.3.exe"; + await writeFile(join(dist, artifactName), artifact); + await writeFile(join(dist, `${artifactName}.sig`), "different-signature\n"); + const candidatePath = await writeManifest(dist, "latest.mirror.json", candidate); + const key = candidateKeyFor("1.2.3", "run-sidecar-mismatch"); + const backend = new MemoryBackend("r2", { + [key]: await readFile(candidatePath), + }); + + await expect( + verifyBackendCandidate({ + backend, + candidateKey: key, + candidatePath, + candidateManifest: candidate, + distDir: dist, + mirrorBase: "https://mirror.example/manager", + publicKey: fixture.publicKey, + workDir: join(root, "verify"), + }), + ).rejects.toThrow("sidecar does not match manifest"); + }); +}); + +describe("pre-publication mirror verification", () => { + it("verifies both origins and the public route without writing latest.json", async () => { + const root = await tempRoot("prepublish-verify"); + const dist = join(root, "dist"); + await mkdir(dist, { recursive: true }); + const artifact = Buffer.from("stable updater payload"); + const fixture = updaterFixture(artifact); + const candidate = completeManifest("1.2.3", fixture.signature); + const candidatePath = await writeManifest(dist, "latest.mirror.json", candidate); + const candidateKey = candidateKeyFor("1.2.3", "verify-run"); + const candidateBytes = await readFile(candidatePath); + const stagedObjects = {}; + const publicObjects = new Map([[`/manager/${candidateKey}`, candidateBytes]]); + for (const entry of Object.values(candidate.platforms)) { + const name = new URL(entry.url).pathname.split("/").at(-1); + const sidecar = Buffer.from(`${fixture.signature}\n`); + await writeFile(join(dist, name), artifact); + await writeFile(join(dist, `${name}.sig`), sidecar); + stagedObjects[`1.2.3/${name}`] = artifact; + stagedObjects[`1.2.3/${name}.sig`] = sidecar; + publicObjects.set(`/manager/1.2.3/${name}`, artifact); + } + const current = Buffer.from(`${JSON.stringify(manifest("1.0.0"))}\n`); + const r2 = new MemoryBackend("r2", { + "latest.json": current, + [candidateKey]: candidateBytes, + ...stagedObjects, + }); + const ihep = new MemoryBackend("ihep", { + "latest.json": current, + [candidateKey]: candidateBytes, + ...stagedObjects, + }); + const promotionToken = hash(Buffer.from(candidateKey)); + r2.objects.get(candidateKey).metadata["cam-promotion-token"] = promotionToken; + ihep.objects.get(candidateKey).metadata["cam-promotion-token"] = promotionToken; + const summaryPath = join(root, "mirror-verification-summary.json"); + + const summary = await verifyMirrors({ + backends: [r2, ihep], + candidateKey, + candidateManifest: candidate, + candidatePath, + distDir: dist, + mirrorBase: "https://mirror.example/manager", + override: overrideOff(), + publicKey: fixture.publicKey, + summaryPath, + tempRoot: join(root, "verify"), + fetchImpl: publicRouteFetch(publicObjects), + }); + + expect(summary.outcome).toBe("verified"); + expect(summary.publicRouteVerification).toBe("passed"); + expect(Object.keys(summary.publicRoute.backends)).toEqual(["r2", "ihep"]); + expect(summary.backends.map((backend) => backend.candidateVerification)).toEqual([ + "passed", + "passed", + ]); + expect([r2.latestPutAttempts, ihep.latestPutAttempts]).toEqual([0, 0]); + expect(JSON.parse(await readFile(summaryPath, "utf8")).outcome).toBe("verified"); + + const corruptedName = new URL(Object.values(candidate.platforms)[0].url).pathname + .split("/") + .at(-1); + const corruptedIhep = new Map(publicObjects); + corruptedIhep.set(`/manager/1.2.3/${corruptedName}`, Buffer.from("corrupt")); + await expect( + promoteMirrors({ + backends: [r2, ihep], + candidateKey, + candidateManifest: candidate, + candidatePath, + distDir: dist, + mirrorBase: "https://mirror.example/manager", + override: overrideOff(), + publicKey: fixture.publicKey, + summaryPath: join(root, "failed-promotion-summary.json"), + tempRoot: join(root, "failed-promote"), + fetchImpl: publicRouteFetch(publicObjects, [], { ihepObjects: corruptedIhep }), + }), + ).rejects.toThrow(`public mirror ihep size mismatch for ${corruptedName}`); + expect([r2.latestPutAttempts, ihep.latestPutAttempts]).toEqual([0, 0]); + }); +}); + +describe("monotonic mirror promotion", () => { + it("blocks an old tag rerun without writing either backend", async () => { + const root = await tempRoot("old-rerun"); + const current = manifest("2.0.0"); + const candidate = manifest("1.9.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(current)}\n`; + const backends = [ + new MemoryBackend("r2", { "latest.json": initial }), + new MemoryBackend("ihep", { "latest.json": initial }), + ]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }), + ).rejects.toBeInstanceOf(DowngradeBlockedError); + + expect(backends.map((backend) => backend.latestPutAttempts)).toEqual([0, 0]); + expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ + "2.0.0", + "2.0.0", + ]); + }); + + it("treats a same-version rerun as a no-write idempotent success", async () => { + const root = await tempRoot("same-version"); + const current = manifest("2.0.0"); + current.pub_date = "2026-01-01T00:00:00Z"; + const candidate = manifest("2.0.0"); + candidate.pub_date = "2026-02-01T00:00:00Z"; + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const original = Buffer.from(`${JSON.stringify(current)}\n`); + const backends = [ + new MemoryBackend("r2", { "latest.json": original }), + new MemoryBackend("ihep", { "latest.json": original }), + ]; + + const result = await promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }); + + expect(result.outcome).toBe("idempotent"); + expect(backends.map((backend) => backend.latestPutAttempts)).toEqual([0, 0]); + expect(backends.every((backend) => backend.body("latest.json").equals(original))).toBe(true); + }); + + it("rejects a concurrent latest change before returning idempotent", async () => { + const root = await tempRoot("same-version-race"); + const candidate = manifest("2.0.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const original = Buffer.from(`${JSON.stringify(candidate)}\n`); + const r2 = new MemoryBackend("r2", { "latest.json": original }); + const ihep = new MemoryBackend("ihep", { "latest.json": original }); + const backends = [r2, ihep]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "transaction"), + hooks: { + afterSnapshots: () => { + ihep.set("latest.json", `${JSON.stringify(manifest("2.1.0"))}\n`); + }, + }, + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect([r2.latestPutAttempts, ihep.latestPutAttempts]).toEqual([0, 0]); + expect(JSON.parse(r2.body("latest.json")).version).toBe("2.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("2.1.0"); + }); + + it("uses conditional writes so a concurrent newer release wins without mixed latest pointers", async () => { + const root = await tempRoot("concurrent"); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const backends = [ + new MemoryBackend("r2", { "latest.json": initial }), + new MemoryBackend("ihep", { "latest.json": initial }), + ]; + const older = manifest("1.1.0"); + const newer = manifest("1.2.0"); + const olderPath = await writeManifest(root, "older.json", older); + const newerPath = await writeManifest(root, "newer.json", newer); + let releaseOlderSnapshots; + let olderSnapshotsReached; + const holdOlder = new Promise((resolvePromise) => { + releaseOlderSnapshots = resolvePromise; + }); + const olderReady = new Promise((resolvePromise) => { + olderSnapshotsReached = resolvePromise; + }); + + const olderRun = promoteCandidateTransaction({ + backends, + candidateManifest: older, + candidatePath: olderPath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "older-transaction"), + hooks: { + afterSnapshots: async () => { + olderSnapshotsReached(); + await holdOlder; + }, + }, + }); + await olderReady; + + await promoteCandidateTransaction({ + backends, + candidateManifest: newer, + candidatePath: newerPath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "newer-transaction"), + }); + releaseOlderSnapshots(); + + await expect(olderRun).rejects.toBeInstanceOf(ConditionalWriteError); + expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ + "1.2.0", + "1.2.0", + ]); + }); + + it("heals a hard-terminated partial promotion without rewriting the committed backend", async () => { + const root = await tempRoot("partial-terminated-transaction"); + const current = manifest("1.0.0"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(current)}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new MemoryBackend("ihep", { "latest.json": initial }); + // Model SIGKILL/OOM after the first conditional write committed but before + // the process could enter its rollback handler. + const r2Previous = await r2.head("latest.json"); + await r2.putLatestConditional(candidatePath, r2Previous.etag, "terminated-run"); + const r2AttemptsAfterTermination = r2.latestPutAttempts; + const backends = [r2, ihep]; + const failedResumeSummary = summaryFor(backends); + const realIhepPut = ihep.putLatestConditional.bind(ihep); + let failFirstResume = true; + ihep.putLatestConditional = async (...args) => { + if (failFirstResume) { + failFirstResume = false; + ihep.latestPutAttempts += 1; + throw new ConditionalWriteError("ihep: simulated conditional outage"); + } + return await realIhepPut(...args); + }; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "this-run", + summary: failedResumeSummary, + workDir: join(root, "failed-resume"), + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect(r2.latestPutAttempts).toBe(r2AttemptsAfterTermination); + expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ + "1.1.0", + "1.0.0", + ]); + expect(failedResumeSummary.rollback).toEqual( + expect.objectContaining({ attempted: false, complete: true }), + ); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "fresh-run", + summary: summaryFor(backends), + workDir: join(root, "successful-resume"), + }), + ).resolves.toEqual(expect.objectContaining({ outcome: "promoted" })); + + expect(r2.latestPutAttempts).toBe(r2AttemptsAfterTermination); + expect(ihep.latestPutAttempts).toBe(2); + expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ + "1.1.0", + "1.1.0", + ]); + expect(r2.objects.get("latest.json").metadata["cam-promotion-token"]).toBe( + "terminated-run", + ); + expect(ihep.objects.get("latest.json").metadata["cam-promotion-token"]).toBe("fresh-run"); + }); + + it("fails closed before writing when either backend has no baseline latest.json", async () => { + const root = await tempRoot("unseeded-backend"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const r2 = new MemoryBackend("r2", { + "latest.json": `${JSON.stringify(manifest("1.0.0"))}\n`, + }); + const ihep = new MemoryBackend("ihep"); + const backends = [r2, ihep]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }), + ).rejects.toThrow("seed both backends"); + + expect(backends.map((backend) => backend.latestPutAttempts)).toEqual([0, 0]); + expect(ihep.body("latest.json")).toBeUndefined(); + }); + + it("never rolls back an ETag that a concurrent writer owns", async () => { + const root = await tempRoot("etag-ownership"); + const current = manifest("1.0.0"); + const candidate = manifest("1.1.0"); + const external = manifest("1.2.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const r2 = new MemoryBackend("r2", { + "latest.json": `${JSON.stringify(current)}\n`, + }); + const ihep = new MemoryBackend("ihep", { + "latest.json": `${JSON.stringify(current)}\n`, + }); + const realPut = r2.putLatestConditional.bind(r2); + r2.putLatestConditional = async (...args) => { + const committed = await realPut(...args); + r2.set( + "latest.json", + `${JSON.stringify(external)}\n`, + { "cam-promotion-token": "external-run" }, + ); + return committed; + }; + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "this-run", + summary, + workDir: join(root, "transaction"), + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("1.2.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.0.0"); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe( + "skipped-concurrent-change", + ); + }); + + it("observes and rolls back a committed write whose process response was lost", async () => { + const root = await tempRoot("ambiguous-commit"); + const current = manifest("1.0.0"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = Buffer.from(`${JSON.stringify(current)}\n`); + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new MemoryBackend("ihep", { "latest.json": initial }); + const realPut = r2.putLatestConditional.bind(r2); + let firstWrite = true; + r2.putLatestConditional = async (localPath, expectedEtag, promotionToken) => { + if (!firstWrite) return await realPut(localPath, expectedEtag, promotionToken); + firstWrite = false; + r2.latestPutAttempts += 1; + const currentObject = r2.objects.get("latest.json"); + if (currentObject?.etag !== expectedEtag) { + throw new ConditionalWriteError("r2: stale latest ETag"); + } + r2.set( + "latest.json", + await readFile(localPath), + { "cam-promotion-token": promotionToken }, + ); + throw new Error("simulated process termination after server commit"); + }; + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "interrupted-run", + summary, + workDir: join(root, "transaction"), + }), + ).rejects.toThrow("simulated process termination"); + + expect(backends.every((backend) => backend.body("latest.json").equals(initial))).toBe(true); + expect(summary.rollback).toEqual( + expect.objectContaining({ attempted: true, complete: true }), + ); + }); + + it("rolls back the first backend when execution is interrupted between writes", async () => { + const root = await tempRoot("interrupted"); + const current = manifest("1.0.0"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = Buffer.from(`${JSON.stringify(current)}\n`); + const backends = [ + new MemoryBackend("r2", { "latest.json": initial }), + new MemoryBackend("ihep", { "latest.json": initial }), + ]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary, + workDir: join(root, "transaction"), + hooks: { + afterWrite: (state) => { + if (state.backend.name === "r2") throw new Error("simulated SIGTERM"); + }, + }, + }), + ).rejects.toThrow("simulated SIGTERM"); + + expect(backends.every((backend) => backend.body("latest.json").equals(initial))).toBe(true); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe("restored"); + expect(summary.backends.find((backend) => backend.name === "ihep").promotion).toBe( + "not-started", + ); + }); + + it("does not write latest when one backend candidate verification fails", async () => { + const root = await tempRoot("one-backend-fails"); + const dist = join(root, "dist"); + await mkdir(dist, { recursive: true }); + const artifact = Buffer.from("valid release artifact"); + const fixture = updaterFixture(artifact); + const candidate = completeManifest("1.1.0", fixture.signature); + const artifactNames = Object.values(candidate.platforms).map((entry) => + new URL(entry.url).pathname.split("/").at(-1), + ); + for (const name of artifactNames) { + await writeFile(join(dist, name), artifact); + await writeFile(join(dist, `${name}.sig`), `${fixture.signature}\n`); + } + const candidatePath = await writeManifest(dist, "latest.mirror.json", candidate); + const candidateKey = candidateKeyFor("1.1.0", "run-2"); + const current = Buffer.from(`${JSON.stringify(manifest("1.0.0"))}\n`); + const stagedObjects = Object.fromEntries( + artifactNames.flatMap((name) => [ + [`1.1.0/${name}`, artifact], + [`1.1.0/${name}.sig`, Buffer.from(`${fixture.signature}\n`)], + ]), + ); + const r2 = new MemoryBackend("r2", { + "latest.json": current, + [candidateKey]: await readFile(candidatePath), + ...stagedObjects, + }); + const missingArtifact = artifactNames.at(-1); + const ihepObjects = { ...stagedObjects }; + delete ihepObjects[`1.1.0/${missingArtifact}`]; + const ihep = new MemoryBackend("ihep", { + "latest.json": current, + [candidateKey]: await readFile(candidatePath), + // Deliberately missing one required platform artifact. + ...ihepObjects, + }); + const promotionToken = hash(Buffer.from(candidateKey)); + r2.objects.get(candidateKey).metadata["cam-promotion-token"] = promotionToken; + ihep.objects.get(candidateKey).metadata["cam-promotion-token"] = promotionToken; + const summaryPath = join(root, "promotion-summary.json"); + + await expect( + promoteMirrors({ + backends: [r2, ihep], + candidateKey, + candidateManifest: candidate, + candidatePath, + distDir: dist, + mirrorBase: "https://mirror.example/manager", + override: overrideOff(), + publicKey: fixture.publicKey, + summaryPath, + tempRoot: join(root, "promote"), + }), + ).rejects.toThrow("ihep: artifact is missing"); + + expect([r2.latestPutAttempts, ihep.latestPutAttempts]).toEqual([0, 0]); + const audit = JSON.parse(await readFile(summaryPath, "utf8")); + expect(audit.outcome).toBe("failed"); + expect(audit.backends.map((backend) => backend.candidateVerification)).toEqual([ + "passed", + "failed", + ]); + }); + + it("requires a workflow-dispatch audit trail for emergency downgrade override", () => { + expect(() => + downgradeOverrideFromEnv({ + MIRROR_ALLOW_DOWNGRADE: "1", + MIRROR_DOWNGRADE_REASON: "urgent rollback after launch regression", + GITHUB_EVENT_NAME: "push", + GITHUB_ACTOR: "release-admin", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_RUN_ID: "42", + }), + ).toThrow("only from workflow_dispatch"); + + expect(() => + downgradeOverrideFromEnv({ + MIRROR_ALLOW_DOWNGRADE: "1", + MIRROR_DEFAULT_BRANCH: "main", + MIRROR_DOWNGRADE_REASON: "urgent rollback after launch regression", + MIRROR_WORKFLOW_REF_NAME: "release-experiment", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_ACTOR: "release-admin", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_RUN_ID: "42", + }), + ).toThrow("default branch"); + + const override = downgradeOverrideFromEnv({ + MIRROR_ALLOW_DOWNGRADE: "1", + MIRROR_DEFAULT_BRANCH: "main", + MIRROR_DOWNGRADE_REASON: "urgent rollback after launch regression", + MIRROR_WORKFLOW_REF_NAME: "main", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_ACTOR: "release-admin", + GITHUB_TRIGGERING_ACTOR: "incident-commander", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_RUN_ID: "42", + }); + expect(override).toEqual( + expect.objectContaining({ + actor: "incident-commander", + originalActor: "release-admin", + reason: "urgent rollback after launch regression", + requested: true, + runUrl: "https://github.com/owner/repo/actions/runs/42", + }), + ); + }); + + it("uses an audited override to downgrade both backends consistently", async () => { + const root = await tempRoot("audited-downgrade"); + const current = manifest("3.0.0"); + const candidate = manifest("2.5.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(current)}\n`; + const backends = [ + new MemoryBackend("r2", { "latest.json": initial }), + new MemoryBackend("ihep", { "latest.json": initial }), + ]; + const override = { + actor: "release-admin", + eventName: "workflow_dispatch", + reason: "rollback production crash regression", + requested: true, + runUrl: "https://github.com/owner/repo/actions/runs/42", + used: false, + }; + const summary = summaryFor(backends, override); + + const result = await promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override, + summary, + workDir: join(root, "transaction"), + }); + + expect(result.outcome).toBe("downgrade-override-promoted"); + expect(summary.override.used).toBe(true); + expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ + "2.5.0", + "2.5.0", + ]); + }); +}); + +describe("mirror manifest rewriting", () => { + it("uses an immutable version path and a per-run candidate key", async () => { + const root = await tempRoot("manifest-rewrite"); + const dist = join(root, "dist"); + await mkdir(dist, { recursive: true }); + const source = manifest("3.4.5"); + source.platforms["windows-x86_64"].url = + "https://github.com/owner/repo/releases/download/v3.4.5/manager-3.4.5.exe"; + await writeManifest(dist, "latest.json", source); + + const result = await createMirrorManifest(dist, "https://mirror.example/manager"); + + expect(result.manifest.platforms["windows-x86_64"].url).toBe( + "https://mirror.example/manager/3.4.5/manager-3.4.5.exe", + ); + expect(candidateKeyFor("3.4.5", "987-2")).toBe("candidates/3.4.5/987-2.json"); + }); +}); + +describe("release audit summary", () => { + it("renders per-backend verification, monotonic decisions, rollback, and override audit", async () => { + const root = await tempRoot("release-summary"); + const summaryPath = join(root, "github-summary.md"); + await writeManifest(root, "latest.json", manifest("4.0.0")); + await writeManifest(root, "mirror-stage-summary.json", { + candidateKey: "candidates/4.0.0/42-1.json", + candidateVersion: "4.0.0", + outcome: "staged", + }); + await writeManifest(root, "mirror-verification-summary.json", { + candidateKey: "candidates/4.0.0/42-1.json", + candidateVersion: "4.0.0", + outcome: "verified", + publicRouteVerification: "passed", + }); + await writeManifest(root, "mirror-promotion-summary.json", { + candidateKey: "candidates/4.0.0/42-1.json", + candidateVersion: "4.0.0", + outcome: "downgrade-override-promoted", + override: { + actor: "incident-commander", + originalActor: "release-admin", + reason: "rollback broken production release", + requested: true, + runUrl: "https://github.com/owner/repo/actions/runs/42", + used: true, + }, + backends: [ + { + name: "r2", + candidateVerification: "passed", + currentVersion: "5.0.0", + decision: "promote-downgrade-override", + promotion: "verified", + rollback: "not-needed", + finalVersion: "4.0.0", + }, + { + name: "ihep", + candidateVerification: "passed", + currentVersion: "5.0.0", + decision: "promote-downgrade-override", + promotion: "verified", + rollback: "not-needed", + finalVersion: "4.0.0", + }, + ], + }); + + await execFileAsync(process.execPath, [join(process.cwd(), "scripts/write-release-summary.mjs")], { + cwd: root, + env: { + ...process.env, + GITHUB_REF_NAME: "v4.0.0", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_STEP_SUMMARY: summaryPath, + }, + }); + + const rendered = await readFile(summaryPath, "utf8"); + expect(rendered).toContain("Promotion outcome: **downgrade-override-promoted**"); + expect(rendered).toContain("Pre-publish verification: **verified**"); + expect(rendered).toContain("Public route verification: **passed**"); + expect(rendered).toContain("| r2 | passed | 5.0.0 | promote-downgrade-override | verified"); + expect(rendered).toContain("rollback broken production release"); + expect(rendered).toContain("by `incident-commander` (original workflow actor: `release-admin`)"); + expect(rendered).toContain("https://github.com/owner/repo/actions/runs/42"); + }); +}); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs new file mode 100644 index 0000000..7f919f6 --- /dev/null +++ b/scripts/release-workflow.test.mjs @@ -0,0 +1,164 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + assertImmutableReleasesEnabled, + verifyImmutableReleases, +} from "./check-immutable-releases.mjs"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const workflow = await readFile(join(repoRoot, ".github/workflows/release.yml"), "utf8"); +const mirrorRelease = await readFile(join(repoRoot, "scripts/mirror-release.mjs"), "utf8"); +const releaseJob = workflow.slice(workflow.indexOf(" release:\n")); + +describe("release workflow recovery invariants", () => { + it("queues every release run instead of replacing an older pending tag", () => { + expect(workflow).toMatch( + /concurrency:\n\s+group: release-latest-\$\{\{ github\.repository \}\}\n\s+cancel-in-progress: false\n\s+queue: max/, + ); + }); + + it("refreshes immutable Release state inside the release job on failed-job reruns", () => { + expect(releaseJob).toContain("id: live_release"); + expect(releaseJob).toContain( + "if: ${{ steps.live_release.outputs.release_reusable != 'true' }}", + ); + expect(releaseJob).toContain( + "RELEASE_ASSET_DIGESTS: ${{ steps.live_release.outputs.release_asset_digests }}", + ); + expect(releaseJob).toContain( + 'if [[ "${{ steps.live_release.outputs.release_reusable }}" == "true" ]]; then', + ); + expect(releaseJob).not.toContain( + "if: ${{ needs.prepare.outputs.release_reusable != 'true' }}", + ); + }); + + it("uses the target tag updater trust root without executing historical scripts", () => { + const refresh = releaseJob.indexOf("- name: Refresh immutable release state"); + const resolveTrust = releaseJob.indexOf( + "- name: Resolve updater trust root for release tag", + ); + const download = releaseJob.indexOf("- name: Download canonical build artifacts"); + const localVerify = releaseJob.indexOf( + "- name: Verify local updater signatures before immutable publication", + ); + const stage = releaseJob.indexOf("- name: Stage CDN mirror candidate"); + const trustStep = releaseJob.slice(resolveTrust, download); + const localVerifyStep = releaseJob.slice(localVerify, stage); + + expect(resolveTrust).toBeGreaterThan(refresh); + expect(download).toBeGreaterThan(resolveTrust); + expect(trustStep).toContain("gh api --method GET"); + expect(trustStep).toContain("application/vnd.github.raw+json"); + expect(trustStep).toContain('-f ref="$RELEASE_TAG"'); + expect(trustStep).toContain("RELEASE_TAURI_CONFIG="); + expect(trustStep).toContain("MIRROR_UPDATER_PUBLIC_KEY="); + expect(trustStep).toContain('>> "$GITHUB_ENV"'); + expect(localVerifyStep).toContain('"$RELEASE_TAURI_CONFIG"'); + expect(localVerifyStep).not.toContain("src-tauri/tauri.conf.json"); + expect(mirrorRelease).toContain("process.env.MIRROR_UPDATER_PUBLIC_KEY ||"); + }); + + it("fails closed when immutable settings are disabled or cannot be queried", () => { + expect(assertImmutableReleasesEnabled({ enabled: true })).toEqual({ enabled: true }); + expect(() => assertImmutableReleasesEnabled({ enabled: false })).toThrow( + "GitHub Immutable Releases are disabled", + ); + expect(() => + verifyImmutableReleases({ + repository: "owner/repo", + token: "read-only-token", + runner: () => ({ status: 1, stderr: "HTTP 403", stdout: "" }), + }), + ).toThrow("could not verify GitHub Immutable Releases"); + + const prepare = workflow.slice( + workflow.indexOf(" prepare:\n"), + workflow.indexOf(" build:\n"), + ); + expect(prepare).toContain("environment: release"); + expect(prepare).toContain("GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}"); + expect(prepare).toContain("run: node scripts/check-immutable-releases.mjs"); + expect(releaseJob).toContain("run: node scripts/check-immutable-releases.mjs"); + }); + + it("uploads stable and prerelease assets to a draft before publishing", () => { + const localVerify = releaseJob.indexOf( + "- name: Verify local updater signatures before immutable publication", + ); + const stage = releaseJob.indexOf("- name: Stage CDN mirror candidate"); + const mirrorVerify = releaseJob.indexOf( + "- name: Verify staged CDN mirror before immutable publication", + ); + const upload = releaseJob.indexOf("- name: Upload GitHub Release draft"); + const publish = releaseJob.indexOf("- name: Publish GitHub Release"); + const publishedVerify = releaseJob.indexOf( + "- name: Verify published immutable Release and asset digests", + ); + const attest = releaseJob.indexOf("- name: Attest build provenance"); + const promote = releaseJob.indexOf("- name: Promote CDN mirror latest"); + const winget = releaseJob.indexOf("- name: Trigger winget submission"); + const summary = releaseJob.indexOf("- name: Write release summary"); + expect(localVerify).toBeGreaterThan(-1); + expect(stage).toBeGreaterThan(localVerify); + expect(mirrorVerify).toBeGreaterThan(stage); + expect(upload).toBeGreaterThan(mirrorVerify); + expect(upload).toBeGreaterThan(-1); + expect(publish).toBeGreaterThan(upload); + expect(publishedVerify).toBeGreaterThan(publish); + expect(attest).toBeGreaterThan(publishedVerify); + expect(promote).toBeGreaterThan(attest); + + const uploadStep = releaseJob.slice(upload, publish); + const publishStep = releaseJob.slice(publish, publishedVerify); + const verifyStep = releaseJob.slice(publishedVerify, attest); + const attestStep = releaseJob.slice(attest, promote); + const promoteStep = releaseJob.slice(promote, winget); + const wingetStep = releaseJob.slice(winget, summary); + const localVerifyStep = releaseJob.slice(localVerify, stage); + const mirrorVerifyStep = releaseJob.slice(mirrorVerify, upload); + expect(localVerifyStep).toContain("node scripts/verify-release-artifacts.mjs"); + expect(mirrorVerifyStep).toContain("MIRROR_PHASE: verify"); + expect(mirrorVerifyStep).toContain("bash scripts/sync-mirror.sh dist"); + expect(uploadStep).toContain("draft: true"); + expect(uploadStep).toContain("prerelease: ${{ contains(env.RELEASE_TAG, '-') }}"); + expect(uploadStep).toContain("files: |"); + expect(publishStep).not.toMatch(/^\s+draft:/m); + expect(publishStep).not.toContain("files: |"); + expect(verifyStep).toContain("node scripts/check-release-reuse.mjs"); + expect(verifyStep).toContain("did not become immutable with canonical asset digests"); + expect(attestStep).toContain( + "steps.publish_release.outcome == 'success' || steps.release_source.outputs.existing == 'true'", + ); + expect(attestStep).toContain("actions/attest-build-provenance@"); + expect(attestStep).not.toContain("continue-on-error: true"); + expect(promoteStep).toContain("steps.attest.outcome == 'success'"); + expect(wingetStep).toContain("steps.attest.outcome == 'success'"); + expect(releaseJob.slice(0, upload)).toContain( + "rm -f dist/latest.mirror.json dist/latest.json", + ); + }); + + it("attests only immutable Release bytes on a failed-job reuse", () => { + const source = releaseJob.indexOf("- name: Resolve immutable release artifact source"); + const validate = releaseJob.indexOf("- name: Validate final release artifacts"); + const attest = releaseJob.indexOf("- name: Attest build provenance"); + const promote = releaseJob.indexOf("- name: Promote CDN mirror latest"); + const sourceStep = releaseJob.slice(source, validate); + const attestStep = releaseJob.slice(attest, promote); + + expect(sourceStep).toContain("gh release download"); + expect(sourceStep).toContain("--pattern 'CodexAppManager*'"); + expect(sourceStep).toContain("--pattern 'latest.json'"); + expect(sourceStep).toContain('actual_digest="sha256:$(sha256sum "$file"'); + expect(sourceStep).toContain('if [[ "$actual_digest" != "$expected_digest" ]]'); + expect(attestStep).toContain("dist/*"); + expect(attestStep).toContain("latest.json"); + expect(attestStep).not.toContain("SHA256SUMS"); + expect(attestStep).not.toContain("release-assets/*"); + }); +}); diff --git a/scripts/sync-mirror.sh b/scripts/sync-mirror.sh index d055373..de60715 100755 --- a/scripts/sync-mirror.sh +++ b/scripts/sync-mirror.sh @@ -1,243 +1,8 @@ #!/usr/bin/env bash -# Publish the manager's release artifacts + a URL-rewritten latest.json to the -# CDN mirror, so in-app self-update works without depending on GitHub (which is -# slow/blocked for the mainland-China audience). -# -# • R2 (global) — uploaded when MANAGER_R2_* env vars are set. -# • IHEP S3 (CN) — uploaded when MANAGER_IHEP_S3_* env vars are set. -# -# The worker at codexapp.agentsmirror.com/manager/* serves R2 globally and the -# presigned IHEP object for CN. The artifact bytes are identical to the GitHub -# release, so the signatures already embedded in latest.json stay valid — we -# only rewrite the download URLs. -# -# Usage: scripts/sync-mirror.sh -# MIRROR_PHASE=all upload versioned assets + latest.json (default, -# the original one-step behavior) -# MIRROR_PHASE=stage upload versioned assets + latest.candidate.json -# MIRROR_PHASE=promote health-check candidate, then publish latest.json -# -# holds the release assets + the GitHub latest.json produced -# by gen-updater-manifest.mjs. +# Compatibility entry point for the release workflow. The Node implementation +# owns policy, direct backend verification, conditional promotion, and rollback; +# keeping this wrapper preserves the existing local/CI command surface. set -euo pipefail -dist="${1:?usage: sync-mirror.sh }" -phase="${MIRROR_PHASE:-all}" -mirror_base="${MIRROR_BASE_URL:-https://codexapp.agentsmirror.com/manager}" - -case "$phase" in - all|stage|promote) ;; - *) - echo "::error::unsupported MIRROR_PHASE=$phase (expected all, stage, or promote)" >&2 - exit 2 - ;; -esac - -if [ ! -f "$dist/latest.json" ]; then - echo "::error::no latest.json in $dist" >&2 - exit 1 -fi - -# Installers live under a per-version path on the mirror (see below), so read the -# version once and reuse it for both the rewritten URLs and the upload keys. -version="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).version||""))' "$dist/latest.json")" -if [ -z "$version" ]; then - echo "::error::latest.json has no version" >&2 - exit 1 -fi - -# 1) Mirror variant of latest.json: same signatures, URLs pointed at the mirror. -# Each installer URL gets a // segment so every release is an -# immutable, uniquely-addressed object. The macOS updater tarballs are renamed -# to versionless arch-only names upstream (CodexAppManager_.app.tar.gz), -# so without this each release would reuse one URL — and the worker's long -# installer cache could then serve a previous version's bytes against the new -# latest.json signature, breaking self-update. latest.json itself stays at the -# fixed root path the updater polls. -node -e ' - const fs = require("fs"); - const [dir, base, ver] = [process.argv[1], process.argv[2], process.argv[3]]; - const j = JSON.parse(fs.readFileSync(dir + "/latest.json", "utf8")); - for (const k of Object.keys(j.platforms || {})) { - const name = j.platforms[k].url.split("/").pop(); - j.platforms[k].url = `${base}/${ver}/${name}`; - } - fs.writeFileSync(dir + "/latest.mirror.json", JSON.stringify(j, null, 2) + "\n"); -' "$dist" "$mirror_base" "$version" - -content_type() { - case "$1" in - *.json) echo "application/json" ;; - *.tar.gz) echo "application/gzip" ;; - *.exe) echo "application/octet-stream" ;; - *.dmg) echo "application/x-apple-diskimage" ;; - *) echo "application/octet-stream" ;; - esac -} - -candidate_key="latest.candidate.json" -latest_key="latest.json" - -# Upload every asset (+ the mirror latest.json) to one S3-compatible endpoint. -# Path-style addressing works for both R2 and IHEP. -upload_assets() { # endpoint bucket region access_key secret_key phase [prefix] - local endpoint="$1" bucket="$2" region="$3" ak="$4" sk="$5" mode="$6" prefix="${7:-}" - local f name key - for f in "$dist"/*; do - name="$(basename "$f")" - [ "$name" = "latest.json" ] && continue # GitHub variant — skip - if [ "$name" = "latest.mirror.json" ]; then - if [ "$mode" = "stage" ]; then - key="$candidate_key" # candidate only; updater never polls it - else - key="$latest_key" # original one-step behavior - fi - else - key="$version/$name" # immutable, per-version object - fi - [ -n "$prefix" ] && key="${prefix%/}/$key" - AWS_ACCESS_KEY_ID="$ak" \ - AWS_SECRET_ACCESS_KEY="$sk" \ - AWS_DEFAULT_REGION="$region" \ - aws s3 cp "$f" "s3://$bucket/$key" \ - --endpoint-url "$endpoint" \ - --content-type "$(content_type "$name")" \ - --only-show-errors - echo " ↑ $key" - done -} - -upload_latest() { # endpoint bucket region access_key secret_key [prefix] - local endpoint="$1" bucket="$2" region="$3" ak="$4" sk="$5" prefix="${6:-}" - local key="$latest_key" - [ -n "$prefix" ] && key="${prefix%/}/$key" - AWS_ACCESS_KEY_ID="$ak" \ - AWS_SECRET_ACCESS_KEY="$sk" \ - AWS_DEFAULT_REGION="$region" \ - aws s3 cp "$dist/latest.mirror.json" "s3://$bucket/$key" \ - --endpoint-url "$endpoint" \ - --content-type "application/json" \ - --only-show-errors - echo " ↑ $key" -} - -check_candidate_health() { - local candidate_url="$mirror_base/$candidate_key" - local candidate urls - candidate="$(mktemp)" - urls="$(mktemp)" - CANDIDATE_TMP="$candidate" - CANDIDATE_URLS_TMP="$urls" - trap 'rm -f "$AWS_CONFIG_FILE" "${CANDIDATE_TMP:-}" "${CANDIDATE_URLS_TMP:-}"' EXIT - - echo "→ health-check $candidate_url" - curl -fsSL --retry 3 --max-time 30 "$candidate_url" -o "$candidate" - node - "$candidate" "$version" > "$urls" <<'NODE' -const fs = require("fs"); -const [path, expectedVersion] = process.argv.slice(2); -const manifest = JSON.parse(fs.readFileSync(path, "utf8")); -if (manifest.version !== expectedVersion) { - console.error(`candidate version ${manifest.version || "(missing)"} does not match ${expectedVersion}`); - process.exit(1); -} -const urls = Object.values(manifest.platforms || {}).map((platform) => platform && platform.url).filter(Boolean); -if (urls.length === 0) { - console.error("candidate has no platform URLs"); - process.exit(1); -} -for (const url of urls) console.log(url); -NODE - - while IFS= read -r url; do - [ -n "$url" ] || continue - echo " HEAD $url" - curl -fsSI --retry 3 --max-time 30 "$url" >/dev/null - done < "$urls" -} - -r2_configured() { - [ -n "${MANAGER_R2_S3_ENDPOINT:-}" ] && [ -n "${MANAGER_R2_ACCESS_KEY_ID:-}" ] && [ -n "${MANAGER_R2_SECRET_ACCESS_KEY:-}" ] -} - -ihep_configured() { - [ -n "${MANAGER_IHEP_S3_ENDPOINT:-}" ] && [ -n "${MANAGER_IHEP_S3_BUCKET:-}" ] && [ -n "${MANAGER_IHEP_S3_ACCESS_KEY_ID:-}" ] && [ -n "${MANAGER_IHEP_S3_SECRET_ACCESS_KEY:-}" ] -} - -sync_r2_assets() { - if r2_configured; then - echo "→ R2 (${MANAGER_R2_BUCKET:-codex-app-manager})" - upload_assets "$MANAGER_R2_S3_ENDPOINT" "${MANAGER_R2_BUCKET:-codex-app-manager}" "auto" \ - "$MANAGER_R2_ACCESS_KEY_ID" "$MANAGER_R2_SECRET_ACCESS_KEY" "$phase" - else - if [ "${ALLOW_STALE_MIRROR:-}" = "1" ]; then - echo "::warning::MANAGER_R2_* not set — skipped R2 mirror sync because ALLOW_STALE_MIRROR=1" - else - echo "::error::MANAGER_R2_* not set — refusing stable release with stale self-update mirror (set ALLOW_STALE_MIRROR=1 only for an intentional manual/pre-release bypass)" >&2 - exit 1 - fi - fi -} - -sync_ihep_assets() { - if ihep_configured; then - echo "→ IHEP S3 (${MANAGER_IHEP_S3_BUCKET})" - upload_assets "$MANAGER_IHEP_S3_ENDPOINT" "$MANAGER_IHEP_S3_BUCKET" "${MANAGER_IHEP_S3_REGION:-auto}" \ - "$MANAGER_IHEP_S3_ACCESS_KEY_ID" "$MANAGER_IHEP_S3_SECRET_ACCESS_KEY" "$phase" "${MANAGER_IHEP_S3_PREFIX:-}" - else - echo "IHEP S3 not configured — CN falls back to R2 via the worker" - fi -} - -promote_r2_latest() { - if r2_configured; then - echo "→ R2 promote (${MANAGER_R2_BUCKET:-codex-app-manager})" - upload_latest "$MANAGER_R2_S3_ENDPOINT" "${MANAGER_R2_BUCKET:-codex-app-manager}" "auto" \ - "$MANAGER_R2_ACCESS_KEY_ID" "$MANAGER_R2_SECRET_ACCESS_KEY" - else - if [ "${ALLOW_STALE_MIRROR:-}" = "1" ]; then - echo "::warning::MANAGER_R2_* not set — skipped R2 latest promote because ALLOW_STALE_MIRROR=1" - else - echo "::error::MANAGER_R2_* not set — cannot promote latest.json" >&2 - exit 1 - fi - fi -} - -promote_ihep_latest() { - if ihep_configured; then - echo "→ IHEP S3 promote (${MANAGER_IHEP_S3_BUCKET})" - upload_latest "$MANAGER_IHEP_S3_ENDPOINT" "$MANAGER_IHEP_S3_BUCKET" "${MANAGER_IHEP_S3_REGION:-auto}" \ - "$MANAGER_IHEP_S3_ACCESS_KEY_ID" "$MANAGER_IHEP_S3_SECRET_ACCESS_KEY" "${MANAGER_IHEP_S3_PREFIX:-}" - else - echo "IHEP S3 not configured — CN falls back to R2 via the worker" - fi -} - -# Force path-style addressing via a temp config — the AWS CLI ignores an -# AWS_S3_ADDRESSING_STYLE env var, and both R2 and IHEP need path-style here. -export AWS_EC2_METADATA_DISABLED=true -AWS_CONFIG_FILE="$(mktemp)" -export AWS_CONFIG_FILE -printf '[default]\nregion = auto\ns3 =\n addressing_style = path\n' > "$AWS_CONFIG_FILE" -trap 'rm -f "$AWS_CONFIG_FILE"' EXIT - -case "$phase" in - all|stage) - sync_r2_assets - sync_ihep_assets - echo "✓ mirror $phase done" - ;; - promote) - if r2_configured; then - check_candidate_health - elif [ "${ALLOW_STALE_MIRROR:-}" = "1" ]; then - echo "::warning::skipped mirror health check because MANAGER_R2_* is not set and ALLOW_STALE_MIRROR=1" - else - echo "::error::MANAGER_R2_* not set — cannot health-check or promote latest.json" >&2 - exit 1 - fi - promote_ihep_latest - promote_r2_latest - echo "✓ mirror promote done" - ;; -esac +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec node "$ROOT/scripts/mirror-release.mjs" "$@" diff --git a/scripts/validate-release-manifest.mjs b/scripts/validate-release-manifest.mjs new file mode 100644 index 0000000..ba25f4e --- /dev/null +++ b/scripts/validate-release-manifest.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; + +import { assertCandidateMatchesRelease } from "./mirror-release.mjs"; + +const [, , releaseTag, candidatePath, artifactManifestPath] = process.argv; +if (!releaseTag || !candidatePath || !artifactManifestPath) { + console.error( + "usage: validate-release-manifest.mjs ", + ); + process.exit(2); +} + +const readManifest = async (path, label) => { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`cannot read ${label}: ${detail}`); + } +}; + +try { + const [candidate, artifactManifest] = await Promise.all([ + readManifest(candidatePath, "candidate latest.json"), + readManifest(artifactManifestPath, "artifact-derived latest.json"), + ]); + const result = assertCandidateMatchesRelease(candidate, artifactManifest, releaseTag); + console.log( + `[manifest] candidate is bound to ${releaseTag} (${result.platformCount} updater platforms)`, + ); +} catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`::error::[manifest] ${detail}`); + process.exitCode = 1; +} diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs new file mode 100644 index 0000000..7a9f25e --- /dev/null +++ b/scripts/verify-release-artifacts.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { verifyLocalUpdaterArtifacts } from "./mirror-release.mjs"; + +function usage() { + return "usage: verify-release-artifacts.mjs "; +} + +async function readJson(path, label) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`cannot read ${label}: ${detail}`); + } +} + +async function main() { + const [manifestArg, distArg, configArg] = process.argv.slice(2); + if (!manifestArg || !distArg || !configArg) throw new Error(usage()); + + const manifest = await readJson(resolve(manifestArg), "updater manifest"); + const config = await readJson(resolve(configArg), "Tauri configuration"); + const publicKey = config.plugins?.updater?.pubkey; + if (typeof publicKey !== "string" || !publicKey.trim()) { + throw new Error("Tauri configuration has no updater public key"); + } + + const report = await verifyLocalUpdaterArtifacts({ + manifest, + distDir: resolve(distArg), + publicKey: publicKey.trim(), + }); + const summaryPath = resolve( + process.env.UPDATER_SIGNATURE_SUMMARY_PATH || + "updater-signature-verification.json", + ); + await writeFile( + summaryPath, + `${JSON.stringify( + { + ...report, + manifestVersion: manifest.version, + verifiedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log( + `[release] cryptographically verified ${report.artifactCount} updater artifacts`, + ); +} + +main().catch((error) => { + console.error( + `::error::[release] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +}); diff --git a/scripts/write-release-summary.mjs b/scripts/write-release-summary.mjs index 299999d..22f11bf 100644 --- a/scripts/write-release-summary.mjs +++ b/scripts/write-release-summary.mjs @@ -2,7 +2,7 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs"; const env = process.env; -const tag = env.GITHUB_REF_NAME || env.RELEASE_TAG || ""; +const tag = env.RELEASE_TAG || env.GITHUB_REF_NAME || ""; const repo = env.GITHUB_REPOSITORY || "Wangnov/Codex-App-Manager"; const mirrorBase = (env.MIRROR_BASE_URL || "https://codexapp.agentsmirror.com/manager").replace(/\/$/, ""); const summaryPath = env.GITHUB_STEP_SUMMARY; @@ -12,9 +12,27 @@ if (!summaryPath) { process.exit(0); } -const readJson = (path) => (existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null); +const readJson = (path) => { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + return { readError: error instanceof Error ? error.message : String(error) }; + } +}; +const cell = (value) => + String(value ?? "") + .replace(/\s+/g, " ") + .replaceAll("|", "\\|") + .replaceAll("`", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .slice(0, 500); const manifest = readJson("latest.json"); const manifestSummary = readJson("manifest-summary.json"); +const mirrorStage = readJson("mirror-stage-summary.json"); +const mirrorVerification = readJson("mirror-verification-summary.json"); +const mirrorPromotion = readJson("mirror-promotion-summary.json"); const version = manifest?.version || (tag ? tag.replace(/^v/, "") : ""); const releaseUrl = `https://github.com/${repo}/releases/tag/${tag}`; const latestUrl = `${mirrorBase}/latest.json`; @@ -69,6 +87,78 @@ if (checksums.length > 0) { rows.push("_SHA256SUMS was not generated._"); } +rows.push(""); +rows.push("## Mirror publication audit"); +rows.push(""); +if (mirrorStage || mirrorVerification || mirrorPromotion) { + const audit = mirrorPromotion || mirrorVerification || mirrorStage; + rows.push(`Candidate: \`${cell(audit?.candidateVersion || version)}\``); + rows.push(`Candidate object: \`${cell(audit?.candidateKey || "unknown")}\``); + rows.push(`Stage outcome: **${cell(mirrorStage?.outcome || "missing")}**`); + rows.push( + `Pre-publish verification: **${cell(mirrorVerification?.outcome || "not-run")}**`, + ); + rows.push(`Promotion outcome: **${cell(mirrorPromotion?.outcome || "not-run")}**`); + rows.push( + `Public route verification: **${cell( + mirrorPromotion?.publicRouteVerification || + mirrorVerification?.publicRouteVerification || + "not-run", + )}**`, + ); + if (mirrorPromotion?.error || mirrorVerification?.error || mirrorStage?.error) { + rows.push( + `Mirror error: \`${cell( + mirrorPromotion?.error || mirrorVerification?.error || mirrorStage?.error, + )}\``, + ); + } + rows.push(""); + rows.push("| Backend | Candidate verification | Previous | Decision | Promotion | Rollback | Final | Error |"); + rows.push("| --- | --- | --- | --- | --- | --- | --- | --- |"); + const backendRows = + mirrorPromotion?.backends || mirrorVerification?.backends || mirrorStage?.backends || []; + for (const backend of backendRows) { + rows.push( + `| ${cell(backend.name)} | ${cell(backend.candidateVerification || backend.status)} | ${cell( + backend.currentVersion || "absent", + )} | ${cell(backend.decision)} | ${cell(backend.promotion)} | ${cell( + backend.rollback, + )} | ${cell(backend.finalVersion)} | ${cell( + backend.error || backend.rollbackError, + )} |`, + ); + } + const override = audit?.override; + rows.push(""); + if (override?.requested) { + const originalActor = + override.originalActor && override.originalActor !== override.actor + ? ` (original workflow actor: \`${cell(override.originalActor)}\`)` + : ""; + rows.push( + `Emergency downgrade override: **requested=${cell(override.requested)}, used=${cell( + override.used, + )}** by \`${cell(override.actor)}\`${originalActor}; reason: ${cell(override.reason)}; [workflow audit](${cell( + override.runUrl, + )}).`, + ); + } else { + rows.push("Emergency downgrade override: not requested."); + } + if (mirrorPromotion?.rollback?.complete === false) { + rows.push( + "**Manual intervention required:** promotion rollback was incomplete; inspect the per-backend errors before another release.", + ); + } else if (["failed", "blocked-downgrade", "rolled-back"].includes(mirrorPromotion?.outcome)) { + rows.push( + "This run left no owned mirror-pointer advance in place; any completed rollback is shown per backend above.", + ); + } +} else { + rows.push("_No mirror audit record was produced (pre-release, skipped job, or failure before staging)._ "); +} + rows.push(""); rows.push("## Job status"); rows.push(""); @@ -76,6 +166,7 @@ rows.push("| Item | Status |"); rows.push("| --- | --- |"); rows.push(`| GitHub Release | ${env.PUBLISH_OUTCOME || "unknown"} |`); rows.push(`| Mirror stage | ${env.MIRROR_STAGE_OUTCOME || "skipped"} |`); +rows.push(`| Mirror pre-publish verify | ${env.MIRROR_VERIFY_OUTCOME || "skipped"} |`); rows.push(`| Mirror promote | ${env.MIRROR_PROMOTE_OUTCOME || "skipped"} |`); rows.push(`| winget dispatch | ${env.WINGET_OUTCOME || "skipped"} |`); rows.push(`| SBOM | ${env.SBOM_OUTCOME || "skipped"} |`); From 4c1d083da100910d5ba9490b82d755bd61e00392 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:00:28 +0800 Subject: [PATCH 04/21] feat(delivery): complete signed manager updates --- .github/workflows/release.yml | 115 ++- .github/workflows/win-installer-check.yml | 11 +- docs/release.md | 42 +- docs/releases/FALLBACK.md | 4 +- docs/releases/TEMPLATE.md | 4 +- docs/windows-signing.md | 244 ++--- scripts/gen-updater-manifest.mjs | 15 +- scripts/prepare-windows-authenticode.ps1 | 103 ++ scripts/verify-windows-authenticode.ps1 | 57 +- scripts/windows-packaged-smoke.ps1 | 89 +- src-tauri/installer/installer.nsi | 15 +- src-tauri/src/app/manager_update_handoff.rs | 222 +++++ src-tauri/src/app/manager_update_runtime.rs | 269 ++++++ src-tauri/src/app/mod.rs | 2 + src-tauri/src/app/oplock.rs | 27 + src-tauri/src/commands.rs | 735 ++++++++++++++- src-tauri/src/errors.rs | 16 +- src-tauri/src/lib.rs | 5 + src-tauri/src/state.rs | 146 ++- src/app/App.tsx | 73 +- src/app/i18n.tsx | 110 +++ src/app/managerUpdate.test.tsx | 731 +++++++++++++++ src/app/managerUpdate.tsx | 985 ++++++++++++++++++++ src/app/styles.css | 87 ++ src/app/views/About.tsx | 143 ++- src/app/views/Home.tsx | 21 +- src/app/views/WinHome.tsx | 19 +- src/services/managerApi.test.ts | 69 ++ src/services/managerApi.ts | 59 +- src/shared/types.ts | 32 +- 30 files changed, 4015 insertions(+), 435 deletions(-) create mode 100644 scripts/prepare-windows-authenticode.ps1 create mode 100644 src-tauri/src/app/manager_update_handoff.rs create mode 100644 src-tauri/src/app/manager_update_runtime.rs create mode 100644 src/app/managerUpdate.test.tsx create mode 100644 src/app/managerUpdate.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64e88d4..f2b1185 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: Release # Tag-driven cross-platform release: -# build (unsigned) → macOS finalize (adaptive icon + inside-out Developer ID -# sign + notarize + staple + repackage updater/dmg) → collect → publish a +# build (Windows inside-out Authenticode; macOS unsigned) → macOS finalize +# (adaptive icon + inside-out Developer ID sign + notarize + staple + repackage) → collect → publish a # GitHub Release with the Tauri updater manifest (latest.json). # # Required repo secrets (Settings ▸ Secrets and variables ▸ Actions / release env): @@ -16,10 +16,9 @@ name: Release # TAURI_SIGNING_PRIVATE_KEY updater private key (string) # TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password (empty if none) # -# Optional Windows Authenticode (non-blocking until configured; see docs/windows-signing.md): +# Required Windows Authenticode (release environment; see docs/windows-signing.md): # WINDOWS_CERTIFICATE base64 of OV/EV code-signing .pfx # WINDOWS_CERTIFICATE_PASSWORD password for that .pfx -# vars.AUTHENTICODE_REQUIRED set to "true" to fail release when PE is unsigned # vars.WINDOWS_TIMESTAMP_URL optional RFC3161 timestamp URL override on: @@ -95,16 +94,38 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KC" security list-keychains -d user -s "$KC" $(security list-keychains -d user | sed s/\"//g) - # ── Build WITHOUT a tauri signingIdentity; we sign inside-out after ──── - - name: Build (Tauri, unsigned) + # ── Windows: import the release PFX and generate a private, per-job Tauri + # config. certificateThumbprint signs the main PE; NSIS receives the + # same RFC3161 command via !uninstfinalize, then Tauri signs the outer + # installer. A missing certificate is a hard release failure. + - name: Prepare Windows Authenticode (required) + id: windows_signing + if: matrix.os == 'windows' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} + run: | + & .\scripts\prepare-windows-authenticode.ps1 ` + -ConfigPath ".tauri-authenticode.conf.json" + + # macOS remains unsigned here and is finalized below. Windows consumes the + # generated config so all three PE layers are signed during bundling. + - name: Build (Tauri) shell: bash # Bundling downloads toolchains on the fly (e.g. the NSIS archive from # GitHub) which occasionally 504s and fails the whole release. Retry a # few times so a transient download hiccup doesn't sink the run — cargo # output is cached, so a retry mostly just re-runs the bundling step. run: | + args=(--target "${{ matrix.target }}") + if [ "${{ matrix.os }}" = "windows" ]; then + [ -f "$TAURI_AUTHENTICODE_CONFIG" ] || { echo "::error::missing generated Authenticode config"; exit 1; } + args+=(--config "$TAURI_AUTHENTICODE_CONFIG") + fi for attempt in 1 2 3; do - npm run tauri build -- --target ${{ matrix.target }} && exit 0 + npm run tauri build -- "${args[@]}" && exit 0 echo "::warning::tauri build attempt ${attempt} failed (often a transient NSIS/toolchain download, e.g. HTTP 504) — retrying in 20s" sleep 20 done @@ -168,43 +189,46 @@ jobs: & .\scripts\windows-pe-arch.ps1 -Path @($setup | ForEach-Object { $_.FullName }) } if ($target -like "aarch64-*") { - Write-Host "::notice::ARM64 main PE asserted machine=0xAA64 (cross-build on x64). Full install/launch smoke remains x64-only until ARM64 runners or trusted virtualization is available." + Write-Host "::notice::ARM64 main PE asserted machine=0xAA64 (cross-build on x64). Install/signature/upgrade/uninstall smoke runs below; only native ARM64 app launch still needs ARM64 hardware or trusted virtualization." } - # ── Windows: optional Authenticode (OV/EV) on final NSIS installer ──── - # Non-blocking until WINDOWS_CERTIFICATE is configured in the release - # environment. When present, signs the published -setup.exe. Full - # inside-out signing of main binary + uninstaller should eventually - # move into tauri build via certificateThumbprint (docs/windows-signing.md). - # This is independent of the Tauri updater minisign key below. - - name: Authenticode-sign Windows installer (optional) + # ── Windows: mandatory Authenticode + RFC3161 gate ──────────────────── + # Tauri restores target/.../release/codex-app-manager.exe to its + # unsigned, unpatched build input after bundling, so only the outer + # installer is inspectable here. Packaged smoke installs each target and + # verifies the signed embedded main executable and uninstall.exe. + - name: Verify Windows installer Authenticode (required) if: matrix.os == 'windows' shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} run: | - $setup = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe" -ErrorAction Stop - & .\scripts\sign-windows-authenticode.ps1 -Path $setup.FullName -Stage "sign" + $target = "${{ matrix.target }}" + $setup = Get-ChildItem "src-tauri/target/$target/release/bundle/nsis/*-setup.exe" -ErrorAction Stop + & .\scripts\verify-windows-authenticode.ps1 ` + -Path $setup.FullName ` + -Mode required ` + -ExpectedThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT ` + -RequireTimestamp ` + -SigningConfigPath $env:TAURI_AUTHENTICODE_CONFIG ` + -Stage "sign-verify" - - name: Verify Authenticode on Windows installer + - name: Verify signed installed app and uninstaller (all Windows targets) if: matrix.os == 'windows' shell: pwsh - env: - AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }} run: | + $skipLaunch = "${{ matrix.target }}" -like "aarch64-*" $setup = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe" -ErrorAction Stop - & .\scripts\verify-windows-authenticode.ps1 ` - -Path $setup.FullName ` - -Mode $env:AUTHENTICODE_MODE ` - -Stage "sign-verify" + & .\scripts\windows-packaged-smoke.ps1 ` + -Installer $setup.FullName ` + -AuthenticodeMode required ` + -ExpectedThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT ` + -RequireTimestamp ` + -SigningConfigPath $env:TAURI_AUTHENTICODE_CONFIG ` + -SkipLaunch:$skipLaunch # ── Windows: sign the NSIS installer for the updater ────────────────── - # The build is intentionally unsigned for Authenticode (until OV/EV is - # configured), so Tauri emits no updater `.sig` during build. In Tauri 2 - # the Windows updater ships the `-setup.exe` itself, so signing it here - # with the updater key — exactly what finalize-macos.sh does for the + # The Windows updater ships the Authenticode-signed `-setup.exe` itself. + # Signing those final bytes here with the updater key — exactly what + # finalize-macos.sh does for the # .app.tar.gz — is equivalent to `createUpdaterArtifacts` and is what # gets windows-x86_64 and windows-aarch64 into latest.json. Without it # the manifest has macOS platforms only and Windows in-app self-update @@ -279,6 +303,33 @@ jobs: fi echo "::endgroup::" + - name: Verify final publishable Windows installer (required) + if: matrix.os == 'windows' + shell: pwsh + run: | + $setup = Get-ChildItem "$env:GITHUB_WORKSPACE/dist-artifacts/*-setup.exe" -ErrorAction Stop + & .\scripts\verify-windows-authenticode.ps1 ` + -Path $setup.FullName ` + -Mode required ` + -ExpectedThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT ` + -RequireTimestamp ` + -SigningConfigPath $env:TAURI_AUTHENTICODE_CONFIG ` + -Stage "publish-sign-verify" + + - name: Remove temporary Windows signing material + if: always() && matrix.os == 'windows' + shell: pwsh + run: | + if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -ErrorAction SilentlyContinue + } + if ($env:WINDOWS_CERTIFICATE_TEMP_DIR -and (Test-Path -LiteralPath $env:WINDOWS_CERTIFICATE_TEMP_DIR)) { + Remove-Item -LiteralPath $env:WINDOWS_CERTIFICATE_TEMP_DIR -Recurse -Force -ErrorAction SilentlyContinue + } + if ($env:TAURI_AUTHENTICODE_CONFIG -and (Test-Path -LiteralPath $env:TAURI_AUTHENTICODE_CONFIG)) { + Remove-Item -LiteralPath $env:TAURI_AUTHENTICODE_CONFIG -Force -ErrorAction SilentlyContinue + } + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: bundle-${{ matrix.target }} diff --git a/.github/workflows/win-installer-check.yml b/.github/workflows/win-installer-check.yml index f2bff1b..00dda68 100644 --- a/.github/workflows/win-installer-check.yml +++ b/.github/workflows/win-installer-check.yml @@ -6,7 +6,7 @@ name: Windows installer check # install → first launch → upgrade → uninstall. # # Also probes Authenticode on the installer and installed PE files in -# non-blocking (optional) mode until OV/EV secrets are configured. +# non-blocking (optional) mode because pull requests never receive the PFX. # # ARM64 is NOT smoke-tested here: GitHub-hosted runners are x64, and a # cross-built ARM64 PE is not runtime verification. See docs/windows-signing.md. @@ -28,6 +28,7 @@ on: - "package-lock.json" - "scripts/windows-*.ps1" - "scripts/sign-windows-authenticode.ps1" + - "scripts/prepare-windows-authenticode.ps1" - "scripts/verify-windows-authenticode.ps1" - ".github/workflows/win-installer-check.yml" workflow_dispatch: @@ -65,7 +66,7 @@ jobs: # pull_request trigger running PR-authored code (config/template/hooks), so # exposing TAURI_SIGNING_PRIVATE_KEY / WINDOWS_CERTIFICATE would let a PR # exfiltrate keys. createUpdaterArtifacts is off; Authenticode remains a - # release-time concern (release.yml) with optional post-build path. + # release-time concern (release.yml) with mandatory inside-out signing. - name: Build NSIS installer id: build shell: bash @@ -101,8 +102,8 @@ jobs: - name: Authenticode probe (optional / non-blocking) shell: pwsh env: - # Flip to "required" via repo variable once OV/EV is wired into release. - AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }} + # Pull requests never receive the release PFX secret. + AUTHENTICODE_MODE: optional run: | & .\scripts\verify-windows-authenticode.ps1 ` -Path "${{ steps.artifact.outputs.installer }}" ` @@ -112,7 +113,7 @@ jobs: - name: Packaged lifecycle smoke (install / launch / upgrade / uninstall) shell: pwsh env: - AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }} + AUTHENTICODE_MODE: optional run: | & .\scripts\windows-packaged-smoke.ps1 ` -Installer "${{ steps.artifact.outputs.installer }}" ` diff --git a/docs/release.md b/docs/release.md index 5e6a7e3..a013419 100644 --- a/docs/release.md +++ b/docs/release.md @@ -47,19 +47,24 @@ Windows has no light/dark adaptive app icon (`.ico` is static) — the single Default icon is used. NSIS installer + updater bundle are produced by `tauri build`; no Apple-style finalize step. -Post-build on the Windows matrix (see [`release.yml`](../.github/workflows/release.yml)): - -1. **PE arch diagnostic** — `scripts/windows-pe-arch.ps1` records x64 vs ARM64 - machine types. ARM64 is cross-built on x64 runners; this is **not** runtime - verification ([`docs/windows-signing.md`](./windows-signing.md)). -2. **Optional Authenticode** — `scripts/sign-windows-authenticode.ps1` signs the - final `-setup.exe` when `WINDOWS_CERTIFICATE` is present; otherwise skips. -3. **Authenticode verify** — `scripts/verify-windows-authenticode.ps1` in - `optional` mode by default; set `AUTHENTICODE_REQUIRED=true` to gate. -4. **Tauri updater `.sig`** — `npx tauri signer sign` (always required for - Windows in-app update entries in `latest.json`). -5. **Collect final artifacts** — space-stripped names under `dist-artifacts/`, - with an explicit check that both `-setup.exe` and `-setup.exe.sig` exist. +Windows release builds are signed inside-out (see +[`release.yml`](../.github/workflows/release.yml)): + +1. **Prepare Authenticode** — import the release PFX and generate a temporary + Tauri config with the exact certificate thumbprint, SHA-256, and RFC3161 + timestamping (`tsp=true`). Missing credentials hard-fail the tag build. +2. **Build + sign all PE layers** — Tauri signs the main executable, the NSIS + uninstaller generated through `!uninstfinalize`, and the outer installer. +3. **Required verification** — every layer must report + `Get-AuthenticodeSignature.Status == Valid`, match the imported thumbprint, + and include a timestamp countersigner. x64 runs the full lifecycle; ARM64 is + installed on the x64 host to inspect its payload but is not launched there. +4. **Tauri updater `.sig`** — `npx tauri signer sign` authenticates the final, + Authenticode-signed installer bytes used by in-app updates. +5. **Collect + verify final artifacts** — space-stripped names under + `dist-artifacts/`, then another required signature check on the exact setup + executable that will be uploaded. +6. **Always clean up** — remove the temporary PFX directory and imported cert. PR-time x64 packaged smoke lives in [`win-installer-check.yml`](../.github/workflows/win-installer-check.yml) @@ -80,14 +85,13 @@ Required CI (`ci.yml`) also runs standalone engine crate tests for | `AC_API_KEY_BASE64` | base64 of the `AuthKey_XXXX.p8` | | `TAURI_SIGNING_PRIVATE_KEY` | updater private key | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | its password (empty if none) | +| `WINDOWS_CERTIFICATE` | base64 of the OV/EV code-signing **.pfx** (release env) | +| `WINDOWS_CERTIFICATE_PASSWORD` | password for that .pfx; may be empty only when the PFX has no password | -### Optional Windows Authenticode secrets / vars +### Optional release variables | Name | What | |---|---| -| `WINDOWS_CERTIFICATE` | base64 of OV/EV code-signing **.pfx** (release env) | -| `WINDOWS_CERTIFICATE_PASSWORD` | password for that .pfx | -| `AUTHENTICODE_REQUIRED` (repo **variable**) | `true` → fail release when PE is not `Valid` | | `WINDOWS_TIMESTAMP_URL` (repo **variable**) | optional RFC3161 timestamp URL | Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. @@ -97,4 +101,6 @@ Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. > adjust them if your `productName`/bundler config changes the filenames. > > Keep **updater signature**, **Authenticode**, and **SmartScreen reputation** -> conceptually separate — see [`docs/windows-signing.md`](./windows-signing.md). +> conceptually separate. Authenticode is mandatory for new tag builds, but a +> valid publisher signature does not guarantee instant SmartScreen reputation. +> See [`docs/windows-signing.md`](./windows-signing.md). diff --git a/docs/releases/FALLBACK.md b/docs/releases/FALLBACK.md index 9d86bd6..0e9c725 100644 --- a/docs/releases/FALLBACK.md +++ b/docs/releases/FALLBACK.md @@ -16,8 +16,8 @@ | Windows · x64 | [CodexAppManager_x64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows · ARM64 | [CodexAppManager_arm64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -**Windows 签名状态:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` 当前没有 Authenticode 代码签名,首次运行可能出现 SmartScreen 提示;`.sig` / `latest.json` 里的 Tauri updater 签名只用于应用内自更新的字节校验,不代表 Windows 发行者信任。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 -**Windows signing status:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` are not Authenticode-signed yet, so SmartScreen may warn on first run; the Tauri updater signature in `.sig` / `latest.json` verifies in-app update bytes only and is not Windows publisher trust. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). +**Windows 信任说明:** x64 / ARM64 安装器、主程序和卸载器均带 Authenticode 发行者签名与 RFC3161 时间戳;`.sig` / `latest.json` 中的 Tauri updater 签名另行保护应用内更新字节。SmartScreen 信誉仍由 Microsoft 独立评估,可能出现的信誉提示不等于任一签名失效。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 +**Windows trust:** The x64 / ARM64 installer, app, and uninstaller carry an Authenticode publisher signature and RFC3161 timestamp; the separate Tauri updater signature in `.sig` / `latest.json` protects in-app update bytes. Microsoft evaluates SmartScreen reputation independently, so a reputation prompt does not mean either signature failed. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). **核验下载:** 本页 Assets 带有 `SHA256SUMS`;Windows 用 `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` 或替换为 ARM64 文件名,macOS 用 `shasum -a 256 CodexAppManager_aarch64.dmg`,再与 `SHA256SUMS` 比对。 **Verify downloads:** This release includes `SHA256SUMS` in Assets; on Windows run `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` or swap in the ARM64 filename, and on macOS run `shasum -a 256 CodexAppManager_aarch64.dmg`, then compare with `SHA256SUMS`. diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md index 8c81d84..ac5607d 100644 --- a/docs/releases/TEMPLATE.md +++ b/docs/releases/TEMPLATE.md @@ -42,8 +42,8 @@ | Windows · x64 | [CodexAppManager_x64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows · ARM64 | [CodexAppManager_arm64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -**Windows 签名状态:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` 当前没有 Authenticode 代码签名,首次运行可能出现 SmartScreen 提示;`.sig` / `latest.json` 里的 Tauri updater 签名只用于应用内自更新的字节校验,不代表 Windows 发行者信任。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 -**Windows signing status:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` are not Authenticode-signed yet, so SmartScreen may warn on first run; the Tauri updater signature in `.sig` / `latest.json` verifies in-app update bytes only and is not Windows publisher trust. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). +**Windows 信任说明:** x64 / ARM64 安装器、主程序和卸载器均带 Authenticode 发行者签名与 RFC3161 时间戳;`.sig` / `latest.json` 中的 Tauri updater 签名另行保护应用内更新字节。SmartScreen 信誉仍由 Microsoft 独立评估,可能出现的信誉提示不等于任一签名失效。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 +**Windows trust:** The x64 / ARM64 installer, app, and uninstaller carry an Authenticode publisher signature and RFC3161 timestamp; the separate Tauri updater signature in `.sig` / `latest.json` protects in-app update bytes. Microsoft evaluates SmartScreen reputation independently, so a reputation prompt does not mean either signature failed. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). **核验下载:** 本页 Assets 带有 `SHA256SUMS`;Windows 用 `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` 或替换为 ARM64 文件名,macOS 用 `shasum -a 256 CodexAppManager_aarch64.dmg`,再与 `SHA256SUMS` 比对。 **Verify downloads:** This release includes `SHA256SUMS` in Assets; on Windows run `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` or swap in the ARM64 filename, and on macOS run `shasum -a 256 CodexAppManager_aarch64.dmg`, then compare with `SHA256SUMS`. diff --git a/docs/windows-signing.md b/docs/windows-signing.md index bec12cd..a7dde00 100644 --- a/docs/windows-signing.md +++ b/docs/windows-signing.md @@ -1,208 +1,128 @@ # Windows signing and verification -This project currently ships a Windows NSIS installer without Authenticode code -signing. The installer is still published through GitHub Releases and the -agentsmirror download mirror, and every release includes `SHA256SUMS` so users -can verify the bytes they downloaded. +The release workflow uses three independent trust mechanisms. Authenticode +identifies the Windows publisher, the Tauri updater signature protects the +bytes consumed by in-app updates, and SmartScreen reputation is a Microsoft +risk signal that neither signature can guarantee. Keep them separate when +triaging a warning or a failed release. -CI already prepares the Authenticode **path** (sign script, verify script, optional -release step, packaged lifecycle smoke). Certificate secrets are optional and -non-blocking until budget/demand justify an OV/EV cert. +Historical releases keep the signing status recorded in their own release +notes. The mandatory pipeline below applies starting with the first release +built from this workflow revision. ## 中文 -### 当前状态 +### 发布状态 -- macOS 构建已经使用 Developer ID 签名并完成 Apple 公证。 -- Windows 安装器 `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` 当前没有 Authenticode 代码签名。 -- Windows 应用内自更新包带有 Tauri updater 签名,用于校验下载字节没有被篡改。 -- Windows 首次手动运行安装器时可能出现 SmartScreen 提示;这是预期风险,不是更新器签名失效。 -- CI 已接入安装包冒烟(x64:`install → launch → upgrade → uninstall`)与 Authenticode 探测;证书未配置时签名步骤跳过且校验为非阻塞。 +- Windows 正式发布必须提供 OV/EV Authenticode `.pfx`;缺少证书或密码会直接阻断 tag 发布。 +- x64 与 ARM64 的主程序、NSIS `uninstall.exe`、外层 `-setup.exe` 均由同一张证书签名,并使用 SHA-256 + RFC3161 时间戳。 +- CI 要求每层 `Get-AuthenticodeSignature` 都为 `Valid`、签名证书 thumbprint 与本次导入证书完全一致,并存在时间戳 countersigner。 +- PR 构建不会取得发布证书,仍以 unsigned 方式验证打包模板和安装生命周期;PR 的 optional 探测不代表正式发布可以未签名。 +- Windows 应用内更新仍另外要求 Tauri updater `.sig`;它覆盖已经完成 Authenticode 签名的最终安装器字节。 -### 三个概念 +### 三个独立概念 -**Tauri updater 签名:** `latest.json` 里的 `signature` 对安装包字节签名。它保护应用内自更新下载,确保镜像或网络传输没有改包。它不参与 Windows 系统的发行者信任判断,也不能消除 SmartScreen 提示。实现:`npx tauri signer sign` + `TAURI_SIGNING_PRIVATE_KEY`。 +**Authenticode 代码签名** -**Authenticode 代码签名:** Windows 对 PE 文件和安装器使用的代码签名体系。拥有 OV 或 EV 证书后,安装器可以显示发行者身份,并更容易建立系统信任。本项目当前还没有给 Windows 安装器做 Authenticode 签名。实现路径见下文「CI / 发布管线」。 +Windows 对 PE 文件和安装器使用的发行者签名。本项目在 `tauri build` 前导入 PFX,并通过临时 Tauri config 配置 `certificateThumbprint`、`digestAlgorithm=sha256`、RFC3161 `timestampUrl` 与 `tsp=true`。Tauri 按 inside-out 顺序签主程序、NSIS 生成的卸载器和外层安装器。 -**SmartScreen 信誉:** Microsoft Defender SmartScreen 会结合签名身份、下载量、历史信誉和风险信号做拦截判断。EV 证书通常能更快建立信誉;OV 证书和未签名分发都需要累积信誉,未签名安装器首次运行更容易被提示。 +**Tauri updater 签名** -### 如何核验下载 +`latest.json` 的 `signature` 对应用内更新所下载的最终安装器字节签名。它防止 GitHub、镜像或传输链路改包,但不向 Windows 声明发行者身份。实现使用 `TAURI_SIGNING_PRIVATE_KEY` 和 `npx tauri signer sign`;其私钥与 Authenticode PFX 完全不同。 -1. 从 [GitHub Releases](https://github.com/Wangnov/Codex-App-Manager/releases/latest) 或 agentsmirror 镜像下载对应安装包。 -2. 从同一个 release 的 Assets 下载 `SHA256SUMS`。 -3. 在本机计算哈希并与 `SHA256SUMS` 中的同名文件比对。 +**SmartScreen 信誉** -Windows PowerShell: +Microsoft Defender SmartScreen 会综合发行者、文件流行度、历史与其他风险信号。一个 Authenticode 签名可以让系统显示可验证的发行者,但不能承诺新证书或新工件绝不出现 SmartScreen 提示。看到 SmartScreen 提示也不等于 Tauri updater 签名失败。 -```powershell -Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256 -Get-FileHash .\CodexAppManager_arm64-setup.exe -Algorithm SHA256 -``` - -macOS: - -```bash -shasum -a 256 CodexAppManager_aarch64.dmg -shasum -a 256 CodexAppManager_x86_64.dmg -``` +### 发布硬门 -如果哈希不一致,不要运行该文件,请重新下载或在 issue 中反馈下载来源和文件名。 +Windows matrix 的顺序如下: -### 分发渠道与成本评估 +1. `prepare-windows-authenticode.ps1` 解码并导入 PFX,生成仅用于当前 job 的 Tauri signing config。 +2. `tauri build --config ...` 完成三层 inside-out Authenticode 签名,并让自定义 NSIS 模板优先使用 bundler 准备的已签名插件目录。 +3. `verify-windows-authenticode.ps1 -Mode required` 验证外层安装器。Tauri 打包结束后会把 `target/.../release/codex-app-manager.exe` 恢复成 unsigned、未 patch 的构建输入,因此这个 raw 中间文件只用于 PE 架构诊断,不作为签名硬门。 +4. `windows-packaged-smoke.ps1` 安装包并直接验证安装后的主程序与 `uninstall.exe`。 + - x64 执行 `install → launch → upgrade → uninstall`。 + - ARM64 在 x64 runner 上执行安装、签名验证、升级和卸载,但跳过 ARM64 主程序启动;完整 ARM64 运行验收仍需 ARM64 设备或可信虚拟化。 +5. 对 Authenticode 签名后的最终 `-setup.exe` 生成独立的 Tauri updater `.sig`。 +6. 收集无空格的发布文件名后,再次对最终待发布安装器执行 required 验证。 +7. 无论成功失败都移除临时证书和 PFX 目录。 -**GitHub Releases + agentsmirror + SHA256SUMS:** 这是当前主渠道。优点是透明、可回溯、可独立核验;缺点是 Windows 无 Authenticode 时仍可能触发 SmartScreen。 +任何一层缺失、`Status != Valid`、证书 thumbprint 不符、没有时间戳、没有 updater `.sig`,发布 job 都失败。RFC3161 同时由生成配置中的 `tsp=true` / SHA-256 / timestamp URL 和工件上的 timestamp countersigner 证明。 -**winget:** `Wangnov.CodexAppManager` 已在 microsoft/winget-pkgs 中可用,本仓库会在稳定版发布后自动提交新版本。winget 可接受未签名 NSIS 安装器,但新增架构或元数据变化仍可能触发人工审查。 +### 必需配置 -**Microsoft Store / Partner Center:** 这是中期可选路径,需要开发者账号、MSIX 打包和商店审核。优点是用户信任更强,缺点是流程和维护成本更高。 +在 GitHub `release` environment 配置: -**OV / EV 代码签名证书:** 证书不是当前发布阻塞项。后续可在 Windows 下载量、企业用户需求或支持成本达到明确阈值后重新评估。EV 成本更高但信誉建立更快;OV 成本较低但仍需累积 SmartScreen 信誉。 - -**不推荐的降本方式:** 不把私钥托管给不可信第三方,不合租硬件令牌,不把代码签名能力转交给无法审计的渠道。 - -### 风险披露 - -未签名 Windows 安装器意味着用户首次运行可能需要在 SmartScreen 中选择更多信息后继续。项目通过公开 release、镜像直链、`SHA256SUMS`、Tauri updater 签名和透明文档降低篡改与误解风险;这不能替代 Authenticode,但可以让用户在证书预算到位前做独立核验。 - -### CI / 发布管线(Authenticode 路径) - -| 阶段 | 行为 | 阻塞? | +| 名称 | 类型 | 用途 | |---|---|---| -| `ci.yml` Rust | 独立跑 `codex-mac-engine` / `codex-win-engine` 测试 | 是(required) | -| `win-installer-check.yml` | 构建 x64 NSIS → Authenticode 探测 → 安装/启动/升级/卸载冒烟 | 否(路径变更时跑) | -| `release.yml` Windows | PE 架构诊断 → 可选 Authenticode 签名 → Authenticode 校验 → Tauri updater `.sig` → 收集**最终**工件 | updater `.sig` 与工件齐全为阻塞;Authenticode 默认非阻塞 | -| ARM64 | 交叉构建 + PE machine=`0xAA64` 诊断;**不是**实机运行验证 | 交叉构建失败阻塞;运行验证见下 | - -**启用 Authenticode(证书就绪后):** - -1. 在 `release` environment 配置 secrets: - - `WINDOWS_CERTIFICATE` — base64 编码的 OV/EV `.pfx` - - `WINDOWS_CERTIFICATE_PASSWORD` — pfx 密码 -2. (可选) repo variable `WINDOWS_TIMESTAMP_URL` 覆盖默认 RFC3161 时间戳。 -3. 确认 `release.yml` 的 *Authenticode-sign Windows installer* 步骤对 `-setup.exe` 签出 `Valid`。 -4. 将 repo variable `AUTHENTICODE_REQUIRED=true`,使 `verify-windows-authenticode.ps1` 在 `required` 模式下失败即阻断发布。 -5. 中期目标:在 `tauri build` 前导入证书并配置 `bundle.windows.certificateThumbprint`,让主程序 + uninstaller + installer 在打包期一并签名(比只签外层 setup 更完整)。 - -脚本: - -- [`scripts/sign-windows-authenticode.ps1`](../scripts/sign-windows-authenticode.ps1) — 有证书则签,无证书则跳过(exit 0)。 -- [`scripts/verify-windows-authenticode.ps1`](../scripts/verify-windows-authenticode.ps1) — `optional` / `required`。 -- [`scripts/windows-packaged-smoke.ps1`](../scripts/windows-packaged-smoke.ps1) — x64 生命周期冒烟。 -- [`scripts/windows-pe-arch.ps1`](../scripts/windows-pe-arch.ps1) — 读取 PE machine type。 - -失败日志阶段标签:`[build]` / `[sign]` / `[sign-verify]` / `[install]` / `[launch]` / `[upgrade]` / `[uninstall]`。 - -### ARM64 运行验证策略 - -- GitHub-hosted `windows-latest` 是 **x64**。`aarch64-pc-windows-msvc` 目标是交叉编译,产物经 `windows-pe-arch.ps1` 确认 machine=`0xAA64`。 -- **交叉构建成功 ≠ 运行验证。** 完整 install/launch/upgrade/uninstall 冒烟只在 x64 runner 上对 x64 安装包执行。 -- ARM64 实机或可信虚拟化验收清单(人工 / 自备 runner): - 1. 安装 `CodexAppManager_arm64-setup.exe`(被动 `/P` 或 UI)。 - 2. 确认 `%LOCALAPPDATA%\Codex App Manager\codex-app-manager.exe` 存在且 PE 为 ARM64。 - 3. 首次启动管理器 UI,无崩溃。 - 4. 再跑一遍安装器 `/P /UPDATE` 升级路径。 - 5. 卸载后主程序消失。 - 6. 若已配置 Authenticode,确认 installer / 主程序 / uninstaller 的 `Get-AuthenticodeSignature` 为 `Valid`。 +| `WINDOWS_CERTIFICATE` | secret | base64 编码的 OV/EV 代码签名 `.pfx` | +| `WINDOWS_CERTIFICATE_PASSWORD` | secret | PFX 密码;证书无密码时可以为空 | +| `WINDOWS_TIMESTAMP_URL` | variable,可选 | RFC3161 服务地址;默认 `http://timestamp.digicert.com` | +| `TAURI_SIGNING_PRIVATE_KEY` | secret | Tauri updater 私钥,不是 Authenticode 证书 | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | secret | updater 私钥密码 | -## English +不再使用 `AUTHENTICODE_REQUIRED` 开关:tag release 永远是 required。不要把 PFX 暴露给 `pull_request` workflow,也不要把真实证书提交进仓库。 -### Current status +### 用户核验 -- macOS builds are Developer ID signed and Apple notarized. -- The Windows installers `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` are not Authenticode-signed yet. -- Windows in-app update artifacts carry the Tauri updater signature, which verifies the downloaded bytes. -- SmartScreen may warn when users manually run the Windows installer for the first time; that is the known distribution risk, not an updater-signature failure. -- CI already runs x64 packaged lifecycle smoke (`install → launch → upgrade → uninstall`) and Authenticode probes; signing is skipped and verification is non-blocking until a certificate is configured. +在 Windows 上可直接查看三层签名: -### Three separate concepts - -**Tauri updater signature:** The `signature` field in `latest.json` signs the installer bytes. It protects in-app self-update downloads from tampering across mirrors and network hops. It is not Windows publisher trust and does not remove SmartScreen warnings. Implementation: `npx tauri signer sign` + `TAURI_SIGNING_PRIVATE_KEY`. - -**Authenticode code signing:** This is the Windows code-signing system for PE files and installers. With an OV or EV certificate, the installer can show a publisher identity and build operating-system trust. This project does not currently Authenticode-sign the Windows installer. The prepared CI path is documented below. - -**SmartScreen reputation:** Microsoft Defender SmartScreen combines signing identity, download volume, historical reputation, and risk signals. EV certificates usually establish reputation faster; OV certificates and unsigned distribution still need reputation to build, and unsigned installers are more likely to warn on first run. - -### How to verify downloads - -1. Download the installer from [GitHub Releases](https://github.com/Wangnov/Codex-App-Manager/releases/latest) or the agentsmirror mirror. -2. Download `SHA256SUMS` from Assets on the same release. -3. Compute the local hash and compare it with the matching filename in `SHA256SUMS`. +```powershell +Get-AuthenticodeSignature .\CodexAppManager_x64-setup.exe | Format-List Status,SignerCertificate,TimeStamperCertificate +Get-AuthenticodeSignature "$env:LOCALAPPDATA\Codex App Manager\codex-app-manager.exe" | Format-List Status,SignerCertificate,TimeStamperCertificate +Get-AuthenticodeSignature "$env:LOCALAPPDATA\Codex App Manager\uninstall.exe" | Format-List Status,SignerCertificate,TimeStamperCertificate +``` -Windows PowerShell: +发布页仍提供 `SHA256SUMS`,用于确认下载文件与 release 资产逐字节一致: ```powershell Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256 Get-FileHash .\CodexAppManager_arm64-setup.exe -Algorithm SHA256 ``` -macOS: +哈希、Authenticode、updater `.sig` 各自回答不同问题,不能互相替代。 -```bash -shasum -a 256 CodexAppManager_aarch64.dmg -shasum -a 256 CodexAppManager_x86_64.dmg -``` +### 相关脚本 + +- [`scripts/prepare-windows-authenticode.ps1`](../scripts/prepare-windows-authenticode.ps1) — 正式发布导入证书并生成 Tauri 配置;凭据缺失即失败。 +- [`scripts/verify-windows-authenticode.ps1`](../scripts/verify-windows-authenticode.ps1) — `required` 发布硬门;`optional` 仅供无 secret 的 PR / 本地诊断。 +- [`scripts/windows-packaged-smoke.ps1`](../scripts/windows-packaged-smoke.ps1) — 安装后验证主程序与卸载器,并覆盖升级、卸载路径。 +- [`scripts/sign-windows-authenticode.ps1`](../scripts/sign-windows-authenticode.ps1) — 旧的本地单文件签名工具;正式发布不依赖它。 +- [`scripts/windows-pe-arch.ps1`](../scripts/windows-pe-arch.ps1) — 验证 x64 / ARM64 PE machine type。 -If the hash does not match, do not run the file. Download it again or open an -issue with the source URL and filename. +## English + +### Release state -### Distribution channels and cost +- Windows releases require an OV/EV Authenticode PFX. Missing credentials hard-fail the tag build. +- The x64 and ARM64 main executable, generated NSIS uninstaller, and outer setup executable are signed by the same certificate with SHA-256 and an RFC3161 timestamp. +- CI requires `Get-AuthenticodeSignature.Status == Valid`, an exact match to the imported certificate thumbprint, and a timestamp countersigner on every layer. +- Pull requests do not receive release secrets. They intentionally build unsigned packages and run optional probes plus lifecycle smoke; this does not make unsigned release artifacts acceptable. +- In-app updates separately require a Tauri updater `.sig` over the final Authenticode-signed setup bytes. -**GitHub Releases + agentsmirror + SHA256SUMS:** This is the current primary channel. It is transparent, traceable, and independently verifiable, but the unsigned Windows installer can still trigger SmartScreen. +### Three independent mechanisms -**winget:** `Wangnov.CodexAppManager` is available in microsoft/winget-pkgs, and this repository auto-submits new stable releases. winget accepts unsigned NSIS installers, but a new architecture or metadata change can still receive manual review. +**Authenticode** is Windows publisher identity for PE files. The release job imports the PFX and gives Tauri a private config with `certificateThumbprint`, SHA-256, an RFC3161 timestamp URL, and `tsp=true`. Tauri signs the main executable, generated uninstaller, and outer NSIS installer inside-out; the custom template also selects Tauri's private signed NSIS plugin directory. -**Microsoft Store / Partner Center:** This is a medium-term option. It requires a developer account, MSIX packaging, and Store review. It improves user trust but adds process and maintenance cost. +**The Tauri updater signature** authenticates the final bytes referenced by `latest.json`. It protects downloads from tampering across GitHub, the mirror, and transport hops. It does not establish a Windows publisher. Its private key is separate from the Authenticode PFX. -**OV / EV code-signing certificate:** A certificate is not a release blocker today. Re-evaluate when Windows download volume, enterprise demand, or support cost crosses a clear threshold. EV costs more but establishes reputation faster; OV costs less but still needs SmartScreen reputation to build. +**SmartScreen reputation** is a Microsoft risk signal based on more than the presence of a signature. Authenticode provides a verifiable publisher but cannot promise that a new certificate or artifact will never trigger a warning. A SmartScreen warning is also not evidence that the updater signature failed. -**Cost shortcuts to avoid:** Do not custody private keys with untrusted third parties, share hardware tokens, or hand signing capability to channels that cannot be audited. +### Release gate -### Risk disclosure +The Windows jobs prepare the certificate, build with inside-out signing, verify the outer setup executable, install each architecture's package to inspect the signed embedded main/uninstaller, create the independent updater signature, collect the final names, and verify the publishable setup again. Tauri restores `target/.../release/codex-app-manager.exe` to its unsigned, unpatched build input after bundling, so that raw intermediate is used only for the PE architecture diagnostic and is not a signature gate. x64 runs the full launch lifecycle; ARM64 performs install/signature/upgrade/uninstall checks on the x64 host but requires ARM64 hardware or trusted virtualization for a real launch test. -An unsigned Windows installer means users may need to choose more information -and continue through SmartScreen on first run. The project reduces tampering and -confusion risk with public releases, mirror permalinks, `SHA256SUMS`, Tauri -updater signatures, and transparent documentation. Those mitigations do not -replace Authenticode, but they let users verify downloads independently until -certificate budget and demand justify Windows code signing. +There is no `AUTHENTICODE_REQUIRED` toggle for a tag release. Any missing layer, non-`Valid` status, wrong thumbprint, absent timestamp, or missing updater signature blocks publication. The PFX and imported certificate are removed in an `always()` cleanup step. -### CI / release pipeline (Authenticode path) +### Required release environment -| Stage | Behavior | Blocking? | +| Name | Kind | Purpose | |---|---|---| -| `ci.yml` Rust | Standalone `codex-mac-engine` / `codex-win-engine` tests | Yes (required) | -| `win-installer-check.yml` | Build x64 NSIS → Authenticode probe → install/launch/upgrade/uninstall smoke | No (path-filtered) | -| `release.yml` Windows | PE arch diagnostic → optional Authenticode sign → Authenticode verify → Tauri updater `.sig` → collect **final** artifacts | Updater `.sig` + artifact set block; Authenticode optional by default | -| ARM64 | Cross-build + PE machine=`0xAA64` diagnostic; **not** runtime verification | Cross-build failure blocks; runtime verification below | - -**Turning on Authenticode (when a cert is available):** - -1. Add secrets on the `release` environment: - - `WINDOWS_CERTIFICATE` — base64-encoded OV/EV `.pfx` - - `WINDOWS_CERTIFICATE_PASSWORD` — pfx password -2. Optionally set repo variable `WINDOWS_TIMESTAMP_URL` for the RFC3161 timestamp server. -3. Confirm the *Authenticode-sign Windows installer* step produces `Valid` on `-setup.exe`. -4. Set repo variable `AUTHENTICODE_REQUIRED=true` so `verify-windows-authenticode.ps1` runs in `required` mode and fails the release if unsigned. -5. Medium-term: import the cert before `tauri build` and set `bundle.windows.certificateThumbprint` so the main binary, uninstaller, and installer are all signed during packaging (more complete than outer-setup-only signing). - -Scripts: - -- [`scripts/sign-windows-authenticode.ps1`](../scripts/sign-windows-authenticode.ps1) — signs when a cert is present; skips with exit 0 otherwise. -- [`scripts/verify-windows-authenticode.ps1`](../scripts/verify-windows-authenticode.ps1) — `optional` / `required`. -- [`scripts/windows-packaged-smoke.ps1`](../scripts/windows-packaged-smoke.ps1) — x64 lifecycle smoke. -- [`scripts/windows-pe-arch.ps1`](../scripts/windows-pe-arch.ps1) — PE machine type probe. - -Failure log stage tags: `[build]` / `[sign]` / `[sign-verify]` / `[install]` / `[launch]` / `[upgrade]` / `[uninstall]`. - -### ARM64 runtime verification strategy - -- GitHub-hosted `windows-latest` is **x64**. The `aarch64-pc-windows-msvc` target is cross-compiled; `windows-pe-arch.ps1` asserts machine=`0xAA64`. -- **A successful cross-build is not runtime verification.** Full install/launch/upgrade/uninstall smoke runs only for the x64 installer on x64 runners. -- ARM64 bare-metal or trusted virtualization checklist (manual / self-hosted runner): - 1. Install `CodexAppManager_arm64-setup.exe` (passive `/P` or UI). - 2. Confirm `%LOCALAPPDATA%\Codex App Manager\codex-app-manager.exe` exists and is an ARM64 PE. - 3. First-launch the manager UI without crash. - 4. Re-run the installer with `/P /UPDATE` (upgrade path). - 5. Uninstall and confirm the main binary is gone. - 6. If Authenticode is configured, confirm installer / main / uninstaller `Get-AuthenticodeSignature` is `Valid`. +| `WINDOWS_CERTIFICATE` | secret | base64-encoded OV/EV code-signing PFX | +| `WINDOWS_CERTIFICATE_PASSWORD` | secret | PFX password; may be empty for an unprotected PFX | +| `WINDOWS_TIMESTAMP_URL` | optional variable | RFC3161 endpoint; defaults to `http://timestamp.digicert.com` | +| `TAURI_SIGNING_PRIVATE_KEY` | secret | Tauri updater private key, not the Authenticode certificate | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | secret | updater-key password | + +Never expose the PFX to a pull-request workflow or commit certificate material to the repository. Historical release notes retain the actual status of those older artifacts. diff --git a/scripts/gen-updater-manifest.mjs b/scripts/gen-updater-manifest.mjs index 37ad561..a7001e3 100644 --- a/scripts/gen-updater-manifest.mjs +++ b/scripts/gen-updater-manifest.mjs @@ -17,6 +17,17 @@ if (!tag || !dir) { } const version = tag.replace(/^v/, ""); const REPO = "Wangnov/Codex-App-Manager"; +const releaseNotesPath = join("docs", "releases", `${tag}.md`); +const updaterNotes = (markdown) => { + // Release pages start with a GitHub-only banner and end with the fixed + // download/signature table. The in-app sheet needs the reviewed summary and + // user-visible changes, not raw HTML or another set of install links. + const withoutBanner = markdown.replace(/^\s*[\s\S]*?<\/p>\s*/i, ""); + return withoutBanner.split(/^##\s+📦\s+/m, 1)[0].trim(); +}; +const notes = existsSync(releaseNotesPath) + ? updaterNotes(readFileSync(releaseNotesPath, "utf8")) || `Codex App Manager ${tag}` + : `Codex App Manager ${tag}`; const downloadUrl = (file) => `https://github.com/${REPO}/releases/download/${tag}/${encodeURIComponent(file)}`; @@ -80,7 +91,9 @@ if (missing.length > 0) { const manifest = { version, - notes: `Codex App Manager ${tag}`, + // The same reviewed release note shown on GitHub powers the in-app details + // view. Older/fallback builds still receive a short non-empty label. + notes, pub_date: new Date().toISOString(), platforms, }; diff --git a/scripts/prepare-windows-authenticode.ps1 b/scripts/prepare-windows-authenticode.ps1 new file mode 100644 index 0000000..d939905 --- /dev/null +++ b/scripts/prepare-windows-authenticode.ps1 @@ -0,0 +1,103 @@ +# Import the release code-signing certificate and generate the Tauri config used +# for inside-out Windows signing (main executable, NSIS uninstaller, installer). +# +# This script is intentionally release-only: missing credentials are fatal. Pull +# request workflows must never receive the PFX secret; they keep building unsigned +# installers and exercise the same NSIS template in optional verification mode. + +[CmdletBinding()] +param( + [string]$CertificateBase64 = $env:WINDOWS_CERTIFICATE, + [string]$CertificatePassword = $env:WINDOWS_CERTIFICATE_PASSWORD, + [string]$TimestampUrl = $(if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { "http://timestamp.digicert.com" }), + [string]$ConfigPath = $(Join-Path ([System.IO.Path]::GetTempPath()) "tauri-authenticode.conf.json"), + [string]$Stage = "sign-prepare" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Fail-Stage([string]$Message) { + Write-Host "::error::[$Stage] $Message" + throw "[$Stage] $Message" +} + +if ([string]::IsNullOrWhiteSpace($CertificateBase64)) { + Fail-Stage "WINDOWS_CERTIFICATE is required for a release build" +} +if ([string]::IsNullOrWhiteSpace($TimestampUrl)) { + Fail-Stage "WINDOWS_TIMESTAMP_URL must resolve to an RFC3161 timestamp service" +} + +$timestamp = $null +try { + $timestamp = [Uri]$TimestampUrl +} +catch { + Fail-Stage "WINDOWS_TIMESTAMP_URL is not a valid absolute URL" +} +if (-not $timestamp.IsAbsoluteUri -or $timestamp.Scheme -notin @("http", "https")) { + Fail-Stage "WINDOWS_TIMESTAMP_URL must use http or https" +} + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cam-authenticode-" + [guid]::NewGuid().ToString("n")) +New-Item -ItemType Directory -Path $tempDir | Out-Null +$pfxPath = Join-Path $tempDir "codesign.pfx" + +try { + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($CertificateBase64.Trim())) + $securePass = if ([string]::IsNullOrEmpty($CertificatePassword)) { + New-Object System.Security.SecureString + } + else { + ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force + } + + $imported = @(Import-PfxCertificate ` + -FilePath $pfxPath ` + -CertStoreLocation Cert:\CurrentUser\My ` + -Password $securePass) + $certificate = $imported | + Where-Object { $_.HasPrivateKey } | + Select-Object -First 1 + if (-not $certificate) { + Fail-Stage "the imported PFX contains no certificate with a private key" + } + + $thumbprint = $certificate.Thumbprint.Replace(" ", "").ToUpperInvariant() + $configDir = Split-Path -Parent $ConfigPath + if ($configDir) { + New-Item -ItemType Directory -Path $configDir -Force | Out-Null + } + @{ + bundle = @{ + windows = @{ + certificateThumbprint = $thumbprint + digestAlgorithm = "sha256" + timestampUrl = $TimestampUrl + tsp = $true + } + } + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ConfigPath -Encoding utf8 + + Write-Host "[$Stage] imported subject=$($certificate.Subject) thumbprint=$thumbprint" + Write-Host "[$Stage] generated Tauri Authenticode config: $ConfigPath" + + if ($env:GITHUB_ENV) { + "TAURI_AUTHENTICODE_CONFIG=$ConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "WINDOWS_CERTIFICATE_TEMP_DIR=$tempDir" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + if ($env:GITHUB_OUTPUT) { + "thumbprint=$thumbprint" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "config=$ConfigPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } +} +catch { + if (Test-Path -LiteralPath $tempDir) { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + throw +} + +return diff --git a/scripts/verify-windows-authenticode.ps1 b/scripts/verify-windows-authenticode.ps1 index 2616f7b..3dedb2e 100644 --- a/scripts/verify-windows-authenticode.ps1 +++ b/scripts/verify-windows-authenticode.ps1 @@ -1,11 +1,10 @@ # Verify Authenticode signatures on Windows PE files (installer, app, uninstaller). # # Modes: -# optional — report status; unsigned/NotSigned exits 0 (current milestone while -# OV/EV cert budget is not in place). Fail only if a path is missing -# or Get-AuthenticodeSignature itself errors. -# required — every path must have Status -eq Valid. Use after cert is wired -# into release (repo var AUTHENTICODE_REQUIRED=true). +# optional — PR/local diagnostic for builds that intentionally receive no +# release certificate. Reports unsigned files without blocking. +# required — release gate: every path must have Status -eq Valid. Can also +# pin the imported thumbprint and require RFC3161 evidence. # # Usage: # pwsh scripts/verify-windows-authenticode.ps1 -Path a.exe,b.exe -Mode optional @@ -25,6 +24,18 @@ param( # When set (required mode), SignerCertificate.Subject must contain this. [string]$ExpectedSubject = "", + # When set (required mode), the signer must be the exact certificate that + # the release job imported, not merely any locally trusted publisher. + [string]$ExpectedThumbprint = "", + + # Tauri is configured with tsp=true (/tr + /td SHA256). Requiring a + # countersigner here ensures the RFC3161 timestamp was actually embedded. + [switch]$RequireTimestamp, + + # Required together with -RequireTimestamp. The generated Tauri config is + # the build-time proof that signing used RFC3161 (/tr), not legacy /t. + [string]$SigningConfigPath = "", + [string]$Stage = "sign-verify" ) @@ -44,6 +55,24 @@ function Fail-Stage([string]$Message) { throw "[$Stage] $Message" } +if ($RequireTimestamp) { + if ([string]::IsNullOrWhiteSpace($SigningConfigPath) -or -not (Test-Path -LiteralPath $SigningConfigPath)) { + Fail-Stage "-RequireTimestamp also requires the generated Tauri signing config" + } + $signingConfig = Get-Content -LiteralPath $SigningConfigPath -Raw | ConvertFrom-Json + $windowsSigning = $signingConfig.bundle.windows + if ($windowsSigning.tsp -ne $true) { + Fail-Stage "Tauri signing config must set bundle.windows.tsp=true (RFC3161 /tr)" + } + if ([string]::IsNullOrWhiteSpace([string]$windowsSigning.timestampUrl)) { + Fail-Stage "Tauri signing config has no RFC3161 timestampUrl" + } + if ([string]$windowsSigning.digestAlgorithm -ne "sha256") { + Fail-Stage "Tauri signing config must use SHA256" + } + Write-Host "[$Stage] RFC3161 config asserted: tsp=true digest=sha256 url=$($windowsSigning.timestampUrl)" +} + Write-Stage "Authenticode verification (mode=$Mode)" $results = @() @@ -66,19 +95,21 @@ foreach ($raw in $Path) { $sig = Get-AuthenticodeSignature -LiteralPath $item.FullName $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "" } + $thumbprint = if ($sig.SignerCertificate) { $sig.SignerCertificate.Thumbprint.Replace(" ", "").ToUpperInvariant() } else { "" } + $timestampSubject = if ($sig.TimeStamperCertificate) { $sig.TimeStamperCertificate.Subject } else { "" } $status = [string]$sig.Status $ok = $false switch ($Mode) { "optional" { - # NotSigned is expected until OV/EV is configured. HashMismatch / + # PR builds intentionally receive no release secret. HashMismatch / # NotTrusted / UnknownError still surface as warnings for visibility. if ($status -eq "Valid") { $ok = $true } elseif ($status -eq "NotSigned") { $ok = $true - Write-Host "::warning::[$Stage] unsigned (expected until Authenticode cert is configured): $($item.Name)" + Write-Host "::warning::[$Stage] unsigned (expected only for PR/local diagnostics): $($item.Name)" } else { # Soft-fail in optional mode: report but do not block release. @@ -95,6 +126,14 @@ foreach ($raw in $Path) { $ok = $false Write-Host "::error::[$Stage] $($item.Name): subject '$subject' does not contain '$ExpectedSubject'" } + elseif ($ExpectedThumbprint -and ($thumbprint -ne $ExpectedThumbprint.Replace(" ", "").ToUpperInvariant())) { + $ok = $false + Write-Host "::error::[$Stage] $($item.Name): signer thumbprint '$thumbprint' does not match the release certificate" + } + elseif ($RequireTimestamp -and -not $sig.TimeStamperCertificate) { + $ok = $false + Write-Host "::error::[$Stage] $($item.Name): no RFC3161 timestamp countersigner" + } else { $ok = $true } @@ -107,10 +146,12 @@ foreach ($raw in $Path) { Path = $item.FullName Status = $status Subject = $subject + Thumbprint = $thumbprint + TimestampSubject = $timestampSubject Ok = $ok } - Write-Host (" {0,-12} {1} {2}" -f $status, $item.Name, $subject) + Write-Host (" {0,-12} {1} signer={2} timestamp={3}" -f $status, $item.Name, $subject, $timestampSubject) } $results | Format-Table -AutoSize | Out-String | Write-Host diff --git a/scripts/windows-packaged-smoke.ps1 b/scripts/windows-packaged-smoke.ps1 index 0baf91d..d2ca521 100644 --- a/scripts/windows-packaged-smoke.ps1 +++ b/scripts/windows-packaged-smoke.ps1 @@ -1,12 +1,13 @@ -# Windows x64 packaged lifecycle smoke test. +# Windows packaged lifecycle and signature smoke test. # # Stages (each failure message is prefixed so CI logs pin the phase): # build — caller already built; this script only consumes the installer # install — passive NSIS install (/P) -# launch — first start of the installed main executable +# launch — first start of the installed main executable (or explicit skip +# for an ARM64 payload inspected on an x64 runner) # upgrade — re-run installer with /P /UPDATE (in-place upgrade path) # uninstall — passive uninstall of the installed product (always attempted in finally) -# sign-verify — optional Authenticode probe on installer + installed PE files +# sign-verify — mode-controlled Authenticode check on installer + installed PE files # # Usage (prefer in-process from CI shell:pwsh steps — nested `pwsh -File` breaks # array binding for -Path on some runners): @@ -24,7 +25,11 @@ param( [string]$MainBinaryName = "codex-app-manager", [int]$LaunchSeconds = 12, [ValidateSet("optional", "required", "skip")] - [string]$AuthenticodeMode = "optional" + [string]$AuthenticodeMode = "optional", + [string]$ExpectedThumbprint = "", + [switch]$RequireTimestamp, + [string]$SigningConfigPath = "", + [switch]$SkipLaunch ) Set-StrictMode -Version Latest @@ -97,7 +102,13 @@ try { # ── sign-verify (installer artifact, pre-install) ─────────────────────── if ($AuthenticodeMode -ne "skip" -and (Test-Path $verifyScript)) { Write-Stage "sign-verify" "Probe Authenticode on installer ($AuthenticodeMode)" - & $verifyScript -Path $installerItem.FullName -Mode $AuthenticodeMode -Stage "sign-verify" + & $verifyScript ` + -Path $installerItem.FullName ` + -Mode $AuthenticodeMode ` + -ExpectedThumbprint $ExpectedThumbprint ` + -RequireTimestamp:$RequireTimestamp ` + -SigningConfigPath $SigningConfigPath ` + -Stage "sign-verify" Close-Stage } @@ -129,33 +140,46 @@ try { # ── sign-verify (installed PE) ────────────────────────────────────────── if ($AuthenticodeMode -ne "skip" -and (Test-Path $verifyScript)) { Write-Stage "sign-verify" "Probe Authenticode on installed executable + uninstaller" - & $verifyScript -Path @($mainExe, $uninstaller) -Mode $AuthenticodeMode -Stage "sign-verify" + & $verifyScript ` + -Path @($mainExe, $uninstaller) ` + -Mode $AuthenticodeMode ` + -ExpectedThumbprint $ExpectedThumbprint ` + -RequireTimestamp:$RequireTimestamp ` + -SigningConfigPath $SigningConfigPath ` + -Stage "sign-verify" Close-Stage } # ── launch ────────────────────────────────────────────────────────────── - Write-Stage "launch" "First launch (${LaunchSeconds}s observe window)" - Stop-AppProcesses $MainBinaryName + if ($SkipLaunch) { + Write-Stage "launch" "Skipped: cross-architecture signature install smoke" + Write-Host "[launch] main executable was installed and signature-verified but is not runnable on this host architecture" + Close-Stage + } + else { + Write-Stage "launch" "First launch (${LaunchSeconds}s observe window)" + Stop-AppProcesses $MainBinaryName - $proc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Minimized - Start-Sleep -Seconds $LaunchSeconds + $proc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Minimized + Start-Sleep -Seconds $LaunchSeconds - if ($proc.HasExited) { - # A GUI app exiting immediately with non-zero is a real failure. Exit 0 can - # happen if single-instance hands off, but a brand-new install should stay up. - if ($proc.ExitCode -ne 0) { - Fail-Stage "launch" "process exited early with code $($proc.ExitCode)" + if ($proc.HasExited) { + # A GUI app exiting immediately with non-zero is a real failure. Exit 0 can + # happen if single-instance hands off, but a brand-new install should stay up. + if ($proc.ExitCode -ne 0) { + Fail-Stage "launch" "process exited early with code $($proc.ExitCode)" + } + Write-Host "::warning::[launch] process exited during observe window with code 0 — treating as soft pass" } - Write-Host "::warning::[launch] process exited during observe window with code 0 — treating as soft pass" - } - else { - Write-Host "[launch] process still running (pid=$($proc.Id)) — first launch OK" - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 + else { + Write-Host "[launch] process still running (pid=$($proc.Id)) — first launch OK" + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + # Ensure nothing leftover holds files open for upgrade. + Stop-AppProcesses $MainBinaryName + Close-Stage } - # Ensure nothing leftover holds files open for upgrade. - Stop-AppProcesses $MainBinaryName - Close-Stage # ── upgrade ───────────────────────────────────────────────────────────── Write-Stage "upgrade" "Re-run installer with /P /UPDATE" @@ -171,6 +195,20 @@ try { Write-Host "[upgrade] post-upgrade PE size=$((Get-Item -LiteralPath $mainExe).Length) bytes" Close-Stage + # The upgrade path rewrites both payload files. Verify the bytes that remain + # installed, not only the first-install copies. + if ($AuthenticodeMode -ne "skip" -and (Test-Path $verifyScript)) { + Write-Stage "sign-verify" "Verify post-upgrade executable + uninstaller" + & $verifyScript ` + -Path @($mainExe, $uninstaller) ` + -Mode $AuthenticodeMode ` + -ExpectedThumbprint $ExpectedThumbprint ` + -RequireTimestamp:$RequireTimestamp ` + -SigningConfigPath $SigningConfigPath ` + -Stage "sign-verify" + Close-Stage + } + # ── uninstall (happy path) ────────────────────────────────────────────── Write-Stage "uninstall" "Passive uninstall (/P)" Stop-AppProcesses $MainBinaryName @@ -187,7 +225,8 @@ try { $didInstall = $false Close-Stage - Write-Host "Packaged lifecycle smoke passed: install → launch → upgrade → uninstall" + $launchLabel = if ($SkipLaunch) { "signature-only launch skip" } else { "launch" } + Write-Host "Packaged lifecycle smoke passed: install → $launchLabel → upgrade → uninstall" } catch { $smokeFailed = $true diff --git a/src-tauri/installer/installer.nsi b/src-tauri/installer/installer.nsi index 214d758..ac3c370 100644 --- a/src-tauri/installer/installer.nsi +++ b/src-tauri/installer/installer.nsi @@ -1,8 +1,9 @@ ; ───────────────────────────────────────────────────────────────────────────── ; Codex App Manager — custom NSIS installer template. -; Vendored verbatim from Tauri's default (@tauri-apps/cli v2.11.2) and lightly -; customized; every {{handlebars}} variable and all Tauri install/uninstall logic -; is preserved. Local changes are marked with "[codex-app-manager]". +; Based on Tauri's default (@tauri-apps/cli v2.11.2) and lightly customized; +; compatibility fragments are kept in sync with the pinned CLI. Every +; {{handlebars}} variable and all Tauri install/uninstall logic is preserved. +; Local changes are marked with "[codex-app-manager]". ; Re-sync with upstream when bumping the Tauri CLI. Branding (icon/header/sidebar) ; and the installer languages are driven from tauri.conf.json > bundle.windows.nsis; ; translated welcome/finish copy can be layered in here later (per-language LangStrings). @@ -22,6 +23,14 @@ ManifestDPIAwareness PerMonitorV2 SetCompressor /SOLID "{{compression}}" !endif +; Keep above !include to stay ahead of any plugin command. Tauri signs a private +; plugin copy when Windows code signing is configured; using the system plugin +; directory here would silently embed unsigned DLLs in an otherwise signed setup. +; https://github.com/tauri-apps/tauri/pull/15422 +{{#if signed_plugins_path}} +!addplugindir "{{signed_plugins_path}}" +{{/if}} + !include MUI2.nsh !include FileFunc.nsh !include x64.nsh diff --git a/src-tauri/src/app/manager_update_handoff.rs b/src-tauri/src/app/manager_update_handoff.rs new file mode 100644 index 0000000..0439654 --- /dev/null +++ b/src-tauri/src/app/manager_update_handoff.rs @@ -0,0 +1,222 @@ +//! Durable Windows manager-updater handoff state. +//! +//! `tauri-plugin-updater` launches NSIS and immediately terminates the old +//! process. A renderer event cannot be the only evidence during that gap, so +//! the backend fsyncs this record before entering `Update::install`. A newly +//! opened old build restores both the UI fence and the shared operation lock. + +#[cfg(any(target_os = "windows", test))] +use std::{fs, path::Path}; +use std::{ + io, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; + +#[cfg(any(target_os = "windows", test))] +use crate::app::atomic_file; +#[cfg(target_os = "windows")] +use crate::app::paths; + +pub const HANDOFF_MAX_AGE_MS: u64 = 10 * 60 * 1_000; +const MAX_FUTURE_SKEW_MS: u64 = 60 * 1_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagerUpdateHandoff { + pub version: String, + pub current_version: String, + pub body: Option, + pub started_at_unix_ms: u64, +} + +impl ManagerUpdateHandoff { + pub fn is_expired_at(&self, now_unix_ms: u64) -> bool { + self.started_at_unix_ms == 0 + || self.started_at_unix_ms > now_unix_ms.saturating_add(MAX_FUTURE_SKEW_MS) + || now_unix_ms.saturating_sub(self.started_at_unix_ms) >= HANDOFF_MAX_AGE_MS + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManagerUpdateHandoffStatus { + None, + Active(ManagerUpdateHandoff), + Expired(ManagerUpdateHandoff), +} + +pub fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[cfg(target_os = "windows")] +fn handoff_path() -> io::Result { + paths::data_dir() + .map(|dir| dir.join("manager-update-handoff.json")) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "manager data directory unavailable", + ) + }) +} + +pub fn persist_for_platform(record: &ManagerUpdateHandoff) -> io::Result<()> { + #[cfg(target_os = "windows")] + { + persist_at(&handoff_path()?, record) + } + #[cfg(not(target_os = "windows"))] + { + let _ = record; + Ok(()) + } +} + +pub fn clear_for_platform() { + #[cfg(target_os = "windows")] + if let Ok(path) = handoff_path() { + clear_at(&path); + } +} + +pub fn status_for_platform(current_version: &str) -> ManagerUpdateHandoffStatus { + #[cfg(target_os = "windows")] + { + handoff_path() + .ok() + .map_or(ManagerUpdateHandoffStatus::None, |path| { + status_at(&path, current_version, now_unix_ms()) + }) + } + #[cfg(not(target_os = "windows"))] + { + let _ = current_version; + ManagerUpdateHandoffStatus::None + } +} + +#[cfg(any(target_os = "windows", test))] +fn persist_at(path: &Path, record: &ManagerUpdateHandoff) -> io::Result<()> { + let bytes = serde_json::to_vec(record) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + atomic_file::write_atomic(path, &bytes) +} + +#[cfg(any(target_os = "windows", test))] +fn load_at(path: &Path) -> Option { + let backup = atomic_file::backup_path(path); + if !path.exists() && !backup.exists() { + return None; + } + atomic_file::read_with_recovery(path).0 +} + +#[cfg(any(target_os = "windows", test))] +fn clear_at(path: &Path) { + let _ = fs::remove_file(path); + let _ = fs::remove_file(atomic_file::backup_path(path)); +} + +#[cfg(any(target_os = "windows", test))] +fn status_at(path: &Path, current_version: &str, now: u64) -> ManagerUpdateHandoffStatus { + let Some(record) = load_at(path) else { + clear_at(path); + return ManagerUpdateHandoffStatus::None; + }; + if record.version == current_version || record.current_version != current_version { + clear_at(path); + return ManagerUpdateHandoffStatus::None; + } + if record.is_expired_at(now) { + clear_at(path); + return ManagerUpdateHandoffStatus::Expired(record); + } + ManagerUpdateHandoffStatus::Active(record) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::{ + clear_at, load_at, persist_at, status_at, ManagerUpdateHandoff, ManagerUpdateHandoffStatus, + HANDOFF_MAX_AGE_MS, + }; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(1); + + fn test_path(name: &str) -> std::path::PathBuf { + let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("test-data") + .join(format!( + "manager-handoff-{name}-{}-{id}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir.join("handoff.json") + } + + fn record(started_at_unix_ms: u64) -> ManagerUpdateHandoff { + ManagerUpdateHandoff { + version: "2.0.0".into(), + current_version: "1.0.0".into(), + body: Some("notes".into()), + started_at_unix_ms, + } + } + + #[test] + fn roundtrips_and_clears_both_primary_and_backup() { + let path = test_path("roundtrip"); + persist_at(&path, &record(100)).unwrap(); + persist_at(&path, &record(200)).unwrap(); + assert_eq!(load_at(&path), Some(record(200))); + + clear_at(&path); + assert!(load_at(&path).is_none()); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn status_only_fences_the_exact_old_version_for_a_bounded_window() { + let path = test_path("status"); + persist_at(&path, &record(1_000)).unwrap(); + assert!(matches!( + status_at(&path, "1.0.0", 1_001), + ManagerUpdateHandoffStatus::Active(_) + )); + + assert!(matches!( + status_at(&path, "1.0.0", 1_000 + HANDOFF_MAX_AGE_MS), + ManagerUpdateHandoffStatus::Expired(_) + )); + assert!(load_at(&path).is_none()); + + persist_at(&path, &record(2_000)).unwrap(); + assert_eq!( + status_at(&path, "2.0.0", 2_001), + ManagerUpdateHandoffStatus::None + ); + assert!(load_at(&path).is_none()); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn rejects_zero_and_implausibly_future_timestamps() { + assert!(record(0).is_expired_at(1)); + assert!(record(61_001).is_expired_at(1)); + } +} diff --git a/src-tauri/src/app/manager_update_runtime.rs b/src-tauri/src/app/manager_update_runtime.rs new file mode 100644 index 0000000..a8d017e --- /dev/null +++ b/src-tauri/src/app/manager_update_runtime.rs @@ -0,0 +1,269 @@ +//! Renderer-independent state for the manager's own updater. +//! +//! Tauri commands outlive a WebView navigation. Keeping this state behind the +//! application `ManagerState` lets a freshly loaded renderer reattach to an +//! in-flight download, recover a terminal error, or relaunch after a successful +//! macOS install whose original JavaScript caller disappeared. + +use std::sync::{Arc, Mutex}; + +use serde::Serialize; + +use crate::errors::CommandError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ManagerUpdateRuntimePhase { + Downloading, + Installing, + Installed, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagerUpdateRuntimeSnapshot { + pub revision: u64, + pub version: String, + pub current_version: String, + pub body: Option, + pub phase: ManagerUpdateRuntimePhase, + pub downloaded: u64, + pub total: Option, + pub failure: Option, + pub handoff_started_at: Option, +} + +#[derive(Debug, Default)] +struct Inner { + revision: u64, + snapshot: Option, +} + +#[derive(Clone, Default)] +pub struct ManagerUpdateRuntime { + inner: Arc>, +} + +impl ManagerUpdateRuntime { + pub fn snapshot(&self) -> Option { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .snapshot + .clone() + } + + pub fn begin( + &self, + version: String, + current_version: String, + body: Option, + ) -> ManagerUpdateRuntimeSnapshot { + self.replace(|revision| ManagerUpdateRuntimeSnapshot { + revision, + version, + current_version, + body, + phase: ManagerUpdateRuntimePhase::Downloading, + downloaded: 0, + total: None, + failure: None, + handoff_started_at: None, + }) + } + + pub fn downloading( + &self, + downloaded: u64, + total: Option, + ) -> Option { + self.mutate(|snapshot| { + snapshot.phase = ManagerUpdateRuntimePhase::Downloading; + snapshot.downloaded = downloaded; + snapshot.total = total; + snapshot.failure = None; + snapshot.handoff_started_at = None; + }) + } + + pub fn installing( + &self, + handoff_started_at: Option, + ) -> Option { + self.mutate(|snapshot| { + snapshot.phase = ManagerUpdateRuntimePhase::Installing; + snapshot.failure = None; + snapshot.handoff_started_at = handoff_started_at; + }) + } + + pub fn recover_installing( + &self, + version: String, + current_version: String, + body: Option, + handoff_started_at: u64, + ) -> ManagerUpdateRuntimeSnapshot { + self.replace(|revision| ManagerUpdateRuntimeSnapshot { + revision, + version, + current_version, + body, + phase: ManagerUpdateRuntimePhase::Installing, + downloaded: 0, + total: None, + failure: None, + handoff_started_at: Some(handoff_started_at), + }) + } + + pub fn installed(&self) -> Option { + self.mutate(|snapshot| { + snapshot.phase = ManagerUpdateRuntimePhase::Installed; + snapshot.failure = None; + snapshot.handoff_started_at = None; + }) + } + + pub fn failed(&self, failure: CommandError) -> Option { + self.mutate(|snapshot| { + snapshot.phase = ManagerUpdateRuntimePhase::Error; + snapshot.failure = Some(failure); + snapshot.handoff_started_at = None; + }) + } + + pub fn acknowledge_terminal( + &self, + revision: u64, + version: &str, + current_version: &str, + ) -> bool { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let should_clear = inner.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.revision == revision + && snapshot.version == version + && snapshot.current_version == current_version + && matches!( + snapshot.phase, + ManagerUpdateRuntimePhase::Installed | ManagerUpdateRuntimePhase::Error + ) + }); + if should_clear { + inner.snapshot = None; + } + should_clear + } + + fn replace( + &self, + create: impl FnOnce(u64) -> ManagerUpdateRuntimeSnapshot, + ) -> ManagerUpdateRuntimeSnapshot { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + inner.revision = inner.revision.saturating_add(1); + let snapshot = create(inner.revision); + inner.snapshot = Some(snapshot.clone()); + snapshot + } + + fn mutate( + &self, + update: impl FnOnce(&mut ManagerUpdateRuntimeSnapshot), + ) -> Option { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + inner.revision = inner.revision.saturating_add(1); + let revision = inner.revision; + let snapshot = inner.snapshot.as_mut()?; + update(snapshot); + snapshot.revision = revision; + Some(snapshot.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::{ManagerUpdateRuntime, ManagerUpdateRuntimePhase}; + use crate::errors::CommandError; + + #[test] + fn preserves_progress_and_terminal_state_across_handles() { + let runtime = ManagerUpdateRuntime::default(); + let other_handle = runtime.clone(); + + let started = runtime.begin( + "2.0.0".to_string(), + "1.0.0".to_string(), + Some("notes".to_string()), + ); + let progress = runtime.downloading(5, Some(10)).unwrap(); + let installing = runtime.installing(Some(123)).unwrap(); + let installed = runtime.installed().unwrap(); + + assert!(started.revision < progress.revision); + assert!(progress.revision < installing.revision); + assert!(installing.revision < installed.revision); + assert_eq!(installed.phase, ManagerUpdateRuntimePhase::Installed); + assert_eq!(installing.handoff_started_at, Some(123)); + assert_eq!(installed.handoff_started_at, None); + assert_eq!(installed.downloaded, 5); + assert_eq!(other_handle.snapshot(), Some(installed)); + } + + #[test] + fn records_structured_terminal_failure() { + let runtime = ManagerUpdateRuntime::default(); + runtime.begin("2.0.0".into(), "1.0.0".into(), None); + let failed = runtime + .failed(CommandError { + code: "network".into(), + message: "offline".into(), + }) + .unwrap(); + + assert_eq!(failed.phase, ManagerUpdateRuntimePhase::Error); + assert_eq!(failed.failure.unwrap().code, "network"); + } + + #[test] + fn terminal_ack_is_compare_and_swap_safe() { + let runtime = ManagerUpdateRuntime::default(); + runtime.begin("2.0.0".into(), "1.0.0".into(), None); + let failed = runtime + .failed(CommandError { + code: "stale_expectation".into(), + message: "changed".into(), + }) + .unwrap(); + + assert!(!runtime.acknowledge_terminal( + failed.revision.saturating_sub(1), + &failed.version, + &failed.current_version, + )); + assert!(runtime.snapshot().is_some()); + assert!(runtime.acknowledge_terminal( + failed.revision, + &failed.version, + &failed.current_version, + )); + assert!(runtime.snapshot().is_none()); + + let active = runtime.begin("3.0.0".into(), "2.0.0".into(), None); + assert!(!runtime.acknowledge_terminal( + active.revision, + &active.version, + &active.current_version, + )); + assert_eq!(runtime.snapshot(), Some(active)); + } +} diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index cf29106..fa1fc46 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -6,6 +6,8 @@ pub mod disk; pub mod install_tx; pub mod logging; pub mod mac_update; +pub mod manager_update_handoff; +pub mod manager_update_runtime; pub mod op_phase; pub mod operation_outcome; pub mod oplock; diff --git a/src-tauri/src/app/oplock.rs b/src-tauri/src/app/oplock.rs index 09a4856..290e5a9 100644 --- a/src-tauri/src/app/oplock.rs +++ b/src-tauri/src/app/oplock.rs @@ -17,6 +17,7 @@ const DEFAULT_STALE_AFTER_SECS: u64 = 5 * 60; pub enum OperationKind { Install, Update, + ManagerUpdate, Uninstall, SetInstallRoot, Adopt, @@ -27,6 +28,7 @@ impl OperationKind { match self { Self::Install => "install", Self::Update => "update", + Self::ManagerUpdate => "manager-update", Self::Uninstall => "uninstall", Self::SetInstallRoot => "set-install-root", Self::Adopt => "adopt", @@ -620,6 +622,31 @@ mod tests { let _ = fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn manager_update_lease_blocks_codex_mutations() { + let path = lock_path("manager-update"); + let manager = OperationManager::new(path.clone()); + let guard = manager.begin(OperationKind::ManagerUpdate).unwrap(); + + assert_eq!(guard.kind().as_str(), "manager-update"); + assert_eq!( + manager.snapshot().map(|snapshot| snapshot.kind), + Some(OperationKind::ManagerUpdate) + ); + assert!(matches!( + manager.begin(OperationKind::Install), + Err(OperationError::BusySameProcess("manager-update")) + )); + assert!(matches!( + manager.begin(OperationKind::Update), + Err(OperationError::BusySameProcess("manager-update")) + )); + + drop(guard); + assert!(manager.begin(OperationKind::Update).is_ok()); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn detached_token_must_be_ended_once() { let path = lock_path("detached"); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b7e1d3b..dfecfcd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,9 @@ -use std::path::{Path, PathBuf}; +use std::{ + future::Future, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; use codex_win_engine::InstalledWindowsCodex; use serde::{Deserialize, Serialize}; @@ -20,6 +25,16 @@ use crate::app::mac_update::{ uninstall_macos, InstalledCodex, MacInstallStatus, MacPerformReport, MacStageReport, MacUninstallReport, MacUpdateReport, PerformExpectation, }; +use crate::app::manager_update_handoff::clear_for_platform as clear_manager_update_handoff; +#[cfg(target_os = "windows")] +use crate::app::manager_update_handoff::{ + now_unix_ms, persist_for_platform as persist_manager_update_handoff, + ManagerUpdateHandoff, +}; +use crate::app::manager_update_handoff::{ + status_for_platform as manager_update_handoff_status, ManagerUpdateHandoffStatus, +}; +use crate::app::manager_update_runtime::ManagerUpdateRuntimeSnapshot; use crate::app::op_phase::{OperationPhase, QuitPolicy}; use crate::app::operation_outcome::{AncillaryRetryReport, AncillaryRetryRequest}; use crate::app::oplock::{ @@ -46,7 +61,7 @@ use crate::app::win_update::{ use crate::domain::settings::AppSettings as DomainAppSettings; use crate::domain::target::OperatingSystem; use crate::errors::{AppError, CommandError}; -use crate::state::ManagerState; +use crate::state::{manager_update_handoff_timeout, ManagerState}; fn normalize_windows_source_base(raw: &str) -> Option { let mut base = raw.trim().trim_end_matches('/').to_string(); @@ -158,8 +173,48 @@ pub struct ManagerUpdateMetadata { pub body: Option, } -fn manager_updater_builder( +const MANAGER_UPDATE_STATE_EVENT: &str = "manager://update-state"; +const MANAGER_UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(30); +const MANAGER_UPDATE_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const MANAGER_UPDATE_READ_TIMEOUT: Duration = Duration::from_secs(30); + +fn emit_manager_update_state(app: &AppHandle, snapshot: ManagerUpdateRuntimeSnapshot) { + let _ = app.emit(MANAGER_UPDATE_STATE_EVENT, snapshot); +} + +fn persist_manager_update_handoff_before_install( + snapshot: &ManagerUpdateRuntimeSnapshot, +) -> Result, AppError> { + #[cfg(target_os = "windows")] + { + let started_at_unix_ms = now_unix_ms(); + persist_manager_update_handoff(&ManagerUpdateHandoff { + version: snapshot.version.clone(), + current_version: snapshot.current_version.clone(), + body: snapshot.body.clone(), + started_at_unix_ms, + }) + .map_err(|error| AppError::Internal(format!("persist manager updater handoff: {error}")))?; + Ok(Some(started_at_unix_ms)) + } + #[cfg(not(target_os = "windows"))] + { + let _ = snapshot; + Ok(None) + } +} + +fn release_manager_update_handoff_guard(state: &ManagerState) { + state + .manager_update_handoff_guard + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); +} + +fn manager_updater_builder_for_endpoints( app: &AppHandle, + endpoints: Option>, ) -> Result { let saved = PersistedAppSettings::load(); let mut builder = app.updater_builder(); @@ -176,7 +231,145 @@ fn manager_updater_builder( builder = builder.proxy(proxy); } } - Ok(builder) + if let Some(endpoints) = endpoints { + builder = builder + .endpoints(endpoints) + .map_err(|e| AppError::Engine(format!("configure manager updater endpoints: {e}")))?; + } + // `UpdaterBuilder::timeout` only reaches manifest checks. The updater + // deliberately creates downloads without that total timeout, so keep + // downloads unbounded while data is flowing and enforce a read-stall + // timeout through the shared reqwest client configuration instead. + Ok(builder + .timeout(MANAGER_UPDATE_CHECK_TIMEOUT) + .configure_client(|client| { + client + .connect_timeout(MANAGER_UPDATE_CONNECT_TIMEOUT) + .read_timeout(MANAGER_UPDATE_READ_TIMEOUT) + })) +} + +fn configured_manager_update_endpoints(app: &AppHandle) -> Result, AppError> { + let raw = app + .config() + .plugins + .0 + .get("updater") + .cloned() + .ok_or_else(|| AppError::Internal("missing updater plugin configuration".to_string()))?; + let config: tauri_plugin_updater::Config = serde_json::from_value(raw) + .map_err(|e| AppError::Internal(format!("read updater plugin configuration: {e}")))?; + if config.endpoints.is_empty() { + return Err(AppError::Internal( + "updater plugin has no configured endpoints".to_string(), + )); + } + Ok(config.endpoints) +} + +async fn download_with_manager_fallback( + endpoints: Vec, + mut attempt: Attempt, +) -> Result +where + Attempt: FnMut(url::Url) -> AttemptFuture, + AttemptFuture: Future>, +{ + let mut failures = Vec::new(); + let mut stale_failures = Vec::new(); + for endpoint in endpoints { + let origin = redact_url(endpoint.as_str()); + match attempt(endpoint).await { + Ok(value) => return Ok(value), + // Consent is bound to the exact expected version/current pair, not + // to one particular feed. A stale mirror may safely fall through to + // GitHub, but no mismatching package is ever downloaded/installed. + Err(AppError::StaleExpectation(message)) => { + log::warn!("manager updater endpoint is stale origin={origin} error={message}"); + stale_failures.push(format!("{origin}: {message}")); + } + Err(err) => { + log::warn!("manager updater endpoint attempt failed origin={origin} error={err}"); + failures.push(format!("{origin}: {err}")); + } + } + } + + if !stale_failures.is_empty() { + let mut details = stale_failures; + details.extend(failures); + return Err(AppError::StaleExpectation(format!( + "管理器更新内容已变化,请重新检查后再确认。({})", + details.join("; ") + ))); + } + + Err(AppError::Engine(format!( + "manager update download failed for all configured endpoints: {}", + failures.join("; ") + ))) +} + +async fn check_with_manager_fallback( + endpoints: Vec, + mut attempt: Attempt, +) -> Result, AppError> +where + Attempt: FnMut(url::Url) -> AttemptFuture, + AttemptFuture: Future, AppError>>, +{ + let mut failures_after_last_success = Vec::new(); + let mut saw_no_update = false; + + for endpoint in endpoints { + let origin = redact_url(endpoint.as_str()); + match attempt(endpoint).await { + Ok(Some(update)) => return Ok(Some(update)), + Ok(None) => { + // A valid but stale mirror (including HTTP 204) is not proof + // that the authoritative fallback has no update. Continue all + // the way through the configured feeds. A later successful + // no-update result supersedes earlier transport failures. + log::debug!("manager updater endpoint has no update origin={origin}"); + saw_no_update = true; + failures_after_last_success.clear(); + } + Err(err) => { + log::warn!("manager updater check failed origin={origin} error={err}"); + failures_after_last_success.push(format!("{origin}: {err}")); + } + } + } + + if saw_no_update && failures_after_last_success.is_empty() { + return Ok(None); + } + + Err(AppError::Engine(format!( + "manager update check failed for all configured endpoints: {}", + failures_after_last_success.join("; ") + ))) +} + +async fn download_then_install_manager_update( + endpoints: Vec, + attempt: Attempt, + install: Install, +) -> Result<(), AppError> +where + Attempt: FnMut(url::Url) -> AttemptFuture, + AttemptFuture: Future>, + Install: FnOnce(T) -> Result<(), AppError>, +{ + // The fallback boundary ends when a fully downloaded package has passed its + // Tauri signature verification. Installation is invoked exactly once. + let package = download_with_manager_fallback(endpoints, attempt).await?; + install(package) +} + +struct DownloadedManagerUpdate { + update: tauri_plugin_updater::Update, + bytes: Vec, } fn manager_update_matches_confirmation( @@ -192,13 +385,20 @@ fn manager_update_matches_confirmation( pub async fn manager_check_update( app: AppHandle, ) -> Result, CommandError> { - let updater = manager_updater_builder(&app)? - .build() - .map_err(|e| AppError::Engine(format!("build manager updater: {e}")))?; - let update = updater - .check() - .await - .map_err(|e| AppError::Engine(format!("check manager update: {e}")))?; + let endpoints = configured_manager_update_endpoints(&app)?; + let update = check_with_manager_fallback(endpoints, |endpoint| { + let app = app.clone(); + async move { + let updater = manager_updater_builder_for_endpoints(&app, Some(vec![endpoint]))? + .build() + .map_err(|e| AppError::Engine(format!("build manager updater: {e}")))?; + updater + .check() + .await + .map_err(|e| AppError::Engine(format!("check manager update: {e}"))) + } + }) + .await?; Ok(update.map(|update| ManagerUpdateMetadata { version: update.version, current_version: update.current_version, @@ -206,36 +406,219 @@ pub async fn manager_check_update( })) } +#[tauri::command] +pub fn manager_get_update_runtime( + app: AppHandle, + state: State<'_, ManagerState>, +) -> Option { + let snapshot = state.manager_update.snapshot()?; + if snapshot.handoff_started_at.is_none() { + return Some(snapshot); + } + + let current_version = app.package_info().version.to_string(); + match manager_update_handoff_status(¤t_version) { + ManagerUpdateHandoffStatus::Active(record) + if record.version == snapshot.version + && record.current_version == snapshot.current_version + && record.started_at_unix_ms == snapshot.handoff_started_at.unwrap_or_default() => + { + Some(snapshot) + } + ManagerUpdateHandoffStatus::Active(_) + | ManagerUpdateHandoffStatus::Expired(_) + | ManagerUpdateHandoffStatus::None => { + // Missing, mismatched, or expired durable evidence must release the + // recovered cross-process lock and become an explicit retry surface. + clear_manager_update_handoff(); + release_manager_update_handoff_guard(&state); + state + .manager_update + .failed(manager_update_handoff_timeout()) + } + } +} + +#[tauri::command] +pub fn manager_ack_update_runtime( + state: State<'_, ManagerState>, + revision: u64, + version: String, + current_version: String, +) -> bool { + state + .manager_update + .acknowledge_terminal(revision, &version, ¤t_version) +} + #[tauri::command] pub async fn manager_install_update( app: AppHandle, + state: State<'_, ManagerState>, expected_version: String, expected_current_version: String, + expected_body: Option, ) -> Result<(), CommandError> { - let updater = manager_updater_builder(&app)? - .build() - .map_err(|e| AppError::Engine(format!("build manager updater: {e}")))?; - let update = updater - .check() - .await - .map_err(|e| AppError::Engine(format!("check manager update before install: {e}")))? - .ok_or_else(|| AppError::Engine("No manager update is available.".to_string()))?; - if !manager_update_matches_confirmation( - &update.version, - &update.current_version, - &expected_version, - &expected_current_version, - ) { - return Err(AppError::StaleExpectation( - "管理器更新内容已变化,请重新检查后再确认。".to_string(), + // Own the same process/cross-process lock as Codex mutations. This is both + // the final busy preflight and a race-free reservation: once acquired, no + // install/update can start while the manager package is downloading. + let op = begin_guard(&state, OperationKind::ManagerUpdate)?; + let start = state.manager_update.begin( + expected_version.clone(), + expected_current_version.clone(), + expected_body, + ); + emit_manager_update_state(&app, start); + + let result = async { + state + .operations + .set_phase(op.token(), OperationPhase::Downloading) + .map_err(AppError::from)?; + let endpoints = configured_manager_update_endpoints(&app)?; + let runtime_for_attempts = state.manager_update.clone(); + let operations_for_attempts = state.operations.clone(); + let token_for_attempts = op.token().clone(); + let app_for_attempts = app.clone(); + let expected_version = Arc::new(expected_version); + let expected_current_version = Arc::new(expected_current_version); + + download_then_install_manager_update( + endpoints, + move |endpoint| { + let runtime = runtime_for_attempts.clone(); + let operations = operations_for_attempts.clone(); + let token = token_for_attempts.clone(); + let app = app_for_attempts.clone(); + let expected_version = Arc::clone(&expected_version); + let expected_current_version = Arc::clone(&expected_current_version); + async move { + operations + .set_phase(&token, OperationPhase::Downloading) + .map_err(AppError::from)?; + if let Some(snapshot) = runtime.downloading(0, None) { + emit_manager_update_state(&app, snapshot); + } + + let updater = + manager_updater_builder_for_endpoints(&app, Some(vec![endpoint.clone()]))? + .build() + .map_err(|e| AppError::Engine(format!("build manager updater: {e}")))?; + let update = updater + .check() + .await + .map_err(|e| { + AppError::Engine(format!("check manager update before install: {e}")) + })? + .ok_or_else(|| { + AppError::StaleExpectation( + "管理器更新内容已变化,请重新检查后再确认。".to_string(), + ) + })?; + if !manager_update_matches_confirmation( + &update.version, + &update.current_version, + expected_version.as_str(), + expected_current_version.as_str(), + ) { + return Err(AppError::StaleExpectation( + "管理器更新内容已变化,请重新检查后再确认。".to_string(), + )); + } + + let source = redact_url(endpoint.as_str()); + let runtime_for_progress = runtime.clone(); + let operations_for_progress = operations.clone(); + let token_for_progress = token.clone(); + let app_for_progress = app.clone(); + let operations_for_verify = operations.clone(); + let token_for_verify = token.clone(); + let mut downloaded = 0_u64; + let bytes = update + .download( + move |chunk, total| { + downloaded = downloaded.saturating_add(chunk as u64); + let _ = operations_for_progress.set_progress( + &token_for_progress, + OperationProgress { + downloaded, + total: total.unwrap_or(0), + source: source.clone(), + }, + ); + if let Some(snapshot) = + runtime_for_progress.downloading(downloaded, total) + { + emit_manager_update_state(&app_for_progress, snapshot); + } + }, + move || { + // `download` invokes this before minisign verification. + // Verifying remains interruptible and may still fall back. + let _ = operations_for_verify + .set_phase(&token_for_verify, OperationPhase::Verifying); + }, + ) + .await + .map_err(|e| AppError::Engine(format!("download manager update: {e}")))?; + Ok(DownloadedManagerUpdate { update, bytes }) + } + }, + |package| { + // This is the no-return boundary on Windows. Set all durable and + // renderer-independent state before invoking the installer. + state + .operations + .set_phase(op.token(), OperationPhase::Committing) + .map_err(AppError::from)?; + let runtime = state.manager_update.snapshot().ok_or_else(|| { + AppError::Internal("manager update runtime disappeared before install".into()) + })?; + let handoff_started_at = persist_manager_update_handoff_before_install(&runtime)?; + let snapshot = state + .manager_update + .installing(handoff_started_at) + .ok_or_else(|| { + clear_manager_update_handoff(); + AppError::Internal( + "manager update runtime disappeared during install handoff".into(), + ) + })?; + emit_manager_update_state(&app, snapshot); + let installed = package + .update + .install(package.bytes) + .map_err(|e| AppError::Engine(format!("install manager update: {e}"))); + if installed.is_err() { + clear_manager_update_handoff(); + } + installed + }, ) - .into()); + .await?; + + state + .operations + .set_phase(op.token(), OperationPhase::Finishing) + .map_err(AppError::from)?; + if let Some(snapshot) = state.manager_update.installed() { + emit_manager_update_state(&app, snapshot); + } + clear_manager_update_handoff(); + Ok::<(), AppError>(()) + } + .await; + + match result { + Ok(()) => Ok(()), + Err(err) => { + let command_error = CommandError::from(err); + if let Some(snapshot) = state.manager_update.failed(command_error.clone()) { + emit_manager_update_state(&app, snapshot); + } + Err(command_error) + } } - update - .download_and_install(|_, _| {}, || {}) - .await - .map_err(|e| AppError::Engine(format!("install manager update: {e}")))?; - Ok(()) } fn windows_domain_settings_for_persisted(state: &ManagerState) -> DomainAppSettings { @@ -1818,11 +2201,14 @@ pub async fn win_uninstall( #[cfg(test)] mod tests { use super::{ + check_with_manager_fallback, download_then_install_manager_update, install_root_from_picked_dir, manager_update_matches_confirmation, normalize_windows_source_base, validate_install_root_path, validated_custom_proxy_for_settings, }; + use crate::errors::AppError; use std::fs; + use std::sync::{Arc, Mutex}; fn temp_path(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("{name}-{}", std::process::id())) @@ -1876,6 +2262,289 @@ mod tests { )); } + fn manager_update_test_endpoints() -> Vec { + vec![ + url::Url::parse("https://mirror.example/latest.json").unwrap(), + url::Url::parse("https://github.com/example/releases/latest/download/latest.json") + .unwrap(), + ] + } + + #[test] + fn manager_update_check_falls_back_after_primary_reports_no_update() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let attempts_for_check = Arc::clone(&attempts); + + let result = tauri::async_runtime::block_on(check_with_manager_fallback( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_check); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + if endpoint.host_str() == Some("mirror.example") { + Ok(None) + } else { + Ok(Some("github-update")) + } + } + }, + )); + + assert_eq!(result.unwrap(), Some("github-update")); + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + } + + #[test] + fn manager_update_check_falls_back_after_primary_check_failure() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let attempts_for_check = Arc::clone(&attempts); + + let result = tauri::async_runtime::block_on(check_with_manager_fallback( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_check); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + if endpoint.host_str() == Some("mirror.example") { + Err(AppError::Engine( + "manifest has no current platform".to_string(), + )) + } else { + Ok(Some("github-update")) + } + } + }, + )); + + assert_eq!(result.unwrap(), Some("github-update")); + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + } + + #[test] + fn manager_update_check_accepts_final_no_update_after_primary_failure() { + let result = tauri::async_runtime::block_on(check_with_manager_fallback( + manager_update_test_endpoints(), + |endpoint| async move { + if endpoint.host_str() == Some("mirror.example") { + Err(AppError::Engine("timed out".to_string())) + } else { + Ok(None::<&'static str>) + } + }, + )); + + assert_eq!(result.unwrap(), None); + } + + #[test] + fn manager_update_check_rejects_unconfirmed_no_update() { + let error = tauri::async_runtime::block_on(check_with_manager_fallback( + manager_update_test_endpoints(), + |endpoint| async move { + if endpoint.host_str() == Some("mirror.example") { + Ok(None::<&'static str>) + } else { + Err(AppError::Engine("timed out".to_string())) + } + }, + )) + .unwrap_err(); + + assert!(error.to_string().contains("github.com")); + assert!(error.to_string().contains("timed out")); + } + + #[test] + fn manager_update_falls_back_after_mirror_artifact_failure() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let installed = Arc::new(Mutex::new(Vec::new())); + let attempts_for_download = Arc::clone(&attempts); + let installed_for_install = Arc::clone(&installed); + + let result = tauri::async_runtime::block_on(download_then_install_manager_update( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_download); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + if endpoint.host_str() == Some("mirror.example") { + Err(AppError::Engine( + "download manager update: HTTP 404".to_string(), + )) + } else { + Ok("github-verified-package") + } + } + }, + move |package| { + installed_for_install.lock().unwrap().push(package); + Ok(()) + }, + )); + + assert!(result.is_ok()); + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + assert_eq!(*installed.lock().unwrap(), vec!["github-verified-package"]); + } + + #[test] + fn manager_update_falls_back_after_mirror_stream_resets() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let attempts_for_download = Arc::clone(&attempts); + + let result = tauri::async_runtime::block_on(download_then_install_manager_update( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_download); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + if endpoint.host_str() == Some("mirror.example") { + Err(AppError::Engine( + "download manager update: connection reset mid-stream".to_string(), + )) + } else { + Ok("github-verified-package") + } + } + }, + |_| Ok(()), + )); + + assert!(result.is_ok()); + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + } + + #[test] + fn manager_update_falls_back_when_primary_manifest_is_stale() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let attempts_for_download = Arc::clone(&attempts); + + let result = tauri::async_runtime::block_on(download_then_install_manager_update( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_download); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + if endpoint.host_str() == Some("mirror.example") { + Err(AppError::StaleExpectation("mirror is stale".to_string())) + } else { + Ok("github-exact-expected-package") + } + } + }, + |_| Ok(()), + )); + + assert!(result.is_ok()); + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + } + + #[test] + fn manager_update_reports_when_both_artifact_sources_fail() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let install_called = Arc::new(Mutex::new(false)); + let attempts_for_download = Arc::clone(&attempts); + let install_called_for_install = Arc::clone(&install_called); + + let error = tauri::async_runtime::block_on(download_then_install_manager_update::< + &'static str, + _, + _, + _, + >( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_download); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + Err(AppError::Engine(format!( + "download failed from {}", + endpoint.host_str().unwrap() + ))) + } + }, + move |_| { + *install_called_for_install.lock().unwrap() = true; + Ok(()) + }, + )) + .unwrap_err(); + + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + assert!(!*install_called.lock().unwrap()); + assert!(error.to_string().contains("mirror.example")); + assert!(error.to_string().contains("github.com")); + } + + #[test] + fn manager_update_never_falls_back_after_install_starts() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let install_calls = Arc::new(Mutex::new(0_u32)); + let attempts_for_download = Arc::clone(&attempts); + let install_calls_for_install = Arc::clone(&install_calls); + + let error = tauri::async_runtime::block_on(download_then_install_manager_update( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_download); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + Ok("verified-package") + } + }, + move |_| { + *install_calls_for_install.lock().unwrap() += 1; + Err(AppError::Engine( + "installer failed after launch".to_string(), + )) + }, + )) + .unwrap_err(); + + assert_eq!(*attempts.lock().unwrap(), vec!["mirror.example"]); + assert_eq!(*install_calls.lock().unwrap(), 1); + assert!(error.to_string().contains("installer failed after launch")); + } + #[test] fn rejects_non_empty_non_codex_install_root() { let root = temp_path("codex-manager-non-empty-root"); diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 2e31f12..26f7d7d 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -137,6 +137,8 @@ pub fn classify(message: &str) -> ErrorKind { } if m.contains("could not resolve") + || m.contains("network error") + || m.contains("error sending request") || m.contains("failed to connect") || m.contains("connection refused") || m.contains("connection reset") @@ -194,7 +196,7 @@ impl From for AppError { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct CommandError { pub code: String, @@ -254,6 +256,18 @@ mod tests { assert_eq!(classify("download cancelled"), ErrorKind::Cancelled); } + #[test] + fn manager_updater_transport_errors_are_network_failures() { + assert_eq!( + classify("check manager update: network error: dns lookup failed"), + ErrorKind::Network + ); + assert_eq!( + classify("install manager update: error sending request for url"), + ErrorKind::Network + ); + } + #[test] fn combined_retry_message_classifies_on_the_fresh_attempt() { // resume's incidental range failure (33) must not mask the fresh diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aff96e3..89d3c0d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -161,6 +161,8 @@ pub fn run() { commands::mac_launch_codex, commands::mac_uninstall, commands::manager_check_update, + commands::manager_ack_update_runtime, + commands::manager_get_update_runtime, commands::manager_install_update, commands::get_settings, commands::set_settings, @@ -199,6 +201,9 @@ pub fn run() { commands::log_frontend_error, ]) .setup(|app| { + let current_version = app.package_info().version.to_string(); + app.state::() + .restore_manager_update_handoff(¤t_version); #[cfg(target_os = "macos")] install_macos_menu(app)?; log::info!( diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 466174e..49c0043 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,7 +3,12 @@ use std::sync::Mutex; use crate::adapters::host; use crate::app::config_health::ConfigHealth; -use crate::app::oplock::OperationManager; +use crate::app::manager_update_handoff::{ + status_for_platform as manager_update_handoff_status, ManagerUpdateHandoff, + ManagerUpdateHandoffStatus, +}; +use crate::app::manager_update_runtime::ManagerUpdateRuntime; +use crate::app::oplock::{OperationGuard, OperationKind, OperationManager}; use crate::app::provenance::ProvenanceStore; use crate::app::settings_store::AppSettings as PersistedAppSettings; use crate::domain::manifest::MirrorEndpoints; @@ -18,9 +23,56 @@ pub struct ManagerState { /// exit handlers stop intercepting and let the process go. pub force_quit: AtomicBool, pub operations: OperationManager, + pub manager_update: ManagerUpdateRuntime, + /// Holds the shared operation lock when an old Windows build is reopened + /// while its updater-owned NSIS child is still replacing files. + pub manager_update_handoff_guard: Mutex>, pub config_health: Mutex, } +fn restore_manager_update_handoff(runtime: &ManagerUpdateRuntime, record: &ManagerUpdateHandoff) { + runtime.recover_installing( + record.version.clone(), + record.current_version.clone(), + record.body.clone(), + record.started_at_unix_ms, + ); +} + +pub(crate) fn manager_update_handoff_timeout() -> crate::errors::CommandError { + crate::errors::CommandError { + code: "timeout".to_string(), + message: "manager updater handoff expired before the target version launched".to_string(), + } +} + +fn restore_manager_update_handoff_status( + status: ManagerUpdateHandoffStatus, + operations: &OperationManager, + runtime: &ManagerUpdateRuntime, +) -> Option { + match status { + ManagerUpdateHandoffStatus::Active(record) => { + restore_manager_update_handoff(runtime, &record); + match operations.begin(OperationKind::ManagerUpdate) { + Ok(guard) => Some(guard), + Err(error) => { + // Another live manager may still own the same filesystem + // lock. The restored runtime still fences this renderer. + log::warn!("could not restore manager handoff operation lock: {error}"); + None + } + } + } + ManagerUpdateHandoffStatus::Expired(record) => { + restore_manager_update_handoff(runtime, &record); + runtime.failed(manager_update_handoff_timeout()); + None + } + ManagerUpdateHandoffStatus::None => None, + } +} + impl ManagerState { pub fn new() -> Self { let target = Target::current(); @@ -41,6 +93,7 @@ impl ManagerState { .map(|dir| dir.join("operation.lock")) .unwrap_or_else(|| std::env::temp_dir().join("codex-app-manager-operation.lock")); let operations = OperationManager::new(lock_path); + let manager_update = ManagerUpdateRuntime::default(); Self { target, @@ -48,9 +101,23 @@ impl ManagerState { endpoints, force_quit: AtomicBool::new(false), operations, + manager_update, + manager_update_handoff_guard: Mutex::new(None), config_health, } } + + pub(crate) fn restore_manager_update_handoff(&self, current_version: &str) { + let guard = restore_manager_update_handoff_status( + manager_update_handoff_status(current_version), + &self.operations, + &self.manager_update, + ); + *self + .manager_update_handoff_guard + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = guard; + } } impl Default for ManagerState { @@ -58,3 +125,80 @@ impl Default for ManagerState { Self::new() } } + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::restore_manager_update_handoff_status; + use crate::app::manager_update_handoff::{ManagerUpdateHandoff, ManagerUpdateHandoffStatus}; + use crate::app::manager_update_runtime::{ManagerUpdateRuntime, ManagerUpdateRuntimePhase}; + use crate::app::oplock::{OperationError, OperationKind, OperationManager}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(1); + + fn operations(name: &str) -> (OperationManager, std::path::PathBuf) { + let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("test-data") + .join(format!("state-handoff-{name}-{}-{id}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + (OperationManager::new(dir.join("operation.lock")), dir) + } + + fn record() -> ManagerUpdateHandoff { + ManagerUpdateHandoff { + version: "2.0.0".into(), + current_version: "1.0.0".into(), + body: Some("notes".into()), + started_at_unix_ms: 123, + } + } + + #[test] + fn active_handoff_restores_runtime_and_blocks_codex_operations() { + let (operations, dir) = operations("active"); + let runtime = ManagerUpdateRuntime::default(); + let guard = restore_manager_update_handoff_status( + ManagerUpdateHandoffStatus::Active(record()), + &operations, + &runtime, + ) + .unwrap(); + + let snapshot = runtime.snapshot().unwrap(); + assert_eq!(snapshot.phase, ManagerUpdateRuntimePhase::Installing); + assert_eq!(snapshot.handoff_started_at, Some(123)); + assert!(matches!( + operations.begin(OperationKind::Update), + Err(OperationError::BusySameProcess("manager-update")) + )); + + drop(guard); + assert!(operations.begin(OperationKind::Update).is_ok()); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn expired_handoff_restores_a_retryable_terminal_without_a_lock() { + let (operations, dir) = operations("expired"); + let runtime = ManagerUpdateRuntime::default(); + let guard = restore_manager_update_handoff_status( + ManagerUpdateHandoffStatus::Expired(record()), + &operations, + &runtime, + ); + + assert!(guard.is_none()); + let snapshot = runtime.snapshot().unwrap(); + assert_eq!(snapshot.phase, ManagerUpdateRuntimePhase::Error); + assert_eq!(snapshot.failure.unwrap().code, "timeout"); + assert!(operations.begin(OperationKind::Update).is_ok()); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index c538f4f..52ec737 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,12 @@ import { Settings } from "./views/Settings"; import { About } from "./views/About"; import { Uninstall } from "./views/Uninstall"; import { CodexConfig } from "./views/CodexConfig"; +import { + ManagerUpdateBanner, + ManagerUpdateProvider, + ManagerUpdateSheet, + useManagerUpdate, +} from "./managerUpdate"; type View = "home" | "settings" | "about" | "uninstall" | "config"; @@ -30,6 +36,7 @@ function focusPageTarget(root: ParentNode | null) { function Shell() { const [view, setView] = useState("home"); + const managerUpdate = useManagerUpdate(); // Skip the first paint: NavBar / Home already own initial focus; stealing it // on mount is noisier than helpful for keyboard users. const skipInitialFocus = useRef(true); @@ -61,34 +68,40 @@ function Shell() { return ( <> -
- setView("settings")} /> -
- {view === "settings" ? ( -
- withViewTransition(() => setView("home"))} - onOpenAbout={() => setView("about")} - onOpenUninstall={() => setView("uninstall")} - onOpenConfig={() => setView("config")} +
+
+ setView("settings")} + managerUpdateSlot={} />
- ) : null} - {view === "about" ? ( -
- setView("settings")} /> -
- ) : null} - {view === "uninstall" ? ( -
- setView("settings")} /> -
- ) : null} - {view === "config" ? ( -
- setView("settings")} /> -
- ) : null} + {view === "settings" ? ( +
+ withViewTransition(() => setView("home"))} + onOpenAbout={() => setView("about")} + onOpenUninstall={() => setView("uninstall")} + onOpenConfig={() => setView("config")} + /> +
+ ) : null} + {view === "about" ? ( +
+ setView("settings")} /> +
+ ) : null} + {view === "uninstall" ? ( +
+ setView("settings")} /> +
+ ) : null} + {view === "config" ? ( +
+ setView("settings")} /> +
+ ) : null} +
+ setView("settings")} /> ); } @@ -101,7 +114,13 @@ export function App() { - + + {/* Keep updater state alive when an unrelated view render fails; + the outer boundary still catches provider-level failures. */} + + + + diff --git a/src/app/i18n.tsx b/src/app/i18n.tsx index 96355f4..63ae10d 100644 --- a/src/app/i18n.tsx +++ b/src/app/i18n.tsx @@ -244,6 +244,16 @@ const ZH = { "about.mgrUpToDate": "管理器已是最新版。", "about.mgrFound": "发现管理器新版本 {version}", "about.mgrConfirmBody": "将下载并安装管理器更新,完成后自动重启管理器。", + "managerUpdate.later": "稍后提醒", + "managerUpdate.viewNotes": "查看说明", + "managerUpdate.downloading": "正在下载…", + "managerUpdate.notes": "版本说明", + "managerUpdate.noNotes": "此版本没有附加说明。", + "managerUpdate.networkHint": "已自动尝试镜像与 GitHub。你可以重试,或到网络设置检查代理。", + "managerUpdate.restart": "立即重启", + "managerUpdate.restartRequired": "版本 {version} 已安装,需要重启管理器才能完成升级。", + "managerUpdate.relaunchFailed": "更新已安装,但管理器未能自动重启。请立即重试或稍后手动重启。", + "managerUpdate.completed": "管理器已升级到 {version}。", "about.feedback": "反馈与支持", "about.openLogsDir": "打开日志目录", "about.copyDiagnostics": "复制诊断信息", @@ -522,6 +532,16 @@ const EN: Record = { "about.mgrUpToDate": "The manager is up to date.", "about.mgrFound": "Manager version {version} is available", "about.mgrConfirmBody": "The manager update will download, install, and restart the manager.", + "managerUpdate.later": "Remind me later", + "managerUpdate.viewNotes": "View notes", + "managerUpdate.downloading": "Downloading…", + "managerUpdate.notes": "Release notes", + "managerUpdate.noNotes": "No additional notes were provided for this release.", + "managerUpdate.networkHint": "The mirror and GitHub were tried automatically. Retry, or check the proxy in Network settings.", + "managerUpdate.restart": "Restart now", + "managerUpdate.restartRequired": "Version {version} is installed. Restart the manager to finish updating.", + "managerUpdate.relaunchFailed": "The update was installed, but the manager could not restart. Retry now or restart it manually later.", + "managerUpdate.completed": "The manager was updated to {version}.", "about.feedback": "Feedback & support", "about.openLogsDir": "Open logs folder", "about.copyDiagnostics": "Copy diagnostics", @@ -797,6 +817,16 @@ const FR: Record = { "about.mgrUpToDate": "Le gestionnaire est à jour.", "about.mgrFound": "La version {version} du gestionnaire est disponible", "about.mgrConfirmBody": "La mise à jour du gestionnaire sera téléchargée, installée, puis le gestionnaire redémarrera.", + "managerUpdate.later": "Me le rappeler plus tard", + "managerUpdate.viewNotes": "Voir les notes", + "managerUpdate.downloading": "Téléchargement…", + "managerUpdate.notes": "Notes de version", + "managerUpdate.noNotes": "Aucune note supplémentaire pour cette version.", + "managerUpdate.networkHint": "Le miroir et GitHub ont été essayés automatiquement. Réessayez ou vérifiez le proxy dans les réglages réseau.", + "managerUpdate.restart": "Redémarrer maintenant", + "managerUpdate.restartRequired": "La version {version} est installée. Redémarrez le gestionnaire pour terminer la mise à jour.", + "managerUpdate.relaunchFailed": "La mise à jour est installée, mais le gestionnaire n’a pas pu redémarrer. Réessayez ou redémarrez-le manuellement plus tard.", + "managerUpdate.completed": "Le gestionnaire a été mis à jour vers la version {version}.", "about.feedback": "Retours et support", "about.openLogsDir": "Ouvrir le dossier des journaux", "about.copyDiagnostics": "Copier le diagnostic", @@ -1077,6 +1107,16 @@ const ZH_TW: Record = { "about.mgrUpToDate": "管理員已是最新版。", "about.mgrFound": "發現管理員新版本 {version}", "about.mgrConfirmBody": "將下載並安裝管理員更新,完成後自動重新啟動管理員。", + "managerUpdate.later": "稍後提醒", + "managerUpdate.viewNotes": "查看說明", + "managerUpdate.downloading": "正在下載…", + "managerUpdate.notes": "版本說明", + "managerUpdate.noNotes": "此版本沒有附加說明。", + "managerUpdate.networkHint": "已自動嘗試鏡像與 GitHub。你可以重試,或到網路設定檢查代理。", + "managerUpdate.restart": "立即重新啟動", + "managerUpdate.restartRequired": "版本 {version} 已安裝,需要重新啟動管理員才能完成更新。", + "managerUpdate.relaunchFailed": "更新已安裝,但管理員未能自動重新啟動。請立即重試或稍後手動重新啟動。", + "managerUpdate.completed": "管理員已升級至 {version}。", "about.feedback": "意見回饋與支援", "about.openLogsDir": "打開日誌目錄", "about.copyDiagnostics": "複製診斷資訊", @@ -1363,6 +1403,16 @@ const DE: Record = { "about.mgrUpToDate": "Der Manager ist aktuell.", "about.mgrFound": "Manager-Version {version} ist verfügbar", "about.mgrConfirmBody": "Das Manager-Update wird heruntergeladen und installiert; danach startet der Manager neu.", + "managerUpdate.later": "Später erinnern", + "managerUpdate.viewNotes": "Hinweise anzeigen", + "managerUpdate.downloading": "Wird heruntergeladen…", + "managerUpdate.notes": "Versionshinweise", + "managerUpdate.noNotes": "Für diese Version wurden keine zusätzlichen Hinweise bereitgestellt.", + "managerUpdate.networkHint": "Spiegelserver und GitHub wurden automatisch versucht. Versuchen Sie es erneut oder prüfen Sie den Proxy in den Netzwerkeinstellungen.", + "managerUpdate.restart": "Jetzt neu starten", + "managerUpdate.restartRequired": "Version {version} ist installiert. Starten Sie den Manager neu, um das Update abzuschließen.", + "managerUpdate.relaunchFailed": "Das Update wurde installiert, aber der Manager konnte nicht neu starten. Versuchen Sie es erneut oder starten Sie ihn später manuell.", + "managerUpdate.completed": "Der Manager wurde auf {version} aktualisiert.", "about.feedback": "Feedback & Support", "about.openLogsDir": "Log-Ordner öffnen", "about.copyDiagnostics": "Diagnose kopieren", @@ -1649,6 +1699,16 @@ const KO: Record = { "about.mgrUpToDate": "관리자가 최신 상태입니다.", "about.mgrFound": "관리자 버전 {version} 사용 가능", "about.mgrConfirmBody": "관리자 업데이트를 다운로드하고 설치한 뒤 관리자를 다시 시작합니다.", + "managerUpdate.later": "나중에 알림", + "managerUpdate.viewNotes": "설명 보기", + "managerUpdate.downloading": "다운로드 중…", + "managerUpdate.notes": "릴리스 노트", + "managerUpdate.noNotes": "이 릴리스에는 추가 설명이 없습니다.", + "managerUpdate.networkHint": "미러와 GitHub를 자동으로 시도했습니다. 다시 시도하거나 네트워크 설정에서 프록시를 확인하세요.", + "managerUpdate.restart": "지금 다시 시작", + "managerUpdate.restartRequired": "버전 {version}이(가) 설치되었습니다. 업데이트를 완료하려면 관리자를 다시 시작하세요.", + "managerUpdate.relaunchFailed": "업데이트가 설치되었지만 관리자를 다시 시작하지 못했습니다. 지금 다시 시도하거나 나중에 수동으로 다시 시작하세요.", + "managerUpdate.completed": "관리자가 {version}(으)로 업데이트되었습니다.", "about.feedback": "피드백 및 지원", "about.openLogsDir": "로그 폴더 열기", "about.copyDiagnostics": "진단 정보 복사", @@ -1923,6 +1983,16 @@ const JA: Record = { "about.mgrUpToDate": "マネージャーは最新です。", "about.mgrFound": "マネージャーのバージョン {version} が利用できます", "about.mgrConfirmBody": "マネージャーの更新をダウンロードしてインストールし、完了後にマネージャーを再起動します。", + "managerUpdate.later": "後で通知", + "managerUpdate.viewNotes": "説明を見る", + "managerUpdate.downloading": "ダウンロード中…", + "managerUpdate.notes": "リリースノート", + "managerUpdate.noNotes": "このリリースには追加の説明がありません。", + "managerUpdate.networkHint": "ミラーと GitHub を自動的に試しました。再試行するか、ネットワーク設定でプロキシを確認してください。", + "managerUpdate.restart": "今すぐ再起動", + "managerUpdate.restartRequired": "バージョン {version} をインストールしました。更新を完了するにはマネージャーを再起動してください。", + "managerUpdate.relaunchFailed": "更新はインストールされましたが、マネージャーを再起動できませんでした。再試行するか、後で手動で再起動してください。", + "managerUpdate.completed": "マネージャーを {version} に更新しました。", "about.feedback": "フィードバックとサポート", "about.openLogsDir": "ログフォルダーを開く", "about.copyDiagnostics": "診断情報をコピー", @@ -2189,6 +2259,16 @@ const RU: Record = { "about.mgrUpToDate": "Менеджер уже обновлен.", "about.mgrFound": "Доступна версия менеджера {version}", "about.mgrConfirmBody": "Обновление менеджера будет загружено и установлено, затем менеджер перезапустится.", + "managerUpdate.later": "Напомнить позже", + "managerUpdate.viewNotes": "Показать описание", + "managerUpdate.downloading": "Загрузка…", + "managerUpdate.notes": "Примечания к выпуску", + "managerUpdate.noNotes": "Для этого выпуска нет дополнительных примечаний.", + "managerUpdate.networkHint": "Зеркало и GitHub были проверены автоматически. Повторите попытку или проверьте прокси в настройках сети.", + "managerUpdate.restart": "Перезапустить сейчас", + "managerUpdate.restartRequired": "Версия {version} установлена. Перезапустите менеджер, чтобы завершить обновление.", + "managerUpdate.relaunchFailed": "Обновление установлено, но менеджер не удалось перезапустить. Повторите попытку или перезапустите его вручную позже.", + "managerUpdate.completed": "Менеджер обновлён до версии {version}.", "about.feedback": "Отзывы и поддержка", "about.openLogsDir": "Открыть папку журналов", "about.copyDiagnostics": "Скопировать диагностику", @@ -2455,6 +2535,16 @@ const AR: Record = { "about.mgrUpToDate": "المدير مُحدَّث.", "about.mgrFound": "يتوفر إصدار المدير {version}", "about.mgrConfirmBody": "سيتم تنزيل تحديث المدير وتثبيته، ثم سيُعاد تشغيل المدير.", + "managerUpdate.later": "ذكّرني لاحقاً", + "managerUpdate.viewNotes": "عرض الملاحظات", + "managerUpdate.downloading": "جارٍ التنزيل…", + "managerUpdate.notes": "ملاحظات الإصدار", + "managerUpdate.noNotes": "لا توجد ملاحظات إضافية لهذا الإصدار.", + "managerUpdate.networkHint": "تمت محاولة المرآة وGitHub تلقائياً. أعد المحاولة أو تحقق من الوكيل في إعدادات الشبكة.", + "managerUpdate.restart": "إعادة التشغيل الآن", + "managerUpdate.restartRequired": "تم تثبيت الإصدار {version}. أعد تشغيل المدير لإكمال التحديث.", + "managerUpdate.relaunchFailed": "تم تثبيت التحديث، لكن تعذرت إعادة تشغيل المدير. حاول الآن أو أعد تشغيله يدوياً لاحقاً.", + "managerUpdate.completed": "تم تحديث المدير إلى الإصدار {version}.", "about.feedback": "ملاحظات ودعم", "about.openLogsDir": "فتح مجلد السجلات", "about.copyDiagnostics": "نسخ التشخيص", @@ -2721,6 +2811,16 @@ const ES: Record = { "about.mgrUpToDate": "El gestor está actualizado.", "about.mgrFound": "La versión {version} del gestor está disponible", "about.mgrConfirmBody": "La actualización del gestor se descargará, se instalará y reiniciará el gestor.", + "managerUpdate.later": "Recordármelo más tarde", + "managerUpdate.viewNotes": "Ver notas", + "managerUpdate.downloading": "Descargando…", + "managerUpdate.notes": "Notas de la versión", + "managerUpdate.noNotes": "No hay notas adicionales para esta versión.", + "managerUpdate.networkHint": "El espejo y GitHub se probaron automáticamente. Reinténtalo o revisa el proxy en la configuración de red.", + "managerUpdate.restart": "Reiniciar ahora", + "managerUpdate.restartRequired": "La versión {version} está instalada. Reinicia el gestor para completar la actualización.", + "managerUpdate.relaunchFailed": "La actualización se instaló, pero el gestor no pudo reiniciarse. Reinténtalo o reinícialo manualmente más tarde.", + "managerUpdate.completed": "El gestor se actualizó a la versión {version}.", "about.feedback": "Comentarios y soporte", "about.openLogsDir": "Abrir carpeta de registros", "about.copyDiagnostics": "Copiar diagnóstico", @@ -2987,6 +3087,16 @@ const PT_BR: Record = { "about.mgrUpToDate": "O gerenciador está atualizado.", "about.mgrFound": "A versão {version} do gerenciador está disponível", "about.mgrConfirmBody": "A atualização do gerenciador será baixada e instalada; depois o gerenciador reiniciará.", + "managerUpdate.later": "Lembrar mais tarde", + "managerUpdate.viewNotes": "Ver notas", + "managerUpdate.downloading": "Baixando…", + "managerUpdate.notes": "Notas da versão", + "managerUpdate.noNotes": "Não há notas adicionais para esta versão.", + "managerUpdate.networkHint": "O espelho e o GitHub foram tentados automaticamente. Tente novamente ou verifique o proxy nas configurações de rede.", + "managerUpdate.restart": "Reiniciar agora", + "managerUpdate.restartRequired": "A versão {version} foi instalada. Reinicie o gerenciador para concluir a atualização.", + "managerUpdate.relaunchFailed": "A atualização foi instalada, mas o gerenciador não pôde reiniciar. Tente novamente ou reinicie-o manualmente mais tarde.", + "managerUpdate.completed": "O gerenciador foi atualizado para {version}.", "about.feedback": "Feedback e suporte", "about.openLogsDir": "Abrir pasta de logs", "about.copyDiagnostics": "Copiar diagnóstico", diff --git a/src/app/managerUpdate.test.tsx b/src/app/managerUpdate.test.tsx new file mode 100644 index 0000000..2c02324 --- /dev/null +++ b/src/app/managerUpdate.test.tsx @@ -0,0 +1,731 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { listen } from "@tauri-apps/api/event"; + +import { managerApi, type ManagerUpdateAvailable } from "../services/managerApi"; +import type { ManagerUpdateRuntimeSnapshot } from "../shared/types"; +import { I18nProvider } from "./i18n"; +import { ThemeProvider } from "./theme"; +import { About } from "./views/About"; +import { + MANAGER_UPDATE_COMPLETION_KEY, + MANAGER_UPDATE_HANDOFF_GRACE_MS, + MANAGER_UPDATE_STATE_EVENT, + MANAGER_UPDATE_SNOOZE_KEY, + ManagerUpdateBanner, + ManagerUpdateProvider, + ManagerUpdateSheet, + useManagerUpdate, +} from "./managerUpdate"; + +vi.mock("../services/managerApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + managerApi: { + ...actual.managerApi, + checkManagerUpdate: vi.fn(), + acknowledgeManagerUpdateRuntime: vi.fn(), + getManagerUpdateRuntime: vi.fn(), + installManagerUpdate: vi.fn(), + relaunchManager: vi.fn(), + }, + }; +}); + +const api = vi.mocked(managerApi); +const listenMock = vi.mocked(listen); +const AVAILABLE: ManagerUpdateAvailable = { + kind: "available", + version: "2.0.0", + currentVersion: "1.0.0", + body: "# 2.0.0\n\nReliable delivery notes.", +}; + +function runtimeSnapshot( + overrides: Partial = {}, +): ManagerUpdateRuntimeSnapshot { + return { + revision: 1, + version: AVAILABLE.version, + currentVersion: AVAILABLE.currentVersion, + body: AVAILABLE.body, + phase: "downloading", + downloaded: 0, + total: null, + failure: null, + ...overrides, + }; +} + +function Probe() { + const state = useManagerUpdate(); + return ( + + ); +} + +function renderManager({ + currentVersion = "1.0.0", + startupDelayMs = 0, + periodicIntervalMs = 60_000, + includeAbout = false, +}: { + currentVersion?: string; + startupDelayMs?: number; + periodicIntervalMs?: number; + includeAbout?: boolean; +} = {}) { + return render( + + + + + + {includeAbout ? : null} + + + + , + ); +} + +describe("manager self-update state machine", () => { + let onRuntime: ((event: { payload: ManagerUpdateRuntimeSnapshot }) => void) | undefined; + + beforeEach(() => { + localStorage.setItem("cam.lang", "zh-CN"); + api.checkManagerUpdate.mockReset(); + api.acknowledgeManagerUpdateRuntime.mockReset(); + api.getManagerUpdateRuntime.mockReset(); + api.installManagerUpdate.mockReset(); + api.relaunchManager.mockReset(); + api.checkManagerUpdate.mockResolvedValue({ kind: "none" }); + api.acknowledgeManagerUpdateRuntime.mockResolvedValue(true); + api.getManagerUpdateRuntime.mockResolvedValue(null); + api.installManagerUpdate.mockResolvedValue(undefined); + api.relaunchManager.mockResolvedValue(undefined); + onRuntime = undefined; + listenMock.mockImplementation((event, handler) => { + if (event === MANAGER_UPDATE_STATE_EVENT) { + onRuntime = handler as typeof onRuntime; + } + return Promise.resolve(() => {}); + }); + }); + + it("checks on startup, presents notes on Home, and never installs without confirmation", async () => { + const user = userEvent.setup(); + api.checkManagerUpdate.mockResolvedValue(AVAILABLE); + renderManager(); + + expect(await screen.findByText("发现管理器新版本 2.0.0")).toBeInTheDocument(); + expect(api.installManagerUpdate).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "查看说明" })); + expect(await screen.findByRole("dialog")).toHaveTextContent("Reliable delivery notes."); + expect(screen.getByLabelText("1.0.0 → 2.0.0")).toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "稍后提醒" })[1]); + expect(screen.queryByText("发现管理器新版本 2.0.0")).not.toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_SNOOZE_KEY) ?? "null")).toEqual( + expect.objectContaining({ version: "2.0.0" }), + ); + }); + + it("rechecks at the bounded periodic cadence without overlapping startup work", async () => { + vi.useFakeTimers(); + try { + let resolveStartup: (() => void) | undefined; + api.checkManagerUpdate + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStartup = () => resolve({ kind: "none" }); + }), + ) + .mockResolvedValue({ kind: "none" }); + renderManager({ periodicIntervalMs: 60_000 }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + expect(api.checkManagerUpdate).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + }); + expect(api.checkManagerUpdate).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveStartup?.(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + }); + expect(api.checkManagerUpdate).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps startup network failures silent until the user opens a recovery surface", async () => { + api.checkManagerUpdate.mockRejectedValue({ code: "network", message: "offline" }); + renderManager(); + + await waitFor(() => + expect(screen.getByTestId("manager-failure")).toHaveTextContent("network"), + ); + expect(screen.getByTestId("manager-status")).toHaveTextContent("idle"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps retry checks busy and offers Close after no update is found", async () => { + const user = userEvent.setup(); + let resolveRetry: ((value: { kind: "none" }) => void) | undefined; + api.checkManagerUpdate + .mockRejectedValueOnce({ code: "network", message: "offline" }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + renderManager({ startupDelayMs: 60_000, includeAbout: true }); + + await user.click(screen.getByRole("button", { name: /^检查管理器更新/ })); + expect(await screen.findByRole("alert")).toHaveTextContent("无法连接更新服务器"); + + await user.click(screen.getByRole("button", { name: "重试" })); + expect(screen.getByTestId("manager-status")).toHaveTextContent("checking"); + expect(screen.getByRole("dialog")).toHaveTextContent("检查中…"); + expect(screen.getByRole("button", { name: "检查中…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "返回" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "稍后提醒" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "更新" })).not.toBeInTheDocument(); + + await act(async () => { + resolveRetry?.({ kind: "none" }); + await Promise.resolve(); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("up-to-date"); + expect(screen.getByRole("dialog")).toHaveTextContent("管理器已是最新版。"); + await user.click(screen.getAllByRole("button", { name: "关闭" }).at(-1)!); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("renders real byte progress and preserves a retry path for offline failures", async () => { + const user = userEvent.setup(); + let rejectInstall: ((cause: unknown) => void) | undefined; + api.checkManagerUpdate.mockResolvedValue(AVAILABLE); + api.installManagerUpdate.mockImplementation( + () => new Promise((_resolve, reject) => (rejectInstall = reject)), + ); + const view = renderManager(); + + await user.click(await screen.findByRole("button", { name: "查看说明" })); + await user.click(screen.getByRole("button", { name: "更新" })); + await waitFor(() => expect(onRuntime).toBeDefined()); + act(() => { + onRuntime?.({ + payload: { + revision: 2, + version: "2.0.0", + currentVersion: "1.0.0", + body: AVAILABLE.body, + phase: "downloading", + downloaded: 5 * 1024 * 1024, + total: 10 * 1024 * 1024, + failure: null, + }, + }); + }); + expect(screen.getByText("5.0 / 10.0 MiB")).toBeInTheDocument(); + expect(view.container.querySelector(".manager-update-progress .bar-fill")).toHaveStyle({ + width: "50%", + }); + + act(() => rejectInstall?.({ code: "network", message: "offline" })); + expect(await screen.findByTestId("manager-failure")).toHaveTextContent("network"); + expect(screen.getByRole("alert")).toHaveTextContent("无法连接更新服务器"); + expect(screen.getByRole("button", { name: "网络" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "重试" })).toBeEnabled(); + expect(screen.getByText(/镜像与 GitHub/)).toBeInTheDocument(); + }); + + it("blocks manager installation while a Codex operation owns the backend lock", async () => { + const user = userEvent.setup(); + api.checkManagerUpdate.mockResolvedValue(AVAILABLE); + api.installManagerUpdate.mockRejectedValue({ + code: "operation_busy", + message: "Codex update is still committing", + }); + renderManager(); + + await user.click(await screen.findByRole("button", { name: "查看说明" })); + await user.click(screen.getByRole("button", { name: "更新" })); + + expect(await screen.findByTestId("manager-failure")).toHaveTextContent("operation_busy"); + expect(screen.getByRole("alert")).toHaveTextContent("已有操作正在进行"); + expect(screen.queryByRole("button", { name: "网络" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重试" })).toBeEnabled(); + }); + + it("keeps an installed marker and offers relaunch retry when automatic restart fails", async () => { + const user = userEvent.setup(); + api.checkManagerUpdate.mockResolvedValue(AVAILABLE); + api.relaunchManager + .mockRejectedValueOnce(new Error("process plugin unavailable")) + .mockRejectedValueOnce(new Error("process plugin still unavailable")) + .mockResolvedValueOnce(undefined); + renderManager({ includeAbout: true }); + + await user.click(await screen.findByRole("button", { name: "查看说明" })); + await user.click(screen.getByRole("button", { name: "更新" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("更新已安装,但管理器未能自动重启"); + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY) ?? "null")).toEqual( + expect.objectContaining({ from: "1.0.0", to: "2.0.0", stage: "installed" }), + ); + expect( + screen.getAllByText("版本 2.0.0 已安装,需要重启管理器才能完成升级。"), + ).toHaveLength(2); + + await user.click(screen.getByRole("button", { name: "取消" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "更新" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "立即重启" })).toBeEnabled(); + + // A retry launched from the compact Home banner must reopen the error + // surface if relaunch still fails instead of looking like a dead button. + await user.click(screen.getByRole("button", { name: "立即重启" })); + expect(api.relaunchManager).toHaveBeenCalledTimes(2); + expect(await screen.findByRole("dialog")).toHaveTextContent( + "更新已安装,但管理器未能自动重启", + ); + await user.click(screen.getByRole("button", { name: "取消" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + // About shares the same state: its check entry only reopens the restart + // surface and cannot check the feed or install the same package again. + await user.click(screen.getByRole("button", { name: /^检查管理器更新/ })); + expect(await screen.findByRole("dialog")).toHaveTextContent("需要重启管理器"); + expect(api.checkManagerUpdate).toHaveBeenCalledTimes(1); + expect(api.installManagerUpdate).toHaveBeenCalledTimes(1); + + await user.click(screen.getAllByRole("button", { name: "立即重启" }).at(-1)!); + expect(api.relaunchManager).toHaveBeenCalledTimes(3); + expect(screen.getByTestId("manager-status")).toHaveTextContent("relaunching"); + }); + + it("writes a download marker before invoking the platform updater", async () => { + const user = userEvent.setup(); + api.checkManagerUpdate.mockResolvedValue(AVAILABLE); + api.installManagerUpdate.mockImplementation(() => new Promise(() => {})); + const view = renderManager(); + + await user.click(await screen.findByRole("button", { name: "查看说明" })); + await user.click(screen.getByRole("button", { name: "更新" })); + + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY) ?? "null")).toEqual( + expect.objectContaining({ from: "1.0.0", to: "2.0.0", stage: "downloading" }), + ); + view.unmount(); + localStorage.removeItem(MANAGER_UPDATE_COMPLETION_KEY); + }); + + it("reattaches to an in-flight manager update after a renderer reload", async () => { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "downloading", + }), + ); + api.getManagerUpdateRuntime.mockResolvedValue( + runtimeSnapshot({ downloaded: 5 * 1024 * 1024, total: 10 * 1024 * 1024 }), + ); + const view = renderManager(); + + expect(await screen.findByTestId("manager-status")).toHaveTextContent("downloading"); + expect(screen.getByText("5.0 / 10.0 MiB")).toBeInTheDocument(); + expect(view.container.querySelector(".manager-update-progress .bar-fill")).toHaveStyle({ + width: "50%", + }); + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY) ?? "null")).toEqual( + expect.objectContaining({ from: "1.0.0", to: "2.0.0", stage: "downloading" }), + ); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + }); + + it("discards an interrupted pre-handoff download without fencing a new process", async () => { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "downloading", + }), + ); + renderManager({ currentVersion: "1.0.0" }); + + expect(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY)).toBeNull(); + expect(screen.getByTestId("manager-status")).not.toHaveTextContent("installing"); + await waitFor(() => expect(api.checkManagerUpdate).toHaveBeenCalledTimes(1)); + expect(api.installManagerUpdate).not.toHaveBeenCalled(); + }); + + it("restores a durable Windows handoff when the renderer never promoted its marker", async () => { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "downloading", + }), + ); + api.getManagerUpdateRuntime.mockResolvedValue( + runtimeSnapshot({ + revision: 1, + phase: "installing", + handoffStartedAt: Date.now(), + }), + ); + renderManager({ currentVersion: "1.0.0" }); + + expect(await screen.findByTestId("manager-status")).toHaveTextContent("installing"); + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY) ?? "null")).toEqual( + expect.objectContaining({ from: "1.0.0", to: "2.0.0", stage: "installing" }), + ); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "正在安装…" })).toBeDisabled(); + }); + + it("does not let an older hydration snapshot overwrite a newer runtime event", async () => { + let resolveRuntime: ((snapshot: ManagerUpdateRuntimeSnapshot | null) => void) | undefined; + api.getManagerUpdateRuntime.mockImplementation( + () => + new Promise((resolve) => { + resolveRuntime = resolve; + }), + ); + renderManager(); + await waitFor(() => expect(onRuntime).toBeDefined()); + + act(() => { + onRuntime?.({ payload: runtimeSnapshot({ revision: 2, phase: "installing" }) }); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("installing"); + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY) ?? "null")).toEqual( + expect.objectContaining({ from: "1.0.0", to: "2.0.0", stage: "installing" }), + ); + + await act(async () => { + resolveRuntime?.(runtimeSnapshot({ revision: 1, phase: "downloading" })); + await Promise.resolve(); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("installing"); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + }); + + it("does not let an in-flight startup check overwrite a recovered runtime", async () => { + let resolveCheck: ((value: typeof AVAILABLE) => void) | undefined; + api.checkManagerUpdate.mockImplementation( + () => + new Promise((resolve) => { + resolveCheck = resolve; + }), + ); + renderManager(); + await waitFor(() => expect(api.checkManagerUpdate).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onRuntime).toBeDefined()); + + act(() => { + onRuntime?.({ payload: runtimeSnapshot({ revision: 3, phase: "installing" }) }); + }); + await act(async () => { + resolveCheck?.(AVAILABLE); + await Promise.resolve(); + }); + + expect(screen.getByTestId("manager-status")).toHaveTextContent("installing"); + expect(screen.getByRole("dialog")).toHaveTextContent("正在安装"); + }); + + it("polls an active runtime to terminal state when event subscription fails", async () => { + vi.useFakeTimers(); + try { + listenMock.mockRejectedValueOnce(new Error("event bus unavailable")); + api.getManagerUpdateRuntime + .mockResolvedValueOnce(runtimeSnapshot({ revision: 1, phase: "downloading" })) + .mockResolvedValueOnce(runtimeSnapshot({ revision: 2, phase: "installed" })); + api.relaunchManager.mockRejectedValueOnce(new Error("relaunch unavailable")); + renderManager(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("downloading"); + + await act(async () => { + vi.advanceTimersByTime(800); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(api.getManagerUpdateRuntime).toHaveBeenCalledTimes(2); + expect(api.relaunchManager).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("manager-status")).toHaveTextContent( + "installed-awaiting-relaunch", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("retries a failed initial runtime query while a provisional handoff exists", async () => { + vi.useFakeTimers(); + try { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "installing", + }), + ); + listenMock.mockRejectedValueOnce(new Error("event bus unavailable")); + api.getManagerUpdateRuntime + .mockRejectedValueOnce(new Error("invoke bridge reloading")) + .mockResolvedValueOnce(runtimeSnapshot({ revision: 1, phase: "downloading" })) + .mockResolvedValueOnce(runtimeSnapshot({ revision: 2, phase: "installed" })); + api.relaunchManager.mockRejectedValueOnce(new Error("relaunch unavailable")); + renderManager(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(api.getManagerUpdateRuntime).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY)).not.toBeNull(); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(800); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("downloading"); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(800); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(api.relaunchManager).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("manager-status")).toHaveTextContent( + "installed-awaiting-relaunch", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("recovers a macOS terminal install and retries relaunch after renderer loss", async () => { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "installing", + }), + ); + api.getManagerUpdateRuntime.mockResolvedValue( + runtimeSnapshot({ revision: 4, phase: "installed", downloaded: 10, total: 10 }), + ); + api.relaunchManager.mockRejectedValueOnce(new Error("process plugin unavailable")); + renderManager(); + + await waitFor(() => expect(api.relaunchManager).toHaveBeenCalledTimes(1)); + expect(await screen.findByRole("alert")).toHaveTextContent( + "更新已安装,但管理器未能自动重启", + ); + expect(screen.getByTestId("manager-status")).toHaveTextContent( + "installed-awaiting-relaunch", + ); + expect(JSON.parse(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY) ?? "null")).toEqual( + expect.objectContaining({ from: "1.0.0", to: "2.0.0", stage: "installed" }), + ); + }); + + it("restores a terminal update failure instead of silently starting over", async () => { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "installing", + }), + ); + api.getManagerUpdateRuntime.mockResolvedValue( + runtimeSnapshot({ + revision: 3, + phase: "error", + failure: { code: "network", message: "mirror and GitHub failed" }, + }), + ); + renderManager(); + + expect(await screen.findByTestId("manager-failure")).toHaveTextContent("network"); + expect(screen.getByRole("alert")).toHaveTextContent("无法连接更新服务器"); + expect(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY)).toBeNull(); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + }); + + it("CAS-acknowledges a stale terminal before refreshing consent", async () => { + const user = userEvent.setup(); + const staleRuntime = runtimeSnapshot({ + revision: 5, + phase: "error", + failure: { code: "stale_expectation", message: "changed" }, + }); + api.checkManagerUpdate.mockResolvedValue(AVAILABLE); + api.getManagerUpdateRuntime + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(staleRuntime); + api.installManagerUpdate.mockRejectedValueOnce({ + code: "stale_expectation", + message: "changed", + }); + renderManager(); + + await user.click(await screen.findByRole("button", { name: "查看说明" })); + await user.click(screen.getByRole("button", { name: "更新" })); + + await waitFor(() => + expect(api.acknowledgeManagerUpdateRuntime).toHaveBeenCalledWith(staleRuntime), + ); + expect(api.checkManagerUpdate).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("manager-status")).toHaveTextContent("available"); + act(() => { + onRuntime?.({ payload: staleRuntime }); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("available"); + }); + + it("uses the launched target version as proof of a Windows updater handoff", async () => { + const user = userEvent.setup(); + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "installing", + }), + ); + renderManager({ currentVersion: "2.0.0", startupDelayMs: 60_000 }); + + expect(screen.getByText("管理器已升级到 2.0.0。")).toBeInTheDocument(); + await waitFor(() => expect(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY)).toBeNull()); + await user.click(screen.getByRole("button", { name: "关闭" })); + expect(screen.queryByText("管理器已升级到 2.0.0。")).not.toBeInTheDocument(); + }); + + it("keeps a fresh Windows handoff fenced before expiring it", async () => { + vi.useFakeTimers(); + try { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "installing", + }), + ); + renderManager({ currentVersion: "1.0.0", startupDelayMs: 1 }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("manager-status")).toHaveTextContent("installing"); + expect(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY)).not.toBeNull(); + expect(api.checkManagerUpdate).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "正在安装…" })).toBeDisabled(); + + await act(async () => { + vi.advanceTimersByTime(MANAGER_UPDATE_HANDOFF_GRACE_MS); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(localStorage.getItem(MANAGER_UPDATE_COMPLETION_KEY)).toBeNull(); + expect(screen.getByTestId("manager-status")).toHaveTextContent("idle"); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(api.checkManagerUpdate).toHaveBeenCalledTimes(1); + expect(api.installManagerUpdate).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("restores installed-awaiting-relaunch when the old process is opened again", async () => { + localStorage.setItem( + MANAGER_UPDATE_COMPLETION_KEY, + JSON.stringify({ + from: "1.0.0", + to: "2.0.0", + installedAt: Date.now(), + stage: "installed", + }), + ); + renderManager({ currentVersion: "1.0.0" }); + + expect(screen.getByTestId("manager-status")).toHaveTextContent( + "installed-awaiting-relaunch", + ); + expect( + screen.getByText("版本 2.0.0 已安装,需要重启管理器才能完成升级。"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "立即重启" })).toBeEnabled(); + await waitFor(() => expect(api.checkManagerUpdate).not.toHaveBeenCalled()); + expect(api.installManagerUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/managerUpdate.tsx b/src/app/managerUpdate.tsx new file mode 100644 index 0000000..e019fcd --- /dev/null +++ b/src/app/managerUpdate.tsx @@ -0,0 +1,985 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { listen } from "@tauri-apps/api/event"; + +import { + errorCode, + managerApi, + type ManagerUpdateAvailable, +} from "../services/managerApi"; +import type { + ManagerUpdateProgress, + ManagerUpdateRuntimeSnapshot, +} from "../shared/types"; +import { FailureBanner, Ring, StatusBanner } from "./components"; +import { resolveFailure, type FailureSurface } from "./errorCopy"; +import { Icon } from "./icons"; +import { useI18n } from "./i18n"; +import { Sheet } from "./Sheet"; + +export const MANAGER_UPDATE_STATE_EVENT = "manager://update-state"; +export const MANAGER_UPDATE_STARTUP_DELAY_MS = 1_500; +export const MANAGER_UPDATE_PERIODIC_INTERVAL_MS = 6 * 60 * 60 * 1_000; +export const MANAGER_UPDATE_SNOOZE_MS = 24 * 60 * 60 * 1_000; +export const MANAGER_UPDATE_SNOOZE_KEY = "cam.manager-update-snooze"; +export const MANAGER_UPDATE_COMPLETION_KEY = "cam.manager-update-completion"; +export const MANAGER_UPDATE_HANDOFF_GRACE_MS = 10 * 60 * 1_000; + +const APP_VERSION = import.meta.env.VITE_APP_VERSION ?? "0.0.0"; + +type ManagerUpdateStatus = + | "idle" + | "checking" + | "available" + | "up-to-date" + | "development" + | "downloading" + | "installing" + | "relaunching" + | "installed-awaiting-relaunch" + | "error"; + +type CheckOptions = { + manual?: boolean; + openWhenAvailable?: boolean; +}; + +type Completion = { + from: string; + to: string; + installedAt: number; + /** + * `downloading` is written before invoking the updater and must never be + * treated as proof that Windows handed off to NSIS. `installing` is written + * only after the backend reports that no-return boundary. Seeing the target + * version on the next launch is the durable proof that the handoff completed. + * + * Markers without a stage come from older builds and are treated as + * `installed` for backwards compatibility. + */ + stage?: "downloading" | "installing" | "installed"; +}; + +type Snooze = { + version: string; + remindAt: number; +}; + +interface ManagerUpdateContextValue { + status: ManagerUpdateStatus; + update: ManagerUpdateAvailable | null; + progress: ManagerUpdateProgress | null; + failure: FailureSurface | null; + detailsOpen: boolean; + snoozed: boolean; + completion: Completion | null; + check: (options?: CheckOptions) => Promise; + openDetails: () => void; + closeDetails: () => void; + remindLater: () => void; + install: () => Promise; + retryRelaunch: () => Promise; + dismissCompletion: () => void; +} + +const ManagerUpdateContext = createContext(null); + +function readJson(key: string): T | null { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : null; + } catch { + return null; + } +} + +function writeJson(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // A disabled/full localStorage must not block a signed update or relaunch. + } +} + +function removeStored(key: string) { + try { + localStorage.removeItem(key); + } catch { + // Best effort only. + } +} + +function completionStateForVersion(currentVersion: string): { + completion: Completion | null; + awaitingUpdate: ManagerUpdateAvailable | null; + provisional: Completion | null; +} { + const completion = readJson(MANAGER_UPDATE_COMPLETION_KEY); + if (!completion) return { completion: null, awaitingUpdate: null, provisional: null }; + if (completion.to === currentVersion) { + // This process is already the requested target. That is stronger evidence + // than the old process receiving an install result (which never happens on + // Windows because the updater exits it after handing off to NSIS). + return { + completion: { ...completion, stage: "installed" }, + awaitingUpdate: null, + provisional: null, + }; + } + // The updater committed a replacement but the old process did not relaunch. + // Restoring this state prevents a reopened old build from installing again. + if (completion.from === currentVersion && completion.to !== currentVersion) { + if (completion.stage === "installing") { + // Do not synchronously discard this marker. A renderer reload keeps the + // backend command alive; runtime hydration decides whether this is an + // active handoff or a genuinely stale cold-start marker. + return { completion: null, awaitingUpdate: null, provisional: completion }; + } + if (completion.stage === "downloading") { + // A process can exit or crash before Update::install reaches its Windows + // installer handoff. The backend runtime is process-local, so a fresh + // process must not turn this pre-handoff marker into a grace-period lock. + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + return { completion: null, awaitingUpdate: null, provisional: null }; + } + return { + completion: null, + awaitingUpdate: { + kind: "available", + version: completion.to, + currentVersion: completion.from, + }, + provisional: null, + }; + } + // Unrelated/malformed markers must never claim this build completed. + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + return { completion: null, awaitingUpdate: null, provisional: null }; +} + +function snoozedFor(update: ManagerUpdateAvailable | null): boolean { + if (!update) return false; + const snooze = readJson(MANAGER_UPDATE_SNOOZE_KEY); + if (!snooze || snooze.version !== update.version) return false; + if (snooze.remindAt <= Date.now()) { + removeStored(MANAGER_UPDATE_SNOOZE_KEY); + return false; + } + return true; +} + +function contextualRelaunchFailure( + cause: unknown, + message: string, + t: ReturnType["t"], +): FailureSurface { + const failure = resolveFailure(cause, t); + return { + ...failure, + code: "manager_relaunch_failed", + message, + recoverable: true, + }; +} + +export function ManagerUpdateProvider({ + children, + currentVersion = APP_VERSION, + startupDelayMs = MANAGER_UPDATE_STARTUP_DELAY_MS, + periodicIntervalMs = MANAGER_UPDATE_PERIODIC_INTERVAL_MS, +}: { + children: ReactNode; + currentVersion?: string; + startupDelayMs?: number; + periodicIntervalMs?: number; +}) { + const { t } = useI18n(); + const [initialCompletion] = useState(() => completionStateForVersion(currentVersion)); + const [status, setStatus] = useState(() => + initialCompletion.awaitingUpdate + ? "installed-awaiting-relaunch" + : initialCompletion.provisional + ? "installing" + : "idle", + ); + const [update, setUpdate] = useState( + initialCompletion.awaitingUpdate ?? + (initialCompletion.provisional + ? { + kind: "available", + version: initialCompletion.provisional.to, + currentVersion: initialCompletion.provisional.from, + } + : null), + ); + const [progress, setProgress] = useState( + initialCompletion.provisional + ? { phase: "installing", downloaded: 0, total: null } + : null, + ); + const [failure, setFailure] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(Boolean(initialCompletion.provisional)); + const [snoozed, setSnoozed] = useState(false); + const [completion, setCompletion] = useState( + initialCompletion.completion, + ); + const [runtimeHydrated, setRuntimeHydrated] = useState(false); + const checkInFlightRef = useRef | null>(null); + const installInFlightRef = useRef(Boolean(initialCompletion.provisional)); + const ownsInstallRef = useRef(false); + const lastRuntimeRevisionRef = useRef(0); + const lastRuntimeSnapshotRef = useRef(null); + const recoveredRuntimeRef = useRef(false); + const recoveredInstalledRuntimeRef = useRef(false); + const automaticRelaunchAttemptedRef = useRef(false); + // Once the updater has committed the new package, this process must never + // check or install again. Only a relaunch can load the newly installed build. + const installedAwaitingRelaunchRef = useRef(Boolean(initialCompletion.awaitingUpdate)); + const updateRef = useRef(update); + updateRef.current = update; + + useEffect(() => { + // The upgraded process owns the in-memory success banner. Consume the + // durable handoff marker now so a later app launch cannot repeat it. + if (initialCompletion.completion) { + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + } + }, [initialCompletion.completion]); + + const check = useCallback( + (options: CheckOptions = {}) => { + if (installInFlightRef.current || installedAwaitingRelaunchRef.current) { + return Promise.resolve(); + } + if (checkInFlightRef.current) return checkInFlightRef.current; + + const task = (async () => { + const runtimeRevisionAtStart = lastRuntimeRevisionRef.current; + setStatus("checking"); + if (options.manual) setFailure(null); + try { + const result = await managerApi.checkManagerUpdate(); + if ( + lastRuntimeRevisionRef.current !== runtimeRevisionAtStart || + installInFlightRef.current || + installedAwaitingRelaunchRef.current + ) { + return; + } + setFailure(null); + if (result.kind === "available") { + setUpdate(result); + setSnoozed(snoozedFor(result)); + setStatus("available"); + if (options.openWhenAvailable) setDetailsOpen(true); + } else { + setUpdate(null); + setSnoozed(false); + setStatus(result.kind === "development" ? "development" : "up-to-date"); + } + } catch (cause) { + if ( + lastRuntimeRevisionRef.current !== runtimeRevisionAtStart || + installInFlightRef.current || + installedAwaitingRelaunchRef.current + ) { + return; + } + const next = resolveFailure(cause, t); + setStatus(options.manual ? "error" : updateRef.current ? "available" : "idle"); + setFailure(next); + if (options.manual) setDetailsOpen(true); + } + })().finally(() => { + checkInFlightRef.current = null; + }); + checkInFlightRef.current = task; + return task; + }, + [t], + ); + + const applyRuntimeSnapshot = useCallback( + (snapshot: ManagerUpdateRuntimeSnapshot) => { + if ( + snapshot.currentVersion !== currentVersion || + snapshot.revision <= lastRuntimeRevisionRef.current + ) { + return; + } + lastRuntimeRevisionRef.current = snapshot.revision; + lastRuntimeSnapshotRef.current = snapshot; + const available: ManagerUpdateAvailable = { + kind: "available", + version: snapshot.version, + currentVersion: snapshot.currentVersion, + body: snapshot.body, + }; + setUpdate(available); + setSnoozed(false); + + if (snapshot.phase === "downloading" || snapshot.phase === "installing") { + installInFlightRef.current = true; + installedAwaitingRelaunchRef.current = false; + writeJson(MANAGER_UPDATE_COMPLETION_KEY, { + from: snapshot.currentVersion, + to: snapshot.version, + installedAt: Date.now(), + stage: snapshot.phase, + } satisfies Completion); + setFailure(null); + setProgress({ + phase: snapshot.phase, + downloaded: snapshot.downloaded, + total: snapshot.total, + }); + setStatus(snapshot.phase); + setDetailsOpen(true); + return; + } + + if (snapshot.phase === "installed") { + installedAwaitingRelaunchRef.current = true; + if (!ownsInstallRef.current) { + installInFlightRef.current = false; + recoveredInstalledRuntimeRef.current = true; + } + const marker: Completion = { + from: snapshot.currentVersion, + to: snapshot.version, + installedAt: Date.now(), + stage: "installed", + }; + writeJson(MANAGER_UPDATE_COMPLETION_KEY, marker); + removeStored(MANAGER_UPDATE_SNOOZE_KEY); + setProgress({ + phase: "installed", + downloaded: snapshot.downloaded, + total: snapshot.total, + }); + setFailure(null); + setStatus("installed-awaiting-relaunch"); + setDetailsOpen(true); + return; + } + + installInFlightRef.current = false; + ownsInstallRef.current = false; + installedAwaitingRelaunchRef.current = false; + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + setProgress(null); + setFailure( + resolveFailure( + snapshot.failure ?? { + code: "engine_error", + message: "manager update failed", + }, + t, + ), + ); + setStatus("error"); + setDetailsOpen(true); + }, + [currentVersion, t], + ); + + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | null = null; + let pollTimer: number | null = null; + let awaitingHydration = true; + let hydrationFailures = 0; + const provisionalMarker = + initialCompletion.provisional?.from === currentVersion + ? initialCompletion.provisional + : null; + const hasProvisionalMarker = Boolean(provisionalMarker); + const hydrationStartedAt = Date.now(); + const markerInstalledAt = Number(provisionalMarker?.installedAt); + const markerAgeAtHydration = + Number.isFinite(markerInstalledAt) && markerInstalledAt > 0 + ? Math.max(0, hydrationStartedAt - markerInstalledAt) + : 0; + const provisionalDeadline = + hydrationStartedAt + Math.max(0, MANAGER_UPDATE_HANDOFF_GRACE_MS - markerAgeAtHydration); + const isActive = (snapshot: ManagerUpdateRuntimeSnapshot | null) => + snapshot?.phase === "downloading" || snapshot?.phase === "installing"; + + function clearPoll() { + if (pollTimer != null) { + window.clearTimeout(pollTimer); + pollTimer = null; + } + } + + function finishHydration() { + if (!awaitingHydration || disposed) return; + awaitingHydration = false; + setRuntimeHydrated(true); + } + + function provisionalGraceRemaining() { + if (!provisionalMarker) return 0; + return Math.max(0, provisionalDeadline - Date.now()); + } + + function releaseProvisionalHandoff() { + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + installInFlightRef.current = false; + ownsInstallRef.current = false; + setUpdate(null); + setProgress(null); + setFailure(null); + setStatus("idle"); + setDetailsOpen(false); + } + + async function queryRuntime() { + let next: ManagerUpdateRuntimeSnapshot | null = null; + try { + next = await managerApi.getManagerUpdateRuntime(); + } catch { + if (disposed) return; + if (awaitingHydration) { + hydrationFailures += 1; + if (hasProvisionalMarker && lastRuntimeRevisionRef.current === 0) { + const remaining = provisionalGraceRemaining(); + if (remaining > 0) { + schedulePoll(Math.min(800, remaining)); + return; + } + releaseProvisionalHandoff(); + finishHydration(); + } else if ( + isActive(lastRuntimeSnapshotRef.current) || + hydrationFailures < 3 + ) { + schedulePoll(); + } else { + // With no durable evidence of an updater handoff, do not disable + // normal checks forever merely because the runtime query failed. + finishHydration(); + } + } else if (isActive(lastRuntimeSnapshotRef.current)) { + schedulePoll(); + } + return; + } + if (disposed) return; + const relevant = next?.currentVersion === currentVersion ? next : null; + if (relevant) { + recoveredRuntimeRef.current = true; + applyRuntimeSnapshot(relevant); + } + if ( + awaitingHydration && + hasProvisionalMarker && + lastRuntimeRevisionRef.current === 0 + ) { + const remaining = provisionalGraceRemaining(); + if (remaining > 0) { + // A Windows process can be reopened after Tauri has handed the NSIS + // child off and exited. Its fresh marker is the only cross-process + // evidence during that gap, so retain it and keep install controls + // fenced until the installer has had a bounded chance to finish. + schedulePoll(remaining); + return; + } + releaseProvisionalHandoff(); + } + finishHydration(); + if (isActive(relevant ?? lastRuntimeSnapshotRef.current)) schedulePoll(); + } + + function schedulePoll(delayMs = 800) { + clearPoll(); + pollTimer = window.setTimeout(() => { + void queryRuntime(); + }, delayMs); + } + + void (async () => { + try { + const fn = await listen( + MANAGER_UPDATE_STATE_EVENT, + (event) => { + if (!disposed) { + if (event.payload.currentVersion === currentVersion) { + recoveredRuntimeRef.current = true; + } + applyRuntimeSnapshot(event.payload); + } + }, + ); + if (disposed) { + fn(); + return; + } + unlisten = fn; + } catch { + // Browser preview and an already-closing WebView have no event bus. + } + await queryRuntime(); + })(); + return () => { + disposed = true; + clearPoll(); + unlisten?.(); + }; + }, [applyRuntimeSnapshot, currentVersion, initialCompletion.provisional]); + + useEffect(() => { + if (!runtimeHydrated) return; + const startup = recoveredRuntimeRef.current + ? null + : window.setTimeout(() => void check(), Math.max(0, startupDelayMs)); + const periodic = window.setInterval( + () => void check(), + Math.max(60_000, periodicIntervalMs), + ); + return () => { + if (startup != null) window.clearTimeout(startup); + window.clearInterval(periodic); + }; + }, [check, periodicIntervalMs, runtimeHydrated, startupDelayMs]); + + const openDetails = useCallback(() => setDetailsOpen(true), []); + const closeDetails = useCallback(() => { + if (installInFlightRef.current) return; + setDetailsOpen(false); + }, []); + + const remindLater = useCallback(() => { + const current = updateRef.current; + if ( + !current || + installInFlightRef.current || + installedAwaitingRelaunchRef.current + ) { + return; + } + writeJson(MANAGER_UPDATE_SNOOZE_KEY, { + version: current.version, + remindAt: Date.now() + MANAGER_UPDATE_SNOOZE_MS, + } satisfies Snooze); + setSnoozed(true); + setDetailsOpen(false); + }, []); + + const acknowledgeTerminalRuntime = useCallback( + async (expectedFailureCode?: string) => { + try { + const snapshot = await managerApi.getManagerUpdateRuntime(); + if ( + !snapshot || + (snapshot.phase !== "installed" && snapshot.phase !== "error") || + (expectedFailureCode && snapshot.failure?.code !== expectedFailureCode) + ) { + return false; + } + const acknowledged = await managerApi.acknowledgeManagerUpdateRuntime(snapshot); + if (acknowledged) { + // Fence out an already-queued terminal event for the state we just + // consumed. A subsequent update begins at a higher backend revision. + lastRuntimeRevisionRef.current = Math.max( + lastRuntimeRevisionRef.current, + snapshot.revision, + ); + if (lastRuntimeSnapshotRef.current?.revision === snapshot.revision) { + lastRuntimeSnapshotRef.current = null; + } + } + return acknowledged; + } catch { + return false; + } + }, + [], + ); + + const retryRelaunch = useCallback(async () => { + if (installInFlightRef.current || !installedAwaitingRelaunchRef.current) return; + installInFlightRef.current = true; + setFailure(null); + setStatus("relaunching"); + try { + await managerApi.relaunchManager(); + } catch (cause) { + setFailure(contextualRelaunchFailure(cause, t("managerUpdate.relaunchFailed"), t)); + setStatus("installed-awaiting-relaunch"); + setDetailsOpen(true); + installInFlightRef.current = false; + ownsInstallRef.current = false; + } + }, [t]); + + useEffect(() => { + if ( + !runtimeHydrated || + status !== "installed-awaiting-relaunch" || + !recoveredInstalledRuntimeRef.current || + ownsInstallRef.current || + automaticRelaunchAttemptedRef.current + ) { + return; + } + automaticRelaunchAttemptedRef.current = true; + void retryRelaunch(); + }, [retryRelaunch, runtimeHydrated, status]); + + const install = useCallback(async () => { + const current = updateRef.current; + if ( + !current || + installInFlightRef.current || + installedAwaitingRelaunchRef.current + ) { + return; + } + installInFlightRef.current = true; + ownsInstallRef.current = true; + setDetailsOpen(true); + setFailure(null); + setProgress({ phase: "downloading", downloaded: 0, total: null }); + setStatus("downloading"); + let installed = false; + const pendingMarker: Completion = { + from: current.currentVersion, + to: current.version, + installedAt: Date.now(), + stage: "downloading", + }; + // Persist the active download before invoking the updater. The backend + // runtime event promotes this to `installing` only when Windows reaches the + // NSIS handoff; code after the await is a macOS/Linux path. + writeJson(MANAGER_UPDATE_COMPLETION_KEY, pendingMarker); + try { + await managerApi.installManagerUpdate(current); + installed = true; + installedAwaitingRelaunchRef.current = true; + const marker: Completion = { + ...pendingMarker, + installedAt: Date.now(), + stage: "installed", + }; + writeJson(MANAGER_UPDATE_COMPLETION_KEY, marker); + removeStored(MANAGER_UPDATE_SNOOZE_KEY); + setStatus("relaunching"); + await managerApi.relaunchManager(); + } catch (cause) { + if (installed) { + setFailure(contextualRelaunchFailure(cause, t("managerUpdate.relaunchFailed"), t)); + setStatus("installed-awaiting-relaunch"); + } else if (errorCode(cause) === "stale_expectation") { + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + installInFlightRef.current = false; + ownsInstallRef.current = false; + setProgress(null); + setUpdate(null); + await acknowledgeTerminalRuntime("stale_expectation"); + await check({ manual: true, openWhenAvailable: true }); + return; + } else { + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + setFailure(resolveFailure(cause, t)); + setStatus("error"); + } + installInFlightRef.current = false; + ownsInstallRef.current = false; + } + }, [acknowledgeTerminalRuntime, check, t]); + + const dismissCompletion = useCallback(() => { + removeStored(MANAGER_UPDATE_COMPLETION_KEY); + setCompletion(null); + void acknowledgeTerminalRuntime(); + }, [acknowledgeTerminalRuntime]); + + const value = useMemo( + () => ({ + status, + update, + progress, + failure, + detailsOpen, + snoozed, + completion, + check, + openDetails, + closeDetails, + remindLater, + install, + retryRelaunch, + dismissCompletion, + }), + [ + status, + update, + progress, + failure, + detailsOpen, + snoozed, + completion, + check, + openDetails, + closeDetails, + remindLater, + install, + retryRelaunch, + dismissCompletion, + ], + ); + + return {children}; +} + +export function useManagerUpdate(): ManagerUpdateContextValue { + const value = useContext(ManagerUpdateContext); + if (!value) throw new Error("useManagerUpdate must be used inside ManagerUpdateProvider"); + return value; +} + +export function ManagerUpdateBanner() { + const { t } = useI18n(); + const state = useManagerUpdate(); + + if (state.completion) { + return ( + + {t("nav.close")} + + } + > + {t("managerUpdate.completed", { version: state.completion.to })} + + ); + } + if ( + state.update && + (state.status === "installed-awaiting-relaunch" || state.status === "relaunching") + ) { + return ( + void state.retryRelaunch()} + > + {t("managerUpdate.restart")} + + } + > + {t("managerUpdate.restartRequired", { version: state.update.version })} + + ); + } + if (!state.update || state.snoozed) return null; + + return ( +
+ + + + + } + > + {t("about.mgrFound", { version: state.update.version })} + +
+ ); +} + +function progressLabel(progress: ManagerUpdateProgress | null): string | null { + if (!progress || progress.phase !== "downloading") return null; + const downloaded = (progress.downloaded / 1024 / 1024).toFixed(1); + if (!progress.total || progress.total <= 0) return `${downloaded} MiB`; + const total = (progress.total / 1024 / 1024).toFixed(1); + return `${downloaded} / ${total} MiB`; +} + +export function ManagerUpdateSheet({ onOpenSettings }: { onOpenSettings: () => void }) { + const { t } = useI18n(); + const state = useManagerUpdate(); + const titleId = useId(); + const bodyId = useId(); + const busy = ["checking", "downloading", "installing", "relaunching"].includes( + state.status, + ); + const noUpdateResult = state.status === "up-to-date" || state.status === "development"; + const busyLabel = + state.status === "checking" + ? t("about.mgrChecking") + : state.status === "downloading" + ? t("managerUpdate.downloading") + : t("progress.installing"); + const pct = + state.progress?.total && state.progress.total > 0 + ? Math.min(100, (state.progress.downloaded / state.progress.total) * 100) + : null; + const notes = state.update?.body?.trim(); + const awaitingRelaunch = state.status === "installed-awaiting-relaunch"; + const networkFailure = + state.failure?.code === "network" || state.failure?.code === "timeout"; + + return ( + + +

+ {state.status === "checking" + ? t("about.checkManager") + : state.update + ? t("confirm.title", { version: state.update.version }) + : t("about.checkManager")} +

+

+ {state.status === "checking" + ? t("about.mgrChecking") + : awaitingRelaunch && state.update + ? t("managerUpdate.restartRequired", { version: state.update.version }) + : state.status === "up-to-date" + ? t("about.mgrUpToDate") + : state.status === "development" + ? t("about.mgrDev") + : state.update + ? t("about.mgrConfirmBody") + : t("about.mgrUnavailable")} +

+ + {state.update ? ( +
+ {state.update.currentVersion} + + {state.update.version} +
+ ) : null} + + {notes ? ( +
+ {t("managerUpdate.notes")} +
{notes}
+
+ ) : state.update ? ( +

{t("managerUpdate.noNotes")}

+ ) : null} + + {busy ? ( +
+
+ {busyLabel} + {progressLabel(state.progress)} +
+
+
+
+
+ ) : null} + + {state.failure ? : null} + {networkFailure ? ( +

{t("managerUpdate.networkHint")}

+ ) : null} + +
+ {state.status === "installed-awaiting-relaunch" ? ( + <> + + + + ) : state.failure ? ( + <> + {networkFailure ? ( + + ) : ( + + )} + + + ) : busy ? ( + + ) : noUpdateResult ? ( + + ) : ( + <> + + + + )} +
+ + ); +} diff --git a/src/app/styles.css b/src/app/styles.css index 16ad0bf..9044653 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -227,6 +227,9 @@ body { #root { padding: var(--window-gutter-top) var(--window-gutter-x) var(--window-gutter-bottom); } +.manager-app-content { + display: contents; +} button { font-family: inherit; cursor: pointer; @@ -1761,6 +1764,22 @@ button.row.danger:hover { opacity: 0.5; cursor: default; } +.manager-update-banner > .banner { + flex-wrap: wrap; +} +.manager-update-banner > .banner > [role="status"] { + flex: 1; + min-width: 190px; +} +.manager-update-banner-actions { + width: 100%; + display: flex; + justify-content: flex-end; + gap: 4px; +} +.manager-update-banner-actions .linkbtn { + margin-inline-start: 0; +} /* ── Result strip (update / install outcome) ─────────────────────────────── */ /* Owns its lifecycle (auto-dismiss + ✕), unlike the static .banner notes. A @@ -2042,6 +2061,74 @@ button.row.danger:hover { font-size: var(--fs-sm); line-height: 1.55; } +.manager-update-version { + margin: var(--space-sm) auto 0; + display: inline-flex; + align-items: center; + gap: 9px; + padding: 6px 11px; + border: 1px solid var(--line); + border-radius: var(--r-pill); + background: var(--surface-2); + color: var(--text-soft); + font-family: var(--mono); + font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; +} +.manager-update-version span[aria-hidden="true"] { + color: var(--accent-deep); +} +.manager-update-notes { + margin-top: var(--space-sm); + text-align: start; +} +.manager-update-notes strong { + display: block; + margin-bottom: 6px; + font-size: var(--fs-sm); +} +.manager-update-notes pre { + max-height: 170px; + overflow: auto; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface-2); + color: var(--text-soft); + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--font); + font-size: var(--fs-caption); + line-height: 1.5; + user-select: text; +} +.manager-update-empty-notes, +.manager-update-network-hint { + color: var(--text-faint) !important; + font-size: var(--fs-caption) !important; +} +.manager-update-progress { + margin-top: var(--space-sm); +} +.manager-update-progress-head { + display: flex; + justify-content: space-between; + gap: var(--space-xs); + color: var(--text-soft); + font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; +} +.manager-update-progress .bar { + margin-top: 8px; +} +.sheet .manager-update-progress + .failure-banner, +.sheet .manager-update-notes + .failure-banner { + margin-top: var(--space-sm); +} +.about-action-message { + padding: 0 14px 10px; + color: var(--text-soft); +} .sheet-path { margin-top: var(--space-sm); padding: 9px 10px; diff --git a/src/app/views/About.tsx b/src/app/views/About.tsx index f579de1..0978d25 100644 --- a/src/app/views/About.tsx +++ b/src/app/views/About.tsx @@ -1,87 +1,81 @@ -import { useCallback, useId, useState } from "react"; +import { useCallback, useState } from "react"; -import { managerApi, type ManagerUpdateAvailable } from "../../services/managerApi"; +import { managerApi } from "../../services/managerApi"; import { userErrorMessage } from "../errorCopy"; import { Icon, CodexMark } from "../icons"; import { useI18n } from "../i18n"; -import { NavBar, Ring } from "../components"; +import { NavBar } from "../components"; import { formatDiagnostics } from "../diagnostics"; -import { Sheet } from "../Sheet"; +import { useManagerUpdate } from "../managerUpdate"; const APP_VERSION = import.meta.env.VITE_APP_VERSION ?? "0.0.0"; const REPO_URL = "https://github.com/Wangnov/Codex-App-Manager"; export function About({ onBack }: { onBack: () => void }) { const { t } = useI18n(); - const [mgrBusy, setMgrBusy] = useState(false); - const [mgrMsg, setMgrMsg] = useState(null); - const [pendingUpdate, setPendingUpdate] = useState(null); - const updateTitleId = useId(); - const updateBodyId = useId(); - - const closeUpdateConfirm = useCallback(() => { - if (mgrBusy) return; - void pendingUpdate?.discard(); - setPendingUpdate(null); - }, [mgrBusy, pendingUpdate]); + const managerUpdate = useManagerUpdate(); + const [actionMsg, setActionMsg] = useState(null); + const mgrBusy = ["checking", "downloading", "installing", "relaunching"].includes( + managerUpdate.status, + ); + const mgrNavigationLocked = ["downloading", "installing", "relaunching"].includes( + managerUpdate.status, + ); + let managerStatusMessage: string | null = null; + if (managerUpdate.failure) { + managerStatusMessage = managerUpdate.failure.message; + } else if (managerUpdate.status === "checking") { + managerStatusMessage = t("about.mgrChecking"); + } else if (managerUpdate.status === "downloading") { + managerStatusMessage = t("managerUpdate.downloading"); + } else if (managerUpdate.status === "installing") { + managerStatusMessage = t("progress.installing"); + } else if (managerUpdate.status === "installed-awaiting-relaunch" && managerUpdate.update) { + managerStatusMessage = t("managerUpdate.restartRequired", { + version: managerUpdate.update.version, + }); + } else if (managerUpdate.update) { + managerStatusMessage = t("about.mgrFound", { version: managerUpdate.update.version }); + } else if (managerUpdate.status === "up-to-date") { + managerStatusMessage = t("about.mgrUpToDate"); + } else if (managerUpdate.status === "development") { + managerStatusMessage = t("about.mgrDev"); + } + let managerStatusValue = ""; + if (managerUpdate.status === "downloading") { + managerStatusValue = t("managerUpdate.downloading"); + } else if (managerUpdate.status === "installed-awaiting-relaunch") { + managerStatusValue = t("managerUpdate.restart"); + } else if (mgrBusy) { + managerStatusValue = t("about.mgrChecking"); + } const checkManager = useCallback(async () => { - setMgrBusy(true); - setMgrMsg(null); - if (pendingUpdate) { - void pendingUpdate.discard(); - setPendingUpdate(null); + setActionMsg(null); + if (managerUpdate.status === "installed-awaiting-relaunch") { + managerUpdate.openDetails(); + return; } - try { - const result = await managerApi.checkManagerUpdate(); - if (result.kind === "available") { - setPendingUpdate(result); - setMgrMsg(t("about.mgrFound", { version: result.version })); - } else if (result.kind === "none") { - setMgrMsg(t("about.mgrUpToDate")); - } else if (result.kind === "development") { - setMgrMsg(t("about.mgrDev")); - } else { - setMgrMsg(t("about.mgrUnavailable")); - } - } catch (cause) { - setMgrMsg(userErrorMessage(cause, t)); - } finally { - setMgrBusy(false); - } - }, [pendingUpdate, t]); - - const installManagerUpdate = useCallback(async () => { - if (!pendingUpdate) return; - setMgrBusy(true); - setMgrMsg(t("progress.installing")); - try { - await pendingUpdate.installAndRelaunch(); - } catch (cause) { - setMgrMsg(userErrorMessage(cause, t)); - setPendingUpdate(null); - } finally { - setMgrBusy(false); - } - }, [pendingUpdate, t]); + await managerUpdate.check({ manual: true, openWhenAvailable: true }); + }, [managerUpdate]); const openLogsDir = useCallback(async () => { - setMgrMsg(null); + setActionMsg(null); try { await managerApi.openLogsDir(); } catch (cause) { - setMgrMsg(userErrorMessage(cause, t)); + setActionMsg(userErrorMessage(cause, t)); } }, [t]); const copyDiagnostics = useCallback(async () => { - setMgrMsg(null); + setActionMsg(null); try { const diagnostics = await managerApi.getDiagnostics(); await navigator.clipboard.writeText(formatDiagnostics(diagnostics)); - setMgrMsg(t("about.diagnosticsCopied")); + setActionMsg(t("about.diagnosticsCopied")); } catch { - setMgrMsg(t("about.diagnosticsFailed")); + setActionMsg(t("about.diagnosticsFailed")); } }, [t]); @@ -90,8 +84,12 @@ export function About({ onBack }: { onBack: () => void }) { {/* Block leaving while a self-update is downloading/installing — it relaunches the manager process and could interrupt a Codex op started back on the home screen. */} - -
+ +
@@ -108,10 +106,11 @@ export function About({ onBack }: { onBack: () => void }) { {t("about.checkManager")} - {mgrMsg ? {mgrMsg} : null} + {managerStatusMessage ? {managerStatusMessage} : null} - {mgrBusy ? t("about.mgrChecking") : ""} + {managerStatusValue} + {actionMsg ?
{actionMsg}
: null}
- - -

- {pendingUpdate ? t("confirm.title", { version: pendingUpdate.version }) : ""} -

-

{t("about.mgrConfirmBody")}

-
- - -
-
); } diff --git a/src/app/views/Home.tsx b/src/app/views/Home.tsx index 67420ad..1a956a0 100644 --- a/src/app/views/Home.tsx +++ b/src/app/views/Home.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { errorCode, @@ -35,11 +43,17 @@ import { useOperationReattach } from "./useOperationReattach"; type Kind = "loading" | "error" | "none" | "idle" | "update" | "external" | "uptodate"; /** Platform dispatcher — the backend command surface differs per OS. */ -export function Home(props: { onOpenSettings: () => void }) { +export function Home(props: { onOpenSettings: () => void; managerUpdateSlot?: ReactNode }) { return currentPlatform() === "windows" ? : ; } -function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { +function MacHome({ + onOpenSettings, + managerUpdateSlot, +}: { + onOpenSettings: () => void; + managerUpdateSlot?: ReactNode; +}) { const { t, lang } = useI18n(); const [report, setReport] = useState(null); const [status, setStatus] = useState(null); @@ -628,6 +642,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { ref={scopeRef} inert={confirmOpen || manualExistingOpen ? true : undefined} > + {managerUpdateSlot} {perform ? ( void }) { +export function WinHome({ + onOpenSettings, + managerUpdateSlot, +}: { + onOpenSettings: () => void; + managerUpdateSlot?: ReactNode; +}) { const { t, lang } = useI18n(); const [report, setReport] = useState(null); const [status, setStatus] = useState(null); @@ -640,6 +654,7 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { ref={scopeRef} inert={confirmOpen || installDirOpen || manualExistingOpen ? true : undefined} > + {managerUpdateSlot} {perform ? ( { }); }); +describe("manager updater API", () => { + it("keeps check and confirmed install as separate Tauri commands", async () => { + window.__TAURI_INTERNALS__ = {}; + const metadata = { + version: "2.0.0", + currentVersion: "1.0.0", + body: "release notes", + }; + invokeMock.mockResolvedValueOnce(metadata).mockResolvedValueOnce(undefined); + + await expect(managerApi.checkManagerUpdate()).resolves.toEqual({ + kind: "available", + ...metadata, + }); + expect(invokeMock).toHaveBeenNthCalledWith(1, "manager_check_update"); + + await expect(managerApi.installManagerUpdate(metadata)).resolves.toBeUndefined(); + expect(invokeMock).toHaveBeenNthCalledWith(2, "manager_install_update", { + expectedVersion: "2.0.0", + expectedCurrentVersion: "1.0.0", + expectedBody: "release notes", + }); + }); + + it("exposes renderer-independent manager update runtime state", async () => { + window.__TAURI_INTERNALS__ = {}; + const snapshot = { + revision: 4, + version: "2.0.0", + currentVersion: "1.0.0", + body: "release notes", + phase: "downloading", + downloaded: 5, + total: 10, + failure: null, + } as const; + invokeMock.mockResolvedValueOnce(snapshot); + + await expect(managerApi.getManagerUpdateRuntime()).resolves.toEqual(snapshot); + expect(invokeMock).toHaveBeenCalledWith("manager_get_update_runtime"); + }); + + it("acknowledges terminal runtime state with revision and target CAS fields", async () => { + window.__TAURI_INTERNALS__ = {}; + invokeMock.mockResolvedValueOnce(true); + + await expect( + managerApi.acknowledgeManagerUpdateRuntime({ + revision: 7, + version: "2.0.0", + currentVersion: "1.0.0", + }), + ).resolves.toBe(true); + expect(invokeMock).toHaveBeenCalledWith("manager_ack_update_runtime", { + revision: 7, + version: "2.0.0", + currentVersion: "1.0.0", + }); + }); + + it("preserves updater command failures for structured recovery UI", async () => { + window.__TAURI_INTERNALS__ = {}; + const failure = { code: "network", message: "offline" }; + invokeMock.mockRejectedValueOnce(failure); + + await expect(managerApi.checkManagerUpdate()).rejects.toEqual(failure); + }); +}); + describe("diagnostics API", () => { it("returns browser fallbacks without invoking Tauri", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/services/managerApi.ts b/src/services/managerApi.ts index c1662f7..2df6057 100644 --- a/src/services/managerApi.ts +++ b/src/services/managerApi.ts @@ -14,6 +14,8 @@ import type { MacPerformReport, MacUninstallReport, MacUpdateReport, + ManagerUpdateMetadata, + ManagerUpdateRuntimeSnapshot, OperationKind, OperationSnapshot, OperationToken, @@ -34,7 +36,6 @@ const MAX_PERIODIC_CHECK_INTERVAL_SECONDS = 7 * 24 * 60 * 60; export type ManagerUpdateCheck = | { kind: "development" } - | { kind: "unavailable" } | { kind: "none" } | ManagerUpdateAvailable; @@ -42,15 +43,7 @@ export interface ManagerUpdateAvailable { kind: "available"; version: string; currentVersion: string; - body?: string; - installAndRelaunch: () => Promise; - discard: () => Promise; -} - -interface ManagerUpdateMetadata { - version: string; - currentVersion: string; - body?: string; + body?: string | null; } export interface FrontendErrorPayload { @@ -554,19 +547,41 @@ export const managerApi = { if (!hasTauriRuntime()) { return { kind: "development" }; } - // A routine check shouldn't surface a scary error when the release feed - // isn't published yet or is unreachable. - const update = await invoke("manager_check_update").catch( - () => undefined, - ); - if (update === undefined) { - return { kind: "unavailable" }; - } + const update = await invoke("manager_check_update"); if (!update) { return { kind: "none" }; } return managerUpdateAvailable(update); }, + installManagerUpdate(update: ManagerUpdateMetadata): Promise { + if (!hasTauriRuntime()) return Promise.resolve(); + return invoke("manager_install_update", { + expectedVersion: update.version, + expectedCurrentVersion: update.currentVersion, + expectedBody: update.body ?? null, + }); + }, + getManagerUpdateRuntime(): Promise { + if (!hasTauriRuntime()) return Promise.resolve(null); + return invoke("manager_get_update_runtime"); + }, + acknowledgeManagerUpdateRuntime( + snapshot: Pick< + ManagerUpdateRuntimeSnapshot, + "revision" | "version" | "currentVersion" + >, + ): Promise { + if (!hasTauriRuntime()) return Promise.resolve(true); + return invoke("manager_ack_update_runtime", { + revision: snapshot.revision, + version: snapshot.version, + currentVersion: snapshot.currentVersion, + }); + }, + relaunchManager(): Promise { + if (!hasTauriRuntime()) return Promise.resolve(); + return relaunch(); + }, macStatus(): Promise { if (!hasTauriRuntime()) { return Promise.resolve({ installed: null, status: "none" }); @@ -959,13 +974,5 @@ function managerUpdateAvailable(update: ManagerUpdateMetadata): ManagerUpdateAva version: update.version, currentVersion: update.currentVersion, body: update.body, - installAndRelaunch: async () => { - await invoke("manager_install_update", { - expectedVersion: update.version, - expectedCurrentVersion: update.currentVersion, - }); - await relaunch(); - }, - discard: async () => {}, }; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 87e4601..97a09e6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,12 @@ export type OperatingSystem = "windows" | "macos" | "linux" | "unknown"; export type Architecture = "x64" | "arm64" | "unknown"; -export type OperationKind = "install" | "update" | "uninstall" | "set-install-root" | "adopt"; +export type OperationKind = + | "install" + | "update" + | "manager-update" + | "uninstall" + | "set-install-root" + | "adopt"; export type OperationToken = string; /** Lifecycle phase of a backend operation lease (mirrors Rust `OperationPhase`). */ export type OperationPhase = @@ -26,6 +32,30 @@ export interface OperationSnapshot { interruptible: boolean; } +export interface ManagerUpdateMetadata { + version: string; + currentVersion: string; + body?: string | null; +} + +export interface ManagerUpdateProgress { + phase: "downloading" | "installing" | "installed"; + downloaded: number; + total: number | null; +} + +export interface ManagerUpdateRuntimeSnapshot { + revision: number; + version: string; + currentVersion: string; + body?: string | null; + phase: "downloading" | "installing" | "installed" | "error"; + downloaded: number; + total: number | null; + failure: CommandError | null; + handoffStartedAt?: number | null; +} + /** * Serialized error returned by failing Tauri commands. Mirrors the backend * `CommandError` struct (src-tauri/src/errors.rs), which serializes From b8f2e4769388e99fab403d9c916c5bb04ec7b6cf Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:59:19 +0800 Subject: [PATCH 05/21] fix(release): serialize IHEP mirror promotion --- .github/workflows/release.yml | 44 +- cloudflare/manager-download-router/README.md | 13 +- docs/release.md | 55 +- scripts/mirror-release.mjs | 605 ++++++++++++++--- scripts/mirror-release.test.mjs | 644 ++++++++++++++++++- scripts/release-workflow.test.mjs | 22 + scripts/write-release-summary.mjs | 12 +- 7 files changed, 1232 insertions(+), 163 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4ba4e6..295815f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,16 @@ name: Release # MANAGER_R2_S3_ENDPOINT / MANAGER_R2_PROMOTION_ACCESS_KEY_ID / # MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY # R2 S3-compatible read/write access -# MANAGER_IHEP_S3_ENDPOINT / MANAGER_IHEP_S3_BUCKET / -# MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID / +# vars.MANAGER_IHEP_S3_ENDPOINT / vars.MANAGER_IHEP_S3_BUCKET / +# vars.MANAGER_IHEP_S3_REGION / vars.MANAGER_IHEP_S3_PREFIX +# MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID / # MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY # IHEP S3-compatible read/write access -# Both mirror backends are required for stable publication. They must already -# contain a valid latest.json baseline and support conditional PutObject -# (If-Match / If-None-Match) plus HeadObject user-metadata round trips. +# Both mirror backends are required for stable publication and must already +# contain a valid latest.json baseline. R2 is the sole CAS authority and must +# enforce conditional PutObject plus HeadObject metadata round trips. IHEP is an +# unconditional single-writer follower and only needs PutObject + metadata +# round trips; promotion never relies on its ignored conditional headers. # GitHub Immutable Releases must be enabled before any release starts; the # workflow verifies the repository setting with IMMUTABLE_RELEASES_READ_TOKEN. # Every reused asset is also pinned to the API-provided sha256 digest. @@ -66,9 +69,10 @@ permissions: env: RELEASE_TAG: ${{ inputs.target_tag || github.ref_name }} -# Serialize every tag in the repository through one release lane. The mirror -# promotion also uses per-object ETag conditions, so an external/manual writer -# cannot bypass monotonicity merely by racing this workflow. +# Serialize every credentialed mirror writer through one release lane. This is a +# correctness boundary for the unconditional IHEP follower, not an optimization. +# R2 CAS plus pre/post ownership fencing handles a losing or accidentally +# overlapping writer, but there is no cross-provider atomic transaction. concurrency: group: release-latest-${{ github.repository }} cancel-in-progress: false @@ -718,10 +722,10 @@ jobs: MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }} MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }} MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }} - MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }} - MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }} - MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }} - MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }} + MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }} + MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }} + MANAGER_IHEP_S3_REGION: ${{ vars.MANAGER_IHEP_S3_REGION }} + MANAGER_IHEP_S3_PREFIX: ${{ vars.MANAGER_IHEP_S3_PREFIX }} MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }} MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }} run: | @@ -751,10 +755,10 @@ jobs: MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }} MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }} MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }} - MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }} - MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }} - MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }} - MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }} + MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }} + MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }} + MANAGER_IHEP_S3_REGION: ${{ vars.MANAGER_IHEP_S3_REGION }} + MANAGER_IHEP_S3_PREFIX: ${{ vars.MANAGER_IHEP_S3_PREFIX }} MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }} MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }} run: | @@ -913,10 +917,10 @@ jobs: MANAGER_R2_BUCKET: ${{ vars.MANAGER_R2_BUCKET }} MANAGER_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_PROMOTION_ACCESS_KEY_ID }} MANAGER_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY }} - MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }} - MANAGER_IHEP_S3_BUCKET: ${{ secrets.MANAGER_IHEP_S3_BUCKET }} - MANAGER_IHEP_S3_REGION: ${{ secrets.MANAGER_IHEP_S3_REGION }} - MANAGER_IHEP_S3_PREFIX: ${{ secrets.MANAGER_IHEP_S3_PREFIX }} + MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }} + MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }} + MANAGER_IHEP_S3_REGION: ${{ vars.MANAGER_IHEP_S3_REGION }} + MANAGER_IHEP_S3_PREFIX: ${{ vars.MANAGER_IHEP_S3_PREFIX }} MANAGER_IHEP_S3_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID }} MANAGER_IHEP_S3_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY }} run: | diff --git a/cloudflare/manager-download-router/README.md b/cloudflare/manager-download-router/README.md index bb08eea..bdfd10f 100644 --- a/cloudflare/manager-download-router/README.md +++ b/cloudflare/manager-download-router/README.md @@ -64,14 +64,15 @@ So each release uploads to the mirror (`release.yml` → `scripts/sync-mirror.sh | --- | --- | | `MANAGER_R2_S3_ENDPOINT` | `https://d39dc6c92d1c4cfde580bf13e946b616.r2.cloudflarestorage.com` | | `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` / `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 S3 API token for the current protected release workflow | -| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` | IHEP endpoint and bucket (`_REGION`/`_PREFIX` optional) | +| `MANAGER_IHEP_S3_ENDPOINT` / `_BUCKET` environment variables | IHEP endpoint and bucket (`_REGION`/`_PREFIX` variables optional) | | `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` / `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP token for the current protected release workflow | -Both backends must already contain the same valid `latest.json` baseline and must -support conditional `PutObject` (`If-Match` / `If-None-Match`) plus custom -user-metadata round trips through `HeadObject`. Delete the legacy access-key secret -names after migration so historical workflow revisions cannot perform their old -unconditional write. +Both backends must already contain the same valid `latest.json` baseline and +preserve custom user metadata through `HeadObject`. R2 is the sole CAS authority +and must enforce conditional `PutObject` (`If-Match` / `If-None-Match`). IHEP is +the serialized unconditional follower; its ignored conditional headers are not a +promotion prerequisite. Delete the legacy access-key secret names after migration +so historical workflow revisions cannot perform their old unconditional write. Deploy this Worker revision before enabling the protected release workflow. The release gate intentionally rejects an older Worker that cannot force and identify diff --git a/docs/release.md b/docs/release.md index ff9369a..3e3489e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -94,21 +94,38 @@ Stable releases use a stage/verify/promote, dual-backend protocol implemented by immutable with canonical asset digests. 5. Repeat the complete direct-backend and public-route readback immediately before promotion, then read each backend's current `latest.json` and compare semantic - versions. Newer candidates advance, same-version reruns are - no-write/idempotent, and older tags fail closed. -6. Publish with ETag (`If-Match` / `If-None-Match`) conditions. If the second - backend or final verification fails, restore every backend already changed, - also using ETag conditions so rollback cannot overwrite a concurrent writer. + versions. Newer candidates advance, a fully converged same-version rerun is a + no-write success, and older tags fail closed. If a hard-killed run left R2 on + the candidate while IHEP still lags, the rerun first reclaims R2 with CAS. +6. Treat R2 as the only linearization authority. The workflow conditionally + writes R2, verifies the committed ETag and promotion token, then writes IHEP + unconditionally as a follower. A CAS loser never writes IHEP. The winner + checks R2 ownership immediately before and after the follower write. If it is + superseded during that window, it either preserves the newer follower or + repairs only its own IHEP value from the newer stable R2 snapshot. If another + repair already moved IHEP to the exact R2 candidate after the CAS, that + follower is accepted. A higher version or any identity that cannot be proven + canonical is preserved but fails closed; it cannot force the owned R2 CAS to + roll back and it is never overwritten by the older run. +7. If IHEP fails, roll R2 back only while its committed ETag and token are still + owned by this transaction. IHEP is restored only when it still contains this + transaction's token and bytes; an unchanged baseline or a concurrent value is + preserved rather than overwritten. The release workflow has one repository-wide `release-latest-*` concurrency lane with `queue: max`, so every pending tag remains queued instead of a third tag -replacing the second. The object-store conditions remain the independent backstop -for manual/external writes. `mirror-stage-summary.json`, +replacing the second. This single-writer lane and the promotion-only credential +names are a correctness boundary because IHEP does not enforce conditional +writes. Do not run another credentialed promotion workflow outside this lane. +R2 CAS and the before/after ownership checks are defense in depth for accidental +overlap; they are not a claim of an atomic transaction across providers. +`mirror-stage-summary.json`, `mirror-verification-summary.json`, and `mirror-promotion-summary.json` are shown in the job summary and retained as workflow artifacts for 90 days. If a runner is -hard-killed after only one -`latest.json` write, a fresh run recognizes the exact candidate identity, leaves -that backend untouched, and conditionally advances only the lagging backend. +hard-killed after the R2 CAS but before IHEP follows, rerun that release or a newer +one: the new run reclaims R2 with CAS and converges IHEP. A hard kill during an +out-of-policy concurrent follower race can still leave IHEP temporarily stale; +rerunning the release currently authoritative in R2 is the recovery procedure. Before `prepare` permits any build, and again at the start of every release-job attempt, the workflow queries the repository Immutable Releases setting with a @@ -171,10 +188,6 @@ used, and the run URL are written to the promotion audit and job summary. | `MANAGER_R2_S3_ENDPOINT` | R2 S3-compatible endpoint | | `MANAGER_R2_PROMOTION_ACCESS_KEY_ID` | R2 write/read credential used only by the protected workflow | | `MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY` | R2 write/read secret used only by the protected workflow | -| `MANAGER_IHEP_S3_ENDPOINT` | IHEP S3-compatible endpoint | -| `MANAGER_IHEP_S3_BUCKET` | IHEP bucket | -| `MANAGER_IHEP_S3_REGION` | IHEP region; defaults to `auto` when empty | -| `MANAGER_IHEP_S3_PREFIX` | optional object-key prefix for IHEP | | `MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID` | IHEP write/read credential used only by the protected workflow | | `MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY` | IHEP write/read secret used only by the protected workflow | @@ -193,6 +206,10 @@ those exact names; leaving any of them available defeats old-run isolation. | `AUTHENTICODE_REQUIRED` (repo **variable**) | `true` → fail release when PE is not `Valid` | | `WINDOWS_TIMESTAMP_URL` (repo **variable**) | optional RFC3161 timestamp URL | | `MANAGER_R2_BUCKET` (repo **variable**) | R2 bucket; defaults to `codex-app-manager` | +| `MANAGER_IHEP_S3_ENDPOINT` (`release` environment **variable**) | IHEP S3-compatible endpoint | +| `MANAGER_IHEP_S3_BUCKET` (`release` environment **variable**) | IHEP bucket | +| `MANAGER_IHEP_S3_REGION` (`release` environment **variable**) | IHEP region; defaults to `auto` when empty | +| `MANAGER_IHEP_S3_PREFIX` (`release` environment **variable**) | optional object-key prefix for IHEP | Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. @@ -204,7 +221,9 @@ Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. > conceptually separate — see [`docs/windows-signing.md`](./windows-signing.md). > > Before enabling promotion, seed both S3-compatible endpoints with a valid -> `latest.json` baseline. Both endpoints must support conditional `PutObject` -> requests (`If-Match` / `If-None-Match`) and must preserve custom user metadata -> through `HeadObject`. Promotion fails closed if either baseline is absent or an -> endpoint cannot enforce those requirements. +> `latest.json` baseline. R2 must enforce conditional `PutObject` requests +> (`If-Match` / `If-None-Match`) and preserve custom user metadata through +> `HeadObject`. IHEP must preserve metadata and support ordinary read/write, but +> it is explicitly allowed to ignore conditional headers because the workflow +> uses it only as the serialized unconditional follower. Promotion fails closed +> if either baseline is absent. diff --git a/scripts/mirror-release.mjs b/scripts/mirror-release.mjs index c4bda1f..b7c5b9a 100755 --- a/scripts/mirror-release.mjs +++ b/scripts/mirror-release.mjs @@ -914,6 +914,16 @@ export class AwsObjectStore { singleAttempt: true, }); } + + async putLatestUnconditional(localPath, promotionToken = "") { + return await this.putObject(localPath, LATEST_KEY, { + contentType: "application/json", + ...(promotionToken + ? { metadata: { "cam-promotion-token": promotionToken } } + : {}), + singleAttempt: true, + }); + } } function contentType(name) { @@ -1178,7 +1188,7 @@ async function readCurrentState(backend, workDir, candidateManifest) { const snapshot = await backend.snapshot(LATEST_KEY, path); if (!snapshot.exists) { throw new Error( - `${backend.name}: latest.json is absent; seed both backends with the same valid baseline before enabling transactional promotion`, + `${backend.name}: latest.json is absent; seed both backends with the same valid baseline before enabling single-writer promotion`, ); } const manifest = await readJson(path, `${backend.name} current latest.json`); @@ -1246,54 +1256,305 @@ async function withRollbackIo(callback) { } } -async function rollbackTouched(touched, summaryByName, workDir) { +function promotionBackends(backends) { + const r2 = backends.filter((backend) => backend.name === "r2"); + const ihep = backends.filter((backend) => backend.name === "ihep"); + if (backends.length !== 2 || r2.length !== 1 || ihep.length !== 1) { + throw new Error("mirror promotion requires exactly one r2 authority and one ihep follower"); + } + return { ihep: ihep[0], r2: r2[0] }; +} + +async function assertSnapshotUnchanged(state) { + const current = await state.backend.head(LATEST_KEY); + if (!current || current.etag !== state.previous.etag) { + throw new ConditionalWriteError( + `${state.backend.name}: latest.json changed after the promotion snapshot`, + ); + } +} + +async function classifyFollowerCoverage(snapshot, candidateManifest, label) { + if (!snapshot.exists) throw new Error(`${label}: latest.json disappeared`); + const manifest = await readJson(snapshot.path, `${label} latest.json`); + const followerVersion = assertManifestShape(manifest, `${label} latest.json`); + const candidateVersion = assertManifestShape(candidateManifest, "candidate latest.json"); + const comparison = compareSemver(followerVersion, candidateVersion); + if (comparison > 0) { + return { coverage: "higher-version", manifest, version: followerVersion }; + } + if ( + comparison === 0 && + manifestReleaseIdentity(manifest) === manifestReleaseIdentity(candidateManifest) + ) { + return { coverage: "candidate", manifest, version: followerVersion }; + } + if (comparison === 0) { + throw new Error( + `${label}: latest.json has the candidate version but different artifact/signature identity`, + ); + } + return { coverage: "behind", manifest, version: followerVersion }; +} + +async function refreshFollowerAfterAuthorityCas(state, candidateManifest, workDir) { + const snapshot = await state.backend.snapshot( + LATEST_KEY, + join(workDir, "ihep-post-authority-cas.json"), + ); + if (!snapshot.exists) throw new Error("ihep: latest.json disappeared after the R2 CAS"); + if (snapshot.etag === state.previous.etag) { + return { coverage: "snapshot-unchanged", needsWrite: true, snapshot }; + } + const classified = await classifyFollowerCoverage( + snapshot, + candidateManifest, + "IHEP after R2 CAS", + ); + if (classified.coverage !== "candidate") { + throw new ConditionalWriteError( + `ihep: latest.json changed after the R2 CAS to ${classified.version}; exact candidate identity is required`, + ); + } + return { ...classified, needsWrite: false, snapshot }; +} + +async function ownedCandidateSnapshot({ + state, + candidatePath, + expectedEtag, + promotionToken, + workDir, + label, +}) { + const snapshot = await state.backend.snapshot( + LATEST_KEY, + join(workDir, `${state.backend.name}-${label}.json`), + ); + if ( + !snapshot.exists || + snapshot.etag !== expectedEtag || + snapshot.metadata?.["cam-promotion-token"] !== promotionToken || + !(await snapshotMatchesFile(snapshot, candidatePath)) + ) { + throw new ConditionalWriteError( + `${state.backend.name}: latest.json is no longer owned by this promotion`, + ); + } + return snapshot; +} + +async function rollbackAuthoritativePromotion({ + r2State, + ihepState, + r2Commit, + followerWriteAttempted, + candidatePath, + promotionToken, + summaryByName, + workDir, +}) { const failures = []; + const r2Summary = summaryByName.get("r2"); + const ihepSummary = summaryByName.get("ihep"); rollbackInProgress = true; try { - for (const item of [...touched].reverse()) { - const backendSummary = summaryByName.get(item.state.backend.name); + try { + await ownedCandidateSnapshot({ + state: r2State, + candidatePath, + expectedEtag: r2Commit.etag, + promotionToken, + workDir, + label: "rollback-ownership", + }); + } catch (error) { + r2Summary.rollback = "skipped-concurrent-change"; + r2Summary.rollbackError = safeSummaryError(error); + if (followerWriteAttempted) ihepSummary.rollback = "preserved-authority-lost"; + failures.push(`r2: ${errorText(error)}`); + return { failures, rolledBack: false }; + } + + try { + if (!r2State.previous.exists) { + throw new Error("authoritative promotion cannot roll back an unseeded R2 baseline"); + } + const restored = await r2State.backend.putLatestConditional( + r2State.previous.path, + r2Commit.etag, + `rollback-${randomUUID()}`, + ); + const check = await r2State.backend.snapshot( + LATEST_KEY, + join(workDir, "r2-rollback-check.json"), + ); + if ( + check.etag !== restored.etag || + !(await snapshotMatchesFile(check, r2State.previous.path)) + ) { + throw new Error("R2 rollback verification did not match the previous latest.json"); + } + r2Summary.rollback = "restored"; + } catch (error) { + r2Summary.rollback = "failed"; + r2Summary.rollbackError = safeSummaryError(error); + failures.push(`r2: ${errorText(error)}`); + if (followerWriteAttempted) ihepSummary.rollback = "preserved-authority-rollback-failed"; + return { failures, rolledBack: false }; + } + + if (followerWriteAttempted) { try { - const owned = await item.state.backend.snapshot( + const current = await ihepState.backend.snapshot( LATEST_KEY, - join(workDir, `${item.state.backend.name}-rollback-ownership.json`), + join(workDir, "ihep-rollback-current.json"), ); - if ( - !owned.exists || - owned.etag !== item.promotedEtag || - owned.metadata?.["cam-promotion-token"] !== item.promotionToken + if (await snapshotMatchesFile(current, ihepState.previous.path)) { + ihepSummary.rollback = "preserved-previous"; + } else if ( + current.exists && + current.metadata?.["cam-promotion-token"] === promotionToken && + (await snapshotMatchesFile(current, candidatePath)) ) { - throw new ConditionalWriteError( - `${item.state.backend.name}: latest.json is no longer owned by this promotion`, - ); - } - if (item.state.previous.exists) { - const restored = await item.state.backend.putLatestConditional( - item.state.previous.path, - item.promotedEtag, + const restored = await ihepState.backend.putLatestUnconditional( + ihepState.previous.path, `rollback-${randomUUID()}`, ); - const check = await item.state.backend.snapshot( + const check = await ihepState.backend.snapshot( LATEST_KEY, - join(workDir, `${item.state.backend.name}-rollback-check.json`), + join(workDir, "ihep-rollback-check.json"), ); - if (!(await snapshotMatchesFile(check, item.state.previous.path))) { - throw new Error("rollback verification did not match previous latest.json"); + if ( + check.etag !== restored.etag || + !(await snapshotMatchesFile(check, ihepState.previous.path)) + ) { + throw new Error("IHEP rollback verification did not match the previous latest.json"); } - item.promotedEtag = restored.etag; + ihepSummary.rollback = "restored-unconditionally"; } else { - throw new Error("transactional promotion cannot roll back an unseeded backend"); + // IHEP cannot enforce conditional writes. If its object is neither the + // snapshot nor this transaction's token+bytes, never overwrite it. + ihepSummary.rollback = "preserved-concurrent-change"; } - backendSummary.rollback = "restored"; } catch (error) { - backendSummary.rollback = "failed"; - backendSummary.rollbackError = safeSummaryError(error); - failures.push(`${item.state.backend.name}: ${errorText(error)}`); + ihepSummary.rollback = "failed"; + ihepSummary.rollbackError = safeSummaryError(error); + failures.push(`ihep: ${errorText(error)}`); } } } finally { rollbackInProgress = false; } - return failures; + return { failures, rolledBack: true }; +} + +async function reconcileFollowerToCurrentAuthority({ + r2State, + ihepState, + candidateManifest, + candidatePath, + promotionToken, + summaryByName, + workDir, +}) { + const authority = await r2State.backend.snapshot( + LATEST_KEY, + join(workDir, "r2-superseding-authority.json"), + ); + if (!authority.exists) throw new Error("r2: superseding latest.json disappeared"); + const authorityManifest = await readJson( + authority.path, + "R2 superseding authoritative latest.json", + ); + const authorityVersion = assertManifestShape( + authorityManifest, + "R2 superseding authoritative latest.json", + ); + const candidateVersion = assertManifestShape(candidateManifest, "candidate latest.json"); + const comparison = compareSemver(authorityVersion, candidateVersion); + if ( + comparison < 0 || + (comparison === 0 && + manifestReleaseIdentity(authorityManifest) !== + manifestReleaseIdentity(candidateManifest)) + ) { + throw new Error( + `r2: superseding authority ${authorityVersion} is not a safe monotonic successor of ${candidateVersion}`, + ); + } + + const currentFollower = await ihepState.backend.snapshot( + LATEST_KEY, + join(workDir, "ihep-supersession-current.json"), + ); + if (!currentFollower.exists) { + throw new Error("ihep: latest.json disappeared during supersession repair"); + } + if (await snapshotMatchesFile(currentFollower, authority.path)) { + summaryByName.get("ihep").supersession = "already-follows-r2"; + return authorityManifest; + } + + const followerManifest = await readJson( + currentFollower.path, + "IHEP latest.json during supersession repair", + ); + const followerVersion = assertManifestShape( + followerManifest, + "IHEP latest.json during supersession repair", + ); + if (compareSemver(followerVersion, authorityVersion) > 0) { + // Never let an older workflow overwrite a follower that has already moved + // beyond the R2 snapshot it observed. + summaryByName.get("ihep").supersession = "preserved-newer-follower"; + return followerManifest; + } + + const ownsFollower = + (currentFollower.metadata?.["cam-promotion-token"] === promotionToken && + (await snapshotMatchesFile(currentFollower, candidatePath))) || + (currentFollower.etag === ihepState.previous.etag && + (await snapshotMatchesFile(currentFollower, ihepState.previous.path))); + if (!ownsFollower) { + throw new ConditionalWriteError( + "ihep: cannot safely repair a follower object owned by another writer", + ); + } + + const repaired = await ihepState.backend.putLatestUnconditional( + authority.path, + `superseded-${randomUUID()}`, + ); + const check = await ihepState.backend.snapshot( + LATEST_KEY, + join(workDir, "ihep-supersession-check.json"), + ); + if ( + check.etag === repaired.etag && + (await snapshotMatchesFile(check, authority.path)) + ) { + summaryByName.get("ihep").supersession = "repaired-to-r2"; + return authorityManifest; + } + + if (check.exists) { + const racedManifest = await readJson( + check.path, + "IHEP latest.json after supersession repair race", + ); + const racedVersion = assertManifestShape( + racedManifest, + "IHEP latest.json after supersession repair race", + ); + if (compareSemver(racedVersion, authorityVersion) >= 0) { + summaryByName.get("ihep").supersession = "preserved-racing-successor"; + return racedManifest; + } + } + throw new ConditionalWriteError( + "ihep: follower changed to an older value during supersession repair", + ); } export async function promoteCandidateTransaction({ @@ -1306,9 +1567,10 @@ export async function promoteCandidateTransaction({ hooks = {}, promotionToken = randomUUID(), }) { + const { r2, ihep } = promotionBackends(backends); const summaryByName = new Map(summary.backends.map((entry) => [entry.name, entry])); const states = []; - for (const backend of backends) { + for (const backend of [r2, ihep]) { try { states.push(await readCurrentState(backend, workDir, candidateManifest)); } catch (error) { @@ -1348,8 +1610,11 @@ export async function promoteCandidateTransaction({ } } - const toPromote = states.filter((state) => state.decision !== "idempotent"); - if (toPromote.length === 0) { + const r2State = states.find((state) => state.backend === r2); + const ihepState = states.find((state) => state.backend === ihep); + let followerNeedsWrite = ihepState.decision !== "idempotent"; + const authorityNeedsWrite = r2State.decision !== "idempotent"; + if (!authorityNeedsWrite && !followerNeedsWrite) { for (const state of states) { const backendSummary = summaryByName.get(state.backend.name); try { @@ -1383,105 +1648,237 @@ export async function promoteCandidateTransaction({ return { outcome: "idempotent", states }; } - const touched = []; - let activeState = null; + if (!authorityNeedsWrite && followerNeedsWrite) { + r2State.decision = override.used + ? "claim-follower-downgrade-override" + : "claim-follower-reconciliation"; + summaryByName.get("r2").decision = r2State.decision; + } + + let activeState = r2State; + let r2Commit = null; + let followerWriteAttempted = false; + let followerCommit = null; + let supersededByAuthority = false; + let supersessionFailures = []; + let followerConflictAfterAuthority = false; try { - for (const state of toPromote) { - activeState = state; - const backendSummary = summaryByName.get(state.backend.name); - await hooks.beforeWrite?.(state); - let promoted; - try { - promoted = await state.backend.putLatestConditional( + // R2 is the only linearization authority. The follower snapshot must still + // be current before this workflow attempts the authoritative CAS. + await assertSnapshotUnchanged(ihepState); + await hooks.beforeWrite?.(r2State); + try { + r2Commit = await r2.putLatestConditional( + candidatePath, + r2State.previous.etag, + promotionToken, + ); + summaryByName.get("r2").promotion = "written-authoritative-cas"; + } catch (error) { + if (error instanceof ConditionalWriteError) throw error; + const ambiguous = await withRollbackIo(() => + observeAmbiguousWrite( + r2State, candidatePath, - state.previous.etag, + workDir, promotionToken, + ), + ); + if (!ambiguous) { + summaryByName.get("r2").promotion = "write-outcome-unresolved"; + summaryByName.get("r2").rollback = "uncertain"; + throw new WriteOutcomeUncertainError( + `r2: write failed and ownership could not be resolved after ${errorText(error)}`, + ); + } + r2Commit = ambiguous; + summaryByName.get("r2").promotion = "write-outcome-ambiguous"; + throw error; + } + + await ownedCandidateSnapshot({ + state: r2State, + candidatePath, + expectedEtag: r2Commit.etag, + promotionToken, + workDir, + label: "authoritative-check", + }); + summaryByName.get("r2").promotion = "verified-authoritative-cas"; + await hooks.afterWrite?.(r2State); + + if (followerNeedsWrite) { + await ownedCandidateSnapshot({ + state: r2State, + candidatePath, + expectedEtag: r2Commit.etag, + promotionToken, + workDir, + label: "pre-follower-ownership", + }); + activeState = ihepState; + let refreshedFollower; + try { + refreshedFollower = await refreshFollowerAfterAuthorityCas( + ihepState, + candidateManifest, + workDir, ); } catch (error) { - if (error instanceof ConditionalWriteError) { - throw error; - } - const ambiguous = await withRollbackIo(() => + followerConflictAfterAuthority = true; + summaryByName.get("r2").rollback = "preserved-authoritative-cas"; + summaryByName.get("ihep").promotion = "conflict-after-authority-cas"; + summaryByName.get("ihep").rollback = "preserved-unowned-follower"; + if (error instanceof ConditionalWriteError) throw error; + throw new ConditionalWriteError( + `ihep: cannot prove exact candidate identity after the R2 CAS: ${errorText(error)}`, + ); + } + if (!refreshedFollower.needsWrite) { + followerNeedsWrite = false; + summaryByName.get("ihep").promotion = "already-current-after-authority-cas"; + } + } + + if (followerNeedsWrite) { + activeState = ihepState; + await hooks.beforeWrite?.(ihepState); + followerWriteAttempted = true; + try { + followerCommit = await ihep.putLatestUnconditional(candidatePath, promotionToken); + summaryByName.get("ihep").promotion = "written-unconditional-follower"; + } catch (error) { + const observed = await withRollbackIo(() => observeAmbiguousWrite( - state, + ihepState, candidatePath, workDir, promotionToken, ), ); - if (ambiguous) { - touched.push({ - state, - promotedEtag: ambiguous.etag, - promotionToken, - }); - backendSummary.promotion = "write-outcome-ambiguous"; - } else { - backendSummary.promotion = "write-outcome-unresolved"; - backendSummary.rollback = "uncertain"; - throw new WriteOutcomeUncertainError( - `${state.backend.name}: write failed and ownership could not be resolved after ${errorText(error)}`, - ); + if (!observed) { + summaryByName.get("ihep").promotion = "write-outcome-unresolved"; + throw error; } - throw error; + followerCommit = observed; + summaryByName.get("ihep").promotion = "write-outcome-confirmed"; } - const touchedItem = { - state, - promotedEtag: promoted.etag, + await ownedCandidateSnapshot({ + state: ihepState, + candidatePath, + expectedEtag: followerCommit.etag, promotionToken, - }; - touched.push(touchedItem); - backendSummary.promotion = "written"; - const check = await state.backend.snapshot( - LATEST_KEY, - join(workDir, `${state.backend.name}-promoted-check.json`), - ); - if ( - check.etag !== promoted.etag || - check.metadata?.["cam-promotion-token"] !== promotionToken - ) { - touched.pop(); - backendSummary.promotion = "ownership-lost"; - backendSummary.rollback = "skipped-concurrent-change"; - throw new ConditionalWriteError( - `${state.backend.name}: latest.json changed after the conditional write`, + workDir, + label: "follower-check", + }); + summaryByName.get("ihep").promotion = "verified-unconditional-follower"; + await hooks.afterWrite?.(ihepState); + } else if (summaryByName.get("ihep").promotion === "not-started") { + summaryByName.get("ihep").promotion = "already-current"; + } + + activeState = r2State; + try { + await ownedCandidateSnapshot({ + state: r2State, + candidatePath, + expectedEtag: r2Commit.etag, + promotionToken, + workDir, + label: "final-authority", + }); + } catch (ownershipError) { + // The protected release workflow is serialized, but keep a fencing repair + // for accidental/manual overlap. An older writer that was superseded + // between its pre-IHEP check and its unconditional IHEP write repairs only + // an object it still owns, using the newer stable R2 snapshot. + supersededByAuthority = true; + summaryByName.get("r2").rollback = "skipped-superseded"; + try { + await withRollbackIo(() => + reconcileFollowerToCurrentAuthority({ + r2State, + ihepState, + candidateManifest, + candidatePath, + promotionToken, + summaryByName, + workDir, + }), ); + } catch (repairError) { + supersessionFailures = [`ihep: ${errorText(repairError)}`]; } - if (!(await snapshotMatchesFile(check, candidatePath))) { - throw new Error(`${state.backend.name}: promoted latest.json failed byte verification`); - } - backendSummary.promotion = "verified"; - await hooks.afterWrite?.(state); + throw ownershipError; } + summaryByName.get("r2").finalVersion = candidateManifest.version; - for (const state of states) { - activeState = state; - const finalSnapshot = await state.backend.snapshot( + activeState = ihepState; + let finalFollower; + let finalFollowerCoverage; + try { + finalFollower = await ihep.snapshot( LATEST_KEY, - join(workDir, `${state.backend.name}-final-latest.json`), + join(workDir, "ihep-final-latest.json"), + ); + finalFollowerCoverage = await classifyFollowerCoverage( + finalFollower, + candidateManifest, + "IHEP final", ); - if (!finalSnapshot.exists) throw new Error(`${state.backend.name}: latest.json disappeared`); - const finalManifest = await readJson( - finalSnapshot.path, - `${state.backend.name} final latest.json`, + } catch (error) { + followerConflictAfterAuthority = true; + summaryByName.get("r2").rollback = "preserved-authoritative-cas"; + summaryByName.get("ihep").rollback = "preserved-unowned-follower"; + throw new ConditionalWriteError( + `ihep: cannot prove exact final candidate identity: ${errorText(error)}`, ); - if (manifestReleaseIdentity(finalManifest) !== manifestReleaseIdentity(candidateManifest)) { - throw new Error(`${state.backend.name}: final latest.json does not expose the candidate release`); - } - summaryByName.get(state.backend.name).finalVersion = finalManifest.version; } + if (finalFollowerCoverage.coverage !== "candidate") { + followerConflictAfterAuthority = true; + summaryByName.get("r2").rollback = "preserved-authoritative-cas"; + summaryByName.get("ihep").rollback = "preserved-unowned-follower"; + throw new ConditionalWriteError( + `ihep: final latest.json is ${finalFollowerCoverage.version}; exact candidate identity is required`, + ); + } + if ( + followerCommit && + (finalFollower.etag !== followerCommit.etag || + finalFollower.metadata?.["cam-promotion-token"] !== promotionToken) + ) { + summaryByName.get("ihep").supersession = + "already-follows-r2"; + } + summaryByName.get("ihep").finalVersion = finalFollowerCoverage.manifest.version; return { outcome: override.used ? "downgrade-override-promoted" : "promoted", states }; } catch (error) { if (activeState) { summaryByName.get(activeState.backend.name).error = safeSummaryError(error); } - const rollbackFailures = await rollbackTouched(touched, summaryByName, workDir); - const ownershipUncertain = error instanceof WriteOutcomeUncertainError; - const failures = ownershipUncertain - ? [...rollbackFailures, `${activeState?.backend.name || "backend"}: write ownership unresolved`] - : rollbackFailures; + let failures = []; + let rolledBack = false; + if (followerConflictAfterAuthority) { + summary.authorityPreserved = true; + } else if (supersededByAuthority) { + failures = supersessionFailures; + } else if (r2Commit) { + ({ failures, rolledBack } = await rollbackAuthoritativePromotion({ + r2State, + ihepState, + r2Commit, + followerWriteAttempted, + candidatePath, + promotionToken, + summaryByName, + workDir, + })); + } else if (error instanceof WriteOutcomeUncertainError) { + failures = ["r2: write ownership unresolved"]; + } summary.rollback = { - attempted: touched.length > 0, + attempted: + Boolean(r2Commit) && !supersededByAuthority && !followerConflictAfterAuthority, complete: failures.length === 0, failures, }; @@ -1490,7 +1887,7 @@ export async function promoteCandidateTransaction({ `${errorText(error)}; rollback incomplete: ${failures.join("; ")}`, ); } - if (touched.length > 0) summary.outcome = "rolled-back"; + if (rolledBack) summary.outcome = "rolled-back"; throw error; } } diff --git a/scripts/mirror-release.test.mjs b/scripts/mirror-release.test.mjs index 3039574..a600896 100644 --- a/scripts/mirror-release.test.mjs +++ b/scripts/mirror-release.test.mjs @@ -143,6 +143,8 @@ class MemoryBackend { this.objects = new Map(); this.counter = 0; this.latestPutAttempts = 0; + this.conditionalLatestPutAttempts = 0; + this.unconditionalLatestPutAttempts = 0; for (const [key, body] of Object.entries(objects)) this.set(key, body); } @@ -196,6 +198,7 @@ class MemoryBackend { async putLatestConditional(localPath, expectedEtag, promotionToken = "") { this.latestPutAttempts += 1; + this.conditionalLatestPutAttempts += 1; const current = this.objects.get("latest.json"); const matches = expectedEtag ? current?.etag === expectedEtag : !current; if (!matches) throw new ConditionalWriteError(`${this.name}: stale latest ETag`); @@ -209,6 +212,19 @@ class MemoryBackend { return { etag: stored.etag, size: stored.body.length }; } + async putLatestUnconditional(localPath, promotionToken = "") { + this.latestPutAttempts += 1; + this.unconditionalLatestPutAttempts += 1; + if (this.failLatestPut) throw new Error(`${this.name}: simulated write failure`); + this.set( + "latest.json", + await readFile(localPath), + promotionToken ? { "cam-promotion-token": promotionToken } : {}, + ); + const stored = this.objects.get("latest.json"); + return { etag: stored.etag, size: stored.body.length }; + } + async deleteLatestConditional(expectedEtag) { const current = this.objects.get("latest.json"); if (!current || current.etag !== expectedEtag) { @@ -218,6 +234,22 @@ class MemoryBackend { } } +class ConditionIgnoringMemoryBackend extends MemoryBackend { + async putLatestConditional(localPath, _expectedEtag, promotionToken = "") { + // Model the real IHEP behavior: the request accepts If-Match but overwrites + // regardless. Production promotion must therefore never call this method. + this.latestPutAttempts += 1; + this.conditionalLatestPutAttempts += 1; + this.set( + "latest.json", + await readFile(localPath), + promotionToken ? { "cam-promotion-token": promotionToken } : {}, + ); + const stored = this.objects.get("latest.json"); + return { etag: stored.etag, size: stored.body.length }; + } +} + function encodedUpdaterSignature(privateKey, keyId, artifact) { const digest = createHash("blake2b512").update(artifact).digest(); const signature = cryptoSign(null, digest, privateKey); @@ -478,7 +510,7 @@ describe("public mirror route verification", () => { }); }); -describe("AWS conditional writes", () => { +describe("AWS latest writes", () => { function objectStore() { return new AwsObjectStore({ name: "r2", @@ -536,6 +568,30 @@ describe("AWS conditional writes", () => { backend.putLatestConditional(candidatePath, '"stale-etag"', "promotion-token"), ).rejects.toBeInstanceOf(ConditionalWriteError); }); + + it("omits conditional headers for the serialized IHEP follower write", async () => { + const root = await tempRoot("unconditional-follower-put"); + const candidatePath = await writeManifest(root, "candidate.json", manifest("1.2.3")); + const backend = objectStore(); + let call; + backend.aws = async (args, options) => { + call = { args, options }; + return { code: 0, stderr: "", stdout: '{"ETag":"\\"follower-etag\\""}' }; + }; + + const result = await backend.putLatestUnconditional(candidatePath, "promotion-token"); + + expect(result.etag).toBe('"follower-etag"'); + expect(call.options.env).toEqual({ AWS_MAX_ATTEMPTS: "1" }); + expect(call.args).toEqual( + expect.arrayContaining([ + "--metadata", + "cam-promotion-token=promotion-token", + ]), + ); + expect(call.args).not.toContain("--if-match"); + expect(call.args).not.toContain("--if-none-match"); + }); }); describe("backend candidate verification", () => { @@ -772,6 +828,101 @@ describe("monotonic mirror promotion", () => { ]); }); + it("never lets an old rerun fill a lagging IHEP follower behind newer R2", async () => { + const root = await tempRoot("old-rerun-lagging-follower"); + const candidate = manifest("1.9.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const r2 = new MemoryBackend("r2", { + "latest.json": `${JSON.stringify(manifest("2.0.0"))}\n`, + }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { + "latest.json": `${JSON.stringify(manifest("1.0.0"))}\n`, + }); + const backends = [r2, ihep]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }), + ).rejects.toBeInstanceOf(DowngradeBlockedError); + + expect(r2.latestPutAttempts).toBe(0); + expect(ihep.latestPutAttempts).toBe(0); + expect(JSON.parse(r2.body("latest.json")).version).toBe("2.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.0.0"); + }); + + it("uses IHEP only as an unconditional follower even when it ignores conditions", async () => { + const root = await tempRoot("ihep-ignores-conditions"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "single-writer", + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }), + ).resolves.toEqual(expect.objectContaining({ outcome: "promoted" })); + + expect(r2.conditionalLatestPutAttempts).toBe(1); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(1); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.1.0"); + }); + + it("does not let an R2 CAS loser touch condition-ignoring IHEP", async () => { + const root = await tempRoot("r2-cas-loser"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const realPut = r2.putLatestConditional.bind(r2); + let raced = false; + r2.putLatestConditional = async (...args) => { + if (!raced) { + raced = true; + r2.set( + "latest.json", + `${JSON.stringify(manifest("1.2.0"))}\n`, + { "cam-promotion-token": "newer-winner" }, + ); + } + return await realPut(...args); + }; + const backends = [r2, ihep]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(0); + expect(JSON.parse(r2.body("latest.json")).version).toBe("1.2.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.0.0"); + }); + it("treats a same-version rerun as a no-write idempotent success", async () => { const root = await tempRoot("same-version"); const current = manifest("2.0.0"); @@ -829,7 +980,7 @@ describe("monotonic mirror promotion", () => { expect(JSON.parse(ihep.body("latest.json")).version).toBe("2.1.0"); }); - it("uses conditional writes so a concurrent newer release wins without mixed latest pointers", async () => { + it("uses R2 CAS so a concurrent newer release wins without mixed latest pointers", async () => { const root = await tempRoot("concurrent"); const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; const backends = [ @@ -882,7 +1033,288 @@ describe("monotonic mirror promotion", () => { ]); }); - it("heals a hard-terminated partial promotion without rewriting the committed backend", async () => { + it("repairs IHEP when an older writer is superseded after its pre-write ownership check", async () => { + const root = await tempRoot("cross-version-follower-race"); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const older = manifest("2.0.0"); + const newer = manifest("3.0.0"); + const olderPath = await writeManifest(root, "older.json", older); + const newerPath = await writeManifest(root, "newer.json", newer); + const olderSummary = summaryFor(backends); + let releaseOlderFollower; + let olderAtFollower; + const holdOlderFollower = new Promise((resolvePromise) => { + releaseOlderFollower = resolvePromise; + }); + const olderFollowerReady = new Promise((resolvePromise) => { + olderAtFollower = resolvePromise; + }); + + const olderRun = promoteCandidateTransaction({ + backends, + candidateManifest: older, + candidatePath: olderPath, + override: overrideOff(), + promotionToken: "older-writer", + summary: olderSummary, + workDir: join(root, "older-transaction"), + hooks: { + beforeWrite: async (state) => { + if (state.backend.name === "ihep") { + olderAtFollower(); + await holdOlderFollower; + } + }, + }, + }); + await olderFollowerReady; + + await promoteCandidateTransaction({ + backends, + candidateManifest: newer, + candidatePath: newerPath, + override: overrideOff(), + promotionToken: "newer-writer", + summary: summaryFor(backends), + workDir: join(root, "newer-transaction"), + }); + releaseOlderFollower(); + + await expect(olderRun).rejects.toBeInstanceOf(ConditionalWriteError); + expect(JSON.parse(r2.body("latest.json")).version).toBe("3.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("3.0.0"); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(3); + expect(olderSummary.backends.find((backend) => backend.name === "ihep").supersession).toBe( + "repaired-to-r2", + ); + }); + + it("keeps B's R2 v3 CAS when A repairs B's changed follower before B resumes", async () => { + const root = await tempRoot("post-cas-follower-repaired-by-older-writer"); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const aManifest = manifest("2.0.0"); + const bManifest = manifest("3.0.0"); + const aPath = await writeManifest(root, "a-v2.json", aManifest); + const bPath = await writeManifest(root, "b-v3.json", bManifest); + const bSummary = summaryFor(backends); + + let releaseA; + let signalAReady; + const holdA = new Promise((resolvePromise) => { + releaseA = resolvePromise; + }); + const aReady = new Promise((resolvePromise) => { + signalAReady = resolvePromise; + }); + const aRun = promoteCandidateTransaction({ + backends, + candidateManifest: aManifest, + candidatePath: aPath, + override: overrideOff(), + promotionToken: "writer-a-v2", + summary: summaryFor(backends), + workDir: join(root, "a-transaction"), + hooks: { + beforeWrite: async (state) => { + if (state.backend.name === "ihep") { + signalAReady(); + await holdA; + } + }, + }, + }); + await aReady; + + let releaseB; + let signalBCasCommitted; + const holdB = new Promise((resolvePromise) => { + releaseB = resolvePromise; + }); + const bCasCommitted = new Promise((resolvePromise) => { + signalBCasCommitted = resolvePromise; + }); + const bRun = promoteCandidateTransaction({ + backends, + candidateManifest: bManifest, + candidatePath: bPath, + override: overrideOff(), + promotionToken: "writer-b-v3", + summary: bSummary, + workDir: join(root, "b-transaction"), + hooks: { + afterWrite: async (state) => { + if (state.backend.name === "r2") { + signalBCasCommitted(); + await holdB; + } + }, + }, + }); + await bCasCommitted; + + releaseA(); + await expect(aRun).rejects.toBeInstanceOf(ConditionalWriteError); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("3.0.0"); + + releaseB(); + await expect(bRun).resolves.toEqual(expect.objectContaining({ outcome: "promoted" })); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("3.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("3.0.0"); + expect(r2.conditionalLatestPutAttempts).toBe(2); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(2); + expect(bSummary.backends.find((backend) => backend.name === "r2").rollback).toBe( + "not-needed", + ); + expect(bSummary.backends.find((backend) => backend.name === "ihep").promotion).toBe( + "already-current-after-authority-cas", + ); + }); + + it("fails closed on a higher post-CAS follower without rolling R2 back", async () => { + const root = await tempRoot("post-cas-safe-successor"); + const candidate = manifest("2.0.0"); + const successor = manifest("3.0.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "candidate-v2", + summary, + workDir: join(root, "transaction"), + hooks: { + afterWrite: (state) => { + if (state.backend.name === "r2") { + ihep.set( + "latest.json", + `${JSON.stringify(successor)}\n`, + { "cam-promotion-token": "successor-v3" }, + ); + } + }, + }, + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("2.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("3.0.0"); + expect(r2.conditionalLatestPutAttempts).toBe(1); + expect(ihep.unconditionalLatestPutAttempts).toBe(0); + expect(summary.authorityPreserved).toBe(true); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe( + "preserved-authoritative-cas", + ); + expect(summary.backends.find((backend) => backend.name === "ihep").promotion).toBe( + "conflict-after-authority-cas", + ); + }); + + it("fails closed on an unknown post-CAS identity without rolling R2 back", async () => { + const root = await tempRoot("post-cas-unknown-identity"); + const candidate = manifest("2.0.0"); + const unknown = structuredClone(candidate); + unknown.platforms["windows-x86_64"].signature = "unknown-signature"; + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "candidate-v2", + summary, + workDir: join(root, "transaction"), + hooks: { + afterWrite: (state) => { + if (state.backend.name === "r2") { + ihep.set( + "latest.json", + `${JSON.stringify(unknown)}\n`, + { "cam-promotion-token": "unknown-v2" }, + ); + } + }, + }, + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("2.0.0"); + expect(JSON.parse(ihep.body("latest.json")).platforms["windows-x86_64"].signature).toBe( + "unknown-signature", + ); + expect(r2.conditionalLatestPutAttempts).toBe(1); + expect(ihep.unconditionalLatestPutAttempts).toBe(0); + expect(summary.authorityPreserved).toBe(true); + }); + + it("fails closed when the final follower becomes higher after this run writes it", async () => { + const root = await tempRoot("final-follower-safe-successor"); + const candidate = manifest("2.0.0"); + const successor = manifest("3.0.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "candidate-v2", + summary, + workDir: join(root, "transaction"), + hooks: { + afterWrite: (state) => { + if (state.backend.name === "ihep") { + ihep.set( + "latest.json", + `${JSON.stringify(successor)}\n`, + { "cam-promotion-token": "successor-v3" }, + ); + } + }, + }, + }), + ).rejects.toBeInstanceOf(ConditionalWriteError); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("2.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("3.0.0"); + expect(r2.conditionalLatestPutAttempts).toBe(1); + expect(ihep.unconditionalLatestPutAttempts).toBe(1); + expect(summary.authorityPreserved).toBe(true); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe( + "preserved-authoritative-cas", + ); + }); + + it("reclaims R2 CAS and heals a hard-terminated R2-only promotion", async () => { const root = await tempRoot("partial-terminated-transaction"); const current = manifest("1.0.0"); const candidate = manifest("1.1.0"); @@ -897,13 +1329,14 @@ describe("monotonic mirror promotion", () => { const r2AttemptsAfterTermination = r2.latestPutAttempts; const backends = [r2, ihep]; const failedResumeSummary = summaryFor(backends); - const realIhepPut = ihep.putLatestConditional.bind(ihep); + const realIhepPut = ihep.putLatestUnconditional.bind(ihep); let failFirstResume = true; - ihep.putLatestConditional = async (...args) => { + ihep.putLatestUnconditional = async (...args) => { if (failFirstResume) { failFirstResume = false; ihep.latestPutAttempts += 1; - throw new ConditionalWriteError("ihep: simulated conditional outage"); + ihep.unconditionalLatestPutAttempts += 1; + throw new Error("ihep: simulated follower outage"); } return await realIhepPut(...args); }; @@ -918,15 +1351,15 @@ describe("monotonic mirror promotion", () => { summary: failedResumeSummary, workDir: join(root, "failed-resume"), }), - ).rejects.toBeInstanceOf(ConditionalWriteError); + ).rejects.toThrow("simulated follower outage"); - expect(r2.latestPutAttempts).toBe(r2AttemptsAfterTermination); + expect(r2.latestPutAttempts).toBe(r2AttemptsAfterTermination + 2); expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ "1.1.0", "1.0.0", ]); expect(failedResumeSummary.rollback).toEqual( - expect.objectContaining({ attempted: false, complete: true }), + expect.objectContaining({ attempted: true, complete: true }), ); await expect( @@ -941,18 +1374,50 @@ describe("monotonic mirror promotion", () => { }), ).resolves.toEqual(expect.objectContaining({ outcome: "promoted" })); - expect(r2.latestPutAttempts).toBe(r2AttemptsAfterTermination); + expect(r2.latestPutAttempts).toBe(r2AttemptsAfterTermination + 3); expect(ihep.latestPutAttempts).toBe(2); expect(backends.map((backend) => JSON.parse(backend.body("latest.json")).version)).toEqual([ "1.1.0", "1.1.0", ]); expect(r2.objects.get("latest.json").metadata["cam-promotion-token"]).toBe( - "terminated-run", + "fresh-run", ); expect(ihep.objects.get("latest.json").metadata["cam-promotion-token"]).toBe("fresh-run"); }); + it("lets a newer rerun converge after a hard-terminated R2-only promotion", async () => { + const root = await tempRoot("newer-after-hard-termination"); + const previous = manifest("1.0.0"); + const interrupted = manifest("1.1.0"); + const newer = manifest("1.2.0"); + const interruptedPath = await writeManifest(root, "interrupted.json", interrupted); + const newerPath = await writeManifest(root, "newer.json", newer); + const initial = `${JSON.stringify(previous)}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const before = await r2.head("latest.json"); + await r2.putLatestConditional(interruptedPath, before.etag, "terminated-run"); + const backends = [r2, ihep]; + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: newer, + candidatePath: newerPath, + override: overrideOff(), + promotionToken: "newer-rerun", + summary: summaryFor(backends), + workDir: join(root, "transaction"), + }), + ).resolves.toEqual(expect.objectContaining({ outcome: "promoted" })); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("1.2.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.2.0"); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(1); + }); + it("fails closed before writing when either backend has no baseline latest.json", async () => { const root = await tempRoot("unseeded-backend"); const candidate = manifest("1.1.0"); @@ -978,6 +1443,161 @@ describe("monotonic mirror promotion", () => { expect(ihep.body("latest.json")).toBeUndefined(); }); + it("CAS-rolls R2 back and preserves IHEP when the follower fails before writing", async () => { + const root = await tempRoot("follower-failure-before-write"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = Buffer.from(`${JSON.stringify(manifest("1.0.0"))}\n`); + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + ihep.failLatestPut = true; + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "failed-follower", + summary, + workDir: join(root, "transaction"), + }), + ).rejects.toThrow("simulated write failure"); + + expect(r2.body("latest.json").equals(initial)).toBe(true); + expect(ihep.body("latest.json").equals(initial)).toBe(true); + expect(r2.conditionalLatestPutAttempts).toBe(2); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(1); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe("restored"); + expect(summary.backends.find((backend) => backend.name === "ihep").rollback).toBe( + "preserved-previous", + ); + }); + + it("restores only its own IHEP follower write after a later local failure", async () => { + const root = await tempRoot("follower-failure-after-write"); + const candidate = manifest("1.1.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = Buffer.from(`${JSON.stringify(manifest("1.0.0"))}\n`); + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "restore-follower", + summary, + workDir: join(root, "transaction"), + hooks: { + afterWrite: (state) => { + if (state.backend.name === "ihep") throw new Error("simulated post-write failure"); + }, + }, + }), + ).rejects.toThrow("simulated post-write failure"); + + expect(r2.body("latest.json").equals(initial)).toBe(true); + expect(ihep.body("latest.json").equals(initial)).toBe(true); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(ihep.unconditionalLatestPutAttempts).toBe(2); + expect(summary.backends.find((backend) => backend.name === "ihep").rollback).toBe( + "restored-unconditionally", + ); + }); + + it("does not roll R2 back when IHEP fails after a newer authority takes ownership", async () => { + const root = await tempRoot("follower-failure-authority-lost"); + const candidate = manifest("1.1.0"); + const successor = manifest("1.2.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + ihep.putLatestUnconditional = async () => { + ihep.latestPutAttempts += 1; + ihep.unconditionalLatestPutAttempts += 1; + r2.set( + "latest.json", + `${JSON.stringify(successor)}\n`, + { "cam-promotion-token": "newer-authority" }, + ); + throw new Error("simulated IHEP outage after R2 supersession"); + }; + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "older-authority", + summary, + workDir: join(root, "transaction"), + }), + ).rejects.toThrow("rollback incomplete"); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("1.2.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.0.0"); + expect(r2.conditionalLatestPutAttempts).toBe(1); + expect(ihep.conditionalLatestPutAttempts).toBe(0); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe( + "skipped-concurrent-change", + ); + }); + + it("preserves a concurrent newer IHEP value while rolling back owned R2", async () => { + const root = await tempRoot("preserve-concurrent-follower"); + const candidate = manifest("1.1.0"); + const followerSuccessor = manifest("1.2.0"); + const candidatePath = await writeManifest(root, "candidate.json", candidate); + const initial = `${JSON.stringify(manifest("1.0.0"))}\n`; + const r2 = new MemoryBackend("r2", { "latest.json": initial }); + const ihep = new ConditionIgnoringMemoryBackend("ihep", { "latest.json": initial }); + const backends = [r2, ihep]; + const summary = summaryFor(backends); + + await expect( + promoteCandidateTransaction({ + backends, + candidateManifest: candidate, + candidatePath, + override: overrideOff(), + promotionToken: "owned-follower", + summary, + workDir: join(root, "transaction"), + hooks: { + afterWrite: (state) => { + if (state.backend.name === "ihep") { + ihep.set( + "latest.json", + `${JSON.stringify(followerSuccessor)}\n`, + { "cam-promotion-token": "newer-follower" }, + ); + throw new Error("simulated failure after concurrent IHEP advance"); + } + }, + }, + }), + ).rejects.toThrow("simulated failure after concurrent IHEP advance"); + + expect(JSON.parse(r2.body("latest.json")).version).toBe("1.0.0"); + expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.2.0"); + expect(summary.backends.find((backend) => backend.name === "r2").rollback).toBe("restored"); + expect(summary.backends.find((backend) => backend.name === "ihep").rollback).toBe( + "preserved-concurrent-change", + ); + }); + it("never rolls back an ETag that a concurrent writer owns", async () => { const root = await tempRoot("etag-ownership"); const current = manifest("1.0.0"); @@ -1013,7 +1633,7 @@ describe("monotonic mirror promotion", () => { summary, workDir: join(root, "transaction"), }), - ).rejects.toBeInstanceOf(ConditionalWriteError); + ).rejects.toThrow("rollback incomplete"); expect(JSON.parse(r2.body("latest.json")).version).toBe("1.2.0"); expect(JSON.parse(ihep.body("latest.json")).version).toBe("1.0.0"); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 7f919f6..d223a6f 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -21,6 +21,28 @@ describe("release workflow recovery invariants", () => { ); }); + it("enforces the single-writer boundary for the unconditional IHEP follower", () => { + expect(workflow).toContain( + "correctness boundary for the unconditional IHEP follower", + ); + expect(releaseJob).toContain("Enforce legacy mirror credential revocation"); + expect(releaseJob).toContain("MANAGER_R2_PROMOTION_ACCESS_KEY_ID"); + expect(releaseJob).toContain("MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID"); + expect(releaseJob).toContain( + "MANAGER_IHEP_S3_ENDPOINT: ${{ vars.MANAGER_IHEP_S3_ENDPOINT }}", + ); + expect(releaseJob).toContain( + "MANAGER_IHEP_S3_BUCKET: ${{ vars.MANAGER_IHEP_S3_BUCKET }}", + ); + expect(releaseJob).not.toContain( + "MANAGER_IHEP_S3_ENDPOINT: ${{ secrets.MANAGER_IHEP_S3_ENDPOINT }}", + ); + expect(mirrorRelease).toContain( + "followerCommit = await ihep.putLatestUnconditional(candidatePath, promotionToken)", + ); + expect(mirrorRelease).not.toContain("await ihep.putLatestConditional("); + }); + it("refreshes immutable Release state inside the release job on failed-job reruns", () => { expect(releaseJob).toContain("id: live_release"); expect(releaseJob).toContain( diff --git a/scripts/write-release-summary.mjs b/scripts/write-release-summary.mjs index 22f11bf..0c14802 100644 --- a/scripts/write-release-summary.mjs +++ b/scripts/write-release-summary.mjs @@ -114,8 +114,8 @@ if (mirrorStage || mirrorVerification || mirrorPromotion) { ); } rows.push(""); - rows.push("| Backend | Candidate verification | Previous | Decision | Promotion | Rollback | Final | Error |"); - rows.push("| --- | --- | --- | --- | --- | --- | --- | --- |"); + rows.push("| Backend | Candidate verification | Previous | Decision | Promotion | Supersession | Rollback | Final | Error |"); + rows.push("| --- | --- | --- | --- | --- | --- | --- | --- | --- |"); const backendRows = mirrorPromotion?.backends || mirrorVerification?.backends || mirrorStage?.backends || []; for (const backend of backendRows) { @@ -123,6 +123,8 @@ if (mirrorStage || mirrorVerification || mirrorPromotion) { `| ${cell(backend.name)} | ${cell(backend.candidateVerification || backend.status)} | ${cell( backend.currentVersion || "absent", )} | ${cell(backend.decision)} | ${cell(backend.promotion)} | ${cell( + backend.supersession, + )} | ${cell( backend.rollback, )} | ${cell(backend.finalVersion)} | ${cell( backend.error || backend.rollbackError, @@ -146,7 +148,11 @@ if (mirrorStage || mirrorVerification || mirrorPromotion) { } else { rows.push("Emergency downgrade override: not requested."); } - if (mirrorPromotion?.rollback?.complete === false) { + if (mirrorPromotion?.authorityPreserved) { + rows.push( + "**Authority preserved:** R2 kept its verified CAS commit, but IHEP did not expose the exact canonical candidate identity. The follower was not overwritten and this run failed closed.", + ); + } else if (mirrorPromotion?.rollback?.complete === false) { rows.push( "**Manual intervention required:** promotion rollback was incomplete; inspect the per-backend errors before another release.", ); From 47ee1e7bac801b80a3edde0db542952408cef88e Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:36:46 +0800 Subject: [PATCH 06/21] fix(release): validate immutable publication provenance --- .github/workflows/release.yml | 213 ++++++++++++++++++++++++------ scripts/check-release-version.mjs | 177 +++++++++++++++++++++++++ scripts/release-workflow.test.mjs | 155 +++++++++++++++++++--- scripts/write-release-summary.mjs | 3 +- 4 files changed, 488 insertions(+), 60 deletions(-) create mode 100644 scripts/check-release-version.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 295815f..1b46d7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,8 +49,7 @@ on: inputs: target_tag: description: "Existing vX.Y.Z release to republish through the current protected workflow" - required: false - default: "" + required: true type: string allow_mirror_downgrade: description: "Emergency only: allow this tag to replace a newer mirror latest.json" @@ -79,8 +78,55 @@ concurrency: queue: max jobs: + # A manual dispatch may execute only from the protected default branch and + # must name an existing target_tag. This also rejects an empty-input dispatch + # from an attacker-controlled branch named like a release tag. + # Keep the rejecting path free of checkout, environments, permissions, and + # secrets so an untrusted ref cannot reach any credential-bearing job. Compare + # the complete ref, not ref_name: a tag named like the default branch must fail. + reject_untrusted_target_dispatch: + if: ${{ github.event_name == 'workflow_dispatch' && github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Reject manual dispatch outside the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DISPATCH_REF: ${{ github.ref }} + run: | + echo "::error::target_tag must be dispatched from refs/heads/$DEFAULT_BRANCH, not $DISPATCH_REF" + exit 1 + + preflight: + if: ${{ github.event_name != 'workflow_dispatch' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Validate release invocation without secrets + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DISPATCH_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$DISPATCH_REF" != "refs/heads/$DEFAULT_BRANCH" ]]; then + echo "::error::workflow_dispatch must run from refs/heads/$DEFAULT_BRANCH, not $DISPATCH_REF" + exit 1 + fi + if [[ "$EVENT_NAME" == "workflow_dispatch" && -z "$REQUESTED_TARGET_TAG" ]]; then + echo "::error::workflow_dispatch requires a non-empty target_tag" + exit 1 + fi + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG" + exit 1 + fi + prepare: - if: ${{ startsWith(inputs.target_tag || github.ref_name, 'v') }} + needs: preflight + if: ${{ needs.preflight.result == 'success' }} runs-on: ubuntu-latest environment: release permissions: @@ -97,6 +143,26 @@ jobs: with: node-version: 20 + # Verify the release source rather than the workflow source. For a + # target_tag recovery the workflow itself comes from the protected default + # branch, while the six application version declarations come from the tag. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ env.RELEASE_TAG }} + path: release-source + persist-credentials: false + + - name: Match release tag to every application version declaration + shell: bash + run: | + set -euo pipefail + node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source + release_source_sha="$(git -C release-source rev-parse HEAD)" + if [[ "$GITHUB_EVENT_NAME" == "push" && "$release_source_sha" != "$GITHUB_SHA" ]]; then + echo "::error::release tag $RELEASE_TAG moved away from triggering commit $GITHUB_SHA" + exit 1 + fi + - name: Require GitHub Immutable Releases env: GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }} @@ -106,16 +172,10 @@ jobs: id: lookup shell: bash env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - DISPATCH_REF_NAME: ${{ github.ref_name }} GH_TOKEN: ${{ github.token }} REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }} run: | set -euo pipefail - if [[ -n "$REQUESTED_TARGET_TAG" && "$DISPATCH_REF_NAME" != "$DEFAULT_BRANCH" ]]; then - echo "::error::target_tag must be dispatched from the default branch ($DEFAULT_BRANCH), not $DISPATCH_REF_NAME" - exit 1 - fi if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG" exit 1 @@ -492,6 +552,27 @@ jobs: with: node-version: 20 + # Re-run this gate inside every release-job attempt. GitHub's "Re-run + # failed jobs" does not re-run prepare, and no immutable publication may + # rely only on a previous attempt's version decision. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ env.RELEASE_TAG }} + path: release-source + persist-credentials: false + + - name: Re-check release tag against application versions + shell: bash + run: | + set -euo pipefail + node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source + release_source_sha="$(git -C release-source rev-parse HEAD)" + if [[ "$GITHUB_EVENT_NAME" == "push" && "$release_source_sha" != "$GITHUB_SHA" ]]; then + echo "::error::release tag $RELEASE_TAG moved away from triggering commit $GITHUB_SHA" + exit 1 + fi + echo "RELEASE_SOURCE_SHA=$release_source_sha" >> "$GITHUB_ENV" + # Re-check on every release-job attempt. `prepare` is not rerun by # "Re-run failed jobs", and publishing a resumed draft while the setting is # disabled would create a mutable canonical source. @@ -640,6 +721,7 @@ jobs: run: | echo "::group::[build] validate final publishable artifacts" set -euo pipefail + node scripts/check-release-version.mjs artifacts "$RELEASE_TAG" dist ls -la dist missing=0 require() { @@ -686,12 +768,10 @@ jobs: if [[ "${{ steps.release_source.outputs.existing }}" == "true" ]]; then cp "$RUNNER_TEMP/published-latest.json" latest.json fi - if [[ "$RELEASE_TAG" != *-* ]]; then - node scripts/validate-release-manifest.mjs \ - "$RELEASE_TAG" \ - latest.json \ - "$RUNNER_TEMP/artifact-derived-latest.json" - fi + node scripts/validate-release-manifest.mjs \ + "$RELEASE_TAG" \ + latest.json \ + "$RUNNER_TEMP/artifact-derived-latest.json" # A wrong private/public updater-key pairing cannot be repaired after an # immutable GitHub Release is published. Verify the exact local payloads @@ -704,12 +784,68 @@ jobs: dist \ "$RELEASE_TAURI_CONFIG" + # Create provenance only for bytes produced by this release run, before a + # draft can become immutable. A later failed-job rerun must never mint a + # new statement that falsely attributes already-published bytes to the + # rerun's OIDC identity. + - name: Attest fresh build provenance + id: attest_fresh + if: ${{ steps.release_source.outputs.existing != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + dist/* + latest.json + + # Existing immutable releases are accepted only when their downloaded, + # API-digest-pinned bytes already carry provenance from this repository's + # release workflow and exact tag ref. This is verification, not a new OIDC + # statement; missing historical provenance fails closed. + - name: Verify existing immutable release provenance + id: verify_existing_provenance + if: ${{ steps.release_source.outputs.existing == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + signer_workflow="$GITHUB_REPOSITORY/.github/workflows/release.yml" + for file in dist/* latest.json; do + gh attestation verify "$file" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$signer_workflow" \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$RELEASE_SOURCE_SHA" \ + --deny-self-hosted-runners >/dev/null + echo "Verified existing provenance: $(basename "$file")" + done + + - name: Resolve provenance gate + id: provenance + shell: bash + env: + FRESH_ATTESTATION_OUTCOME: ${{ steps.attest_fresh.outcome }} + EXISTING_PROVENANCE_OUTCOME: ${{ steps.verify_existing_provenance.outcome }} + RELEASE_REUSED: ${{ steps.release_source.outputs.existing }} + run: | + set -euo pipefail + if [[ "$RELEASE_REUSED" == "true" ]]; then + [[ "$FRESH_ATTESTATION_OUTCOME" == "skipped" ]] + [[ "$EXISTING_PROVENANCE_OUTCOME" == "success" ]] + echo "mode=verified-existing-immutable-release" >> "$GITHUB_OUTPUT" + else + [[ "$FRESH_ATTESTATION_OUTCOME" == "success" ]] + [[ "$EXISTING_PROVENANCE_OUTCOME" == "skipped" ]] + echo "mode=fresh-build-attested-before-publication" >> "$GITHUB_OUTPUT" + fi + echo "ready=true" >> "$GITHUB_OUTPUT" + # Stable releases stage immutable versioned assets before GitHub Release # publication, verify them separately, then promote latest.json only after # the GitHub Release succeeds. Pre-releases still skip the mirror entirely. - name: Stage CDN mirror candidate (R2 + IHEP S3) id: mirror_stage - if: ${{ !contains(env.RELEASE_TAG, '-') }} + if: ${{ steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }} env: MIRROR_PHASE: stage MIRROR_REQUIRED_BACKENDS: r2,ihep @@ -742,7 +878,7 @@ jobs: # branches must pass before GitHub publication becomes immutable. - name: Verify staged CDN mirror before immutable publication id: mirror_verify - if: ${{ !contains(env.RELEASE_TAG, '-') }} + if: ${{ steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }} env: MIRROR_PHASE: verify MIRROR_REQUIRED_BACKENDS: r2,ihep @@ -829,7 +965,7 @@ jobs: # releases are enabled: a published release can no longer accept assets. - name: Upload GitHub Release draft id: upload_release_draft - if: ${{ steps.release_source.outputs.existing != 'true' }} + if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }} uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: tag_name: ${{ env.RELEASE_TAG }} @@ -851,7 +987,7 @@ jobs: # after all uploads succeeded while retaining its stable/prerelease metadata. - name: Publish GitHub Release id: publish_release - if: ${{ steps.release_source.outputs.existing != 'true' }} + if: ${{ steps.provenance.outputs.ready == 'true' && steps.release_source.outputs.existing != 'true' }} uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: tag_name: ${{ env.RELEASE_TAG }} @@ -884,27 +1020,25 @@ jobs: exit 1 fi cat "$verification_log" - - # Attest immediately after GitHub confirms the canonical immutable bytes, - # before mirror promotion can fail the job. Keep the subjects limited to - # updater payloads and latest.json: on a failed-job rerun those are fetched - # from the immutable Release and checked against GitHub's canonical SHA-256 - # digests. SHA256SUMS and SBOMs are intentionally excluded because they are - # regenerated locally and are not necessarily the bytes already published. - # A failed attestation must fail the job so the release stays retryable and - # neither mirror promotion nor winget dispatch can proceed without proof. - - name: Attest build provenance - id: attest - if: ${{ steps.publish_release.outcome == 'success' || steps.release_source.outputs.existing == 'true' }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: | - dist/* - latest.json + release_asset_digests="$(sed -n 's/^release_asset_digests=//p' "$verification_output")" + jq -e 'type == "object" and length > 0' <<<"$release_asset_digests" >/dev/null || { + echo "::error::published immutable digest evidence is missing" + exit 1 + } + for file in dist/* latest.json; do + name="$(basename "$file")" + expected_digest="$(jq -er --arg name "$name" '.[$name]' <<<"$release_asset_digests")" + actual_digest="sha256:$(sha256sum "$file" | cut -d' ' -f1)" + if [[ "$actual_digest" != "$expected_digest" ]]; then + echo "::error::published immutable digest does not match attested local bytes: $name" + exit 1 + fi + echo "Verified published attested digest: $name" + done - name: Promote CDN mirror latest (R2 + IHEP S3) id: mirror_promote - if: ${{ success() && steps.attest.outcome == 'success' && !contains(env.RELEASE_TAG, '-') }} + if: ${{ success() && steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }} env: MIRROR_PHASE: promote MIRROR_REQUIRED_BACKENDS: r2,ihep @@ -934,7 +1068,7 @@ jobs: # so a re-run is harmless. - name: Trigger winget submission id: winget - if: ${{ success() && steps.attest.outcome == 'success' && !contains(env.RELEASE_TAG, '-') }} + if: ${{ success() && steps.provenance.outputs.ready == 'true' && !contains(env.RELEASE_TAG, '-') }} env: GH_TOKEN: ${{ github.token }} run: | @@ -954,7 +1088,8 @@ jobs: MIRROR_PROMOTE_OUTCOME: ${{ steps.mirror_promote.outcome }} WINGET_OUTCOME: ${{ steps.winget.outputs.status || steps.winget.outcome }} SBOM_OUTCOME: ${{ steps.sbom.outcome }} - ATTESTATION_OUTCOME: ${{ steps.attest.outcome }} + ATTESTATION_OUTCOME: ${{ steps.attest_fresh.outcome }} + PROVENANCE_MODE: ${{ steps.provenance.outputs.mode }} run: node scripts/write-release-summary.mjs - name: Upload release audit records diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..30a8441 --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { requiredReleaseAssetNames } from "./check-release-reuse.mjs"; + +const RELEASE_TAG_PATTERN = + /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export function releaseVersionFromTag(releaseTag) { + const tag = String(releaseTag); + if (!RELEASE_TAG_PATTERN.test(tag)) { + throw new Error(`release tag must be a semantic vX.Y.Z tag: ${releaseTag}`); + } + return tag.slice(1); +} + +function readJson(path, label) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`cannot read ${label}: ${detail}`); + } +} + +function quotedTomlValue(block, key, label) { + const matches = [...block.matchAll(new RegExp(`^${key}\\s*=\\s*"([^"]+)"\\s*(?:#.*)?$`, "gm"))]; + if (matches.length !== 1) { + throw new Error(`${label} must contain exactly one quoted ${key}`); + } + return matches[0][1]; +} + +function cargoPackageBlock(cargoToml, label) { + const match = /^\[package\]\s*$/m.exec(cargoToml); + if (!match) { + throw new Error(`${label} has no [package] table`); + } + const start = match.index + match[0].length; + const rest = cargoToml.slice(start); + const nextTable = /^\s*\[/m.exec(rest); + return nextTable ? rest.slice(0, nextTable.index) : rest; +} + +function cargoLockPackageVersion(cargoLock, packageName) { + const blocks = cargoLock.split(/^\[\[package\]\]\s*$/m).slice(1); + const matches = blocks.filter((block) => { + try { + return quotedTomlValue(block, "name", "Cargo.lock package") === packageName; + } catch { + return false; + } + }); + if (matches.length !== 1) { + throw new Error(`Cargo.lock must contain exactly one ${packageName} package block`); + } + return quotedTomlValue(matches[0], "version", `Cargo.lock ${packageName} package`); +} + +export function readReleaseSourceVersions(sourceRoot) { + const root = resolve(sourceRoot); + const packageJson = readJson(join(root, "package.json"), "package.json"); + const packageLock = readJson(join(root, "package-lock.json"), "package-lock.json"); + const tauriConfig = readJson( + join(root, "src-tauri", "tauri.conf.json"), + "src-tauri/tauri.conf.json", + ); + const cargoTomlPath = join(root, "src-tauri", "Cargo.toml"); + const cargoLockPath = join(root, "src-tauri", "Cargo.lock"); + const cargoToml = readFileSync(cargoTomlPath, "utf8"); + const cargoLock = readFileSync(cargoLockPath, "utf8"); + + return [ + ["package.json#version", packageJson.version], + ["package-lock.json#version", packageLock.version], + ['package-lock.json#packages[""].version', packageLock.packages?.[""]?.version], + ["src-tauri/tauri.conf.json#version", tauriConfig.version], + [ + "src-tauri/Cargo.toml#[package].version", + quotedTomlValue( + cargoPackageBlock(cargoToml, "src-tauri/Cargo.toml"), + "version", + "src-tauri/Cargo.toml [package]", + ), + ], + [ + 'src-tauri/Cargo.lock#[[package]] name="codex-app-manager".version', + cargoLockPackageVersion(cargoLock, "codex-app-manager"), + ], + ]; +} + +export function assertReleaseSourceVersions(releaseTag, sourceRoot) { + const expected = releaseVersionFromTag(releaseTag); + const entries = readReleaseSourceVersions(sourceRoot); + const mismatches = entries.filter(([, version]) => version !== expected); + if (mismatches.length > 0) { + const detail = mismatches + .map(([label, version]) => `${label}=${JSON.stringify(version)}`) + .join(", "); + throw new Error( + `release tag ${releaseTag} requires application version ${expected}; mismatched source versions: ${detail}`, + ); + } + return { entries, version: expected }; +} + +export function assertLocalReleaseArtifactNames(releaseTag, artifactsDir) { + const version = releaseVersionFromTag(releaseTag); + const expected = requiredReleaseAssetNames(releaseTag).filter((name) => name !== "latest.json"); + const dir = resolve(artifactsDir); + const files = readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + const missing = expected.filter((name) => { + const path = join(dir, name); + return !existsSync(path) || !statSync(path).isFile() || statSync(path).size <= 0; + }); + if (missing.length > 0) { + throw new Error( + `release ${releaseTag} is missing exact local artifact names: ${missing.join(", ")}`, + ); + } + + const updaterInstallers = files.filter((name) => + /_(?:x64|arm64)-setup\.(?:exe|nsis\.zip)(?:\.sig)?$/.test(name), + ); + const wrongVersion = updaterInstallers.filter( + (name) => !name.startsWith(`CodexAppManager_${version}_`), + ); + if (wrongVersion.length > 0) { + throw new Error( + `release ${releaseTag} contains installer artifacts for another version: ${wrongVersion.join(", ")}`, + ); + } + + const unexpected = files.filter( + (name) => name.startsWith("CodexAppManager") && !expected.includes(name), + ); + if (unexpected.length > 0) { + throw new Error( + `release ${releaseTag} contains unexpected local artifact names: ${unexpected.join(", ")}`, + ); + } + + return { expected, version }; +} + +const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + const [, , mode, releaseTag, path] = process.argv; + try { + if (!mode || !releaseTag || !path || !["source", "artifacts"].includes(mode)) { + throw new Error( + "usage: check-release-version.mjs ", + ); + } + if (mode === "source") { + const result = assertReleaseSourceVersions(releaseTag, path); + console.log( + `[version] ${releaseTag} matches all ${result.entries.length} application version declarations`, + ); + } else { + const result = assertLocalReleaseArtifactNames(releaseTag, path); + console.log( + `[version] ${releaseTag} matches all ${result.expected.length} exact local release artifact names`, + ); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`::error::[version] ${detail}`); + process.exitCode = 1; + } +} diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index d223a6f..113f341 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,6 +9,11 @@ import { assertImmutableReleasesEnabled, verifyImmutableReleases, } from "./check-immutable-releases.mjs"; +import { requiredReleaseAssetNames } from "./check-release-reuse.mjs"; +import { + assertLocalReleaseArtifactNames, + assertReleaseSourceVersions, +} from "./check-release-version.mjs"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const workflow = await readFile(join(repoRoot, ".github/workflows/release.yml"), "utf8"); @@ -21,6 +27,93 @@ describe("release workflow recovery invariants", () => { ); }); + it("rejects every manual dispatch outside the full default-branch ref before checkout or secrets", () => { + const dispatchInputs = workflow.slice( + workflow.indexOf(" workflow_dispatch:\n"), + workflow.indexOf("\npermissions:\n"), + ); + const reject = workflow.slice( + workflow.indexOf(" reject_untrusted_target_dispatch:\n"), + workflow.indexOf(" preflight:\n"), + ); + const preflight = workflow.slice( + workflow.indexOf(" preflight:\n"), + workflow.indexOf(" prepare:\n"), + ); + const prepare = workflow.slice( + workflow.indexOf(" prepare:\n"), + workflow.indexOf(" build:\n"), + ); + + expect(reject).toContain("github.event_name == 'workflow_dispatch'"); + expect(reject).toContain( + "github.ref != format('refs/heads/{0}', github.event.repository.default_branch)", + ); + expect(reject).not.toContain("inputs.target_tag != ''"); + expect(reject).not.toContain("actions/checkout@"); + expect(reject).not.toContain("environment:"); + expect(reject).not.toContain("${{ secrets."); + expect(dispatchInputs).toMatch(/target_tag:\n\s+description:.*\n\s+required: true/); + + expect(preflight).toContain( + "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)", + ); + expect(preflight).toContain("workflow_dispatch requires a non-empty target_tag"); + expect(preflight).not.toContain("actions/checkout@"); + expect(preflight).not.toContain("environment:"); + expect(preflight).not.toContain("${{ secrets."); + expect(prepare).toContain("needs: preflight"); + expect(prepare).toContain("needs.preflight.result == 'success'"); + }); + + it("binds the release tag to all six source versions and exact local artifact names", async () => { + const packageJson = JSON.parse(await readFile(join(repoRoot, "package.json"), "utf8")); + const releaseTag = `v${packageJson.version}`; + const source = assertReleaseSourceVersions(releaseTag, repoRoot); + expect(source.entries).toHaveLength(6); + expect(() => assertReleaseSourceVersions("v9.9.9", repoRoot)).toThrow( + "mismatched source versions", + ); + + const artifactsDir = await mkdtemp(join(tmpdir(), "cam-release-artifacts-")); + try { + const expected = requiredReleaseAssetNames(releaseTag).filter( + (name) => name !== "latest.json", + ); + await Promise.all(expected.map((name) => writeFile(join(artifactsDir, name), "x"))); + expect(assertLocalReleaseArtifactNames(releaseTag, artifactsDir).expected).toEqual( + expected, + ); + + await writeFile(join(artifactsDir, "CodexAppManager_9.9.9_x64-setup.exe"), "x"); + expect(() => assertLocalReleaseArtifactNames(releaseTag, artifactsDir)).toThrow( + "installer artifacts for another version", + ); + } finally { + await rm(artifactsDir, { force: true, recursive: true }); + } + + const prepare = workflow.slice( + workflow.indexOf(" prepare:\n"), + workflow.indexOf(" build:\n"), + ); + expect(prepare).toContain('ref: ${{ env.RELEASE_TAG }}'); + expect(prepare).toContain( + 'node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source', + ); + expect(releaseJob).toContain( + 'node scripts/check-release-version.mjs artifacts "$RELEASE_TAG" dist', + ); + const manifestStep = releaseJob.slice( + releaseJob.indexOf("- name: Generate updater manifest (latest.json)"), + releaseJob.indexOf( + "- name: Verify local updater signatures before immutable publication", + ), + ); + expect(manifestStep).toContain("node scripts/validate-release-manifest.mjs"); + expect(manifestStep).not.toContain('if [[ "$RELEASE_TAG" != *-* ]]'); + }); + it("enforces the single-writer boundary for the unconditional IHEP follower", () => { expect(workflow).toContain( "correctness boundary for the unconditional IHEP follower", @@ -112,6 +205,11 @@ describe("release workflow recovery invariants", () => { const localVerify = releaseJob.indexOf( "- name: Verify local updater signatures before immutable publication", ); + const attest = releaseJob.indexOf("- name: Attest fresh build provenance"); + const verifyExisting = releaseJob.indexOf( + "- name: Verify existing immutable release provenance", + ); + const provenance = releaseJob.indexOf("- name: Resolve provenance gate"); const stage = releaseJob.indexOf("- name: Stage CDN mirror candidate"); const mirrorVerify = releaseJob.indexOf( "- name: Verify staged CDN mirror before immutable publication", @@ -121,24 +219,27 @@ describe("release workflow recovery invariants", () => { const publishedVerify = releaseJob.indexOf( "- name: Verify published immutable Release and asset digests", ); - const attest = releaseJob.indexOf("- name: Attest build provenance"); const promote = releaseJob.indexOf("- name: Promote CDN mirror latest"); const winget = releaseJob.indexOf("- name: Trigger winget submission"); const summary = releaseJob.indexOf("- name: Write release summary"); expect(localVerify).toBeGreaterThan(-1); - expect(stage).toBeGreaterThan(localVerify); + expect(attest).toBeGreaterThan(localVerify); + expect(verifyExisting).toBeGreaterThan(attest); + expect(provenance).toBeGreaterThan(verifyExisting); + expect(stage).toBeGreaterThan(provenance); expect(mirrorVerify).toBeGreaterThan(stage); expect(upload).toBeGreaterThan(mirrorVerify); expect(upload).toBeGreaterThan(-1); expect(publish).toBeGreaterThan(upload); expect(publishedVerify).toBeGreaterThan(publish); - expect(attest).toBeGreaterThan(publishedVerify); - expect(promote).toBeGreaterThan(attest); + expect(promote).toBeGreaterThan(publishedVerify); const uploadStep = releaseJob.slice(upload, publish); const publishStep = releaseJob.slice(publish, publishedVerify); - const verifyStep = releaseJob.slice(publishedVerify, attest); - const attestStep = releaseJob.slice(attest, promote); + const verifyStep = releaseJob.slice(publishedVerify, promote); + const attestStep = releaseJob.slice(attest, verifyExisting); + const existingStep = releaseJob.slice(verifyExisting, provenance); + const provenanceStep = releaseJob.slice(provenance, stage); const promoteStep = releaseJob.slice(promote, winget); const wingetStep = releaseJob.slice(winget, summary); const localVerifyStep = releaseJob.slice(localVerify, stage); @@ -147,40 +248,54 @@ describe("release workflow recovery invariants", () => { expect(mirrorVerifyStep).toContain("MIRROR_PHASE: verify"); expect(mirrorVerifyStep).toContain("bash scripts/sync-mirror.sh dist"); expect(uploadStep).toContain("draft: true"); + expect(uploadStep).toContain("steps.provenance.outputs.ready == 'true'"); expect(uploadStep).toContain("prerelease: ${{ contains(env.RELEASE_TAG, '-') }}"); expect(uploadStep).toContain("files: |"); expect(publishStep).not.toMatch(/^\s+draft:/m); expect(publishStep).not.toContain("files: |"); + expect(publishStep).toContain("steps.provenance.outputs.ready == 'true'"); expect(verifyStep).toContain("node scripts/check-release-reuse.mjs"); expect(verifyStep).toContain("did not become immutable with canonical asset digests"); - expect(attestStep).toContain( - "steps.publish_release.outcome == 'success' || steps.release_source.outputs.existing == 'true'", - ); + expect(verifyStep).toContain("published immutable digest does not match attested local bytes"); + expect(attestStep).toContain("steps.release_source.outputs.existing != 'true'"); expect(attestStep).toContain("actions/attest-build-provenance@"); expect(attestStep).not.toContain("continue-on-error: true"); - expect(promoteStep).toContain("steps.attest.outcome == 'success'"); - expect(wingetStep).toContain("steps.attest.outcome == 'success'"); + expect(existingStep).toContain("gh attestation verify"); + expect(existingStep).toContain('--signer-workflow "$signer_workflow"'); + expect(existingStep).toContain('--source-ref "refs/tags/$RELEASE_TAG"'); + expect(existingStep).toContain('--source-digest "$RELEASE_SOURCE_SHA"'); + expect(provenanceStep).toContain('echo "ready=true" >> "$GITHUB_OUTPUT"'); + expect(promoteStep).toContain("steps.provenance.outputs.ready == 'true'"); + expect(promoteStep).not.toContain("steps.attest_fresh.outcome"); + expect(wingetStep).toContain("steps.provenance.outputs.ready == 'true'"); + expect(wingetStep).not.toContain("steps.attest_fresh.outcome"); expect(releaseJob.slice(0, upload)).toContain( "rm -f dist/latest.mirror.json dist/latest.json", ); }); - it("attests only immutable Release bytes on a failed-job reuse", () => { + it("verifies existing immutable provenance without minting a rerun attestation", () => { const source = releaseJob.indexOf("- name: Resolve immutable release artifact source"); const validate = releaseJob.indexOf("- name: Validate final release artifacts"); - const attest = releaseJob.indexOf("- name: Attest build provenance"); - const promote = releaseJob.indexOf("- name: Promote CDN mirror latest"); + const attest = releaseJob.indexOf("- name: Attest fresh build provenance"); + const verifyExisting = releaseJob.indexOf( + "- name: Verify existing immutable release provenance", + ); + const provenance = releaseJob.indexOf("- name: Resolve provenance gate"); const sourceStep = releaseJob.slice(source, validate); - const attestStep = releaseJob.slice(attest, promote); + const attestStep = releaseJob.slice(attest, verifyExisting); + const existingStep = releaseJob.slice(verifyExisting, provenance); expect(sourceStep).toContain("gh release download"); expect(sourceStep).toContain("--pattern 'CodexAppManager*'"); expect(sourceStep).toContain("--pattern 'latest.json'"); expect(sourceStep).toContain('actual_digest="sha256:$(sha256sum "$file"'); expect(sourceStep).toContain('if [[ "$actual_digest" != "$expected_digest" ]]'); - expect(attestStep).toContain("dist/*"); - expect(attestStep).toContain("latest.json"); - expect(attestStep).not.toContain("SHA256SUMS"); - expect(attestStep).not.toContain("release-assets/*"); + expect(attestStep).toContain("steps.release_source.outputs.existing != 'true'"); + expect(attestStep).not.toContain("steps.release_source.outputs.existing == 'true'"); + expect(existingStep).toContain("steps.release_source.outputs.existing == 'true'"); + expect(existingStep).toContain("for file in dist/* latest.json"); + expect(existingStep).toContain("gh attestation verify"); + expect(existingStep).toContain("--deny-self-hosted-runners"); }); }); diff --git a/scripts/write-release-summary.mjs b/scripts/write-release-summary.mjs index 0c14802..8785983 100644 --- a/scripts/write-release-summary.mjs +++ b/scripts/write-release-summary.mjs @@ -176,7 +176,8 @@ rows.push(`| Mirror pre-publish verify | ${env.MIRROR_VERIFY_OUTCOME || "skipped rows.push(`| Mirror promote | ${env.MIRROR_PROMOTE_OUTCOME || "skipped"} |`); rows.push(`| winget dispatch | ${env.WINGET_OUTCOME || "skipped"} |`); rows.push(`| SBOM | ${env.SBOM_OUTCOME || "skipped"} |`); -rows.push(`| provenance attestation | ${env.ATTESTATION_OUTCOME || "skipped"} |`); +rows.push(`| fresh provenance attestation | ${env.ATTESTATION_OUTCOME || "skipped"} |`); +rows.push(`| provenance mode | ${env.PROVENANCE_MODE || "blocked"} |`); rows.push(""); rows.push( "winget dispatch only starts the downstream submission workflow; until the first package is accepted in microsoft/winget-pkgs, downstream failures are expected noise.", From dcfdfee84e5e3adbb27d1780ce0d56de99271d94 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:48:50 +0800 Subject: [PATCH 07/21] fix(delivery): harden signed update boundaries --- .github/workflows/ci.yml | 4 + .github/workflows/release.yml | 129 +- .github/workflows/win-installer-check.yml | 13 +- README.md | 20 +- cloudflare/manager-download-router/README.md | 14 +- crates/codex-mac-engine/Cargo.lock | 279 + crates/codex-mac-engine/Cargo.toml | 1 + crates/codex-mac-engine/src/download.rs | 68 +- crates/codex-mac-engine/src/network.rs | 34 +- crates/codex-mac-engine/src/sys.rs | 60 +- crates/codex-win-engine/Cargo.lock | 268 + crates/codex-win-engine/Cargo.toml | 1 + crates/codex-win-engine/src/download.rs | 57 +- crates/codex-win-engine/src/network.rs | 91 +- crates/codex-win-engine/src/sys.rs | 84 +- docs/code-signing-policy.md | 182 + docs/privacy.md | 140 + docs/release.md | 94 +- docs/releases/FALLBACK.md | 6 +- docs/releases/TEMPLATE.md | 6 +- docs/releases/v0.3.1.md | 6 +- docs/windows-signing.md | 269 +- eslint.config.js | 1 + scripts/assert-signpath-foundation-ready.ps1 | 24 + scripts/gen-updater-manifest.mjs | 24 + scripts/gen-updater-manifest.test.mjs | 57 + scripts/minisign-verify.mjs | 108 + scripts/minisign-verify.test.mjs | 110 + scripts/prepare-windows-authenticode.ps1 | 103 - scripts/release-identity-workflow.test.mjs | 16 + scripts/reuse-release-identity-signature.mjs | 172 + scripts/sign-windows-authenticode.ps1 | 129 - scripts/sync-mirror-identity.test.mjs | 92 + scripts/sync-mirror.sh | 69 +- scripts/verify-windows-authenticode.ps1 | 37 +- scripts/windows-packaged-smoke.ps1 | 12 +- src-tauri/Cargo.lock | 6 +- src-tauri/Cargo.toml | 10 +- src-tauri/installer/installer.nsi | 8 - src-tauri/src/app/logging.rs | 18 +- src-tauri/src/app/mac_update.rs | 84 +- src-tauri/src/app/staging.rs | 14 +- src-tauri/src/app/url_guard.rs | 21 + src-tauri/src/app/win_update.rs | 84 +- src-tauri/src/commands.rs | 555 +- src-tauri/src/errors.rs | 76 +- src/app/signingPolicyFiles.test.ts | 67 + .../tauri-plugin-updater-2.10.1/CHANGELOG.md | 210 + vendor/tauri-plugin-updater-2.10.1/Cargo.lock | 4580 +++++++++++++++++ vendor/tauri-plugin-updater-2.10.1/Cargo.toml | 185 + .../Cargo.toml.orig | 74 + .../tauri-plugin-updater-2.10.1/LICENSE.spdx | 20 + .../LICENSE_APACHE-2.0 | 177 + .../tauri-plugin-updater-2.10.1/LICENSE_MIT | 21 + vendor/tauri-plugin-updater-2.10.1/README.md | 102 + .../tauri-plugin-updater-2.10.1/SECURITY.md | 23 + .../tauri-plugin-updater-2.10.1/VENDORED.md | 48 + .../tauri-plugin-updater-2.10.1/api-iife.js | 1 + vendor/tauri-plugin-updater-2.10.1/banner.png | Bin 0 -> 57959 bytes vendor/tauri-plugin-updater-2.10.1/build.rs | 25 + .../guest-js/index.ts | 155 + .../tauri-plugin-updater-2.10.1/package.json | 29 + .../autogenerated/commands/check.toml | 13 + .../autogenerated/commands/download.toml | 13 + .../commands/download_and_install.toml | 13 + .../autogenerated/commands/install.toml | 13 + .../permissions/autogenerated/reference.md | 130 + .../permissions/default.toml | 18 + .../permissions/schemas/schema.json | 354 ++ .../rollup.config.js | 7 + .../src/commands.rs | 197 + .../tauri-plugin-updater-2.10.1/src/config.rs | 164 + .../tauri-plugin-updater-2.10.1/src/error.rs | 114 + vendor/tauri-plugin-updater-2.10.1/src/lib.rs | 240 + .../src/updater.rs | 1894 +++++++ .../tauri-plugin-updater-2.10.1/tsconfig.json | 4 + website/index.html | 11 + website/public/fonts/shs-bold.woff2 | Bin 242788 -> 247880 bytes website/public/fonts/shs-heavy.woff2 | Bin 237856 -> 243916 bytes website/scripts/subset-fonts.mjs | 7 +- website/src/locales/en.ts | 12 +- website/src/locales/zh.ts | 10 +- website/src/styles/sections.css | 20 + 83 files changed, 11860 insertions(+), 747 deletions(-) create mode 100644 docs/code-signing-policy.md create mode 100644 docs/privacy.md create mode 100644 scripts/assert-signpath-foundation-ready.ps1 create mode 100644 scripts/gen-updater-manifest.test.mjs create mode 100644 scripts/minisign-verify.mjs create mode 100644 scripts/minisign-verify.test.mjs delete mode 100644 scripts/prepare-windows-authenticode.ps1 create mode 100644 scripts/release-identity-workflow.test.mjs create mode 100644 scripts/reuse-release-identity-signature.mjs delete mode 100644 scripts/sign-windows-authenticode.ps1 create mode 100644 scripts/sync-mirror-identity.test.mjs create mode 100644 src/app/signingPolicyFiles.test.ts create mode 100644 vendor/tauri-plugin-updater-2.10.1/CHANGELOG.md create mode 100644 vendor/tauri-plugin-updater-2.10.1/Cargo.lock create mode 100644 vendor/tauri-plugin-updater-2.10.1/Cargo.toml create mode 100644 vendor/tauri-plugin-updater-2.10.1/Cargo.toml.orig create mode 100644 vendor/tauri-plugin-updater-2.10.1/LICENSE.spdx create mode 100644 vendor/tauri-plugin-updater-2.10.1/LICENSE_APACHE-2.0 create mode 100644 vendor/tauri-plugin-updater-2.10.1/LICENSE_MIT create mode 100644 vendor/tauri-plugin-updater-2.10.1/README.md create mode 100644 vendor/tauri-plugin-updater-2.10.1/SECURITY.md create mode 100644 vendor/tauri-plugin-updater-2.10.1/VENDORED.md create mode 100644 vendor/tauri-plugin-updater-2.10.1/api-iife.js create mode 100644 vendor/tauri-plugin-updater-2.10.1/banner.png create mode 100644 vendor/tauri-plugin-updater-2.10.1/build.rs create mode 100644 vendor/tauri-plugin-updater-2.10.1/guest-js/index.ts create mode 100644 vendor/tauri-plugin-updater-2.10.1/package.json create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/check.toml create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download.toml create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download_and_install.toml create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/install.toml create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/reference.md create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/default.toml create mode 100644 vendor/tauri-plugin-updater-2.10.1/permissions/schemas/schema.json create mode 100644 vendor/tauri-plugin-updater-2.10.1/rollup.config.js create mode 100644 vendor/tauri-plugin-updater-2.10.1/src/commands.rs create mode 100644 vendor/tauri-plugin-updater-2.10.1/src/config.rs create mode 100644 vendor/tauri-plugin-updater-2.10.1/src/error.rs create mode 100644 vendor/tauri-plugin-updater-2.10.1/src/lib.rs create mode 100644 vendor/tauri-plugin-updater-2.10.1/src/updater.rs create mode 100644 vendor/tauri-plugin-updater-2.10.1/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da040fe..8f7224c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,10 @@ jobs: run: cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings - name: Test (src-tauri) run: cargo test --manifest-path src-tauri/Cargo.toml --workspace + # The updater is a path-patched dependency, so its response-limit tests + # are not included in the application workspace test command above. + - name: Test vendored tauri updater bounds + run: cargo test --locked --manifest-path vendor/tauri-plugin-updater-2.10.1/Cargo.toml --lib # Path deps are not workspace members, so their unit/integration tests are # not covered by the step above. Run each engine crate explicitly so # mac/win pure-logic regressions fail required CI on every PR. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2b1185..91c8924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Release -# Tag-driven cross-platform release: -# build (Windows inside-out Authenticode; macOS unsigned) → macOS finalize -# (adaptive icon + inside-out Developer ID sign + notarize + staple + repackage) → collect → publish a -# GitHub Release with the Tauri updater manifest (latest.json). +# Tag-driven cross-platform release. Windows tag jobs currently fail closed at +# the SignPath readiness gate; therefore no release is published until the +# approved trusted-build integration replaces that gate. macOS continues to be +# built/finalized in the matrix, but the publish job requires every build job. # # Required repo secrets (Settings ▸ Secrets and variables ▸ Actions / release env): # APPLE_CERTIFICATE base64 of your "Developer ID Application" .p12 @@ -16,10 +16,11 @@ name: Release # TAURI_SIGNING_PRIVATE_KEY updater private key (string) # TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password (empty if none) # -# Required Windows Authenticode (release environment; see docs/windows-signing.md): -# WINDOWS_CERTIFICATE base64 of OV/EV code-signing .pfx -# WINDOWS_CERTIFICATE_PASSWORD password for that .pfx -# vars.WINDOWS_TIMESTAMP_URL optional RFC3161 timestamp URL override +# Windows Authenticode status and migration contract: +# docs/code-signing-policy.md + docs/windows-signing.md +# Do not add PFX secrets. SignPath Foundation approval and trusted-build +# integration are pending; scripts/assert-signpath-foundation-ready.ps1 blocks +# Windows tag jobs until a separately reviewed implementation replaces it. on: push: @@ -29,6 +30,13 @@ on: permissions: contents: read +# A rerun for the same tag must observe any first attempt before touching the +# immutable per-version mirror keys (especially release-identity.json.sig, +# whose trusted-comment timestamp makes a fresh signature byte-different). +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: build: if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} @@ -53,6 +61,17 @@ jobs: with: persist-credentials: false + # Intentionally has no bypass or success path. The Windows matrix failure + # prevents the dependent publish job from releasing a partial macOS-only + # set or silently shipping unsigned Windows artifacts. A future reviewed + # change must replace this step with the approved SignPath trusted-build + # request, manual approval, download, and verification flow. + - name: Assert SignPath Foundation readiness (fail closed) + if: matrix.os == 'windows' + shell: pwsh + run: | + & .\scripts\assert-signpath-foundation-ready.ps1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 20 @@ -94,38 +113,19 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KC" security list-keychains -d user -s "$KC" $(security list-keychains -d user | sed s/\"//g) - # ── Windows: import the release PFX and generate a private, per-job Tauri - # config. certificateThumbprint signs the main PE; NSIS receives the - # same RFC3161 command via !uninstfinalize, then Tauri signs the outer - # installer. A missing certificate is a hard release failure. - - name: Prepare Windows Authenticode (required) - id: windows_signing - if: matrix.os == 'windows' - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }} - run: | - & .\scripts\prepare-windows-authenticode.ps1 ` - -ConfigPath ".tauri-authenticode.conf.json" - - # macOS remains unsigned here and is finalized below. Windows consumes the - # generated config so all three PE layers are signed during bundling. - - name: Build (Tauri) + # Build without an Authenticode configuration. The fail-closed readiness + # step above makes this unreachable for Windows today; even if that guard + # were removed alone, the required signature gates below would reject the + # unsigned output. Do not infer a two-pass SignPath design from this order. + - name: Build (Tauri, unsigned before platform finalization) shell: bash # Bundling downloads toolchains on the fly (e.g. the NSIS archive from # GitHub) which occasionally 504s and fails the whole release. Retry a # few times so a transient download hiccup doesn't sink the run — cargo # output is cached, so a retry mostly just re-runs the bundling step. run: | - args=(--target "${{ matrix.target }}") - if [ "${{ matrix.os }}" = "windows" ]; then - [ -f "$TAURI_AUTHENTICODE_CONFIG" ] || { echo "::error::missing generated Authenticode config"; exit 1; } - args+=(--config "$TAURI_AUTHENTICODE_CONFIG") - fi for attempt in 1 2 3; do - npm run tauri build -- "${args[@]}" && exit 0 + npm run tauri build -- --target "${{ matrix.target }}" && exit 0 echo "::warning::tauri build attempt ${attempt} failed (often a transient NSIS/toolchain download, e.g. HTTP 504) — retrying in 20s" sleep 20 done @@ -192,11 +192,13 @@ jobs: Write-Host "::notice::ARM64 main PE asserted machine=0xAA64 (cross-build on x64). Install/signature/upgrade/uninstall smoke runs below; only native ARM64 app launch still needs ARM64 hardware or trusted virtualization." } - # ── Windows: mandatory Authenticode + RFC3161 gate ──────────────────── + # ── Windows: future SignPath Authenticode verification contract ────── # Tauri restores target/.../release/codex-app-manager.exe to its # unsigned, unpatched build input after bundling, so only the outer # installer is inspectable here. Packaged smoke installs each target and - # verifies the signed embedded main executable and uninstall.exe. + # verifies the signed embedded main executable and uninstall.exe. These + # steps are intentionally unreachable until the readiness blocker is + # replaced by an approved integration; unsigned output still fails. - name: Verify Windows installer Authenticode (required) if: matrix.os == 'windows' shell: pwsh @@ -206,9 +208,8 @@ jobs: & .\scripts\verify-windows-authenticode.ps1 ` -Path $setup.FullName ` -Mode required ` - -ExpectedThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT ` + -ExpectedSubject "SignPath Foundation" ` -RequireTimestamp ` - -SigningConfigPath $env:TAURI_AUTHENTICODE_CONFIG ` -Stage "sign-verify" - name: Verify signed installed app and uninstaller (all Windows targets) @@ -220,9 +221,8 @@ jobs: & .\scripts\windows-packaged-smoke.ps1 ` -Installer $setup.FullName ` -AuthenticodeMode required ` - -ExpectedThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT ` + -ExpectedSubject "SignPath Foundation" ` -RequireTimestamp ` - -SigningConfigPath $env:TAURI_AUTHENTICODE_CONFIG ` -SkipLaunch:$skipLaunch # ── Windows: sign the NSIS installer for the updater ────────────────── @@ -311,25 +311,10 @@ jobs: & .\scripts\verify-windows-authenticode.ps1 ` -Path $setup.FullName ` -Mode required ` - -ExpectedThumbprint $env:WINDOWS_CERTIFICATE_THUMBPRINT ` + -ExpectedSubject "SignPath Foundation" ` -RequireTimestamp ` - -SigningConfigPath $env:TAURI_AUTHENTICODE_CONFIG ` -Stage "publish-sign-verify" - - name: Remove temporary Windows signing material - if: always() && matrix.os == 'windows' - shell: pwsh - run: | - if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) { - Remove-Item -LiteralPath "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -ErrorAction SilentlyContinue - } - if ($env:WINDOWS_CERTIFICATE_TEMP_DIR -and (Test-Path -LiteralPath $env:WINDOWS_CERTIFICATE_TEMP_DIR)) { - Remove-Item -LiteralPath $env:WINDOWS_CERTIFICATE_TEMP_DIR -Recurse -Force -ErrorAction SilentlyContinue - } - if ($env:TAURI_AUTHENTICODE_CONFIG -and (Test-Path -LiteralPath $env:TAURI_AUTHENTICODE_CONFIG)) { - Remove-Item -LiteralPath $env:TAURI_AUTHENTICODE_CONFIG -Force -ErrorAction SilentlyContinue - } - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: bundle-${{ matrix.target }} @@ -356,6 +341,10 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 20 + cache: npm + + - name: Install release tooling + run: npm ci - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -403,6 +392,31 @@ jobs: ALLOW_PARTIAL_RELEASE: ${{ vars.ALLOW_PARTIAL_RELEASE }} run: node scripts/gen-updater-manifest.mjs "${{ github.ref_name }}" dist + - name: Prepare signed canonical release identity + env: + GITHUB_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -euo pipefail + status=0 + node scripts/reuse-release-identity-signature.mjs \ + release-identity.json release-identity.json.sig \ + src-tauri/tauri.conf.json || status=$? + if [ "$status" -eq 3 ]; then + echo "No reusable immutable identity signature; signing canonical identity." + npx tauri signer sign release-identity.json + elif [ "$status" -ne 0 ]; then + echo "::error::prior immutable release identity/signature is invalid or inconsistent" >&2 + exit "$status" + fi + test -s release-identity.json.sig + node scripts/minisign-verify.mjs \ + release-identity.json release-identity.json.sig \ + src-tauri/tauri.conf.json + cp release-identity.json release-identity.json.sig dist/ + # Stable releases mirror in two phases: stage immutable versioned assets # before GitHub Release publication, then promote latest.json only after # the GitHub Release succeeds. Pre-releases still skip the mirror entirely. @@ -423,7 +437,9 @@ jobs: run: | [ -f dist/latest.json ] || cp latest.json dist/latest.json bash scripts/sync-mirror.sh dist - rm -f dist/latest.mirror.json + # GitHub publishes root latest.json explicitly below. Keeping the + # staging copy under dist/* would submit the same immutable asset twice. + rm -f dist/latest.json dist/latest.mirror.json # ── release notes: the hand-written file from the version-bump PR # (docs/releases/.md, see docs/releases/TEMPLATE.md) wins; @@ -519,6 +535,7 @@ jobs: run: | [ -f dist/latest.json ] || cp latest.json dist/latest.json bash scripts/sync-mirror.sh dist + rm -f dist/latest.json dist/latest.mirror.json - name: Attest build provenance id: attest diff --git a/.github/workflows/win-installer-check.yml b/.github/workflows/win-installer-check.yml index 00dda68..c43e139 100644 --- a/.github/workflows/win-installer-check.yml +++ b/.github/workflows/win-installer-check.yml @@ -6,7 +6,8 @@ name: Windows installer check # install → first launch → upgrade → uninstall. # # Also probes Authenticode on the installer and installed PE files in -# non-blocking (optional) mode because pull requests never receive the PFX. +# non-blocking (optional) mode. Pull requests intentionally build unsigned; +# production signing remains blocked pending the SignPath Foundation migration. # # ARM64 is NOT smoke-tested here: GitHub-hosted runners are x64, and a # cross-built ARM64 PE is not runtime verification. See docs/windows-signing.md. @@ -27,8 +28,6 @@ on: - "package.json" - "package-lock.json" - "scripts/windows-*.ps1" - - "scripts/sign-windows-authenticode.ps1" - - "scripts/prepare-windows-authenticode.ps1" - "scripts/verify-windows-authenticode.ps1" - ".github/workflows/win-installer-check.yml" workflow_dispatch: @@ -64,9 +63,9 @@ jobs: # NSIS only — just needs to compile the template, resolve the brand images, # and link all languages. NO signing secrets here on purpose: this is a # pull_request trigger running PR-authored code (config/template/hooks), so - # exposing TAURI_SIGNING_PRIVATE_KEY / WINDOWS_CERTIFICATE would let a PR - # exfiltrate keys. createUpdaterArtifacts is off; Authenticode remains a - # release-time concern (release.yml) with mandatory inside-out signing. + # exposing TAURI_SIGNING_PRIVATE_KEY or any future SignPath credential + # would let a PR exfiltrate it. createUpdaterArtifacts is off; + # Authenticode remains a release-time concern (release.yml). - name: Build NSIS installer id: build shell: bash @@ -102,7 +101,7 @@ jobs: - name: Authenticode probe (optional / non-blocking) shell: pwsh env: - # Pull requests never receive the release PFX secret. + # Pull requests do not receive release-signing credentials. AUTHENTICODE_MODE: optional run: | & .\scripts\verify-windows-authenticode.ps1 ` diff --git a/README.md b/README.md index 0ac8d7d..cba16f5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

- 官网 · Official Website · 中文 · English + 官网 · Official Website · 代码签名政策 · Code signing policy · 隐私政策 · Privacy policy · 中文 · English

--- @@ -89,7 +89,7 @@ brew install --cask wangnov/tap/codex-app-manager | Windows x64 | `CodexAppManager_x64-setup.exe` | [⤓ 镜像下载](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows ARM64 | `CodexAppManager_arm64-setup.exe` | [⤓ 镜像下载](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -macOS 版本经 **Developer ID 签名 + Apple 公证**,首次打开不会被 Gatekeeper 拦截。Windows 安装器(`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`)当前**没有 Authenticode 代码签名**,首次运行可能出现 SmartScreen 提示;应用内自更新使用的 Tauri updater 签名只校验下载字节,不代表 Windows 发行者信任。详情见 [Windows signing and verification](docs/windows-signing.md)。 +macOS 版本经 **Developer ID 签名 + Apple 公证**,首次打开不会被 Gatekeeper 拦截。Windows 安装器(`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`)当前**没有 Authenticode 代码签名**,首次运行可能出现 SmartScreen 提示;应用内自更新使用的 Tauri updater 签名只校验下载字节,不代表 Windows 发行者信任。SignPath Foundation 仍在申请/迁移中,新的 Windows tag 发布在集成验证完成前保持 fail-closed。详情见 [代码签名政策](docs/code-signing-policy.md)与 [Windows signing and verification](docs/windows-signing.md)。 下载后建议用同一 GitHub Release 的 `SHA256SUMS` 核验文件。Windows 可用 PowerShell,macOS 可用 `shasum`: @@ -135,7 +135,7 @@ Manager 通过上游镜像管理 Codex 桌面应用本体: ### 发布流水线 -打 `v*` tag 触发 `release.yml`:四平台构建(瞬时下载失败自动重试)→ macOS inside-out Developer ID 签名 + 公证 + 重打 updater 包 → Windows updater 产物签名 → 发布 GitHub Release → 自动同步到 R2 + IHEP 镜像。 +打 `v*` tag 触发 `release.yml`。当前 Windows job 会在构建前由 SignPath Foundation readiness gate 主动失败,因此整个发布不会继续,也不会悄悄发布 unsigned Windows 或仅 macOS 的不完整版本。待申请获批并以真实工件验证 trusted-build、人工审批、NSIS 边界和最终签名后,再由独立 PR 接回四平台发布链路。 ### 生态:codex-app-mirror @@ -161,6 +161,11 @@ npm run tauri:build # 本地构建(未签名) - 不伪造或重算 Sparkle 签名(只字节级复制官方签名) - 不替代 OpenAI、Microsoft Store 的官方分发渠道 +## 政策与隐私 + +- [代码签名政策](docs/code-signing-policy.md):SignPath Foundation 申请状态、项目角色、逐次人工审批、可签工件边界与发布硬门。 +- [隐私政策](docs/privacy.md):Manager 自身与 Codex 的更新检查、镜像/代理网络行为、本地数据和第三方连接元数据。 + ## 致谢 - **[LINUX DO](https://linux.do/)** 社区 —— 安装体验、更新链路、问题反馈的讨论都汇聚于此。 @@ -221,7 +226,7 @@ Grab your platform's file from the [latest GitHub Release](https://github.com/Wa | Windows x64 | `CodexAppManager_x64-setup.exe` | [⤓ mirror](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows ARM64 | `CodexAppManager_arm64-setup.exe` | [⤓ mirror](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -The macOS builds are **Developer ID signed + Apple notarized**, so Gatekeeper won't block first launch. The Windows installers (`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`) are **not Authenticode-signed** yet, so SmartScreen may warn on first run; the Tauri updater signature used for in-app updates verifies bytes only and is not Windows publisher trust. See [Windows signing and verification](docs/windows-signing.md). +The macOS builds are **Developer ID signed + Apple notarized**, so Gatekeeper won't block first launch. The Windows installers (`CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe`) are **not Authenticode-signed** yet, so SmartScreen may warn on first run; the Tauri updater signature used for in-app updates verifies bytes only and is not Windows publisher trust. The SignPath Foundation application/migration is pending, and new Windows tag publication remains fail-closed until the integration is proven. See the [code signing policy](docs/code-signing-policy.md) and [Windows signing and verification](docs/windows-signing.md). After downloading, compare the file with `SHA256SUMS` from the same GitHub Release. Use PowerShell on Windows or `shasum` on macOS: @@ -267,7 +272,7 @@ Self-update and payload downloads share the `codexapp.agentsmirror.com` link, fr ### Release pipeline -Pushing a `v*` tag triggers `release.yml`: four-platform build (with automatic retry on transient download hiccups) → macOS inside-out Developer ID signing + notarization + updater repackage → Windows updater artifact signing → publish the GitHub Release → auto-sync to the R2 + IHEP mirror. +Pushing a `v*` tag triggers `release.yml`. Today the Windows jobs deliberately fail at the SignPath Foundation readiness gate before building, so the full release cannot proceed and cannot silently publish unsigned Windows or a macOS-only partial set. A separate reviewed PR will restore the four-platform path only after real artifacts prove the trusted build, manual approval, NSIS boundary, and final signatures. ### Ecosystem: codex-app-mirror @@ -293,6 +298,11 @@ npm run tauri:build # local (unsigned) build - Does not forge or recompute Sparkle signatures (official signatures are copied verbatim) - Is not a replacement for official OpenAI / Microsoft Store distribution +## Policies & privacy + +- [Code signing policy](docs/code-signing-policy.md):SignPath Foundation application state, project roles, per-release human approval, eligible-artifact boundaries, and release gates. +- [Privacy policy](docs/privacy.md):Manager/Codex update checks, mirror and proxy behavior, local data, and ordinary third-party connection metadata. + ## Acknowledgements - **[LINUX DO](https://linux.do/)** community — the home for feedback on install experience, update links, and bug reports. diff --git a/cloudflare/manager-download-router/README.md b/cloudflare/manager-download-router/README.md index 920e514..0e72b28 100644 --- a/cloudflare/manager-download-router/README.md +++ b/cloudflare/manager-download-router/README.md @@ -12,8 +12,13 @@ the manager's **own** bucket so the two never mix: a presigned **IHEP S3** URL, once the `SECONDARY_S3_*` secrets are set. Until then CN also falls back to R2 (still far better than GitHub). -The updater (`src-tauri/tauri.conf.json`) already checks -`…/manager/latest.json` first, GitHub second — no app change needed. +The updater (`src-tauri/tauri.conf.json`) checks `…/manager/latest.json` first +and GitHub second. A mirror manifest is accepted only after its version, +platform, artifact basename, release-note hash, updater signature, and SHA-256 +match the pinned-key-signed `/release-identity.json(.sig)` from the same +source. This keeps mainland-China discovery/download on the mirror without +letting the URL-rewritten (and therefore unsigned) `latest.json` invent a +candidate or replay an older signed package as a higher version. ## Why a separate latest.json on the mirror `latest.json`'s embedded signatures sign the artifact **bytes**, not the URL, so @@ -29,6 +34,11 @@ could serve a previous version's bytes against the new signature. (The seeded v0.1.8 predates this and lives at flat `…/manager/` keys — still self-consistent; v0.1.9+ use the versioned layout.) +`release-identity.json` and `release-identity.json.sig` are also immutable +per-version objects. Release reruns reuse a previously verified signature, and +the sync script rejects a byte-different overwrite (the minisign trusted-comment +timestamp otherwise makes a freshly generated signature differ on every run). + > ⚠️ The mirror's `latest.json` MUST be refreshed on every stable release, or the > first endpoint serves a stale version and **blocks** updates. That's why the > `release.yml` "Sync to CDN mirror" step exists. diff --git a/crates/codex-mac-engine/Cargo.lock b/crates/codex-mac-engine/Cargo.lock index 3f55b8c..f17dde6 100644 --- a/crates/codex-mac-engine/Cargo.lock +++ b/crates/codex-mac-engine/Cargo.lock @@ -58,6 +58,7 @@ dependencies = [ "roxmltree", "serde", "thiserror", + "url", "uuid", ] @@ -133,6 +134,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -175,6 +187,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -254,12 +275,115 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -301,6 +425,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.32" @@ -319,6 +449,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -335,6 +471,15 @@ dependencies = [ "spki", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -474,6 +619,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "spki" version = "0.7.3" @@ -484,6 +635,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -501,6 +658,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -521,6 +689,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.20.1" @@ -539,6 +717,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.3" @@ -753,12 +949,95 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/codex-mac-engine/Cargo.toml b/crates/codex-mac-engine/Cargo.toml index 80d1acf..dfa917e 100644 --- a/crates/codex-mac-engine/Cargo.toml +++ b/crates/codex-mac-engine/Cargo.toml @@ -14,6 +14,7 @@ base64 = "0.22" log = "0.4" uuid = { version = "1", features = ["v4"] } libc = "0.2" +url = "2.5" [[bin]] name = "mac_plan" diff --git a/crates/codex-mac-engine/src/download.rs b/crates/codex-mac-engine/src/download.rs index 745a914..94c4246 100644 --- a/crates/codex-mac-engine/src/download.rs +++ b/crates/codex-mac-engine/src/download.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use crate::limits::MAX_PACKAGE_BYTES; -use crate::network::NetworkConfig; +use crate::network::{safe_url_origin, NetworkConfig}; use crate::EngineError; const CURL: &str = "/usr/bin/curl"; @@ -113,11 +113,17 @@ fn partial_path(dest: &Path) -> PathBuf { dest.with_file_name(format!("{file_name}.part")) } -fn url_host(url: &str) -> &str { - url.split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") +fn curl_download_failure(url: &str, exit: &str) -> EngineError { + let source = safe_url_origin(url); + EngineError::Io(format!("curl download failed exit={exit} source={source}")) +} + +fn curl_download_stalled(url: &str) -> EngineError { + let source = safe_url_origin(url); + EngineError::Io(format!( + "curl download stalled for {} seconds source={source}", + STALL_TIMEOUT.as_secs() + )) } fn is_cancelled_error(err: &EngineError) -> bool { @@ -132,7 +138,7 @@ fn run_curl( on_progress: &dyn Fn(u64), network: &NetworkConfig, ) -> Result<(), EngineError> { - let source = url_host(url); + let source = safe_url_origin(url); let dest_arg = dest.to_string_lossy().into_owned(); let max_bytes = max_bytes.to_string(); let mut args = vec![ @@ -189,13 +195,11 @@ fn run_curl( // Carry the curl exit code so the app-layer classifier can // tell a connect / timeout / write failure apart (stderr is // not piped for the streamed download). - return Err(EngineError::Io(format!( - "curl download failed exit={} url={url}", - status - .code() - .map(|c| c.to_string()) - .unwrap_or_else(|| "signal".to_string()), - ))); + let exit = status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()); + return Err(curl_download_failure(url, &exit)); } break; } @@ -211,10 +215,7 @@ fn run_curl( log::warn!( "macOS download stalled source={source} downloaded={downloaded} stall_secs={stall_secs}" ); - return Err(EngineError::Io(format!( - "curl download stalled for {} seconds: {url}", - STALL_TIMEOUT.as_secs() - ))); + return Err(curl_download_stalled(url)); } on_progress(downloaded); std::thread::sleep(Duration::from_millis(300)); @@ -258,7 +259,7 @@ pub fn download_to_with_progress_bounded_with_network( let part = partial_path(dest); let should_resume = part.metadata().map(|m| m.len() > 0).unwrap_or(false); - let source = url_host(url); + let source = safe_url_origin(url); let dest_name = dest .file_name() .and_then(|name| name.to_str()) @@ -312,6 +313,35 @@ pub fn read_file(path: &Path) -> Result, EngineError> { mod tests { use super::*; + #[test] + fn failure_diagnostics_omit_url_credentials_path_query_and_fragment() { + let raw = "https://basic-user:basic-pass@downloads.example:8443/private/file.zip?X-Amz-Credential=secret#fragment-secret"; + let failed = curl_download_failure(raw, "22").to_string(); + let stalled = curl_download_stalled(raw).to_string(); + + assert_eq!( + failed, + "io error: curl download failed exit=22 source=https://downloads.example:8443" + ); + assert_eq!( + stalled, + format!( + "io error: curl download stalled for {} seconds source=https://downloads.example:8443", + STALL_TIMEOUT.as_secs() + ) + ); + for secret in [ + "basic-user", + "basic-pass", + "/private/file.zip", + "X-Amz-Credential", + "fragment-secret", + ] { + assert!(!failed.contains(secret), "failure leaked {secret}"); + assert!(!stalled.contains(secret), "stall leaked {secret}"); + } + } + // Mirrors the Windows engine's guard test: the guard serializes downloads, // pause vs cancel set the discard flag correctly, and dropping the guard // clears every flag so the next download starts clean. No network/curl is diff --git a/crates/codex-mac-engine/src/network.rs b/crates/codex-mac-engine/src/network.rs index f9424f9..db6df04 100644 --- a/crates/codex-mac-engine/src/network.rs +++ b/crates/codex-mac-engine/src/network.rs @@ -1,5 +1,22 @@ use std::process::Command; +use url::Url; + +/// Return only a URL's parsed origin for diagnostics. The original URL still +/// goes to curl, but credentials, path, query, and fragment must never cross +/// into persisted errors or logs. +pub(crate) fn safe_url_origin(raw: &str) -> String { + let Ok(url) = Url::parse(raw.trim()) else { + return "".to_string(); + }; + let origin = url.origin().ascii_serialization(); + if origin == "null" { + "".to_string() + } else { + origin + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProxyMode { System, @@ -65,7 +82,22 @@ impl Default for NetworkConfig { #[cfg(test)] mod tests { - use super::NetworkConfig; + use super::{safe_url_origin, NetworkConfig}; + + #[test] + fn diagnostic_origin_strips_all_sensitive_url_components() { + assert_eq!( + safe_url_origin( + "https://basic-user:basic-pass@downloads.example:8443/private/file.zip?X-Amz-Credential=secret#fragment-secret", + ), + "https://downloads.example:8443" + ); + assert_eq!( + safe_url_origin("https://downloads.example/public/file.zip"), + "https://downloads.example" + ); + assert_eq!(safe_url_origin("not a URL secret-token"), ""); + } #[test] fn direct_proxy_mode_disables_curl_proxy_resolution() { diff --git a/crates/codex-mac-engine/src/sys.rs b/crates/codex-mac-engine/src/sys.rs index 533c11b..dde70aa 100644 --- a/crates/codex-mac-engine/src/sys.rs +++ b/crates/codex-mac-engine/src/sys.rs @@ -10,7 +10,7 @@ use std::path::Path; use std::process::Command; use crate::limits::MAX_TEXT_BYTES; -use crate::network::NetworkConfig; +use crate::network::{safe_url_origin, NetworkConfig}; use crate::EngineError; const CURL: &str = "/usr/bin/curl"; @@ -20,15 +20,12 @@ fn text_from_curl(url: &str, output: std::process::Output) -> Result MAX_TEXT_BYTES as usize { return Err(EngineError::Io(format!( @@ -38,6 +35,14 @@ fn text_from_curl(url: &str, output: std::process::Output) -> Result EngineError { + let source = safe_url_origin(url); + // Do not carry curl stderr across the persisted-log boundary: curl may + // repeat or normalize a credentialed/presigned URL there. Exit code plus + // safe origin preserve the classifier and actionable source diagnostics. + EngineError::Io(format!("curl failed for source={source} exit={exit}")) +} + /// Fetch a small text resource (the appcast) over HTTPS via system `curl`. pub fn fetch_text(url: &str) -> Result { fetch_text_with_network(url, &NetworkConfig::system()) @@ -296,9 +301,7 @@ fn quarantine_flags_indicate_translocation(value: &str) -> bool { const QTN_FLAG_DO_NOT_TRANSLOCATE: u32 = 0x0100; let flags_hex = value.split(';').next().unwrap_or("").trim(); match u32::from_str_radix(flags_hex, 16) { - Ok(flags) => { - flags & QTN_FLAG_TRANSLOCATE != 0 && flags & QTN_FLAG_DO_NOT_TRANSLOCATE == 0 - } + Ok(flags) => flags & QTN_FLAG_TRANSLOCATE != 0 && flags & QTN_FLAG_DO_NOT_TRANSLOCATE == 0, Err(_) => true, } } @@ -373,6 +376,32 @@ pub fn read_bundle_short_version(app: &str) -> Option { } } +#[cfg(test)] +mod url_diagnostic_tests { + use super::text_curl_failure; + + #[test] + fn text_fetch_failure_keeps_only_safe_origin_and_exit_code() { + let raw = "https://basic-user:basic-pass@appcast.example/feed.xml?token=presigned#private"; + let error = text_curl_failure(raw, "22"); + let diagnostic = error.to_string(); + + assert_eq!( + diagnostic, + "io error: curl failed for source=https://appcast.example exit=22" + ); + for secret in [ + "basic-user", + "basic-pass", + "/feed.xml", + "presigned", + "private", + ] { + assert!(!diagnostic.contains(secret), "diagnostic leaked {secret}"); + } + } +} + #[cfg(test)] mod quarantine_tests { use super::quarantine_flags_indicate_translocation; @@ -442,7 +471,10 @@ mod tests { installed_codex_build_at_path(&renamed), Some((renamed.clone(), 5059)) ); - assert_eq!(read_bundle_identifier(&renamed).as_deref(), Some(CODEX_BUNDLE_ID)); + assert_eq!( + read_bundle_identifier(&renamed).as_deref(), + Some(CODEX_BUNDLE_ID) + ); let _ = fs::remove_dir_all(&root); } diff --git a/crates/codex-win-engine/Cargo.lock b/crates/codex-win-engine/Cargo.lock index 5d3728c..96d0a0f 100644 --- a/crates/codex-win-engine/Cargo.lock +++ b/crates/codex-win-engine/Cargo.lock @@ -60,6 +60,7 @@ dependencies = [ "serde_json", "sha2", "thiserror", + "url", "uuid", "windows-sys", "zip", @@ -153,6 +154,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -221,12 +231,115 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -268,6 +381,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.32" @@ -296,12 +415,27 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -420,6 +554,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -431,6 +577,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -451,6 +608,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.20.1" @@ -469,6 +636,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.3" @@ -692,6 +877,89 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/crates/codex-win-engine/Cargo.toml b/crates/codex-win-engine/Cargo.toml index a535194..1a64c2d 100644 --- a/crates/codex-win-engine/Cargo.toml +++ b/crates/codex-win-engine/Cargo.toml @@ -14,6 +14,7 @@ sha2 = "0.10" thiserror = "2.0" uuid = { version = "1", features = ["v4"] } zip = { version = "2", default-features = false, features = ["deflate"] } +url = "2.5" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_SystemInformation", "Win32_System_Threading"] } diff --git a/crates/codex-win-engine/src/download.rs b/crates/codex-win-engine/src/download.rs index bf98031..ebfc50b 100644 --- a/crates/codex-win-engine/src/download.rs +++ b/crates/codex-win-engine/src/download.rs @@ -5,7 +5,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use sha2::{Digest, Sha256}; use crate::limits::MAX_PACKAGE_BYTES; -use crate::network::{is_schannel_revocation_offline, NetworkConfig, SchannelRevocationCheck}; +use crate::network::{ + is_schannel_revocation_offline, safe_curl_failure_message, safe_url_host, NetworkConfig, + SchannelRevocationCheck, +}; use crate::process::{ curl_exe, hidden_command, run_with_progress, RunError, RunLimits, TimeoutKind, }; @@ -91,13 +94,6 @@ fn partial_path(dest: &Path) -> PathBuf { dest.with_file_name(format!("{file_name}.part")) } -fn url_host(url: &str) -> &str { - url.split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") -} - fn proxy_env_summary() -> String { let vars = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"]; let configured = vars @@ -116,14 +112,7 @@ fn proxy_env_summary() -> String { } fn curl_failure_message(url: &str, exit_code: Option, stderr: &str) -> String { - let base = format!( - "curl failed for host={} exit={}: stderr='{}'", - url_host(url), - exit_code - .map(|code| code.to_string()) - .unwrap_or_else(|| "signal".to_string()), - stderr.trim(), - ); + let base = safe_curl_failure_message(url, exit_code, stderr); // Append the proxy diagnostic only for connectivity failures — pasting it // onto write / disk / HTTP errors (e.g. exit 23) only misleads. if is_connectivity_exit(exit_code) { @@ -230,9 +219,7 @@ fn run_curl_once( let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0); return match kind { TimeoutKind::Stall => { - log::warn!( - "Windows download stalled source={source} downloaded={downloaded}" - ); + log::warn!("Windows download stalled source={source} downloaded={downloaded}"); Err(CurlAttemptError::Other(format!( "curl download stalled (no progress) source={source} downloaded={downloaded}" ))) @@ -274,8 +261,8 @@ struct CurlRun<'a> { } impl CurlRun<'_> { - fn source(&self) -> &str { - url_host(self.url) + fn source(&self) -> String { + safe_url_host(self.url) } } @@ -293,7 +280,7 @@ fn run_curl_with_mode( run.network, revocation_check, ); - run_curl_once(run.source(), run.dest, args, run.on_progress) + run_curl_once(&run.source(), run.dest, args, run.on_progress) } fn retry_with_schannel_no_revoke( @@ -326,7 +313,7 @@ fn run_curl( on_progress: &dyn Fn(u64), network: &NetworkConfig, ) -> Result<(), String> { - let source = url_host(url); + let source = safe_url_host(url); let dest = dest.to_string_lossy().into_owned(); let max_bytes = max_bytes.to_string(); let run = CurlRun { @@ -440,7 +427,7 @@ pub fn download_to_with_progress_bounded_with_network( let part = partial_path(dest); let should_resume = part.metadata().map(|m| m.len() > 0).unwrap_or(false); - let source = url_host(url); + let source = safe_url_host(url); let dest_name = dest .file_name() .and_then(|name| name.to_str()) @@ -532,6 +519,26 @@ mod tests { assert!(!cancel_active_download()); } + #[test] + fn persisted_download_failure_uses_safe_host_and_reason_only() { + let raw = "https://basic-user:basic-pass@downloads.example/private/Codex.msix?token=presigned-secret#fragment-secret"; + let stderr = format!("curl: (6) Could not resolve host while requesting {raw}"); + let message = curl_failure_message(raw, Some(6), &stderr); + + assert!(message.starts_with( + "curl failed for host=downloads.example exit=6 reason='DNS resolution failed'" + )); + for secret in [ + "basic-user", + "basic-pass", + "/private/Codex.msix", + "presigned-secret", + "fragment-secret", + ] { + assert!(!message.contains(secret), "download error leaked {secret}"); + } + } + #[test] fn curl_args_keep_modern_progress_flag_by_default() { let args = curl_args( @@ -620,7 +627,7 @@ mod tests { seen.lock().unwrap().push(downloaded); }); if let Err(EngineError::Io(message)) = &result { - if message.contains("Protocol \"file\" disabled") { + if message.contains("reason='protocol disabled'") { let _ = std::fs::remove_dir_all(&root); return; } diff --git a/crates/codex-win-engine/src/network.rs b/crates/codex-win-engine/src/network.rs index f898860..a4ceeaf 100644 --- a/crates/codex-win-engine/src/network.rs +++ b/crates/codex-win-engine/src/network.rs @@ -1,5 +1,66 @@ use std::process::Command; +use url::Url; + +/// Parsed host (plus explicit port) for diagnostics. The original URL remains +/// available to curl, but credentials, path, query, and fragment never cross +/// into progress events, errors, or persisted logs. +pub(crate) fn safe_url_host(raw: &str) -> String { + let Ok(url) = Url::parse(raw.trim()) else { + return "".to_string(); + }; + let origin = url.origin().ascii_serialization(); + if origin == "null" { + return "".to_string(); + } + origin + .split_once("://") + .map(|(_, host)| host.to_string()) + .unwrap_or_else(|| "".to_string()) +} + +/// Convert curl stderr into a fixed diagnostic category. Raw stderr is used +/// transiently for retry decisions, but must not be persisted because curl can +/// repeat or normalize a credentialed/presigned URL in its message. +pub(crate) fn safe_curl_failure_message(url: &str, exit_code: Option, stderr: &str) -> String { + let host = safe_url_host(url); + let exit = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); + let lower = stderr.to_ascii_lowercase(); + let reason = if lower.contains("no space left") + || lower.contains("not enough space") + || lower.contains("disk is full") + { + Some("disk is full") + } else if lower.contains("access is denied") || lower.contains("permission denied") { + Some("permission denied") + } else if lower.contains("failure writing output") || lower.contains("write error") { + Some("write error") + } else if lower.contains("protocol") && lower.contains("disabled") { + Some("protocol disabled") + } else if lower.contains("could not resolve") { + Some("DNS resolution failed") + } else if lower.contains("failed to connect") || lower.contains("connection refused") { + Some("connection failed") + } else if lower.contains("timed out") || lower.contains("timeout") { + Some("timeout") + } else if lower.contains("schannel") + || lower.contains("ssl") + || lower.contains("tls") + || lower.contains("certificate") + { + Some("TLS failure") + } else { + None + }; + + match reason { + Some(reason) => format!("curl failed for host={host} exit={exit} reason='{reason}'"), + None => format!("curl failed for host={host} exit={exit}"), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SchannelRevocationCheck { Strict, @@ -101,7 +162,35 @@ fn push_schannel_no_revoke(_args: &mut Vec) {} #[cfg(test)] mod tests { - use super::{is_schannel_revocation_offline, NetworkConfig, SchannelRevocationCheck}; + #[cfg(windows)] + use super::SchannelRevocationCheck; + use super::{ + is_schannel_revocation_offline, safe_curl_failure_message, safe_url_host, NetworkConfig, + }; + + #[test] + fn diagnostic_host_and_curl_error_strip_sensitive_url_components() { + let raw = "https://basic-user:basic-pass@downloads.example:8443/private/Codex.msix?X-Amz-Signature=presigned-secret#fragment-secret"; + assert_eq!(safe_url_host(raw), "downloads.example:8443"); + + let stderr = format!("curl: (23) No space left while requesting {raw}"); + let message = safe_curl_failure_message(raw, Some(23), &stderr); + assert_eq!( + message, + "curl failed for host=downloads.example:8443 exit=23 reason='disk is full'" + ); + for secret in [ + "basic-user", + "basic-pass", + "/private/Codex.msix", + "X-Amz-Signature", + "presigned-secret", + "fragment-secret", + ] { + assert!(!message.contains(secret), "diagnostic leaked {secret}"); + } + assert_eq!(safe_url_host("not a URL with-secret"), ""); + } #[test] fn direct_proxy_mode_disables_curl_proxy_resolution() { diff --git a/crates/codex-win-engine/src/sys.rs b/crates/codex-win-engine/src/sys.rs index b09f043..7b36482 100644 --- a/crates/codex-win-engine/src/sys.rs +++ b/crates/codex-win-engine/src/sys.rs @@ -10,10 +10,13 @@ use crate::capability::WinCapabilityReport; use crate::capability::{CapabilityCheck, CapabilityState}; use crate::limits::MAX_TEXT_BYTES; use crate::msix::parse_appx_manifest_xml; -use crate::network::{is_schannel_revocation_offline, NetworkConfig, SchannelRevocationCheck}; +use crate::network::{ + is_schannel_revocation_offline, safe_curl_failure_message, safe_url_host, NetworkConfig, + SchannelRevocationCheck, +}; use crate::process::{ - curl_exe, hidden_command, run_capturing, spawn_and_require_liveness, LivenessResult, - RunError, RunLimits, TimeoutKind, MSIX_ACTIVATION_WINDOW_SECS, MSIX_LIVENESS_WINDOW_SECS, + curl_exe, hidden_command, run_capturing, spawn_and_require_liveness, LivenessResult, RunError, + RunLimits, TimeoutKind, MSIX_ACTIVATION_WINDOW_SECS, MSIX_LIVENESS_WINDOW_SECS, PORTABLE_LIVENESS_WINDOW, }; use crate::EngineError; @@ -190,12 +193,16 @@ fn fetch_text_output( ]); // curl's own --max-time is the primary budget; the outer deadline is a // backstop that also kills a hung curl that ignored max-time. - run_capturing(command, RunLimits::total(std::time::Duration::from_secs(75)), None) - .map_err(|e| EngineError::Io(format!("curl: {}", e.message()))) + run_capturing( + command, + RunLimits::total(std::time::Duration::from_secs(75)), + None, + ) + .map_err(|e| EngineError::Io(format!("curl: {}", e.message()))) } pub fn fetch_text_with_network(url: &str, network: &NetworkConfig) -> Result { - let source = url_host(url); + let source = safe_url_host(url); log::debug!("fetch Windows text source={source}"); let mut output = fetch_text_output(url, network, SchannelRevocationCheck::Strict)?; let should_retry_without_revocation = { @@ -233,14 +240,7 @@ pub fn fetch_text_with_network(url: &str, network: &NetworkConfig) -> Result, stderr: &str) -> String { - let base = format!( - "curl failed for host={} exit={}: stderr='{}'", - url_host(url), - exit_code - .map(|code| code.to_string()) - .unwrap_or_else(|| "signal".to_string()), - stderr.trim(), - ); + let base = safe_curl_failure_message(url, exit_code, stderr); // Append the proxy diagnostic only for connectivity failures — pasting it // onto write / disk / HTTP errors (e.g. exit 23) only misleads. if is_connectivity_exit(exit_code) { @@ -276,13 +276,6 @@ fn is_connectivity_exit(exit_code: Option) -> bool { ) } -fn url_host(url: &str) -> &str { - url.split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") -} - fn proxy_env_summary() -> String { let vars = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"]; let configured = vars @@ -300,6 +293,30 @@ fn proxy_env_summary() -> String { } } +#[cfg(test)] +mod curl_diagnostic_tests { + use super::curl_failure_message; + + #[test] + fn text_fetch_failure_never_persists_url_or_raw_stderr_secrets() { + let raw = "https://basic-user:basic-pass@manifest.example/latest/manifest?token=presigned-secret#fragment-secret"; + let stderr = format!("curl: (35) TLS failed while requesting {raw}"); + let message = curl_failure_message(raw, Some(35), &stderr); + + assert!(message + .starts_with("curl failed for host=manifest.example exit=35 reason='TLS failure'")); + for secret in [ + "basic-user", + "basic-pass", + "/latest/manifest", + "presigned-secret", + "fragment-secret", + ] { + assert!(!message.contains(secret), "text error leaked {secret}"); + } + } +} + pub fn detect_installed_codex(portable_root: &Path) -> Option { detect_msix_install().or_else(|| detect_portable_install(portable_root)) } @@ -1016,9 +1033,7 @@ if ($statusOk -and $aumidResolved -and $missing.Count -eq 0) {{ let parsed = match &run_result { Ok(json) => serde_json::from_str::(json).ok(), Err(PowerShellRunError::Timeout(kind)) => { - log::info!( - "MSIX health check result healthy=false status=timeout kind={kind:?}" - ); + log::info!("MSIX health check result healthy=false status=timeout kind={kind:?}"); // Start-Process detaches; kill any process the timed-out probe left running. best_effort_close_msix_after_probe(); return MsixHealthReport { @@ -1252,7 +1267,8 @@ pub fn detect_portable_install(portable_root: &Path) -> Option { - let asar_name = crate::app_version::read_asar_package_name_from_install_root(portable_root); + let asar_name = + crate::app_version::read_asar_package_name_from_install_root(portable_root); if asar_name.as_deref() != Some(crate::app_version::CODEX_ASAR_PACKAGE_NAME) { log::debug!( "portable root at {} has no manifest and its app payload name is {:?} (expected {}); not a Codex install", @@ -1297,13 +1313,12 @@ pub fn launch_codex_with_options( ) -> Result<(), EngineError> { if installed.source == "portable" { let root = Path::new(&installed.path); - let exe = crate::portable::installed_app_exe(root) - .ok_or_else(|| { - EngineError::Io(format!( - "no app entry executable (ChatGPT.exe / Codex.exe) in {}", - root.display() - )) - })?; + let exe = crate::portable::installed_app_exe(root).ok_or_else(|| { + EngineError::Io(format!( + "no app entry executable (ChatGPT.exe / Codex.exe) in {}", + root.display() + )) + })?; // CREATE_NO_WINDOW only suppresses a console flash; the GUI still shows. // Require a short liveness window so an immediate crash is reported as a // launch failure instead of a silent no-op. @@ -1321,10 +1336,7 @@ pub fn launch_codex_with_options( code.map(|c| c.to_string()) .unwrap_or_else(|| "signal".to_string()) ))), - Err(err) => Err(EngineError::Io(format!( - "launch Codex: {}", - err.message() - ))), + Err(err) => Err(EngineError::Io(format!("launch Codex: {}", err.message()))), } } else { if options.disable_codex_self_updates { diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md new file mode 100644 index 0000000..670ef6b --- /dev/null +++ b/docs/code-signing-policy.md @@ -0,0 +1,182 @@ +# Code signing policy · 代码签名政策 + +Last updated / 最后更新:2026-07-11 + +## 当前状态 · Current status + +Codex App Manager 正在申请 SignPath Foundation,并评估从 GitHub Actions trusted build +接入 Windows Authenticode 的可行方案。**申请尚未获批,证书尚未签发,SignPath 签名流水线 +尚未启用;当前 Windows 安装器仍未带 Authenticode 发行者签名。** 在下列构建、审批和验证 +条件全部满足前,tag 驱动的 Windows 正式发布会 fail closed,不会悄悄回退为 unsigned 发布。 + +Codex App Manager is applying to SignPath Foundation and evaluating a GitHub +Actions trusted-build integration for Windows Authenticode. **The application +has not yet been approved, no certificate has been issued, and no SignPath +signing pipeline is active. Current Windows installers remain unsigned.** The +tag-driven Windows release path fails closed until all build, approval, and +verification requirements below are met; it must never silently fall back to +publishing unsigned artifacts. + +If the application is approved and the integration is activated, the required +provider attribution will be: + +> Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/) + +This sentence records the intended attribution and does not claim that the +current downloads are signed by SignPath. + +## 适用范围 · Scope + +- 本政策只适用于本仓库以 MIT 许可证发布、由本项目构建的 Codex App Manager 工件。 +- Foundation 证书只会签署项目自身拥有和维护的可执行文件或安装器,不会用于其他项目、 + 私有软件、商业软件或个人文件。 +- OpenAI Codex 桌面应用不会被嵌入或重新打包进 Manager 安装器;Manager 运行后按用户选择 + 下载官方 Codex 工件。 +- 第三方依赖(包括 NSIS/Tauri 随附插件)可以作为开源依赖被打包,但不得被当作本项目 + 自有二进制直接使用 Foundation 证书签名。接入方案必须证明这一边界。 + +- This policy covers only Codex App Manager artifacts built from this MIT- + licensed public repository. +- A Foundation certificate will be used only for executables or installers + owned and maintained by this project. It will not be used for other projects, + private or commercial software, or personal files. +- The OpenAI Codex desktop app is not embedded or repackaged in the Manager + installer. The Manager downloads official Codex artifacts after launch when + the user requests an operation. +- Third-party dependencies, including plugins supplied with NSIS or Tauri, may + be bundled as open-source dependencies but must not be directly signed as + project-owned binaries with the Foundation certificate. The final integration + must prove this boundary. + +## 角色与人工审批 · Roles and manual approval + +签名发布采用职责明确、逐次人工批准的流程: + +1. **Author**:常规源码、依赖或发布配置变更必须通过 pull request 和 required checks + 进入 `main`。仓库管理员技术上拥有紧急 bypass;若确需使用,必须保留审计记录,并在 + 发起任何签名请求前由 Reviewer 重新核对 bypass diff、commit 与 CI。 +2. **Reviewer**:在合并前审查变更和必需 CI;不得用自动化结果替代代码审查。 +3. **Release approver**:核对 tag、公开源码、构建来源、目标版本和待签工件后,在 + SignPath 中对**每一次**签名请求进行人工批准。不得自动批准、批量预批准或复用旧请求。 +4. 角色成员及权限会在 SignPath 项目获批后按最小权限配置;GitHub 与 SignPath 账户必须 + 启用双因素认证。角色或权限变化必须可审计。 + +当前团队成员(公开 GitHub 身份): + +| Role | Member | 说明 | +|---|---|---| +| Committer / Author | [@Wangnov](https://github.com/Wangnov) | 维护仓库、准备 maintainer PR 与 release tag;外部贡献者只通过 PR 提交,不自动取得此角色。 | +| Reviewer | [@Wangnov](https://github.com/Wangnov) | 审查外部 PR;对维护者自己的变更逐项复核 diff 与必需 CI。 | +| SignPath Approver | [@Wangnov](https://github.com/Wangnov) | 申请获批后,对每次签名请求执行单独的人工审批。 | + +本项目目前是单维护者项目,因此同一人会兼任多个角色;这不会把签名批准自动化,也不会 +省略每次请求的人工作业。如果 SignPath 的最终配置要求不同人员之间的职责分离,项目会在 +启用生产签名前公开增补成员并更新本表。 + +The signing release process uses explicit responsibilities and per-request +human approval: + +1. **Author** — normal source, dependency, and release-configuration changes + must enter `main` through a pull request and required checks. Repository + administrators technically retain an emergency bypass; if it is ever used, + the action must remain auditable and the Reviewer must re-check the bypassed + diff, commit, and CI before any signing request is submitted. +2. **Reviewer** — reviews the change and required CI before merge; automated + checks do not replace code review. +3. **Release approver** — checks the tag, public source, build origin, target + version, and candidate artifacts, then manually approves **each** SignPath + signing request. Automatic approval, advance bulk approval, and reuse of an + old request are prohibited. +4. Named role assignments and least-privilege access will be configured after + the SignPath project is approved. GitHub and SignPath accounts must use + two-factor authentication, and role changes must remain auditable. + +Current team members (public GitHub identities): + +| Role | Member | Responsibility | +|---|---|---| +| Committer / Author | [@Wangnov](https://github.com/Wangnov) | Maintains the repository and prepares maintainer PRs and release tags. External contributors submit PRs and do not automatically receive this role. | +| Reviewer | [@Wangnov](https://github.com/Wangnov) | Reviews external PRs and explicitly re-checks the diff and required CI for maintainer-authored changes. | +| SignPath Approver | [@Wangnov](https://github.com/Wangnov) | After approval, manually approves each individual signing request. | + +This is currently a single-maintainer project, so one person holds multiple +roles. That does not automate signing approval or remove the manual action for +each request. If the final SignPath configuration requires separation between +different people, the project will add members publicly and update this table +before production signing is enabled. + +## Trusted build 与发布门 · Trusted build and release gates + +未来的 SignPath 接入必须使用 SignPath 验证过的 GitHub Actions trusted build/origin,且只 +接受来自本公开仓库受保护 tag 的工件。正式启用前必须通过一次可复现的试签与审查,证明: + +- 签名请求绑定准确的仓库、commit、tag、版本和 GitHub Actions run; +- 待签文件的产品名和版本资源与 tag 一致; +- x64 与 ARM64 的最终安装器、安装后的主程序和卸载器均满足设计的 Authenticode 边界, + 且没有把 Foundation 证书直接用于第三方插件; +- 每个要求签名的 PE 都显示预期的 SignPath Foundation 发行者并带有效时间戳; +- Tauri updater `.sig` 在 Authenticode 之后覆盖最终发布字节;它与 Authenticode 是两套 + 独立的信任机制; +- 最终上传到 GitHub Release 和镜像的文件与已验证文件逐字节一致。 + +Until that review is complete, [`release.yml`](../.github/workflows/release.yml) +intentionally blocks Windows tag jobs before building. Removing the blocker +alone cannot make a release valid: unsigned artifacts must still fail the +required post-build verification gates. See +[`Windows signing and verification`](./windows-signing.md) for the operational +status and unresolved integration boundary. + +## 隐私与网络行为 · Privacy and network behavior + +完整、可稳定链接的隐私政策见 [`docs/privacy.md`](./privacy.md)。以下为与代码签名申请 +直接相关的网络行为摘要。 + +See the stable, standalone [`privacy policy`](./privacy.md) for the complete +disclosure. The following is the network-behavior summary relevant to the code +signing application. + +Codex App Manager 不提供账户系统,也不包含遥测、广告或行为分析 SDK,不会主动上传用户 +身份、文件内容或使用画像。应用仍会为完成其公开功能发起网络请求: + +- Manager 启动后以及保持打开期间会检查自身更新;发现更新后由用户决定是否安装。 +- Codex 的更新检查可在设置中关闭启动检查和定时检查;安装或更新操作会按用户选择访问 + 镜像、GitHub、OpenAI 官方源或用户配置的自定义源。 +- 下载可使用系统代理、直连或用户配置的代理。所选服务会像任何 HTTPS 服务一样看到 + 正常的连接元数据,例如来源 IP、时间、请求路径和 User-Agent;其日志受各服务自己的 + 隐私政策和保留规则约束。 +- 中国大陆的 `codexapp.agentsmirror.com` 请求可能由 Cloudflare 按地区路由到 IHEP S3; + 其他地区通常由 Cloudflare R2 提供。GitHub 是 Manager 自更新的后备源。 +- 本地诊断日志可能记录版本、平台、更新源 host 和错误信息,但不应记录私钥、密码、 + SignPath token 或下载 URL 中的临时签名参数。用户只有在主动提交 Issue/诊断信息时才会 + 把这些本地内容发送给项目维护者。 + +Codex App Manager has no account system and includes no telemetry, advertising, +or behavioral analytics SDK. It does not intentionally upload user identity, +file contents, or usage profiles. It still makes network requests to provide +its documented functionality: + +- The Manager checks for its own updates after startup and periodically while + open; the user chooses whether to install an available update. +- Startup and periodic update checks for Codex can be disabled in Settings. + Install or update operations contact the selected mirror, GitHub, official + OpenAI source, or a user-configured custom source. +- Downloads can use the system proxy, direct mode, or a custom proxy. Like any + HTTPS service, the selected endpoint can observe ordinary connection metadata + such as source IP, time, request path, and User-Agent; its own privacy and + retention policies apply. +- Mainland-China requests to `codexapp.agentsmirror.com` may be routed by + Cloudflare to IHEP S3; other regions are normally served from Cloudflare R2. + GitHub is a fallback source for Manager self-updates. +- Local diagnostic logs may contain versions, platform, update-source host, and + errors, but must not contain private keys, passwords, SignPath tokens, or + temporary signed query strings. Local diagnostics reach maintainers only when + a user deliberately includes them in an issue or support request. + +## 参考 · References + +- [SignPath Foundation terms](https://signpath.org/terms.html) +- [SignPath GitHub trusted build-system integration](https://docs.signpath.io/trusted-build-systems/github) +- [SignPath origin verification](https://docs.signpath.io/origin-verification/) +- [Privacy policy](./privacy.md) +- [Windows signing and verification](./windows-signing.md) +- [Release process](./release.md) diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..8e15523 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,140 @@ +# Privacy policy · 隐私政策 + +Effective date / 生效日期:2026-07-11 + +This policy applies to Codex App Manager, its public website, and the download +router maintained in this repository. It does not replace the privacy policies +of GitHub, Cloudflare, IHEP, OpenAI, Microsoft, or a custom endpoint/proxy chosen +by the user. + +本政策适用于 Codex App Manager、本项目官网以及本仓库维护的下载路由。GitHub、 +Cloudflare、IHEP、OpenAI、Microsoft,以及用户自行选择的自定义源或代理,分别适用其 +自己的隐私政策,本政策不能替代这些第三方政策。 + +## 我们不收集什么 · What we do not collect + +- 应用没有账户系统,不要求姓名、邮箱、电话或付款信息。 +- 应用不包含遥测、广告、崩溃上报或行为分析 SDK,也不会为画像目的上传使用行为。 +- 项目不会主动读取或上传 `~/.codex` 中的对话、凭据、工作区文件或其他用户内容。 +- 本地日志和设置不会自动发送给维护者。只有用户主动附在 Issue 或支持请求中时,维护者 + 才会收到用户选择提交的内容。 + +- The app has no account system and does not request a name, email address, + phone number, or payment information. +- The app includes no telemetry, advertising, crash-reporting, or behavioral + analytics SDK and does not upload usage activity for profiling. +- The project does not intentionally read or upload conversations, credentials, + workspace files, or other user content from `~/.codex`. +- Local logs and settings are not automatically sent to maintainers. Maintainers + receive only the material a user deliberately attaches to an issue or support + request. + +## 应用会发起的网络请求 · Network requests made by the app + +网络请求用于更新检查、下载和校验,不用于遥测: + +- **Manager 自身更新**:应用启动约 1.5 秒后检查一次;保持打开时约每 6 小时检查一次。 + 当前版本没有关闭 Manager 自身检查的设置。发现新版本后由用户决定是否安装。 +- **Codex 更新**:默认在 Manager 主界面启动时检查,并在应用保持打开时按设置周期检查。 + 用户可分别关闭启动检查和定时检查,也可调整定时间隔。 +- **安装、更新和校验**:用户发起操作时,应用会访问所选的镜像、GitHub、OpenAI 官方 + 源或用户配置的自定义 HTTPS 源,以取得 manifest、appcast、checksum 和安装包。 +- **代理**:请求可使用系统代理、直连或用户配置的代理。自定义代理能看到经其转发的 + 目标 host 和正常连接元数据。 + +These requests provide update, download, and verification functionality, not +telemetry: + +- **Manager self-update** — one check runs about 1.5 seconds after startup and + another runs about every six hours while the app remains open. The current + version has no setting to disable Manager self-update checks. The user decides + whether to install an available update. +- **Codex updates** — by default, the Manager checks when its main screen starts + and periodically while open. Users can independently disable startup and + periodic checks and can change the periodic interval. +- **Install, update, and verification** — when the user starts an operation, the + app contacts the selected mirror, GitHub, official OpenAI source, or a custom + HTTPS source to obtain manifests, appcasts, checksums, and installers. +- **Proxy behavior** — requests can use the system proxy, direct mode, or a + user-configured proxy. A custom proxy can observe destination hosts and + ordinary connection metadata for traffic it handles. + +## 服务与连接元数据 · Services and connection metadata + +默认网络路径可能包括: + +- `codexapp.agentsmirror.com`:由 Cloudflare Worker 提供;全球通常使用 Cloudflare R2, + 中国大陆请求可能按 `CF-IPCountry` 路由到 IHEP S3 的临时预签名地址。 +- `github.com`:Manager 自更新后备源及公开 release 下载。 +- `persistent.oaistatic.com` 等 OpenAI 官方 host:用户选择官方源时获取 Codex appcast 或 + 工件。 +- 用户配置的自定义 HTTPS 源或代理。 + +相关第三方隐私政策: + +- [GitHub General Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) +- [Cloudflare Privacy Policy](https://www.cloudflare.com/privacypolicy/) +- [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy/) +- [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement) +- IHEP S3 为中国大陆镜像节点。截至本政策日期,项目未找到该镜像端点单独公开的隐私政策; + 相关处理受 IHEP/CAS 的适用服务条款与法律义务约束。可从 + [IHEP 官网](https://english.ihep.cas.cn/) 查询或联系其机构。若发现适用的官方政策, + 本页会补充直接链接。 + +这些服务与普通 HTTPS 服务一样,可能处理来源 IP、请求时间、请求路径、User-Agent、 +响应状态以及防滥用/安全日志。项目维护者无法代表第三方承诺其保留期限;请查阅所选服务 +的政策。官网自身不嵌入广告或分析脚本,但托管和路由服务仍可能生成正常的访问日志。 + +Default paths may include `codexapp.agentsmirror.com` (Cloudflare Worker, R2, +and possible IHEP S3 routing for mainland China), `github.com` (fallback update +and public release downloads), official OpenAI hosts such as +`persistent.oaistatic.com` when selected, and a custom HTTPS source or proxy. +Like ordinary HTTPS services, these providers may process source IP, request +time and path, User-Agent, response status, and security/abuse logs. Project +maintainers cannot promise third-party retention periods. The project website +embeds no advertising or analytics script, but hosting and routing providers +may still produce ordinary access logs. + +Third-party privacy policies: + +- [GitHub General Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) +- [Cloudflare Privacy Policy](https://www.cloudflare.com/privacypolicy/) +- [OpenAI Privacy Policy](https://openai.com/policies/privacy-policy/) +- [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement) +- IHEP S3 hosts the mainland-China mirror node. As of this policy date, the + project has not identified a standalone public privacy policy for that mirror + endpoint. Applicable IHEP/CAS service terms and legal obligations govern its + processing; consult the [IHEP website](https://english.ihep.cas.cn/) or the + institution directly. This page will add a direct link if an applicable + official policy is identified. + +## 本地数据 · Data stored locally + +应用在本机保存运行所需的设置、安装来源信息、更新状态、操作状态和诊断日志。诊断信息可 +包含应用版本、操作系统、架构、更新源 host、安装状态和错误文本。它不应包含代码签名 +私钥、密码、SignPath token,或下载 URL 的临时签名 query;发现这类泄漏应按安全问题 +处理。卸载 Manager 不等同于删除 Codex 自己的数据;卸载界面会明确说明保留范围。 + +The app stores settings, installation provenance, update state, operation state, +and diagnostic logs locally. Diagnostics can include app version, operating +system, architecture, update-source host, install state, and error text. They +must not contain code-signing private keys, passwords, SignPath tokens, or +temporary signed URL queries; any such leak is a security issue. Uninstalling +the Manager is not the same as deleting Codex data, and the uninstall UI states +what is retained. + +## 用户选择与反馈 · User choices and contact + +用户可以关闭 Codex 的启动/定时检查、选择更新源和代理,并在提交 Issue 前查看和删改诊断 +内容。隐私或安全问题请通过 +[GitHub Issues](https://github.com/Wangnov/Codex-App-Manager/issues) 报告;请勿公开粘贴 +token、密码、私钥、完整预签名 URL 或其他秘密。 + +Users can disable Codex startup/periodic checks, select an update source and +proxy, and review or redact diagnostics before filing an issue. Report privacy +or security concerns through +[GitHub Issues](https://github.com/Wangnov/Codex-App-Manager/issues), and never +post tokens, passwords, private keys, complete presigned URLs, or other secrets +publicly. + +See also the [code signing policy](./code-signing-policy.md). diff --git a/docs/release.md b/docs/release.md index a013419..8b760b7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -8,8 +8,10 @@ tag 推送后 `release.yml` 按 tag 名取用该文件作为 GitHub Release 正 (安装表 + 升级说明),正文永远不会为空。写法与双语风格见 `docs/releases/TEMPLATE.md`。 Cross-platform release is tag-driven via [`.github/workflows/release.yml`](../.github/workflows/release.yml). -Push a `v*` tag and CI builds, signs, notarizes, and publishes a GitHub Release -with the Tauri updater manifest. +The publish job normally consumes the full four-platform build matrix. Windows +tag jobs currently fail closed while the SignPath Foundation application and +trusted-build migration are pending, so a new tag cannot publish a partial or +unsigned release. See the [code signing policy](./code-signing-policy.md). ## macOS signing pipeline @@ -41,30 +43,59 @@ bash scripts/finalize-macos.sh aarch64-apple-darwin Omit the `AC_API_*` vars to skip notarization for a quick local dev finalize. +## Manager updater trust and size limits + +`latest.json` contains provider-specific download URLs, so the R2/IHEP copy is +intentionally not byte-identical to GitHub's copy and is not itself a trust +anchor. `gen-updater-manifest.mjs` also emits a deterministic, URL-free +`release-identity.json` containing the version, release-note hash, exact target +artifact name, Tauri minisign signature, and SHA-256. The release workflow signs +that identity with the existing Tauri updater key and publishes the identity and +`.sig` as immutable `/` assets on GitHub, R2, and IHEP. + +The client still checks the mainland-friendly mirror first. Before displaying or +installing a candidate, it bounded-fetches the same source's versioned identity +and signature, verifies them with the pinned updater public key, and binds every +security-relevant manifest field to that identity. Artifact bytes then pass both +the normal Tauri minisign check and the signed SHA-256 before `Update::install`. +Any missing, oversized, invalid, or mismatched mirror response falls through to +the GitHub endpoint; installation still occurs at most once. + +Hard in-memory limits are 256 KiB for manifests/identity, 16 KiB for the identity +signature, and 64 MiB for updater artifacts. For comparison, v0.3.1's largest +updater artifact is about 10.9 MiB. The exact vendored +`tauri-plugin-updater` 2.10.1 patch and its upgrade checklist live in +[`vendor/tauri-plugin-updater-2.10.1/VENDORED.md`](../vendor/tauri-plugin-updater-2.10.1/VENDORED.md). + +Because Tauri's minisign trusted comment contains a timestamp, a rerun would +normally create a byte-different identity `.sig`. The workflow first reuses and +verifies an existing GitHub Release asset (then the mirror copy), and the mirror +sync refuses to overwrite byte-different versioned identity objects. + ## Windows Windows has no light/dark adaptive app icon (`.ico` is static) — the single Default icon is used. NSIS installer + updater bundle are produced by `tauri build`; no Apple-style finalize step. -Windows release builds are signed inside-out (see -[`release.yml`](../.github/workflows/release.yml)): - -1. **Prepare Authenticode** — import the release PFX and generate a temporary - Tauri config with the exact certificate thumbprint, SHA-256, and RFC3161 - timestamping (`tsp=true`). Missing credentials hard-fail the tag build. -2. **Build + sign all PE layers** — Tauri signs the main executable, the NSIS - uninstaller generated through `!uninstfinalize`, and the outer installer. -3. **Required verification** — every layer must report - `Get-AuthenticodeSignature.Status == Valid`, match the imported thumbprint, - and include a timestamp countersigner. x64 runs the full lifecycle; ARM64 is - installed on the x64 host to inspect its payload but is not launched there. -4. **Tauri updater `.sig`** — `npx tauri signer sign` authenticates the final, - Authenticode-signed installer bytes used by in-app updates. -5. **Collect + verify final artifacts** — space-stripped names under - `dist-artifacts/`, then another required signature check on the exact setup - executable that will be uploaded. -6. **Always clean up** — remove the temporary PFX directory and imported cert. +Windows Authenticode is in an explicit **application/migration pending** state: + +1. The project is applying to SignPath Foundation; approval, certificate + issuance, and trusted-build project identifiers do not exist yet. +2. `release.yml` runs `assert-signpath-foundation-ready.ps1` before a Windows + tag build. The script intentionally always fails and has no variable/secret + bypass. Because publish depends on the complete matrix, this blocks the + whole release rather than shipping an unsigned or macOS-only set. +3. No `WINDOWS_CERTIFICATE` PFX, password, thumbprint config, or local + `signCommand` is supported. SignPath Foundation signs submitted trusted-build + artifacts and requires per-request human approval; it is not a PFX provider. +4. A future PR must prove the NSIS packaging/signing order with real SignPath + pilot artifacts, exclude direct signing of third-party plugins, and verify + the final setup, installed app, uninstaller, timestamp, updater `.sig`, and + mirror hashes before replacing the blocker. +5. The existing `required` Authenticode and packaged-smoke steps are a + post-integration verification contract, not evidence that signing is active. + Removing the blocker alone still leaves unsigned output failing closed. PR-time x64 packaged smoke lives in [`win-installer-check.yml`](../.github/workflows/win-installer-check.yml) @@ -85,22 +116,21 @@ Required CI (`ci.yml`) also runs standalone engine crate tests for | `AC_API_KEY_BASE64` | base64 of the `AuthKey_XXXX.p8` | | `TAURI_SIGNING_PRIVATE_KEY` | updater private key | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | its password (empty if none) | -| `WINDOWS_CERTIFICATE` | base64 of the OV/EV code-signing **.pfx** (release env) | -| `WINDOWS_CERTIFICATE_PASSWORD` | password for that .pfx; may be empty only when the PFX has no password | - -### Optional release variables - -| Name | What | -|---|---| -| `WINDOWS_TIMESTAMP_URL` (repo **variable**) | optional RFC3161 timestamp URL | -Export your local .p12 / .p8 / .pfx to base64 with `base64 -i file -o -`. +There are currently **no Windows signing secrets or variables to configure**. +After Foundation approval, use only the exact organization/project/signing +policy/artifact-configuration values provisioned by SignPath, in a separately +reviewed trusted-build integration. Do not guess names and do not add a PFX +fallback. Export Apple `.p12` / `.p8` files to base64 with +`base64 -i file -o -`. > Artifact globs in the workflow's *Collect* step and the matcher regexes in > `gen-updater-manifest.mjs` assume the default Tauri bundler output names — > adjust them if your `productName`/bundler config changes the filenames. > > Keep **updater signature**, **Authenticode**, and **SmartScreen reputation** -> conceptually separate. Authenticode is mandatory for new tag builds, but a -> valid publisher signature does not guarantee instant SmartScreen reputation. -> See [`docs/windows-signing.md`](./windows-signing.md). +> conceptually separate. Current Windows installers remain unsigned and new tag +> publication is blocked until the SignPath migration is proven. See +> [`docs/windows-signing.md`](./windows-signing.md), the +> [code signing policy](./code-signing-policy.md), and the +> [privacy policy](./privacy.md). diff --git a/docs/releases/FALLBACK.md b/docs/releases/FALLBACK.md index 0e9c725..b130534 100644 --- a/docs/releases/FALLBACK.md +++ b/docs/releases/FALLBACK.md @@ -16,8 +16,10 @@ | Windows · x64 | [CodexAppManager_x64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows · ARM64 | [CodexAppManager_arm64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -**Windows 信任说明:** x64 / ARM64 安装器、主程序和卸载器均带 Authenticode 发行者签名与 RFC3161 时间戳;`.sig` / `latest.json` 中的 Tauri updater 签名另行保护应用内更新字节。SmartScreen 信誉仍由 Microsoft 独立评估,可能出现的信誉提示不等于任一签名失效。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 -**Windows trust:** The x64 / ARM64 installer, app, and uninstaller carry an Authenticode publisher signature and RFC3161 timestamp; the separate Tauri updater signature in `.sig` / `latest.json` protects in-app update bytes. Microsoft evaluates SmartScreen reputation independently, so a reputation prompt does not mean either signature failed. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). +**Windows 信任状态:** 当前 SignPath Foundation 申请与 trusted-build 迁移尚未完成,Windows tag 发布门保持 fail-closed;`.sig` / `latest.json` 的 Tauri updater 签名不是 Authenticode 发行者身份。本 fallback 不得发布 Windows 工件;参见 [代码签名政策](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md)与 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 +**Windows trust:** The SignPath Foundation application and trusted-build migration are pending, so the Windows tag gate remains fail-closed. A Tauri updater signature in `.sig` / `latest.json` is not Authenticode publisher identity. This fallback must not accompany published Windows artifacts; see the [code signing policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md) and [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). + +**隐私 · Privacy:** [隐私政策 · Privacy policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/privacy.md) **核验下载:** 本页 Assets 带有 `SHA256SUMS`;Windows 用 `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` 或替换为 ARM64 文件名,macOS 用 `shasum -a 256 CodexAppManager_aarch64.dmg`,再与 `SHA256SUMS` 比对。 **Verify downloads:** This release includes `SHA256SUMS` in Assets; on Windows run `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` or swap in the ARM64 filename, and on macOS run `shasum -a 256 CodexAppManager_aarch64.dmg`, then compare with `SHA256SUMS`. diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md index ac5607d..62f3f99 100644 --- a/docs/releases/TEMPLATE.md +++ b/docs/releases/TEMPLATE.md @@ -42,8 +42,10 @@ | Windows · x64 | [CodexAppManager_x64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows · ARM64 | [CodexAppManager_arm64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -**Windows 信任说明:** x64 / ARM64 安装器、主程序和卸载器均带 Authenticode 发行者签名与 RFC3161 时间戳;`.sig` / `latest.json` 中的 Tauri updater 签名另行保护应用内更新字节。SmartScreen 信誉仍由 Microsoft 独立评估,可能出现的信誉提示不等于任一签名失效。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 -**Windows trust:** The x64 / ARM64 installer, app, and uninstaller carry an Authenticode publisher signature and RFC3161 timestamp; the separate Tauri updater signature in `.sig` / `latest.json` protects in-app update bytes. Microsoft evaluates SmartScreen reputation independently, so a reputation prompt does not mean either signature failed. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). +**Windows 信任状态(本版必须据实填写):** 当前 SignPath Foundation 申请与 trusted-build 迁移尚未完成,Windows tag 发布门保持 fail-closed;不得把 `.sig` / `latest.json` 的 Tauri updater 字节签名写成 Authenticode 发行者身份。启用后仍须写明本版实际核验到的发行者、时间戳和三层 PE 状态。参见 [代码签名政策](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md)与 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 +**Windows trust (replace with this release's verified facts):** The SignPath Foundation application and trusted-build migration are still pending, so the Windows tag gate remains fail-closed. Never describe a Tauri updater signature in `.sig` / `latest.json` as Authenticode publisher identity. After activation, every release must still state the publisher, timestamp, and all three PE-layer results actually observed for that release. See the [code signing policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md) and [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). + +**隐私 · Privacy:** [隐私政策 · Privacy policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/privacy.md) **核验下载:** 本页 Assets 带有 `SHA256SUMS`;Windows 用 `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` 或替换为 ARM64 文件名,macOS 用 `shasum -a 256 CodexAppManager_aarch64.dmg`,再与 `SHA256SUMS` 比对。 **Verify downloads:** This release includes `SHA256SUMS` in Assets; on Windows run `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` or swap in the ARM64 filename, and on macOS run `shasum -a 256 CodexAppManager_aarch64.dmg`, then compare with `SHA256SUMS`. diff --git a/docs/releases/v0.3.1.md b/docs/releases/v0.3.1.md index 0074596..e29c05c 100644 --- a/docs/releases/v0.3.1.md +++ b/docs/releases/v0.3.1.md @@ -53,8 +53,10 @@ | Windows · x64 | [CodexAppManager_x64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_x64-setup.exe) | | Windows · ARM64 | [CodexAppManager_arm64-setup.exe](https://codexapp.agentsmirror.com/manager/latest/CodexAppManager_arm64-setup.exe) | -**Windows 签名状态:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` 当前没有 Authenticode 代码签名,首次运行可能出现 SmartScreen 提示;`.sig` / `latest.json` 里的 Tauri updater 签名只用于应用内自更新的字节校验,不代表 Windows 发行者信任。详情见 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 -**Windows signing status:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` are not Authenticode-signed yet, so SmartScreen may warn on first run; the Tauri updater signature in `.sig` / `latest.json` verifies in-app update bytes only and is not Windows publisher trust. See [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). +**Windows 签名状态:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` 当前没有 Authenticode 代码签名,首次运行可能出现 SmartScreen 提示;`.sig` / `latest.json` 里的 Tauri updater 签名只用于应用内自更新的字节校验,不代表 Windows 发行者信任。详情见 [代码签名政策](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md)与 [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md)。 +**Windows signing status:** `CodexAppManager_x64-setup.exe` / `CodexAppManager_arm64-setup.exe` are not Authenticode-signed yet, so SmartScreen may warn on first run; the Tauri updater signature in `.sig` / `latest.json` verifies in-app update bytes only and is not Windows publisher trust. See the [code signing policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/code-signing-policy.md) and [Windows signing and verification](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/windows-signing.md). + +**隐私 · Privacy:** [隐私政策 · Privacy policy](https://github.com/Wangnov/Codex-App-Manager/blob/main/docs/privacy.md) **核验下载:** 本页 Assets 带有 `SHA256SUMS`;Windows 用 `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` 或替换为 ARM64 文件名,macOS 用 `shasum -a 256 CodexAppManager_aarch64.dmg`,再与 `SHA256SUMS` 比对。 **Verify downloads:** This release includes `SHA256SUMS` in Assets; on Windows run `Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256` or swap in the ARM64 filename, and on macOS run `shasum -a 256 CodexAppManager_aarch64.dmg`, then compare with `SHA256SUMS`. diff --git a/docs/windows-signing.md b/docs/windows-signing.md index a7dde00..7523e31 100644 --- a/docs/windows-signing.md +++ b/docs/windows-signing.md @@ -1,128 +1,169 @@ # Windows signing and verification -The release workflow uses three independent trust mechanisms. Authenticode -identifies the Windows publisher, the Tauri updater signature protects the -bytes consumed by in-app updates, and SmartScreen reputation is a Microsoft -risk signal that neither signature can guarantee. Keep them separate when -triaging a warning or a failed release. +Windows publisher trust, Tauri updater signatures, and Microsoft SmartScreen +reputation are independent mechanisms. A `.sig` file is not Authenticode, and a +valid Authenticode signature cannot guarantee immediate SmartScreen reputation. -Historical releases keep the signing status recorded in their own release -notes. The mandatory pipeline below applies starting with the first release -built from this workflow revision. +Public policy: [code signing policy](./code-signing-policy.md) · +[privacy policy](./privacy.md) ## 中文 -### 发布状态 - -- Windows 正式发布必须提供 OV/EV Authenticode `.pfx`;缺少证书或密码会直接阻断 tag 发布。 -- x64 与 ARM64 的主程序、NSIS `uninstall.exe`、外层 `-setup.exe` 均由同一张证书签名,并使用 SHA-256 + RFC3161 时间戳。 -- CI 要求每层 `Get-AuthenticodeSignature` 都为 `Valid`、签名证书 thumbprint 与本次导入证书完全一致,并存在时间戳 countersigner。 -- PR 构建不会取得发布证书,仍以 unsigned 方式验证打包模板和安装生命周期;PR 的 optional 探测不代表正式发布可以未签名。 -- Windows 应用内更新仍另外要求 Tauri updater `.sig`;它覆盖已经完成 Authenticode 签名的最终安装器字节。 - -### 三个独立概念 - -**Authenticode 代码签名** - -Windows 对 PE 文件和安装器使用的发行者签名。本项目在 `tauri build` 前导入 PFX,并通过临时 Tauri config 配置 `certificateThumbprint`、`digestAlgorithm=sha256`、RFC3161 `timestampUrl` 与 `tsp=true`。Tauri 按 inside-out 顺序签主程序、NSIS 生成的卸载器和外层安装器。 - -**Tauri updater 签名** - -`latest.json` 的 `signature` 对应用内更新所下载的最终安装器字节签名。它防止 GitHub、镜像或传输链路改包,但不向 Windows 声明发行者身份。实现使用 `TAURI_SIGNING_PRIVATE_KEY` 和 `npx tauri signer sign`;其私钥与 Authenticode PFX 完全不同。 - -**SmartScreen 信誉** - -Microsoft Defender SmartScreen 会综合发行者、文件流行度、历史与其他风险信号。一个 Authenticode 签名可以让系统显示可验证的发行者,但不能承诺新证书或新工件绝不出现 SmartScreen 提示。看到 SmartScreen 提示也不等于 Tauri updater 签名失败。 - -### 发布硬门 - -Windows matrix 的顺序如下: - -1. `prepare-windows-authenticode.ps1` 解码并导入 PFX,生成仅用于当前 job 的 Tauri signing config。 -2. `tauri build --config ...` 完成三层 inside-out Authenticode 签名,并让自定义 NSIS 模板优先使用 bundler 准备的已签名插件目录。 -3. `verify-windows-authenticode.ps1 -Mode required` 验证外层安装器。Tauri 打包结束后会把 `target/.../release/codex-app-manager.exe` 恢复成 unsigned、未 patch 的构建输入,因此这个 raw 中间文件只用于 PE 架构诊断,不作为签名硬门。 -4. `windows-packaged-smoke.ps1` 安装包并直接验证安装后的主程序与 `uninstall.exe`。 - - x64 执行 `install → launch → upgrade → uninstall`。 - - ARM64 在 x64 runner 上执行安装、签名验证、升级和卸载,但跳过 ARM64 主程序启动;完整 ARM64 运行验收仍需 ARM64 设备或可信虚拟化。 -5. 对 Authenticode 签名后的最终 `-setup.exe` 生成独立的 Tauri updater `.sig`。 -6. 收集无空格的发布文件名后,再次对最终待发布安装器执行 required 验证。 -7. 无论成功失败都移除临时证书和 PFX 目录。 - -任何一层缺失、`Status != Valid`、证书 thumbprint 不符、没有时间戳、没有 updater `.sig`,发布 job 都失败。RFC3161 同时由生成配置中的 `tsp=true` / SHA-256 / timestamp URL 和工件上的 timestamp countersigner 证明。 - -### 必需配置 - -在 GitHub `release` environment 配置: - -| 名称 | 类型 | 用途 | -|---|---|---| -| `WINDOWS_CERTIFICATE` | secret | base64 编码的 OV/EV 代码签名 `.pfx` | -| `WINDOWS_CERTIFICATE_PASSWORD` | secret | PFX 密码;证书无密码时可以为空 | -| `WINDOWS_TIMESTAMP_URL` | variable,可选 | RFC3161 服务地址;默认 `http://timestamp.digicert.com` | -| `TAURI_SIGNING_PRIVATE_KEY` | secret | Tauri updater 私钥,不是 Authenticode 证书 | -| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | secret | updater 私钥密码 | - -不再使用 `AUTHENTICODE_REQUIRED` 开关:tag release 永远是 required。不要把 PFX 暴露给 `pull_request` workflow,也不要把真实证书提交进仓库。 - -### 用户核验 - -在 Windows 上可直接查看三层签名: - -```powershell -Get-AuthenticodeSignature .\CodexAppManager_x64-setup.exe | Format-List Status,SignerCertificate,TimeStamperCertificate -Get-AuthenticodeSignature "$env:LOCALAPPDATA\Codex App Manager\codex-app-manager.exe" | Format-List Status,SignerCertificate,TimeStamperCertificate -Get-AuthenticodeSignature "$env:LOCALAPPDATA\Codex App Manager\uninstall.exe" | Format-List Status,SignerCertificate,TimeStamperCertificate -``` - -发布页仍提供 `SHA256SUMS`,用于确认下载文件与 release 资产逐字节一致: +### 当前状态(申请/迁移中) + +- 项目正在申请 **SignPath Foundation**。截至 2026-07-11,申请尚未获批,证书尚未签发, + GitHub trusted-build 签名集成尚未上线。 +- 当前已发布的 Windows x64 / ARM64 安装器**没有 Authenticode 发行者签名**,首次运行 + 可能出现 SmartScreen 提示。每个历史版本的实际状态以其 release note 为准。 +- `.sig` / `latest.json` 中已有的 Tauri updater 签名只校验应用内更新下载到的字节,不会 + 让 Windows 显示可信发行者。 +- tag 工作流中的 Windows job 当前会在构建前执行 + `assert-signpath-foundation-ready.ps1` 并故意失败。因为发布 job 依赖所有 matrix build, + 所以不会发布缺少 Windows 或 unsigned Windows 的不完整 release。 +- 仓库不再接受 `WINDOWS_CERTIFICATE`、PFX 密码或临时 Tauri thumbprint config。给 + environment 填入一个证书 secret 不能绕过阻断。 + +### 为什么没有直接接入 SignPath action + +SignPath Foundation 的公开 GitHub 流程是:可信 GitHub Actions 产生工件 → 把工件提交给 +SignPath → 对每次请求进行人工批准 → 下载签名后的工件。它不是本地 PFX,也不能直接当作 +Tauri `signCommand` 使用。 + +当前 Tauri/NSIS 的 inside-out 签名路径会在打包期间处理主程序、卸载器、外层安装器,且 +可能准备已签名的第三方 NSIS 插件副本;而 Foundation 证书只能用于本项目允许的开源 +工件,不能把第三方插件冒充项目自有二进制直接签名。SignPath 对 PE 的工件模型也不能被 +未经验证地当成会递归处理 NSIS 内嵌 payload。因此,本仓库不会先猜一个“两轮签名”顺序 +并把它当作生产方案。 + +### 启用前必须关闭的阻塞项 + +一个独立 PR 必须提供真实的 SignPath 测试项目/证书证据,并完成: + +1. **Trusted build**:签名请求绑定本公开仓库、受保护 tag、commit、workflow run 与准确 + 版本;任何 PR 代码都拿不到签名凭据。 +2. **人工批准**:每个 release 请求由明确的 SignPath approver 单独审核,不能自动批准。 +3. **工件配置**:明确哪些文件属于本项目,排除第三方 NSIS/Tauri 插件直接签名;产品名 + 与版本资源必须受 SignPath 规则约束。 +4. **可复现试签**:对 x64 与 ARM64 进行试签,证明最终安装器、安装后的主程序和 + `uninstall.exe` 都得到预期的 SignPath Foundation 发行者身份及有效时间戳。 +5. **NSIS 顺序**:用真实工件证明可行的打包/签名顺序;在证据出现前,不实现两轮签名 + 流水线,也不声称 SignPath 会深入签署 NSIS payload。 +6. **发布字节绑定**:在 Authenticode 完成后生成 Tauri updater `.sig`,再核对 GitHub + Release、R2 与 IHEP 最终上传文件的 hash 与已验证工件一致。 +7. **运行验收**:x64 完整执行 install → launch → upgrade → uninstall;ARM64 至少完成 + 安装、签名、升级和卸载检查,并在 ARM64 设备或可信虚拟化上补主程序启动。 + +只有上述证据经过 review,才可以用已批准的 trusted-build action 替换 fail-closed 脚本。 +仅删除阻断脚本仍不够:后续 `required` 验证会拒绝 `NotSigned`、错误发行者或缺少时间戳的 +工件。 + +### 预留的验证工具 + +- [`scripts/assert-signpath-foundation-ready.ps1`](../scripts/assert-signpath-foundation-ready.ps1) + — 当前正式发布硬阻断;故意没有成功路径或 secret 开关。 +- [`scripts/verify-windows-authenticode.ps1`](../scripts/verify-windows-authenticode.ps1) + — `required` 模式验证 Windows 报告的签名状态、预期 subject 与时间戳;`optional` 只供 + unsigned PR / 本地诊断。时间戳 countersigner 本身不足以证明所有 RFC3161 细节,试签还 + 必须检查 SignPath policy 与 `signtool verify /pa /all /v` 输出。 +- [`scripts/windows-packaged-smoke.ps1`](../scripts/windows-packaged-smoke.ps1) + — 安装后检查主程序与卸载器,并覆盖升级/卸载路径。 +- [`scripts/windows-pe-arch.ps1`](../scripts/windows-pe-arch.ps1) + — 断言 x64 / ARM64 主程序的 PE machine type;cross-build 不等同于 ARM64 运行验证。 + +仓库中不存在生产可用的 SignPath token、organization/project slug、signing policy slug 或 +artifact configuration slug。在申请获批前不要猜测这些值,也不要新增 PFX fallback。 + +### 用户如何核验现有版本 + +当前版本预期会显示 `NotSigned`;下面的命令用于核对实际文件,而不是把 updater `.sig` +误认成 Authenticode: ```powershell +Get-AuthenticodeSignature .\CodexAppManager_x64-setup.exe | + Format-List Status,StatusMessage,SignerCertificate,TimeStamperCertificate Get-FileHash .\CodexAppManager_x64-setup.exe -Algorithm SHA256 -Get-FileHash .\CodexAppManager_arm64-setup.exe -Algorithm SHA256 ``` -哈希、Authenticode、updater `.sig` 各自回答不同问题,不能互相替代。 - -### 相关脚本 - -- [`scripts/prepare-windows-authenticode.ps1`](../scripts/prepare-windows-authenticode.ps1) — 正式发布导入证书并生成 Tauri 配置;凭据缺失即失败。 -- [`scripts/verify-windows-authenticode.ps1`](../scripts/verify-windows-authenticode.ps1) — `required` 发布硬门;`optional` 仅供无 secret 的 PR / 本地诊断。 -- [`scripts/windows-packaged-smoke.ps1`](../scripts/windows-packaged-smoke.ps1) — 安装后验证主程序与卸载器,并覆盖升级、卸载路径。 -- [`scripts/sign-windows-authenticode.ps1`](../scripts/sign-windows-authenticode.ps1) — 旧的本地单文件签名工具;正式发布不依赖它。 -- [`scripts/windows-pe-arch.ps1`](../scripts/windows-pe-arch.ps1) — 验证 x64 / ARM64 PE machine type。 +文件 hash 应与对应 GitHub Release 的 `SHA256SUMS` 比对。未来启用 SignPath 后,每个 +release note 仍必须按真实验证结果说明该版的 Windows 签名状态。 ## English -### Release state - -- Windows releases require an OV/EV Authenticode PFX. Missing credentials hard-fail the tag build. -- The x64 and ARM64 main executable, generated NSIS uninstaller, and outer setup executable are signed by the same certificate with SHA-256 and an RFC3161 timestamp. -- CI requires `Get-AuthenticodeSignature.Status == Valid`, an exact match to the imported certificate thumbprint, and a timestamp countersigner on every layer. -- Pull requests do not receive release secrets. They intentionally build unsigned packages and run optional probes plus lifecycle smoke; this does not make unsigned release artifacts acceptable. -- In-app updates separately require a Tauri updater `.sig` over the final Authenticode-signed setup bytes. - -### Three independent mechanisms - -**Authenticode** is Windows publisher identity for PE files. The release job imports the PFX and gives Tauri a private config with `certificateThumbprint`, SHA-256, an RFC3161 timestamp URL, and `tsp=true`. Tauri signs the main executable, generated uninstaller, and outer NSIS installer inside-out; the custom template also selects Tauri's private signed NSIS plugin directory. - -**The Tauri updater signature** authenticates the final bytes referenced by `latest.json`. It protects downloads from tampering across GitHub, the mirror, and transport hops. It does not establish a Windows publisher. Its private key is separate from the Authenticode PFX. - -**SmartScreen reputation** is a Microsoft risk signal based on more than the presence of a signature. Authenticode provides a verifiable publisher but cannot promise that a new certificate or artifact will never trigger a warning. A SmartScreen warning is also not evidence that the updater signature failed. - -### Release gate - -The Windows jobs prepare the certificate, build with inside-out signing, verify the outer setup executable, install each architecture's package to inspect the signed embedded main/uninstaller, create the independent updater signature, collect the final names, and verify the publishable setup again. Tauri restores `target/.../release/codex-app-manager.exe` to its unsigned, unpatched build input after bundling, so that raw intermediate is used only for the PE architecture diagnostic and is not a signature gate. x64 runs the full launch lifecycle; ARM64 performs install/signature/upgrade/uninstall checks on the x64 host but requires ARM64 hardware or trusted virtualization for a real launch test. - -There is no `AUTHENTICODE_REQUIRED` toggle for a tag release. Any missing layer, non-`Valid` status, wrong thumbprint, absent timestamp, or missing updater signature blocks publication. The PFX and imported certificate are removed in an `always()` cleanup step. - -### Required release environment - -| Name | Kind | Purpose | -|---|---|---| -| `WINDOWS_CERTIFICATE` | secret | base64-encoded OV/EV code-signing PFX | -| `WINDOWS_CERTIFICATE_PASSWORD` | secret | PFX password; may be empty for an unprotected PFX | -| `WINDOWS_TIMESTAMP_URL` | optional variable | RFC3161 endpoint; defaults to `http://timestamp.digicert.com` | -| `TAURI_SIGNING_PRIVATE_KEY` | secret | Tauri updater private key, not the Authenticode certificate | -| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | secret | updater-key password | - -Never expose the PFX to a pull-request workflow or commit certificate material to the repository. Historical release notes retain the actual status of those older artifacts. +### Current state (application/migration pending) + +- The project is applying to **SignPath Foundation**. As of 2026-07-11, the + application has not been approved, no certificate has been issued, and no + GitHub trusted-build signing integration is active. +- Published Windows x64/ARM64 installers are **not Authenticode-signed** today + and may trigger SmartScreen. The release note for each historical version is + the source of truth for that artifact. +- Existing Tauri updater signatures in `.sig` / `latest.json` authenticate the + downloaded update bytes only; they do not establish a Windows publisher. +- Windows tag jobs intentionally fail before building by running + `assert-signpath-foundation-ready.ps1`. The publish job requires the complete + build matrix, so it cannot produce a partial or silently unsigned release. +- This repository no longer accepts `WINDOWS_CERTIFICATE`, PFX passwords, or a + generated Tauri thumbprint config. Adding a certificate secret cannot bypass + the blocker. + +### Why there is no SignPath action yet + +The public SignPath Foundation GitHub flow submits an artifact from a trusted +GitHub Actions build, requires manual approval for each request, and returns a +signed artifact. It is not a local PFX and cannot be used directly as Tauri's +`signCommand`. + +Tauri/NSIS inside-out signing operates during packaging across the app, +uninstaller, outer installer, and potentially prepared copies of third-party +NSIS plugins. A Foundation certificate must not directly sign third-party +plugins as project-owned binaries. SignPath's PE artifact model must also not be +assumed to recursively sign an embedded NSIS payload without evidence. This +repository therefore does not implement or claim an unverified two-pass flow. + +### Blockers that must be closed before activation + +A separate PR must use a real SignPath test project/certificate to demonstrate: + +1. trusted origin binding to this public repository, protected tag, commit, + workflow run, and exact version, without exposing credentials to PR code; +2. manual approval of every release request by an explicit SignPath approver; +3. artifact rules that bind product/version metadata and exclude direct signing + of third-party NSIS/Tauri plugins; +4. reproducible x64 and ARM64 pilot signing for the final setup executable, + installed app, and `uninstall.exe`, with the expected SignPath Foundation + publisher and valid timestamp; +5. a proven NSIS packaging/signing order rather than an assumed two-pass design; +6. a Tauri updater `.sig` created only after Authenticode, followed by hash + equality across the verified artifact, GitHub Release, R2, and IHEP; and +7. x64 install/launch/upgrade/uninstall smoke plus native ARM64 launch evidence. + +Only reviewed evidence can replace the fail-closed script with the approved +trusted-build action. Deleting the blocker alone is insufficient: downstream +required checks must still reject `NotSigned`, an unexpected publisher, or a +missing timestamp. + +### Reserved verification tools + +- `assert-signpath-foundation-ready.ps1` is the current hard blocker and has no + success path or secret-controlled bypass. +- `verify-windows-authenticode.ps1` checks Windows signature status, expected + subject, and timestamp in required mode; optional mode is for intentionally + unsigned PR/local diagnostics. A timestamp countersigner alone does not prove + every RFC3161 detail, so the pilot must also review SignPath policy and + `signtool verify /pa /all /v` output. +- `windows-packaged-smoke.ps1` verifies installed PE files and exercises the + package lifecycle. +- `windows-pe-arch.ps1` asserts the app PE architecture; cross-building ARM64 + does not replace a native ARM64 launch test. + +There is no production SignPath token, organization/project slug, signing +policy slug, or artifact-configuration slug in this repository. Do not invent +those values or add a PFX fallback before approval. + +### Verifying current releases + +Current artifacts are expected to report `NotSigned`. Use +`Get-AuthenticodeSignature` to inspect the actual file and compare its SHA-256 +hash with the matching release's `SHA256SUMS`; do not treat a Tauri updater +`.sig` as publisher identity. After SignPath activation, each release note must +still state that release's observed Windows signing status. diff --git a/eslint.config.js b/eslint.config.js index 104698a..faf277f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,7 @@ export default tseslint.config( "src-tauri/", "website/", "cloudflare/", + "vendor/", "scripts/", "docs/", "*.config.*", diff --git a/scripts/assert-signpath-foundation-ready.ps1 b/scripts/assert-signpath-foundation-ready.ps1 new file mode 100644 index 0000000..c1423fc --- /dev/null +++ b/scripts/assert-signpath-foundation-ready.ps1 @@ -0,0 +1,24 @@ +# Fail-closed release placeholder for the SignPath Foundation migration. +# +# This script intentionally has no success path. Remove or replace it only in a +# reviewed change that implements an approved SignPath trusted-build flow, +# manual per-release approval, project-owned artifact boundaries, and final +# Authenticode/timestamp verification. Setting a secret or variable must never +# bypass this guard by itself. + +[CmdletBinding()] +param( + [string]$Stage = "signpath-readiness" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$message = @( + "Windows release signing is blocked: the SignPath Foundation application and trusted-build migration are not complete." + "No certificate has been issued and no production signing integration is active." + "See docs/code-signing-policy.md and docs/windows-signing.md." +) -join " " + +Write-Host "::error title=Windows release is fail-closed::[$Stage] $message" +throw "[$Stage] $message" diff --git a/scripts/gen-updater-manifest.mjs b/scripts/gen-updater-manifest.mjs index a7001e3..aa98abc 100644 --- a/scripts/gen-updater-manifest.mjs +++ b/scripts/gen-updater-manifest.mjs @@ -103,6 +103,29 @@ if (missing.length > 0) { } writeFileSync("latest.json", JSON.stringify(manifest, null, 2)); + +// `latest.json` remains URL-rewritten on the CN mirror for compatibility with +// already-shipped clients, so it cannot itself have one cross-provider +// signature. Sign this deterministic URL-free identity instead. New clients +// require the identity before trusting any mirror's version/signature claim. +// Deliberately exclude pub_date/build time so a rerun for the same release +// produces byte-identical identity JSON. +const releaseIdentity = { + schema: 1, + version, + notes_sha256: createHash("sha256").update(notes, "utf8").digest("hex"), + platforms: Object.fromEntries( + resolved.map(({ platform, artifact, sha256 }) => [ + platform, + { + artifact, + signature: platforms[platform].signature, + sha256, + }, + ]), + ), +}; +writeFileSync("release-identity.json", JSON.stringify(releaseIdentity, null, 2) + "\n"); writeFileSync( "manifest-summary.json", JSON.stringify( @@ -124,3 +147,4 @@ writeFileSync( ) + "\n", ); console.log("wrote latest.json:\n" + JSON.stringify(manifest, null, 2)); +console.log("wrote release-identity.json:\n" + JSON.stringify(releaseIdentity, null, 2)); diff --git a/scripts/gen-updater-manifest.test.mjs b/scripts/gen-updater-manifest.test.mjs new file mode 100644 index 0000000..3fdea47 --- /dev/null +++ b/scripts/gen-updater-manifest.test.mjs @@ -0,0 +1,57 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = join(dirname(fileURLToPath(import.meta.url)), "gen-updater-manifest.mjs"); +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("release identity generation", () => { + it("is byte-stable across reruns and excludes the manifest clock", () => { + const root = mkdtempSync(join(tmpdir(), "cam-release-identity-")); + tempDirs.push(root); + mkdirSync(join(root, "dist")); + mkdirSync(join(root, "docs", "releases"), { recursive: true }); + writeFileSync(join(root, "docs", "releases", "v1.2.3.md"), "Reviewed notes\n"); + + const fixtures = [ + ["CodexAppManager_aarch64.app.tar.gz", "mac-arm"], + ["CodexAppManager_x86_64.app.tar.gz", "mac-x64"], + ["CodexAppManager_1.2.3_x64-setup.exe", "win-x64"], + ["CodexAppManager_1.2.3_arm64-setup.exe", "win-arm"], + ]; + for (const [name, bytes] of fixtures) { + writeFileSync(join(root, "dist", name), bytes); + writeFileSync(join(root, "dist", `${name}.sig`), `signature-${name}`); + } + + const run = () => { + const result = spawnSync(process.execPath, [script, "v1.2.3", "dist"], { + cwd: root, + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + return readFileSync(join(root, "release-identity.json")); + }; + const first = run(); + const second = run(); + expect(second.equals(first)).toBe(true); + + const identity = JSON.parse(first.toString("utf8")); + expect(identity).not.toHaveProperty("pub_date"); + expect(identity.notes_sha256).toBe( + createHash("sha256").update("Reviewed notes", "utf8").digest("hex"), + ); + expect(identity.platforms["windows-x86_64"]).toMatchObject({ + artifact: "CodexAppManager_1.2.3_x64-setup.exe", + sha256: createHash("sha256").update("win-x64").digest("hex"), + }); + }); +}); diff --git a/scripts/minisign-verify.mjs b/scripts/minisign-verify.mjs new file mode 100644 index 0000000..9f332a8 --- /dev/null +++ b/scripts/minisign-verify.mjs @@ -0,0 +1,108 @@ +import { createHash, createPublicKey, timingSafeEqual, verify } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PUBLIC_KEY_PACKET_BYTES = 42; +const SIGNATURE_PACKET_BYTES = 74; +const ED25519_SIGNATURE_BYTES = 64; +const TRUSTED_COMMENT_PREFIX = "trusted comment: "; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +const decodeBase64 = (value, label) => { + const compact = String(value).trim(); + if (!compact) throw new Error(`${label} is empty`); + const decoded = Buffer.from(compact, "base64"); + if (decoded.length === 0) throw new Error(`${label} is not valid base64`); + return decoded; +}; + +const decodeTauriPublicKey = (encodedPublicKey) => { + const text = decodeBase64(encodedPublicKey, "Tauri updater public key").toString("utf8"); + const lines = text.trim().split(/\r?\n/); + const packet = decodeBase64(lines.at(-1), "minisign public key packet"); + if (packet.length !== PUBLIC_KEY_PACKET_BYTES) { + throw new Error("minisign public key packet has an invalid length"); + } + const algorithm = packet.subarray(0, 2).toString("ascii"); + if (algorithm !== "Ed" && algorithm !== "ED") { + throw new Error("minisign public key uses an unsupported algorithm"); + } + return { + keyId: packet.subarray(2, 10), + key: createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, packet.subarray(10)]), + format: "der", + type: "spki", + }), + }; +}; + +const decodeTauriSignature = (encodedSignature) => { + const text = decodeBase64(encodedSignature, "Tauri updater signature").toString("utf8"); + const lines = text.trim().split(/\r?\n/); + if (lines.length !== 4 || !lines[2].startsWith(TRUSTED_COMMENT_PREFIX)) { + throw new Error("minisign signature has an invalid four-line envelope"); + } + const packet = decodeBase64(lines[1], "minisign signature packet"); + const globalSignature = decodeBase64(lines[3], "minisign global signature"); + if ( + packet.length !== SIGNATURE_PACKET_BYTES || + globalSignature.length !== ED25519_SIGNATURE_BYTES + ) { + throw new Error("minisign signature packet has an invalid length"); + } + const algorithm = packet.subarray(0, 2).toString("ascii"); + if (algorithm !== "Ed" && algorithm !== "ED") { + throw new Error("minisign signature uses an unsupported algorithm"); + } + return { + algorithm, + keyId: packet.subarray(2, 10), + signature: packet.subarray(10), + trustedComment: lines[2].slice(TRUSTED_COMMENT_PREFIX.length), + globalSignature, + }; +}; + +export const verifyTauriMinisign = (bytes, encodedSignature, encodedPublicKey) => { + const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); + const publicKey = decodeTauriPublicKey(encodedPublicKey); + const signature = decodeTauriSignature(encodedSignature); + if (!timingSafeEqual(publicKey.keyId, signature.keyId)) { + throw new Error("minisign signature key id does not match the updater public key"); + } + + const message = + signature.algorithm === "ED" ? createHash("blake2b512").update(data).digest() : data; + if (!verify(null, message, publicKey.key, signature.signature)) { + throw new Error("minisign content signature verification failed"); + } + const globalMessage = Buffer.concat([ + signature.signature, + Buffer.from(signature.trustedComment, "utf8"), + ]); + if (!verify(null, globalMessage, publicKey.key, signature.globalSignature)) { + throw new Error("minisign trusted-comment signature verification failed"); + } + return true; +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [, , filePath, signaturePath, configPath = "src-tauri/tauri.conf.json"] = process.argv; + if (!filePath || !signaturePath) { + console.error("usage: minisign-verify.mjs [tauri.conf.json]"); + process.exit(2); + } + try { + const config = JSON.parse(readFileSync(configPath, "utf8")); + verifyTauriMinisign( + readFileSync(filePath), + readFileSync(signaturePath, "utf8").trim(), + config?.plugins?.updater?.pubkey ?? "", + ); + console.log(`verified minisign signature: ${filePath}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/minisign-verify.test.mjs b/scripts/minisign-verify.test.mjs new file mode 100644 index 0000000..5c3ca74 --- /dev/null +++ b/scripts/minisign-verify.test.mjs @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { verifyTauriMinisign } from "./minisign-verify.mjs"; +import { + reuseGithubReleaseIdentitySignature, + reuseReleaseIdentitySignature, +} from "./reuse-release-identity-signature.mjs"; + +const RAW_PUBLIC_KEY = + "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3"; +const RAW_SIGNATURE = `untrusted comment: signature from minisign secret key +RUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo= +trusted comment: timestamp:1633700835\tfile:test\tprehashed +wLMDjy9FLAuxZ3q4NlEvkgtyhrr0gtTu6KC4KBJdITbbOeAi1zBIYo0v4iTgt8jJpIidRJnp94ABQkJAgAooBQ==`; +const TAURI_PUBLIC_KEY = Buffer.from( + `untrusted comment: minisign public key\n${RAW_PUBLIC_KEY}\n`, +).toString("base64"); +const TAURI_SIGNATURE = Buffer.from(`${RAW_SIGNATURE}\n`).toString("base64"); + +const response = (body, status = 200) => + new Response(body, { + status, + headers: body == null ? {} : { "content-length": String(Buffer.byteLength(body)) }, + }); + +describe("release identity minisign", () => { + it("verifies the Tauri base64 minisign envelope and rejects changed bytes", () => { + expect(verifyTauriMinisign(Buffer.from("test"), TAURI_SIGNATURE, TAURI_PUBLIC_KEY)).toBe(true); + expect(() => + verifyTauriMinisign(Buffer.from("tampered"), TAURI_SIGNATURE, TAURI_PUBLIC_KEY), + ).toThrow(/content signature verification failed/); + }); + + it("reuses the exact first-stage signature on a release rerun", async () => { + const identityBytes = Buffer.from("test"); + const missingFetch = async () => response(null, 404); + await expect( + reuseReleaseIdentitySignature({ + identityBytes, + version: "1.0.0", + mirrorBase: "https://mirror.example/manager", + publicKey: TAURI_PUBLIC_KEY, + fetchImpl: missingFetch, + }), + ).resolves.toBeNull(); + + const rerunFetch = async (url) => + response(url.pathname.endsWith(".sig") ? TAURI_SIGNATURE : identityBytes); + const reused = await reuseReleaseIdentitySignature({ + identityBytes, + version: "1.0.0", + mirrorBase: "https://mirror.example/manager", + publicKey: TAURI_PUBLIC_KEY, + fetchImpl: rerunFetch, + }); + + expect(reused.toString("utf8")).toBe(`${TAURI_SIGNATURE}\n`); + }); + + it("reuses an already-published GitHub Release signature before consulting mirrors", async () => { + const identityBytes = Buffer.from("test"); + const calls = []; + const fetchImpl = async (url) => { + calls.push(String(url)); + if (String(url).includes("/releases/tags/v1.0.0")) { + return response( + JSON.stringify({ + assets: [ + { name: "release-identity.json", url: "https://api.github.test/assets/1" }, + { name: "release-identity.json.sig", url: "https://api.github.test/assets/2" }, + ], + }), + ); + } + if (String(url).endsWith("/assets/1")) return response(identityBytes); + if (String(url).endsWith("/assets/2")) return response(TAURI_SIGNATURE); + throw new Error(`unexpected URL ${url}`); + }; + + const reused = await reuseGithubReleaseIdentitySignature({ + identityBytes, + version: "1.0.0", + repository: "owner/repo", + tag: "v1.0.0", + token: "test-token", + publicKey: TAURI_PUBLIC_KEY, + fetchImpl, + }); + + expect(reused.toString("utf8")).toBe(`${TAURI_SIGNATURE}\n`); + expect(calls).toEqual([ + "https://api.github.com/repos/owner/repo/releases/tags/v1.0.0", + "https://api.github.test/assets/1", + "https://api.github.test/assets/2", + ]); + }); + + it("fails closed when an immutable prior identity differs", async () => { + const fetchImpl = async (url) => + response(url.pathname.endsWith(".sig") ? TAURI_SIGNATURE : "different"); + await expect( + reuseReleaseIdentitySignature({ + identityBytes: Buffer.from("test"), + version: "1.0.0", + mirrorBase: "https://mirror.example/manager", + publicKey: TAURI_PUBLIC_KEY, + fetchImpl, + }), + ).rejects.toThrow(/immutable release identity differs/); + }); +}); diff --git a/scripts/prepare-windows-authenticode.ps1 b/scripts/prepare-windows-authenticode.ps1 deleted file mode 100644 index d939905..0000000 --- a/scripts/prepare-windows-authenticode.ps1 +++ /dev/null @@ -1,103 +0,0 @@ -# Import the release code-signing certificate and generate the Tauri config used -# for inside-out Windows signing (main executable, NSIS uninstaller, installer). -# -# This script is intentionally release-only: missing credentials are fatal. Pull -# request workflows must never receive the PFX secret; they keep building unsigned -# installers and exercise the same NSIS template in optional verification mode. - -[CmdletBinding()] -param( - [string]$CertificateBase64 = $env:WINDOWS_CERTIFICATE, - [string]$CertificatePassword = $env:WINDOWS_CERTIFICATE_PASSWORD, - [string]$TimestampUrl = $(if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { "http://timestamp.digicert.com" }), - [string]$ConfigPath = $(Join-Path ([System.IO.Path]::GetTempPath()) "tauri-authenticode.conf.json"), - [string]$Stage = "sign-prepare" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function Fail-Stage([string]$Message) { - Write-Host "::error::[$Stage] $Message" - throw "[$Stage] $Message" -} - -if ([string]::IsNullOrWhiteSpace($CertificateBase64)) { - Fail-Stage "WINDOWS_CERTIFICATE is required for a release build" -} -if ([string]::IsNullOrWhiteSpace($TimestampUrl)) { - Fail-Stage "WINDOWS_TIMESTAMP_URL must resolve to an RFC3161 timestamp service" -} - -$timestamp = $null -try { - $timestamp = [Uri]$TimestampUrl -} -catch { - Fail-Stage "WINDOWS_TIMESTAMP_URL is not a valid absolute URL" -} -if (-not $timestamp.IsAbsoluteUri -or $timestamp.Scheme -notin @("http", "https")) { - Fail-Stage "WINDOWS_TIMESTAMP_URL must use http or https" -} - -$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cam-authenticode-" + [guid]::NewGuid().ToString("n")) -New-Item -ItemType Directory -Path $tempDir | Out-Null -$pfxPath = Join-Path $tempDir "codesign.pfx" - -try { - [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($CertificateBase64.Trim())) - $securePass = if ([string]::IsNullOrEmpty($CertificatePassword)) { - New-Object System.Security.SecureString - } - else { - ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force - } - - $imported = @(Import-PfxCertificate ` - -FilePath $pfxPath ` - -CertStoreLocation Cert:\CurrentUser\My ` - -Password $securePass) - $certificate = $imported | - Where-Object { $_.HasPrivateKey } | - Select-Object -First 1 - if (-not $certificate) { - Fail-Stage "the imported PFX contains no certificate with a private key" - } - - $thumbprint = $certificate.Thumbprint.Replace(" ", "").ToUpperInvariant() - $configDir = Split-Path -Parent $ConfigPath - if ($configDir) { - New-Item -ItemType Directory -Path $configDir -Force | Out-Null - } - @{ - bundle = @{ - windows = @{ - certificateThumbprint = $thumbprint - digestAlgorithm = "sha256" - timestampUrl = $TimestampUrl - tsp = $true - } - } - } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ConfigPath -Encoding utf8 - - Write-Host "[$Stage] imported subject=$($certificate.Subject) thumbprint=$thumbprint" - Write-Host "[$Stage] generated Tauri Authenticode config: $ConfigPath" - - if ($env:GITHUB_ENV) { - "TAURI_AUTHENTICODE_CONFIG=$ConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "WINDOWS_CERTIFICATE_TEMP_DIR=$tempDir" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - } - if ($env:GITHUB_OUTPUT) { - "thumbprint=$thumbprint" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - "config=$ConfigPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - } -} -catch { - if (Test-Path -LiteralPath $tempDir) { - Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - throw -} - -return diff --git a/scripts/release-identity-workflow.test.mjs b/scripts/release-identity-workflow.test.mjs new file mode 100644 index 0000000..940ad27 --- /dev/null +++ b/scripts/release-identity-workflow.test.mjs @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("release identity workflow wiring", () => { + it("publishes one latest.json asset while retaining versioned identity assets", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"); + expect(workflow).toContain("cp release-identity.json release-identity.json.sig dist/"); + expect(workflow).not.toMatch(/cp latest\.json release-identity\.json/); + expect(workflow.match(/rm -f dist\/latest\.json dist\/latest\.mirror\.json/g)).toHaveLength(2); + expect(workflow).toMatch(/files:\s*\|[\s\S]*?dist\/\*[\s\S]*?\n\s+latest\.json/); + }); +}); diff --git a/scripts/reuse-release-identity-signature.mjs b/scripts/reuse-release-identity-signature.mjs new file mode 100644 index 0000000..bd50935 --- /dev/null +++ b/scripts/reuse-release-identity-signature.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { verifyTauriMinisign } from "./minisign-verify.mjs"; + +const IDENTITY_MAX_BYTES = 256 * 1024; +const SIGNATURE_MAX_BYTES = 16 * 1024; + +export const fetchBounded = async (fetchImpl, url, maxBytes, label, options = {}) => { + const response = await fetchImpl(url, { redirect: "follow", ...options }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}`); + + const announced = Number(response.headers.get("content-length")); + if (Number.isFinite(announced) && announced > maxBytes) { + throw new Error(`${label} exceeds ${maxBytes}-byte limit (announced ${announced})`); + } + if (!response.body) return Buffer.alloc(0); + + const chunks = []; + let observed = 0; + for await (const chunk of response.body) { + const bytes = Buffer.from(chunk); + observed += bytes.length; + if (!Number.isSafeInteger(observed) || observed > maxBytes) { + await response.body.cancel().catch(() => {}); + throw new Error(`${label} exceeds ${maxBytes}-byte limit (observed ${observed})`); + } + chunks.push(bytes); + } + return Buffer.concat(chunks, observed); +}; + +const verifyReusableSignature = ({ identityBytes, remoteIdentity, remoteSignature, publicKey }) => { + if (remoteIdentity && !remoteIdentity.equals(identityBytes)) { + throw new Error("prior immutable release identity differs from the local canonical bytes"); + } + if (!remoteSignature) return null; + const encodedSignature = remoteSignature.toString("utf8").trim(); + verifyTauriMinisign(identityBytes, encodedSignature, publicKey); + return Buffer.from(`${encodedSignature}\n`, "utf8"); +}; + +export const reuseGithubReleaseIdentitySignature = async ({ + identityBytes, + version, + repository, + tag, + token, + publicKey, + fetchImpl = fetch, +}) => { + if (!repository || !tag || tag.replace(/^v/, "") !== version) return null; + const headers = { + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; + const metadataBytes = await fetchBounded( + fetchImpl, + `https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, + 1024 * 1024, + "prior GitHub Release metadata", + { headers }, + ); + if (!metadataBytes) return null; + const release = JSON.parse(metadataBytes.toString("utf8")); + const assets = Array.isArray(release.assets) ? release.assets : []; + const identityAsset = assets.find((asset) => asset?.name === "release-identity.json"); + const signatureAsset = assets.find((asset) => asset?.name === "release-identity.json.sig"); + if (!identityAsset && !signatureAsset) return null; + + const assetHeaders = { ...headers, accept: "application/octet-stream" }; + const [remoteIdentity, remoteSignature] = await Promise.all([ + identityAsset + ? fetchBounded( + fetchImpl, + identityAsset.url, + IDENTITY_MAX_BYTES, + "prior GitHub release identity", + { headers: assetHeaders }, + ) + : null, + signatureAsset + ? fetchBounded( + fetchImpl, + signatureAsset.url, + SIGNATURE_MAX_BYTES, + "prior GitHub identity signature", + { headers: assetHeaders }, + ) + : null, + ]); + return verifyReusableSignature({ identityBytes, remoteIdentity, remoteSignature, publicKey }); +}; + +export const reuseReleaseIdentitySignature = async ({ + identityBytes, + version, + mirrorBase, + publicKey, + fetchImpl = fetch, +}) => { + if (!/^[0-9A-Za-z.+-]+$/.test(version)) { + throw new Error("release identity has an unsafe version"); + } + const base = new URL(`${mirrorBase.replace(/\/+$/, "")}/${encodeURIComponent(version)}/`); + const identityUrl = new URL("release-identity.json", base); + const signatureUrl = new URL("release-identity.json.sig", base); + const [remoteIdentity, remoteSignature] = await Promise.all([ + fetchBounded(fetchImpl, identityUrl, IDENTITY_MAX_BYTES, "prior release identity"), + fetchBounded(fetchImpl, signatureUrl, SIGNATURE_MAX_BYTES, "prior identity signature"), + ]); + + return verifyReusableSignature({ identityBytes, remoteIdentity, remoteSignature, publicKey }); +}; + +const main = async () => { + const [, , identityPath, signaturePath, configPath = "src-tauri/tauri.conf.json"] = process.argv; + if (!identityPath || !signaturePath) { + console.error( + "usage: reuse-release-identity-signature.mjs [tauri.conf.json]", + ); + process.exit(2); + } + const identityBytes = readFileSync(identityPath); + const identity = JSON.parse(identityBytes.toString("utf8")); + const config = JSON.parse(readFileSync(configPath, "utf8")); + const mirrorBase = process.env.MANAGER_MIRROR_BASE_URL ?? "https://codexapp.agentsmirror.com/manager"; + const publicKey = config?.plugins?.updater?.pubkey ?? ""; + let reused; + try { + const version = String(identity.version ?? ""); + try { + reused = await reuseGithubReleaseIdentitySignature({ + identityBytes, + version, + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.GITHUB_REF_NAME, + token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, + publicKey, + }); + } catch (error) { + if (!(error instanceof TypeError && /fetch/i.test(error.message))) throw error; + console.warn(`prior GitHub identity lookup unavailable: ${error.message}`); + } + if (!reused) { + reused = await reuseReleaseIdentitySignature({ + identityBytes, + version, + mirrorBase, + publicKey, + }); + } + } catch (error) { + if (error instanceof TypeError && /fetch/i.test(error.message)) { + console.warn(`prior mirror identity lookup unavailable: ${error.message}`); + process.exit(3); + } + throw error; + } + if (!reused) process.exit(3); + writeFileSync(signaturePath, reused); + console.log(`reused verified immutable identity signature for ${identity.version}`); +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/sign-windows-authenticode.ps1 b/scripts/sign-windows-authenticode.ps1 deleted file mode 100644 index 1fc230d..0000000 --- a/scripts/sign-windows-authenticode.ps1 +++ /dev/null @@ -1,129 +0,0 @@ -# Authenticode-sign Windows PE files when a code-signing certificate is available. -# -# Non-blocking milestone: if WINDOWS_CERTIFICATE (base64 PFX) is unset/empty, -# the script prints a clear skip message and exits 0. Wire secrets into the -# `release` environment, then set repo variable AUTHENTICODE_REQUIRED=true to -# make verify-windows-authenticode.ps1 enforce Valid signatures. -# -# Signing order for a full release (see docs/windows-signing.md): -# 1. Prefer signing during `tauri build` via certificateThumbprint once the -# cert is imported (covers main binary + uninstaller + installer). -# 2. This script is the post-build fallback for final published artifacts -# (primarily the NSIS -setup.exe) and for local/CI verification of the path. -# -# Usage: -# $env:WINDOWS_CERTIFICATE = "" -# $env:WINDOWS_CERTIFICATE_PASSWORD = "..." -# pwsh scripts/sign-windows-authenticode.ps1 -Path path\to\setup.exe -# -# Does NOT produce Tauri updater .sig files — use `tauri signer sign` for that. - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string[]]$Path, - - [string]$CertificateBase64 = $env:WINDOWS_CERTIFICATE, - [string]$CertificatePassword = $env:WINDOWS_CERTIFICATE_PASSWORD, - [string]$TimestampUrl = $(if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { "http://timestamp.digicert.com" }), - [string]$Stage = "sign" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function Write-Stage([string]$Message) { - Write-Host "::group::[$Stage] $Message" -} - -function Close-Stage { - Write-Host "::endgroup::" -} - -function Fail-Stage([string]$Message) { - Write-Host "::error::[$Stage] $Message" - throw "[$Stage] $Message" -} - -if ([string]::IsNullOrWhiteSpace($CertificateBase64)) { - Write-Host "[$Stage] WINDOWS_CERTIFICATE not set — skipping Authenticode signing (non-blocking milestone)." - Write-Host "[$Stage] Installer remains unsigned; see docs/windows-signing.md." - # Do not `exit` — CI invokes this in-process with `&`. - return -} - -Write-Stage "Import certificate and locate signtool" - -$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cam-codesign-" + [guid]::NewGuid().ToString("n")) -New-Item -ItemType Directory -Path $tempDir | Out-Null -$pfxPath = Join-Path $tempDir "codesign.pfx" -$securePass = $null -$cert = $null - -try { - [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($CertificateBase64.Trim())) - - if ([string]::IsNullOrEmpty($CertificatePassword)) { - $securePass = New-Object System.Security.SecureString - } - else { - $securePass = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force - } - - $cert = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $securePass - if (-not $cert) { - Fail-Stage "Import-PfxCertificate returned no certificate" - } - $thumbprint = $cert.Thumbprint - Write-Host "[$Stage] Imported cert thumbprint=$thumbprint subject=$($cert.Subject)" - - $signtool = $null - $kitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" - if (Test-Path $kitsRoot) { - $candidates = Get-ChildItem -Path $kitsRoot -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | - Sort-Object FullName -Descending - if ($candidates) { $signtool = $candidates[0].FullName } - } - if (-not $signtool) { - $cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue - if ($cmd) { $signtool = $cmd.Source } - } - if (-not $signtool) { - Fail-Stage "signtool.exe not found (install Windows SDK signing tools on the runner)" - } - Write-Host "[$Stage] Using signtool: $signtool" - Close-Stage - - foreach ($raw in $Path) { - if ([string]::IsNullOrWhiteSpace($raw)) { continue } - $item = Get-Item -LiteralPath $raw -ErrorAction Stop - Write-Stage "Sign $($item.Name)" - & $signtool sign ` - /fd SHA256 ` - /td SHA256 ` - /tr $TimestampUrl ` - /sha1 $thumbprint ` - $item.FullName - if ($LASTEXITCODE -ne 0) { - Fail-Stage "signtool failed for $($item.FullName) (exit=$LASTEXITCODE)" - } - $sig = Get-AuthenticodeSignature -LiteralPath $item.FullName - if ($sig.Status -ne "Valid") { - Fail-Stage "post-sign status for $($item.Name) is $($sig.Status), expected Valid" - } - Write-Host "[$Stage] Signed OK: $($item.Name) subject=$($sig.SignerCertificate.Subject)" - Close-Stage - } -} -finally { - if ($cert -and $cert.Thumbprint) { - Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -ErrorAction SilentlyContinue - } - if (Test-Path $tempDir) { - Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } -} - -Write-Host "[$Stage] Authenticode signing complete" -return diff --git a/scripts/sync-mirror-identity.test.mjs b/scripts/sync-mirror-identity.test.mjs new file mode 100644 index 0000000..2acb547 --- /dev/null +++ b/scripts/sync-mirror-identity.test.mjs @@ -0,0 +1,92 @@ +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = join(dirname(fileURLToPath(import.meta.url)), "sync-mirror.sh"); +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("mirror identity staging", () => { + it("reuses identical immutable identity bytes and rejects a rerun with a new signature", () => { + const root = mkdtempSync(join(tmpdir(), "cam-mirror-identity-")); + tempDirs.push(root); + const dist = join(root, "dist"); + const bin = join(root, "bin"); + const store = join(root, "s3"); + mkdirSync(dist); + mkdirSync(bin); + mkdirSync(store); + writeFileSync(join(dist, "app.bin"), "artifact"); + writeFileSync(join(dist, "release-identity.json"), '{"schema":1,"version":"1.2.3"}\n'); + writeFileSync(join(dist, "release-identity.json.sig"), "first-signature\n"); + writeFileSync( + join(dist, "latest.json"), + JSON.stringify({ + version: "1.2.3", + platforms: { test: { url: "https://github.test/app.bin", signature: "artifact-sig" } }, + }), + ); + + const fakeAws = join(bin, "aws"); + writeFileSync( + fakeAws, + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1 $2" = "s3api head-object" ]; then + shift 2; bucket=""; key="" + while [ "$#" -gt 0 ]; do + case "$1" in --bucket) bucket="$2"; shift 2;; --key) key="$2"; shift 2;; *) shift;; esac + done + [ -f "$FAKE_S3/$bucket/$key" ] && { echo '{}'; exit 0; } + echo 'An error occurred (404) when calling HeadObject' >&2; exit 255 +fi +if [ "$1 $2" = "s3 cp" ]; then + src="$3"; dst="$4" + if [[ "$src" == s3://* ]]; then + remote="\${src#s3://}"; cp "$FAKE_S3/$remote" "$dst" + else + remote="\${dst#s3://}"; mkdir -p "$(dirname "$FAKE_S3/$remote")"; cp "$src" "$FAKE_S3/$remote" + echo "$remote" >> "$FAKE_AWS_LOG" + fi + exit 0 +fi +echo "unsupported fake aws invocation: $*" >&2; exit 2 +`, + ); + chmodSync(fakeAws, 0o755); + + const env = { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH}`, + FAKE_S3: store, + FAKE_AWS_LOG: join(root, "aws.log"), + MIRROR_PHASE: "stage", + MIRROR_BASE_URL: "https://mirror.test/manager", + MANAGER_R2_S3_ENDPOINT: "https://r2.test", + MANAGER_R2_BUCKET: "bucket", + MANAGER_R2_ACCESS_KEY_ID: "test-ak", + MANAGER_R2_SECRET_ACCESS_KEY: "test-sk", + }; + const run = () => spawnSync("bash", [script, dist], { encoding: "utf8", env }); + + const first = run(); + expect(first.status, first.stderr).toBe(0); + const second = run(); + expect(second.status, second.stderr).toBe(0); + expect(second.stdout).toContain("identical immutable object already staged"); + const uploads = readFileSync(env.FAKE_AWS_LOG, "utf8").trim().split("\n"); + expect(uploads.filter((key) => key.endsWith("/release-identity.json"))).toHaveLength(1); + expect(uploads.filter((key) => key.endsWith("/release-identity.json.sig"))).toHaveLength(1); + + writeFileSync(join(dist, "release-identity.json.sig"), "different-rerun-signature\n"); + const changed = run(); + expect(changed.status).not.toBe(0); + expect(changed.stderr).toContain("refusing to overwrite byte-different immutable identity object"); + }); +}); diff --git a/scripts/sync-mirror.sh b/scripts/sync-mirror.sh index d055373..f7a0590 100755 --- a/scripts/sync-mirror.sh +++ b/scripts/sync-mirror.sh @@ -75,6 +75,51 @@ content_type() { esac } +upload_immutable_identity_asset() { # endpoint bucket region access_key secret_key file key content_type + local endpoint="$1" bucket="$2" region="$3" ak="$4" sk="$5" file="$6" key="$7" type="$8" + local head_output head_status existing + set +e + head_output="$(AWS_ACCESS_KEY_ID="$ak" \ + AWS_SECRET_ACCESS_KEY="$sk" \ + AWS_DEFAULT_REGION="$region" \ + aws s3api head-object --bucket "$bucket" --key "$key" \ + --endpoint-url "$endpoint" 2>&1)" + head_status=$? + set -e + + if [ "$head_status" -eq 0 ]; then + existing="$(mktemp)" + AWS_ACCESS_KEY_ID="$ak" \ + AWS_SECRET_ACCESS_KEY="$sk" \ + AWS_DEFAULT_REGION="$region" \ + aws s3 cp "s3://$bucket/$key" "$existing" \ + --endpoint-url "$endpoint" --only-show-errors + if cmp -s "$file" "$existing"; then + rm -f "$existing" + echo " = $key (identical immutable object already staged)" + return 0 + fi + rm -f "$existing" + echo "::error::refusing to overwrite byte-different immutable identity object: $key" >&2 + return 1 + fi + + if ! grep -Eqi '(404|NoSuchKey|Not Found|not found)' <<<"$head_output"; then + echo "::error::could not determine whether immutable identity object exists: $key" >&2 + echo "$head_output" >&2 + return "$head_status" + fi + + AWS_ACCESS_KEY_ID="$ak" \ + AWS_SECRET_ACCESS_KEY="$sk" \ + AWS_DEFAULT_REGION="$region" \ + aws s3 cp "$file" "s3://$bucket/$key" \ + --endpoint-url "$endpoint" \ + --content-type "$type" \ + --only-show-errors + echo " ↑ $key" +} + candidate_key="latest.candidate.json" latest_key="latest.json" @@ -96,14 +141,22 @@ upload_assets() { # endpoint bucket region access_key secret_key phase [prefix] key="$version/$name" # immutable, per-version object fi [ -n "$prefix" ] && key="${prefix%/}/$key" - AWS_ACCESS_KEY_ID="$ak" \ - AWS_SECRET_ACCESS_KEY="$sk" \ - AWS_DEFAULT_REGION="$region" \ - aws s3 cp "$f" "s3://$bucket/$key" \ - --endpoint-url "$endpoint" \ - --content-type "$(content_type "$name")" \ - --only-show-errors - echo " ↑ $key" + if [ "$name" = "release-identity.json" ] || [ "$name" = "release-identity.json.sig" ]; then + # Tauri's trusted-comment timestamp makes a freshly generated `.sig` + # byte-different on rerun. These versioned identity objects are immutable: + # reuse identical prior bytes or fail instead of silently replacing them. + upload_immutable_identity_asset "$endpoint" "$bucket" "$region" "$ak" "$sk" \ + "$f" "$key" "$(content_type "$name")" + else + AWS_ACCESS_KEY_ID="$ak" \ + AWS_SECRET_ACCESS_KEY="$sk" \ + AWS_DEFAULT_REGION="$region" \ + aws s3 cp "$f" "s3://$bucket/$key" \ + --endpoint-url "$endpoint" \ + --content-type "$(content_type "$name")" \ + --only-show-errors + echo " ↑ $key" + fi done } diff --git a/scripts/verify-windows-authenticode.ps1 b/scripts/verify-windows-authenticode.ps1 index 3dedb2e..b825ae7 100644 --- a/scripts/verify-windows-authenticode.ps1 +++ b/scripts/verify-windows-authenticode.ps1 @@ -4,7 +4,7 @@ # optional — PR/local diagnostic for builds that intentionally receive no # release certificate. Reports unsigned files without blocking. # required — release gate: every path must have Status -eq Valid. Can also -# pin the imported thumbprint and require RFC3161 evidence. +# pin the SignPath subject and require timestamp evidence. # # Usage: # pwsh scripts/verify-windows-authenticode.ps1 -Path a.exe,b.exe -Mode optional @@ -24,18 +24,11 @@ param( # When set (required mode), SignerCertificate.Subject must contain this. [string]$ExpectedSubject = "", - # When set (required mode), the signer must be the exact certificate that - # the release job imported, not merely any locally trusted publisher. - [string]$ExpectedThumbprint = "", - - # Tauri is configured with tsp=true (/tr + /td SHA256). Requiring a - # countersigner here ensures the RFC3161 timestamp was actually embedded. + # Requires a timestamp countersigner on the artifact. This property alone + # does not distinguish RFC3161 from every legacy timestamp form; the future + # SignPath pilot must also validate its policy and verbose signtool output. [switch]$RequireTimestamp, - # Required together with -RequireTimestamp. The generated Tauri config is - # the build-time proof that signing used RFC3161 (/tr), not legacy /t. - [string]$SigningConfigPath = "", - [string]$Stage = "sign-verify" ) @@ -55,24 +48,6 @@ function Fail-Stage([string]$Message) { throw "[$Stage] $Message" } -if ($RequireTimestamp) { - if ([string]::IsNullOrWhiteSpace($SigningConfigPath) -or -not (Test-Path -LiteralPath $SigningConfigPath)) { - Fail-Stage "-RequireTimestamp also requires the generated Tauri signing config" - } - $signingConfig = Get-Content -LiteralPath $SigningConfigPath -Raw | ConvertFrom-Json - $windowsSigning = $signingConfig.bundle.windows - if ($windowsSigning.tsp -ne $true) { - Fail-Stage "Tauri signing config must set bundle.windows.tsp=true (RFC3161 /tr)" - } - if ([string]::IsNullOrWhiteSpace([string]$windowsSigning.timestampUrl)) { - Fail-Stage "Tauri signing config has no RFC3161 timestampUrl" - } - if ([string]$windowsSigning.digestAlgorithm -ne "sha256") { - Fail-Stage "Tauri signing config must use SHA256" - } - Write-Host "[$Stage] RFC3161 config asserted: tsp=true digest=sha256 url=$($windowsSigning.timestampUrl)" -} - Write-Stage "Authenticode verification (mode=$Mode)" $results = @() @@ -126,10 +101,6 @@ foreach ($raw in $Path) { $ok = $false Write-Host "::error::[$Stage] $($item.Name): subject '$subject' does not contain '$ExpectedSubject'" } - elseif ($ExpectedThumbprint -and ($thumbprint -ne $ExpectedThumbprint.Replace(" ", "").ToUpperInvariant())) { - $ok = $false - Write-Host "::error::[$Stage] $($item.Name): signer thumbprint '$thumbprint' does not match the release certificate" - } elseif ($RequireTimestamp -and -not $sig.TimeStamperCertificate) { $ok = $false Write-Host "::error::[$Stage] $($item.Name): no RFC3161 timestamp countersigner" diff --git a/scripts/windows-packaged-smoke.ps1 b/scripts/windows-packaged-smoke.ps1 index d2ca521..122e59e 100644 --- a/scripts/windows-packaged-smoke.ps1 +++ b/scripts/windows-packaged-smoke.ps1 @@ -26,9 +26,8 @@ param( [int]$LaunchSeconds = 12, [ValidateSet("optional", "required", "skip")] [string]$AuthenticodeMode = "optional", - [string]$ExpectedThumbprint = "", + [string]$ExpectedSubject = "", [switch]$RequireTimestamp, - [string]$SigningConfigPath = "", [switch]$SkipLaunch ) @@ -105,9 +104,8 @@ try { & $verifyScript ` -Path $installerItem.FullName ` -Mode $AuthenticodeMode ` - -ExpectedThumbprint $ExpectedThumbprint ` + -ExpectedSubject $ExpectedSubject ` -RequireTimestamp:$RequireTimestamp ` - -SigningConfigPath $SigningConfigPath ` -Stage "sign-verify" Close-Stage } @@ -143,9 +141,8 @@ try { & $verifyScript ` -Path @($mainExe, $uninstaller) ` -Mode $AuthenticodeMode ` - -ExpectedThumbprint $ExpectedThumbprint ` + -ExpectedSubject $ExpectedSubject ` -RequireTimestamp:$RequireTimestamp ` - -SigningConfigPath $SigningConfigPath ` -Stage "sign-verify" Close-Stage } @@ -202,9 +199,8 @@ try { & $verifyScript ` -Path @($mainExe, $uninstaller) ` -Mode $AuthenticodeMode ` - -ExpectedThumbprint $ExpectedThumbprint ` + -ExpectedSubject $ExpectedSubject ` -RequireTimestamp:$RequireTimestamp ` - -SigningConfigPath $SigningConfigPath ` -Stage "sign-verify" Close-Stage } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6767d44..d3d9b7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -615,11 +615,13 @@ dependencies = [ "codex-win-engine", "directories", "fs4", + "futures-util", "libc", "log", "reqwest", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -645,6 +647,7 @@ dependencies = [ "roxmltree", "serde", "thiserror 2.0.18", + "url", "uuid", ] @@ -658,6 +661,7 @@ dependencies = [ "serde_json", "sha2", "thiserror 2.0.18", + "url", "uuid", "windows-sys 0.61.2", "zip 2.4.2", @@ -4381,8 +4385,6 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", "dirs 6.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc12a57..6e3f441 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,9 +26,11 @@ codex-win-engine = { path = "../crates/codex-win-engine" } directories = "6.0.0" fs4 = "1.1" log = "0.4" -reqwest = { version = "0.13.4", default-features = false, features = ["socks", "system-proxy"] } +futures-util = "0.3" +reqwest = { version = "0.13.4", default-features = false, features = ["socks", "system-proxy", "stream"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" tauri = { version = "2.11.2", features = ["tray-icon", "image-png", "macos-private-api"] } tauri-plugin-autostart = "2" tauri-plugin-dialog = "2.7.1" @@ -41,5 +43,11 @@ url = "2.5" uuid = { version = "1", features = ["v4"] } windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +# Upstream 2.10.1 buffers manifest and artifact responses without a byte cap. +# Keep the exact vendored patch until an upstream release exposes equivalent +# Content-Length plus streaming limits; see the vendor VENDORED.md checklist. +[patch.crates-io] +tauri-plugin-updater = { path = "../vendor/tauri-plugin-updater-2.10.1" } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/installer/installer.nsi b/src-tauri/installer/installer.nsi index ac3c370..ec18242 100644 --- a/src-tauri/installer/installer.nsi +++ b/src-tauri/installer/installer.nsi @@ -23,14 +23,6 @@ ManifestDPIAwareness PerMonitorV2 SetCompressor /SOLID "{{compression}}" !endif -; Keep above !include to stay ahead of any plugin command. Tauri signs a private -; plugin copy when Windows code signing is configured; using the system plugin -; directory here would silently embed unsigned DLLs in an otherwise signed setup. -; https://github.com/tauri-apps/tauri/pull/15422 -{{#if signed_plugins_path}} -!addplugindir "{{signed_plugins_path}}" -{{/if}} - !include MUI2.nsh !include FileFunc.nsh !include x64.nsh diff --git a/src-tauri/src/app/logging.rs b/src-tauri/src/app/logging.rs index f8f075a..99d1e8c 100644 --- a/src-tauri/src/app/logging.rs +++ b/src-tauri/src/app/logging.rs @@ -26,6 +26,17 @@ pub fn redact_url(raw: &str) -> String { redacted } +/// Parsed host (plus an explicit non-default port) for user-facing source +/// labels. Reuses the origin redactor so credentials, path, query, and fragment +/// can never enter progress events or logs. +pub fn redact_url_host(raw: &str) -> String { + let origin = redact_url(raw); + origin + .split_once("://") + .map(|(_, host)| host.to_string()) + .unwrap_or(origin) +} + pub fn prune_old_logs(dir: &Path, keep: usize) { let Ok(entries) = std::fs::read_dir(dir) else { return; @@ -66,7 +77,7 @@ pub fn prune_old_logs(dir: &Path, keep: usize) { #[cfg(test)] mod tests { - use super::{prune_old_logs, redact_url}; + use super::{prune_old_logs, redact_url, redact_url_host}; fn temp_dir(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("{name}-{}", std::process::id())) @@ -81,6 +92,11 @@ mod tests { assert_eq!(redact_url("http://127.0.0.1/path"), "http://127.0.0.1"); assert_eq!(redact_url("127.0.0.1/path"), ""); assert_eq!(redact_url("not a url"), ""); + assert_eq!( + redact_url_host("https://u:p@example.com:8443/a/b?x=1#frag"), + "example.com:8443" + ); + assert_eq!(redact_url_host("not a url"), ""); } #[test] diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index cc45c1c..19f750c 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -24,6 +24,7 @@ use codex_mac_engine::{ use crate::app::disk; use crate::app::install_tx::{ActiveInstallTx, InstallTxKind}; +use crate::app::logging::redact_url_host; use crate::app::op_phase::OperationPhase; use crate::app::operation_outcome::{recovery, OperationOutcome, StepOutcome}; use crate::app::provenance::ProvenanceStore; @@ -456,11 +457,60 @@ pub struct DownloadProgress { /// Host portion of a URL, for showing the user which source is downloading. fn host_of(url: &str) -> String { - url.split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") - .to_string() + redact_url_host(url) +} + +fn download_file_name(url: &str) -> String { + url::Url::parse(url) + .ok() + .and_then(|parsed| { + parsed + .path_segments()? + .rev() + .find(|segment| !segment.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "payload.bin".to_string()) +} + +#[cfg(test)] +mod download_diagnostic_tests { + use super::{download_file_name, host_of, DownloadProgress}; + + #[test] + fn malicious_enclosure_never_reaches_progress_or_log_material() { + let raw = "https://basic-user:basic-pass@downloads.example/private/Codex.zip?X-Amz-Credential=presigned-secret#fragment-secret"; + let source = host_of(raw); + let progress = DownloadProgress { + downloaded: 1, + total: 2, + source: source.clone(), + }; + let progress_json = serde_json::to_string(&progress).unwrap(); + let start_log = format!("macOS download and verify start source={source}"); + let file_name = download_file_name(raw); + let complete_log = "macOS download and verify complete"; + + assert_eq!(source, "downloads.example"); + assert_eq!(file_name, "Codex.zip"); + for secret in [ + "basic-user", + "basic-pass", + "X-Amz-Credential", + "presigned-secret", + "fragment-secret", + ] { + assert!(!progress_json.contains(secret), "progress leaked {secret}"); + assert!(!start_log.contains(secret), "start log leaked {secret}"); + assert!( + !complete_log.contains(secret), + "complete log leaked {secret}" + ); + } + + assert_eq!(host_of("not a URL with-secret"), ""); + assert_eq!(download_file_name("not a URL with-secret"), "payload.bin"); + } } /// No-op progress sink for downloads whose progress isn't surfaced (e.g. stage). @@ -496,12 +546,12 @@ fn download_and_verify( "artifact size {size} exceeds {max_size} byte limit" ))); } - let file_name = url.rsplit('/').next().unwrap_or("payload.bin"); + let file_name = download_file_name(url); // Download into the PERSISTENT cache (not a per-run staging dir): a paused // `.part` survives here, so the next perform/install resumes it instead of // restarting at 0. The artifact is consumed (verified → unpacked/applied) // from here; success clears the cache (see perform/install tails). - let dest = staging::download_cache_path(url, file_name)?; + let dest = staging::download_cache_path(url, &file_name)?; let source = host_of(url); let already = std::fs::metadata(&dest) @@ -553,8 +603,7 @@ fn download_and_verify( return Err(err); } - let path = dest.display(); - log::info!("macOS download and verify complete path={path}"); + log::info!("macOS download and verify complete"); Ok(dest) } @@ -815,13 +864,7 @@ pub fn perform_macos_update_with_network( progress: &dyn Fn(DownloadProgress), network: &NetworkConfig, ) -> Result { - perform_macos_update_with_network_and_phase( - binary_delta, - expected, - progress, - network, - None, - ) + perform_macos_update_with_network_and_phase(binary_delta, expected, progress, network, None) } pub fn perform_macos_update_with_network_and_phase( @@ -1438,7 +1481,8 @@ fn install_macos_in_staging( } else { outcome.path = Some(install_path.to_string_lossy().into_owned()); outcome.provenance = StepOutcome::failed("安装后未能读取 bundle 版本,托管记录未写入"); - outcome.push_warning("已写入应用,但未能确认版本;请重新检查状态或「开始管理」".to_string()); + outcome + .push_warning("已写入应用,但未能确认版本;请重新检查状态或「开始管理」".to_string()); outcome.push_recovery(recovery::RECORD_PROVENANCE); outcome.install_class = Some("external".to_string()); // Honest: app may be present but we couldn't classify it as managed. @@ -1683,11 +1727,7 @@ pub fn retry_macos_ancillary( match detect_managed_installed().or_else(detect_installed) { Some(installed) => { let mut store = ProvenanceStore::load(); - store.record( - installed.path.clone(), - installed.build, - "manager-installed", - ); + store.record(installed.path.clone(), installed.build, "manager-installed"); match store.save() { Ok(()) => { outcome.provenance = StepOutcome::ok(); diff --git a/src-tauri/src/app/staging.rs b/src-tauri/src/app/staging.rs index 74cc8db..0f6b4b9 100644 --- a/src-tauri/src/app/staging.rs +++ b/src-tauri/src/app/staging.rs @@ -170,10 +170,7 @@ pub fn cleanup_stale_staging(ops: &OperationManager) -> CleanupSummary { } summary.scanned += 1; if crate::app::install_tx::path_is_protected(&path, &protected) { - log::info!( - "staging cleanup skipped protected path={}", - path.display() - ); + log::info!("staging cleanup skipped protected path={}", path.display()); continue; } if !is_stale(&path, now) { @@ -214,13 +211,14 @@ pub fn cleanup_stale_staging(ops: &OperationManager) -> CleanupSummary { match std::fs::remove_file(&path) { Ok(()) => { summary.removed += 1; - let path_display = path.display(); - log::debug!("download cache cleanup removed path={path_display}"); + // Older releases derived the cache suffix from the raw URL + // tail, so a historical filename can contain a presigned + // query. Never carry cache paths across the log boundary. + log::debug!("download cache cleanup removed artifact"); } Err(err) => { summary.failed += 1; - let path_display = path.display(); - log::debug!("download cache cleanup failed path={path_display} error={err}"); + log::debug!("download cache cleanup failed error={err}"); } } } diff --git a/src-tauri/src/app/url_guard.rs b/src-tauri/src/app/url_guard.rs index 373f5c1..b91b23f 100644 --- a/src-tauri/src/app/url_guard.rs +++ b/src-tauri/src/app/url_guard.rs @@ -12,6 +12,8 @@ pub enum UrlRejectReason { BareIp, #[error("自定义源不能包含用户名/密码")] HasUserinfo, + #[error("自定义源必须是基础 URL,不能包含查询参数或片段")] + HasQueryOrFragment, #[error("自定义源缺少有效主机名")] MissingHost, #[error("无法解析该 URL")] @@ -59,6 +61,9 @@ pub fn validate_custom_source(raw: &str) -> Result { if !url.username().is_empty() || url.password().is_some() { return Err(UrlRejectReason::HasUserinfo); } + if url.query().is_some() || url.fragment().is_some() { + return Err(UrlRejectReason::HasQueryOrFragment); + } match url.host() { None => return Err(UrlRejectReason::MissingHost), @@ -213,6 +218,22 @@ mod tests { ); } + #[test] + fn rejects_query_and_fragment_on_custom_source_base_urls() { + for raw in [ + "https://mirror.example.com?token=secret", + "https://mirror.example.com/feed?token=secret", + "https://mirror.example.com/#private", + "https://mirror.example.com/feed#private", + ] { + assert_eq!( + validate_custom_source(raw), + Err(UrlRejectReason::HasQueryOrFragment), + "{raw}" + ); + } + } + #[test] fn rejects_loopback_private_and_local_domains() { for raw in [ diff --git a/src-tauri/src/app/win_update.rs b/src-tauri/src/app/win_update.rs index 9736a07..12b7260 100644 --- a/src-tauri/src/app/win_update.rs +++ b/src-tauri/src/app/win_update.rs @@ -26,6 +26,7 @@ use codex_win_engine::{ }; use crate::app::install_tx::{ActiveInstallTx, InstallTxKind}; +use crate::app::logging::redact_url_host; use crate::app::op_phase::OperationPhase; use crate::app::operation_outcome::{ recovery, AncillaryRetryReport, OperationOutcome, StepOutcome, @@ -186,8 +187,8 @@ fn record_managed_install( installed: &InstalledWindowsCodex, source: &str, ) -> OperationOutcome { - let mut outcome = - OperationOutcome::full_success("present", Some("managed")).with_path(installed.path.clone()); + let mut outcome = OperationOutcome::full_success("present", Some("managed")) + .with_path(installed.path.clone()); outcome.cleanup = StepOutcome::not_applicable(); let mut store = ProvenanceStore::load(); if let Some(previous) = previous { @@ -264,7 +265,9 @@ fn outcome_from_portable_uninstall( if outcome.cleanup.is_failed() { // Shortcut / uninstall-entry failures. if portable.notes.iter().any(|n| { - n.contains("Start Menu") || n.contains("Apps & Features") || n.contains("uninstall entry") + n.contains("Start Menu") + || n.contains("Apps & Features") + || n.contains("uninstall entry") }) { outcome.push_recovery(recovery::CLEANUP_METADATA); } @@ -287,11 +290,7 @@ fn outcome_from_portable_uninstall( } fn host_of(url: &str) -> String { - url.split("://") - .nth(1) - .and_then(|rest| rest.split('/').next()) - .unwrap_or("") - .to_string() + redact_url_host(url) } fn no_progress(_p: DownloadProgress) {} @@ -1229,9 +1228,7 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( app_state: "unknown".to_string(), install_class: None, path: None, - provenance: StepOutcome::failed( - "安装完成但未检测到可记录的安装,托管状态未知", - ), + provenance: StepOutcome::failed("安装完成但未检测到可记录的安装,托管状态未知"), cleanup: StepOutcome::not_applicable(), warnings: vec![ "MSIX sideload reported success but no install was detected afterward." @@ -1279,7 +1276,14 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( return Ok(report); } - install_portable_after_stage(settings, stage, Some(sideload), None, current_installed, phase) + install_portable_after_stage( + settings, + stage, + Some(sideload), + None, + current_installed, + phase, + ) } fn install_portable_after_stage( @@ -1381,9 +1385,7 @@ fn install_portable_after_stage( }, install_class: None, path: Some(install_root.clone()), - provenance: StepOutcome::failed( - "便携安装完成但未检测到可记录的安装,托管状态未知", - ), + provenance: StepOutcome::failed("便携安装完成但未检测到可记录的安装,托管状态未知"), cleanup: StepOutcome::not_applicable(), warnings: vec![ "Portable install finished but no install was detected for provenance.".to_string(), @@ -1650,7 +1652,10 @@ pub fn uninstall_windows_codex( success: msix.success, action: "remove-msix".to_string(), message: if outcome.is_partial() { - format!("{}(主卸载已完成,附属步骤有失败 — 可仅重试清理)", msix.message) + format!( + "{}(主卸载已完成,附属步骤有失败 — 可仅重试清理)", + msix.message + ) } else { msix.message.clone() }, @@ -1677,8 +1682,7 @@ pub fn uninstall_windows_codex( provenance = StepOutcome::failed(format!("托管记录清除失败({e})")); } } - let outcome = - outcome_from_portable_uninstall(&portable, provenance, &installed_before.path); + let outcome = outcome_from_portable_uninstall(&portable, provenance, &installed_before.path); let mut notes = portable.notes.clone(); notes.extend(outcome.warnings.iter().cloned()); let report = WinUninstallReport { @@ -1826,8 +1830,8 @@ pub fn retry_windows_ancillary( mod tests { use super::{ bind_manifest_checksums, check_win_update_abort, detect_existing_windows_install_at_path, - detect_managed_codex, outcome_from_portable_uninstall, WinAbortGuard, WinPerformAction, - WIN_UPDATE_ABORT, + detect_managed_codex, host_of, outcome_from_portable_uninstall, DownloadProgress, + WinAbortGuard, WinPerformAction, WIN_UPDATE_ABORT, }; use crate::app::operation_outcome::{recovery, StepOutcome}; use crate::app::provenance::ProvenanceStore; @@ -1843,6 +1847,31 @@ mod tests { dir } + #[test] + fn malicious_package_url_never_reaches_progress_source() { + let raw = "https://basic-user:basic-pass@downloads.example:8443/private/Codex.msix?X-Amz-Signature=presigned-secret#fragment-secret"; + let source = host_of(raw); + let progress = DownloadProgress { + downloaded: 1, + total: 2, + source: source.clone(), + }; + let progress_json = serde_json::to_string(&progress).unwrap(); + + assert_eq!(source, "downloads.example:8443"); + for secret in [ + "basic-user", + "basic-pass", + "/private/Codex.msix", + "X-Amz-Signature", + "presigned-secret", + "fragment-secret", + ] { + assert!(!progress_json.contains(secret), "progress leaked {secret}"); + } + assert_eq!(host_of("not a URL with-secret"), ""); + } + fn write_fake_portable_install(dir: &std::path::Path, version: &str) { std::fs::write(dir.join("Codex.exe"), b"").unwrap(); std::fs::write( @@ -2044,11 +2073,7 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OpenAI.Codex_2 message: "cleanup warnings".into(), notes: vec!["Start Menu shortcut cleanup failed: access denied".into()], }; - let outcome = outcome_from_portable_uninstall( - &portable, - StepOutcome::ok(), - r"C:\Codex", - ); + let outcome = outcome_from_portable_uninstall(&portable, StepOutcome::ok(), r"C:\Codex"); assert!(outcome.primary_ok, "absent tree is still primary success"); assert_eq!(outcome.app_state, "absent"); assert_eq!(outcome.path.as_deref(), Some(r"C:\Codex")); @@ -2074,11 +2099,7 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OpenAI.Codex_2 message: "cleanup warnings".into(), notes: vec!["User data cleanup failed: access denied".into()], }; - let outcome = outcome_from_portable_uninstall( - &portable, - StepOutcome::ok(), - r"C:\Codex", - ); + let outcome = outcome_from_portable_uninstall(&portable, StepOutcome::ok(), r"C:\Codex"); assert!(outcome.primary_ok); assert!(outcome.is_partial()); assert!(outcome @@ -2100,8 +2121,7 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OpenAI.Codex_2 message: "remove failed".into(), notes: vec![], }; - let outcome = - outcome_from_portable_uninstall(&portable, StepOutcome::ok(), r"C:\Codex"); + let outcome = outcome_from_portable_uninstall(&portable, StepOutcome::ok(), r"C:\Codex"); assert!(!outcome.primary_ok); assert!(!outcome.is_partial()); assert_eq!(outcome.app_state, "present"); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dfecfcd..b1d2750 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, future::Future, path::{Path, PathBuf}, sync::Arc, @@ -6,7 +7,10 @@ use std::{ }; use codex_win_engine::InstalledWindowsCodex; +use futures_util::StreamExt; +use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_autostart::ManagerExt; use tauri_plugin_dialog::DialogExt; @@ -28,8 +32,7 @@ use crate::app::mac_update::{ use crate::app::manager_update_handoff::clear_for_platform as clear_manager_update_handoff; #[cfg(target_os = "windows")] use crate::app::manager_update_handoff::{ - now_unix_ms, persist_for_platform as persist_manager_update_handoff, - ManagerUpdateHandoff, + now_unix_ms, persist_for_platform as persist_manager_update_handoff, ManagerUpdateHandoff, }; use crate::app::manager_update_handoff::{ status_for_platform as manager_update_handoff_status, ManagerUpdateHandoffStatus, @@ -51,10 +54,9 @@ use crate::app::win_update::{ detect_existing_windows_install_at_path as detect_windows_install_at_path, discard_windows_download, pause_windows_download, perform_windows_update_with_install_mode_network_and_phase, - plan_windows_update_with_install_mode_and_network, - retry_windows_ancillary, stage_windows_update_with_install_mode_and_network, - uninstall_windows_codex, win_adopt as adopt_windows_install, - win_adopt_path as adopt_windows_path, win_install_status, + plan_windows_update_with_install_mode_and_network, retry_windows_ancillary, + stage_windows_update_with_install_mode_and_network, uninstall_windows_codex, + win_adopt as adopt_windows_install, win_adopt_path as adopt_windows_path, win_install_status, DownloadProgress as WinDownloadProgress, WinAutoStageReport, WinInstallStatus, WinPerformExpectation, WinPerformReport, WinStageReport, WinUninstallReport, WinUpdateReport, }; @@ -177,6 +179,33 @@ const MANAGER_UPDATE_STATE_EVENT: &str = "manager://update-state"; const MANAGER_UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(30); const MANAGER_UPDATE_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const MANAGER_UPDATE_READ_TIMEOUT: Duration = Duration::from_secs(30); +const MANAGER_UPDATE_MANIFEST_MAX_BYTES: u64 = 256 * 1024; +const MANAGER_UPDATE_IDENTITY_MAX_BYTES: u64 = 256 * 1024; +const MANAGER_UPDATE_IDENTITY_SIGNATURE_MAX_BYTES: u64 = 16 * 1024; +const MANAGER_UPDATE_ARTIFACT_MAX_BYTES: u64 = 64 * 1024 * 1024; +const MANAGER_UPDATE_IDENTITY_SCHEMA: u32 = 1; +const MANAGER_UPDATE_IDENTITY_FILE: &str = "release-identity.json"; +const MANAGER_UPDATE_IDENTITY_SIGNATURE_FILE: &str = "release-identity.json.sig"; + +#[derive(Debug, Deserialize)] +struct ManagerReleaseIdentity { + schema: u32, + version: String, + notes_sha256: String, + platforms: HashMap, +} + +#[derive(Debug, Deserialize)] +struct ManagerReleaseIdentityPlatform { + artifact: String, + signature: String, + sha256: String, +} + +struct AuthenticatedManagerUpdate { + update: tauri_plugin_updater::Update, + artifact_sha256: String, +} fn emit_manager_update_state(app: &AppHandle, snapshot: ManagerUpdateRuntimeSnapshot) { let _ = app.emit(MANAGER_UPDATE_STATE_EVENT, snapshot); @@ -236,12 +265,13 @@ fn manager_updater_builder_for_endpoints( .endpoints(endpoints) .map_err(|e| AppError::Engine(format!("configure manager updater endpoints: {e}")))?; } - // `UpdaterBuilder::timeout` only reaches manifest checks. The updater - // deliberately creates downloads without that total timeout, so keep - // downloads unbounded while data is flowing and enforce a read-stall - // timeout through the shared reqwest client configuration instead. + // `UpdaterBuilder::timeout` only reaches manifest checks. Artifact transfer + // remains allowed to run while bytes keep flowing, but the patched plugin + // enforces a hard byte cap and this client still aborts read stalls. Ok(builder .timeout(MANAGER_UPDATE_CHECK_TIMEOUT) + .max_manifest_size(MANAGER_UPDATE_MANIFEST_MAX_BYTES) + .max_download_size(MANAGER_UPDATE_ARTIFACT_MAX_BYTES) .configure_client(|client| { client .connect_timeout(MANAGER_UPDATE_CONNECT_TIMEOUT) @@ -249,7 +279,9 @@ fn manager_updater_builder_for_endpoints( })) } -fn configured_manager_update_endpoints(app: &AppHandle) -> Result, AppError> { +fn configured_manager_update_config( + app: &AppHandle, +) -> Result { let raw = app .config() .plugins @@ -257,14 +289,250 @@ fn configured_manager_update_endpoints(app: &AppHandle) -> Result, .get("updater") .cloned() .ok_or_else(|| AppError::Internal("missing updater plugin configuration".to_string()))?; - let config: tauri_plugin_updater::Config = serde_json::from_value(raw) - .map_err(|e| AppError::Internal(format!("read updater plugin configuration: {e}")))?; - if config.endpoints.is_empty() { - return Err(AppError::Internal( - "updater plugin has no configured endpoints".to_string(), + serde_json::from_value(raw) + .map_err(|e| AppError::Internal(format!("read updater plugin configuration: {e}"))) +} + +fn manager_update_identity_client() -> Result { + let saved = PersistedAppSettings::load(); + let mut builder = reqwest::Client::builder() + .connect_timeout(MANAGER_UPDATE_CONNECT_TIMEOUT) + .read_timeout(MANAGER_UPDATE_READ_TIMEOUT) + .timeout(MANAGER_UPDATE_CHECK_TIMEOUT); + match saved.proxy_mode { + ProxyMode::System => {} + ProxyMode::Direct => builder = builder.no_proxy(), + ProxyMode::Custom => { + let normalized = + validated_custom_proxy_for_settings(&saved.custom_proxy_url, "manager updater")?; + let proxy = reqwest::Proxy::all(&normalized) + .map_err(|e| AppError::Engine(format!("configure manager updater proxy: {e}")))?; + builder = builder.proxy(proxy); + } + } + builder + .build() + .map_err(|e| AppError::Engine(format!("build manager updater identity client: {e}"))) +} + +async fn fetch_manager_update_file_limited( + client: &reqwest::Client, + url: url::Url, + max_bytes: u64, + resource: &'static str, +) -> Result, AppError> { + let response = client + .get(url) + .send() + .await + .map_err(|e| AppError::Engine(format!("fetch {resource}: {e}")))?; + if !response.status().is_success() { + return Err(AppError::Engine(format!( + "fetch {resource}: HTTP {}", + response.status() + ))); + } + + let content_length = response + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + if let Some(announced) = content_length.filter(|length| *length > max_bytes) { + return Err(AppError::Engine(format!( + "{resource} exceeds {max_bytes}-byte limit (announced {announced} bytes)" + ))); + } + + let mut bytes = Vec::new(); + let mut observed = 0_u64; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| AppError::Engine(format!("read {resource}: {e}")))?; + observed = observed.checked_add(chunk.len() as u64).ok_or_else(|| { + AppError::Engine(format!("{resource} exceeds {max_bytes}-byte limit")) + })?; + if observed > max_bytes { + return Err(AppError::Engine(format!( + "{resource} exceeds {max_bytes}-byte limit (observed {observed} bytes)" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn manager_update_versioned_url( + manifest_endpoint: &url::Url, + version: &str, + filename: &str, +) -> Result { + let safe_filename = !filename.is_empty() + && filename != "." + && filename != ".." + && filename + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + if !safe_filename { + return Err(AppError::Engine( + "signed manager update identity contains an unsafe artifact name".to_string(), )); } - Ok(config.endpoints) + + let mut url = manifest_endpoint.clone(); + url.set_query(None); + url.set_fragment(None); + let is_github = url.scheme() == "https" + && url.host_str() == Some("github.com") + && url.path() == "/Wangnov/Codex-App-Manager/releases/latest/download/latest.json"; + url.set_path(""); + { + let mut segments = url.path_segments_mut().map_err(|_| { + AppError::Engine("manager updater endpoint cannot be a base URL".to_string()) + })?; + if is_github { + for segment in ["Wangnov", "Codex-App-Manager", "releases", "download"] { + segments.push(segment); + } + segments.push(&format!("v{version}")); + } else { + let base_segments = manifest_endpoint + .path_segments() + .ok_or_else(|| { + AppError::Engine("manager updater endpoint has no path".to_string()) + })? + .collect::>(); + let Some((manifest_name, directories)) = base_segments.split_last() else { + return Err(AppError::Engine( + "manager updater endpoint has no manifest filename".to_string(), + )); + }; + if *manifest_name != "latest.json" { + return Err(AppError::Engine( + "manager updater endpoint must end in latest.json".to_string(), + )); + } + for segment in directories { + if !segment.is_empty() { + segments.push(segment); + } + } + segments.push(version); + } + segments.push(filename); + } + Ok(url) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn validate_manager_update_identity_claim( + version: &str, + notes: Option<&str>, + platform_key: &str, + manifest_artifact: &str, + manifest_signature: &str, + identity: &ManagerReleaseIdentity, +) -> Result { + if identity.schema != MANAGER_UPDATE_IDENTITY_SCHEMA || identity.version != version { + return Err(AppError::Engine( + "manager update manifest does not match the signed release identity".to_string(), + )); + } + if identity.notes_sha256 != sha256_hex(notes.unwrap_or_default().as_bytes()) { + return Err(AppError::Engine( + "manager update notes do not match the signed release identity".to_string(), + )); + } + let platform = identity.platforms.get(platform_key).ok_or_else(|| { + AppError::Engine( + "signed manager update identity has no entry for this platform".to_string(), + ) + })?; + if platform.artifact != manifest_artifact || platform.signature != manifest_signature { + return Err(AppError::Engine( + "manager update artifact does not match the signed release identity".to_string(), + )); + } + let valid_sha256 = platform.sha256.len() == 64 + && platform + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()); + if !valid_sha256 { + return Err(AppError::Engine( + "signed manager update identity has an invalid artifact digest".to_string(), + )); + } + Ok(platform.sha256.clone()) +} + +async fn authenticate_manager_update( + client: &reqwest::Client, + manifest_endpoint: &url::Url, + pubkey: &str, + mut update: tauri_plugin_updater::Update, +) -> Result { + let identity_url = manager_update_versioned_url( + manifest_endpoint, + &update.version, + MANAGER_UPDATE_IDENTITY_FILE, + )?; + let signature_url = manager_update_versioned_url( + manifest_endpoint, + &update.version, + MANAGER_UPDATE_IDENTITY_SIGNATURE_FILE, + )?; + let identity_bytes = fetch_manager_update_file_limited( + client, + identity_url, + MANAGER_UPDATE_IDENTITY_MAX_BYTES, + "manager release identity", + ) + .await?; + let signature_bytes = fetch_manager_update_file_limited( + client, + signature_url, + MANAGER_UPDATE_IDENTITY_SIGNATURE_MAX_BYTES, + "manager release identity signature", + ) + .await?; + let signature = std::str::from_utf8(&signature_bytes) + .map(str::trim) + .map_err(|_| { + AppError::Engine("manager release identity signature is not UTF-8".to_string()) + })?; + tauri_plugin_updater::verify_signature(&identity_bytes, signature, pubkey) + .map_err(|e| AppError::Engine(format!("verify manager release identity: {e}")))?; + let identity: ManagerReleaseIdentity = serde_json::from_slice(&identity_bytes) + .map_err(|e| AppError::Engine(format!("parse manager release identity: {e}")))?; + let manifest_artifact = update + .download_url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .unwrap_or_default(); + let artifact_sha256 = validate_manager_update_identity_claim( + &update.version, + update.body.as_deref(), + &update.platform, + manifest_artifact, + &update.signature, + &identity, + )?; + let artifact = identity + .platforms + .get(&update.platform) + .expect("validated identity platform") + .artifact + .clone(); + update.download_url = + manager_update_versioned_url(manifest_endpoint, &update.version, &artifact)?; + Ok(AuthenticatedManagerUpdate { + update, + artifact_sha256, + }) } async fn download_with_manager_fallback( @@ -385,24 +653,46 @@ fn manager_update_matches_confirmation( pub async fn manager_check_update( app: AppHandle, ) -> Result, CommandError> { - let endpoints = configured_manager_update_endpoints(&app)?; + let config = configured_manager_update_config(&app)?; + if config.endpoints.is_empty() { + return Err( + AppError::Internal("updater plugin has no configured endpoints".to_string()).into(), + ); + } + let endpoints = config.endpoints.clone(); + let pubkey = Arc::new(config.pubkey); + let identity_client = manager_update_identity_client()?; let update = check_with_manager_fallback(endpoints, |endpoint| { let app = app.clone(); + let pubkey = Arc::clone(&pubkey); + let identity_client = identity_client.clone(); async move { - let updater = manager_updater_builder_for_endpoints(&app, Some(vec![endpoint]))? - .build() - .map_err(|e| AppError::Engine(format!("build manager updater: {e}")))?; - updater + let updater = + manager_updater_builder_for_endpoints(&app, Some(vec![endpoint.clone()]))? + .build() + .map_err(|e| AppError::Engine(format!("build manager updater: {e}")))?; + let update = updater .check() .await - .map_err(|e| AppError::Engine(format!("check manager update: {e}"))) + .map_err(|e| AppError::Engine(format!("check manager update: {e}")))?; + match update { + Some(update) => authenticate_manager_update( + &identity_client, + &endpoint, + pubkey.as_str(), + update, + ) + .await + .map(Some), + None => Ok(None), + } } }) .await?; - Ok(update.map(|update| ManagerUpdateMetadata { - version: update.version, - current_version: update.current_version, - body: update.body, + Ok(update.map(|authenticated| ManagerUpdateMetadata { + version: authenticated.update.version, + current_version: authenticated.update.current_version, + body: authenticated.update.body, })) } @@ -475,7 +765,15 @@ pub async fn manager_install_update( .operations .set_phase(op.token(), OperationPhase::Downloading) .map_err(AppError::from)?; - let endpoints = configured_manager_update_endpoints(&app)?; + let config = configured_manager_update_config(&app)?; + if config.endpoints.is_empty() { + return Err(AppError::Internal( + "updater plugin has no configured endpoints".to_string(), + )); + } + let endpoints = config.endpoints.clone(); + let pubkey = Arc::new(config.pubkey); + let identity_client = manager_update_identity_client()?; let runtime_for_attempts = state.manager_update.clone(); let operations_for_attempts = state.operations.clone(); let token_for_attempts = op.token().clone(); @@ -492,6 +790,8 @@ pub async fn manager_install_update( let app = app_for_attempts.clone(); let expected_version = Arc::clone(&expected_version); let expected_current_version = Arc::clone(&expected_current_version); + let pubkey = Arc::clone(&pubkey); + let identity_client = identity_client.clone(); async move { operations .set_phase(&token, OperationPhase::Downloading) @@ -515,6 +815,14 @@ pub async fn manager_install_update( "管理器更新内容已变化,请重新检查后再确认。".to_string(), ) })?; + let authenticated = authenticate_manager_update( + &identity_client, + &endpoint, + pubkey.as_str(), + update, + ) + .await?; + let update = authenticated.update; if !manager_update_matches_confirmation( &update.version, &update.current_version, @@ -561,6 +869,13 @@ pub async fn manager_install_update( ) .await .map_err(|e| AppError::Engine(format!("download manager update: {e}")))?; + let actual_sha256 = sha256_hex(&bytes); + if actual_sha256 != authenticated.artifact_sha256 { + return Err(AppError::Engine( + "downloaded manager update does not match the signed release identity digest" + .to_string(), + )); + } Ok(DownloadedManagerUpdate { update, bytes }) } }, @@ -768,7 +1083,8 @@ fn destructive_token_error(err: OperationError) -> CommandError { fn refresh_config_health(state: &ManagerState) -> ConfigHealth { let (_, settings_health) = PersistedAppSettings::load_with_health(); let (_, provenance_health) = ProvenanceStore::load_with_health(); - let health = ConfigHealth::from_parts(settings_health, provenance_health).with_live_backup_flags(); + let health = + ConfigHealth::from_parts(settings_health, provenance_health).with_live_backup_flags(); let mut slot = state .config_health .lock() @@ -1216,9 +1532,7 @@ pub fn mac_pause_download(state: State<'_, ManagerState>) -> Result "ok", }; if status == "corrupt" { - return Err(AppError::Internal(format!( - "已从 .bak 还原 {which},但重新读取仍判定为损坏" - )) - .into()); + return Err( + AppError::Internal(format!("已从 .bak 还原 {which},但重新读取仍判定为损坏")).into(), + ); } Ok(health) } @@ -1427,10 +1740,7 @@ pub fn reset_config( _ => "ok", }; if status == "corrupt" { - return Err(AppError::Internal(format!( - "已重置 {which},但重新读取仍判定为损坏" - )) - .into()); + return Err(AppError::Internal(format!("已重置 {which},但重新读取仍判定为损坏")).into()); } Ok(health) } @@ -1463,10 +1773,9 @@ pub fn retry_ancillary( } let _guard: RetryGuard = if purge { if confirm != Some(true) { - return Err(AppError::Internal( - "清除用户数据需要二次确认(confirm=true)".to_string(), - ) - .into()); + return Err( + AppError::Internal("清除用户数据需要二次确认(confirm=true)".to_string()).into(), + ); } let token = token.ok_or_else(|| { AppError::Internal( @@ -1478,9 +1787,7 @@ pub fn retry_ancillary( RetryGuard::Scoped(begin_guard(&state, OperationKind::Adopt)?) }; match state.target.os { - OperatingSystem::Macos => { - retry_macos_ancillary(actions, path, purge).map_err(Into::into) - } + OperatingSystem::Macos => retry_macos_ancillary(actions, path, purge).map_err(Into::into), OperatingSystem::Windows => { let settings = windows_domain_settings_for_persisted(&state); retry_windows_ancillary(&settings, actions, path, purge).map_err(Into::into) @@ -1555,7 +1862,10 @@ pub fn get_operation_snapshot( /// CloseRequested / ExitRequested guards stop intercepting and let it go. /// Still refuses when the backend is in a non-interruptible install phase. #[tauri::command] -pub fn confirm_quit(app: tauri::AppHandle, state: State<'_, ManagerState>) -> Result<(), CommandError> { +pub fn confirm_quit( + app: tauri::AppHandle, + state: State<'_, ManagerState>, +) -> Result<(), CommandError> { let confirm_close = crate::app::settings_store::AppSettings::load().confirm_close; // Evaluate as if force_quit is not yet set so a point-of-no-return phase // still blocks even after the user clicks the confirm dialog. @@ -1783,9 +2093,7 @@ pub fn win_pause_download(state: State<'_, ManagerState>) -> Result ManagerReleaseIdentity { + ManagerReleaseIdentity { + schema: 1, + version: version.to_string(), + notes_sha256: sha256_hex(notes.as_bytes()), + platforms: HashMap::from([( + "windows-x86_64".to_string(), + ManagerReleaseIdentityPlatform { + artifact: "CodexAppManager_0.3.1_x64-setup.exe".to_string(), + signature: signature.to_string(), + sha256: "a".repeat(64), + }, + )]), + } + } + + #[test] + fn manager_update_rejects_forged_v999_with_replayed_old_signature() { + let identity = manager_release_identity("0.3.1", "reviewed notes", "old-valid-signature"); + let error = validate_manager_update_identity_claim( + "999.0.0", + Some("reviewed notes"), + "windows-x86_64", + "CodexAppManager_0.3.1_x64-setup.exe", + "old-valid-signature", + &identity, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("does not match the signed release identity")); + } + + #[test] + fn manager_update_accepts_mirror_claim_bound_to_signed_identity() { + let identity = manager_release_identity("0.3.1", "reviewed notes", "current-signature"); + let digest = validate_manager_update_identity_claim( + "0.3.1", + Some("reviewed notes"), + "windows-x86_64", + "CodexAppManager_0.3.1_x64-setup.exe", + "current-signature", + &identity, + ) + .unwrap(); + + assert_eq!(digest, "a".repeat(64)); + } + + #[test] + fn manager_update_derives_versioned_identity_and_artifact_urls_per_source() { + let mirror = + url::Url::parse("https://codexapp.agentsmirror.com/manager/latest.json?ignored=1") + .unwrap(); + assert_eq!( + manager_update_versioned_url(&mirror, "0.3.2", "release-identity.json") + .unwrap() + .as_str(), + "https://codexapp.agentsmirror.com/manager/0.3.2/release-identity.json" + ); + + let github = url::Url::parse( + "https://github.com/Wangnov/Codex-App-Manager/releases/latest/download/latest.json", + ) + .unwrap(); + assert_eq!( + manager_update_versioned_url( + &github, + "0.3.2", + "CodexAppManager_0.3.2_x64-setup.exe" + ) + .unwrap() + .as_str(), + "https://github.com/Wangnov/Codex-App-Manager/releases/download/v0.3.2/CodexAppManager_0.3.2_x64-setup.exe" + ); + assert!(manager_update_versioned_url(&mirror, "0.3.2", "../escape").is_err()); + } + + #[test] + fn manager_update_cn_path_accepts_authenticated_mirror_first() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let attempts_for_check = Arc::clone(&attempts); + let result = tauri::async_runtime::block_on(check_with_manager_fallback( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_check); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + Ok(Some("authenticated-mirror-update")) + } + }, + )); + + assert_eq!(result.unwrap(), Some("authenticated-mirror-update")); + assert_eq!(*attempts.lock().unwrap(), vec!["mirror.example"]); + } + #[test] fn manager_update_check_falls_back_after_primary_reports_no_update() { let attempts = Arc::new(Mutex::new(Vec::new())); @@ -2405,6 +2821,39 @@ mod tests { assert_eq!(*installed.lock().unwrap(), vec!["github-verified-package"]); } + #[test] + fn manager_update_falls_back_when_mirror_serves_old_bytes_for_current_signature() { + let attempts = Arc::new(Mutex::new(Vec::new())); + let attempts_for_download = Arc::clone(&attempts); + let result = tauri::async_runtime::block_on(download_then_install_manager_update( + manager_update_test_endpoints(), + move |endpoint| { + let attempts = Arc::clone(&attempts_for_download); + async move { + attempts + .lock() + .unwrap() + .push(endpoint.host_str().unwrap().to_string()); + if endpoint.host_str() == Some("mirror.example") { + Err(AppError::Engine( + "download manager update: minisign verification failed for old bytes" + .to_string(), + )) + } else { + Ok("github-current-signed-bytes") + } + } + }, + |_| Ok(()), + )); + + assert!(result.is_ok()); + assert_eq!( + *attempts.lock().unwrap(), + vec!["mirror.example", "github.com"] + ); + } + #[test] fn manager_update_falls_back_after_mirror_stream_resets() { let attempts = Arc::new(Mutex::new(Vec::new())); diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 26f7d7d..6671d2a 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -66,7 +66,7 @@ impl ErrorKind { } /// Pull the curl exit code out of an engine message such as -/// `"curl failed for host=… exit=23: stderr='…'"`. Uses the LAST `exit=` so a +/// `"curl failed for host=… exit=23 reason='disk is full'"`. Uses the LAST `exit=` so a /// combined `resume failed (…exit=A…); fresh download failed (…exit=B…)` message /// classifies on the final (fresh) attempt the user must act on — not the resume, /// whose range/partial failure is incidental. @@ -116,7 +116,9 @@ pub fn classify(message: &str) -> ErrorKind { { return ErrorKind::Signature; } - if m.contains("capability probe") || m.contains("sideload policy") || m.contains("developer mode") + if m.contains("capability probe") + || m.contains("sideload policy") + || m.contains("developer mode") { return ErrorKind::Incompatible; } @@ -237,22 +239,58 @@ mod tests { #[test] fn curl_connect_and_timeout_codes() { - assert_eq!(classify("curl failed exit=7: stderr='Failed to connect'"), ErrorKind::Network); - assert_eq!(classify("curl failed exit=6: stderr='Could not resolve host'"), ErrorKind::Network); - assert_eq!(classify("curl failed exit=28: stderr='Operation timed out'"), ErrorKind::Timeout); - assert_eq!(classify("curl failed exit=35: stderr='SSL connect error'"), ErrorKind::Network); - assert_eq!(classify("curl failed exit=55: stderr='Failed sending network data'"), ErrorKind::Network); - assert_eq!(classify("curl failed exit=22: stderr='The requested URL returned error: 404'"), ErrorKind::Artifact); + assert_eq!( + classify("curl failed exit=7: stderr='Failed to connect'"), + ErrorKind::Network + ); + assert_eq!( + classify("curl failed exit=6: stderr='Could not resolve host'"), + ErrorKind::Network + ); + assert_eq!( + classify("curl failed exit=28: stderr='Operation timed out'"), + ErrorKind::Timeout + ); + assert_eq!( + classify("curl failed exit=35: stderr='SSL connect error'"), + ErrorKind::Network + ); + assert_eq!( + classify("curl failed exit=55: stderr='Failed sending network data'"), + ErrorKind::Network + ); + assert_eq!( + classify("curl failed exit=22: stderr='The requested URL returned error: 404'"), + ErrorKind::Artifact + ); } #[test] fn marker_based_classification_without_exit_code() { - assert_eq!(classify("Authenticode verification failed"), ErrorKind::Signature); - assert_eq!(classify("codesign verification failed: invalid signature"), ErrorKind::Signature); - assert_eq!(classify("Access is denied. (os error 5)"), ErrorKind::Permission); - assert_eq!(classify("hash mismatch for staged package"), ErrorKind::Artifact); - assert_eq!(classify("run Add-AppxPackage: deployment failed"), ErrorKind::Install); - assert_eq!(classify("capability probe error: sideloading is disabled"), ErrorKind::Incompatible); + assert_eq!( + classify("Authenticode verification failed"), + ErrorKind::Signature + ); + assert_eq!( + classify("codesign verification failed: invalid signature"), + ErrorKind::Signature + ); + assert_eq!( + classify("Access is denied. (os error 5)"), + ErrorKind::Permission + ); + assert_eq!( + classify("hash mismatch for staged package"), + ErrorKind::Artifact + ); + assert_eq!( + classify("run Add-AppxPackage: deployment failed"), + ErrorKind::Install + ); + assert_eq!( + classify("capability probe error: sideloading is disabled"), + ErrorKind::Incompatible + ); assert_eq!(classify("download cancelled"), ErrorKind::Cancelled); } @@ -279,14 +317,20 @@ mod tests { #[test] fn unknown_engine_message_falls_back_to_generic() { - assert_eq!(classify("something unexpected happened"), ErrorKind::Generic); + assert_eq!( + classify("something unexpected happened"), + ErrorKind::Generic + ); assert_eq!(code_of("something unexpected happened"), "engine_error"); } #[test] fn non_engine_variants_keep_their_codes() { assert_eq!(AppError::UnsupportedPlatform.code(), "unsupported_platform"); - assert_eq!(AppError::StaleExpectation("x".into()).code(), "stale_expectation"); + assert_eq!( + AppError::StaleExpectation("x".into()).code(), + "stale_expectation" + ); assert_eq!(AppError::Busy("x".into()).code(), "operation_busy"); assert_eq!(AppError::Internal("x".into()).code(), "internal_error"); } diff --git a/src/app/signingPolicyFiles.test.ts b/src/app/signingPolicyFiles.test.ts new file mode 100644 index 0000000..35509a2 --- /dev/null +++ b/src/app/signingPolicyFiles.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import releaseWorkflow from "../../.github/workflows/release.yml?raw"; +import readme from "../../README.md?raw"; +import codeSigningPolicy from "../../docs/code-signing-policy.md?raw"; +import privacyPolicy from "../../docs/privacy.md?raw"; +import fallbackRelease from "../../docs/releases/FALLBACK.md?raw"; +import releaseTemplate from "../../docs/releases/TEMPLATE.md?raw"; +import readinessGuard from "../../scripts/assert-signpath-foundation-ready.ps1?raw"; +import website from "../../website/index.html?raw"; +import websiteEnglish from "../../website/src/locales/en.ts?raw"; +import websiteChinese from "../../website/src/locales/zh.ts?raw"; + +describe("public SignPath Foundation disclosures", () => { + it("states the pending status, attribution, members, and privacy behavior", () => { + expect(codeSigningPolicy).toContain( + "Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)", + ); + expect(codeSigningPolicy).toMatch(/The application\s+has not yet been approved/); + expect(codeSigningPolicy).toContain("[@Wangnov](https://github.com/Wangnov)"); + expect(codeSigningPolicy).toContain("manual approval"); + expect(codeSigningPolicy).toContain("[Privacy policy](./privacy.md)"); + + expect(privacyPolicy).toContain("about 1.5 seconds after startup"); + expect(privacyPolicy).toContain("about every six hours"); + expect(privacyPolicy).toContain("Users can independently disable startup and"); + expect(privacyPolicy).toContain("includes no telemetry"); + expect(privacyPolicy).toContain("GitHub General Privacy Statement"); + expect(privacyPolicy).toContain("Cloudflare Privacy Policy"); + expect(privacyPolicy).toContain("OpenAI Privacy Policy"); + expect(privacyPolicy).toContain("Microsoft Privacy Statement"); + }); + + it("links the stable policies from download, footer, README, and release copy", () => { + for (const content of [website, readme, releaseTemplate, fallbackRelease]) { + expect(content).toContain("docs/code-signing-policy.md"); + expect(content).toContain("docs/privacy.md"); + } + + for (const locale of [websiteChinese, websiteEnglish]) { + expect(locale).toContain("signingPolicy"); + expect(locale).toContain("privacyPolicy"); + } + + expect(releaseTemplate).not.toContain("均带 Authenticode 发行者签名"); + expect(fallbackRelease).not.toContain("carry an Authenticode publisher signature"); + expect(website).toContain("Free code signing provided by"); + expect(website).toContain("https://signpath.io/"); + expect(website).toContain("https://signpath.org/"); + }); +}); + +describe("Windows release signing readiness", () => { + it("fails closed without accepting the retired PFX assumptions", () => { + expect(releaseWorkflow).toContain( + "Assert SignPath Foundation readiness (fail closed)", + ); + expect(releaseWorkflow).toContain("assert-signpath-foundation-ready.ps1"); + expect(releaseWorkflow).toContain('ExpectedSubject "SignPath Foundation"'); + expect(releaseWorkflow).not.toContain("WINDOWS_CERTIFICATE"); + expect(releaseWorkflow).not.toContain("prepare-windows-authenticode.ps1"); + + expect(readinessGuard).toContain("intentionally has no success path"); + expect(readinessGuard).toContain("throw \"[$Stage] $message\""); + expect(readinessGuard).not.toMatch(/SIGNPATH_.*READY/); + }); +}); diff --git a/vendor/tauri-plugin-updater-2.10.1/CHANGELOG.md b/vendor/tauri-plugin-updater-2.10.1/CHANGELOG.md new file mode 100644 index 0000000..8e84961 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/CHANGELOG.md @@ -0,0 +1,210 @@ +# Changelog + +## \[2.10.1] + +- [`31ab6f8d`](https://github.com/tauri-apps/plugins-workspace/commit/31ab6f8d2466d86c80b1d70510c0400ce2cdcb0a) ([#3285](https://github.com/tauri-apps/plugins-workspace/pull/3285) by [@hrzlgnm](https://github.com/tauri-apps/plugins-workspace/../../hrzlgnm)) fix: preserve file extension of updater package, otherwise users may get confused when presented with a sudo dialog suggesting to install a file with the extension `.rpm` using `dpkg -i` + +## \[2.10.0] + +- [`4375c98b`](https://github.com/tauri-apps/plugins-workspace/commit/4375c98bed1769a1fc1be26eded4db9f7c095d95) ([#3073](https://github.com/tauri-apps/plugins-workspace/pull/3073)) Add no_proxy config to disable system proxy for updater plugin. +- [`f122ee98`](https://github.com/tauri-apps/plugins-workspace/commit/f122ee98c6954075891ac3cd530f5e29da117b39) Allow configuring the updater client to accept invalid TLS certificates and hostnames for internal/self-signed update servers. These options are available via the plugin config (`dangerousAcceptInvalidCerts`, `dangerousAcceptInvalidHostnames`) and via the `UpdaterBuilder` (`dangerous_accept_invalid_certs`, `dangerous_accept_invalid_hostnames`). +- [`f122ee98`](https://github.com/tauri-apps/plugins-workspace/commit/f122ee98c6954075891ac3cd530f5e29da117b39) Updater plugin now supports all bundle types: Deb, Rpm and AppImage for Linux; NSiS, MSI for Windows. This was added in https://github.com/tauri-apps/plugins-workspace/pull/2624 +- [`05c5da07`](https://github.com/tauri-apps/plugins-workspace/commit/05c5da072b6e3254c19ee69024f80f4dfe1b779b) ([#3213](https://github.com/tauri-apps/plugins-workspace/pull/3213)) Updated `reqwest` to 0.13, make sure to update your `reqwest` dependency if you're using `UpdaterBuilder::configure_client` + +## \[2.9.0] + +- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6 + +## \[2.8.1] + +- [`735d209d`](https://github.com/tauri-apps/plugins-workspace/commit/735d209d5d1d92dcac70c6083bcfcd34ec7d84be) ([#2761](https://github.com/tauri-apps/plugins-workspace/pull/2761) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue preventing updates via the NSIS installer from succeeding when the app was launched with command line arguments containing spaces. + +## \[2.8.0] + +- [`87afa23c`](https://github.com/tauri-apps/plugins-workspace/commit/87afa23cad077c09bc1eb743800ae3396b531146) ([#2726](https://github.com/tauri-apps/plugins-workspace/pull/2726)) Add allowDowngrades parameter to check command + + Added a new optional `allowDowngrades` parameter to the JavaScript check command that allows the updater to consider versions that are lower than the current version as valid updates. When enabled, the version comparator will accept any version that is different from the current version, effectively allowing downgrades. +- [`73ff15de`](https://github.com/tauri-apps/plugins-workspace/commit/73ff15de5d07d476693e40e8e5d138c16da5211e) ([#2757](https://github.com/tauri-apps/plugins-workspace/pull/2757)) Fix headers option in `Update.download` and `Update.downloadAndInstall` doesn't work with `Record | Headers` types + +## \[2.7.1] + +- [`c5b0f51c`](https://github.com/tauri-apps/plugins-workspace/commit/c5b0f51cfd911cca9317b59efc718b570980129b) ([#2621](https://github.com/tauri-apps/plugins-workspace/pull/2621) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `check` and `download` overrides the `accept` header + +## \[2.7.0] + +### bug + +- [`2d731f80`](https://github.com/tauri-apps/plugins-workspace/commit/2d731f80224f74faf1b7170b25e04f5da1da49c8) ([#2573](https://github.com/tauri-apps/plugins-workspace/pull/2573)) Fix JS API `Update.date` not formatted to RFC 3339 +- [`0bc5d588`](https://github.com/tauri-apps/plugins-workspace/commit/0bc5d5887420ba1eb718254490b7995c771c0447) ([#2572](https://github.com/tauri-apps/plugins-workspace/pull/2572)) Fix `timeout` passed to `check` gets re-used by `download` and `downloadAndinstall` + +## \[2.6.1] + +- [`12c4537b`](https://github.com/tauri-apps/plugins-workspace/commit/12c4537b8e4fed29b415ff817434b664c0596dac) ([#2541](https://github.com/tauri-apps/plugins-workspace/pull/2541) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Add support to the `riscv64` architecture. + +## \[2.6.0] + +- [`faefcc9f`](https://github.com/tauri-apps/plugins-workspace/commit/faefcc9fd8c61f709d491649e255a7fcac82c09a) ([#2430](https://github.com/tauri-apps/plugins-workspace/pull/2430) by [@goenning](https://github.com/tauri-apps/plugins-workspace/../../goenning)) Add `UpdaterBuilder::configure_client` method on Rust side, to configure the `reqwest` client used to check and download the update. +- [`ac60d589`](https://github.com/tauri-apps/plugins-workspace/commit/ac60d589eca2bbc4aed040feb18da148e66ec171) ([#2513](https://github.com/tauri-apps/plugins-workspace/pull/2513) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Enhance error logging. + +## \[2.5.1] + +- [`6f881293`](https://github.com/tauri-apps/plugins-workspace/commit/6f881293fcd67838f6f3f8063f536292431dd1f7) ([#2439](https://github.com/tauri-apps/plugins-workspace/pull/2439) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused the plugin to emit a `ReleaseNotFound` error instead of a `Reqwest` error when the http request in `check()` failed. + +## \[2.5.0] + +- [`5369898d`](https://github.com/tauri-apps/plugins-workspace/commit/5369898db7a6098e3e2f43436100ea556d405628) ([#2067](https://github.com/tauri-apps/plugins-workspace/pull/2067) by [@jLynx](https://github.com/tauri-apps/plugins-workspace/../../jLynx)) Fix update installation on macOS when using an user without admin privileges. +- [`5369898d`](https://github.com/tauri-apps/plugins-workspace/commit/5369898db7a6098e3e2f43436100ea556d405628) ([#2067](https://github.com/tauri-apps/plugins-workspace/pull/2067) by [@jLynx](https://github.com/tauri-apps/plugins-workspace/../../jLynx)) Remove the `UpdaterBuilder::new` function, use `UpdaterExt::updater_builder` instead. + +## \[2.4.0] + +- [`0afc9b6b`](https://github.com/tauri-apps/plugins-workspace/commit/0afc9b6be07bee1077f05a86285d977e57810ed9) ([#2325](https://github.com/tauri-apps/plugins-workspace/pull/2325) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The `Update` struct/object will now contain a `raw_json`/`rawJson` property to be able to read parts of server's json response that are not handled by the plugin. + +## \[2.3.1] + +- [`57efb47c`](https://github.com/tauri-apps/plugins-workspace/commit/57efb47c116f880477f72f02a8e4239e88007d44) ([#2235](https://github.com/tauri-apps/plugins-workspace/pull/2235) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add `Builder::header` and `Builder::headers` method to configure default headers for updater. + +## \[2.3.0] + +- [`829b6326`](https://github.com/tauri-apps/plugins-workspace/commit/829b63265030bc9c61d1738c4eaca0ffb3178677) ([#1919](https://github.com/tauri-apps/plugins-workspace/pull/1919) by [@n1ght-hunter](https://github.com/tauri-apps/plugins-workspace/../../n1ght-hunter)) Add `tauri_plugin_updater::Builder::default_version_comparator` method to set the default version comparator for the updater. + +## \[2.2.0] + +- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other. + +## \[2.1.0] + +- [`f8f2eefe`](https://github.com/tauri-apps/plugins-workspace/commit/f8f2eefe03ab231beafbd6a88d61b53d77f0400d) ([#1991](https://github.com/tauri-apps/plugins-workspace/pull/1991) by [@jLynx](https://github.com/tauri-apps/plugins-workspace/../../jLynx)) Added support for `.deb` package updates on Linux systems. + +## \[2.0.2] + +- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7. + +## \[2.0.1] + +- [`9501cfa5`](https://github.com/tauri-apps/plugins-workspace/commit/9501cfa5f5385b2d7eb43a8378b322ee97cba06f) ([#1868](https://github.com/tauri-apps/plugins-workspace/pull/1868) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fix configuration parser incorrectly warning about the endpoint scheme. + +## \[2.0.0] + +- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release. + +## \[2.0.0-rc.4] + +- [`221f50f5`](https://github.com/tauri-apps/plugins-workspace/commit/221f50f53bd7a87dbd404e4cb1aaf502a5047785) ([#1816](https://github.com/tauri-apps/plugins-workspace/pull/1816) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Encode `+` when making updater requests which can be cause incorrectly interpolating the endpoint when using `{{current_version}}` in the endpoint where the current version contains a build number, for example `1.8.0+1`. +- [`04a0aea0`](https://github.com/tauri-apps/plugins-workspace/commit/04a0aea0ab9f8750200bc2fe5aff99c1c488082d) ([#1814](https://github.com/tauri-apps/plugins-workspace/pull/1814) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) **Breaking change**, Changed `UpdaterBuilder::endpoints` method to return a `Result`. +- [`04a0aea0`](https://github.com/tauri-apps/plugins-workspace/commit/04a0aea0ab9f8750200bc2fe5aff99c1c488082d) ([#1814](https://github.com/tauri-apps/plugins-workspace/pull/1814) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add `dangerousInsecureTransportProtocol` config option to allow using insecure transport protocols, like `http` + +## \[2.0.0-rc.3] + +- [`d00519e3`](https://github.com/tauri-apps/plugins-workspace/commit/d00519e3e3a3234f9eb6c2ba82c92d4199f03e53) ([#1735](https://github.com/tauri-apps/plugins-workspace/pull/1735) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) This releases the changes from 2.0.0-rc.2 to crates.io. Please see the links below for the actual changes. + +## \[2.0.0-rc.2] + +- [`f8255e1d`](https://github.com/tauri-apps/plugins-workspace/commit/f8255e1db5df6cf562b9334fbefe5e62f4a28e0a) ([#1661](https://github.com/tauri-apps/plugins-workspace/pull/1661) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add a second argument in `Update.download` and `Update.donloadAndInstall` JS APIs to modify headers and timeout when downloading the update. + +## \[2.0.0-rc.1] + +- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8 + +## \[2.0.0-rc.1] + +- [`77013925`](https://github.com/tauri-apps/plugins-workspace/commit/7701392500f375340045880fce5fb8f867bfe670) ([#1636](https://github.com/tauri-apps/plugins-workspace/pull/1636) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fixes the updater not preserving AppImage file permissions. +- [`5d170a54`](https://github.com/tauri-apps/plugins-workspace/commit/5d170a5444982dcc14135f6f1fc3e5da359f0eb0) ([#1671](https://github.com/tauri-apps/plugins-workspace/pull/1671) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.3. + +## \[2.0.0-rc.0] + +- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC. + +## \[2.0.0-beta.8] + +- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version. +- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25. + +## \[2.0.0-beta.11] + +- [`f83b9e98`](https://github.com/tauri-apps/plugins-workspace/commit/f83b9e9813843df19b03b6af1018d848111b2a62) ([#1544](https://github.com/tauri-apps/plugins-workspace/pull/1544) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) On Windows, use a named tempfile with `--installer.exe` (or `.msi`) for v2 updater + + **Breaking Change**: `UpdaterBuilder::new` now takes one more argument `app_name: String` + +## \[2.0.0-beta.7] + +- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24. + +## \[2.0.0-beta.6] + +- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23. + +## \[2.0.0-beta.8] + +- [`bf29a72b`](https://github.com/tauri-apps/plugins-workspace/commit/bf29a72baaff15214a21989df23081eee84e3b8b) ([#1454](https://github.com/tauri-apps/plugins-workspace/pull/1454) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix regression in updater plugin failing to update using `.msi` installer. + +## \[2.0.0-beta.5] + +- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22. + +## \[2.0.0-beta.4] + +- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20. +- [`43224c5d`](https://github.com/tauri-apps/plugins-workspace/commit/43224c5d5cfe2dd676e79ebafe424027c62c51c3)([#1330](https://github.com/tauri-apps/plugins-workspace/pull/1330)) Add `Update.download` and `Update.install` functions to the JavaScript API + +## \[2.0.0-beta.3] + +- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17. + +## \[2.0.0-beta.4] + +- [`293f363`](https://github.com/tauri-apps/plugins-workspace/commit/293f363c0dccc43e8403729fdc8cc2b4311c2d5b)([#1175](https://github.com/tauri-apps/plugins-workspace/pull/1175)) Add a `on_before_exit` hook for cleanup before spawning the updater on Windows, defaults to `app.cleanup_before_exit` when used through `UpdaterExt` +- [`293f363`](https://github.com/tauri-apps/plugins-workspace/commit/293f363c0dccc43e8403729fdc8cc2b4311c2d5b)([#1175](https://github.com/tauri-apps/plugins-workspace/pull/1175)) **Breaking change:** The `rustls-tls` feature flag is now enabled by default. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Internally use the webview scoped resources table instead of the app one, so other webviews can't access other webviews resources. +- [`7e2fcc5`](https://github.com/tauri-apps/plugins-workspace/commit/7e2fcc5e74df7c3c718e40f75bfb0eafc7d69d8d)([#1146](https://github.com/tauri-apps/plugins-workspace/pull/1146)) Update dependencies to align with tauri 2.0.0-beta.14. +- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Update for tauri 2.0.0-beta.15. + +## \[2.0.0-beta.3] + +- [`4e37316`](https://github.com/tauri-apps/plugins-workspace/commit/4e37316af0d6532bf9a9bd0e712b5b14b0598285)([#1051](https://github.com/tauri-apps/plugins-workspace/pull/1051)) Fix deserialization of `windows > installerArgs` config field. +- [`4e37316`](https://github.com/tauri-apps/plugins-workspace/commit/4e37316af0d6532bf9a9bd0e712b5b14b0598285)([#1051](https://github.com/tauri-apps/plugins-workspace/pull/1051)) On Windows, fallback to `passive` install mode when not defined in config. +- [`a3b5396`](https://github.com/tauri-apps/plugins-workspace/commit/a3b5396113ca93912274f6890d9ef5b1a409587a)([#1054](https://github.com/tauri-apps/plugins-workspace/pull/1054)) Fix Windows powershell window flashing on update +- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true. + +## \[2.0.0-beta.2] + +- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8. + +## \[2.0.0-beta.1] + +- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4. + +## \[2.0.0-beta.0] + +- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta. +- [`0879a87`](https://github.com/tauri-apps/plugins-workspace/commit/0879a87a7ecc83c9e886e6f1412fe253082b8d34)([#899](https://github.com/tauri-apps/plugins-workspace/pull/899)) Fix `Started` event not emitted to JS when downloading update. +- [`8505a75`](https://github.com/tauri-apps/plugins-workspace/commit/8505a756b569d88757ec58e452bfe4814d8107bf)([#907](https://github.com/tauri-apps/plugins-workspace/pull/907)) Add support for specifying proxy to use for checking and downloading updates. + +## \[2.0.0-alpha.5] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13. +- [`e5f979f`](https://github.com/tauri-apps/plugins-workspace/commit/e5f979f91abbb1775fa048af3219b30ff30ed691)([#818](https://github.com/tauri-apps/plugins-workspace/pull/818)) Fix NSIS updater failing to launch when using `basicUi` mode. + +## \[2.0.0-alpha.4] + +- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12. + +## \[2.0.0-alpha.3] + +- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11. + +## \[2.0.0-alpha.2] + +- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9. + +## \[2.0.0-alpha.2] + +- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12. + +## \[2.0.0-alpha.1] + +- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11. +- [`4ab90f0`](https://github.com/tauri-apps/plugins-workspace/commit/4ab90f048eab2918344f97dc8e04413a404e392d)([#431](https://github.com/tauri-apps/plugins-workspace/pull/431)) The updater plugin is recieving a few changes to improve consistency and ergonomics of the Rust and JS APIs + +## \[2.0.0-alpha.0] + +- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release! diff --git a/vendor/tauri-plugin-updater-2.10.1/Cargo.lock b/vendor/tauri-plugin-updater-2.10.1/Cargo.lock new file mode 100644 index 0000000..6afe78c --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/Cargo.lock @@ -0,0 +1,4580 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc309ed89476c8957c50fb818f56fe894db857866c3e163335faa91dc34eb85" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cargo_toml" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257" +dependencies = [ + "serde", + "toml 0.8.20", +] + +[[package]] +name = "cc" +version = "1.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.100", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.100", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "derive_more" +version = "0.99.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.100", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "dpi" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "embed-resource" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.8.20", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.9.0", + "selectors", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6367d84fb54d4242af283086402907277715b8fe46976963af5ebf173f8efba3" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" +dependencies = [ + "bitflags 2.9.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +dependencies = [ + "bitflags 2.9.0", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +dependencies = [ + "bitflags 2.9.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ac59da3ceebc4a82179b35dc550431ad9458f9cc326e053f49ba371ce76c5a" +dependencies = [ + "bitflags 2.9.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3126341c65c5d5728423ae95d788e1b660756486ad0592307ab87ba02d9a7268" +dependencies = [ + "bitflags 2.9.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1" +dependencies = [ + "bitflags 2.9.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b717127e4014b0f9f3e8bba3d3f2acec81f1bde01f656823036e823ed2c94dce" +dependencies = [ + "bitflags 2.9.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.0+3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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.12", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +dependencies = [ + "base64 0.22.1", + "indexmap 2.9.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[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.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.100", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299d9c19d7d466db4ab10addd5703e4c615dec2a5a16dbbafe191045e87ee66e" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.20", + "version-compare", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19299a5f2ccb6bcb2a38b1eb7999e8bbdbd5bdd3cf74a4f8ab412726cbd65e7" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.2", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-utils", + "thiserror 2.0.12", + "tokio", + "url", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76809f63061c8b25537b87f46b8733b31397cf419706dc874e1602be6479ba90" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.5", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2ebe49d690ccaea93aa81fff99277d4f445968f085ba13be67859151e9e4b8" +dependencies = [ + "base64 0.22.1", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.100", + "tauri-utils", + "thiserror 2.0.12", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1119f651b0187c686c0fc72c66bba311e560e1b5f61869086ce788d43be6cf41" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.100", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.5", + "walkdir", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.12", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.12", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-utils" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +dependencies = [ + "anyhow", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.12", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56eaa45f707bedf34d19312c26d350bc0f3c59a47e58e8adbeecdc850d2c13a0" +dependencies = [ + "embed-resource", + "toml 0.8.20", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned 0.6.8", + "toml_datetime 0.6.8", + "toml_edit 0.22.24", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.12", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.9.0", + "toml_datetime 0.6.8", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.9.0", + "toml_datetime 0.6.8", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned 0.6.8", + "toml_datetime 0.6.8", + "winnow 0.7.12", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow 0.7.12", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.9.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.100", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.12", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-version" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "zip" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.9.0", + "memchr", +] diff --git a/vendor/tauri-plugin-updater-2.10.1/Cargo.toml b/vendor/tauri-plugin-updater-2.10.1/Cargo.toml new file mode 100644 index 0000000..f33dec5 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/Cargo.toml @@ -0,0 +1,185 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.77.2" +name = "tauri-plugin-updater" +version = "2.10.1" +authors = ["Tauri Programme within The Commons Conservancy"] +build = "build.rs" +links = "tauri-plugin-updater" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "In-app updates for Tauri applications." +readme = "README.md" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/tauri-apps/plugins-workspace" + +[package.metadata.docs.rs] +no-default-features = true +features = ["zip"] + +[package.metadata.platforms.support.windows] +level = "full" +notes = "" + +[package.metadata.platforms.support.linux] +level = "full" +notes = "" + +[package.metadata.platforms.support.macos] +level = "full" +notes = "" + +[package.metadata.platforms.support.android] +level = "none" +notes = "" + +[package.metadata.platforms.support.ios] +level = "none" +notes = "" + +[features] +default = [ + "rustls-tls", + "zip", +] +native-tls = ["reqwest/native-tls"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +rustls-tls = [ + "reqwest/rustls-no-provider", + "dep:rustls", +] +zip = [ + "dep:zip", + "dep:tar", + "dep:flate2", +] + +[lib] +name = "tauri_plugin_updater" +path = "src/lib.rs" + +[dependencies.base64] +version = "0.22" + +[dependencies.futures-util] +version = "0.3" + +[dependencies.http] +version = "1" + +[dependencies.infer] +version = "0.19" + +[dependencies.log] +version = "0.4" + +[dependencies.minisign-verify] +version = "0.2" + +[dependencies.percent-encoding] +version = "2.3" + +[dependencies.reqwest] +version = "0.13" +features = [ + "json", + "stream", +] +default-features = false + +[dependencies.rustls] +version = "0.23" +features = ["ring"] +optional = true +default-features = false + +[dependencies.semver] +version = "1" +features = ["serde"] + +[dependencies.serde] +version = "1" +features = ["derive"] + +[dependencies.serde_json] +version = "1" + +[dependencies.tauri] +version = "2.10" +default-features = false + +[dependencies.tempfile] +version = "3.20" + +[dependencies.thiserror] +version = "2" + +[dependencies.time] +version = "0.3" +features = [ + "parsing", + "formatting", +] + +[dependencies.tokio] +version = "1" + +[dependencies.url] +version = "2" + +[build-dependencies.tauri-plugin] +version = "2.5" +features = ["build"] + +[dev-dependencies.tauri] +version = "2.10" +features = ["test"] +default-features = false + +[target.'cfg(target_os = "linux")'.dependencies.dirs] +version = "6" + +[target.'cfg(target_os = "linux")'.dependencies.flate2] +version = "1" +optional = true + +[target.'cfg(target_os = "linux")'.dependencies.tar] +version = "0.4" +optional = true + +[target.'cfg(target_os = "macos")'.dependencies.flate2] +version = "1" + +[target.'cfg(target_os = "macos")'.dependencies.osakit] +version = "0.3" +features = ["full"] + +[target.'cfg(target_os = "macos")'.dependencies.tar] +version = "0.4" + +[target.'cfg(target_os = "windows")'.dependencies.windows-sys] +version = "0.60.0" +features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Shell", +] + +[target.'cfg(target_os = "windows")'.dependencies.zip] +version = "4" +optional = true +default-features = false diff --git a/vendor/tauri-plugin-updater-2.10.1/Cargo.toml.orig b/vendor/tauri-plugin-updater-2.10.1/Cargo.toml.orig new file mode 100644 index 0000000..3324825 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/Cargo.toml.orig @@ -0,0 +1,74 @@ +[package] +name = "tauri-plugin-updater" +version = "2.10.1" +description = "In-app updates for Tauri applications." +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } +repository = { workspace = true } +links = "tauri-plugin-updater" + +[package.metadata.docs.rs] +no-default-features = true +features = ["zip"] + +[package.metadata.platforms.support] +windows = { level = "full", notes = "" } +linux = { level = "full", notes = "" } +macos = { level = "full", notes = "" } +android = { level = "none", notes = "" } +ios = { level = "none", notes = "" } + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dependencies] +tauri = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +tokio = "1" +reqwest = { version = "0.13", default-features = false, features = [ + "json", + "stream", +] } +rustls = { version = "0.23", default-features = false, features = [ + "ring", +], optional = true } +url = { workspace = true } +http = "1" +minisign-verify = "0.2" +time = { version = "0.3", features = ["parsing", "formatting"] } +base64 = "0.22" +semver = { version = "1", features = ["serde"] } +futures-util = "0.3" +tempfile = "3.20" +infer = "0.19" +percent-encoding = "2.3" + +[target."cfg(target_os = \"windows\")".dependencies] +zip = { version = "4", default-features = false, optional = true } +windows-sys = { version = "0.60.0", features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Shell", +] } + +[target."cfg(target_os = \"linux\")".dependencies] +dirs = "6" +tar = { version = "0.4", optional = true } +flate2 = { version = "1", optional = true } + +[target."cfg(target_os = \"macos\")".dependencies] +tar = "0.4" +flate2 = "1" +osakit = { version = "0.3", features = ["full"] } + +[features] +default = ["rustls-tls", "zip"] +zip = ["dep:zip", "dep:tar", "dep:flate2"] +native-tls = ["reqwest/native-tls"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +rustls-tls = ["reqwest/rustls-no-provider", "dep:rustls"] diff --git a/vendor/tauri-plugin-updater-2.10.1/LICENSE.spdx b/vendor/tauri-plugin-updater-2.10.1/LICENSE.spdx new file mode 100644 index 0000000..cdd0df5 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/LICENSE.spdx @@ -0,0 +1,20 @@ +SPDXVersion: SPDX-2.1 +DataLicense: CC0-1.0 +PackageName: tauri +DataFormat: SPDXRef-1 +PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy +PackageHomePage: https://tauri.app +PackageLicenseDeclared: Apache-2.0 +PackageLicenseDeclared: MIT +PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy +PackageSummary: Tauri is a rust project that enables developers to make secure +and small desktop applications using a web frontend. + +PackageComment: The package includes the following libraries; see +Relationship information. + +Created: 2019-05-20T09:00:00Z +PackageDownloadLocation: git://github.com/tauri-apps/tauri +PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git +PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git +Creator: Person: Daniel Thompson-Yvetot \ No newline at end of file diff --git a/vendor/tauri-plugin-updater-2.10.1/LICENSE_APACHE-2.0 b/vendor/tauri-plugin-updater-2.10.1/LICENSE_APACHE-2.0 new file mode 100644 index 0000000..4947287 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/LICENSE_APACHE-2.0 @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/vendor/tauri-plugin-updater-2.10.1/LICENSE_MIT b/vendor/tauri-plugin-updater-2.10.1/LICENSE_MIT new file mode 100644 index 0000000..4d75472 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/LICENSE_MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/vendor/tauri-plugin-updater-2.10.1/README.md b/vendor/tauri-plugin-updater-2.10.1/README.md new file mode 100644 index 0000000..b3afb81 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/README.md @@ -0,0 +1,102 @@ +![plugin-updater](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/updater/banner.png) + +In-app updates for Tauri applications. + +| Platform | Supported | +| -------- | --------- | +| Linux | ✓ | +| Windows | ✓ | +| macOS | ✓ | +| Android | x | +| iOS | x | + +## Install + +_This plugin requires a Rust version of at least **1.77.2**_ + +There are three general methods of installation that we can recommend. + +1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked) +2. Pull sources directly from Github using git tags / revision hashes (most secure) +3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use) + +Install the Core plugin by adding the following to your `Cargo.toml` file: + +`src-tauri/Cargo.toml` + +```toml +# you can add the dependencies on the `[dependencies]` section if you do not target mobile +[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] +tauri-plugin-updater = "2.0.0" +# alternatively with Git: +tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" } +``` + +You can install the JavaScript Guest bindings using your preferred JavaScript package manager: + +```sh +pnpm add @tauri-apps/plugin-updater +# or +npm add @tauri-apps/plugin-updater +# or +yarn add @tauri-apps/plugin-updater +``` + +## Usage + +First you need to register the core plugin with Tauri: + +`src-tauri/src/lib.rs` + +```rust +fn main() { + tauri::Builder::default() + .setup(|app| { + #[cfg(desktop)] + app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Afterwards all the plugin's APIs are available through the JavaScript guest bindings: + +```javascript +import { check } from '@tauri-apps/plugin-updater' +import { relaunch } from '@tauri-apps/plugin-process' +const update = await check() +if (update) { + await update.downloadAndInstall() + await relaunch() +} +``` + +Note that for these APIs to work you have to properly configure the updater first and generate updater artifacts. Please refer to the [guide on our website](https://v2.tauri.app/plugin/updater/) for this. + +## Contributing + +PRs accepted. Please make sure to read the Contributing Guide before making a pull request. + +## Partners + + + + + + + +
+ + CrabNebula + +
+ +For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri). + +## License + +Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy. + +MIT or MIT/Apache 2.0 where applicable. diff --git a/vendor/tauri-plugin-updater-2.10.1/SECURITY.md b/vendor/tauri-plugin-updater-2.10.1/SECURITY.md new file mode 100644 index 0000000..4f09bba --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not report security vulnerabilities through public GitHub issues.** + +**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.** + +Include as much of the following information: + +- Type of issue (e.g. improper input parsing, privilege escalation, etc.) +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- The distribution affected or used to help us with reproduction of the issue +- Step-by-step instructions to reproduce the issue +- Ideally a reproduction repository +- Impact of the issue, including how an attacker might exploit the issue + +We prefer to receive reports in English. + +## Contact + +Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new). + +Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app). diff --git a/vendor/tauri-plugin-updater-2.10.1/VENDORED.md b/vendor/tauri-plugin-updater-2.10.1/VENDORED.md new file mode 100644 index 0000000..ff3f91b --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/VENDORED.md @@ -0,0 +1,48 @@ +# Vendored tauri-plugin-updater 2.10.1 + +This directory is an exact source-vendor of +[`tauri-plugin-updater` 2.10.1](https://crates.io/crates/tauri-plugin-updater/2.10.1), +whose crates.io checksum is +`806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af`. +The upstream repository is +[`tauri-apps/plugins-workspace`](https://github.com/tauri-apps/plugins-workspace). +The original Apache-2.0 and MIT license files are preserved beside this note. + +## Local security patch + +Upstream 2.10.1 reads both `latest.json` and updater artifacts completely into +memory. `configure_client` only exposes `reqwest::ClientBuilder`, so callers +cannot impose a response-body limit. This vendor adds: + +- explicit non-zero default manifest and artifact byte limits; +- `UpdaterBuilder::max_manifest_size` and `max_download_size` overrides; +- `Content-Length` preflight plus checked streaming byte accumulation; +- an explicit `ResponseTooLarge` error before an oversized chunk is retained; +- the selected static-manifest platform key on `Update`; +- public reuse of the plugin's exact minisign verification routine for signed + release identity files; and +- regression tests for header-declared and chunked/no-header oversized bodies. + +No installer, extraction, proxy, TLS, or minisign artifact semantics are +otherwise changed. + +The standalone `Cargo.lock` is retained so CI can run this vendor's own tests +with `--locked`; the application still resolves the path patch through +`src-tauri/Cargo.lock`. + +## Upgrade checklist + +Do not remove the `[patch.crates-io]` entry during an updater upgrade until the +candidate upstream version is verified to provide all of these properties: + +1. Manifest and artifact responses have both header and streamed hard limits. +2. The limit aborts before appending the first over-limit chunk. +3. Minisign verification still runs before `Update::install`. +4. Codex App Manager can identify the exact selected platform and verify its + signed release identity before trusting a mirror manifest. +5. The tests in this vendor and the Manager replay/fallback tests have been + ported and pass against the replacement. + +When upgrading, diff this directory against the exact new crate source, retain +the upstream license files, update the checksum/version above, regenerate +`src-tauri/Cargo.lock`, and run both the vendor and application Rust suites. diff --git a/vendor/tauri-plugin-updater-2.10.1/api-iife.js b/vendor/tauri-plugin-updater-2.10.1/api-iife.js new file mode 100644 index 0000000..e13f519 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/api-iife.js @@ -0,0 +1 @@ +if("__TAURI__"in window){var __TAURI_PLUGIN_UPDATER__=function(t){"use strict";function e(t,e,s,n){if("function"==typeof e?t!==e||!n:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===s?n:"a"===s?n.call(t):n?n.value:e.get(t)}function s(t,e,s,n,i){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,s),s}var n,i,a,r,o;"function"==typeof SuppressedError&&SuppressedError;const d="__TAURI_TO_IPC_KEY__";class c{constructor(t){n.set(this,void 0),i.set(this,0),a.set(this,[]),r.set(this,void 0),s(this,n,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}(t=>{const o=t.index;if("end"in t)return void(o==e(this,i,"f")?this.cleanupCallback():s(this,r,o));const d=t.message;if(o==e(this,i,"f")){for(e(this,n,"f").call(this,d),s(this,i,e(this,i,"f")+1);e(this,i,"f")in e(this,a,"f");){const t=e(this,a,"f")[e(this,i,"f")];e(this,n,"f").call(this,t),delete e(this,a,"f")[e(this,i,"f")],s(this,i,e(this,i,"f")+1)}e(this,i,"f")===e(this,r,"f")&&this.cleanupCallback()}else e(this,a,"f")[o]=d})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){s(this,n,t)}get onmessage(){return e(this,n,"f")}[(n=new WeakMap,i=new WeakMap,a=new WeakMap,r=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}async function l(t,e={},s){return window.__TAURI_INTERNALS__.invoke(t,e,s)}class h{get rid(){return e(this,o,"f")}constructor(t){o.set(this,void 0),s(this,o,t)}async close(){return l("plugin:resources|close",{rid:this.rid})}}o=new WeakMap;class u extends h{constructor(t){super(t.rid),this.available=!0,this.currentVersion=t.currentVersion,this.version=t.version,this.date=t.date,this.body=t.body,this.rawJson=t.rawJson}async download(t,e){_(e);const s=new c;t&&(s.onmessage=t);const n=await l("plugin:updater|download",{onEvent:s,rid:this.rid,...e});this.downloadedBytes=new h(n)}async install(){if(!this.downloadedBytes)throw new Error("Update.install called before Update.download");await l("plugin:updater|install",{updateRid:this.rid,bytesRid:this.downloadedBytes.rid}),this.downloadedBytes=void 0}async downloadAndInstall(t,e){_(e);const s=new c;t&&(s.onmessage=t),await l("plugin:updater|download_and_install",{onEvent:s,rid:this.rid,...e})}async close(){await(this.downloadedBytes?.close()),await super.close()}}function _(t){t?.headers&&(t.headers=Array.from(new Headers(t.headers).entries()))}return t.Update=u,t.check=async function(t){_(t);const e=await l("plugin:updater|check",{...t});return e?new u(e):null},t}({});Object.defineProperty(window.__TAURI__,"updater",{value:__TAURI_PLUGIN_UPDATER__})} diff --git a/vendor/tauri-plugin-updater-2.10.1/banner.png b/vendor/tauri-plugin-updater-2.10.1/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..439110d842e2c80461003f4a9a5ede92ae410a96 GIT binary patch literal 57959 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
Sy5IL63lkgen*3W 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 literal 0 HcmV?d00001 diff --git a/vendor/tauri-plugin-updater-2.10.1/build.rs b/vendor/tauri-plugin-updater-2.10.1/build.rs new file mode 100644 index 0000000..30f70b9 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/build.rs @@ -0,0 +1,25 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +const COMMANDS: &[&str] = &["check", "download", "install", "download_and_install"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .global_api_script_path("./api-iife.js") + .build(); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + let mobile = target_os == "ios" || target_os == "android"; + alias("desktop", !mobile); + alias("mobile", mobile); +} + +// creates a cfg alias if `has_feature` is true. +// `alias` must be a snake case string. +fn alias(alias: &str, has_feature: bool) { + println!("cargo:rustc-check-cfg=cfg({alias})"); + if has_feature { + println!("cargo:rustc-cfg={alias}"); + } +} diff --git a/vendor/tauri-plugin-updater-2.10.1/guest-js/index.ts b/vendor/tauri-plugin-updater-2.10.1/guest-js/index.ts new file mode 100644 index 0000000..b651843 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/guest-js/index.ts @@ -0,0 +1,155 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { invoke, Channel, Resource } from '@tauri-apps/api/core' + +/** Options used when checking for updates */ +interface CheckOptions { + /** + * Request headers + */ + headers?: HeadersInit + /** + * Timeout in milliseconds + */ + timeout?: number + /** + * A proxy url to be used when checking and downloading updates. + */ + proxy?: string + /** + * Target identifier for the running application. This is sent to the backend. + */ + target?: string + /** + * Allow downgrades to previous versions by not checking if the current version is greater than the available version. + */ + allowDowngrades?: boolean +} + +/** Options used when downloading an update */ +interface DownloadOptions { + /** + * Request headers + */ + headers?: HeadersInit + /** + * Timeout in milliseconds + */ + timeout?: number +} + +interface UpdateMetadata { + rid: number + currentVersion: string + version: string + date?: string + body?: string + rawJson: Record +} + +/** Updater download event */ +type DownloadEvent = + | { event: 'Started'; data: { contentLength?: number } } + | { event: 'Progress'; data: { chunkLength: number } } + | { event: 'Finished' } + +class Update extends Resource { + // TODO: remove this field in v3 + /** @deprecated This is always true, check if the return value is `null` instead when using {@linkcode check} */ + available: boolean + currentVersion: string + version: string + date?: string + body?: string + rawJson: Record + private downloadedBytes?: Resource + + constructor(metadata: UpdateMetadata) { + super(metadata.rid) + this.available = true + this.currentVersion = metadata.currentVersion + this.version = metadata.version + this.date = metadata.date + this.body = metadata.body + this.rawJson = metadata.rawJson + } + + /** Download the updater package */ + async download( + onEvent?: (progress: DownloadEvent) => void, + options?: DownloadOptions + ): Promise { + convertToRustHeaders(options) + const channel = new Channel() + if (onEvent) { + channel.onmessage = onEvent + } + const downloadedBytesRid = await invoke('plugin:updater|download', { + onEvent: channel, + rid: this.rid, + ...options + }) + this.downloadedBytes = new Resource(downloadedBytesRid) + } + + /** Install downloaded updater package */ + async install(): Promise { + if (!this.downloadedBytes) { + throw new Error('Update.install called before Update.download') + } + + await invoke('plugin:updater|install', { + updateRid: this.rid, + bytesRid: this.downloadedBytes.rid + }) + + // Don't need to call close, we did it in rust side already + this.downloadedBytes = undefined + } + + /** Downloads the updater package and installs it */ + async downloadAndInstall( + onEvent?: (progress: DownloadEvent) => void, + options?: DownloadOptions + ): Promise { + convertToRustHeaders(options) + const channel = new Channel() + if (onEvent) { + channel.onmessage = onEvent + } + await invoke('plugin:updater|download_and_install', { + onEvent: channel, + rid: this.rid, + ...options + }) + } + + async close(): Promise { + await this.downloadedBytes?.close() + await super.close() + } +} + +/** Check for updates, resolves to `null` if no updates are available */ +async function check(options?: CheckOptions): Promise { + convertToRustHeaders(options) + + const metadata = await invoke('plugin:updater|check', { + ...options + }) + return metadata ? new Update(metadata) : null +} + +/** + * Converts the headers in options to be an {@linkcode Array<[string, string]>} which is what the Rust side expects + */ +function convertToRustHeaders(options?: { headers?: HeadersInit }) { + if (options?.headers) { + options.headers = Array.from(new Headers(options.headers).entries()) + } +} + +export type { CheckOptions, DownloadOptions, DownloadEvent } +export { check, Update } diff --git a/vendor/tauri-plugin-updater-2.10.1/package.json b/vendor/tauri-plugin-updater-2.10.1/package.json new file mode 100644 index 0000000..1eceda8 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tauri-apps/plugin-updater", + "version": "2.10.1", + "license": "MIT OR Apache-2.0", + "authors": [ + "Tauri Programme within The Commons Conservancy" + ], + "repository": "https://github.com/tauri-apps/plugins-workspace", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "scripts": { + "build": "rollup -c" + }, + "files": [ + "dist-js", + "README.md", + "LICENSE" + ], + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } +} diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/check.toml b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/check.toml new file mode 100644 index 0000000..fca73ce --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/check.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-check" +description = "Enables the check command without any pre-configured scope." +commands.allow = ["check"] + +[[permission]] +identifier = "deny-check" +description = "Denies the check command without any pre-configured scope." +commands.deny = ["check"] diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download.toml b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download.toml new file mode 100644 index 0000000..896b30c --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-download" +description = "Enables the download command without any pre-configured scope." +commands.allow = ["download"] + +[[permission]] +identifier = "deny-download" +description = "Denies the download command without any pre-configured scope." +commands.deny = ["download"] diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download_and_install.toml b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download_and_install.toml new file mode 100644 index 0000000..40858d1 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/download_and_install.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-download-and-install" +description = "Enables the download_and_install command without any pre-configured scope." +commands.allow = ["download_and_install"] + +[[permission]] +identifier = "deny-download-and-install" +description = "Denies the download_and_install command without any pre-configured scope." +commands.deny = ["download_and_install"] diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/install.toml b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/install.toml new file mode 100644 index 0000000..4c6a29d --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/commands/install.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-install" +description = "Enables the install command without any pre-configured scope." +commands.allow = ["install"] + +[[permission]] +identifier = "deny-install" +description = "Denies the install command without any pre-configured scope." +commands.deny = ["install"] diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/reference.md b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/reference.md new file mode 100644 index 0000000..938ca2e --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/autogenerated/reference.md @@ -0,0 +1,130 @@ +## Default Permission + +This permission set configures which kind of +updater functions are exposed to the frontend. + +#### Granted Permissions + +The full workflow from checking for updates to installing them +is enabled. + +#### This default permission set includes the following: + +- `allow-check` +- `allow-download` +- `allow-install` +- `allow-download-and-install` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`updater:allow-check` + + + +Enables the check command without any pre-configured scope. + +
+ +`updater:deny-check` + + + +Denies the check command without any pre-configured scope. + +
+ +`updater:allow-download` + + + +Enables the download command without any pre-configured scope. + +
+ +`updater:deny-download` + + + +Denies the download command without any pre-configured scope. + +
+ +`updater:allow-download-and-install` + + + +Enables the download_and_install command without any pre-configured scope. + +
+ +`updater:deny-download-and-install` + + + +Denies the download_and_install command without any pre-configured scope. + +
+ +`updater:allow-install` + + + +Enables the install command without any pre-configured scope. + +
+ +`updater:deny-install` + + + +Denies the install command without any pre-configured scope. + +
diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/default.toml b/vendor/tauri-plugin-updater-2.10.1/permissions/default.toml new file mode 100644 index 0000000..fcf08fa --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/default.toml @@ -0,0 +1,18 @@ +"$schema" = "schemas/schema.json" +[default] +description = """ +This permission set configures which kind of +updater functions are exposed to the frontend. + +#### Granted Permissions + +The full workflow from checking for updates to installing them +is enabled. + +""" +permissions = [ + "allow-check", + "allow-download", + "allow-install", + "allow-download-and-install", +] diff --git a/vendor/tauri-plugin-updater-2.10.1/permissions/schemas/schema.json b/vendor/tauri-plugin-updater-2.10.1/permissions/schemas/schema.json new file mode 100644 index 0000000..7f29591 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/permissions/schemas/schema.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the check command without any pre-configured scope.", + "type": "string", + "const": "allow-check", + "markdownDescription": "Enables the check command without any pre-configured scope." + }, + { + "description": "Denies the check command without any pre-configured scope.", + "type": "string", + "const": "deny-check", + "markdownDescription": "Denies the check command without any pre-configured scope." + }, + { + "description": "Enables the download command without any pre-configured scope.", + "type": "string", + "const": "allow-download", + "markdownDescription": "Enables the download command without any pre-configured scope." + }, + { + "description": "Denies the download command without any pre-configured scope.", + "type": "string", + "const": "deny-download", + "markdownDescription": "Denies the download command without any pre-configured scope." + }, + { + "description": "Enables the download_and_install command without any pre-configured scope.", + "type": "string", + "const": "allow-download-and-install", + "markdownDescription": "Enables the download_and_install command without any pre-configured scope." + }, + { + "description": "Denies the download_and_install command without any pre-configured scope.", + "type": "string", + "const": "deny-download-and-install", + "markdownDescription": "Denies the download_and_install command without any pre-configured scope." + }, + { + "description": "Enables the install command without any pre-configured scope.", + "type": "string", + "const": "allow-install", + "markdownDescription": "Enables the install command without any pre-configured scope." + }, + { + "description": "Denies the install command without any pre-configured scope.", + "type": "string", + "const": "deny-install", + "markdownDescription": "Denies the install command without any pre-configured scope." + }, + { + "description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`", + "type": "string", + "const": "default", + "markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`" + } + ] + } + } +} \ No newline at end of file diff --git a/vendor/tauri-plugin-updater-2.10.1/rollup.config.js b/vendor/tauri-plugin-updater-2.10.1/rollup.config.js new file mode 100644 index 0000000..1f349ec --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/rollup.config.js @@ -0,0 +1,7 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import { createConfig } from '../../shared/rollup.config.js' + +export default createConfig() diff --git a/vendor/tauri-plugin-updater-2.10.1/src/commands.rs b/vendor/tauri-plugin-updater-2.10.1/src/commands.rs new file mode 100644 index 0000000..129c413 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/src/commands.rs @@ -0,0 +1,197 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use crate::{Result, Update, UpdaterExt}; + +use http::{HeaderMap, HeaderName, HeaderValue}; +use serde::Serialize; +use tauri::{ipc::Channel, Manager, Resource, ResourceId, Runtime, Webview}; + +use std::{str::FromStr, time::Duration}; +use url::Url; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", content = "data")] +pub enum DownloadEvent { + #[serde(rename_all = "camelCase")] + Started { + content_length: Option, + }, + #[serde(rename_all = "camelCase")] + Progress { + chunk_length: usize, + }, + Finished, +} + +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Metadata { + rid: ResourceId, + current_version: String, + version: String, + date: Option, + body: Option, + raw_json: serde_json::Value, +} + +struct DownloadedBytes(pub Vec); +impl Resource for DownloadedBytes {} + +#[tauri::command] +pub(crate) async fn check( + webview: Webview, + headers: Option>, + timeout: Option, + proxy: Option, + target: Option, + allow_downgrades: Option, +) -> Result> { + let mut builder = webview.updater_builder(); + if let Some(headers) = headers { + for (k, v) in headers { + builder = builder.header(k, v)?; + } + } + if let Some(timeout) = timeout { + builder = builder.timeout(Duration::from_millis(timeout)); + } + if let Some(ref proxy) = proxy { + let url = Url::parse(proxy.as_str())?; + builder = builder.proxy(url); + } + if let Some(target) = target { + builder = builder.target(target); + } + if allow_downgrades.unwrap_or(false) { + builder = builder.version_comparator(|current, update| update.version != current); + } + + let updater = builder.build()?; + let update = updater.check().await?; + + if let Some(update) = update { + let formatted_date = if let Some(date) = update.date { + let formatted_date = date + .format(&time::format_description::well_known::Rfc3339) + .map_err(|_| crate::Error::FormatDate)?; + Some(formatted_date) + } else { + None + }; + let metadata = Metadata { + current_version: update.current_version.clone(), + version: update.version.clone(), + date: formatted_date, + body: update.body.clone(), + raw_json: update.raw_json.clone(), + rid: webview.resources_table().add(update), + }; + Ok(Some(metadata)) + } else { + Ok(None) + } +} + +#[tauri::command] +pub(crate) async fn download( + webview: Webview, + rid: ResourceId, + on_event: Channel, + headers: Option>, + timeout: Option, +) -> Result { + let update = webview.resources_table().get::(rid)?; + + let mut update = (*update).clone(); + + if let Some(headers) = headers { + let mut map = HeaderMap::new(); + for (k, v) in headers { + map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?); + } + update.headers = map; + } + + if let Some(timeout) = timeout { + update.timeout = Some(Duration::from_millis(timeout)); + } + + let mut first_chunk = true; + let bytes = update + .download( + |chunk_length, content_length| { + if first_chunk { + first_chunk = !first_chunk; + let _ = on_event.send(DownloadEvent::Started { content_length }); + } + let _ = on_event.send(DownloadEvent::Progress { chunk_length }); + }, + || { + let _ = on_event.send(DownloadEvent::Finished); + }, + ) + .await?; + + Ok(webview.resources_table().add(DownloadedBytes(bytes))) +} + +#[tauri::command] +pub(crate) async fn install( + webview: Webview, + update_rid: ResourceId, + bytes_rid: ResourceId, +) -> Result<()> { + let update = webview.resources_table().get::(update_rid)?; + let bytes = webview + .resources_table() + .get::(bytes_rid)?; + update.install(&bytes.0)?; + let _ = webview.resources_table().close(bytes_rid); + Ok(()) +} + +#[tauri::command] +pub(crate) async fn download_and_install( + webview: Webview, + rid: ResourceId, + on_event: Channel, + headers: Option>, + timeout: Option, +) -> Result<()> { + let update = webview.resources_table().get::(rid)?; + + let mut update = (*update).clone(); + + if let Some(headers) = headers { + let mut map = HeaderMap::new(); + for (k, v) in headers { + map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?); + } + update.headers = map; + } + + if let Some(timeout) = timeout { + update.timeout = Some(Duration::from_millis(timeout)); + } + + let mut first_chunk = true; + + update + .download_and_install( + |chunk_length, content_length| { + if first_chunk { + first_chunk = !first_chunk; + let _ = on_event.send(DownloadEvent::Started { content_length }); + } + let _ = on_event.send(DownloadEvent::Progress { chunk_length }); + }, + || { + let _ = on_event.send(DownloadEvent::Finished); + }, + ) + .await?; + + Ok(()) +} diff --git a/vendor/tauri-plugin-updater-2.10.1/src/config.rs b/vendor/tauri-plugin-updater-2.10.1/src/config.rs new file mode 100644 index 0000000..8c6e8bc --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/src/config.rs @@ -0,0 +1,164 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ffi::OsString, fmt::Display}; + +use serde::{Deserialize, Deserializer}; +use url::Url; + +/// Install modes for the Windows update. +#[derive(Debug, PartialEq, Eq, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[derive(Default)] +pub enum WindowsUpdateInstallMode { + /// Specifies there's a basic UI during the installation process, including a final dialog box at the end. + BasicUi, + /// The quiet mode means there's no user interaction required. + /// Requires admin privileges if the installer does. + Quiet, + /// Specifies unattended mode, which means the installation only shows a progress bar. + #[default] + Passive, +} + +impl WindowsUpdateInstallMode { + /// Returns the associated `msiexec.exe` arguments. + pub fn msiexec_args(&self) -> &'static [&'static str] { + match self { + Self::BasicUi => &["/qb+"], + Self::Quiet => &["/quiet"], + Self::Passive => &["/passive"], + } + } + + /// Returns the associated nsis arguments. + pub fn nsis_args(&self) -> &'static [&'static str] { + // `/P`: Passive + // `/S`: Silent + // `/R`: Restart + match self { + Self::Passive => &["/P", "/R"], + Self::Quiet => &["/S", "/R"], + _ => &[], + } + } +} + +impl Display for WindowsUpdateInstallMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::BasicUi => "basicUi", + Self::Quiet => "quiet", + Self::Passive => "passive", + } + ) + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WindowsConfig { + /// Additional arguments given to the NSIS or WiX installer. + #[serde( + default, + alias = "installer-args", + deserialize_with = "deserialize_os_string" + )] + pub installer_args: Vec, + /// Updating mode, defaults to `passive` mode. + /// + /// See [`WindowsUpdateInstallMode`] for more info. + #[serde(default, alias = "install-mode")] + pub install_mode: WindowsUpdateInstallMode, +} + +fn deserialize_os_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Vec::::deserialize(deserializer)? + .into_iter() + .map(OsString::from) + .collect::>()) +} + +/// Updater configuration. +#[derive(Debug, Clone, Default)] +pub struct Config { + /// Dangerously allow using insecure transport protocols for update endpoints. + pub dangerous_insecure_transport_protocol: bool, + /// Dangerously accept invalid TLS certificates for update requests. + pub dangerous_accept_invalid_certs: bool, + /// Dangerously accept invalid hostnames for TLS certificates for update requests. + pub dangerous_accept_invalid_hostnames: bool, + /// Updater endpoints. + pub endpoints: Vec, + /// Signature public key. + pub pubkey: String, + /// The Windows configuration for the updater. + pub windows: Option, +} + +impl<'de> Deserialize<'de> for Config { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Config { + #[serde(default, alias = "dangerous-insecure-transport-protocol")] + pub dangerous_insecure_transport_protocol: bool, + #[serde(default, alias = "dangerous-accept-invalid-certs")] + pub dangerous_accept_invalid_certs: bool, + #[serde(default, alias = "dangerous-accept-invalid-hostnames")] + pub dangerous_accept_invalid_hostnames: bool, + #[serde(default)] + pub endpoints: Vec, + pub pubkey: String, + pub windows: Option, + } + + let config = Config::deserialize(deserializer)?; + + validate_endpoints( + &config.endpoints, + config.dangerous_insecure_transport_protocol, + ) + .map_err(serde::de::Error::custom)?; + + Ok(Self { + dangerous_insecure_transport_protocol: config.dangerous_insecure_transport_protocol, + dangerous_accept_invalid_certs: config.dangerous_accept_invalid_certs, + dangerous_accept_invalid_hostnames: config.dangerous_accept_invalid_hostnames, + endpoints: config.endpoints, + pubkey: config.pubkey, + windows: config.windows, + }) + } +} + +pub(crate) fn validate_endpoints( + endpoints: &[Url], + dangerous_insecure_transport_protocol: bool, +) -> crate::Result<()> { + if !dangerous_insecure_transport_protocol { + for url in endpoints { + if url.scheme() != "https" { + #[cfg(debug_assertions)] + { + eprintln!("[\x1b[33mWARNING\x1b[0m] The updater endpoint \"{url}\" doesn't use `https` protocol. This is allowed in development but will fail in release builds."); + eprintln!("[\x1b[33mWARNING\x1b[0m] if this is a desired behavior, you can enable `dangerousInsecureTransportProtocol` in the plugin configuration"); + } + #[cfg(not(debug_assertions))] + return Err(crate::Error::InsecureTransportProtocol); + } + } + } + + Ok(()) +} diff --git a/vendor/tauri-plugin-updater-2.10.1/src/error.rs b/vendor/tauri-plugin-updater-2.10.1/src/error.rs new file mode 100644 index 0000000..ca4d559 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/src/error.rs @@ -0,0 +1,114 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Serialize, Serializer}; +use thiserror::Error; + +/// All errors that can occur while running the updater. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + /// Endpoints are not sent. + #[error("Updater does not have any endpoints set.")] + EmptyEndpoints, + /// IO errors. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Semver errors. + #[error(transparent)] + Semver(#[from] semver::Error), + /// Serialization errors. + #[error(transparent)] + Serialization(#[from] serde_json::Error), + /// Could not fetch a valid response from the server. + #[error("Could not fetch a valid release JSON from the remote")] + ReleaseNotFound, + /// Unsupported app architecture. + #[error("Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`.")] + UnsupportedArch, + /// Operating system is not supported. + #[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")] + UnsupportedOs, + /// Failed to determine updater package extract path + #[error("Failed to determine updater package extract path.")] + FailedToDetermineExtractPath, + /// Url parsing errors. + #[error(transparent)] + UrlParse(#[from] url::ParseError), + /// `reqwest` crate errors. + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + /// The platform was not found in the updater JSON response. + #[error("the platform `{0}` was not found in the response `platforms` object")] + TargetNotFound(String), + /// Neither the platform nor the fallback platform was found in the updater JSON response. + #[error( + "None of the fallback platforms `{0:?}` were found in the response `platforms` object" + )] + TargetsNotFound(Vec), + /// Download failed + #[error("`{0}`")] + Network(String), + /// A manifest or updater artifact exceeded its configured in-memory limit. + #[error( + "{resource} exceeds the configured {max_bytes}-byte limit (observed {observed_bytes} bytes)" + )] + ResponseTooLarge { + resource: &'static str, + max_bytes: u64, + observed_bytes: u64, + }, + /// `minisign_verify` errors. + #[error(transparent)] + Minisign(#[from] minisign_verify::Error), + /// `base64` errors. + #[error(transparent)] + Base64(#[from] base64::DecodeError), + /// UTF8 Errors in signature. + #[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")] + SignatureUtf8(String), + #[cfg(all(target_os = "windows", feature = "zip"))] + /// `zip` errors. + #[error(transparent)] + Extract(#[from] zip::result::ZipError), + /// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file. + #[error("temp directory is not on the same mount point as the AppImage")] + TempDirNotOnSameMountPoint, + #[error("binary for the current target not found in the archive")] + BinaryNotFoundInArchive, + #[error("failed to create temporary directory")] + TempDirNotFound, + #[error("Authentication failed or was cancelled")] + AuthenticationFailed, + #[error("Failed to install .deb package")] + DebInstallFailed, + #[error("Failed to install package")] + PackageInstallFailed, + #[error("invalid updater binary format")] + InvalidUpdaterFormat, + #[error(transparent)] + Http(#[from] http::Error), + #[error(transparent)] + InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), + #[error(transparent)] + InvalidHeaderName(#[from] http::header::InvalidHeaderName), + #[error("Failed to format date")] + FormatDate, + /// The configured updater endpoint must use a secure protocol like `https` + #[error("The configured updater endpoint must use a secure protocol like `https`.")] + InsecureTransportProtocol, + #[error(transparent)] + Tauri(#[from] tauri::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +pub type Result = std::result::Result; diff --git a/vendor/tauri-plugin-updater-2.10.1/src/lib.rs b/vendor/tauri-plugin-updater-2.10.1/src/lib.rs new file mode 100644 index 0000000..8eba426 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/src/lib.rs @@ -0,0 +1,240 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! In-app updates for Tauri applications. +//! +//! - Supported platforms: Windows, Linux and macOS.crypted database and secure runtime. + +#![doc( + html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", + html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" +)] + +use std::{ffi::OsString, sync::Arc}; + +use http::{HeaderMap, HeaderName, HeaderValue}; +use semver::Version; +use tauri::{ + plugin::{Builder as PluginBuilder, TauriPlugin}, + Manager, Runtime, +}; + +mod commands; +mod config; +mod error; +mod updater; + +pub use config::Config; +pub use error::{Error, Result}; +pub use updater::*; + +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the updater APIs. +pub trait UpdaterExt { + /// Gets the updater builder to build and updater + /// that can manually check if an update is available. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_updater::UpdaterExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle().clone(); + /// tauri::async_runtime::spawn(async move { + /// let response = handle.updater_builder().build().unwrap().check().await; + /// }); + /// Ok(()) + /// }); + /// ``` + fn updater_builder(&self) -> UpdaterBuilder; + + /// Gets the updater to manually check if an update is available. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_updater::UpdaterExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle().clone(); + /// tauri::async_runtime::spawn(async move { + /// let response = handle.updater().unwrap().check().await; + /// }); + /// Ok(()) + /// }); + /// ``` + fn updater(&self) -> Result; +} + +impl> UpdaterExt for T { + fn updater_builder(&self) -> UpdaterBuilder { + let app = self.app_handle(); + let UpdaterState { + config, + target, + version_comparator, + headers, + } = self.state::().inner(); + + let mut builder = UpdaterBuilder::new(app, config.clone()).headers(headers.clone()); + + if let Some(target) = target { + builder = builder.target(target); + } + + let args = self.env().args_os; + if !args.is_empty() { + builder = builder.current_exe_args(args); + } + + builder.version_comparator = version_comparator.clone(); + + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + let env = app.env(); + if let Some(appimage) = env.appimage { + builder = builder.executable_path(appimage); + } + } + + let app_handle = app.app_handle().clone(); + builder = builder.on_before_exit(move || { + app_handle.cleanup_before_exit(); + }); + + builder + } + + fn updater(&self) -> Result { + self.updater_builder().build() + } +} + +struct UpdaterState { + target: Option, + config: Config, + version_comparator: Option, + headers: HeaderMap, +} + +#[derive(Default)] +pub struct Builder { + target: Option, + pubkey: Option, + installer_args: Vec, + headers: HeaderMap, + default_version_comparator: Option, +} + +impl Builder { + pub fn new() -> Self { + Self::default() + } + + pub fn target(mut self, target: impl Into) -> Self { + self.target.replace(target.into()); + self + } + + pub fn pubkey>(mut self, pubkey: S) -> Self { + self.pubkey.replace(pubkey.into()); + self + } + + /// Adds an additional argument to pass to the Windows installer. + pub fn installer_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.installer_args.extend(args.into_iter().map(Into::into)); + self + } + + /// Adds multiple additional arguments to pass to the Windows installer. + pub fn installer_arg(mut self, arg: S) -> Self + where + S: Into, + { + self.installer_args.push(arg.into()); + self + } + + /// Removes all the additional arguments to pass to the Windows installer. + /// + /// Note: this only removes the additional arguments added through [`Self::installer_args`], + /// not the ones managed by us (e.g. `/UPDATER` flag passed to the NSIS installer) + pub fn clear_installer_args(mut self) -> Self { + self.installer_args.clear(); + self + } + + pub fn header(mut self, key: K, value: V) -> Result + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + let key: std::result::Result = key.try_into().map_err(Into::into); + let value: std::result::Result = + value.try_into().map_err(Into::into); + self.headers.insert(key?, value?); + + Ok(self) + } + + pub fn headers(mut self, headers: HeaderMap) -> Self { + self.headers = headers; + self + } + + pub fn default_version_comparator< + F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static, + >( + mut self, + f: F, + ) -> Self { + self.default_version_comparator.replace(Arc::new(f)); + self + } + + pub fn build(self) -> TauriPlugin { + let pubkey = self.pubkey; + let target = self.target; + let version_comparator = self.default_version_comparator; + let installer_args = self.installer_args; + let headers = self.headers; + PluginBuilder::::new("updater") + .setup(move |app, api| { + let mut config = api.config().clone(); + if let Some(pubkey) = pubkey { + config.pubkey = pubkey; + } + if let Some(windows) = &mut config.windows { + windows.installer_args.extend(installer_args); + } + app.manage(UpdaterState { + target, + config, + version_comparator, + headers, + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::check, + commands::download, + commands::install, + commands::download_and_install, + ]) + .build() + } +} diff --git a/vendor/tauri-plugin-updater-2.10.1/src/updater.rs b/vendor/tauri-plugin-updater-2.10.1/src/updater.rs new file mode 100644 index 0000000..81cb404 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/src/updater.rs @@ -0,0 +1,1894 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + collections::HashMap, + ffi::OsString, + io::Cursor, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, + time::Duration, +}; + +#[cfg(not(target_os = "macos"))] +use std::ffi::OsStr; + +use base64::Engine; +use futures_util::StreamExt; +use http::{header::ACCEPT, HeaderName}; +use minisign_verify::{PublicKey, Signature}; +use percent_encoding::{AsciiSet, CONTROLS}; +use reqwest::{ + header::{HeaderMap, HeaderValue, CONTENT_LENGTH}, + ClientBuilder, StatusCode, +}; +use semver::Version; +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; +use tauri::{ + utils::{ + config::BundleType, + platform::{bundle_type, current_exe}, + }, + AppHandle, Resource, Runtime, +}; +use time::OffsetDateTime; +use url::Url; + +use crate::{ + error::{Error, Result}, + Config, +}; + +const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +/// Safe defaults for callers that do not override the response limits. +/// +/// Manifests are normally only a few KiB and updater artifacts are expected to +/// stay well below 256 MiB. Keeping explicit defaults prevents any plugin entry +/// point from silently reverting to an unbounded in-memory response. +pub const DEFAULT_MAX_MANIFEST_SIZE: u64 = 1024 * 1024; +pub const DEFAULT_MAX_DOWNLOAD_SIZE: u64 = 256 * 1024 * 1024; + +#[derive(Copy, Clone)] +pub enum Installer { + AppImage, + Deb, + Rpm, + + App, + + Msi, + Nsis, +} + +#[cfg(test)] +mod response_limit_tests { + use super::{ + read_response_limited, UpdaterBuilder, DEFAULT_MAX_DOWNLOAD_SIZE, DEFAULT_MAX_MANIFEST_SIZE, + }; + use crate::{Config, Error}; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + fn serve_once(parts: Vec<&'static [u8]>) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + if count == 0 { + return; + } + request.extend_from_slice(&buffer[..count]); + } + for part in parts { + if stream.write_all(part).is_err() { + break; + } + let _ = stream.flush(); + } + }); + (format!("http://{address}/body"), handle) + } + + fn install_test_crypto_provider() { + #[cfg(feature = "rustls-tls")] + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + } + + #[test] + fn updater_builder_defaults_to_nonzero_response_limits() { + let app = tauri::test::mock_app(); + let builder = UpdaterBuilder::new( + app.handle(), + Config { + endpoints: vec![url::Url::parse("https://example.test/latest.json").unwrap()], + ..Default::default() + }, + ); + + assert_eq!(builder.max_manifest_size, DEFAULT_MAX_MANIFEST_SIZE); + assert_eq!(builder.max_download_size, DEFAULT_MAX_DOWNLOAD_SIZE); + assert!(builder.max_manifest_size > 0); + assert!(builder.max_download_size > 0); + } + + #[test] + fn zero_limit_rejects_first_nonempty_response() { + install_test_crypto_provider(); + let (url, server) = serve_once(vec![ + b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\n", + b"x", + ]); + let error = tauri::async_runtime::block_on(async move { + let response = reqwest::Client::new().get(url).send().await.unwrap(); + read_response_limited(response, 0, "test response", |_, _| {}) + .await + .unwrap_err() + }); + server.join().unwrap(); + + assert!(matches!( + error, + Error::ResponseTooLarge { + max_bytes: 0, + observed_bytes: 1, + .. + } + )); + } + + #[test] + fn rejects_oversized_content_length_before_reading_body() { + install_test_crypto_provider(); + let (url, server) = serve_once(vec![ + b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\n", + b"x", + ]); + let error = tauri::async_runtime::block_on(async move { + let response = reqwest::Client::new().get(url).send().await.unwrap(); + read_response_limited(response, 8, "test response", |_, _| {}) + .await + .unwrap_err() + }); + server.join().unwrap(); + + assert!(matches!( + error, + Error::ResponseTooLarge { + max_bytes: 8, + observed_bytes: 999, + .. + } + )); + } + + #[test] + fn rejects_chunked_response_without_content_length_at_stream_limit() { + install_test_crypto_provider(); + let (url, server) = serve_once(vec![ + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", + b"5\r\nhello\r\n", + b"5\r\nworld\r\n", + b"0\r\n\r\n", + ]); + let error = tauri::async_runtime::block_on(async move { + let response = reqwest::Client::new().get(url).send().await.unwrap(); + read_response_limited(response, 8, "test response", |_, total| { + assert_eq!(total, None); + }) + .await + .unwrap_err() + }); + server.join().unwrap(); + + assert!(matches!( + error, + Error::ResponseTooLarge { + max_bytes: 8, + observed_bytes: 10, + .. + } + )); + } +} + +impl Installer { + fn name(self) -> &'static str { + match self { + Self::AppImage => "appimage", + Self::Deb => "deb", + Self::Rpm => "rpm", + Self::App => "app", + Self::Msi => "msi", + Self::Nsis => "nsis", + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ReleaseManifestPlatform { + /// Download URL for the platform + pub url: Url, + /// Signature for the platform + pub signature: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum RemoteReleaseInner { + Dynamic(ReleaseManifestPlatform), + Static { + platforms: HashMap, + }, +} + +/// Information about a release returned by the remote update server. +/// +/// This type can have one of two shapes: Server Format (Dynamic Format) and Static Format. +#[derive(Debug, Clone)] +pub struct RemoteRelease { + /// Version to install. + pub version: Version, + /// Release notes. + pub notes: Option, + /// Release date. + pub pub_date: Option, + /// Release data. + pub data: RemoteReleaseInner, +} + +impl RemoteRelease { + /// The release's download URL for the given target. + pub fn download_url(&self, target: &str) -> Result<&Url> { + match self.data { + RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url), + RemoteReleaseInner::Static { ref platforms } => platforms + .get(target) + .map_or(Err(Error::TargetNotFound(target.to_string())), |p| { + Ok(&p.url) + }), + } + } + + /// The release's signature for the given target. + pub fn signature(&self, target: &str) -> Result<&String> { + match self.data { + RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature), + RemoteReleaseInner::Static { ref platforms } => platforms + .get(target) + .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| { + Ok(&platform.signature) + }), + } + } +} + +pub type OnBeforeExit = Arc; +pub type OnBeforeRequest = Arc ClientBuilder + Send + Sync + 'static>; +pub type VersionComparator = Arc bool + Send + Sync>; +type MainThreadClosure = Box; +type RunOnMainThread = + Box std::result::Result<(), tauri::Error> + Send + Sync + 'static>; + +pub struct UpdaterBuilder { + #[allow(dead_code)] + run_on_main_thread: RunOnMainThread, + app_name: String, + current_version: Version, + config: Config, + pub(crate) version_comparator: Option, + executable_path: Option, + target: Option, + endpoints: Option>, + headers: HeaderMap, + timeout: Option, + proxy: Option, + no_proxy: bool, + installer_args: Vec, + current_exe_args: Vec, + on_before_exit: Option, + configure_client: Option, + max_manifest_size: u64, + max_download_size: u64, +} + +impl UpdaterBuilder { + pub(crate) fn new(app: &AppHandle, config: crate::Config) -> Self { + let app_ = app.clone(); + let run_on_main_thread = move |f| app_.run_on_main_thread(f); + Self { + run_on_main_thread: Box::new(run_on_main_thread), + installer_args: config + .windows + .as_ref() + .map(|w| w.installer_args.clone()) + .unwrap_or_default(), + current_exe_args: Vec::new(), + app_name: app.package_info().name.clone(), + current_version: app.package_info().version.clone(), + config, + version_comparator: None, + executable_path: None, + target: None, + endpoints: None, + headers: Default::default(), + timeout: None, + proxy: None, + no_proxy: false, + on_before_exit: None, + configure_client: None, + max_manifest_size: DEFAULT_MAX_MANIFEST_SIZE, + max_download_size: DEFAULT_MAX_DOWNLOAD_SIZE, + } + } + + pub fn version_comparator bool + Send + Sync + 'static>( + mut self, + f: F, + ) -> Self { + self.version_comparator = Some(Arc::new(f)); + self + } + + pub fn target(mut self, target: impl Into) -> Self { + self.target.replace(target.into()); + self + } + + pub fn endpoints(mut self, endpoints: Vec) -> Result { + crate::config::validate_endpoints( + &endpoints, + self.config.dangerous_insecure_transport_protocol, + )?; + + self.endpoints.replace(endpoints); + Ok(self) + } + + pub fn executable_path>(mut self, p: P) -> Self { + self.executable_path.replace(p.as_ref().into()); + self + } + + pub fn header(mut self, key: K, value: V) -> Result + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + let key: std::result::Result = key.try_into().map_err(Into::into); + let value: std::result::Result = + value.try_into().map_err(Into::into); + self.headers.insert(key?, value?); + + Ok(self) + } + + pub fn headers(mut self, headers: HeaderMap) -> Self { + self.headers = headers; + self + } + + pub fn clear_headers(mut self) -> Self { + self.headers.clear(); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn proxy(mut self, proxy: Url) -> Self { + self.proxy.replace(proxy); + self + } + + /// Clear all proxies. See [`reqwest::ClientBuilder::no_proxy`](https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html#method.no_proxy). + pub fn no_proxy(mut self) -> Self { + self.no_proxy = true; + self + } + + pub fn pubkey>(mut self, pubkey: S) -> Self { + self.config.pubkey = pubkey.into(); + self + } + + /// Adds an argument to pass to the Windows installer. + pub fn installer_arg(mut self, arg: S) -> Self + where + S: Into, + { + self.installer_args.push(arg.into()); + self + } + + /// Adds multiple arguments to pass to the Windows installer. + pub fn installer_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.installer_args.extend(args.into_iter().map(Into::into)); + self + } + + /// Removes all the additional arguments to pass to the Windows installer. + /// + /// Note: this only removes the additional arguments added through + /// [`Self::installer_arg`], [`crate::Builder::installer_arg`] + /// and the `plugins > updater > windows > installerArgs` config, + /// not the ones managed by us (e.g. `/UPDATER` flag passed to the NSIS installer) + pub fn clear_installer_args(mut self) -> Self { + self.installer_args.clear(); + self + } + + /// Function to run before we run the installer and exit the app through `std::process::exit(0)` on Windows + pub fn on_before_exit(mut self, f: F) -> Self { + self.on_before_exit.replace(Arc::new(f)); + self + } + + /// Allows you to modify the `reqwest` client builder before the HTTP request is sent. + /// + /// Note that `reqwest` crate may be updated in minor releases of tauri-plugin-updater. + /// Therefore it's recommended to pin the plugin to at least a minor version when you're using `configure_client`. + pub fn configure_client ClientBuilder + Send + Sync + 'static>( + mut self, + f: F, + ) -> Self { + self.configure_client.replace(Arc::new(f)); + self + } + + /// Sets the maximum number of manifest response bytes retained in memory. + /// Both `Content-Length` and the streamed byte count are enforced. + pub fn max_manifest_size(mut self, max_bytes: u64) -> Self { + self.max_manifest_size = max_bytes; + self + } + + /// Sets the maximum number of updater artifact bytes retained in memory. + /// Both `Content-Length` and the streamed byte count are enforced. + pub fn max_download_size(mut self, max_bytes: u64) -> Self { + self.max_download_size = max_bytes; + self + } + + pub fn build(self) -> Result { + let endpoints = self + .endpoints + .unwrap_or_else(|| self.config.endpoints.clone()); + + if endpoints.is_empty() { + return Err(Error::EmptyEndpoints); + }; + + let arch = updater_arch().ok_or(Error::UnsupportedArch)?; + + let executable_path = self.executable_path.clone().unwrap_or(current_exe()?); + + // Get the extract_path from the provided executable_path + let extract_path = if cfg!(target_os = "linux") { + executable_path + } else { + extract_path_from_executable(&executable_path)? + }; + + Ok(Updater { + run_on_main_thread: Arc::new(self.run_on_main_thread), + config: self.config, + app_name: self.app_name, + current_version: self.current_version, + version_comparator: self.version_comparator, + timeout: self.timeout, + proxy: self.proxy, + no_proxy: self.no_proxy, + endpoints, + installer_args: self.installer_args, + current_exe_args: self.current_exe_args, + arch, + target: self.target, + headers: self.headers, + extract_path, + on_before_exit: self.on_before_exit, + configure_client: self.configure_client, + max_manifest_size: self.max_manifest_size, + max_download_size: self.max_download_size, + }) + } +} + +impl UpdaterBuilder { + pub(crate) fn current_exe_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.current_exe_args + .extend(args.into_iter().map(Into::into)); + self + } +} + +pub struct Updater { + #[allow(dead_code)] + run_on_main_thread: Arc, + config: Config, + app_name: String, + current_version: Version, + version_comparator: Option, + timeout: Option, + proxy: Option, + no_proxy: bool, + endpoints: Vec, + arch: &'static str, + // The `{{target}}` variable we replace in the endpoint and serach for in the JSON, + // this is either the user provided target or the current operating system by default + target: Option, + headers: HeaderMap, + extract_path: PathBuf, + on_before_exit: Option, + configure_client: Option, + max_manifest_size: u64, + max_download_size: u64, + #[allow(unused)] + installer_args: Vec, + #[allow(unused)] + current_exe_args: Vec, +} + +impl Updater { + pub async fn check(&self) -> Result> { + // we want JSON only + let mut headers = self.headers.clone(); + if !headers.contains_key(ACCEPT) { + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + } + + // Set SSL certs for linux if they aren't available. + #[cfg(target_os = "linux")] + { + if std::env::var_os("SSL_CERT_FILE").is_none() { + std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + } + if std::env::var_os("SSL_CERT_DIR").is_none() { + std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); + } + } + let target = if let Some(target) = &self.target { + target + } else { + updater_os().ok_or(Error::UnsupportedOs)? + }; + + let mut remote_release: Option = None; + let mut raw_json: Option = None; + let mut last_error: Option = None; + for url in &self.endpoints { + // replace {{current_version}}, {{target}}, {{arch}} and {{bundle_type}} in the provided URL + // this is useful if we need to query example + // https://releases.myapp.com/update/{{target}}/{{arch}}/{{current_version}} + // will be translated into -> + // https://releases.myapp.com/update/darwin/aarch64/1.0.0 + // The main objective is if the update URL is defined via the Cargo.toml + // the URL will be generated dynamically + let version = self.current_version.to_string(); + let version = version.as_bytes(); + const CONTROLS_ADD: &AsciiSet = &CONTROLS.add(b'+'); + let encoded_version = percent_encoding::percent_encode(version, CONTROLS_ADD); + let encoded_version = encoded_version.to_string(); + let installer = installer_for_bundle_type(bundle_type()) + .map(|i| i.name()) + .unwrap_or("unknown"); + + let url: Url = url + .to_string() + // url::Url automatically url-encodes the path components + .replace("%7B%7Bcurrent_version%7D%7D", &encoded_version) + .replace("%7B%7Btarget%7D%7D", target) + .replace("%7B%7Barch%7D%7D", self.arch) + .replace("%7B%7Bbundle_type%7D%7D", installer) + // but not query parameters + .replace("{{current_version}}", &encoded_version) + .replace("{{target}}", target) + .replace("{{arch}}", self.arch) + .replace("{{bundle_type}}", installer) + .parse()?; + + log::debug!("checking for updates {url}"); + + #[cfg(feature = "rustls-tls")] + if rustls::crypto::CryptoProvider::get_default().is_none() { + // This can only fail if there is already a default provider which we checked for already. + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT); + if self.config.dangerous_accept_invalid_certs { + request = request.danger_accept_invalid_certs(true); + } + if self.config.dangerous_accept_invalid_hostnames { + request = request.danger_accept_invalid_hostnames(true); + } + if let Some(timeout) = self.timeout { + request = request.timeout(timeout); + } + if self.no_proxy { + log::debug!("disabling proxy"); + request = request.no_proxy(); + } else if let Some(ref proxy) = self.proxy { + log::debug!("using proxy {proxy}"); + let proxy = reqwest::Proxy::all(proxy.as_str())?; + request = request.proxy(proxy); + } + + if let Some(ref configure_client) = self.configure_client { + request = configure_client(request); + } + + let response = request + .build()? + .get(url) + .headers(headers.clone()) + .send() + .await; + + match response { + Ok(res) => { + if res.status().is_success() { + // no updates found! + if StatusCode::NO_CONTENT == res.status() { + log::debug!("update endpoint returned 204 No Content"); + return Ok(None); + }; + + let manifest = read_response_limited( + res, + self.max_manifest_size, + "updater manifest", + |_, _| {}, + ) + .await?; + let update_response: serde_json::Value = serde_json::from_slice(&manifest)?; + log::debug!("update response: {update_response:?}"); + raw_json = Some(update_response.clone()); + match serde_json::from_value::(update_response) + .map_err(Into::into) + { + Ok(release) => { + log::debug!("parsed release response {release:?}"); + last_error = None; + remote_release = Some(release); + // we found a release, break the loop + break; + } + Err(err) => { + log::error!("failed to deserialize update response: {err}"); + last_error = Some(err) + } + } + } else { + log::error!( + "update endpoint did not respond with a successful status code" + ); + } + } + Err(err) => { + log::error!("failed to check for updates: {err}"); + last_error = Some(err.into()) + } + } + } + + // Last error is cleaned on success. + // Shouldn't be triggered if we had a successfull call + if let Some(error) = last_error { + return Err(error); + } + + // Extracted remote metadata + let release = remote_release.ok_or(Error::ReleaseNotFound)?; + + let should_update = match self.version_comparator.as_ref() { + Some(comparator) => comparator(self.current_version.clone(), release.clone()), + None => release.version > self.current_version, + }; + + let installer = installer_for_bundle_type(bundle_type()); + let (download_url, signature, platform) = self.get_urls(&release, &installer)?; + + let update = if should_update { + Some(Update { + run_on_main_thread: self.run_on_main_thread.clone(), + config: self.config.clone(), + on_before_exit: self.on_before_exit.clone(), + app_name: self.app_name.clone(), + current_version: self.current_version.to_string(), + target: target.to_owned(), + platform, + extract_path: self.extract_path.clone(), + version: release.version.to_string(), + date: release.pub_date, + download_url: download_url.clone(), + signature: signature.to_owned(), + body: release.notes, + raw_json: raw_json.unwrap(), + timeout: None, + proxy: self.proxy.clone(), + no_proxy: self.no_proxy, + headers: self.headers.clone(), + installer_args: self.installer_args.clone(), + current_exe_args: self.current_exe_args.clone(), + configure_client: self.configure_client.clone(), + max_download_size: self.max_download_size, + }) + } else { + None + }; + + Ok(update) + } + + fn get_urls<'a>( + &self, + release: &'a RemoteRelease, + installer: &Option, + ) -> Result<(&'a Url, &'a String, String)> { + // Use the user provided target + if let Some(target) = &self.target { + return Ok(( + release.download_url(target)?, + release.signature(target)?, + target.clone(), + )); + } + + // Or else we search for [`{os}-{arch}-{installer}`, `{os}-{arch}`] in order + let os = updater_os().ok_or(Error::UnsupportedOs)?; + let arch = self.arch; + let mut targets = Vec::new(); + if let Some(installer) = installer { + let installer = installer.name(); + targets.push(format!("{os}-{arch}-{installer}")); + } + targets.push(format!("{os}-{arch}")); + + for target in &targets { + log::debug!("Searching for updater target '{target}' in release data"); + if let (Ok(download_url), Ok(signature)) = + (release.download_url(target), release.signature(target)) + { + return Ok((download_url, signature, target.clone())); + }; + } + + Err(Error::TargetsNotFound(targets)) + } +} + +#[derive(Clone)] +pub struct Update { + #[allow(dead_code)] + run_on_main_thread: Arc, + config: Config, + #[allow(unused)] + on_before_exit: Option, + /// Update description + pub body: Option, + /// Version used to check for update + pub current_version: String, + /// Version announced + pub version: String, + /// Update publish date + pub date: Option, + /// The `{{target}}` variable we replace in the endpoint and search for in the JSON, + /// this is either the user provided target or the current operating system by default + pub target: String, + /// The exact platform key selected from a static release manifest. + pub platform: String, + /// Download URL announced + pub download_url: Url, + /// Signature announced + pub signature: String, + /// The raw version of server's JSON response. Useful if the response contains additional fields that the updater doesn't handle. + pub raw_json: serde_json::Value, + /// Request timeout + pub timeout: Option, + /// Request proxy + pub proxy: Option, + /// Disable system proxy + pub no_proxy: bool, + /// Request headers + pub headers: HeaderMap, + /// Extract path + #[allow(unused)] + extract_path: PathBuf, + /// App name, used for creating named tempfiles on Windows + #[allow(unused)] + app_name: String, + #[allow(unused)] + installer_args: Vec, + #[allow(unused)] + current_exe_args: Vec, + configure_client: Option, + max_download_size: u64, +} + +impl Resource for Update {} + +impl Update { + /// Downloads the updater package, verifies it then return it as bytes. + /// + /// Use [`Update::install`] to install it + pub async fn download), D: FnOnce()>( + &self, + mut on_chunk: C, + on_download_finish: D, + ) -> Result> { + // set our headers + let mut headers = self.headers.clone(); + if !headers.contains_key(ACCEPT) { + headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream")); + } + + let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT); + if self.config.dangerous_accept_invalid_certs { + request = request.danger_accept_invalid_certs(true); + } + if self.config.dangerous_accept_invalid_hostnames { + request = request.danger_accept_invalid_hostnames(true); + } + if let Some(timeout) = self.timeout { + request = request.timeout(timeout); + } + if self.no_proxy { + request = request.no_proxy(); + } else if let Some(ref proxy) = self.proxy { + let proxy = reqwest::Proxy::all(proxy.as_str())?; + request = request.proxy(proxy); + } + if let Some(ref configure_client) = self.configure_client { + request = configure_client(request); + } + let response = request + .build()? + .get(self.download_url.clone()) + .headers(headers) + .send() + .await?; + + if !response.status().is_success() { + return Err(Error::Network(format!( + "Download request failed with status: {}", + response.status() + ))); + } + + let buffer = read_response_limited( + response, + self.max_download_size, + "updater artifact", + &mut on_chunk, + ) + .await?; + on_download_finish(); + + verify_signature(&buffer, &self.signature, &self.config.pubkey)?; + + Ok(buffer) + } + + /// Installs the updater package downloaded by [`Update::download`] + pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> { + self.install_inner(bytes.as_ref()) + } + + /// Downloads and installs the updater package + pub async fn download_and_install), D: FnOnce()>( + &self, + on_chunk: C, + on_download_finish: D, + ) -> Result<()> { + let bytes = self.download(on_chunk, on_download_finish).await?; + self.install(bytes) + } + + #[cfg(mobile)] + fn install_inner(&self, _bytes: &[u8]) -> Result<()> { + Ok(()) + } +} + +async fn read_response_limited( + response: reqwest::Response, + max_bytes: u64, + resource: &'static str, + mut on_chunk: C, +) -> Result> +where + C: FnMut(usize, Option), +{ + let content_length = response + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + + if let Some(announced) = content_length.filter(|length| *length > max_bytes) { + return Err(Error::ResponseTooLarge { + resource, + max_bytes, + observed_bytes: announced, + }); + } + + let mut buffer = Vec::new(); + let mut observed = 0_u64; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + observed = observed + .checked_add(chunk.len() as u64) + .ok_or(Error::ResponseTooLarge { + resource, + max_bytes, + observed_bytes: u64::MAX, + })?; + if observed > max_bytes { + return Err(Error::ResponseTooLarge { + resource, + max_bytes, + observed_bytes: observed, + }); + } + on_chunk(chunk.len(), content_length); + buffer.extend_from_slice(&chunk); + } + Ok(buffer) +} + +#[cfg(windows)] +enum WindowsUpdaterType { + Nsis { + path: PathBuf, + #[allow(unused)] + temp: Option, + }, + Msi { + path: PathBuf, + #[allow(unused)] + temp: Option, + }, +} + +#[cfg(windows)] +impl WindowsUpdaterType { + fn nsis(path: PathBuf, temp: Option) -> Self { + Self::Nsis { path, temp } + } + + fn msi(path: PathBuf, temp: Option) -> Self { + Self::Msi { + path: path.wrap_in_quotes(), + temp, + } + } +} + +#[cfg(windows)] +impl Config { + fn install_mode(&self) -> crate::config::WindowsUpdateInstallMode { + self.windows + .as_ref() + .map(|w| w.install_mode.clone()) + .unwrap_or_default() + } +} + +/// Windows +#[cfg(windows)] +impl Update { + /// ### Expected structure: + /// ├── [AppName]_[version]_x64.msi # Application MSI + /// ├── [AppName]_[version]_x64-setup.exe # NSIS installer + /// ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler + /// │ └──[AppName]_[version]_x64.msi # Application MSI + /// ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler + /// │ └──[AppName]_[version]_x64-setup.exe # NSIS installer + /// └── ... + fn install_inner(&self, bytes: &[u8]) -> Result<()> { + use std::iter::once; + use windows_sys::{ + w, + Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW}, + }; + + let updater_type = self.extract(bytes)?; + + let install_mode = self.config.install_mode(); + let current_args = &self.current_exe_args()[1..]; + let msi_args; + let nsis_args; + + let installer_args: Vec<&OsStr> = match &updater_type { + WindowsUpdaterType::Nsis { .. } => { + nsis_args = current_args + .iter() + .map(escape_nsis_current_exe_arg) + .collect::>(); + + install_mode + .nsis_args() + .iter() + .map(OsStr::new) + .chain(once(OsStr::new("/UPDATE"))) + .chain(once(OsStr::new("/ARGS"))) + .chain(nsis_args.iter().map(OsStr::new)) + .chain(self.installer_args()) + .collect() + } + WindowsUpdaterType::Msi { path, .. } => { + let escaped_args = current_args + .iter() + .map(escape_msi_property_arg) + .collect::>() + .join(" "); + msi_args = OsString::from(format!("LAUNCHAPPARGS=\"{escaped_args}\"")); + + [OsStr::new("/i"), path.as_os_str()] + .into_iter() + .chain(install_mode.msiexec_args().iter().map(OsStr::new)) + .chain(once(OsStr::new("/promptrestart"))) + .chain(self.installer_args()) + .chain(once(OsStr::new("AUTOLAUNCHAPP=True"))) + .chain(once(msi_args.as_os_str())) + .collect() + } + }; + + if let Some(on_before_exit) = self.on_before_exit.as_ref() { + log::debug!("running on_before_exit hook"); + on_before_exit(); + } + + let file = match &updater_type { + WindowsUpdaterType::Nsis { path, .. } => path.as_os_str().to_os_string(), + WindowsUpdaterType::Msi { .. } => std::env::var("SYSTEMROOT").as_ref().map_or_else( + |_| OsString::from("msiexec.exe"), + |p| OsString::from(format!("{p}\\System32\\msiexec.exe")), + ), + }; + let file = encode_wide(file); + + let parameters = installer_args.join(OsStr::new(" ")); + let parameters = encode_wide(parameters); + + unsafe { + ShellExecuteW( + std::ptr::null_mut(), + w!("open"), + file.as_ptr(), + parameters.as_ptr(), + std::ptr::null(), + SW_SHOW, + ) + }; + + std::process::exit(0); + } + + fn installer_args(&self) -> Vec<&OsStr> { + self.installer_args + .iter() + .map(OsStr::new) + .collect::>() + } + + fn current_exe_args(&self) -> Vec<&OsStr> { + self.current_exe_args + .iter() + .map(OsStr::new) + .collect::>() + } + + fn extract(&self, bytes: &[u8]) -> Result { + #[cfg(feature = "zip")] + if infer::archive::is_zip(bytes) { + return self.extract_zip(bytes); + } + + self.extract_exe(bytes) + } + + fn make_temp_dir(&self) -> Result { + Ok(tempfile::Builder::new() + .prefix(&format!("{}-{}-updater-", self.app_name, self.version)) + .tempdir()? + .keep()) + } + + #[cfg(feature = "zip")] + fn extract_zip(&self, bytes: &[u8]) -> Result { + let temp_dir = self.make_temp_dir()?; + + let archive = Cursor::new(bytes); + let mut extractor = zip::ZipArchive::new(archive)?; + extractor.extract(&temp_dir)?; + + let paths = std::fs::read_dir(&temp_dir)?; + for path in paths { + let path = path?.path(); + let ext = path.extension(); + if ext == Some(OsStr::new("exe")) { + return Ok(WindowsUpdaterType::nsis(path, None)); + } else if ext == Some(OsStr::new("msi")) { + return Ok(WindowsUpdaterType::msi(path, None)); + } + } + + Err(crate::Error::BinaryNotFoundInArchive) + } + + fn extract_exe(&self, bytes: &[u8]) -> Result { + if infer::app::is_exe(bytes) { + let (path, temp) = self.write_to_temp(bytes, ".exe")?; + Ok(WindowsUpdaterType::nsis(path, temp)) + } else if infer::archive::is_msi(bytes) { + let (path, temp) = self.write_to_temp(bytes, ".msi")?; + Ok(WindowsUpdaterType::msi(path, temp)) + } else { + Err(crate::Error::InvalidUpdaterFormat) + } + } + + fn write_to_temp( + &self, + bytes: &[u8], + ext: &str, + ) -> Result<(PathBuf, Option)> { + use std::io::Write; + + let temp_dir = self.make_temp_dir()?; + let mut temp_file = tempfile::Builder::new() + .prefix(&format!("{}-{}-installer", self.app_name, self.version)) + .suffix(ext) + .rand_bytes(0) + .tempfile_in(temp_dir)?; + temp_file.write_all(bytes)?; + + let temp = temp_file.into_temp_path(); + Ok((temp.to_path_buf(), Some(temp))) + } +} + +/// Linux (AppImage, Deb, RPM) +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +impl Update { + /// ### Expected structure: + /// ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler + /// │ └──[AppName]_[version]_amd64.AppImage # Application AppImage + /// ├── [AppName]_[version]_amd64.deb # Debian package + /// ├── [AppName]_[version]_amd64.rpm # RPM package + /// └── ... + /// + fn install_inner(&self, bytes: &[u8]) -> Result<()> { + match installer_for_bundle_type(bundle_type()) { + Some(Installer::Deb) => self.install_deb(bytes), + Some(Installer::Rpm) => self.install_rpm(bytes), + _ => self.install_appimage(bytes), + } + } + + fn install_appimage(&self, bytes: &[u8]) -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let extract_path_metadata = self.extract_path.metadata()?; + + let tmp_dir_locations = vec![ + Box::new(|| Some(std::env::temp_dir())) as Box Option>, + Box::new(dirs::cache_dir), + Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())), + ]; + + for tmp_dir_location in tmp_dir_locations { + if let Some(tmp_dir_location) = tmp_dir_location() { + let tmp_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir_in(tmp_dir_location)?; + let tmp_dir_metadata = tmp_dir.path().metadata()?; + + if extract_path_metadata.dev() == tmp_dir_metadata.dev() { + let mut perms = tmp_dir_metadata.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(tmp_dir.path(), perms)?; + + let tmp_app_image = &tmp_dir.path().join("current_app.AppImage"); + + let permissions = std::fs::metadata(&self.extract_path)?.permissions(); + + // create a backup of our current app image + std::fs::rename(&self.extract_path, tmp_app_image)?; + + #[cfg(feature = "zip")] + if infer::archive::is_gz(bytes) { + log::debug!("extracting AppImage"); + // extract the buffer to the tmp_dir + // we extract our signed archive into our final directory without any temp file + let archive = Cursor::new(bytes); + let decoder = flate2::read::GzDecoder::new(archive); + let mut archive = tar::Archive::new(decoder); + for mut entry in archive.entries()?.flatten() { + if let Ok(path) = entry.path() { + if path.extension() == Some(OsStr::new("AppImage")) { + // if something went wrong during the extraction, we should restore previous app + if let Err(err) = entry.unpack(&self.extract_path) { + std::fs::rename(tmp_app_image, &self.extract_path)?; + return Err(err.into()); + } + // early finish we have everything we need here + return Ok(()); + } + } + } + // if we have not returned early we should restore the backup + std::fs::rename(tmp_app_image, &self.extract_path)?; + return Err(Error::BinaryNotFoundInArchive); + } + + log::debug!("rewriting AppImage"); + return match std::fs::write(&self.extract_path, bytes) + .and_then(|_| std::fs::set_permissions(&self.extract_path, permissions)) + { + Err(err) => { + // if something went wrong during the extraction, we should restore previous app + std::fs::rename(tmp_app_image, &self.extract_path)?; + Err(err.into()) + } + Ok(_) => Ok(()), + }; + } + } + } + + Err(Error::TempDirNotOnSameMountPoint) + } + + fn install_deb(&self, bytes: &[u8]) -> Result<()> { + // First verify the bytes are actually a .deb package + if !infer::archive::is_deb(bytes) { + log::warn!("update is not a valid deb package"); + return Err(Error::InvalidUpdaterFormat); + } + + self.try_tmp_locations(bytes, "dpkg", "-i", "deb") + } + + fn install_rpm(&self, bytes: &[u8]) -> Result<()> { + // First verify the bytes are actually a .rpm package + if !infer::archive::is_rpm(bytes) { + return Err(Error::InvalidUpdaterFormat); + } + self.try_tmp_locations(bytes, "rpm", "-U", "rpm") + } + + fn try_tmp_locations( + &self, + bytes: &[u8], + install_cmd: &str, + install_arg: &str, + package_extension: &str, + ) -> Result<()> { + // Try different temp directories + let tmp_dir_locations = vec![ + Box::new(|| Some(std::env::temp_dir())) as Box Option>, + Box::new(dirs::cache_dir), + Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())), + ]; + + // Try writing to multiple temp locations until one succeeds + for tmp_dir_location in tmp_dir_locations { + if let Some(path) = tmp_dir_location() { + let prefix = format!("tauri_{package_extension}_update"); + if let Ok(tmp_dir) = tempfile::Builder::new().prefix(&prefix).tempdir_in(path) { + let pkg_path = tmp_dir.path().join(format!("package.{package_extension}")); + + // Try writing the .deb / .rpm file + if std::fs::write(&pkg_path, bytes).is_ok() { + // If write succeeds, proceed with installation + return self.try_install_with_privileges( + &pkg_path, + install_cmd, + install_arg, + ); + } + // If write fails, continue to next temp location + } + } + } + + // If we get here, all temp locations failed + Err(Error::TempDirNotFound) + } + + fn try_install_with_privileges( + &self, + pkg_path: &Path, + install_cmd: &str, + install_arg: &str, + ) -> Result<()> { + // 1. First try using pkexec (graphical sudo prompt) + if let Ok(status) = std::process::Command::new("pkexec") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .status() + { + if status.success() { + log::debug!("installed {pkg_path:?} with pkexec"); + return Ok(()); + } + } + + // 2. Try zenity or kdialog for a graphical sudo experience + if let Ok(password) = self.get_password_graphically() { + if self.install_with_sudo(pkg_path, &password, install_cmd, install_arg)? { + log::debug!("installed {pkg_path:?} with GUI sudo"); + return Ok(()); + } + } + + // 3. Final fallback: terminal sudo + let status = std::process::Command::new("sudo") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .status()?; + + if status.success() { + log::debug!("installed {pkg_path:?} with sudo"); + Ok(()) + } else { + Err(Error::PackageInstallFailed) + } + } + + fn get_password_graphically(&self) -> Result { + // Try zenity first + let zenity_result = std::process::Command::new("zenity") + .args([ + "--password", + "--title=Authentication Required", + "--text=Enter your password to install the update:", + ]) + .output(); + + if let Ok(output) = zenity_result { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + } + + // Fall back to kdialog if zenity fails or isn't available + let kdialog_result = std::process::Command::new("kdialog") + .args(["--password", "Enter your password to install the update:"]) + .output(); + + if let Ok(output) = kdialog_result { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + } + + Err(Error::AuthenticationFailed) + } + + fn install_with_sudo( + &self, + pkg_path: &Path, + password: &str, + install_cmd: &str, + install_arg: &str, + ) -> Result { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut child = Command::new("sudo") + .arg("-S") // read password from stdin + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + // Write password to stdin + writeln!(stdin, "{password}")?; + } + + let status = child.wait()?; + Ok(status.success()) + } +} + +/// MacOS +#[cfg(target_os = "macos")] +impl Update { + /// ### Expected structure: + /// ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler + /// │ └──[AppName].app # Main application + /// │ └── Contents # Application contents... + /// │ └── ... + /// └── ... + fn install_inner(&self, bytes: &[u8]) -> Result<()> { + use flate2::read::GzDecoder; + + let cursor = Cursor::new(bytes); + let mut extracted_files: Vec = Vec::new(); + + // Create temp directories for backup and extraction + let tmp_backup_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir()?; + + let tmp_extract_dir = tempfile::Builder::new() + .prefix("tauri_updated_app") + .tempdir()?; + + let decoder = GzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + + // Extract files to temporary directory + for entry in archive.entries()? { + let mut entry = entry?; + let collected_path: PathBuf = entry.path()?.iter().skip(1).collect(); + let extraction_path = tmp_extract_dir.path().join(&collected_path); + + // Ensure parent directories exist + if let Some(parent) = extraction_path.parent() { + std::fs::create_dir_all(parent)?; + } + + if let Err(err) = entry.unpack(&extraction_path) { + // Cleanup on error + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(err.into()); + } + extracted_files.push(extraction_path); + } + + // Try to move the current app to backup + let move_result = std::fs::rename( + &self.extract_path, + tmp_backup_dir.path().join("current_app"), + ); + let need_authorization = if let Err(err) = move_result { + if err.kind() == std::io::ErrorKind::PermissionDenied { + true + } else { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(err.into()); + } + } else { + false + }; + + if need_authorization { + log::debug!("app installation needs admin privileges"); + // Use AppleScript to perform moves with admin privileges + let apple_script = format!( + "do shell script \"rm -rf '{src}' && mv -f '{new}' '{src}'\" with administrator privileges", + src = self.extract_path.display(), + new = tmp_extract_dir.path().display() + ); + + let (tx, rx) = std::sync::mpsc::channel(); + let res = (self.run_on_main_thread)(Box::new(move || { + let mut script = + osakit::Script::new_from_source(osakit::Language::AppleScript, &apple_script); + script.compile().expect("invalid AppleScript"); + let r = script.execute(); + tx.send(r).unwrap(); + })); + let result = rx.recv().unwrap(); + + if res.is_err() || result.is_err() { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Failed to move the new app into place", + ))); + } + } else { + // Remove existing directory if it exists + if self.extract_path.exists() { + std::fs::remove_dir_all(&self.extract_path)?; + } + // Move the new app to the target path + std::fs::rename(tmp_extract_dir.path(), &self.extract_path)?; + } + + let _ = std::process::Command::new("touch") + .arg(&self.extract_path) + .status(); + + Ok(()) + } +} + +/// Gets the base target string used by the updater. If bundle type is available it +/// will be added to this string when selecting the download URL and signature. +/// `tauri::utils::platform::bundle_type` method is used to obtain current bundle type. +pub fn target() -> Option { + if let (Some(target), Some(arch)) = (updater_os(), updater_arch()) { + Some(format!("{target}-{arch}")) + } else { + None + } +} + +fn updater_os() -> Option<&'static str> { + if cfg!(target_os = "linux") { + Some("linux") + } else if cfg!(target_os = "macos") { + // TODO shouldn't this be macos instead? + Some("darwin") + } else if cfg!(target_os = "windows") { + Some("windows") + } else { + None + } +} + +fn updater_arch() -> Option<&'static str> { + if cfg!(target_arch = "x86") { + Some("i686") + } else if cfg!(target_arch = "x86_64") { + Some("x86_64") + } else if cfg!(target_arch = "arm") { + Some("armv7") + } else if cfg!(target_arch = "aarch64") { + Some("aarch64") + } else if cfg!(target_arch = "riscv64") { + Some("riscv64") + } else { + None + } +} + +pub fn extract_path_from_executable(executable_path: &Path) -> Result { + // Return the path of the current executable by default + // Example C:\Program Files\My App\ + let extract_path = executable_path + .parent() + .map(PathBuf::from) + .ok_or(Error::FailedToDetermineExtractPath)?; + + // MacOS example binary is in /Applications/TestApp.app/Contents/MacOS/myApp + // We need to get /Applications/.app + // TODO(lemarier): Need a better way here + // Maybe we could search for <*.app> to get the right path + #[cfg(target_os = "macos")] + if extract_path + .display() + .to_string() + .contains("Contents/MacOS") + { + return extract_path + .parent() + .map(PathBuf::from) + .ok_or(Error::FailedToDetermineExtractPath)? + .parent() + .map(PathBuf::from) + .ok_or(Error::FailedToDetermineExtractPath); + } + + Ok(extract_path) +} + +impl<'de> Deserialize<'de> for RemoteRelease { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct InnerRemoteRelease { + #[serde(alias = "name", deserialize_with = "parse_version")] + version: Version, + notes: Option, + pub_date: Option, + platforms: Option>, + // dynamic platform response + url: Option, + signature: Option, + } + + let release = InnerRemoteRelease::deserialize(deserializer)?; + + let pub_date = if let Some(date) = release.pub_date { + Some( + OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339) + .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?, + ) + } else { + None + }; + + Ok(RemoteRelease { + version: release.version, + notes: release.notes, + pub_date, + data: if let Some(platforms) = release.platforms { + RemoteReleaseInner::Static { platforms } + } else { + RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { + url: release.url.ok_or_else(|| { + DeError::custom("the `url` field was not set on the updater response") + })?, + signature: release.signature.ok_or_else(|| { + DeError::custom("the `signature` field was not set on the updater response") + })?, + }) + }, + }) + } +} + +fn installer_for_bundle_type(bundle: Option) -> Option { + match bundle? { + BundleType::Deb => Some(Installer::Deb), + BundleType::Rpm => Some(Installer::Rpm), + BundleType::AppImage => Some(Installer::AppImage), + BundleType::Msi => Some(Installer::Msi), + BundleType::Nsis => Some(Installer::Nsis), + BundleType::App => Some(Installer::App), // App is also returned for Dmg type + _ => None, + } +} + +fn parse_version<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let str = String::deserialize(deserializer)?; + + Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom) +} + +// Validate signature +/// Verifies bytes with the same minisign format used for updater artifacts. +/// +/// This is public so applications can authenticate auxiliary release identity +/// files with the updater key before trusting an unsigned mirror manifest. +pub fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Result { + // we need to convert the pub key + let pub_key_decoded = base64_to_string(pub_key)?; + let public_key = PublicKey::decode(&pub_key_decoded)?; + let signature_base64_decoded = base64_to_string(release_signature)?; + let signature = Signature::decode(&signature_base64_decoded)?; + + // Validate signature or bail out + public_key.verify(data, &signature, true)?; + Ok(true) +} + +fn base64_to_string(base64_string: &str) -> Result { + let decoded_string = &base64::engine::general_purpose::STANDARD.decode(base64_string)?; + let result = std::str::from_utf8(decoded_string) + .map_err(|_| Error::SignatureUtf8(base64_string.into()))? + .to_string(); + Ok(result) +} + +#[cfg(windows)] +fn encode_wide(string: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + + string + .as_ref() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +#[cfg(windows)] +trait PathExt { + fn wrap_in_quotes(&self) -> Self; +} + +#[cfg(windows)] +impl PathExt for PathBuf { + fn wrap_in_quotes(&self) -> Self { + let mut msi_path = OsString::from("\""); + msi_path.push(self.as_os_str()); + msi_path.push("\""); + PathBuf::from(msi_path) + } +} + +// adapted from https://github.com/rust-lang/rust/blob/1c047506f94cd2d05228eb992b0a6bbed1942349/library/std/src/sys/args/windows.rs#L174 +#[cfg(windows)] +fn escape_nsis_current_exe_arg(arg: &&OsStr) -> String { + let arg = arg.to_string_lossy(); + let mut cmd: Vec = Vec::new(); + + // compared to std we additionally escape `/` so that nsis won't interpret them as a beginning of an nsis argument. + let quote = arg.chars().any(|c| c == ' ' || c == '\t' || c == '/') || arg.is_empty(); + let escape = true; + if quote { + cmd.push('"'); + } + let mut backslashes: usize = 0; + for x in arg.chars() { + if escape { + if x == '\\' { + backslashes += 1; + } else { + if x == '"' { + // Add n+1 backslashes to total 2n+1 before internal '"'. + cmd.extend((0..=backslashes).map(|_| '\\')); + } + backslashes = 0; + } + } + cmd.push(x); + } + if quote { + // Add n backslashes to total 2n before ending '"'. + cmd.extend((0..backslashes).map(|_| '\\')); + cmd.push('"'); + } + cmd.into_iter().collect() +} + +#[cfg(windows)] +fn escape_msi_property_arg(arg: impl AsRef) -> String { + let mut arg = arg.as_ref().to_string_lossy().to_string(); + + // Otherwise this argument will get lost in ShellExecute + if arg.is_empty() { + return "\"\"\"\"".to_string(); + } else if !arg.contains(' ') && !arg.contains('"') { + return arg; + } + + if arg.contains('"') { + arg = arg.replace('"', r#""""""#); + } + + if arg.starts_with('-') { + if let Some((a1, a2)) = arg.split_once('=') { + format!("{a1}=\"\"{a2}\"\"") + } else { + format!("\"\"{arg}\"\"") + } + } else { + format!("\"\"{arg}\"\"") + } +} + +#[cfg(test)] +mod tests { + + #[test] + #[cfg(windows)] + fn it_wraps_correctly() { + use super::PathExt; + use std::path::PathBuf; + + assert_eq!( + PathBuf::from("C:\\Users\\Some User\\AppData\\tauri-example.exe").wrap_in_quotes(), + PathBuf::from("\"C:\\Users\\Some User\\AppData\\tauri-example.exe\"") + ) + } + + #[test] + #[cfg(windows)] + fn it_escapes_correctly_for_msi() { + use crate::updater::escape_msi_property_arg; + + // Explanation for quotes: + // The output of escape_msi_property_args() will be used in `LAUNCHAPPARGS=\"{HERE}\"`. This is the first quote level. + // To escape a quotation mark we use a second quotation mark, so "" is interpreted as " later. + // This means that the escaped strings can't ever have a single quotation mark! + // Now there are 3 major things to look out for to not break the msiexec call: + // 1) Wrap spaces in quotation marks, otherwise it will be interpreted as the end of the msiexec argument. + // 2) Escape escaping quotation marks, otherwise they will either end the msiexec argument or be ignored. + // 3) Escape emtpy args in quotation marks, otherwise the argument will get lost. + let cases = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "some space", // This simulates `./my-app "some string"`. + "--arg value", // -> This simulates `./my-app "--arg value"`. Same as above but it triggers the startsWith(`-`) logic. + "--arg=unwrapped space", // `./my-app --arg="unwrapped space"` + "--arg=\"wrapped\"", // `./my-app --args=""wrapped""` + "--arg=\"wrapped space\"", // `./my-app --args=""wrapped space""` + "--arg=midword\"wrapped space\"", // `./my-app --args=midword""wrapped""` + "", // `./my-app '""'` + ]; + let cases_escaped = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "\"\"some space\"\"", + "\"\"--arg value\"\"", + "--arg=\"\"unwrapped space\"\"", + r#"--arg=""""""wrapped"""""""#, + r#"--arg=""""""wrapped space"""""""#, + r#"--arg=""midword""""wrapped space"""""""#, + "\"\"\"\"", + ]; + + // Just to be sure we didn't mess that up + assert_eq!(cases.len(), cases_escaped.len()); + + for (orig, escaped) in cases.iter().zip(cases_escaped) { + assert_eq!(escape_msi_property_arg(orig), escaped); + } + } + + #[test] + #[cfg(windows)] + fn it_escapes_correctly_for_nsis() { + use crate::updater::escape_nsis_current_exe_arg; + use std::ffi::OsStr; + + let cases = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "some space", // This simulates `./my-app "some string"`. + "--arg value", // -> This simulates `./my-app "--arg value"`. Same as above but it triggers the startsWith(`-`) logic. + "--arg=unwrapped space", // `./my-app --arg="unwrapped space"` + "--arg=\"wrapped\"", // `./my-app --args=""wrapped""` + "--arg=\"wrapped space\"", // `./my-app --args=""wrapped space""` + "--arg=midword\"wrapped space\"", // `./my-app --args=midword""wrapped""` + "", // `./my-app '""'` + ]; + // Note: These may not be the results we actually want (monitor this!). + // We only make sure the implementation doesn't unintentionally change. + let cases_escaped = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "\"some space\"", + "\"--arg value\"", + "\"--arg=unwrapped space\"", + "--arg=\\\"wrapped\\\"", + "\"--arg=\\\"wrapped space\\\"\"", + "\"--arg=midword\\\"wrapped space\\\"\"", + "\"\"", + ]; + + // Just to be sure we didn't mess that up + assert_eq!(cases.len(), cases_escaped.len()); + + for (orig, escaped) in cases.iter().zip(cases_escaped) { + assert_eq!(escape_nsis_current_exe_arg(&OsStr::new(orig)), escaped); + } + } +} diff --git a/vendor/tauri-plugin-updater-2.10.1/tsconfig.json b/vendor/tauri-plugin-updater-2.10.1/tsconfig.json new file mode 100644 index 0000000..5098169 --- /dev/null +++ b/vendor/tauri-plugin-updater-2.10.1/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["guest-js/*.ts"] +} diff --git a/website/index.html b/website/index.html index affd554..9333e72 100644 --- a/website/index.html +++ b/website/index.html @@ -520,6 +520,15 @@

下载

+

+ Windows Authenticode 正在申请 SignPath Foundation;在 trusted-build 与真实工件验证完成前,新版 Windows 发布保持 fail-closed。 + 以下是获批启用后要求展示的归因,不代表当前下载已经签名: + Free code signing provided by SignPath.io, certificate by SignPath Foundation. + 代码签名政策 + + 隐私政策 +

+